@open-mercato/core 0.5.1-develop.2975.ccbadc8198 → 0.5.1-develop.2996.ce62fd491c

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.
Files changed (82) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/sidebar_variant/index.js +25 -0
  3. package/dist/generated/entities/sidebar_variant/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +1 -0
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +13 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/helpers/integration/authUi.js +1 -1
  9. package/dist/helpers/integration/authUi.js.map +2 -2
  10. package/dist/modules/audit_logs/services/actionLogService.js +4 -5
  11. package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
  12. package/dist/modules/auth/api/sidebar/preferences/route.js +224 -35
  13. package/dist/modules/auth/api/sidebar/preferences/route.js.map +3 -3
  14. package/dist/modules/auth/api/sidebar/variants/[id]/route.js +161 -0
  15. package/dist/modules/auth/api/sidebar/variants/[id]/route.js.map +7 -0
  16. package/dist/modules/auth/api/sidebar/variants/route.js +142 -0
  17. package/dist/modules/auth/api/sidebar/variants/route.js.map +7 -0
  18. package/dist/modules/auth/backend/sidebar-customization/page.js +16 -0
  19. package/dist/modules/auth/backend/sidebar-customization/page.js.map +7 -0
  20. package/dist/modules/auth/backend/sidebar-customization/page.meta.js +28 -0
  21. package/dist/modules/auth/backend/sidebar-customization/page.meta.js.map +7 -0
  22. package/dist/modules/auth/data/entities.js +45 -4
  23. package/dist/modules/auth/data/entities.js.map +2 -2
  24. package/dist/modules/auth/data/validators.js +63 -1
  25. package/dist/modules/auth/data/validators.js.map +2 -2
  26. package/dist/modules/auth/migrations/Migration20260427081815.js +15 -0
  27. package/dist/modules/auth/migrations/Migration20260427081815.js.map +7 -0
  28. package/dist/modules/auth/migrations/Migration20260427124900.js +15 -0
  29. package/dist/modules/auth/migrations/Migration20260427124900.js.map +7 -0
  30. package/dist/modules/auth/migrations/Migration20260427143311.js +72 -0
  31. package/dist/modules/auth/migrations/Migration20260427143311.js.map +7 -0
  32. package/dist/modules/auth/services/sidebarPreferencesService.js +176 -16
  33. package/dist/modules/auth/services/sidebarPreferencesService.js.map +2 -2
  34. package/dist/modules/customers/backend/customers/companies/[id]/page.js +3 -1
  35. package/dist/modules/customers/backend/customers/companies/[id]/page.js.map +2 -2
  36. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +4 -2
  37. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  38. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +8 -3
  39. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  40. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +3 -2
  41. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  42. package/dist/modules/customers/components/formConfig.js +3 -3
  43. package/dist/modules/customers/components/formConfig.js.map +2 -2
  44. package/dist/modules/customers/lib/displayName.js +12 -0
  45. package/dist/modules/customers/lib/displayName.js.map +2 -2
  46. package/dist/modules/entities/cli.js +5 -6
  47. package/dist/modules/entities/cli.js.map +2 -2
  48. package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.js +124 -0
  49. package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.js.map +7 -0
  50. package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.meta.js +11 -0
  51. package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.meta.js.map +7 -0
  52. package/generated/entities/sidebar_variant/index.ts +11 -0
  53. package/generated/entities.ids.generated.ts +1 -0
  54. package/generated/entity-fields-registry.ts +13 -0
  55. package/package.json +6 -6
  56. package/src/helpers/integration/authUi.ts +1 -1
  57. package/src/modules/audit_logs/services/actionLogService.ts +5 -6
  58. package/src/modules/auth/api/sidebar/preferences/route.ts +266 -34
  59. package/src/modules/auth/api/sidebar/variants/[id]/route.ts +183 -0
  60. package/src/modules/auth/api/sidebar/variants/route.ts +157 -0
  61. package/src/modules/auth/backend/sidebar-customization/page.meta.ts +34 -0
  62. package/src/modules/auth/backend/sidebar-customization/page.tsx +17 -0
  63. package/src/modules/auth/data/entities.ts +48 -2
  64. package/src/modules/auth/data/validators.ts +70 -0
  65. package/src/modules/auth/migrations/.snapshot-open-mercato.json +790 -71
  66. package/src/modules/auth/migrations/Migration20260427081815.ts +16 -0
  67. package/src/modules/auth/migrations/Migration20260427124900.ts +19 -0
  68. package/src/modules/auth/migrations/Migration20260427143311.ts +83 -0
  69. package/src/modules/auth/services/sidebarPreferencesService.ts +243 -18
  70. package/src/modules/customers/backend/customers/companies/[id]/page.tsx +5 -4
  71. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +6 -5
  72. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +13 -9
  73. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +3 -2
  74. package/src/modules/customers/components/formConfig.tsx +3 -3
  75. package/src/modules/customers/lib/displayName.ts +21 -0
  76. package/src/modules/entities/cli.ts +5 -6
  77. package/src/modules/portal/frontend/[orgSlug]/portal/reset-password/page.meta.ts +9 -0
  78. package/src/modules/portal/frontend/[orgSlug]/portal/reset-password/page.tsx +168 -0
  79. package/src/modules/portal/i18n/de.json +20 -0
  80. package/src/modules/portal/i18n/en.json +20 -0
  81. package/src/modules/portal/i18n/es.json +20 -0
  82. package/src/modules/portal/i18n/pl.json +20 -0
@@ -28,6 +28,6 @@ export async function createUserViaUi(page: Page, input: { email: string; passwo
28
28
 
29
29
  await page.getByRole('button', { name: 'Create' }).first().click();
30
30
  await expect(page).toHaveURL(/\/backend\/users(?:\?.*)?$/);
31
- await page.getByRole('textbox', { name: 'Search' }).fill(input.email);
31
+ await page.getByRole('textbox', { name: 'Search', exact: true }).fill(input.email);
32
32
  await expect(page.getByRole('row', { name: new RegExp(input.email, 'i') })).toBeVisible();
33
33
  }
@@ -15,7 +15,10 @@ import {
15
15
  deriveActionLogProjection,
16
16
  } from '@open-mercato/core/modules/audit_logs/lib/projections'
17
17
  import { decryptWithAesGcm } from '@open-mercato/shared/lib/encryption/aes'
18
- import { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
18
+ import {
19
+ TenantDataEncryptionService,
20
+ parseDecryptedFieldValue,
21
+ } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
19
22
  import { toOptionalString } from '@open-mercato/shared/lib/string/coerce'
20
23
 
21
24
  let validationWarningLogged = false
@@ -123,11 +126,7 @@ export class ActionLogService {
123
126
  if (typeof value === 'string' && value.split(':').length === 4 && value.endsWith(':v1')) {
124
127
  const decrypted = decryptWithAesGcm(value, dek.key)
125
128
  if (decrypted === null) return value
126
- try {
127
- return JSON.parse(decrypted)
128
- } catch {
129
- return decrypted
130
- }
129
+ return parseDecryptedFieldValue(decrypted)
131
130
  }
132
131
  if (Array.isArray(value)) return value.map((item) => deepDecrypt(item))
133
132
  if (value && typeof value === 'object') {
@@ -1,8 +1,13 @@
1
1
  import { NextResponse } from 'next/server'
2
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
4
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
4
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
- import { sidebarPreferencesInputSchema } from '../../../data/validators'
6
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
+ import {
8
+ sidebarPreferencesInputSchema,
9
+ sidebarPreferencesScopeSchema,
10
+ } from '../../../data/validators'
6
11
  import {
7
12
  loadRoleSidebarPreferences,
8
13
  loadSidebarPreference,
@@ -17,6 +22,7 @@ import { z } from 'zod'
17
22
  export const metadata = {
18
23
  GET: { requireAuth: true },
19
24
  PUT: { requireAuth: true },
25
+ DELETE: { requireAuth: true },
20
26
  }
21
27
 
22
28
  const sidebarSettingsSchema = z.object({
@@ -25,6 +31,7 @@ const sidebarSettingsSchema = z.object({
25
31
  groupLabels: z.record(z.string(), z.string()),
26
32
  itemLabels: z.record(z.string(), z.string()),
27
33
  hiddenItems: z.array(z.string()),
34
+ itemOrder: z.record(z.string(), z.array(z.string())),
28
35
  })
29
36
 
30
37
  const sidebarRoleEntrySchema = z.object({
@@ -38,6 +45,7 @@ const sidebarPreferencesResponseSchema = z.object({
38
45
  settings: sidebarSettingsSchema,
39
46
  canApplyToRoles: z.boolean(),
40
47
  roles: z.array(sidebarRoleEntrySchema),
48
+ scope: sidebarPreferencesScopeSchema,
41
49
  })
42
50
 
43
51
  const sidebarPreferencesUpdateResponseSchema = sidebarPreferencesResponseSchema.extend({
@@ -45,25 +53,135 @@ const sidebarPreferencesUpdateResponseSchema = sidebarPreferencesResponseSchema.
45
53
  clearedRoles: z.array(z.string().uuid()),
46
54
  })
47
55
 
56
+ const sidebarPreferencesDeleteResponseSchema = z.object({
57
+ ok: z.literal(true),
58
+ scope: sidebarPreferencesScopeSchema,
59
+ })
60
+
48
61
  const sidebarErrorSchema = z.object({
49
62
  error: z.string(),
50
63
  })
51
64
 
65
+ const FEATURE_MANAGE = 'auth.sidebar.manage'
66
+
67
+ type EmptySettings = {
68
+ version: number
69
+ groupOrder: string[]
70
+ groupLabels: Record<string, string>
71
+ itemLabels: Record<string, string>
72
+ hiddenItems: string[]
73
+ itemOrder: Record<string, string[]>
74
+ }
75
+
76
+ function emptySettings(): EmptySettings {
77
+ return {
78
+ version: SIDEBAR_PREFERENCES_VERSION,
79
+ groupOrder: [],
80
+ groupLabels: {},
81
+ itemLabels: {},
82
+ hiddenItems: [],
83
+ itemOrder: {},
84
+ }
85
+ }
86
+
87
+ async function loadRolesPayload(
88
+ em: EntityManager,
89
+ options: { tenantId: string | null; locale: string },
90
+ ): Promise<Array<{ id: string; name: string; hasPreference: boolean }>> {
91
+ const roleScope: FilterQuery<Role> = options.tenantId
92
+ ? { $or: [{ tenantId: options.tenantId }, { tenantId: null }] }
93
+ : { tenantId: null }
94
+ const roles = await findWithDecryption(
95
+ em,
96
+ Role,
97
+ roleScope,
98
+ { orderBy: { name: 'asc' } },
99
+ { tenantId: options.tenantId, organizationId: null },
100
+ )
101
+ if (roles.length === 0) return []
102
+ const rolePrefs = await loadRoleSidebarPreferences(em, {
103
+ roleIds: roles.map((r: Role) => r.id),
104
+ tenantId: options.tenantId,
105
+ locale: options.locale,
106
+ })
107
+ return roles.map((role: Role) => ({
108
+ id: role.id,
109
+ name: role.name,
110
+ hasPreference: rolePrefs.has(role.id),
111
+ }))
112
+ }
113
+
114
+ async function findRoleInScope(
115
+ em: EntityManager,
116
+ options: { roleId: string; tenantId: string | null },
117
+ ): Promise<Role | null> {
118
+ const role = await findOneWithDecryption(
119
+ em,
120
+ Role,
121
+ { id: options.roleId },
122
+ undefined,
123
+ { tenantId: options.tenantId, organizationId: null },
124
+ )
125
+ if (!role) return null
126
+ // Cross-tenant guard: a role belongs to either the auth tenant or the global (null tenant) pool.
127
+ // Reject the lookup otherwise so a multi-tenant deployment can't leak across tenants.
128
+ if (role.tenantId && options.tenantId && role.tenantId !== options.tenantId) return null
129
+ if (role.tenantId && !options.tenantId) return null
130
+ return role
131
+ }
132
+
52
133
  export async function GET(req: Request) {
53
134
  const auth = await getAuthFromRequest(req)
54
135
  if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
55
136
 
137
+ const url = new URL(req.url)
138
+ const roleIdParam = url.searchParams.get('roleId')
139
+
56
140
  const { locale } = await resolveTranslations()
57
141
  const { resolve } = await createRequestContainer()
58
- const em = resolve('em') as any
142
+ const em = resolve('em') as EntityManager
59
143
  const rbac = resolve('rbacService') as any
60
144
 
61
145
  const canApplyToRoles = await rbac.userHasAllFeatures?.(
62
146
  auth.sub,
63
- ['auth.sidebar.manage'],
147
+ [FEATURE_MANAGE],
64
148
  { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
65
149
  ) ?? false
66
150
 
151
+ // Role-scoped read: requires `auth.sidebar.manage`.
152
+ if (roleIdParam) {
153
+ if (!canApplyToRoles) {
154
+ return NextResponse.json({ error: 'Forbidden', requiredFeatures: [FEATURE_MANAGE] }, { status: 403 })
155
+ }
156
+ const role = await findRoleInScope(em, { roleId: roleIdParam, tenantId: auth.tenantId ?? null })
157
+ if (!role) {
158
+ return NextResponse.json({ error: 'Role not found' }, { status: 404 })
159
+ }
160
+ const rolePrefs = await loadRoleSidebarPreferences(em, {
161
+ roleIds: [role.id],
162
+ tenantId: auth.tenantId ?? null,
163
+ locale,
164
+ })
165
+ const pref = rolePrefs.get(role.id) ?? null
166
+ const rolesPayload = await loadRolesPayload(em, { tenantId: auth.tenantId ?? null, locale })
167
+ return NextResponse.json({
168
+ locale,
169
+ settings: pref
170
+ ? {
171
+ version: pref.version ?? SIDEBAR_PREFERENCES_VERSION,
172
+ groupOrder: pref.groupOrder ?? [],
173
+ groupLabels: pref.groupLabels ?? {},
174
+ itemLabels: pref.itemLabels ?? {},
175
+ hiddenItems: pref.hiddenItems ?? [],
176
+ itemOrder: pref.itemOrder ?? {},
177
+ }
178
+ : emptySettings(),
179
+ canApplyToRoles,
180
+ roles: rolesPayload,
181
+ scope: { type: 'role', roleId: role.id },
182
+ })
183
+ }
184
+
67
185
  // For API key auth, use userId (the actual user) if available
68
186
  const effectiveUserId = auth.isApiKey ? auth.userId : auth.sub
69
187
  const settings = effectiveUserId
@@ -75,23 +193,9 @@ export async function GET(req: Request) {
75
193
  })
76
194
  : null
77
195
 
78
- let rolesPayload: Array<{ id: string; name: string; hasPreference: boolean }> = []
79
- if (canApplyToRoles) {
80
- const roleScope = auth.tenantId
81
- ? { $or: [{ tenantId: auth.tenantId }, { tenantId: null }] }
82
- : { tenantId: null }
83
- const roles = await em.find(Role, roleScope as any, { orderBy: { name: 'asc' } })
84
- const rolePrefs = await loadRoleSidebarPreferences(em, {
85
- roleIds: roles.map((r: Role) => r.id),
86
- tenantId: auth.tenantId ?? null,
87
- locale,
88
- })
89
- rolesPayload = roles.map((role: Role) => ({
90
- id: role.id,
91
- name: role.name,
92
- hasPreference: rolePrefs.has(role.id),
93
- }))
94
- }
196
+ const rolesPayload = canApplyToRoles
197
+ ? await loadRolesPayload(em, { tenantId: auth.tenantId ?? null, locale })
198
+ : []
95
199
 
96
200
  return NextResponse.json({
97
201
  locale,
@@ -101,9 +205,11 @@ export async function GET(req: Request) {
101
205
  groupLabels: settings?.groupLabels ?? {},
102
206
  itemLabels: settings?.itemLabels ?? {},
103
207
  hiddenItems: settings?.hiddenItems ?? [],
208
+ itemOrder: settings?.itemOrder ?? {},
104
209
  },
105
210
  canApplyToRoles,
106
211
  roles: rolesPayload,
212
+ scope: { type: 'user' },
107
213
  })
108
214
  }
109
215
 
@@ -167,27 +273,86 @@ export async function PUT(req: Request) {
167
273
  }
168
274
  return values
169
275
  })(),
276
+ itemOrder: (() => {
277
+ const source = parsed.data.itemOrder ?? {}
278
+ const out: Record<string, string[]> = {}
279
+ for (const [groupKey, list] of Object.entries(source)) {
280
+ const trimmedGroup = groupKey.trim()
281
+ if (!trimmedGroup) continue
282
+ const seenItem = new Set<string>()
283
+ const values: string[] = []
284
+ for (const itemKey of list) {
285
+ const trimmedItem = itemKey.trim()
286
+ if (!trimmedItem || seenItem.has(trimmedItem)) continue
287
+ seenItem.add(trimmedItem)
288
+ values.push(trimmedItem)
289
+ }
290
+ if (values.length > 0) out[trimmedGroup] = values
291
+ }
292
+ return out
293
+ })(),
170
294
  }
171
295
 
172
296
  const { locale } = await resolveTranslations()
173
297
  const container = await createRequestContainer()
174
- const em = container.resolve('em') as any
298
+ const em = container.resolve('em') as EntityManager
175
299
  const rbac = container.resolve('rbacService') as any
176
300
  const cache = container.resolve('cache') as { deleteByTags?: (tags: string[]) => Promise<unknown> } | undefined
177
301
 
178
- const applyToRolesSource = parsed.data.applyToRoles ?? []
179
- const applyToRoles = Array.from(new Set(applyToRolesSource.map((id) => id.trim()).filter((id) => id.length > 0)))
180
- const clearRoleIdsSource = parsed.data.clearRoleIds ?? []
181
- const clearRoleIds = Array.from(new Set(clearRoleIdsSource.map((id) => id.trim()).filter((id) => id.length > 0)))
182
-
183
302
  const canApplyToRoles = await rbac.userHasAllFeatures?.(
184
303
  auth.sub,
185
- ['auth.sidebar.manage'],
304
+ [FEATURE_MANAGE],
186
305
  { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
187
306
  ) ?? false
188
307
 
308
+ const scope = parsed.data.scope ?? { type: 'user' as const }
309
+
310
+ // Role-scoped write: requires `auth.sidebar.manage` and a role visible to this tenant.
311
+ // applyToRoles/clearRoleIds are forbidden in role scope (validator already rejects them).
312
+ if (scope.type === 'role') {
313
+ if (!canApplyToRoles) {
314
+ return NextResponse.json({ error: 'Forbidden', requiredFeatures: [FEATURE_MANAGE] }, { status: 403 })
315
+ }
316
+ const role = await findRoleInScope(em, { roleId: scope.roleId, tenantId: auth.tenantId ?? null })
317
+ if (!role) {
318
+ return NextResponse.json({ error: 'Role not found' }, { status: 404 })
319
+ }
320
+ const saved = await saveRoleSidebarPreference(em, {
321
+ roleId: role.id,
322
+ tenantId: auth.tenantId ?? null,
323
+ locale,
324
+ }, payload)
325
+ if (cache?.deleteByTags) {
326
+ try {
327
+ await cache.deleteByTags([`nav:sidebar:role:${role.id}`])
328
+ } catch {}
329
+ }
330
+ const rolesPayload = await loadRolesPayload(em, { tenantId: auth.tenantId ?? null, locale })
331
+ return NextResponse.json({
332
+ locale,
333
+ settings: {
334
+ version: saved?.version ?? payload.version,
335
+ groupOrder: saved?.groupOrder ?? payload.groupOrder,
336
+ groupLabels: saved?.groupLabels ?? payload.groupLabels,
337
+ itemLabels: saved?.itemLabels ?? payload.itemLabels,
338
+ hiddenItems: saved?.hiddenItems ?? payload.hiddenItems,
339
+ itemOrder: saved?.itemOrder ?? payload.itemOrder,
340
+ },
341
+ canApplyToRoles,
342
+ roles: rolesPayload,
343
+ scope: { type: 'role', roleId: role.id },
344
+ appliedRoles: [],
345
+ clearedRoles: [],
346
+ })
347
+ }
348
+
349
+ const applyToRolesSource = parsed.data.applyToRoles ?? []
350
+ const applyToRoles = Array.from(new Set(applyToRolesSource.map((id) => id.trim()).filter((id) => id.length > 0)))
351
+ const clearRoleIdsSource = parsed.data.clearRoleIds ?? []
352
+ const clearRoleIds = Array.from(new Set(clearRoleIdsSource.map((id) => id.trim()).filter((id) => id.length > 0)))
353
+
189
354
  if ((applyToRoles.length > 0 || clearRoleIds.length > 0) && !canApplyToRoles) {
190
- return NextResponse.json({ error: 'Forbidden', requiredFeatures: ['auth.sidebar.manage'] }, { status: 403 })
355
+ return NextResponse.json({ error: 'Forbidden', requiredFeatures: [FEATURE_MANAGE] }, { status: 403 })
191
356
  }
192
357
 
193
358
  const settings = await saveSidebarPreference(em, {
@@ -197,15 +362,21 @@ export async function PUT(req: Request) {
197
362
  locale,
198
363
  }, payload)
199
364
 
200
- const roleScope = auth.tenantId
365
+ const roleScope: FilterQuery<Role> = auth.tenantId
201
366
  ? { $or: [{ tenantId: auth.tenantId }, { tenantId: null }] }
202
367
  : { tenantId: null }
203
368
  const availableRoles = canApplyToRoles
204
- ? await em.find(Role, roleScope as any, { orderBy: { name: 'asc' } })
369
+ ? await findWithDecryption(
370
+ em,
371
+ Role,
372
+ roleScope,
373
+ { orderBy: { name: 'asc' } },
374
+ { tenantId: auth.tenantId ?? null, organizationId: null },
375
+ )
205
376
  : []
206
377
  const roleMap = new Map<string, Role>(availableRoles.map((role: Role) => [String(role.id), role]))
207
378
 
208
- let updatedRoleIds: string[] = []
379
+ const updatedRoleIds: string[] = []
209
380
  if (applyToRoles.length > 0) {
210
381
  const missing = applyToRoles.filter((id) => !roleMap.has(id))
211
382
  if (missing.length) {
@@ -225,9 +396,11 @@ export async function PUT(req: Request) {
225
396
  const filteredClearRoleIds = clearRoleIds.filter((id) => !updatedRoleIds.includes(id) && !applyToRoles.includes(id))
226
397
 
227
398
  if (filteredClearRoleIds.length > 0) {
399
+ // Cross-locale: role preferences are unique per (role, tenantId); keep the delete
400
+ // filter aligned with save/load helpers so a clear from one locale does not leave
401
+ // a row created under another locale orphaned.
228
402
  await em.nativeDelete(RoleSidebarPreference, {
229
403
  role: { $in: filteredClearRoleIds },
230
- locale,
231
404
  tenantId: auth.tenantId ?? null,
232
405
  })
233
406
  if (cache?.deleteByTags) {
@@ -267,26 +440,73 @@ export async function PUT(req: Request) {
267
440
  settings,
268
441
  canApplyToRoles,
269
442
  roles: rolesPayload,
443
+ scope: { type: 'user' },
270
444
  appliedRoles: updatedRoleIds,
271
445
  clearedRoles: filteredClearRoleIds,
272
446
  })
273
447
  }
274
448
 
449
+ export async function DELETE(req: Request) {
450
+ const auth = await getAuthFromRequest(req)
451
+ if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
452
+
453
+ const url = new URL(req.url)
454
+ const roleIdParam = url.searchParams.get('roleId')
455
+ if (!roleIdParam) {
456
+ return NextResponse.json({ error: 'roleId query parameter is required' }, { status: 400 })
457
+ }
458
+
459
+ const container = await createRequestContainer()
460
+ const em = container.resolve('em') as EntityManager
461
+ const rbac = container.resolve('rbacService') as any
462
+ const cache = container.resolve('cache') as { deleteByTags?: (tags: string[]) => Promise<unknown> } | undefined
463
+
464
+ const canApplyToRoles = await rbac.userHasAllFeatures?.(
465
+ auth.sub,
466
+ [FEATURE_MANAGE],
467
+ { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
468
+ ) ?? false
469
+ if (!canApplyToRoles) {
470
+ return NextResponse.json({ error: 'Forbidden', requiredFeatures: [FEATURE_MANAGE] }, { status: 403 })
471
+ }
472
+
473
+ const role = await findRoleInScope(em, { roleId: roleIdParam, tenantId: auth.tenantId ?? null })
474
+ if (!role) {
475
+ return NextResponse.json({ error: 'Role not found' }, { status: 404 })
476
+ }
477
+
478
+ // Cross-locale: keep the delete filter aligned with save/load helpers (no locale).
479
+ await em.nativeDelete(RoleSidebarPreference, {
480
+ role: role.id,
481
+ tenantId: auth.tenantId ?? null,
482
+ })
483
+
484
+ if (cache?.deleteByTags) {
485
+ try {
486
+ await cache.deleteByTags([`nav:sidebar:role:${role.id}`])
487
+ } catch {}
488
+ }
489
+
490
+ return NextResponse.json({ ok: true, scope: { type: 'role', roleId: role.id } })
491
+ }
492
+
275
493
  export const openApi: OpenApiRouteDoc = {
276
494
  tag: 'Authentication & Accounts',
277
495
  summary: 'Sidebar preferences',
278
496
  methods: {
279
497
  GET: {
280
498
  summary: 'Get sidebar preferences',
281
- description: 'Returns personal sidebar customization and any role-level preferences the user can manage.',
499
+ description: 'Returns sidebar customization for the current user (default) or the specified role (`?roleId=…`, requires `auth.sidebar.manage`).',
282
500
  responses: [
283
501
  { status: 200, description: 'Current sidebar configuration', schema: sidebarPreferencesResponseSchema },
284
502
  { status: 401, description: 'Unauthorized', schema: sidebarErrorSchema },
503
+ { status: 403, description: 'Missing features for role-scope read', schema: sidebarErrorSchema },
504
+ { status: 404, description: 'Role not found in current tenant scope', schema: sidebarErrorSchema },
285
505
  ],
286
506
  },
287
507
  PUT: {
288
508
  summary: 'Update sidebar preferences',
289
- description: 'Updates personal sidebar configuration and, optionally, applies the same settings to selected roles.',
509
+ description: 'Updates sidebar configuration. With `scope.type === "user"` (default) writes the calling user\'s personal preferences and may optionally apply the same settings to selected roles via `applyToRoles[]`. With `scope.type === "role"` writes the named role variant directly (requires `auth.sidebar.manage`); `applyToRoles[]` and `clearRoleIds[]` are rejected in this mode.',
290
510
  requestBody: {
291
511
  contentType: 'application/json',
292
512
  schema: sidebarPreferencesInputSchema,
@@ -296,6 +516,18 @@ export const openApi: OpenApiRouteDoc = {
296
516
  { status: 400, description: 'Invalid payload', schema: sidebarErrorSchema },
297
517
  { status: 401, description: 'Unauthorized', schema: sidebarErrorSchema },
298
518
  { status: 403, description: 'Missing features for role-wide updates', schema: sidebarErrorSchema },
519
+ { status: 404, description: 'Role not found in current tenant scope', schema: sidebarErrorSchema },
520
+ ],
521
+ },
522
+ DELETE: {
523
+ summary: 'Delete a role sidebar variant',
524
+ description: 'Removes the role variant for the current tenant + locale. Idempotent. Requires `auth.sidebar.manage`.',
525
+ responses: [
526
+ { status: 200, description: 'Variant deleted (or never existed)', schema: sidebarPreferencesDeleteResponseSchema },
527
+ { status: 400, description: 'Missing roleId query parameter', schema: sidebarErrorSchema },
528
+ { status: 401, description: 'Unauthorized', schema: sidebarErrorSchema },
529
+ { status: 403, description: 'Missing features', schema: sidebarErrorSchema },
530
+ { status: 404, description: 'Role not found in current tenant scope', schema: sidebarErrorSchema },
299
531
  ],
300
532
  },
301
533
  },
@@ -0,0 +1,183 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
+ import { SIDEBAR_PREFERENCES_VERSION } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
8
+ import {
9
+ deleteSidebarVariant,
10
+ loadSidebarVariant,
11
+ updateSidebarVariant,
12
+ type SidebarVariantRecord,
13
+ } from '../../../../services/sidebarPreferencesService'
14
+ import {
15
+ sidebarVariantRecordSchema,
16
+ updateSidebarVariantInputSchema,
17
+ } from '../../../../data/validators'
18
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
19
+
20
+ export const metadata = {
21
+ GET: { requireAuth: true },
22
+ PUT: { requireAuth: true },
23
+ DELETE: { requireAuth: true },
24
+ }
25
+
26
+ const variantResponseSchema = z.object({
27
+ locale: z.string(),
28
+ variant: sidebarVariantRecordSchema,
29
+ })
30
+
31
+ const deleteResponseSchema = z.object({ ok: z.literal(true) })
32
+ const errorSchema = z.object({ error: z.string() })
33
+
34
+ function serializeVariant(record: SidebarVariantRecord) {
35
+ return {
36
+ id: record.id,
37
+ name: record.name,
38
+ isActive: record.isActive,
39
+ settings: {
40
+ version: record.settings.version ?? SIDEBAR_PREFERENCES_VERSION,
41
+ groupOrder: record.settings.groupOrder ?? [],
42
+ groupLabels: record.settings.groupLabels ?? {},
43
+ itemLabels: record.settings.itemLabels ?? {},
44
+ hiddenItems: record.settings.hiddenItems ?? [],
45
+ itemOrder: record.settings.itemOrder ?? {},
46
+ },
47
+ createdAt: record.createdAt.toISOString(),
48
+ updatedAt: record.updatedAt ? record.updatedAt.toISOString() : null,
49
+ }
50
+ }
51
+
52
+ function extractIdFromUrl(req: Request): string | null {
53
+ const url = new URL(req.url)
54
+ const segments = url.pathname.split('/').filter(Boolean)
55
+ // .../api/auth/sidebar/variants/<id>
56
+ return segments[segments.length - 1] || null
57
+ }
58
+
59
+ export async function GET(req: Request) {
60
+ const auth = await getAuthFromRequest(req)
61
+ if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
62
+ const effectiveUserId = auth.isApiKey ? auth.userId : auth.sub
63
+ if (!effectiveUserId) return NextResponse.json({ error: 'No user context' }, { status: 403 })
64
+
65
+ const id = extractIdFromUrl(req)
66
+ if (!id) return NextResponse.json({ error: 'Invalid id' }, { status: 400 })
67
+
68
+ const { locale } = await resolveTranslations()
69
+ const { resolve } = await createRequestContainer()
70
+ const em = resolve('em') as EntityManager
71
+
72
+ const variant = await loadSidebarVariant(em, {
73
+ userId: effectiveUserId,
74
+ tenantId: auth.tenantId ?? null,
75
+ organizationId: auth.orgId ?? null,
76
+ locale,
77
+ }, id)
78
+
79
+ if (!variant) return NextResponse.json({ error: 'Variant not found' }, { status: 404 })
80
+
81
+ return NextResponse.json({ locale, variant: serializeVariant(variant) })
82
+ }
83
+
84
+ export async function PUT(req: Request) {
85
+ const auth = await getAuthFromRequest(req)
86
+ if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
87
+ const effectiveUserId = auth.isApiKey ? auth.userId : auth.sub
88
+ if (!effectiveUserId) return NextResponse.json({ error: 'No user context' }, { status: 403 })
89
+
90
+ const id = extractIdFromUrl(req)
91
+ if (!id) return NextResponse.json({ error: 'Invalid id' }, { status: 400 })
92
+
93
+ let parsedBody: unknown
94
+ try {
95
+ parsedBody = await req.json()
96
+ } catch {
97
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
98
+ }
99
+
100
+ const parsed = updateSidebarVariantInputSchema.safeParse(parsedBody)
101
+ if (!parsed.success) {
102
+ return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 })
103
+ }
104
+
105
+ const { locale } = await resolveTranslations()
106
+ const { resolve } = await createRequestContainer()
107
+ const em = resolve('em') as EntityManager
108
+
109
+ const variant = await updateSidebarVariant(em, {
110
+ userId: effectiveUserId,
111
+ tenantId: auth.tenantId ?? null,
112
+ organizationId: auth.orgId ?? null,
113
+ locale,
114
+ }, id, {
115
+ name: parsed.data.name,
116
+ settings: parsed.data.settings ?? null,
117
+ isActive: parsed.data.isActive,
118
+ })
119
+
120
+ if (!variant) return NextResponse.json({ error: 'Variant not found' }, { status: 404 })
121
+
122
+ return NextResponse.json({ locale, variant: serializeVariant(variant) })
123
+ }
124
+
125
+ export async function DELETE(req: Request) {
126
+ const auth = await getAuthFromRequest(req)
127
+ if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
128
+ const effectiveUserId = auth.isApiKey ? auth.userId : auth.sub
129
+ if (!effectiveUserId) return NextResponse.json({ error: 'No user context' }, { status: 403 })
130
+
131
+ const id = extractIdFromUrl(req)
132
+ if (!id) return NextResponse.json({ error: 'Invalid id' }, { status: 400 })
133
+
134
+ const { locale } = await resolveTranslations()
135
+ const { resolve } = await createRequestContainer()
136
+ const em = resolve('em') as EntityManager
137
+
138
+ const ok = await deleteSidebarVariant(em, {
139
+ userId: effectiveUserId,
140
+ tenantId: auth.tenantId ?? null,
141
+ organizationId: auth.orgId ?? null,
142
+ locale,
143
+ }, id)
144
+
145
+ if (!ok) return NextResponse.json({ error: 'Variant not found' }, { status: 404 })
146
+
147
+ return NextResponse.json({ ok: true })
148
+ }
149
+
150
+ export const openApi: OpenApiRouteDoc = {
151
+ tag: 'Authentication & Accounts',
152
+ summary: 'Sidebar variant',
153
+ methods: {
154
+ GET: {
155
+ summary: 'Get a sidebar variant',
156
+ responses: [
157
+ { status: 200, description: 'Variant', schema: variantResponseSchema },
158
+ { status: 401, description: 'Unauthorized', schema: errorSchema },
159
+ { status: 404, description: 'Variant not found', schema: errorSchema },
160
+ ],
161
+ },
162
+ PUT: {
163
+ summary: 'Update a sidebar variant',
164
+ description: 'Updates the variant\'s name, settings, and/or isActive flag. Setting `isActive: true` deactivates other variants in the same scope (only one active per user/tenant/locale).',
165
+ requestBody: { contentType: 'application/json', schema: updateSidebarVariantInputSchema },
166
+ responses: [
167
+ { status: 200, description: 'Variant updated', schema: variantResponseSchema },
168
+ { status: 400, description: 'Invalid payload', schema: errorSchema },
169
+ { status: 401, description: 'Unauthorized', schema: errorSchema },
170
+ { status: 404, description: 'Variant not found', schema: errorSchema },
171
+ ],
172
+ },
173
+ DELETE: {
174
+ summary: 'Delete a sidebar variant',
175
+ description: 'Soft-deletes the variant (sets deleted_at).',
176
+ responses: [
177
+ { status: 200, description: 'Variant deleted', schema: deleteResponseSchema },
178
+ { status: 401, description: 'Unauthorized', schema: errorSchema },
179
+ { status: 404, description: 'Variant not found', schema: errorSchema },
180
+ ],
181
+ },
182
+ },
183
+ }