@open-mercato/core 0.6.2-develop.3467.1.2a1818709d → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/queue-runner.js +15 -3
  3. package/dist/helpers/integration/queue-runner.js.map +2 -2
  4. package/dist/helpers/integration/queue.js +1 -1
  5. package/dist/helpers/integration/queue.js.map +2 -2
  6. package/dist/helpers/integration/salesUi.js +24 -1
  7. package/dist/helpers/integration/salesUi.js.map +2 -2
  8. package/dist/modules/auth/api/roles/acl/route.js +37 -1
  9. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  10. package/dist/modules/auth/api/roles/route.js +36 -2
  11. package/dist/modules/auth/api/roles/route.js.map +2 -2
  12. package/dist/modules/auth/api/users/acl/route.js +36 -1
  13. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  14. package/dist/modules/auth/api/users/route.js +101 -13
  15. package/dist/modules/auth/api/users/route.js.map +2 -2
  16. package/dist/modules/auth/backend/roles/[id]/edit/page.js +32 -14
  17. package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
  18. package/dist/modules/auth/backend/users/[id]/edit/page.js +1 -1
  19. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  20. package/dist/modules/auth/backend/users/create/page.js +1 -1
  21. package/dist/modules/auth/backend/users/create/page.js.map +2 -2
  22. package/dist/modules/auth/backend/users/roleOptions.js +1 -1
  23. package/dist/modules/auth/backend/users/roleOptions.js.map +2 -2
  24. package/dist/modules/auth/commands/roles.js +8 -0
  25. package/dist/modules/auth/commands/roles.js.map +2 -2
  26. package/dist/modules/auth/commands/users.js +3 -0
  27. package/dist/modules/auth/commands/users.js.map +2 -2
  28. package/dist/modules/auth/components/AclEditor.js +53 -26
  29. package/dist/modules/auth/components/AclEditor.js.map +2 -2
  30. package/dist/modules/auth/lib/grantChecks.js +97 -2
  31. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  32. package/dist/modules/business_rules/lib/expression-evaluator.js +9 -7
  33. package/dist/modules/business_rules/lib/expression-evaluator.js.map +2 -2
  34. package/dist/modules/customers/ai-agents.js +10 -1
  35. package/dist/modules/customers/ai-agents.js.map +2 -2
  36. package/dist/modules/customers/ai-tools/settings-pack.js +55 -1
  37. package/dist/modules/customers/ai-tools/settings-pack.js.map +2 -2
  38. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +13 -5
  39. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  40. package/dist/modules/customers/lib/personCompanyLinkTable.js +4 -30
  41. package/dist/modules/customers/lib/personCompanyLinkTable.js.map +2 -2
  42. package/dist/modules/customers/migrations/Migration20260521175027.js +12 -0
  43. package/dist/modules/customers/migrations/Migration20260521175027.js.map +7 -0
  44. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +41 -12
  45. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +2 -2
  46. package/dist/modules/data_sync/api/options.js +2 -0
  47. package/dist/modules/data_sync/api/options.js.map +2 -2
  48. package/dist/modules/data_sync/api/run.js +77 -58
  49. package/dist/modules/data_sync/api/run.js.map +2 -2
  50. package/dist/modules/data_sync/backend/data-sync/page.js +3 -2
  51. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  52. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +8 -3
  53. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  54. package/dist/modules/integrations/backend/integrations/[id]/page.js +15 -13
  55. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  56. package/dist/modules/integrations/data/validators.js +9 -6
  57. package/dist/modules/integrations/data/validators.js.map +2 -2
  58. package/dist/modules/messages/api/route.js +2 -2
  59. package/dist/modules/messages/api/route.js.map +2 -2
  60. package/dist/modules/messages/commands/messages.js +42 -2
  61. package/dist/modules/messages/commands/messages.js.map +2 -2
  62. package/dist/modules/messages/components/MessagesInboxPageClient.js +66 -87
  63. package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
  64. package/dist/modules/messages/components/inboxFilters.js +133 -0
  65. package/dist/modules/messages/components/inboxFilters.js.map +7 -0
  66. package/dist/modules/messages/data/validators.js +8 -6
  67. package/dist/modules/messages/data/validators.js.map +2 -2
  68. package/dist/modules/progress/api/jobs/route.js +26 -16
  69. package/dist/modules/progress/api/jobs/route.js.map +2 -2
  70. package/dist/modules/query_index/di.js +8 -6
  71. package/dist/modules/query_index/di.js.map +2 -2
  72. package/dist/modules/query_index/lib/indexer.js +12 -10
  73. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  74. package/dist/modules/query_index/subscribers/upsert_one.js +7 -1
  75. package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
  76. package/dist/modules/sales/api/order-adjustments/route.js.map +2 -2
  77. package/dist/modules/sales/api/quote-adjustments/route.js.map +2 -2
  78. package/dist/modules/sales/commands/documents.js +104 -0
  79. package/dist/modules/sales/commands/documents.js.map +2 -2
  80. package/dist/modules/sales/data/validators.js +20 -1
  81. package/dist/modules/sales/data/validators.js.map +2 -2
  82. package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
  83. package/dist/modules/sync_excel/api/import/route.js +126 -116
  84. package/dist/modules/sync_excel/api/import/route.js.map +2 -2
  85. package/dist/modules/sync_excel/lib/adapters/customers.js +1 -0
  86. package/dist/modules/sync_excel/lib/adapters/customers.js.map +2 -2
  87. package/dist/modules/translations/components/TranslationManager.js +9 -4
  88. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  89. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  90. package/dist/modules/workflows/lib/event-trigger-service.js +23 -11
  91. package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
  92. package/jest.config.cjs +4 -1
  93. package/package.json +15 -16
  94. package/src/helpers/integration/queue-runner.ts +21 -2
  95. package/src/helpers/integration/queue.ts +1 -1
  96. package/src/helpers/integration/salesUi.ts +26 -1
  97. package/src/modules/auth/api/roles/acl/route.ts +39 -1
  98. package/src/modules/auth/api/roles/route.ts +38 -2
  99. package/src/modules/auth/api/users/acl/route.ts +41 -1
  100. package/src/modules/auth/api/users/route.ts +105 -12
  101. package/src/modules/auth/backend/roles/[id]/edit/page.tsx +35 -13
  102. package/src/modules/auth/backend/users/[id]/edit/page.tsx +1 -1
  103. package/src/modules/auth/backend/users/create/page.tsx +1 -1
  104. package/src/modules/auth/backend/users/roleOptions.ts +2 -1
  105. package/src/modules/auth/commands/roles.ts +8 -0
  106. package/src/modules/auth/commands/users.ts +3 -0
  107. package/src/modules/auth/components/AclEditor.tsx +50 -24
  108. package/src/modules/auth/lib/grantChecks.ts +116 -2
  109. package/src/modules/business_rules/lib/expression-evaluator.ts +9 -13
  110. package/src/modules/customers/ai-agents.ts +10 -1
  111. package/src/modules/customers/ai-tools/settings-pack.ts +62 -1
  112. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +18 -7
  113. package/src/modules/customers/lib/personCompanyLinkTable.ts +33 -41
  114. package/src/modules/customers/migrations/Migration20260521175027.ts +39 -0
  115. package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +38 -12
  116. package/src/modules/data_sync/api/options.ts +2 -0
  117. package/src/modules/data_sync/api/run.ts +80 -61
  118. package/src/modules/data_sync/backend/data-sync/page.tsx +10 -0
  119. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +17 -3
  120. package/src/modules/data_sync/i18n/de.json +3 -0
  121. package/src/modules/data_sync/i18n/en.json +3 -0
  122. package/src/modules/data_sync/i18n/es.json +3 -0
  123. package/src/modules/data_sync/i18n/pl.json +3 -0
  124. package/src/modules/data_sync/lib/adapter.ts +9 -0
  125. package/src/modules/integrations/backend/integrations/[id]/page.tsx +15 -13
  126. package/src/modules/integrations/data/validators.ts +5 -4
  127. package/src/modules/messages/api/route.ts +2 -2
  128. package/src/modules/messages/commands/messages.ts +54 -2
  129. package/src/modules/messages/components/MessagesInboxPageClient.tsx +75 -90
  130. package/src/modules/messages/components/inboxFilters.ts +162 -0
  131. package/src/modules/messages/data/validators.ts +7 -5
  132. package/src/modules/messages/i18n/de.json +5 -4
  133. package/src/modules/messages/i18n/en.json +5 -4
  134. package/src/modules/messages/i18n/es.json +5 -4
  135. package/src/modules/messages/i18n/pl.json +5 -4
  136. package/src/modules/progress/api/jobs/route.ts +30 -20
  137. package/src/modules/query_index/di.ts +9 -7
  138. package/src/modules/query_index/lib/indexer.ts +13 -11
  139. package/src/modules/query_index/subscribers/upsert_one.ts +9 -1
  140. package/src/modules/sales/api/order-adjustments/route.ts +2 -1
  141. package/src/modules/sales/api/quote-adjustments/route.ts +2 -1
  142. package/src/modules/sales/commands/documents.ts +116 -0
  143. package/src/modules/sales/data/validators.ts +38 -0
  144. package/src/modules/sales/i18n/de.json +2 -0
  145. package/src/modules/sales/i18n/en.json +2 -0
  146. package/src/modules/sales/i18n/es.json +2 -0
  147. package/src/modules/sales/i18n/pl.json +2 -0
  148. package/src/modules/sales/lib/makeSalesLineRoute.ts +2 -1
  149. package/src/modules/staff/i18n/de.json +1 -0
  150. package/src/modules/staff/i18n/en.json +1 -0
  151. package/src/modules/staff/i18n/es.json +1 -0
  152. package/src/modules/staff/i18n/pl.json +1 -0
  153. package/src/modules/sync_excel/api/import/route.ts +141 -131
  154. package/src/modules/sync_excel/lib/adapters/customers.ts +1 -0
  155. package/src/modules/translations/components/TranslationManager.tsx +9 -4
  156. package/src/modules/workflows/lib/activity-executor.ts +4 -3
  157. package/src/modules/workflows/lib/event-trigger-service.ts +24 -11
  158. package/tsconfig.json +1 -0
@@ -13,7 +13,11 @@ import { E } from '#generated/entities.ids.generated'
13
13
  import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
14
14
  import type { EntityManager } from '@mikro-orm/postgresql'
15
15
  import { userCrudEvents, userCrudIndexer } from '@open-mercato/core/modules/auth/commands/users'
16
- import { assertActorCanGrantRoleTokens } from '@open-mercato/core/modules/auth/lib/grantChecks'
16
+ import {
17
+ assertActorCanGrantRoleTokens,
18
+ assertActorCanModifySuperAdminUserTarget,
19
+ listSuperAdminUserIds,
20
+ } from '@open-mercato/core/modules/auth/lib/grantChecks'
17
21
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
18
22
  import { buildPasswordSchema } from '@open-mercato/shared/lib/auth/passwordPolicy'
19
23
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
@@ -21,6 +25,10 @@ import { resolveSearchConfig } from '@open-mercato/shared/lib/search/config'
21
25
  import { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'
22
26
  import { sql } from 'kysely'
23
27
  import { normalizeDisplayNameInput } from '@open-mercato/core/modules/auth/lib/displayName'
28
+ import {
29
+ getSelectedTenantFromRequest,
30
+ resolveOrganizationScopeForRequest,
31
+ } from '@open-mercato/core/modules/directory/utils/organizationScope'
24
32
 
25
33
  const querySchema = z.object({
26
34
  id: z.string().uuid().optional(),
@@ -86,6 +94,7 @@ const okResponseSchema = z.object({ ok: z.literal(true) })
86
94
  const errorResponseSchema = z.object({ error: z.string() })
87
95
 
88
96
  type CrudInput = Record<string, unknown>
97
+ type UserListFilter = Record<string, unknown>
89
98
 
90
99
  const routeMetadata = {
91
100
  GET: { requireAuth: true, requireFeatures: ['auth.users.list'] },
@@ -128,6 +137,9 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
128
137
  schema: rawBodySchema,
129
138
  mapInput: async ({ parsed, ctx }) => {
130
139
  if (ctx.request) {
140
+ if (typeof parsed.id === 'string' && parsed.id.length) {
141
+ await assertCanModifySuperAdminTarget(ctx.request, parsed.id)
142
+ }
131
143
  await assertCanAssignRoles(ctx.request, parsed.roles, parsed)
132
144
  }
133
145
  return parsed
@@ -158,12 +170,12 @@ export async function GET(req: Request) {
158
170
  if (!parsed.success) return NextResponse.json({ items: [], total: 0, totalPages: 1 })
159
171
  const container = await createRequestContainer()
160
172
  const em = (container.resolve('em') as EntityManager)
161
- let isSuperAdmin = false
173
+ let isSuperAdmin = auth.isSuperAdmin === true
162
174
  try {
163
175
  if (auth.sub) {
164
176
  const rbacService = container.resolve('rbacService') as any
165
177
  const acl = await rbacService.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
166
- isSuperAdmin = !!acl?.isSuperAdmin
178
+ isSuperAdmin = isSuperAdmin || !!acl?.isSuperAdmin
167
179
  }
168
180
  } catch (err) {
169
181
  console.error('users: failed to resolve rbac', err)
@@ -171,16 +183,62 @@ export async function GET(req: Request) {
171
183
  const { id, page, pageSize, search, name, organizationId, roleIds } = parsed.data
172
184
  const filters: any[] = [{ deletedAt: null }]
173
185
  const actorTenantId = auth.tenantId ? String(auth.tenantId) : null
186
+ let effectiveTenantId: string | null = null
187
+ let effectiveOrganizationIds: string[] | null = null
188
+ let effectiveSelectedOrganizationId: string | null = null
189
+ let usesSelectedTenantScope = false
174
190
  if (!isSuperAdmin) {
175
191
  if (!actorTenantId) {
176
192
  return NextResponse.json({ items: [], total: 0, totalPages: 1, isSuperAdmin })
177
193
  }
178
- filters.push({ tenantId: actorTenantId })
194
+ effectiveTenantId = actorTenantId
195
+ const superAdminUserIds = await listSuperAdminUserIds(em, actorTenantId)
196
+ if (superAdminUserIds.size) {
197
+ filters.push({ id: { $nin: Array.from(superAdminUserIds) as any } })
198
+ }
199
+ } else {
200
+ const selectedTenantId = getSelectedTenantFromRequest(req)
201
+ if (typeof selectedTenantId === 'string' && selectedTenantId.trim().length > 0) {
202
+ const scope = await resolveOrganizationScopeForRequest({
203
+ container,
204
+ auth,
205
+ request: req,
206
+ tenantId: selectedTenantId.trim(),
207
+ })
208
+ if (!scope.tenantId) {
209
+ return NextResponse.json({ items: [], total: 0, totalPages: 1, isSuperAdmin })
210
+ }
211
+ effectiveTenantId = scope.tenantId
212
+ effectiveSelectedOrganizationId = scope.selectedId
213
+ usesSelectedTenantScope = true
214
+ if (Array.isArray(scope.filterIds)) {
215
+ if (scope.filterIds.length === 0) {
216
+ return NextResponse.json({ items: [], total: 0, totalPages: 1, isSuperAdmin })
217
+ }
218
+ effectiveOrganizationIds = scope.filterIds
219
+ }
220
+ }
221
+ }
222
+ if (effectiveTenantId) {
223
+ filters.push({ tenantId: effectiveTenantId })
224
+ }
225
+ if (effectiveOrganizationIds) {
226
+ filters.push({ organizationId: { $in: effectiveOrganizationIds as any } })
179
227
  }
228
+ const scopeOrganizationId = usesSelectedTenantScope
229
+ ? effectiveSelectedOrganizationId
230
+ : auth.orgId ?? null
180
231
  if (organizationId) filters.push({ organizationId })
181
232
  const trimmedName = typeof name === 'string' ? name.trim() : ''
182
233
  if (trimmedName) {
183
- filters.push({ name: { $ilike: `%${escapeLikePattern(trimmedName)}%` } })
234
+ const searchPattern = `%${escapeLikePattern(trimmedName)}%`
235
+ const displayNameFilters: UserListFilter[] = [{ name: { $ilike: searchPattern } }]
236
+ const nameTokenScope: string | null | undefined = isSuperAdmin ? (effectiveTenantId ?? undefined) : auth.tenantId ?? null
237
+ const matchedDisplayNameIds = await findUserIdsBySearchTokens(em, E.auth.user, trimmedName, nameTokenScope, 'name')
238
+ if (matchedDisplayNameIds && matchedDisplayNameIds.length) {
239
+ displayNameFilters.push({ id: { $in: matchedDisplayNameIds } })
240
+ }
241
+ filters.push(displayNameFilters.length > 1 ? { $or: displayNameFilters } : displayNameFilters[0])
184
242
  }
185
243
  let idFilter: Set<string> | null = id ? new Set([id]) : null
186
244
  if (Array.isArray(roleIds) && roleIds.length > 0) {
@@ -204,7 +262,7 @@ export async function GET(req: Request) {
204
262
  const trimmedSearch = typeof search === 'string' ? search.trim() : ''
205
263
  if (trimmedSearch) {
206
264
  // Email is encrypted at rest, so plaintext search must go through search_tokens.
207
- const tenantScope: string | null | undefined = isSuperAdmin ? undefined : auth.tenantId ?? null
265
+ const tenantScope: string | null | undefined = isSuperAdmin ? (effectiveTenantId ?? undefined) : auth.tenantId ?? null
208
266
  const searchFilters: any[] = []
209
267
 
210
268
  const matchedIds = await findUserIdsBySearchTokens(em, E.auth.user, trimmedSearch, tenantScope)
@@ -284,7 +342,10 @@ export async function GET(req: Request) {
284
342
  UserRole,
285
343
  { user: { $in: userIds as any } } as any,
286
344
  { populate: ['role'] },
287
- { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
345
+ {
346
+ tenantId: effectiveTenantId ?? auth.tenantId ?? null,
347
+ organizationId: scopeOrganizationId,
348
+ },
288
349
  )
289
350
  : []
290
351
  const roleMap: Record<string, string[]> = {}
@@ -350,7 +411,7 @@ export async function GET(req: Request) {
350
411
  recordIds: userIds.map(String),
351
412
  tenantIdByRecord: tenantByUser,
352
413
  organizationIdByRecord: organizationByUser,
353
- tenantFallbacks: auth.tenantId ? [auth.tenantId] : [],
414
+ tenantFallbacks: effectiveTenantId ? [effectiveTenantId] : auth.tenantId ? [auth.tenantId] : [],
354
415
  })
355
416
  : {}
356
417
 
@@ -379,8 +440,8 @@ export async function GET(req: Request) {
379
440
  items,
380
441
  idField: 'id',
381
442
  resourceKind: 'auth.user',
382
- organizationId: null,
383
- tenantId: auth.tenantId ?? null,
443
+ organizationId: effectiveSelectedOrganizationId,
444
+ tenantId: effectiveTenantId ?? auth.tenantId ?? null,
384
445
  query: parsed.data,
385
446
  accessType: id ? 'read:item' : undefined,
386
447
  })
@@ -395,13 +456,27 @@ export const PUT = async (req: Request) => {
395
456
  return crud.PUT(req)
396
457
  }
397
458
 
398
- export const DELETE = crud.DELETE
459
+ export const DELETE = async (req: Request) => {
460
+ const targetId = new URL(req.url).searchParams.get('id')
461
+ if (targetId) {
462
+ try {
463
+ await assertCanModifySuperAdminTarget(req, targetId)
464
+ } catch (err) {
465
+ if (err instanceof CrudHttpError) {
466
+ return NextResponse.json(err.body, { status: err.status })
467
+ }
468
+ throw err
469
+ }
470
+ }
471
+ return crud.DELETE(req)
472
+ }
399
473
 
400
474
  async function findUserIdsBySearchTokens(
401
475
  em: EntityManager,
402
476
  entityType: string,
403
477
  search: string,
404
478
  tenantScope: string | null | undefined,
479
+ field?: string,
405
480
  ): Promise<string[] | null> {
406
481
  const trimmed = search.trim()
407
482
  if (!trimmed) return null
@@ -418,6 +493,9 @@ async function findUserIdsBySearchTokens(
418
493
  .where('token_hash', 'in', hashes)
419
494
  .groupBy('entity_id')
420
495
  .having(sql<boolean>`count(distinct token_hash) >= ${hashes.length}`)
496
+ if (field) {
497
+ query = query.where('field', '=', field)
498
+ }
421
499
  if (tenantScope !== undefined) {
422
500
  query = query.where(sql<boolean>`tenant_id is not distinct from ${tenantScope}`)
423
501
  }
@@ -427,6 +505,21 @@ async function findUserIdsBySearchTokens(
427
505
  .filter((id): id is string => typeof id === 'string' && id.length > 0)
428
506
  }
429
507
 
508
+ async function assertCanModifySuperAdminTarget(req: Request, targetUserId: string) {
509
+ const auth = await getAuthFromRequest(req)
510
+ if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
511
+ const container = await createRequestContainer()
512
+ const em = container.resolve('em') as EntityManager
513
+ await assertActorCanModifySuperAdminUserTarget({
514
+ em,
515
+ rbacService: container.resolve('rbacService') as RbacService,
516
+ actorUserId: auth.sub,
517
+ tenantId: auth.tenantId ?? null,
518
+ organizationId: auth.orgId ?? null,
519
+ targetUserId,
520
+ })
521
+ }
522
+
430
523
  async function assertCanAssignRoles(req: Request, roles: unknown, payload: Record<string, unknown>) {
431
524
  if (!Array.isArray(roles)) return
432
525
  const auth = await getAuthFromRequest(req)
@@ -483,7 +576,7 @@ export const openApi: OpenApiRouteDoc = {
483
576
  GET: {
484
577
  summary: 'List users',
485
578
  description:
486
- 'Returns users for the current tenant. Search matches email, organization name, and role name. Super administrators may scope the response via organization or role filters.',
579
+ 'Returns users for the effective selected tenant and organization scope. Search matches email, organization name, and role name. Super administrators may scope the response via the topbar context, organization filters, or role filters.',
487
580
  query: querySchema,
488
581
  responses: [
489
582
  { status: 200, description: 'User collection', schema: userListResponseSchema },
@@ -9,6 +9,7 @@ import { AclEditor, type AclData } from '@open-mercato/core/modules/auth/compone
9
9
  import { WidgetVisibilityEditor, type WidgetVisibilityEditorHandle } from '@open-mercato/core/modules/dashboards/components/WidgetVisibilityEditor'
10
10
  import { E } from '#generated/entities.ids.generated'
11
11
  import { TenantSelect } from '@open-mercato/core/modules/directory/components/TenantSelect'
12
+ import { Alert } from '@open-mercato/ui/primitives/alert'
12
13
  import { useT } from '@open-mercato/shared/lib/i18n/context'
13
14
  import { extractCustomFieldEntries } from '@open-mercato/shared/lib/crud/custom-fields-client'
14
15
 
@@ -93,6 +94,7 @@ export default function EditRolePage({ params }: { params?: { id?: string } }) {
93
94
  label: t('auth.roles.form.field.tenant', 'Tenant'),
94
95
  type: 'custom',
95
96
  required: true,
97
+ disabled,
96
98
  component: ({ value, setValue }) => {
97
99
  const normalizedValue = typeof value === 'string'
98
100
  ? value
@@ -104,10 +106,13 @@ export default function EditRolePage({ params }: { params?: { id?: string } }) {
104
106
  onChange={(next) => {
105
107
  const resolved = next ?? null
106
108
  setValue(resolved)
109
+ if (selectedTenantId !== resolved) {
110
+ setAclData((prev) => ({ ...prev, organizations: null }))
111
+ }
107
112
  setSelectedTenantId(resolved)
108
- setAclData({ isSuperAdmin: false, features: [], organizations: null })
109
113
  }}
110
114
  includeEmptyOption
115
+ disabled={disabled}
111
116
  className="w-full h-9 rounded border px-2 text-sm"
112
117
  tenants={preloadedTenants}
113
118
  />
@@ -131,19 +136,35 @@ export default function EditRolePage({ params }: { params?: { id?: string } }) {
131
136
  id: 'acl',
132
137
  title: t('auth.roles.form.group.access', 'Access'),
133
138
  column: 1,
134
- component: () => (id
135
- ? (
136
- <AclEditor
137
- kind="role"
138
- targetId={String(id)}
139
- canEditOrganizations
140
- value={aclData}
141
- onChange={setAclData}
142
- currentUserIsSuperAdmin={actorIsSuperAdmin}
143
- tenantId={selectedTenantId ?? null}
144
- />
139
+ component: () => {
140
+ if (!id) return null
141
+ const initialTenantId = initial?.tenantId ?? null
142
+ const tenantReassigned = actorIsSuperAdmin
143
+ && initial != null
144
+ && (selectedTenantId ?? null) !== (initialTenantId ?? null)
145
+ return (
146
+ <div className="space-y-3">
147
+ {tenantReassigned ? (
148
+ <Alert status="warning" style="lighter">
149
+ {t(
150
+ 'auth.roles.form.tenantReassignWarning',
151
+ 'This role is being reassigned to a different tenant. The permissions selected here will be saved under the new tenant when you submit.',
152
+ )}
153
+ </Alert>
154
+ ) : null}
155
+ <AclEditor
156
+ kind="role"
157
+ targetId={String(id)}
158
+ canEditOrganizations
159
+ value={aclData}
160
+ onChange={setAclData}
161
+ currentUserIsSuperAdmin={actorIsSuperAdmin}
162
+ tenantId={selectedTenantId ?? null}
163
+ preserveOnTenantChange
164
+ />
165
+ </div>
145
166
  )
146
- : null),
167
+ },
147
168
  },
148
169
  {
149
170
  id: 'dashboardWidgets',
@@ -155,6 +176,7 @@ export default function EditRolePage({ params }: { params?: { id?: string } }) {
155
176
  kind="role"
156
177
  targetId={String(id)}
157
178
  tenantId={selectedTenantId ?? (initial?.tenantId ?? null)}
179
+ preserveOnTenantChange
158
180
  ref={widgetEditorRef}
159
181
  />
160
182
  )
@@ -254,7 +254,7 @@ export default function EditUserPage({ params }: { params?: { id?: string } }) {
254
254
  if (!actorResolved) return []
255
255
  if (actorIsSuperAdmin) {
256
256
  if (!selectedTenantId) return []
257
- return fetchRoleOptions(query, { tenantId: selectedTenantId })
257
+ return fetchRoleOptions(query, { tenantId: selectedTenantId, includeSuperAdmin: true })
258
258
  }
259
259
  return fetchRoleOptions(query)
260
260
  }, [actorIsSuperAdmin, actorResolved, selectedTenantId])
@@ -171,7 +171,7 @@ export default function CreateUserPage() {
171
171
  if (!actorResolved) return []
172
172
  if (actorIsSuperAdmin) {
173
173
  if (!selectedTenantId) return []
174
- return fetchRoleOptions(query, { tenantId: selectedTenantId })
174
+ return fetchRoleOptions(query, { tenantId: selectedTenantId, includeSuperAdmin: true })
175
175
  }
176
176
  return fetchRoleOptions(query)
177
177
  }, [actorIsSuperAdmin, actorResolved, selectedTenantId])
@@ -7,6 +7,7 @@ type RoleListResponse = {
7
7
 
8
8
  type FetchRoleOptionsParams = {
9
9
  tenantId?: string | null
10
+ includeSuperAdmin?: boolean
10
11
  }
11
12
 
12
13
  export async function fetchRoleOptions(query?: string, params?: FetchRoleOptionsParams): Promise<CrudFieldOption[]> {
@@ -28,7 +29,7 @@ export async function fetchRoleOptions(query?: string, params?: FetchRoleOptions
28
29
  const id = typeof item?.id === 'string' ? item.id.trim() : ''
29
30
  const name = typeof item?.name === 'string' ? item?.name.trim() : ''
30
31
  if (!id || !name) return null
31
- if (name === 'superadmin') return null
32
+ if (name === 'superadmin' && !params?.includeSuperAdmin) return null
32
33
  return { value: id, label: name }
33
34
  })
34
35
  .filter((opt): opt is CrudFieldOption => !!opt)
@@ -235,6 +235,14 @@ const updateRoleCommand: CommandHandler<Record<string, unknown>, Role> = {
235
235
  }
236
236
  }
237
237
  }
238
+ const wantsTenantChange = parsed.tenantId !== undefined && parsed.tenantId !== current.tenantId
239
+ if (wantsTenantChange) {
240
+ const assignments = await em.count(UserRole, { role: current, deletedAt: null })
241
+ if (assignments > 0) {
242
+ throw new CrudHttpError(400, { error: 'Role cannot be moved to another tenant while users are assigned' })
243
+ }
244
+ await em.nativeDelete(RoleAcl, { role: parsed.id as unknown as Role })
245
+ }
238
246
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
239
247
  const role = await de.updateOrmEntity({
240
248
  entity: Role,
@@ -286,6 +286,7 @@ const createUserCommand: CommandHandler<Record<string, unknown>, CreateUserResul
286
286
  resourceKind: 'auth.user',
287
287
  resourceId: String(user.id),
288
288
  tenantId: user.tenantId ? String(user.tenantId) : null,
289
+ organizationId: user.organizationId ? String(user.organizationId) : null,
289
290
  snapshotAfter: snapshot.view,
290
291
  payload: {
291
292
  undo: {
@@ -559,6 +560,7 @@ const updateUserCommand: CommandHandler<Record<string, unknown>, User> = {
559
560
  resourceKind: 'auth.user',
560
561
  resourceId: String(result.id),
561
562
  tenantId: result.tenantId ? String(result.tenantId) : null,
563
+ organizationId: result.organizationId ? String(result.organizationId) : null,
562
564
  changes,
563
565
  snapshotBefore: before ?? null,
564
566
  snapshotAfter: after,
@@ -689,6 +691,7 @@ const deleteUserCommand: CommandHandler<{ body?: Record<string, unknown>; query?
689
691
  resourceId: id,
690
692
  snapshotBefore: before ?? null,
691
693
  tenantId: before?.tenantId ?? null,
694
+ organizationId: before?.organizationId ?? null,
692
695
  payload: {
693
696
  undo: {
694
697
  before: beforeUndo,
@@ -100,6 +100,7 @@ export function AclEditor({
100
100
  userRoles,
101
101
  currentUserIsSuperAdmin,
102
102
  tenantId,
103
+ preserveOnTenantChange = false,
103
104
  }: {
104
105
  kind: 'user' | 'role'
105
106
  targetId: string
@@ -109,6 +110,7 @@ export function AclEditor({
109
110
  userRoles?: string[]
110
111
  currentUserIsSuperAdmin?: boolean
111
112
  tenantId?: string | null
113
+ preserveOnTenantChange?: boolean
112
114
  }) {
113
115
  const actorIsSuperAdmin = !!currentUserIsSuperAdmin
114
116
  const [loading, setLoading] = React.useState(true)
@@ -142,8 +144,33 @@ export function AclEditor({
142
144
  [actorSanitizeFeatures],
143
145
  )
144
146
 
147
+ const tenantIdRef = React.useRef(tenantId)
148
+ React.useEffect(() => { tenantIdRef.current = tenantId }, [tenantId])
149
+ const hasMountedRef = React.useRef(false)
150
+
151
+ const fetchAclState = React.useCallback(async (forTenantId: string | null | undefined, cancelledRef: { current: boolean }) => {
152
+ try {
153
+ const aclQuery = new URLSearchParams()
154
+ aclQuery.set(kind === 'user' ? 'userId' : 'roleId', targetId)
155
+ if (forTenantId) aclQuery.set('tenantId', forTenantId)
156
+ const aclQueryString = aclQuery.toString()
157
+ const aclJson = await readJsonOr<AclPayload>(
158
+ `/api/auth/${kind === 'user' ? 'users' : 'roles'}/acl${aclQueryString ? `?${aclQueryString}` : ''}`,
159
+ undefined,
160
+ { hasCustomAcl: true, isSuperAdmin: false, features: [], organizations: null },
161
+ )
162
+ if (cancelledRef.current) return
163
+ const customAclExists = aclJson.hasCustomAcl !== false
164
+ setHasCustomAcl(customAclExists)
165
+ setOverrideEnabled(customAclExists)
166
+ setIsSuperAdmin(!!aclJson.isSuperAdmin)
167
+ setGranted(actorSanitizeFeatures(aclJson.features))
168
+ setOrganizations(aclJson.organizations == null ? null : Array.isArray(aclJson.organizations) ? aclJson.organizations : [])
169
+ } catch {}
170
+ }, [kind, targetId, actorSanitizeFeatures])
171
+
145
172
  React.useEffect(() => {
146
- let cancelled = false
173
+ const cancelled = { current: false }
147
174
  async function load() {
148
175
  setLoading(true)
149
176
  try {
@@ -152,30 +179,30 @@ export function AclEditor({
152
179
  undefined,
153
180
  { items: [], modules: [] },
154
181
  )
155
- if (!cancelled) {
182
+ if (!cancelled.current) {
156
183
  setFeatures(fJson.items || [])
157
184
  setModules(fJson.modules || [])
158
185
  }
159
186
  } catch {}
160
- try {
161
- const aclQuery = new URLSearchParams()
162
- aclQuery.set(kind === 'user' ? 'userId' : 'roleId', targetId)
163
- if (tenantId) aclQuery.set('tenantId', tenantId)
164
- const aclQueryString = aclQuery.toString()
165
- const aclJson = await readJsonOr<AclPayload>(
166
- `/api/auth/${kind === 'user' ? 'users' : 'roles'}/acl${aclQueryString ? `?${aclQueryString}` : ''}`,
167
- undefined,
168
- { hasCustomAcl: true, isSuperAdmin: false, features: [], organizations: null },
169
- )
170
- if (!cancelled) {
171
- const customAclExists = aclJson.hasCustomAcl !== false
172
- setHasCustomAcl(customAclExists)
173
- setOverrideEnabled(customAclExists)
174
- setIsSuperAdmin(!!aclJson.isSuperAdmin)
175
- setGranted(actorSanitizeFeatures(aclJson.features))
176
- setOrganizations(aclJson.organizations == null ? null : Array.isArray(aclJson.organizations) ? aclJson.organizations : [])
177
- }
178
- } catch {}
187
+ await fetchAclState(tenantIdRef.current, cancelled)
188
+ hasMountedRef.current = true
189
+ if (!cancelled.current) setLoading(false)
190
+ }
191
+ load()
192
+ return () => { cancelled.current = true }
193
+ }, [kind, targetId, fetchAclState])
194
+
195
+ React.useEffect(() => {
196
+ if (!hasMountedRef.current) return
197
+ if (preserveOnTenantChange) return
198
+ const cancelled = { current: false }
199
+ fetchAclState(tenantId, cancelled)
200
+ return () => { cancelled.current = true }
201
+ }, [tenantId, preserveOnTenantChange, fetchAclState])
202
+
203
+ React.useEffect(() => {
204
+ let cancelled = false
205
+ async function loadTenantScoped() {
179
206
  if (canEditOrganizations) {
180
207
  try {
181
208
  const orgQuery = new URLSearchParams()
@@ -206,11 +233,10 @@ export function AclEditor({
206
233
  }
207
234
  } catch {}
208
235
  }
209
- if (!cancelled) setLoading(false)
210
236
  }
211
- load()
237
+ loadTenantScoped()
212
238
  return () => { cancelled = true }
213
- }, [kind, targetId, canEditOrganizations, userRoles, actorSanitizeFeatures, tenantId])
239
+ }, [kind, canEditOrganizations, userRoles, tenantId])
214
240
 
215
241
  // Notify parent of changes
216
242
  React.useEffect(() => {
@@ -1,8 +1,8 @@
1
1
  import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
2
  import { CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
3
3
  import { hasFeature } from '@open-mercato/shared/security/features'
4
- import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
- import { Role, RoleAcl } from '@open-mercato/core/modules/auth/data/entities'
4
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { Role, RoleAcl, UserAcl, UserRole } from '@open-mercato/core/modules/auth/data/entities'
6
6
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
7
 
8
8
  type ActorAcl = {
@@ -33,6 +33,16 @@ type FeatureGrantCheckInput = GrantCheckContext & {
33
33
  organizations?: string[] | null
34
34
  }
35
35
 
36
+ type SuperAdminUserTargetInput = GrantCheckContext & {
37
+ targetUserId: string
38
+ actorIsSuperAdmin?: boolean
39
+ }
40
+
41
+ type SuperAdminRoleTargetInput = GrantCheckContext & {
42
+ targetRoleId: string
43
+ actorIsSuperAdmin?: boolean
44
+ }
45
+
36
46
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
37
47
 
38
48
  export async function assertActorCanGrantRoleTokens(input: RoleTokenGrantCheckInput): Promise<Role[]> {
@@ -99,6 +109,110 @@ export function normalizeGrantFeatureList(features: unknown): string[] {
99
109
  return normalizeStringList(features)
100
110
  }
101
111
 
112
+ export async function assertActorCanModifySuperAdminUserTarget(input: SuperAdminUserTargetInput): Promise<void> {
113
+ const actorIsSuperAdmin = await resolveActorIsSuperAdmin(input)
114
+ if (actorIsSuperAdmin) return
115
+ const targetIsSuperAdmin = await isUserEffectivelySuperAdmin(input.em, input.targetUserId)
116
+ if (targetIsSuperAdmin) {
117
+ throw forbidden('Only super administrators can modify super administrator accounts.')
118
+ }
119
+ }
120
+
121
+ export async function assertActorCanModifySuperAdminRoleTarget(input: SuperAdminRoleTargetInput): Promise<void> {
122
+ const actorIsSuperAdmin = await resolveActorIsSuperAdmin(input)
123
+ if (actorIsSuperAdmin) return
124
+ const targetIsSuperAdmin = await isRoleEffectivelySuperAdmin(input.em, input.targetRoleId)
125
+ if (targetIsSuperAdmin) {
126
+ throw forbidden('Only super administrators can modify super administrator roles.')
127
+ }
128
+ }
129
+
130
+ async function resolveActorIsSuperAdmin(input: GrantCheckContext & { actorIsSuperAdmin?: boolean }): Promise<boolean> {
131
+ if (typeof input.actorIsSuperAdmin === 'boolean') return input.actorIsSuperAdmin
132
+ const acl = await loadActorAcl(input)
133
+ return acl.isSuperAdmin
134
+ }
135
+
136
+ export async function isUserEffectivelySuperAdmin(em: EntityManager, userId: string): Promise<boolean> {
137
+ const directGrant = await em.findOne(
138
+ UserAcl,
139
+ { user: userId as unknown, isSuperAdmin: true } as FilterQuery<UserAcl>,
140
+ )
141
+ if (directGrant && (directGrant as { isSuperAdmin?: boolean }).isSuperAdmin === true) return true
142
+ const links = await findWithDecryption(
143
+ em,
144
+ UserRole,
145
+ { user: userId as unknown } as FilterQuery<UserRole>,
146
+ { populate: ['role'] },
147
+ { tenantId: null, organizationId: null },
148
+ )
149
+ const roleIds = (Array.isArray(links) ? links : [])
150
+ .map((link) => {
151
+ const role = (link as { role?: { id?: unknown } | string | null }).role
152
+ if (!role) return null
153
+ if (typeof role === 'string') return role
154
+ return role.id ? String(role.id) : null
155
+ })
156
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
157
+ if (!roleIds.length) return false
158
+ const roleGrant = await em.findOne(
159
+ RoleAcl,
160
+ { role: { $in: roleIds } as unknown, isSuperAdmin: true } as FilterQuery<RoleAcl>,
161
+ )
162
+ return !!roleGrant && (roleGrant as { isSuperAdmin?: boolean }).isSuperAdmin === true
163
+ }
164
+
165
+ export async function isRoleEffectivelySuperAdmin(em: EntityManager, roleId: string): Promise<boolean> {
166
+ const grant = await em.findOne(
167
+ RoleAcl,
168
+ { role: roleId as unknown, isSuperAdmin: true } as FilterQuery<RoleAcl>,
169
+ )
170
+ return !!grant && (grant as { isSuperAdmin?: boolean }).isSuperAdmin === true
171
+ }
172
+
173
+ export async function listSuperAdminUserIds(em: EntityManager, tenantId: string | null): Promise<Set<string>> {
174
+ const ids = new Set<string>()
175
+ const userAclFilter: Record<string, unknown> = { isSuperAdmin: true }
176
+ if (tenantId) userAclFilter.tenantId = tenantId
177
+ const userAcls = await em.find(UserAcl, userAclFilter as FilterQuery<UserAcl>)
178
+ for (const acl of userAcls) {
179
+ const userRef = (acl as { user?: { id?: unknown } | string | null }).user
180
+ const userId = userRef && typeof userRef === 'object'
181
+ ? userRef.id
182
+ : userRef
183
+ if (userId) ids.add(String(userId))
184
+ }
185
+ const roleAcls = await em.find(
186
+ RoleAcl,
187
+ { isSuperAdmin: true } as FilterQuery<RoleAcl>,
188
+ )
189
+ const roleIds = roleAcls
190
+ .map((acl) => {
191
+ const roleRef = (acl as { role?: { id?: unknown } | string | null }).role
192
+ if (!roleRef) return null
193
+ if (typeof roleRef === 'string') return roleRef
194
+ return roleRef.id ? String(roleRef.id) : null
195
+ })
196
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
197
+ if (roleIds.length) {
198
+ const links = await findWithDecryption(
199
+ em,
200
+ UserRole,
201
+ { role: { $in: roleIds } as unknown } as FilterQuery<UserRole>,
202
+ {},
203
+ { tenantId: null, organizationId: null },
204
+ )
205
+ for (const link of Array.isArray(links) ? links : []) {
206
+ const userRef = (link as { user?: { id?: unknown } | string | null }).user
207
+ const userId = userRef && typeof userRef === 'object'
208
+ ? userRef.id
209
+ : userRef
210
+ if (userId) ids.add(String(userId))
211
+ }
212
+ }
213
+ return ids
214
+ }
215
+
102
216
  async function loadActorAcl(input: GrantCheckContext): Promise<ActorAcl> {
103
217
  const actorUserId = normalizeNullableString(input.actorUserId)
104
218
  if (!actorUserId) throw forbidden('Not authorized to grant ACL privileges.')