@open-mercato/core 0.4.11-develop.1362.574a071900 → 0.4.11-develop.1366.20ce92f196
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/dist/modules/auth/api/admin/nav.js +83 -251
- package/dist/modules/auth/api/admin/nav.js.map +2 -2
- package/dist/modules/auth/api/login.js +42 -11
- package/dist/modules/auth/api/login.js.map +3 -3
- package/dist/modules/auth/lib/backendChrome.js +256 -0
- package/dist/modules/auth/lib/backendChrome.js.map +7 -0
- package/package.json +3 -3
- package/src/modules/auth/api/admin/nav.ts +112 -319
- package/src/modules/auth/api/login.ts +57 -11
- package/src/modules/auth/lib/backendChrome.tsx +359 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import type { FilterQuery } from '@mikro-orm/core'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import type { AwilixContainer } from 'awilix'
|
|
5
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
6
|
+
import type { Module } from '@open-mercato/shared/modules/registry'
|
|
7
|
+
import type {
|
|
8
|
+
BackendChromePayload,
|
|
9
|
+
BackendChromeNavGroup,
|
|
10
|
+
BackendChromeNavItem,
|
|
11
|
+
BackendChromeSectionGroup,
|
|
12
|
+
BackendChromeSectionItem,
|
|
13
|
+
} from '@open-mercato/shared/modules/navigation/backendChrome'
|
|
14
|
+
import {
|
|
15
|
+
buildAdminNav,
|
|
16
|
+
buildSettingsSections,
|
|
17
|
+
computeSettingsPathPrefixes,
|
|
18
|
+
convertToSectionNavGroups,
|
|
19
|
+
type AdminNavItem,
|
|
20
|
+
} from '@open-mercato/ui/backend/utils/nav'
|
|
21
|
+
import { profilePathPrefixes, profileSections } from './profile-sections'
|
|
22
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
23
|
+
import { resolveFeatureCheckContext } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
24
|
+
import { CustomEntity } from '@open-mercato/core/modules/entities/data/entities'
|
|
25
|
+
import { Role } from '@open-mercato/core/modules/auth/data/entities'
|
|
26
|
+
import {
|
|
27
|
+
applySidebarPreference,
|
|
28
|
+
loadFirstRoleSidebarPreference,
|
|
29
|
+
loadSidebarPreference,
|
|
30
|
+
} from '@open-mercato/core/modules/auth/services/sidebarPreferencesService'
|
|
31
|
+
import type { SidebarPreferencesSettings } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
|
|
32
|
+
|
|
33
|
+
type TranslationFn = (key: string | undefined, fallback: string) => string
|
|
34
|
+
|
|
35
|
+
type RouteModule = Pick<Module, 'id' | 'backendRoutes'>
|
|
36
|
+
|
|
37
|
+
type SerializableSectionItem = {
|
|
38
|
+
id: string
|
|
39
|
+
label: string
|
|
40
|
+
labelKey?: string
|
|
41
|
+
href: string
|
|
42
|
+
icon?: React.ReactNode
|
|
43
|
+
order?: number
|
|
44
|
+
children?: SerializableSectionItem[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type SerializableSectionGroup = {
|
|
48
|
+
id: string
|
|
49
|
+
label: string
|
|
50
|
+
labelKey?: string
|
|
51
|
+
order?: number
|
|
52
|
+
items: SerializableSectionItem[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type ResolvedNavItem = Omit<BackendChromeNavItem, 'defaultTitle' | 'children'> & {
|
|
56
|
+
defaultTitle: string
|
|
57
|
+
children?: ResolvedNavItem[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type ResolveBackendChromePayloadArgs = {
|
|
61
|
+
auth: Exclude<AuthContext, null>
|
|
62
|
+
locale: string
|
|
63
|
+
modules: RouteModule[]
|
|
64
|
+
translate: TranslationFn
|
|
65
|
+
selectedOrganizationId?: string | null
|
|
66
|
+
selectedTenantId?: string | null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const settingsSectionOrder: Record<string, number> = {
|
|
70
|
+
system: 1,
|
|
71
|
+
auth: 2,
|
|
72
|
+
'customer-portal': 3,
|
|
73
|
+
'data-designer': 4,
|
|
74
|
+
'module-configs': 5,
|
|
75
|
+
directory: 6,
|
|
76
|
+
'feature-toggles': 7,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type NavGroupWithWeight = Omit<BackendChromeNavGroup, 'id' | 'defaultName' | 'items'> & {
|
|
80
|
+
id: string
|
|
81
|
+
defaultName: string
|
|
82
|
+
items: ResolvedNavItem[]
|
|
83
|
+
weight: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let renderToStaticMarkupPromise: Promise<typeof import('react-dom/server')> | null = null
|
|
87
|
+
|
|
88
|
+
async function serializeIconMarkup(icon: React.ReactNode | undefined): Promise<string | undefined> {
|
|
89
|
+
if (!icon) return undefined
|
|
90
|
+
if (!renderToStaticMarkupPromise) {
|
|
91
|
+
renderToStaticMarkupPromise = import('react-dom/server')
|
|
92
|
+
}
|
|
93
|
+
const { renderToStaticMarkup } = await renderToStaticMarkupPromise
|
|
94
|
+
const markup = renderToStaticMarkup(<>{icon}</>)
|
|
95
|
+
return markup.trim().length > 0 ? markup : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function serializeNavItem(item: AdminNavItem): Promise<ResolvedNavItem> {
|
|
99
|
+
return {
|
|
100
|
+
id: item.href,
|
|
101
|
+
href: item.href,
|
|
102
|
+
title: item.title,
|
|
103
|
+
defaultTitle: item.defaultTitle,
|
|
104
|
+
enabled: item.enabled,
|
|
105
|
+
hidden: item.hidden,
|
|
106
|
+
pageContext: item.pageContext,
|
|
107
|
+
iconMarkup: await serializeIconMarkup(item.icon),
|
|
108
|
+
children: item.children ? await Promise.all(item.children.map((child) => serializeNavItem(child))) : undefined,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeGroupWeights(groups: NavGroupWithWeight[]): NavGroupWithWeight[] {
|
|
113
|
+
const defaultGroupOrder = [
|
|
114
|
+
'customers.nav.group',
|
|
115
|
+
'catalog.nav.group',
|
|
116
|
+
'customers~sales.nav.group',
|
|
117
|
+
'resources.nav.group',
|
|
118
|
+
'staff.nav.group',
|
|
119
|
+
'entities.nav.group',
|
|
120
|
+
'directory.nav.group',
|
|
121
|
+
'customers.storage.nav.group',
|
|
122
|
+
]
|
|
123
|
+
const groupOrderIndex = new Map(defaultGroupOrder.map((id, index) => [id, index]))
|
|
124
|
+
groups.sort((a, b) => {
|
|
125
|
+
const aIndex = groupOrderIndex.get(a.id)
|
|
126
|
+
const bIndex = groupOrderIndex.get(b.id)
|
|
127
|
+
if (aIndex !== undefined || bIndex !== undefined) {
|
|
128
|
+
if (aIndex === undefined) return 1
|
|
129
|
+
if (bIndex === undefined) return -1
|
|
130
|
+
if (aIndex !== bIndex) return aIndex - bIndex
|
|
131
|
+
}
|
|
132
|
+
if (a.weight !== b.weight) return a.weight - b.weight
|
|
133
|
+
return a.name.localeCompare(b.name)
|
|
134
|
+
})
|
|
135
|
+
const defaultGroupCount = defaultGroupOrder.length
|
|
136
|
+
groups.forEach((group, index) => {
|
|
137
|
+
const rank = groupOrderIndex.get(group.id)
|
|
138
|
+
const fallbackWeight = typeof group.weight === 'number' ? group.weight : 10_000
|
|
139
|
+
group.weight =
|
|
140
|
+
(rank !== undefined ? rank : defaultGroupCount + index) * 1_000_000 +
|
|
141
|
+
Math.min(Math.max(fallbackWeight, 0), 999_999)
|
|
142
|
+
})
|
|
143
|
+
return groups
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function groupEntries(entries: AdminNavItem[]): Promise<NavGroupWithWeight[]> {
|
|
147
|
+
const groupMap = new Map<string, NavGroupWithWeight>()
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
const weight = entry.priority ?? entry.order ?? 10_000
|
|
150
|
+
const serializedItem = await serializeNavItem(entry)
|
|
151
|
+
const existing = groupMap.get(entry.groupId)
|
|
152
|
+
if (existing) {
|
|
153
|
+
existing.items.push(serializedItem)
|
|
154
|
+
if (weight < existing.weight) existing.weight = weight
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
groupMap.set(entry.groupId, {
|
|
158
|
+
id: entry.groupId,
|
|
159
|
+
name: entry.group,
|
|
160
|
+
defaultName: entry.groupDefaultName,
|
|
161
|
+
items: [serializedItem],
|
|
162
|
+
weight,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
return normalizeGroupWeights(Array.from(groupMap.values()))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function adoptSidebarDefaults(groups: NavGroupWithWeight[]): NavGroupWithWeight[] {
|
|
169
|
+
const adoptItems = (items: ResolvedNavItem[]): ResolvedNavItem[] =>
|
|
170
|
+
items.map((item) => ({
|
|
171
|
+
...item,
|
|
172
|
+
defaultTitle: item.title,
|
|
173
|
+
children: item.children ? adoptItems(item.children) : undefined,
|
|
174
|
+
}))
|
|
175
|
+
|
|
176
|
+
return groups.map((group) => ({
|
|
177
|
+
...group,
|
|
178
|
+
defaultName: group.name,
|
|
179
|
+
items: adoptItems(group.items),
|
|
180
|
+
}))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function serializeSectionItem(item: {
|
|
184
|
+
id: string
|
|
185
|
+
label: string
|
|
186
|
+
labelKey?: string
|
|
187
|
+
href: string
|
|
188
|
+
icon?: React.ReactNode
|
|
189
|
+
order?: number
|
|
190
|
+
children?: SerializableSectionItem[]
|
|
191
|
+
}): Promise<BackendChromeSectionItem> {
|
|
192
|
+
return {
|
|
193
|
+
id: item.id,
|
|
194
|
+
label: item.label,
|
|
195
|
+
labelKey: item.labelKey,
|
|
196
|
+
href: item.href,
|
|
197
|
+
order: item.order,
|
|
198
|
+
iconMarkup: await serializeIconMarkup(item.icon),
|
|
199
|
+
children: item.children ? await Promise.all(item.children.map((child) => serializeSectionItem(child))) : undefined,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function serializeSectionGroups(groups: SerializableSectionGroup[]): Promise<BackendChromeSectionGroup[]> {
|
|
204
|
+
return Promise.all(groups.map(async (group) => ({
|
|
205
|
+
id: group.id,
|
|
206
|
+
label: group.label,
|
|
207
|
+
labelKey: group.labelKey,
|
|
208
|
+
order: group.order,
|
|
209
|
+
items: await Promise.all(group.items.map((item) => serializeSectionItem(item))),
|
|
210
|
+
})))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function loadScopedContainer(): Promise<AwilixContainer> {
|
|
214
|
+
return createRequestContainer()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function resolveBackendChromePayload({
|
|
218
|
+
auth,
|
|
219
|
+
locale,
|
|
220
|
+
modules,
|
|
221
|
+
translate,
|
|
222
|
+
selectedOrganizationId,
|
|
223
|
+
selectedTenantId,
|
|
224
|
+
}: ResolveBackendChromePayloadArgs): Promise<BackendChromePayload> {
|
|
225
|
+
const container = await loadScopedContainer()
|
|
226
|
+
const em = container.resolve('em') as EntityManager
|
|
227
|
+
const rbac = container.resolve('rbacService') as {
|
|
228
|
+
loadAcl: (userId: string, scope: { tenantId: string | null; organizationId: string | null }) => Promise<{
|
|
229
|
+
isSuperAdmin: boolean
|
|
230
|
+
features: string[]
|
|
231
|
+
}>
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let scopedOrganizationId: string | null = auth.orgId ?? null
|
|
235
|
+
let scopedTenantId: string | null = auth.tenantId ?? null
|
|
236
|
+
let allowNavigation = true
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const { organizationId, scope, allowedOrganizationIds } = await resolveFeatureCheckContext({
|
|
240
|
+
container,
|
|
241
|
+
auth,
|
|
242
|
+
selectedId: selectedOrganizationId,
|
|
243
|
+
tenantId: selectedTenantId,
|
|
244
|
+
})
|
|
245
|
+
scopedOrganizationId = organizationId
|
|
246
|
+
scopedTenantId = scope.tenantId ?? auth.tenantId ?? null
|
|
247
|
+
if (Array.isArray(allowedOrganizationIds) && allowedOrganizationIds.length === 0) {
|
|
248
|
+
allowNavigation = false
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
scopedOrganizationId = auth.orgId ?? null
|
|
252
|
+
scopedTenantId = auth.tenantId ?? null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const acl = allowNavigation
|
|
256
|
+
? await rbac.loadAcl(auth.sub, {
|
|
257
|
+
tenantId: scopedTenantId,
|
|
258
|
+
organizationId: scopedOrganizationId,
|
|
259
|
+
})
|
|
260
|
+
: { isSuperAdmin: false, features: [] }
|
|
261
|
+
|
|
262
|
+
const grantedFeatures = acl.isSuperAdmin ? ['*'] : acl.features
|
|
263
|
+
const featureChecker = async (): Promise<string[]> => grantedFeatures
|
|
264
|
+
|
|
265
|
+
let userEntities: Array<{ entityId: string; label: string; href: string }> = []
|
|
266
|
+
if (allowNavigation) {
|
|
267
|
+
try {
|
|
268
|
+
const where: FilterQuery<CustomEntity> = {
|
|
269
|
+
isActive: true,
|
|
270
|
+
showInSidebar: true,
|
|
271
|
+
}
|
|
272
|
+
where.$and = [
|
|
273
|
+
{ $or: [{ organizationId: scopedOrganizationId ?? undefined }, { organizationId: null }] },
|
|
274
|
+
{ $or: [{ tenantId: scopedTenantId ?? undefined }, { tenantId: null }] },
|
|
275
|
+
]
|
|
276
|
+
const entities = await em.find(CustomEntity, where, { orderBy: { label: 'asc' } })
|
|
277
|
+
userEntities = entities.map((entity) => ({
|
|
278
|
+
entityId: entity.entityId,
|
|
279
|
+
label: entity.label,
|
|
280
|
+
href: `/backend/entities/user/${encodeURIComponent(entity.entityId)}/records`,
|
|
281
|
+
}))
|
|
282
|
+
} catch {
|
|
283
|
+
userEntities = []
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const ctxAuth = {
|
|
288
|
+
roles: auth.roles || [],
|
|
289
|
+
sub: auth.sub,
|
|
290
|
+
tenantId: scopedTenantId,
|
|
291
|
+
orgId: scopedOrganizationId,
|
|
292
|
+
}
|
|
293
|
+
const entries = allowNavigation
|
|
294
|
+
? await buildAdminNav(
|
|
295
|
+
modules,
|
|
296
|
+
{ auth: ctxAuth },
|
|
297
|
+
userEntities,
|
|
298
|
+
translate,
|
|
299
|
+
{ checkFeatures: featureChecker },
|
|
300
|
+
)
|
|
301
|
+
: []
|
|
302
|
+
|
|
303
|
+
let rolePreference: SidebarPreferencesSettings | null = null
|
|
304
|
+
let userPreference: SidebarPreferencesSettings | null = null
|
|
305
|
+
|
|
306
|
+
if (Array.isArray(auth.roles) && auth.roles.length > 0) {
|
|
307
|
+
const roleScope: FilterQuery<Role> = scopedTenantId
|
|
308
|
+
? { $or: [{ tenantId: scopedTenantId }, { tenantId: null }] }
|
|
309
|
+
: { tenantId: null }
|
|
310
|
+
const roleRecords = await em.find(Role, {
|
|
311
|
+
name: { $in: auth.roles },
|
|
312
|
+
...roleScope,
|
|
313
|
+
})
|
|
314
|
+
const roleIds = Array.isArray(roleRecords) ? roleRecords.map((role) => role.id) : []
|
|
315
|
+
if (roleIds.length > 0) {
|
|
316
|
+
rolePreference = await loadFirstRoleSidebarPreference(em, {
|
|
317
|
+
roleIds,
|
|
318
|
+
tenantId: scopedTenantId,
|
|
319
|
+
locale,
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const effectiveUserId = auth.isApiKey ? auth.userId : auth.sub
|
|
325
|
+
if (effectiveUserId) {
|
|
326
|
+
userPreference = await loadSidebarPreference(em, {
|
|
327
|
+
userId: effectiveUserId,
|
|
328
|
+
tenantId: scopedTenantId,
|
|
329
|
+
organizationId: scopedOrganizationId,
|
|
330
|
+
locale,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const baseGroups = await groupEntries(entries)
|
|
335
|
+
const groupsWithRole = rolePreference
|
|
336
|
+
? applySidebarPreference<NavGroupWithWeight>(baseGroups, rolePreference)
|
|
337
|
+
: baseGroups
|
|
338
|
+
const baseForUser = adoptSidebarDefaults(groupsWithRole)
|
|
339
|
+
const appliedGroups = userPreference
|
|
340
|
+
? applySidebarPreference<NavGroupWithWeight>(baseForUser, userPreference)
|
|
341
|
+
: baseForUser
|
|
342
|
+
|
|
343
|
+
const settingsSections = await serializeSectionGroups(
|
|
344
|
+
convertToSectionNavGroups(
|
|
345
|
+
buildSettingsSections(entries, settingsSectionOrder),
|
|
346
|
+
translate,
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
groups: appliedGroups.map(({ weight: _weight, ...group }) => group),
|
|
352
|
+
settingsSections,
|
|
353
|
+
settingsPathPrefixes: computeSettingsPathPrefixes(buildSettingsSections(entries, settingsSectionOrder)),
|
|
354
|
+
profileSections: await serializeSectionGroups(profileSections),
|
|
355
|
+
profilePathPrefixes,
|
|
356
|
+
grantedFeatures,
|
|
357
|
+
roles: Array.isArray(auth.roles) ? auth.roles : [],
|
|
358
|
+
}
|
|
359
|
+
}
|