@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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/generated/entities/sidebar_variant/index.js +25 -0
- package/dist/generated/entities/sidebar_variant/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +1 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +13 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/authUi.js +1 -1
- package/dist/helpers/integration/authUi.js.map +2 -2
- package/dist/modules/audit_logs/services/actionLogService.js +4 -5
- package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
- package/dist/modules/auth/api/sidebar/preferences/route.js +224 -35
- package/dist/modules/auth/api/sidebar/preferences/route.js.map +3 -3
- package/dist/modules/auth/api/sidebar/variants/[id]/route.js +161 -0
- package/dist/modules/auth/api/sidebar/variants/[id]/route.js.map +7 -0
- package/dist/modules/auth/api/sidebar/variants/route.js +142 -0
- package/dist/modules/auth/api/sidebar/variants/route.js.map +7 -0
- package/dist/modules/auth/backend/sidebar-customization/page.js +16 -0
- package/dist/modules/auth/backend/sidebar-customization/page.js.map +7 -0
- package/dist/modules/auth/backend/sidebar-customization/page.meta.js +28 -0
- package/dist/modules/auth/backend/sidebar-customization/page.meta.js.map +7 -0
- package/dist/modules/auth/data/entities.js +45 -4
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/data/validators.js +63 -1
- package/dist/modules/auth/data/validators.js.map +2 -2
- package/dist/modules/auth/migrations/Migration20260427081815.js +15 -0
- package/dist/modules/auth/migrations/Migration20260427081815.js.map +7 -0
- package/dist/modules/auth/migrations/Migration20260427124900.js +15 -0
- package/dist/modules/auth/migrations/Migration20260427124900.js.map +7 -0
- package/dist/modules/auth/migrations/Migration20260427143311.js +72 -0
- package/dist/modules/auth/migrations/Migration20260427143311.js.map +7 -0
- package/dist/modules/auth/services/sidebarPreferencesService.js +176 -16
- package/dist/modules/auth/services/sidebarPreferencesService.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies/[id]/page.js +3 -1
- package/dist/modules/customers/backend/customers/companies/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +4 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +8 -3
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js +3 -2
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
- package/dist/modules/customers/components/formConfig.js +3 -3
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/customers/lib/displayName.js +12 -0
- package/dist/modules/customers/lib/displayName.js.map +2 -2
- package/dist/modules/entities/cli.js +5 -6
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.js +124 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.js.map +7 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.meta.js +11 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.meta.js.map +7 -0
- package/generated/entities/sidebar_variant/index.ts +11 -0
- package/generated/entities.ids.generated.ts +1 -0
- package/generated/entity-fields-registry.ts +13 -0
- package/package.json +6 -6
- package/src/helpers/integration/authUi.ts +1 -1
- package/src/modules/audit_logs/services/actionLogService.ts +5 -6
- package/src/modules/auth/api/sidebar/preferences/route.ts +266 -34
- package/src/modules/auth/api/sidebar/variants/[id]/route.ts +183 -0
- package/src/modules/auth/api/sidebar/variants/route.ts +157 -0
- package/src/modules/auth/backend/sidebar-customization/page.meta.ts +34 -0
- package/src/modules/auth/backend/sidebar-customization/page.tsx +17 -0
- package/src/modules/auth/data/entities.ts +48 -2
- package/src/modules/auth/data/validators.ts +70 -0
- package/src/modules/auth/migrations/.snapshot-open-mercato.json +790 -71
- package/src/modules/auth/migrations/Migration20260427081815.ts +16 -0
- package/src/modules/auth/migrations/Migration20260427124900.ts +19 -0
- package/src/modules/auth/migrations/Migration20260427143311.ts +83 -0
- package/src/modules/auth/services/sidebarPreferencesService.ts +243 -18
- package/src/modules/customers/backend/customers/companies/[id]/page.tsx +5 -4
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +6 -5
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +13 -9
- package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +3 -2
- package/src/modules/customers/components/formConfig.tsx +3 -3
- package/src/modules/customers/lib/displayName.ts +21 -0
- package/src/modules/entities/cli.ts +5 -6
- package/src/modules/portal/frontend/[orgSlug]/portal/reset-password/page.meta.ts +9 -0
- package/src/modules/portal/frontend/[orgSlug]/portal/reset-password/page.tsx +168 -0
- package/src/modules/portal/i18n/de.json +20 -0
- package/src/modules/portal/i18n/en.json +20 -0
- package/src/modules/portal/i18n/es.json +20 -0
- 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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
[
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
[
|
|
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: [
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|