@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/queue-runner.js +15 -3
- package/dist/helpers/integration/queue-runner.js.map +2 -2
- package/dist/helpers/integration/queue.js +1 -1
- package/dist/helpers/integration/queue.js.map +2 -2
- package/dist/modules/auth/api/roles/acl/route.js +37 -1
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/roles/route.js +36 -2
- package/dist/modules/auth/api/roles/route.js.map +2 -2
- package/dist/modules/auth/api/users/acl/route.js +36 -1
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +89 -11
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/backend/roles/[id]/edit/page.js +32 -14
- package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
- package/dist/modules/auth/backend/users/[id]/edit/page.js +1 -1
- package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
- package/dist/modules/auth/backend/users/create/page.js +1 -1
- package/dist/modules/auth/backend/users/create/page.js.map +2 -2
- package/dist/modules/auth/backend/users/roleOptions.js +1 -1
- package/dist/modules/auth/backend/users/roleOptions.js.map +2 -2
- package/dist/modules/auth/commands/roles.js +8 -0
- package/dist/modules/auth/commands/roles.js.map +2 -2
- package/dist/modules/auth/commands/users.js +3 -0
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/components/AclEditor.js +53 -26
- package/dist/modules/auth/components/AclEditor.js.map +2 -2
- package/dist/modules/auth/lib/grantChecks.js +97 -2
- package/dist/modules/auth/lib/grantChecks.js.map +2 -2
- package/dist/modules/business_rules/lib/expression-evaluator.js +9 -7
- package/dist/modules/business_rules/lib/expression-evaluator.js.map +2 -2
- package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +41 -12
- package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +104 -0
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/data/validators.js +20 -1
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/translations/components/TranslationManager.js +9 -4
- package/dist/modules/translations/components/TranslationManager.js.map +2 -2
- package/dist/modules/workflows/lib/event-trigger-service.js +23 -11
- package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
- package/jest.config.cjs +1 -0
- package/package.json +7 -7
- package/src/helpers/integration/queue-runner.ts +21 -2
- package/src/helpers/integration/queue.ts +1 -1
- package/src/modules/auth/api/roles/acl/route.ts +39 -1
- package/src/modules/auth/api/roles/route.ts +38 -2
- package/src/modules/auth/api/users/acl/route.ts +41 -1
- package/src/modules/auth/api/users/route.ts +92 -11
- package/src/modules/auth/backend/roles/[id]/edit/page.tsx +35 -13
- package/src/modules/auth/backend/users/[id]/edit/page.tsx +1 -1
- package/src/modules/auth/backend/users/create/page.tsx +1 -1
- package/src/modules/auth/backend/users/roleOptions.ts +2 -1
- package/src/modules/auth/commands/roles.ts +8 -0
- package/src/modules/auth/commands/users.ts +3 -0
- package/src/modules/auth/components/AclEditor.tsx +50 -24
- package/src/modules/auth/lib/grantChecks.ts +116 -2
- package/src/modules/business_rules/lib/expression-evaluator.ts +9 -13
- package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +38 -12
- package/src/modules/sales/commands/documents.ts +116 -0
- package/src/modules/sales/data/validators.ts +38 -0
- package/src/modules/sales/i18n/de.json +2 -0
- package/src/modules/sales/i18n/en.json +2 -0
- package/src/modules/sales/i18n/es.json +2 -0
- package/src/modules/sales/i18n/pl.json +2 -0
- package/src/modules/translations/components/TranslationManager.tsx +9 -4
- 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 }) =>
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
{
|
|
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:
|
|
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 =
|
|
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
|
|
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: () =>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
237
|
+
loadTenantScoped()
|
|
212
238
|
return () => { cancelled = true }
|
|
213
|
-
}, [kind,
|
|
239
|
+
}, [kind, canEditOrganizations, userRoles, tenantId])
|
|
214
240
|
|
|
215
241
|
// Notify parent of changes
|
|
216
242
|
React.useEffect(() => {
|