@open-mercato/core 0.4.11-develop.1365.0acff7b08e → 0.4.11-develop.1383.aeb2d4cdb5
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
|
@@ -1,386 +1,179 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
3
3
|
import { z } from 'zod'
|
|
4
|
-
import { getModules } from '@open-mercato/shared/lib/i18n/server'
|
|
4
|
+
import { getModules, resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
5
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
6
6
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { CustomEntity } from '@open-mercato/core/modules/entities/data/entities'
|
|
10
|
-
import { slugifySidebarId } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
|
|
11
|
-
import { applySidebarPreference, loadFirstRoleSidebarPreference, loadSidebarPreference } from '../../services/sidebarPreferencesService'
|
|
12
|
-
import { Role } from '../../data/entities'
|
|
7
|
+
import { resolveFeatureCheckContext } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
8
|
+
import { resolveBackendChromePayload } from '../../lib/backendChrome'
|
|
13
9
|
|
|
14
10
|
export const metadata = {
|
|
15
11
|
GET: { requireAuth: true },
|
|
16
12
|
}
|
|
17
13
|
|
|
18
|
-
const sidebarNavItemSchema: z.ZodType<{
|
|
14
|
+
const sidebarNavItemSchema: z.ZodType<{
|
|
15
|
+
id?: string
|
|
16
|
+
href: string
|
|
17
|
+
title: string
|
|
18
|
+
defaultTitle?: string
|
|
19
|
+
enabled?: boolean
|
|
20
|
+
hidden?: boolean
|
|
21
|
+
pageContext?: 'main' | 'admin' | 'settings' | 'profile'
|
|
22
|
+
iconMarkup?: string
|
|
23
|
+
children?: any[]
|
|
24
|
+
}> = z.lazy(() =>
|
|
19
25
|
z.object({
|
|
26
|
+
id: z.string().optional(),
|
|
20
27
|
href: z.string(),
|
|
21
28
|
title: z.string(),
|
|
22
|
-
defaultTitle: z.string(),
|
|
23
|
-
enabled: z.boolean(),
|
|
29
|
+
defaultTitle: z.string().optional(),
|
|
30
|
+
enabled: z.boolean().optional(),
|
|
24
31
|
hidden: z.boolean().optional(),
|
|
32
|
+
pageContext: z.enum(['main', 'admin', 'settings', 'profile']).optional(),
|
|
33
|
+
iconMarkup: z.string().optional(),
|
|
25
34
|
children: z.array(sidebarNavItemSchema).optional(),
|
|
26
|
-
})
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const sectionItemSchema: z.ZodType<{
|
|
39
|
+
id: string
|
|
40
|
+
label: string
|
|
41
|
+
labelKey?: string
|
|
42
|
+
href: string
|
|
43
|
+
order?: number
|
|
44
|
+
iconMarkup?: string
|
|
45
|
+
children?: any[]
|
|
46
|
+
}> = z.lazy(() =>
|
|
47
|
+
z.object({
|
|
48
|
+
id: z.string(),
|
|
49
|
+
label: z.string(),
|
|
50
|
+
labelKey: z.string().optional(),
|
|
51
|
+
href: z.string(),
|
|
52
|
+
order: z.number().optional(),
|
|
53
|
+
iconMarkup: z.string().optional(),
|
|
54
|
+
children: z.array(sectionItemSchema).optional(),
|
|
55
|
+
}),
|
|
27
56
|
)
|
|
28
57
|
|
|
58
|
+
const sectionGroupSchema = z.object({
|
|
59
|
+
id: z.string(),
|
|
60
|
+
label: z.string(),
|
|
61
|
+
labelKey: z.string().optional(),
|
|
62
|
+
order: z.number().optional(),
|
|
63
|
+
items: z.array(sectionItemSchema),
|
|
64
|
+
})
|
|
65
|
+
|
|
29
66
|
const adminNavResponseSchema = z.object({
|
|
30
67
|
groups: z.array(
|
|
31
68
|
z.object({
|
|
32
|
-
id: z.string(),
|
|
69
|
+
id: z.string().optional(),
|
|
33
70
|
name: z.string(),
|
|
34
|
-
defaultName: z.string(),
|
|
71
|
+
defaultName: z.string().optional(),
|
|
35
72
|
items: z.array(sidebarNavItemSchema),
|
|
36
|
-
})
|
|
73
|
+
}),
|
|
37
74
|
),
|
|
75
|
+
settingsSections: z.array(sectionGroupSchema),
|
|
76
|
+
settingsPathPrefixes: z.array(z.string()),
|
|
77
|
+
profileSections: z.array(sectionGroupSchema),
|
|
78
|
+
profilePathPrefixes: z.array(z.string()),
|
|
79
|
+
grantedFeatures: z.array(z.string()),
|
|
80
|
+
roles: z.array(z.string()),
|
|
38
81
|
})
|
|
39
82
|
|
|
40
83
|
const adminNavErrorSchema = z.object({
|
|
41
84
|
error: z.string(),
|
|
42
85
|
})
|
|
43
86
|
|
|
44
|
-
type SidebarItemNode = {
|
|
45
|
-
href: string
|
|
46
|
-
title: string
|
|
47
|
-
defaultTitle: string
|
|
48
|
-
enabled: boolean
|
|
49
|
-
hidden?: boolean
|
|
50
|
-
children?: SidebarItemNode[]
|
|
51
|
-
}
|
|
52
|
-
|
|
53
87
|
export async function GET(req: Request) {
|
|
54
88
|
const auth = await getAuthFromRequest(req)
|
|
55
89
|
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
56
90
|
|
|
57
91
|
const { translate, locale } = await resolveTranslations()
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// try {
|
|
67
|
-
// if (cache) {
|
|
68
|
-
// const cached = await cache.get(cacheKey)
|
|
69
|
-
// if (cached) return NextResponse.json(cached)
|
|
70
|
-
// }
|
|
71
|
-
// } catch {}
|
|
72
|
-
|
|
73
|
-
// Load ACL once; we'll evaluate features locally without multiple calls
|
|
74
|
-
const acl = await rbac.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
|
|
75
|
-
|
|
76
|
-
// Build nav entries from discovered backend routes
|
|
77
|
-
type Entry = {
|
|
78
|
-
groupId: string
|
|
79
|
-
groupName: string
|
|
80
|
-
groupKey?: string
|
|
81
|
-
title: string
|
|
82
|
-
titleKey?: string
|
|
83
|
-
href: string
|
|
84
|
-
enabled: boolean
|
|
85
|
-
order?: number
|
|
86
|
-
priority?: number
|
|
87
|
-
children?: Entry[]
|
|
88
|
-
}
|
|
89
|
-
const entries: Entry[] = []
|
|
90
|
-
|
|
91
|
-
function capitalize(s: string) { return s.charAt(0).toUpperCase() + s.slice(1) }
|
|
92
|
-
function deriveTitleFromPath(p: string) {
|
|
93
|
-
const seg = p.split('/').filter(Boolean).pop() || ''
|
|
94
|
-
return seg ? seg.split('-').map(capitalize).join(' ') : 'Home'
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const ctx = { auth: { roles: auth.roles || [], sub: auth.sub, tenantId: auth.tenantId, orgId: auth.orgId } }
|
|
98
|
-
const modules = getModules()
|
|
99
|
-
for (const m of (modules as any[])) {
|
|
100
|
-
const groupDefault = capitalize(m.id)
|
|
101
|
-
for (const r of (m.backendRoutes || [])) {
|
|
102
|
-
const href = (r.pattern ?? r.path ?? '') as string
|
|
103
|
-
if (!href || href.includes('[')) continue
|
|
104
|
-
if ((r as any).navHidden) continue
|
|
105
|
-
const title = (r.title as string) || deriveTitleFromPath(href)
|
|
106
|
-
const titleKey = (r as any).pageTitleKey ?? (r as any).titleKey
|
|
107
|
-
const groupName = (r.group as string) || groupDefault
|
|
108
|
-
const groupKey = (r as any).pageGroupKey ?? (r as any).groupKey
|
|
109
|
-
const groupId = typeof groupKey === 'string' && groupKey ? groupKey : slugifySidebarId(groupName)
|
|
110
|
-
const visible = r.visible ? await Promise.resolve(r.visible(ctx)) : true
|
|
111
|
-
if (!visible) continue
|
|
112
|
-
const enabled = r.enabled ? await Promise.resolve(r.enabled(ctx)) : true
|
|
113
|
-
const requiredRoles = (r.requireRoles as string[]) || []
|
|
114
|
-
if (requiredRoles.length) {
|
|
115
|
-
const roles = auth.roles || []
|
|
116
|
-
const ok = requiredRoles.some((role) => roles.includes(role))
|
|
117
|
-
if (!ok) continue
|
|
118
|
-
}
|
|
119
|
-
const features = (r as any).requireFeatures as string[] | undefined
|
|
120
|
-
if (!acl.isSuperAdmin && !hasAllFeatures(acl.features, features)) continue
|
|
121
|
-
const order = (r as any).order as number | undefined
|
|
122
|
-
const priority = ((r as any).priority as number | undefined) ?? order
|
|
123
|
-
entries.push({ groupId, groupName, groupKey, title, titleKey, href, enabled, order, priority })
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Parent-child relationships within the same group by href prefix
|
|
128
|
-
const roots: any[] = []
|
|
129
|
-
for (const e of entries) {
|
|
130
|
-
let parent: any | undefined
|
|
131
|
-
for (const p of entries) {
|
|
132
|
-
if (p === e) continue
|
|
133
|
-
if (p.groupId !== e.groupId) continue
|
|
134
|
-
if (!e.href.startsWith(p.href + '/')) continue
|
|
135
|
-
if (!parent || p.href.length > parent.href.length) parent = p
|
|
136
|
-
}
|
|
137
|
-
if (parent) {
|
|
138
|
-
;(parent as any).children = (parent as any).children || []
|
|
139
|
-
;(parent as any).children.push(e)
|
|
140
|
-
} else {
|
|
141
|
-
roots.push(e)
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Add dynamic user entities into Data designer > User Entities
|
|
146
|
-
const where: any = { isActive: true, showInSidebar: true }
|
|
147
|
-
where.$and = [
|
|
148
|
-
{ $or: [ { organizationId: auth.orgId ?? undefined as any }, { organizationId: null } ] },
|
|
149
|
-
{ $or: [ { tenantId: auth.tenantId ?? undefined as any }, { tenantId: null } ] },
|
|
150
|
-
]
|
|
92
|
+
const container = await createRequestContainer()
|
|
93
|
+
const cache = container.resolve('cache') as {
|
|
94
|
+
get?: (key: string) => Promise<unknown>
|
|
95
|
+
set?: (key: string, value: unknown, options?: { tags?: string[] }) => Promise<void>
|
|
96
|
+
} | null
|
|
97
|
+
|
|
98
|
+
let selectedOrganizationId: string | null | undefined
|
|
99
|
+
let selectedTenantId: string | null | undefined
|
|
151
100
|
try {
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const userEntitiesAnchor = entries.find((entry: Entry) => entry.href === '/backend/entities/user')
|
|
161
|
-
?? entries.find((entry: Entry) =>
|
|
162
|
-
entry.titleKey === 'entities.nav.userEntities' &&
|
|
163
|
-
typeof entry.groupKey === 'string' &&
|
|
164
|
-
userEntitiesLegacyGroupKeys.has(entry.groupKey),
|
|
165
|
-
)
|
|
166
|
-
if (userEntitiesAnchor) {
|
|
167
|
-
const existing = userEntitiesAnchor.children || []
|
|
168
|
-
const dynamic = items.map((it) => ({
|
|
169
|
-
groupId: userEntitiesAnchor.groupId,
|
|
170
|
-
groupName: userEntitiesAnchor.groupName,
|
|
171
|
-
groupKey: userEntitiesAnchor.groupKey,
|
|
172
|
-
title: it.label,
|
|
173
|
-
href: it.href,
|
|
174
|
-
enabled: true,
|
|
175
|
-
order: 1000,
|
|
176
|
-
priority: 1000,
|
|
177
|
-
}))
|
|
178
|
-
const byHref = new Map<string, Entry>()
|
|
179
|
-
for (const c of existing) if (!byHref.has(c.href)) byHref.set(c.href, c)
|
|
180
|
-
for (const c of dynamic) if (!byHref.has(c.href)) byHref.set(c.href, c)
|
|
181
|
-
userEntitiesAnchor.children = Array.from(byHref.values())
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
} catch (e) {
|
|
185
|
-
console.error('Error loading user entities', e)
|
|
101
|
+
const url = new URL(req.url)
|
|
102
|
+
const orgParam = url.searchParams.get('orgId')
|
|
103
|
+
const tenantParam = url.searchParams.get('tenantId')
|
|
104
|
+
selectedOrganizationId = orgParam === null ? undefined : orgParam || null
|
|
105
|
+
selectedTenantId = tenantParam === null ? undefined : tenantParam || null
|
|
106
|
+
} catch {
|
|
107
|
+
selectedOrganizationId = undefined
|
|
108
|
+
selectedTenantId = undefined
|
|
186
109
|
}
|
|
187
110
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
111
|
+
let cacheScopeTenantId = auth.tenantId ?? null
|
|
112
|
+
let cacheScopeOrganizationId = auth.orgId ?? null
|
|
113
|
+
try {
|
|
114
|
+
const { organizationId, scope } = await resolveFeatureCheckContext({
|
|
115
|
+
container,
|
|
116
|
+
auth,
|
|
117
|
+
selectedId: selectedOrganizationId,
|
|
118
|
+
tenantId: selectedTenantId,
|
|
119
|
+
request: req,
|
|
196
120
|
})
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
type GroupBucket = {
|
|
203
|
-
id: string
|
|
204
|
-
rawName: string
|
|
205
|
-
key?: string
|
|
206
|
-
weight: number
|
|
207
|
-
entries: Entry[]
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const groupBuckets = new Map<string, GroupBucket>()
|
|
211
|
-
for (const entry of roots) {
|
|
212
|
-
const weight = entry.priority ?? entry.order ?? 10_000
|
|
213
|
-
if (!groupBuckets.has(entry.groupId)) {
|
|
214
|
-
groupBuckets.set(entry.groupId, {
|
|
215
|
-
id: entry.groupId,
|
|
216
|
-
rawName: entry.groupName,
|
|
217
|
-
key: entry.groupKey as string | undefined,
|
|
218
|
-
weight,
|
|
219
|
-
entries: [entry],
|
|
220
|
-
})
|
|
221
|
-
} else {
|
|
222
|
-
const bucket = groupBuckets.get(entry.groupId)!
|
|
223
|
-
bucket.entries.push(entry)
|
|
224
|
-
if (weight < bucket.weight) bucket.weight = weight
|
|
225
|
-
if (!bucket.key && entry.groupKey) bucket.key = entry.groupKey as string
|
|
226
|
-
if (!bucket.rawName && entry.groupName) bucket.rawName = entry.groupName
|
|
227
|
-
}
|
|
121
|
+
cacheScopeOrganizationId = organizationId
|
|
122
|
+
cacheScopeTenantId = scope.tenantId ?? auth.tenantId ?? null
|
|
123
|
+
} catch {
|
|
124
|
+
cacheScopeOrganizationId = auth.orgId ?? null
|
|
125
|
+
cacheScopeTenantId = auth.tenantId ?? null
|
|
228
126
|
}
|
|
229
127
|
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
defaultTitle,
|
|
236
|
-
enabled: entry.enabled,
|
|
237
|
-
children: entry.children?.map((child) => toItem(child)),
|
|
128
|
+
const cacheKey = `nav:sidebar:${locale}:${auth.sub}:${cacheScopeTenantId || 'null'}:${cacheScopeOrganizationId || 'null'}`
|
|
129
|
+
try {
|
|
130
|
+
if (cache?.get) {
|
|
131
|
+
const cached = await cache.get(cacheKey)
|
|
132
|
+
if (cached) return NextResponse.json(cached)
|
|
238
133
|
}
|
|
134
|
+
} catch {
|
|
135
|
+
// ignore cache read failures
|
|
239
136
|
}
|
|
240
137
|
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
weight: bucket.weight,
|
|
249
|
-
items: bucket.entries.map((entry) => toItem(entry)),
|
|
250
|
-
}
|
|
251
|
-
})
|
|
252
|
-
const defaultGroupOrder = [
|
|
253
|
-
'customers.nav.group',
|
|
254
|
-
'catalog.nav.group',
|
|
255
|
-
'customers~sales.nav.group',
|
|
256
|
-
'resources.nav.group',
|
|
257
|
-
'staff.nav.group',
|
|
258
|
-
'entities.nav.group',
|
|
259
|
-
'directory.nav.group',
|
|
260
|
-
'customers.storage.nav.group',
|
|
261
|
-
]
|
|
262
|
-
const groupOrderIndex = new Map(defaultGroupOrder.map((id, index) => [id, index]))
|
|
263
|
-
groups.sort((a, b) => {
|
|
264
|
-
const aIndex = groupOrderIndex.get(a.id)
|
|
265
|
-
const bIndex = groupOrderIndex.get(b.id)
|
|
266
|
-
if (aIndex !== undefined || bIndex !== undefined) {
|
|
267
|
-
if (aIndex === undefined) return 1
|
|
268
|
-
if (bIndex === undefined) return -1
|
|
269
|
-
if (aIndex !== bIndex) return aIndex - bIndex
|
|
270
|
-
}
|
|
271
|
-
if (a.weight !== b.weight) return a.weight - b.weight
|
|
272
|
-
return a.name.localeCompare(b.name)
|
|
273
|
-
})
|
|
274
|
-
const defaultGroupCount = defaultGroupOrder.length
|
|
275
|
-
groups.forEach((group, index) => {
|
|
276
|
-
const rank = groupOrderIndex.get(group.id)
|
|
277
|
-
const fallbackWeight = typeof group.weight === 'number' ? group.weight : 10_000
|
|
278
|
-
const normalized =
|
|
279
|
-
(rank !== undefined ? rank : defaultGroupCount + index) * 1_000_000 +
|
|
280
|
-
Math.min(Math.max(fallbackWeight, 0), 999_999)
|
|
281
|
-
group.weight = normalized
|
|
138
|
+
const payload = await resolveBackendChromePayload({
|
|
139
|
+
auth,
|
|
140
|
+
locale,
|
|
141
|
+
modules: getModules(),
|
|
142
|
+
translate: (key, fallback) => (key ? translate(key, fallback) : fallback),
|
|
143
|
+
selectedOrganizationId,
|
|
144
|
+
selectedTenantId,
|
|
282
145
|
})
|
|
283
146
|
|
|
284
|
-
let rolePreference = null
|
|
285
|
-
if (Array.isArray(auth.roles) && auth.roles.length) {
|
|
286
|
-
const roleScope = auth.tenantId
|
|
287
|
-
? { $or: [{ tenantId: auth.tenantId }, { tenantId: null }] }
|
|
288
|
-
: { tenantId: null }
|
|
289
|
-
const roleRecords = await em.find(Role, {
|
|
290
|
-
name: { $in: auth.roles },
|
|
291
|
-
...roleScope,
|
|
292
|
-
} as any)
|
|
293
|
-
const roleIds = roleRecords.map((role: Role) => role.id)
|
|
294
|
-
if (roleIds.length) {
|
|
295
|
-
rolePreference = await loadFirstRoleSidebarPreference(em, {
|
|
296
|
-
roleIds,
|
|
297
|
-
tenantId: auth.tenantId ?? null,
|
|
298
|
-
locale,
|
|
299
|
-
})
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const groupsWithRole = rolePreference ? applySidebarPreference(groups, rolePreference) : groups
|
|
304
|
-
const baseForUser = adoptSidebarDefaults(groupsWithRole)
|
|
305
|
-
|
|
306
|
-
// For API key auth, use userId (the actual user) if available; otherwise skip user preferences
|
|
307
|
-
const effectiveUserId = auth.isApiKey ? auth.userId : auth.sub
|
|
308
|
-
const preference = effectiveUserId
|
|
309
|
-
? await loadSidebarPreference(em, {
|
|
310
|
-
userId: effectiveUserId,
|
|
311
|
-
tenantId: auth.tenantId ?? null,
|
|
312
|
-
organizationId: auth.orgId ?? null,
|
|
313
|
-
locale,
|
|
314
|
-
})
|
|
315
|
-
: null
|
|
316
|
-
|
|
317
|
-
const withPreference = applySidebarPreference(baseForUser, preference)
|
|
318
|
-
|
|
319
|
-
const payload = {
|
|
320
|
-
groups: withPreference.map((group) => ({
|
|
321
|
-
id: group.id,
|
|
322
|
-
name: group.name,
|
|
323
|
-
defaultName: group.defaultName,
|
|
324
|
-
items: (group.items as SidebarItemNode[]).map((item) => ({
|
|
325
|
-
href: item.href,
|
|
326
|
-
title: item.title,
|
|
327
|
-
defaultTitle: item.defaultTitle,
|
|
328
|
-
enabled: item.enabled,
|
|
329
|
-
hidden: item.hidden,
|
|
330
|
-
children: item.children?.map((child) => ({
|
|
331
|
-
href: child.href,
|
|
332
|
-
title: child.title,
|
|
333
|
-
defaultTitle: child.defaultTitle,
|
|
334
|
-
enabled: child.enabled,
|
|
335
|
-
hidden: child.hidden,
|
|
336
|
-
})),
|
|
337
|
-
})),
|
|
338
|
-
})),
|
|
339
|
-
}
|
|
340
|
-
|
|
341
147
|
try {
|
|
342
|
-
if (cache) {
|
|
148
|
+
if (cache?.set) {
|
|
343
149
|
const tags = [
|
|
344
150
|
`rbac:user:${auth.sub}`,
|
|
345
|
-
|
|
346
|
-
`nav:entities:${
|
|
151
|
+
cacheScopeTenantId ? `rbac:tenant:${cacheScopeTenantId}` : undefined,
|
|
152
|
+
`nav:entities:${cacheScopeTenantId || 'null'}`,
|
|
347
153
|
`nav:locale:${locale}`,
|
|
348
154
|
`nav:sidebar:user:${auth.sub}`,
|
|
349
|
-
`nav:sidebar:scope:${auth.sub}:${
|
|
350
|
-
...(Array.isArray(auth.roles) ? auth.roles.map((role
|
|
155
|
+
`nav:sidebar:scope:${auth.sub}:${cacheScopeTenantId || 'null'}:${cacheScopeOrganizationId || 'null'}:${locale}`,
|
|
156
|
+
...((Array.isArray(auth.roles) ? auth.roles : []).map((role) => `nav:sidebar:role:${role}`)),
|
|
351
157
|
].filter(Boolean) as string[]
|
|
352
158
|
await cache.set(cacheKey, payload, { tags })
|
|
353
159
|
}
|
|
354
|
-
} catch {
|
|
160
|
+
} catch {
|
|
161
|
+
// ignore cache write failures
|
|
162
|
+
}
|
|
355
163
|
|
|
356
164
|
return NextResponse.json(payload)
|
|
357
165
|
}
|
|
358
166
|
|
|
359
|
-
function adoptSidebarDefaults(groups: ReturnType<typeof applySidebarPreference>) {
|
|
360
|
-
const adoptItems = <T extends { title: string; defaultTitle?: string; children?: T[] }>(items: T[]): T[] =>
|
|
361
|
-
items.map((item) => ({
|
|
362
|
-
...item,
|
|
363
|
-
defaultTitle: item.title,
|
|
364
|
-
children: item.children ? adoptItems(item.children) : undefined,
|
|
365
|
-
}))
|
|
366
|
-
|
|
367
|
-
return groups.map((group) => ({
|
|
368
|
-
...group,
|
|
369
|
-
defaultName: group.name,
|
|
370
|
-
items: adoptItems(group.items),
|
|
371
|
-
}))
|
|
372
|
-
}
|
|
373
|
-
|
|
374
167
|
export const openApi: OpenApiRouteDoc = {
|
|
375
168
|
tag: 'Authentication & Accounts',
|
|
376
169
|
summary: 'Admin sidebar navigation',
|
|
377
170
|
methods: {
|
|
378
171
|
GET: {
|
|
379
|
-
summary: 'Resolve
|
|
172
|
+
summary: 'Resolve backend chrome bootstrap payload',
|
|
380
173
|
description:
|
|
381
|
-
'Returns the backend
|
|
174
|
+
'Returns the backend chrome payload available to the authenticated administrator after applying scope, RBAC, role defaults, and personal sidebar preferences.',
|
|
382
175
|
responses: [
|
|
383
|
-
{ status: 200, description: '
|
|
176
|
+
{ status: 200, description: 'Backend chrome payload', schema: adminNavResponseSchema },
|
|
384
177
|
{ status: 401, description: 'Unauthorized', schema: adminNavErrorSchema },
|
|
385
178
|
],
|
|
386
179
|
},
|
|
@@ -25,15 +25,62 @@ export const metadata = {}
|
|
|
25
25
|
|
|
26
26
|
// validation comes from userLoginSchema
|
|
27
27
|
|
|
28
|
+
type ParsedLoginForm = {
|
|
29
|
+
email: string
|
|
30
|
+
password: string
|
|
31
|
+
remember: boolean
|
|
32
|
+
tenantIdRaw: string
|
|
33
|
+
requiredRoles: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseRequiredRoles(rawValue: string): string[] {
|
|
37
|
+
return rawValue
|
|
38
|
+
.split(',')
|
|
39
|
+
.map((value) => value.trim())
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function parseLoginForm(req: Request): Promise<ParsedLoginForm> {
|
|
44
|
+
const rawContentType = req.headers.get('content-type') ?? ''
|
|
45
|
+
const contentType = rawContentType.split(';')[0].trim().toLowerCase()
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
if (contentType === 'application/x-www-form-urlencoded') {
|
|
49
|
+
const body = await req.text()
|
|
50
|
+
const params = new URLSearchParams(body)
|
|
51
|
+
const requireRoleRaw = String(params.get('requireRole') ?? params.get('role') ?? '').trim()
|
|
52
|
+
return {
|
|
53
|
+
email: String(params.get('email') ?? ''),
|
|
54
|
+
password: String(params.get('password') ?? ''),
|
|
55
|
+
remember: parseBooleanToken(params.get('remember')) === true,
|
|
56
|
+
tenantIdRaw: String(params.get('tenantId') ?? params.get('tenant') ?? '').trim(),
|
|
57
|
+
requiredRoles: requireRoleRaw ? parseRequiredRoles(requireRoleRaw) : [],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const form = await req.formData()
|
|
62
|
+
const requireRoleRaw = String(form.get('requireRole') ?? form.get('role') ?? '').trim()
|
|
63
|
+
return {
|
|
64
|
+
email: String(form.get('email') ?? ''),
|
|
65
|
+
password: String(form.get('password') ?? ''),
|
|
66
|
+
remember: parseBooleanToken(form.get('remember')?.toString()) === true,
|
|
67
|
+
tenantIdRaw: String(form.get('tenantId') ?? form.get('tenant') ?? '').trim(),
|
|
68
|
+
requiredRoles: requireRoleRaw ? parseRequiredRoles(requireRoleRaw) : [],
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
return {
|
|
72
|
+
email: '',
|
|
73
|
+
password: '',
|
|
74
|
+
remember: false,
|
|
75
|
+
tenantIdRaw: '',
|
|
76
|
+
requiredRoles: [],
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
28
81
|
export async function POST(req: Request) {
|
|
29
82
|
const { translate } = await resolveTranslations()
|
|
30
|
-
const
|
|
31
|
-
const email = String(form.get('email') ?? '')
|
|
32
|
-
const password = String(form.get('password') ?? '')
|
|
33
|
-
const remember = parseBooleanToken(form.get('remember')?.toString()) === true
|
|
34
|
-
const tenantIdRaw = String(form.get('tenantId') ?? form.get('tenant') ?? '').trim()
|
|
35
|
-
const requireRoleRaw = (String(form.get('requireRole') ?? form.get('role') ?? '')).trim()
|
|
36
|
-
const requiredRoles = requireRoleRaw ? requireRoleRaw.split(',').map((s) => s.trim()).filter(Boolean) : []
|
|
83
|
+
const { email, password, remember, tenantIdRaw, requiredRoles } = await parseLoginForm(req)
|
|
37
84
|
// Rate limit — two layers, both checked before validation and DB work
|
|
38
85
|
const { error: rateLimitError, compoundKey: rateLimitCompoundKey } = await checkAuthRateLimit({
|
|
39
86
|
req, ipConfig: loginIpRateLimitConfig, compoundConfig: loginRateLimitConfig, compoundIdentifier: email,
|
|
@@ -103,14 +150,14 @@ export async function POST(req: Request) {
|
|
|
103
150
|
roles: userRoleNames
|
|
104
151
|
})
|
|
105
152
|
void emitAuthEvent('auth.login.success', { id: String(user.id), email: user.email, tenantId: resolvedTenantId, organizationId: user.organizationId ? String(user.organizationId) : null }).catch(() => undefined)
|
|
153
|
+
const rememberMeDays = Number(process.env.REMEMBER_ME_DAYS || '30')
|
|
106
154
|
const responseData: { ok: true; token: string; redirect: string; refreshToken?: string } = {
|
|
107
155
|
ok: true,
|
|
108
156
|
token,
|
|
109
157
|
redirect: '/backend',
|
|
110
158
|
}
|
|
111
159
|
if (remember) {
|
|
112
|
-
const
|
|
113
|
-
const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
|
|
160
|
+
const expiresAt = new Date(Date.now() + rememberMeDays * 24 * 60 * 60 * 1000)
|
|
114
161
|
const sess = await auth.createSession(user, expiresAt)
|
|
115
162
|
responseData.refreshToken = sess.token
|
|
116
163
|
}
|
|
@@ -154,8 +201,7 @@ export async function POST(req: Request) {
|
|
|
154
201
|
const res = NextResponse.json(interceptedBody, { status: interceptedResponse.statusCode })
|
|
155
202
|
res.cookies.set('auth_token', authTokenForCookie, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 8 })
|
|
156
203
|
if (remember && refreshTokenForCookie) {
|
|
157
|
-
const
|
|
158
|
-
const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
|
|
204
|
+
const expiresAt = new Date(Date.now() + rememberMeDays * 24 * 60 * 60 * 1000)
|
|
159
205
|
res.cookies.set('session_token', refreshTokenForCookie, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', expires: expiresAt })
|
|
160
206
|
}
|
|
161
207
|
return res
|