@open-mercato/core 0.6.2-develop.3461.1.605f31c2c9 → 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 (173) 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/frontend/login.js +35 -0
  31. package/dist/modules/auth/frontend/login.js.map +2 -2
  32. package/dist/modules/auth/lib/grantChecks.js +97 -2
  33. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  34. package/dist/modules/business_rules/lib/expression-evaluator.js +9 -7
  35. package/dist/modules/business_rules/lib/expression-evaluator.js.map +2 -2
  36. package/dist/modules/business_rules/lib/rule-evaluator.js +3 -3
  37. package/dist/modules/business_rules/lib/rule-evaluator.js.map +2 -2
  38. package/dist/modules/customers/ai-agents.js +10 -1
  39. package/dist/modules/customers/ai-agents.js.map +2 -2
  40. package/dist/modules/customers/ai-tools/settings-pack.js +55 -1
  41. package/dist/modules/customers/ai-tools/settings-pack.js.map +2 -2
  42. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +13 -5
  43. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  44. package/dist/modules/customers/lib/personCompanyLinkTable.js +4 -30
  45. package/dist/modules/customers/lib/personCompanyLinkTable.js.map +2 -2
  46. package/dist/modules/customers/migrations/Migration20260521175027.js +12 -0
  47. package/dist/modules/customers/migrations/Migration20260521175027.js.map +7 -0
  48. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +41 -12
  49. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +2 -2
  50. package/dist/modules/data_sync/api/options.js +2 -0
  51. package/dist/modules/data_sync/api/options.js.map +2 -2
  52. package/dist/modules/data_sync/api/run.js +77 -58
  53. package/dist/modules/data_sync/api/run.js.map +2 -2
  54. package/dist/modules/data_sync/backend/data-sync/page.js +3 -2
  55. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  56. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +8 -3
  57. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  58. package/dist/modules/integrations/backend/integrations/[id]/page.js +15 -13
  59. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  60. package/dist/modules/integrations/data/validators.js +9 -6
  61. package/dist/modules/integrations/data/validators.js.map +2 -2
  62. package/dist/modules/messages/api/route.js +2 -2
  63. package/dist/modules/messages/api/route.js.map +2 -2
  64. package/dist/modules/messages/commands/messages.js +42 -2
  65. package/dist/modules/messages/commands/messages.js.map +2 -2
  66. package/dist/modules/messages/components/MessagesInboxPageClient.js +70 -89
  67. package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
  68. package/dist/modules/messages/components/defaults/DefaultMessageListItem.js +8 -6
  69. package/dist/modules/messages/components/defaults/DefaultMessageListItem.js.map +2 -2
  70. package/dist/modules/messages/components/inboxFilters.js +133 -0
  71. package/dist/modules/messages/components/inboxFilters.js.map +7 -0
  72. package/dist/modules/messages/components/messageListLabels.js +14 -0
  73. package/dist/modules/messages/components/messageListLabels.js.map +7 -0
  74. package/dist/modules/messages/components/useMessagesInboxBulkActions.js +47 -23
  75. package/dist/modules/messages/components/useMessagesInboxBulkActions.js.map +2 -2
  76. package/dist/modules/messages/data/validators.js +8 -6
  77. package/dist/modules/messages/data/validators.js.map +2 -2
  78. package/dist/modules/progress/api/jobs/route.js +26 -16
  79. package/dist/modules/progress/api/jobs/route.js.map +2 -2
  80. package/dist/modules/query_index/di.js +8 -6
  81. package/dist/modules/query_index/di.js.map +2 -2
  82. package/dist/modules/query_index/lib/indexer.js +12 -10
  83. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  84. package/dist/modules/query_index/subscribers/upsert_one.js +7 -1
  85. package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
  86. package/dist/modules/sales/api/order-adjustments/route.js.map +2 -2
  87. package/dist/modules/sales/api/quote-adjustments/route.js.map +2 -2
  88. package/dist/modules/sales/commands/documents.js +104 -0
  89. package/dist/modules/sales/commands/documents.js.map +2 -2
  90. package/dist/modules/sales/data/validators.js +20 -1
  91. package/dist/modules/sales/data/validators.js.map +2 -2
  92. package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
  93. package/dist/modules/sync_excel/api/import/route.js +126 -116
  94. package/dist/modules/sync_excel/api/import/route.js.map +2 -2
  95. package/dist/modules/sync_excel/lib/adapters/customers.js +1 -0
  96. package/dist/modules/sync_excel/lib/adapters/customers.js.map +2 -2
  97. package/dist/modules/translations/components/TranslationManager.js +9 -4
  98. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  99. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  100. package/dist/modules/workflows/lib/event-trigger-service.js +23 -11
  101. package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
  102. package/jest.config.cjs +4 -1
  103. package/package.json +15 -16
  104. package/src/helpers/integration/queue-runner.ts +21 -2
  105. package/src/helpers/integration/queue.ts +1 -1
  106. package/src/helpers/integration/salesUi.ts +26 -1
  107. package/src/modules/auth/api/roles/acl/route.ts +39 -1
  108. package/src/modules/auth/api/roles/route.ts +38 -2
  109. package/src/modules/auth/api/users/acl/route.ts +41 -1
  110. package/src/modules/auth/api/users/route.ts +105 -12
  111. package/src/modules/auth/backend/roles/[id]/edit/page.tsx +35 -13
  112. package/src/modules/auth/backend/users/[id]/edit/page.tsx +1 -1
  113. package/src/modules/auth/backend/users/create/page.tsx +1 -1
  114. package/src/modules/auth/backend/users/roleOptions.ts +2 -1
  115. package/src/modules/auth/commands/roles.ts +8 -0
  116. package/src/modules/auth/commands/users.ts +3 -0
  117. package/src/modules/auth/components/AclEditor.tsx +50 -24
  118. package/src/modules/auth/frontend/login.tsx +40 -0
  119. package/src/modules/auth/lib/grantChecks.ts +116 -2
  120. package/src/modules/business_rules/lib/expression-evaluator.ts +9 -13
  121. package/src/modules/business_rules/lib/rule-evaluator.ts +3 -3
  122. package/src/modules/customers/ai-agents.ts +10 -1
  123. package/src/modules/customers/ai-tools/settings-pack.ts +62 -1
  124. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +18 -7
  125. package/src/modules/customers/lib/personCompanyLinkTable.ts +33 -41
  126. package/src/modules/customers/migrations/Migration20260521175027.ts +39 -0
  127. package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +38 -12
  128. package/src/modules/data_sync/api/options.ts +2 -0
  129. package/src/modules/data_sync/api/run.ts +80 -61
  130. package/src/modules/data_sync/backend/data-sync/page.tsx +10 -0
  131. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +17 -3
  132. package/src/modules/data_sync/i18n/de.json +3 -0
  133. package/src/modules/data_sync/i18n/en.json +3 -0
  134. package/src/modules/data_sync/i18n/es.json +3 -0
  135. package/src/modules/data_sync/i18n/pl.json +3 -0
  136. package/src/modules/data_sync/lib/adapter.ts +9 -0
  137. package/src/modules/integrations/backend/integrations/[id]/page.tsx +15 -13
  138. package/src/modules/integrations/data/validators.ts +5 -4
  139. package/src/modules/messages/api/route.ts +2 -2
  140. package/src/modules/messages/commands/messages.ts +54 -2
  141. package/src/modules/messages/components/MessagesInboxPageClient.tsx +80 -92
  142. package/src/modules/messages/components/defaults/DefaultMessageListItem.tsx +9 -5
  143. package/src/modules/messages/components/inboxFilters.ts +162 -0
  144. package/src/modules/messages/components/messageListLabels.ts +29 -0
  145. package/src/modules/messages/components/useMessagesInboxBulkActions.ts +58 -26
  146. package/src/modules/messages/data/validators.ts +7 -5
  147. package/src/modules/messages/i18n/de.json +7 -4
  148. package/src/modules/messages/i18n/en.json +7 -4
  149. package/src/modules/messages/i18n/es.json +7 -4
  150. package/src/modules/messages/i18n/pl.json +7 -4
  151. package/src/modules/progress/api/jobs/route.ts +30 -20
  152. package/src/modules/query_index/di.ts +9 -7
  153. package/src/modules/query_index/lib/indexer.ts +13 -11
  154. package/src/modules/query_index/subscribers/upsert_one.ts +9 -1
  155. package/src/modules/sales/api/order-adjustments/route.ts +2 -1
  156. package/src/modules/sales/api/quote-adjustments/route.ts +2 -1
  157. package/src/modules/sales/commands/documents.ts +116 -0
  158. package/src/modules/sales/data/validators.ts +38 -0
  159. package/src/modules/sales/i18n/de.json +2 -0
  160. package/src/modules/sales/i18n/en.json +2 -0
  161. package/src/modules/sales/i18n/es.json +2 -0
  162. package/src/modules/sales/i18n/pl.json +2 -0
  163. package/src/modules/sales/lib/makeSalesLineRoute.ts +2 -1
  164. package/src/modules/staff/i18n/de.json +1 -0
  165. package/src/modules/staff/i18n/en.json +1 -0
  166. package/src/modules/staff/i18n/es.json +1 -0
  167. package/src/modules/staff/i18n/pl.json +1 -0
  168. package/src/modules/sync_excel/api/import/route.ts +141 -131
  169. package/src/modules/sync_excel/lib/adapters/customers.ts +1 -0
  170. package/src/modules/translations/components/TranslationManager.tsx +9 -4
  171. package/src/modules/workflows/lib/activity-executor.ts +4 -3
  172. package/src/modules/workflows/lib/event-trigger-service.ts +24 -11
  173. 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(() => {
@@ -13,6 +13,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
13
13
  import { useT } from '@open-mercato/shared/lib/i18n/context'
14
14
  import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
15
15
  import { clearAllOperations } from '@open-mercato/ui/backend/operations/store'
16
+ import { notifyAuthIdentityChange } from '@open-mercato/ui/backend/AuthSessionGuard'
16
17
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
17
18
  import { X } from 'lucide-react'
18
19
  import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
@@ -125,6 +126,43 @@ export default function LoginPage() {
125
126
  setClientReady(true)
126
127
  }, [])
127
128
 
129
+ useEffect(() => {
130
+ let cancelled = false
131
+ void (async () => {
132
+ try {
133
+ const res = await apiCall<{ userId?: string }>('/api/auth/feature-check', {
134
+ method: 'POST',
135
+ headers: { 'content-type': 'application/json' },
136
+ body: JSON.stringify({ features: [] }),
137
+ cache: 'no-store',
138
+ })
139
+ if (cancelled) return
140
+ const activeUserId = typeof res.result?.userId === 'string' ? res.result.userId : ''
141
+ if (!activeUserId) return
142
+ const rawRedirect = searchParams.get('redirect') || ''
143
+ let destination = '/backend'
144
+ if (rawRedirect) {
145
+ try {
146
+ const resolved = new URL(rawRedirect, window.location.origin)
147
+ if (
148
+ resolved.origin === window.location.origin &&
149
+ resolved.pathname.startsWith('/') &&
150
+ !resolved.pathname.includes('//')
151
+ ) {
152
+ destination = resolved.pathname + resolved.search + resolved.hash
153
+ }
154
+ } catch {
155
+ // fall back to /backend
156
+ }
157
+ }
158
+ router.replace(destination)
159
+ } catch {
160
+ // ignore — leave login form usable on network failure
161
+ }
162
+ })()
163
+ return () => { cancelled = true }
164
+ }, [router, searchParams])
165
+
128
166
  useEffect(() => {
129
167
  const tenantParam = (searchParams.get('tenant') || '').trim()
130
168
  if (tenantParam) {
@@ -212,6 +250,7 @@ export default function LoginPage() {
212
250
  const res = await fetch('/api/auth/login', { method: 'POST', body: form })
213
251
  if (res.redirected) {
214
252
  clearAllOperations()
253
+ notifyAuthIdentityChange()
215
254
  // NextResponse.redirect from API
216
255
  router.replace(res.url)
217
256
  return
@@ -265,6 +304,7 @@ export default function LoginPage() {
265
304
  const data = await res.json().catch(() => null) as LoginResponseEventDetail
266
305
  emitLoginResponseEvent(data)
267
306
  clearAllOperations()
307
+ notifyAuthIdentityChange()
268
308
  if (data && typeof data.redirect === 'string' && data.redirect.length > 0) {
269
309
  router.replace(data.redirect)
270
310
  }