@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.
@@ -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 { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
8
- import { hasAllFeatures } from '@open-mercato/shared/security/features'
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<{ href: string; title: string; defaultTitle: string; enabled: boolean; hidden?: boolean; children?: any[] }> = z.lazy(() =>
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 { resolve } = await createRequestContainer()
60
- const em = resolve('em') as any
61
- const rbac = resolve('rbacService') as any
62
- const cache = resolve('cache') as any
63
-
64
- // Cache key is user + tenant + organization scoped
65
- const cacheKey = `nav:sidebar:${locale}:${auth.sub}:${auth.tenantId || 'null'}:${auth.orgId || 'null'}`
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 entities = await em.find(CustomEntity as any, where as any, { orderBy: { label: 'asc' } as any })
153
- const items = (entities as any[]).map((e) => ({
154
- entityId: e.entityId,
155
- label: e.label,
156
- href: `/backend/entities/user/${encodeURIComponent(e.entityId)}/records`
157
- }))
158
- if (items.length) {
159
- const userEntitiesLegacyGroupKeys = new Set(['settings.sections.dataDesigner', 'entities.nav.group'])
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
- // Sort roots and children
189
- const sortItems = (arr: any[]) => {
190
- arr.sort((a, b) => {
191
- if (a.group !== b.group) return a.group.localeCompare(b.group)
192
- const ap = a.priority ?? a.order ?? 10000
193
- const bp = b.priority ?? b.order ?? 10000
194
- if (ap !== bp) return ap - bp
195
- return String(a.title).localeCompare(String(b.title))
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
- for (const it of arr) if (it.children?.length) sortItems(it.children)
198
- }
199
- sortItems(roots)
200
-
201
- // Group into sidebar groups
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 toItem = (entry: Entry): SidebarItemNode => {
231
- const defaultTitle = entry.titleKey ? translate(entry.titleKey, entry.title) : entry.title
232
- return {
233
- href: entry.href,
234
- title: defaultTitle,
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 groups = Array.from(groupBuckets.values()).map((bucket) => {
242
- const defaultName = bucket.key ? translate(bucket.key, bucket.rawName) : bucket.rawName
243
- return {
244
- id: bucket.id,
245
- key: bucket.key,
246
- name: defaultName,
247
- defaultName,
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
- auth.tenantId ? `rbac:tenant:${auth.tenantId}` : undefined,
346
- `nav:entities:${auth.tenantId || 'null'}`,
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}:${auth.tenantId || 'null'}:${auth.orgId || 'null'}:${locale}`,
350
- ...(Array.isArray(auth.roles) ? auth.roles.map((role: string) => `nav:sidebar:role:${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 sidebar entries',
172
+ summary: 'Resolve backend chrome bootstrap payload',
380
173
  description:
381
- 'Returns the backend navigation tree available to the authenticated administrator after applying role and personal sidebar preferences.',
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: 'Sidebar navigation structure', schema: adminNavResponseSchema },
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 form = await req.formData()
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 days = Number(process.env.REMEMBER_ME_DAYS || '30')
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 days = Number(process.env.REMEMBER_ME_DAYS || '30')
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