@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.
- 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/helpers/integration/salesUi.js +24 -1
- package/dist/helpers/integration/salesUi.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 +101 -13
- 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/frontend/login.js +35 -0
- package/dist/modules/auth/frontend/login.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/business_rules/lib/rule-evaluator.js +3 -3
- package/dist/modules/business_rules/lib/rule-evaluator.js.map +2 -2
- package/dist/modules/customers/ai-agents.js +10 -1
- package/dist/modules/customers/ai-agents.js.map +2 -2
- package/dist/modules/customers/ai-tools/settings-pack.js +55 -1
- package/dist/modules/customers/ai-tools/settings-pack.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +13 -5
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
- package/dist/modules/customers/lib/personCompanyLinkTable.js +4 -30
- package/dist/modules/customers/lib/personCompanyLinkTable.js.map +2 -2
- package/dist/modules/customers/migrations/Migration20260521175027.js +12 -0
- package/dist/modules/customers/migrations/Migration20260521175027.js.map +7 -0
- package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +41 -12
- package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +2 -2
- package/dist/modules/data_sync/api/options.js +2 -0
- package/dist/modules/data_sync/api/options.js.map +2 -2
- package/dist/modules/data_sync/api/run.js +77 -58
- package/dist/modules/data_sync/api/run.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/page.js +3 -2
- package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js +8 -3
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/[id]/page.js +15 -13
- package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
- package/dist/modules/integrations/data/validators.js +9 -6
- package/dist/modules/integrations/data/validators.js.map +2 -2
- package/dist/modules/messages/api/route.js +2 -2
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/commands/messages.js +42 -2
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/components/MessagesInboxPageClient.js +70 -89
- package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
- package/dist/modules/messages/components/defaults/DefaultMessageListItem.js +8 -6
- package/dist/modules/messages/components/defaults/DefaultMessageListItem.js.map +2 -2
- package/dist/modules/messages/components/inboxFilters.js +133 -0
- package/dist/modules/messages/components/inboxFilters.js.map +7 -0
- package/dist/modules/messages/components/messageListLabels.js +14 -0
- package/dist/modules/messages/components/messageListLabels.js.map +7 -0
- package/dist/modules/messages/components/useMessagesInboxBulkActions.js +47 -23
- package/dist/modules/messages/components/useMessagesInboxBulkActions.js.map +2 -2
- package/dist/modules/messages/data/validators.js +8 -6
- package/dist/modules/messages/data/validators.js.map +2 -2
- package/dist/modules/progress/api/jobs/route.js +26 -16
- package/dist/modules/progress/api/jobs/route.js.map +2 -2
- package/dist/modules/query_index/di.js +8 -6
- package/dist/modules/query_index/di.js.map +2 -2
- package/dist/modules/query_index/lib/indexer.js +12 -10
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/query_index/subscribers/upsert_one.js +7 -1
- package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
- package/dist/modules/sales/api/order-adjustments/route.js.map +2 -2
- package/dist/modules/sales/api/quote-adjustments/route.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/sales/lib/makeSalesLineRoute.js.map +2 -2
- package/dist/modules/sync_excel/api/import/route.js +126 -116
- package/dist/modules/sync_excel/api/import/route.js.map +2 -2
- package/dist/modules/sync_excel/lib/adapters/customers.js +1 -0
- package/dist/modules/sync_excel/lib/adapters/customers.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/activity-executor.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 +4 -1
- package/package.json +15 -16
- package/src/helpers/integration/queue-runner.ts +21 -2
- package/src/helpers/integration/queue.ts +1 -1
- package/src/helpers/integration/salesUi.ts +26 -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 +105 -12
- 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/frontend/login.tsx +40 -0
- package/src/modules/auth/lib/grantChecks.ts +116 -2
- package/src/modules/business_rules/lib/expression-evaluator.ts +9 -13
- package/src/modules/business_rules/lib/rule-evaluator.ts +3 -3
- package/src/modules/customers/ai-agents.ts +10 -1
- package/src/modules/customers/ai-tools/settings-pack.ts +62 -1
- package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +18 -7
- package/src/modules/customers/lib/personCompanyLinkTable.ts +33 -41
- package/src/modules/customers/migrations/Migration20260521175027.ts +39 -0
- package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +38 -12
- package/src/modules/data_sync/api/options.ts +2 -0
- package/src/modules/data_sync/api/run.ts +80 -61
- package/src/modules/data_sync/backend/data-sync/page.tsx +10 -0
- package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +17 -3
- package/src/modules/data_sync/i18n/de.json +3 -0
- package/src/modules/data_sync/i18n/en.json +3 -0
- package/src/modules/data_sync/i18n/es.json +3 -0
- package/src/modules/data_sync/i18n/pl.json +3 -0
- package/src/modules/data_sync/lib/adapter.ts +9 -0
- package/src/modules/integrations/backend/integrations/[id]/page.tsx +15 -13
- package/src/modules/integrations/data/validators.ts +5 -4
- package/src/modules/messages/api/route.ts +2 -2
- package/src/modules/messages/commands/messages.ts +54 -2
- package/src/modules/messages/components/MessagesInboxPageClient.tsx +80 -92
- package/src/modules/messages/components/defaults/DefaultMessageListItem.tsx +9 -5
- package/src/modules/messages/components/inboxFilters.ts +162 -0
- package/src/modules/messages/components/messageListLabels.ts +29 -0
- package/src/modules/messages/components/useMessagesInboxBulkActions.ts +58 -26
- package/src/modules/messages/data/validators.ts +7 -5
- package/src/modules/messages/i18n/de.json +7 -4
- package/src/modules/messages/i18n/en.json +7 -4
- package/src/modules/messages/i18n/es.json +7 -4
- package/src/modules/messages/i18n/pl.json +7 -4
- package/src/modules/progress/api/jobs/route.ts +30 -20
- package/src/modules/query_index/di.ts +9 -7
- package/src/modules/query_index/lib/indexer.ts +13 -11
- package/src/modules/query_index/subscribers/upsert_one.ts +9 -1
- package/src/modules/sales/api/order-adjustments/route.ts +2 -1
- package/src/modules/sales/api/quote-adjustments/route.ts +2 -1
- 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/sales/lib/makeSalesLineRoute.ts +2 -1
- package/src/modules/staff/i18n/de.json +1 -0
- package/src/modules/staff/i18n/en.json +1 -0
- package/src/modules/staff/i18n/es.json +1 -0
- package/src/modules/staff/i18n/pl.json +1 -0
- package/src/modules/sync_excel/api/import/route.ts +141 -131
- package/src/modules/sync_excel/lib/adapters/customers.ts +1 -0
- package/src/modules/translations/components/TranslationManager.tsx +9 -4
- package/src/modules/workflows/lib/activity-executor.ts +4 -3
- package/src/modules/workflows/lib/event-trigger-service.ts +24 -11
- 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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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:
|
|
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 =
|
|
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
|
|
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: () =>
|
|
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(() => {
|
|
@@ -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
|
}
|