@open-mercato/core 0.6.3-develop.3525.3.cc18b8a43f → 0.6.3-develop.3574.1.f3c5dcf345

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 (67) 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/modules/auth/api/roles/acl/route.js +37 -1
  7. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  8. package/dist/modules/auth/api/roles/route.js +36 -2
  9. package/dist/modules/auth/api/roles/route.js.map +2 -2
  10. package/dist/modules/auth/api/users/acl/route.js +36 -1
  11. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  12. package/dist/modules/auth/api/users/route.js +89 -11
  13. package/dist/modules/auth/api/users/route.js.map +2 -2
  14. package/dist/modules/auth/backend/roles/[id]/edit/page.js +32 -14
  15. package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
  16. package/dist/modules/auth/backend/users/[id]/edit/page.js +1 -1
  17. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  18. package/dist/modules/auth/backend/users/create/page.js +1 -1
  19. package/dist/modules/auth/backend/users/create/page.js.map +2 -2
  20. package/dist/modules/auth/backend/users/roleOptions.js +1 -1
  21. package/dist/modules/auth/backend/users/roleOptions.js.map +2 -2
  22. package/dist/modules/auth/commands/roles.js +8 -0
  23. package/dist/modules/auth/commands/roles.js.map +2 -2
  24. package/dist/modules/auth/commands/users.js +3 -0
  25. package/dist/modules/auth/commands/users.js.map +2 -2
  26. package/dist/modules/auth/components/AclEditor.js +53 -26
  27. package/dist/modules/auth/components/AclEditor.js.map +2 -2
  28. package/dist/modules/auth/lib/grantChecks.js +97 -2
  29. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  30. package/dist/modules/business_rules/lib/expression-evaluator.js +9 -7
  31. package/dist/modules/business_rules/lib/expression-evaluator.js.map +2 -2
  32. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +41 -12
  33. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +2 -2
  34. package/dist/modules/sales/commands/documents.js +104 -0
  35. package/dist/modules/sales/commands/documents.js.map +2 -2
  36. package/dist/modules/sales/data/validators.js +20 -1
  37. package/dist/modules/sales/data/validators.js.map +2 -2
  38. package/dist/modules/translations/components/TranslationManager.js +9 -4
  39. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  40. package/dist/modules/workflows/lib/event-trigger-service.js +23 -11
  41. package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
  42. package/jest.config.cjs +1 -0
  43. package/package.json +7 -7
  44. package/src/helpers/integration/queue-runner.ts +21 -2
  45. package/src/helpers/integration/queue.ts +1 -1
  46. package/src/modules/auth/api/roles/acl/route.ts +39 -1
  47. package/src/modules/auth/api/roles/route.ts +38 -2
  48. package/src/modules/auth/api/users/acl/route.ts +41 -1
  49. package/src/modules/auth/api/users/route.ts +92 -11
  50. package/src/modules/auth/backend/roles/[id]/edit/page.tsx +35 -13
  51. package/src/modules/auth/backend/users/[id]/edit/page.tsx +1 -1
  52. package/src/modules/auth/backend/users/create/page.tsx +1 -1
  53. package/src/modules/auth/backend/users/roleOptions.ts +2 -1
  54. package/src/modules/auth/commands/roles.ts +8 -0
  55. package/src/modules/auth/commands/users.ts +3 -0
  56. package/src/modules/auth/components/AclEditor.tsx +50 -24
  57. package/src/modules/auth/lib/grantChecks.ts +116 -2
  58. package/src/modules/business_rules/lib/expression-evaluator.ts +9 -13
  59. package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +38 -12
  60. package/src/modules/sales/commands/documents.ts +116 -0
  61. package/src/modules/sales/data/validators.ts +38 -0
  62. package/src/modules/sales/i18n/de.json +2 -0
  63. package/src/modules/sales/i18n/en.json +2 -0
  64. package/src/modules/sales/i18n/es.json +2 -0
  65. package/src/modules/sales/i18n/pl.json +2 -0
  66. package/src/modules/translations/components/TranslationManager.tsx +9 -4
  67. package/src/modules/workflows/lib/event-trigger-service.ts +24 -11
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
3
3
  import { z } from 'zod'
4
4
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
5
5
  import { logCrudAccess, makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
6
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
7
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
7
8
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
8
9
  import { Role, RoleAcl, UserRole } from '@open-mercato/core/modules/auth/data/entities'
@@ -13,6 +14,8 @@ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
13
14
  import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
14
15
  import { roleCrudEvents, roleCrudIndexer } from '@open-mercato/core/modules/auth/commands/roles'
15
16
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
17
+ import { assertActorCanModifySuperAdminRoleTarget } from '@open-mercato/core/modules/auth/lib/grantChecks'
18
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
16
19
 
17
20
  const querySchema = z.object({
18
21
  id: z.string().uuid().optional(),
@@ -87,7 +90,12 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
87
90
  update: {
88
91
  commandId: 'auth.roles.update',
89
92
  schema: rawBodySchema,
90
- mapInput: ({ parsed }) => parsed,
93
+ mapInput: async ({ parsed, ctx }) => {
94
+ if (ctx.request && typeof parsed.id === 'string' && parsed.id.length) {
95
+ await assertCanModifySuperAdminRole(ctx.request, parsed.id)
96
+ }
97
+ return parsed
98
+ },
91
99
  response: () => ({ ok: true }),
92
100
  },
93
101
  delete: {
@@ -236,7 +244,35 @@ export async function GET(req: Request) {
236
244
 
237
245
  export const POST = crud.POST
238
246
  export const PUT = crud.PUT
239
- export const DELETE = crud.DELETE
247
+ export const DELETE = async (req: Request) => {
248
+ const targetId = new URL(req.url).searchParams.get('id')
249
+ if (targetId) {
250
+ try {
251
+ await assertCanModifySuperAdminRole(req, targetId)
252
+ } catch (err) {
253
+ if (err instanceof CrudHttpError) {
254
+ return NextResponse.json(err.body, { status: err.status })
255
+ }
256
+ throw err
257
+ }
258
+ }
259
+ return crud.DELETE(req)
260
+ }
261
+
262
+ async function assertCanModifySuperAdminRole(req: Request, targetRoleId: string) {
263
+ const auth = await getAuthFromRequest(req)
264
+ if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
265
+ const container = await createRequestContainer()
266
+ const em = container.resolve('em') as EntityManager
267
+ await assertActorCanModifySuperAdminRoleTarget({
268
+ em,
269
+ rbacService: container.resolve('rbacService') as RbacService,
270
+ actorUserId: auth.sub,
271
+ tenantId: auth.tenantId ?? null,
272
+ organizationId: auth.orgId ?? null,
273
+ targetRoleId,
274
+ })
275
+ }
240
276
 
241
277
  export const openApi: OpenApiRouteDoc = {
242
278
  tag: 'Authentication & Accounts',
@@ -4,8 +4,11 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
6
  import { logCrudAccess } from '@open-mercato/shared/lib/crud/factory'
7
- import { forbidden } from '@open-mercato/shared/lib/crud/errors'
7
+ import { forbidden, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
8
  import { UserAcl } from '@open-mercato/core/modules/auth/data/entities'
9
+ import { assertActorCanModifySuperAdminUserTarget } from '@open-mercato/core/modules/auth/lib/grantChecks'
10
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
11
+ import type { EntityManager } from '@mikro-orm/postgresql'
9
12
 
10
13
  const getSchema = z.object({ userId: z.string().uuid() })
11
14
  const putSchema = z.object({
@@ -42,6 +45,26 @@ export async function GET(req: Request) {
42
45
  if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
43
46
  const container = await createRequestContainer()
44
47
  const em = container.resolve('em') as any
48
+ const rbacService = container.resolve('rbacService') as any
49
+ const actorAcl = auth.sub
50
+ ? await rbacService.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
51
+ : null
52
+ if (!actorAcl?.isSuperAdmin && auth.sub) {
53
+ try {
54
+ await assertActorCanModifySuperAdminUserTarget({
55
+ em: em as EntityManager,
56
+ rbacService: rbacService as RbacService,
57
+ actorUserId: auth.sub,
58
+ tenantId: auth.tenantId ?? null,
59
+ organizationId: auth.orgId ?? null,
60
+ targetUserId: parsed.data.userId,
61
+ actorIsSuperAdmin: false,
62
+ })
63
+ } catch (err) {
64
+ if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
65
+ throw err
66
+ }
67
+ }
45
68
  const acl = await em.findOne(UserAcl, { user: parsed.data.userId as any, tenantId: auth.tenantId as any })
46
69
  const response = acl
47
70
  ? {
@@ -83,6 +106,23 @@ export async function PUT(req: Request) {
83
106
  : null
84
107
  const actorIsSuperAdmin = !!actorAcl?.isSuperAdmin
85
108
 
109
+ if (!actorIsSuperAdmin && auth.sub) {
110
+ try {
111
+ await assertActorCanModifySuperAdminUserTarget({
112
+ em: em as EntityManager,
113
+ rbacService: rbacService as RbacService,
114
+ actorUserId: auth.sub,
115
+ tenantId: auth.tenantId ?? null,
116
+ organizationId: auth.orgId ?? null,
117
+ targetUserId: parsed.data.userId,
118
+ actorIsSuperAdmin: false,
119
+ })
120
+ } catch (err) {
121
+ if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
122
+ throw err
123
+ }
124
+ }
125
+
86
126
  const requestedFeatures = normalizeFeatureList(parsed.data.features)
87
127
  const organizations = Array.isArray(parsed.data.organizations) ? parsed.data.organizations : null
88
128
 
@@ -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(),
@@ -128,6 +136,9 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
128
136
  schema: rawBodySchema,
129
137
  mapInput: async ({ parsed, ctx }) => {
130
138
  if (ctx.request) {
139
+ if (typeof parsed.id === 'string' && parsed.id.length) {
140
+ await assertCanModifySuperAdminTarget(ctx.request, parsed.id)
141
+ }
131
142
  await assertCanAssignRoles(ctx.request, parsed.roles, parsed)
132
143
  }
133
144
  return parsed
@@ -158,12 +169,12 @@ export async function GET(req: Request) {
158
169
  if (!parsed.success) return NextResponse.json({ items: [], total: 0, totalPages: 1 })
159
170
  const container = await createRequestContainer()
160
171
  const em = (container.resolve('em') as EntityManager)
161
- let isSuperAdmin = false
172
+ let isSuperAdmin = auth.isSuperAdmin === true
162
173
  try {
163
174
  if (auth.sub) {
164
175
  const rbacService = container.resolve('rbacService') as any
165
176
  const acl = await rbacService.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
166
- isSuperAdmin = !!acl?.isSuperAdmin
177
+ isSuperAdmin = isSuperAdmin || !!acl?.isSuperAdmin
167
178
  }
168
179
  } catch (err) {
169
180
  console.error('users: failed to resolve rbac', err)
@@ -171,12 +182,51 @@ export async function GET(req: Request) {
171
182
  const { id, page, pageSize, search, name, organizationId, roleIds } = parsed.data
172
183
  const filters: any[] = [{ deletedAt: null }]
173
184
  const actorTenantId = auth.tenantId ? String(auth.tenantId) : null
185
+ let effectiveTenantId: string | null = null
186
+ let effectiveOrganizationIds: string[] | null = null
187
+ let effectiveSelectedOrganizationId: string | null = null
188
+ let usesSelectedTenantScope = false
174
189
  if (!isSuperAdmin) {
175
190
  if (!actorTenantId) {
176
191
  return NextResponse.json({ items: [], total: 0, totalPages: 1, isSuperAdmin })
177
192
  }
178
- filters.push({ tenantId: actorTenantId })
193
+ effectiveTenantId = actorTenantId
194
+ const superAdminUserIds = await listSuperAdminUserIds(em, actorTenantId)
195
+ if (superAdminUserIds.size) {
196
+ filters.push({ id: { $nin: Array.from(superAdminUserIds) as any } })
197
+ }
198
+ } else {
199
+ const selectedTenantId = getSelectedTenantFromRequest(req)
200
+ if (typeof selectedTenantId === 'string' && selectedTenantId.trim().length > 0) {
201
+ const scope = await resolveOrganizationScopeForRequest({
202
+ container,
203
+ auth,
204
+ request: req,
205
+ tenantId: selectedTenantId.trim(),
206
+ })
207
+ if (!scope.tenantId) {
208
+ return NextResponse.json({ items: [], total: 0, totalPages: 1, isSuperAdmin })
209
+ }
210
+ effectiveTenantId = scope.tenantId
211
+ effectiveSelectedOrganizationId = scope.selectedId
212
+ usesSelectedTenantScope = true
213
+ if (Array.isArray(scope.filterIds)) {
214
+ if (scope.filterIds.length === 0) {
215
+ return NextResponse.json({ items: [], total: 0, totalPages: 1, isSuperAdmin })
216
+ }
217
+ effectiveOrganizationIds = scope.filterIds
218
+ }
219
+ }
220
+ }
221
+ if (effectiveTenantId) {
222
+ filters.push({ tenantId: effectiveTenantId })
179
223
  }
224
+ if (effectiveOrganizationIds) {
225
+ filters.push({ organizationId: { $in: effectiveOrganizationIds as any } })
226
+ }
227
+ const scopeOrganizationId = usesSelectedTenantScope
228
+ ? effectiveSelectedOrganizationId
229
+ : auth.orgId ?? null
180
230
  if (organizationId) filters.push({ organizationId })
181
231
  const trimmedName = typeof name === 'string' ? name.trim() : ''
182
232
  if (trimmedName) {
@@ -204,7 +254,7 @@ export async function GET(req: Request) {
204
254
  const trimmedSearch = typeof search === 'string' ? search.trim() : ''
205
255
  if (trimmedSearch) {
206
256
  // Email is encrypted at rest, so plaintext search must go through search_tokens.
207
- const tenantScope: string | null | undefined = isSuperAdmin ? undefined : auth.tenantId ?? null
257
+ const tenantScope: string | null | undefined = isSuperAdmin ? (effectiveTenantId ?? undefined) : auth.tenantId ?? null
208
258
  const searchFilters: any[] = []
209
259
 
210
260
  const matchedIds = await findUserIdsBySearchTokens(em, E.auth.user, trimmedSearch, tenantScope)
@@ -284,7 +334,10 @@ export async function GET(req: Request) {
284
334
  UserRole,
285
335
  { user: { $in: userIds as any } } as any,
286
336
  { populate: ['role'] },
287
- { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
337
+ {
338
+ tenantId: effectiveTenantId ?? auth.tenantId ?? null,
339
+ organizationId: scopeOrganizationId,
340
+ },
288
341
  )
289
342
  : []
290
343
  const roleMap: Record<string, string[]> = {}
@@ -350,7 +403,7 @@ export async function GET(req: Request) {
350
403
  recordIds: userIds.map(String),
351
404
  tenantIdByRecord: tenantByUser,
352
405
  organizationIdByRecord: organizationByUser,
353
- tenantFallbacks: auth.tenantId ? [auth.tenantId] : [],
406
+ tenantFallbacks: effectiveTenantId ? [effectiveTenantId] : auth.tenantId ? [auth.tenantId] : [],
354
407
  })
355
408
  : {}
356
409
 
@@ -379,8 +432,8 @@ export async function GET(req: Request) {
379
432
  items,
380
433
  idField: 'id',
381
434
  resourceKind: 'auth.user',
382
- organizationId: null,
383
- tenantId: auth.tenantId ?? null,
435
+ organizationId: effectiveSelectedOrganizationId,
436
+ tenantId: effectiveTenantId ?? auth.tenantId ?? null,
384
437
  query: parsed.data,
385
438
  accessType: id ? 'read:item' : undefined,
386
439
  })
@@ -395,7 +448,20 @@ export const PUT = async (req: Request) => {
395
448
  return crud.PUT(req)
396
449
  }
397
450
 
398
- export const DELETE = crud.DELETE
451
+ export const DELETE = async (req: Request) => {
452
+ const targetId = new URL(req.url).searchParams.get('id')
453
+ if (targetId) {
454
+ try {
455
+ await assertCanModifySuperAdminTarget(req, targetId)
456
+ } catch (err) {
457
+ if (err instanceof CrudHttpError) {
458
+ return NextResponse.json(err.body, { status: err.status })
459
+ }
460
+ throw err
461
+ }
462
+ }
463
+ return crud.DELETE(req)
464
+ }
399
465
 
400
466
  async function findUserIdsBySearchTokens(
401
467
  em: EntityManager,
@@ -427,6 +493,21 @@ async function findUserIdsBySearchTokens(
427
493
  .filter((id): id is string => typeof id === 'string' && id.length > 0)
428
494
  }
429
495
 
496
+ async function assertCanModifySuperAdminTarget(req: Request, targetUserId: string) {
497
+ const auth = await getAuthFromRequest(req)
498
+ if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
499
+ const container = await createRequestContainer()
500
+ const em = container.resolve('em') as EntityManager
501
+ await assertActorCanModifySuperAdminUserTarget({
502
+ em,
503
+ rbacService: container.resolve('rbacService') as RbacService,
504
+ actorUserId: auth.sub,
505
+ tenantId: auth.tenantId ?? null,
506
+ organizationId: auth.orgId ?? null,
507
+ targetUserId,
508
+ })
509
+ }
510
+
430
511
  async function assertCanAssignRoles(req: Request, roles: unknown, payload: Record<string, unknown>) {
431
512
  if (!Array.isArray(roles)) return
432
513
  const auth = await getAuthFromRequest(req)
@@ -483,7 +564,7 @@ export const openApi: OpenApiRouteDoc = {
483
564
  GET: {
484
565
  summary: 'List users',
485
566
  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.',
567
+ '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
568
  query: querySchema,
488
569
  responses: [
489
570
  { 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(() => {