@open-mercato/core 0.6.4-develop.3996.1.430e257cfc → 0.6.4-develop.4011.1.4f3ed9ae3e

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 (65) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/features.js +24 -2
  3. package/dist/modules/auth/api/features.js.map +2 -2
  4. package/dist/modules/auth/backend/users/[id]/edit/page.js +70 -57
  5. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  6. package/dist/modules/auth/components/AclDependencyDiagnosticsPanel.js +135 -0
  7. package/dist/modules/auth/components/AclDependencyDiagnosticsPanel.js.map +7 -0
  8. package/dist/modules/auth/components/AclEditor.js +10 -0
  9. package/dist/modules/auth/components/AclEditor.js.map +2 -2
  10. package/dist/modules/catalog/acl.js +30 -5
  11. package/dist/modules/catalog/acl.js.map +2 -2
  12. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +17 -5
  13. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  14. package/dist/modules/catalog/commands/offers.js +26 -7
  15. package/dist/modules/catalog/commands/offers.js.map +2 -2
  16. package/dist/modules/catalog/commands/prices.js +41 -26
  17. package/dist/modules/catalog/commands/prices.js.map +2 -2
  18. package/dist/modules/catalog/commands/productUnitConversions.js +7 -1
  19. package/dist/modules/catalog/commands/productUnitConversions.js.map +2 -2
  20. package/dist/modules/catalog/commands/products.js +2 -0
  21. package/dist/modules/catalog/commands/products.js.map +2 -2
  22. package/dist/modules/catalog/commands/shared.js +58 -11
  23. package/dist/modules/catalog/commands/shared.js.map +2 -2
  24. package/dist/modules/catalog/commands/variants.js +18 -5
  25. package/dist/modules/catalog/commands/variants.js.map +2 -2
  26. package/dist/modules/customers/acl.js +72 -12
  27. package/dist/modules/customers/acl.js.map +2 -2
  28. package/dist/modules/resources/backend/resources/resources/[id]/page.js +17 -2
  29. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  30. package/dist/modules/sales/acl.js +109 -16
  31. package/dist/modules/sales/acl.js.map +2 -2
  32. package/dist/modules/sales/backend/sales/documents/[id]/page.js +20 -1
  33. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  34. package/dist/modules/sales/setup.js +2 -0
  35. package/dist/modules/sales/setup.js.map +2 -2
  36. package/package.json +7 -7
  37. package/src/modules/auth/api/features.ts +34 -4
  38. package/src/modules/auth/backend/users/[id]/edit/page.tsx +28 -6
  39. package/src/modules/auth/components/AclDependencyDiagnosticsPanel.tsx +173 -0
  40. package/src/modules/auth/components/AclEditor.tsx +14 -4
  41. package/src/modules/auth/i18n/de.json +9 -0
  42. package/src/modules/auth/i18n/en.json +9 -0
  43. package/src/modules/auth/i18n/es.json +9 -0
  44. package/src/modules/auth/i18n/pl.json +9 -0
  45. package/src/modules/catalog/acl.ts +30 -5
  46. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +21 -5
  47. package/src/modules/catalog/commands/offers.ts +26 -7
  48. package/src/modules/catalog/commands/prices.ts +41 -26
  49. package/src/modules/catalog/commands/productUnitConversions.ts +7 -1
  50. package/src/modules/catalog/commands/products.ts +2 -0
  51. package/src/modules/catalog/commands/shared.ts +70 -6
  52. package/src/modules/catalog/commands/variants.ts +18 -5
  53. package/src/modules/catalog/i18n/de.json +1 -0
  54. package/src/modules/catalog/i18n/en.json +1 -0
  55. package/src/modules/catalog/i18n/es.json +1 -0
  56. package/src/modules/catalog/i18n/pl.json +1 -0
  57. package/src/modules/customers/acl.ts +72 -12
  58. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +21 -2
  59. package/src/modules/sales/acl.ts +109 -16
  60. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +28 -1
  61. package/src/modules/sales/i18n/de.json +3 -0
  62. package/src/modules/sales/i18n/en.json +3 -0
  63. package/src/modules/sales/i18n/es.json +3 -0
  64. package/src/modules/sales/i18n/pl.json +3 -0
  65. package/src/modules/sales/setup.ts +2 -0
@@ -42,6 +42,8 @@ const setup = {
42
42
  defaultRoleFeatures: {
43
43
  admin: ["sales.*", "sales.documents.number.edit"],
44
44
  employee: [
45
+ "sales.channels.view",
46
+ "sales.settings.view",
45
47
  "sales.orders.view",
46
48
  "sales.orders.manage",
47
49
  "sales.orders.approve",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/sales/setup.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport { SalesSettings, SalesDocumentSequence, SalesTaxRate } from './data/entities'\nimport { DEFAULT_ORDER_NUMBER_FORMAT, DEFAULT_QUOTE_NUMBER_FORMAT } from './lib/documentNumberTokens'\nimport { seedSalesStatusDictionaries, seedSalesAdjustmentKinds } from './lib/dictionaries'\nimport { ensureExampleShippingMethods, ensureExamplePaymentMethods } from './seed/examples-data'\nimport { seedSalesExamples } from './seed/examples'\n\ntype SeedScope = { tenantId: string; organizationId: string }\n\nconst DEFAULT_TAX_RATES = [\n { code: 'vat-23', name: '23% VAT', rate: '23' },\n { code: 'vat-0', name: '0% VAT', rate: '0' },\n] as const\n\nasync function seedSalesTaxRates(em: EntityManager, scope: SeedScope): Promise<void> {\n await em.transactional(async (tem) => {\n const existing = await tem.find(SalesTaxRate, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n })\n const existingCodes = new Set(existing.map((rate) => rate.code))\n const hasDefault = existing.some((rate) => rate.isDefault)\n const now = new Date()\n let isFirst = !hasDefault\n\n for (const seed of DEFAULT_TAX_RATES) {\n if (existingCodes.has(seed.code)) continue\n tem.persist(\n tem.create(SalesTaxRate, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n code: seed.code,\n name: seed.name,\n rate: seed.rate,\n priority: 0,\n isCompound: false,\n isDefault: isFirst,\n createdAt: now,\n updatedAt: now,\n })\n )\n isFirst = false\n }\n })\n}\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n admin: ['sales.*', 'sales.documents.number.edit'],\n employee: [\n 'sales.orders.view',\n 'sales.orders.manage',\n 'sales.orders.approve',\n 'sales.widgets.new-orders',\n 'sales.widgets.new-quotes',\n 'sales.quotes.view',\n 'sales.quotes.manage',\n 'sales.shipments.manage',\n 'sales.payments.manage',\n 'sales.returns.view',\n 'sales.returns.create',\n 'sales.invoices.manage',\n 'sales.credit_memos.manage',\n ],\n },\n\n async onTenantCreated({ em, tenantId, organizationId }) {\n const exists = await em.findOne(SalesSettings, { tenantId, organizationId })\n if (!exists) {\n em.persist(\n em.create(SalesSettings, {\n tenantId,\n organizationId,\n orderNumberFormat: DEFAULT_ORDER_NUMBER_FORMAT,\n quoteNumberFormat: DEFAULT_QUOTE_NUMBER_FORMAT,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n )\n }\n\n for (const kind of ['order', 'quote', 'return', 'invoice', 'credit_memo'] as const) {\n const seq = await em.findOne(SalesDocumentSequence, {\n tenantId,\n organizationId,\n documentKind: kind,\n })\n if (!seq) {\n em.persist(\n em.create(SalesDocumentSequence, {\n tenantId,\n organizationId,\n documentKind: kind,\n currentValue: 0,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n )\n }\n }\n\n await em.flush()\n },\n\n async seedDefaults({ em, tenantId, organizationId }) {\n const scope = { tenantId, organizationId }\n await seedSalesTaxRates(em, scope)\n await seedSalesStatusDictionaries(em, scope)\n await seedSalesAdjustmentKinds(em, scope)\n await ensureExampleShippingMethods(em, scope)\n await ensureExamplePaymentMethods(em, scope)\n },\n\n async seedExamples({ em, container, tenantId, organizationId }) {\n const scope = { tenantId, organizationId }\n await seedSalesExamples(em, container, scope)\n },\n}\n\nexport default setup\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport { SalesSettings, SalesDocumentSequence, SalesTaxRate } from './data/entities'\nimport { DEFAULT_ORDER_NUMBER_FORMAT, DEFAULT_QUOTE_NUMBER_FORMAT } from './lib/documentNumberTokens'\nimport { seedSalesStatusDictionaries, seedSalesAdjustmentKinds } from './lib/dictionaries'\nimport { ensureExampleShippingMethods, ensureExamplePaymentMethods } from './seed/examples-data'\nimport { seedSalesExamples } from './seed/examples'\n\ntype SeedScope = { tenantId: string; organizationId: string }\n\nconst DEFAULT_TAX_RATES = [\n { code: 'vat-23', name: '23% VAT', rate: '23' },\n { code: 'vat-0', name: '0% VAT', rate: '0' },\n] as const\n\nasync function seedSalesTaxRates(em: EntityManager, scope: SeedScope): Promise<void> {\n await em.transactional(async (tem) => {\n const existing = await tem.find(SalesTaxRate, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n })\n const existingCodes = new Set(existing.map((rate) => rate.code))\n const hasDefault = existing.some((rate) => rate.isDefault)\n const now = new Date()\n let isFirst = !hasDefault\n\n for (const seed of DEFAULT_TAX_RATES) {\n if (existingCodes.has(seed.code)) continue\n tem.persist(\n tem.create(SalesTaxRate, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n code: seed.code,\n name: seed.name,\n rate: seed.rate,\n priority: 0,\n isCompound: false,\n isDefault: isFirst,\n createdAt: now,\n updatedAt: now,\n })\n )\n isFirst = false\n }\n })\n}\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n admin: ['sales.*', 'sales.documents.number.edit'],\n employee: [\n 'sales.channels.view',\n 'sales.settings.view',\n 'sales.orders.view',\n 'sales.orders.manage',\n 'sales.orders.approve',\n 'sales.widgets.new-orders',\n 'sales.widgets.new-quotes',\n 'sales.quotes.view',\n 'sales.quotes.manage',\n 'sales.shipments.manage',\n 'sales.payments.manage',\n 'sales.returns.view',\n 'sales.returns.create',\n 'sales.invoices.manage',\n 'sales.credit_memos.manage',\n ],\n },\n\n async onTenantCreated({ em, tenantId, organizationId }) {\n const exists = await em.findOne(SalesSettings, { tenantId, organizationId })\n if (!exists) {\n em.persist(\n em.create(SalesSettings, {\n tenantId,\n organizationId,\n orderNumberFormat: DEFAULT_ORDER_NUMBER_FORMAT,\n quoteNumberFormat: DEFAULT_QUOTE_NUMBER_FORMAT,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n )\n }\n\n for (const kind of ['order', 'quote', 'return', 'invoice', 'credit_memo'] as const) {\n const seq = await em.findOne(SalesDocumentSequence, {\n tenantId,\n organizationId,\n documentKind: kind,\n })\n if (!seq) {\n em.persist(\n em.create(SalesDocumentSequence, {\n tenantId,\n organizationId,\n documentKind: kind,\n currentValue: 0,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n )\n }\n }\n\n await em.flush()\n },\n\n async seedDefaults({ em, tenantId, organizationId }) {\n const scope = { tenantId, organizationId }\n await seedSalesTaxRates(em, scope)\n await seedSalesStatusDictionaries(em, scope)\n await seedSalesAdjustmentKinds(em, scope)\n await ensureExampleShippingMethods(em, scope)\n await ensureExamplePaymentMethods(em, scope)\n },\n\n async seedExamples({ em, container, tenantId, organizationId }) {\n const scope = { tenantId, organizationId }\n await seedSalesExamples(em, container, scope)\n },\n}\n\nexport default setup\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.4-develop.3996.1.430e257cfc",
3
+ "version": "0.6.4-develop.4011.1.4f3ed9ae3e",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -243,16 +243,16 @@
243
243
  "zod": "^4.4.3"
244
244
  },
245
245
  "peerDependencies": {
246
- "@open-mercato/ai-assistant": "0.6.4-develop.3996.1.430e257cfc",
247
- "@open-mercato/shared": "0.6.4-develop.3996.1.430e257cfc",
248
- "@open-mercato/ui": "0.6.4-develop.3996.1.430e257cfc",
246
+ "@open-mercato/ai-assistant": "0.6.4-develop.4011.1.4f3ed9ae3e",
247
+ "@open-mercato/shared": "0.6.4-develop.4011.1.4f3ed9ae3e",
248
+ "@open-mercato/ui": "0.6.4-develop.4011.1.4f3ed9ae3e",
249
249
  "react": "^19.0.0",
250
250
  "react-dom": "^19.0.0"
251
251
  },
252
252
  "devDependencies": {
253
- "@open-mercato/ai-assistant": "0.6.4-develop.3996.1.430e257cfc",
254
- "@open-mercato/shared": "0.6.4-develop.3996.1.430e257cfc",
255
- "@open-mercato/ui": "0.6.4-develop.3996.1.430e257cfc",
253
+ "@open-mercato/ai-assistant": "0.6.4-develop.4011.1.4f3ed9ae3e",
254
+ "@open-mercato/shared": "0.6.4-develop.4011.1.4f3ed9ae3e",
255
+ "@open-mercato/ui": "0.6.4-develop.4011.1.4f3ed9ae3e",
256
256
  "@testing-library/dom": "^10.4.1",
257
257
  "@testing-library/jest-dom": "^6.9.1",
258
258
  "@testing-library/react": "^16.3.1",
@@ -8,15 +8,44 @@ export const metadata = {
8
8
  GET: { requireAuth: true, requireFeatures: ['auth.acl.manage'] },
9
9
  }
10
10
 
11
+ type FeatureItem = {
12
+ id: string
13
+ title: string
14
+ module: string
15
+ dependsOn?: string[]
16
+ }
17
+
18
+ function normalizeDependsOn(value: unknown): string[] | undefined {
19
+ if (!Array.isArray(value)) return undefined
20
+ const out: string[] = []
21
+ for (const entry of value) {
22
+ if (typeof entry !== 'string') continue
23
+ const trimmed = entry.trim()
24
+ if (!trimmed) continue
25
+ out.push(trimmed)
26
+ }
27
+ if (out.length === 0) return undefined
28
+ return Array.from(new Set(out))
29
+ }
30
+
11
31
  export async function GET(req: Request) {
12
32
  const auth = await getAuthFromRequest(req)
13
33
  if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
14
34
  const modules = getModules()
15
- const items = (modules || []).flatMap((m: any) =>
16
- (m.features || []).map((f: any) => ({ id: String(f.id), title: String(f.title || f.id), module: String(f.module || m.id) }))
35
+ const items: FeatureItem[] = (modules || []).flatMap((m: any) =>
36
+ (m.features || []).map((f: any) => {
37
+ const deps = normalizeDependsOn(f?.dependsOn)
38
+ const base: FeatureItem = {
39
+ id: String(f.id),
40
+ title: String(f.title || f.id),
41
+ module: String(f.module || m.id),
42
+ }
43
+ if (deps) base.dependsOn = deps
44
+ return base
45
+ })
17
46
  )
18
- // Deduplicate by id
19
- const byId = new Map<string, { id: string; title: string; module: string }>()
47
+ // Deduplicate by id (keep first occurrence)
48
+ const byId = new Map<string, FeatureItem>()
20
49
  for (const it of items) if (!byId.has(it.id)) byId.set(it.id, it)
21
50
  const list = Array.from(byId.values()).sort((a, b) => a.module.localeCompare(b.module) || a.id.localeCompare(b.id))
22
51
 
@@ -35,6 +64,7 @@ const featureItemSchema = z.object({
35
64
  id: z.string(),
36
65
  title: z.string(),
37
66
  module: z.string(),
67
+ dependsOn: z.array(z.string()).optional(),
38
68
  })
39
69
 
40
70
  const featureModuleSchema = z.object({
@@ -17,6 +17,7 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
17
17
  import { extractCustomFieldEntries } from '@open-mercato/shared/lib/crud/custom-fields-client'
18
18
  import { formatPasswordRequirements, getPasswordPolicy } from '@open-mercato/shared/lib/auth/passwordPolicy'
19
19
  import { UserConsentsPanel } from '@open-mercato/core/modules/auth/components/UserConsentsPanel'
20
+ import { RecordNotFoundState, ErrorMessage } from '@open-mercato/ui/backend/detail'
20
21
  import { normalizeDisplayNameInput } from '@open-mercato/core/modules/auth/lib/displayName'
21
22
 
22
23
  type EditUserFormValues = {
@@ -119,6 +120,7 @@ export default function EditUserPage({ params }: { params?: { id?: string } }) {
119
120
  const [selectedTenantId, setSelectedTenantId] = React.useState<string | null>(null)
120
121
  const [loading, setLoading] = React.useState(true)
121
122
  const [error, setError] = React.useState<string | null>(null)
123
+ const [isNotFound, setIsNotFound] = React.useState(false)
122
124
  const [canEditOrgs, setCanEditOrgs] = React.useState(false)
123
125
  const [aclData, setAclData] = React.useState<AclData>({ isSuperAdmin: false, features: [], organizations: null })
124
126
  const [customFieldValues, setCustomFieldValues] = React.useState<Record<string, unknown>>({})
@@ -171,6 +173,7 @@ export default function EditUserPage({ params }: { params?: { id?: string } }) {
171
173
  async function load() {
172
174
  setLoading(true)
173
175
  setError(null)
176
+ setIsNotFound(false)
174
177
  setCustomFieldValues({})
175
178
  try {
176
179
  const { ok, result } = await apiCall<UserListResponse>(
@@ -182,7 +185,7 @@ export default function EditUserPage({ params }: { params?: { id?: string } }) {
182
185
  setActorIsSuperAdmin(Boolean(result?.isSuperAdmin))
183
186
  setActorResolved(true)
184
187
  if (!item) {
185
- setError(tRef.current('auth.users.form.errors.notFound', 'User not found'))
188
+ setIsNotFound(true)
186
189
  setCustomFieldValues({})
187
190
  setInitialUser(null)
188
191
  setSelectedTenantId(null)
@@ -404,14 +407,33 @@ export default function EditUserPage({ params }: { params?: { id?: string } }) {
404
407
  }
405
408
  }, [initialUser, customFieldValues, selectedTenantId])
406
409
 
410
+ if (isNotFound) {
411
+ return (
412
+ <Page>
413
+ <PageBody>
414
+ <RecordNotFoundState
415
+ label={t('auth.users.form.errors.notFound', 'User not found')}
416
+ backHref="/backend/users"
417
+ backLabel={t('auth.users.form.actions.backToList', 'Back to users')}
418
+ />
419
+ </PageBody>
420
+ </Page>
421
+ )
422
+ }
423
+
424
+ if (error && !loading) {
425
+ return (
426
+ <Page>
427
+ <PageBody>
428
+ <ErrorMessage label={error} />
429
+ </PageBody>
430
+ </Page>
431
+ )
432
+ }
433
+
407
434
  return (
408
435
  <Page>
409
436
  <PageBody>
410
- {error && (
411
- <div className="p-4 mb-4 bg-red-50 border border-red-200 rounded text-red-800">
412
- {error}
413
- </div>
414
- )}
415
437
  <CrudForm<EditUserFormValues>
416
438
  title={t('auth.users.form.title.edit', 'Edit User')}
417
439
  backHref="/backend/users"
@@ -0,0 +1,173 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { Button } from '@open-mercato/ui/primitives/button'
4
+ import { Alert } from '@open-mercato/ui/primitives/alert'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import {
7
+ applyAddMissingDependency,
8
+ applyRemoveDependents,
9
+ applyRestoreDependency,
10
+ resolveAclDependencyDiagnostics,
11
+ type FeatureDescriptor,
12
+ } from '@open-mercato/shared/security/aclDependencies'
13
+
14
+ export type AclDependencyDiagnosticsPanelProps = {
15
+ granted: readonly string[]
16
+ catalog: readonly FeatureDescriptor[]
17
+ onGrantedChange: (updater: (prev: string[]) => string[]) => void
18
+ hideUnknownReferences?: boolean
19
+ }
20
+
21
+ export function AclDependencyDiagnosticsPanel({
22
+ granted,
23
+ catalog,
24
+ onGrantedChange,
25
+ hideUnknownReferences,
26
+ }: AclDependencyDiagnosticsPanelProps) {
27
+ const t = useT()
28
+ const diagnostics = React.useMemo(
29
+ () => resolveAclDependencyDiagnostics(granted, catalog),
30
+ [granted, catalog],
31
+ )
32
+ const titleById = React.useMemo(() => {
33
+ const map = new Map<string, string>()
34
+ for (const entry of catalog) {
35
+ if (entry?.title && !map.has(entry.id)) map.set(entry.id, entry.title)
36
+ }
37
+ return map
38
+ }, [catalog])
39
+ const featureLabel = React.useCallback((id: string) => titleById.get(id) ?? id, [titleById])
40
+
41
+ const hasMissing = diagnostics.missingDependencies.length > 0
42
+ const hasOrphaned = diagnostics.orphanedDependents.length > 0
43
+ const showUnknown = !hideUnknownReferences && diagnostics.unknownReferences.length > 0
44
+ if (!hasMissing && !hasOrphaned && !showUnknown) return null
45
+
46
+ const handleAdd = (dep: string) => onGrantedChange((prev) => applyAddMissingDependency(prev, dep))
47
+ const handleRestore = (dep: string) => onGrantedChange((prev) => applyRestoreDependency(prev, dep))
48
+ const handleDropDependents = (dependents: readonly string[]) =>
49
+ onGrantedChange((prev) => applyRemoveDependents(prev, dependents))
50
+
51
+ return (
52
+ <div className="space-y-3" data-testid="acl-dependency-diagnostics">
53
+ {hasMissing && (
54
+ <Alert status="warning" style="lighter">
55
+ <div className="space-y-2">
56
+ <div className="text-sm font-medium">
57
+ {t(
58
+ 'auth.acl.deps.missing.title',
59
+ 'Some granted permissions need other permissions to work:',
60
+ )}
61
+ </div>
62
+ <ul className="space-y-1 text-sm">
63
+ {diagnostics.missingDependencies.map((row) => (
64
+ <li
65
+ key={row.feature}
66
+ className="flex flex-wrap items-center gap-x-2 gap-y-1"
67
+ data-testid={`missing-${row.feature}`}
68
+ >
69
+ <span>
70
+ {t('auth.acl.deps.missing.item', '"{feature}" needs:', {
71
+ feature: featureLabel(row.feature),
72
+ })}
73
+ </span>
74
+ {row.missing.map((dep) => (
75
+ <span key={dep} className="inline-flex items-center gap-1">
76
+ <span className="font-mono text-xs text-muted-foreground">
77
+ {featureLabel(dep)}
78
+ </span>
79
+ <Button
80
+ type="button"
81
+ size="sm"
82
+ variant="outline"
83
+ onClick={() => handleAdd(dep)}
84
+ data-testid={`add-missing-${row.feature}-${dep}`}
85
+ >
86
+ {t('auth.acl.deps.missing.add', 'Add "{dep}"', {
87
+ dep: featureLabel(dep),
88
+ })}
89
+ </Button>
90
+ </span>
91
+ ))}
92
+ </li>
93
+ ))}
94
+ </ul>
95
+ </div>
96
+ </Alert>
97
+ )}
98
+
99
+ {hasOrphaned && (
100
+ <Alert status="warning" style="lighter">
101
+ <div className="space-y-2">
102
+ <div className="text-sm font-medium">
103
+ {t(
104
+ 'auth.acl.deps.orphaned.title',
105
+ 'Removing a permission that other granted permissions need:',
106
+ )}
107
+ </div>
108
+ <ul className="space-y-1 text-sm">
109
+ {diagnostics.orphanedDependents.map((row) => (
110
+ <li
111
+ key={row.dependency}
112
+ className="flex flex-wrap items-center gap-x-2 gap-y-1"
113
+ data-testid={`orphaned-${row.dependency}`}
114
+ >
115
+ <span>
116
+ {t('auth.acl.deps.orphaned.item', '"{dependency}" is required by:', {
117
+ dependency: featureLabel(row.dependency),
118
+ })}
119
+ </span>
120
+ <span className="font-mono text-xs text-muted-foreground">
121
+ {row.dependents
122
+ .map((dependent) => featureLabel(dependent))
123
+ .join(', ')}
124
+ </span>
125
+ <Button
126
+ type="button"
127
+ size="sm"
128
+ variant="outline"
129
+ onClick={() => handleRestore(row.dependency)}
130
+ data-testid={`restore-${row.dependency}`}
131
+ >
132
+ {t('auth.acl.deps.orphaned.restore', 'Restore "{dependency}"', {
133
+ dependency: featureLabel(row.dependency),
134
+ })}
135
+ </Button>
136
+ <Button
137
+ type="button"
138
+ size="sm"
139
+ variant="outline"
140
+ onClick={() => handleDropDependents(row.dependents)}
141
+ data-testid={`drop-dependents-${row.dependency}`}
142
+ >
143
+ {t('auth.acl.deps.orphaned.drop', 'Drop dependents')}
144
+ </Button>
145
+ </li>
146
+ ))}
147
+ </ul>
148
+ </div>
149
+ </Alert>
150
+ )}
151
+
152
+ {showUnknown && (
153
+ <Alert status="warning" style="lighter">
154
+ <div className="space-y-1">
155
+ <div className="text-sm font-medium">
156
+ {t(
157
+ 'auth.acl.deps.unknown.title',
158
+ 'Some declared dependencies do not match any known permission:',
159
+ )}
160
+ </div>
161
+ <ul className="space-y-1 text-sm font-mono text-xs text-muted-foreground">
162
+ {diagnostics.unknownReferences.map((row) => (
163
+ <li key={row.feature} data-testid={`unknown-${row.feature}`}>
164
+ {row.feature} → {row.missing.join(', ')}
165
+ </li>
166
+ ))}
167
+ </ul>
168
+ </div>
169
+ </Alert>
170
+ )}
171
+ </div>
172
+ )
173
+ }
@@ -5,6 +5,8 @@ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
5
5
  import Link from 'next/link'
6
6
  import { hasFeature, matchFeature } from '@open-mercato/shared/security/features'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
+ import type { FeatureDescriptor } from '@open-mercato/shared/security/aclDependencies'
9
+ import { AclDependencyDiagnosticsPanel } from './AclDependencyDiagnosticsPanel'
8
10
 
9
11
  function toTitleCase(value: string): string {
10
12
  return value.replace(/[-_.]/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase())
@@ -36,7 +38,7 @@ function formatWildcardLabel(moduleId: string, wildcard: string): string {
36
38
  return `All ${suffix.split('.').map(toTitleCase).join(' / ')}`
37
39
  }
38
40
 
39
- type Feature = { id: string; title: string; module: string }
41
+ type Feature = { id: string; title: string; module: string; dependsOn?: string[] }
40
42
  type ModuleInfo = { id: string; title: string }
41
43
  type RoleListItem = { id?: string | null; name?: string | null }
42
44
  type RoleListResponse = { items?: RoleListItem[] }
@@ -364,9 +366,9 @@ export function AclEditor({
364
366
  <div className="rounded border border-blue-200 bg-blue-50 p-3">
365
367
  <div className="text-sm font-medium text-blue-900">Global wildcard (*) enabled</div>
366
368
  <div className="text-xs text-blue-700 mt-1">This grants access to all features in the system.</div>
367
- <Button
368
- variant="outline"
369
- size="sm"
369
+ <Button
370
+ variant="outline"
371
+ size="sm"
370
372
  className="mt-2"
371
373
  onClick={() => updateGranted((prev) => prev.filter((x) => x !== '*'))}
372
374
  >
@@ -374,6 +376,14 @@ export function AclEditor({
374
376
  </Button>
375
377
  </div>
376
378
  )}
379
+ {!hasGlobalWildcard && (
380
+ <AclDependencyDiagnosticsPanel
381
+ granted={granted}
382
+ catalog={features as readonly FeatureDescriptor[]}
383
+ onGrantedChange={updateGranted}
384
+ hideUnknownReferences={process.env.NODE_ENV === 'production'}
385
+ />
386
+ )}
377
387
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
378
388
  {grouped.map((group) => {
379
389
  const moduleWildcard = isModuleWildcardEnabled(group.moduleId)
@@ -4,6 +4,14 @@
4
4
  "auth.accessDenied.message": "Du hast keine Berechtigung, diese Seite anzuzeigen. Bitte wende dich an deinen Administrator.",
5
5
  "auth.accessDenied.title": "Zugriff verweigert",
6
6
  "auth.acl.allowAllOrganizations": "Alle Organisationen zulassen",
7
+ "auth.acl.deps.missing.add": "\"{dep}\" hinzufügen",
8
+ "auth.acl.deps.missing.item": "\"{feature}\" benötigt:",
9
+ "auth.acl.deps.missing.title": "Einige gewährte Berechtigungen benötigen weitere Berechtigungen, um zu funktionieren:",
10
+ "auth.acl.deps.orphaned.drop": "Abhängige entfernen",
11
+ "auth.acl.deps.orphaned.item": "\"{dependency}\" wird benötigt von:",
12
+ "auth.acl.deps.orphaned.restore": "\"{dependency}\" wiederherstellen",
13
+ "auth.acl.deps.orphaned.title": "Entfernung einer Berechtigung, die andere gewährte Berechtigungen benötigen:",
14
+ "auth.acl.deps.unknown.title": "Einige deklarierte Abhängigkeiten passen zu keiner bekannten Berechtigung:",
7
15
  "auth.acl.organizationWarning": "Organisationseinschränkungen werden nur gespeichert, wenn mindestens eine Berechtigungsüberschreibung ausgewählt ist. Füge eine Berechtigung hinzu oder aktiviere einen Modul-Wildcard vor dem Speichern.",
8
16
  "auth.acl.organizationsScope": "Organisationsbereich",
9
17
  "auth.acl.restricted": "Eingeschränkt",
@@ -164,6 +172,7 @@
164
172
  "auth.users.form.action.resendInvite": "Einladung erneut senden",
165
173
  "auth.users.form.action.resendingInvite": "Wird gesendet...",
166
174
  "auth.users.form.action.save": "Speichern",
175
+ "auth.users.form.actions.backToList": "Zurück zu Benutzern",
167
176
  "auth.users.form.errors.aclUpdate": "Aktualisierung der Benutzerberechtigungen fehlgeschlagen",
168
177
  "auth.users.form.errors.delete": "Benutzer konnte nicht gelöscht werden",
169
178
  "auth.users.form.errors.inviteResend": "Einladungs-E-Mail konnte nicht gesendet werden",
@@ -4,6 +4,14 @@
4
4
  "auth.accessDenied.message": "You do not have permission to view this page. Please contact your administrator.",
5
5
  "auth.accessDenied.title": "Access Denied",
6
6
  "auth.acl.allowAllOrganizations": "Allow all organizations",
7
+ "auth.acl.deps.missing.add": "Add \"{dep}\"",
8
+ "auth.acl.deps.missing.item": "\"{feature}\" needs:",
9
+ "auth.acl.deps.missing.title": "Some granted permissions need other permissions to work:",
10
+ "auth.acl.deps.orphaned.drop": "Drop dependents",
11
+ "auth.acl.deps.orphaned.item": "\"{dependency}\" is required by:",
12
+ "auth.acl.deps.orphaned.restore": "Restore \"{dependency}\"",
13
+ "auth.acl.deps.orphaned.title": "Removing a permission that other granted permissions need:",
14
+ "auth.acl.deps.unknown.title": "Some declared dependencies do not match any known permission:",
7
15
  "auth.acl.organizationWarning": "Organization restrictions are saved only when at least one feature override is selected. Add a feature or enable a module wildcard before saving.",
8
16
  "auth.acl.organizationsScope": "Organizations scope",
9
17
  "auth.acl.restricted": "Restricted",
@@ -164,6 +172,7 @@
164
172
  "auth.users.form.action.resendInvite": "Resend Invite",
165
173
  "auth.users.form.action.resendingInvite": "Sending...",
166
174
  "auth.users.form.action.save": "Save",
175
+ "auth.users.form.actions.backToList": "Back to users",
167
176
  "auth.users.form.errors.aclUpdate": "Failed to update user access control",
168
177
  "auth.users.form.errors.delete": "Failed to delete user",
169
178
  "auth.users.form.errors.inviteResend": "Failed to send invitation email",
@@ -4,6 +4,14 @@
4
4
  "auth.accessDenied.message": "No tienes permiso para ver esta página. Por favor, contacta con tu administrador.",
5
5
  "auth.accessDenied.title": "Acceso denegado",
6
6
  "auth.acl.allowAllOrganizations": "Permitir todas las organizaciones",
7
+ "auth.acl.deps.missing.add": "Agregar \"{dep}\"",
8
+ "auth.acl.deps.missing.item": "\"{feature}\" necesita:",
9
+ "auth.acl.deps.missing.title": "Algunos permisos otorgados necesitan otros permisos para funcionar:",
10
+ "auth.acl.deps.orphaned.drop": "Eliminar dependientes",
11
+ "auth.acl.deps.orphaned.item": "\"{dependency}\" es requerido por:",
12
+ "auth.acl.deps.orphaned.restore": "Restaurar \"{dependency}\"",
13
+ "auth.acl.deps.orphaned.title": "Eliminando un permiso que otros permisos otorgados necesitan:",
14
+ "auth.acl.deps.unknown.title": "Algunas dependencias declaradas no coinciden con ningún permiso conocido:",
7
15
  "auth.acl.organizationWarning": "Las restricciones de organización se guardan solo cuando se selecciona al menos una anulación de función. Agrega una función o habilita un comodín de módulo antes de guardar.",
8
16
  "auth.acl.organizationsScope": "Alcance de organizaciones",
9
17
  "auth.acl.restricted": "Restringido",
@@ -164,6 +172,7 @@
164
172
  "auth.users.form.action.resendInvite": "Reenviar invitación",
165
173
  "auth.users.form.action.resendingInvite": "Enviando...",
166
174
  "auth.users.form.action.save": "Guardar",
175
+ "auth.users.form.actions.backToList": "Volver a usuarios",
167
176
  "auth.users.form.errors.aclUpdate": "No se pudo actualizar el control de acceso del usuario",
168
177
  "auth.users.form.errors.delete": "No se pudo eliminar el usuario",
169
178
  "auth.users.form.errors.inviteResend": "No se pudo enviar el correo de invitación",
@@ -4,6 +4,14 @@
4
4
  "auth.accessDenied.message": "Nie masz uprawnień do wyświetlenia tej strony. Skontaktuj się z administratorem.",
5
5
  "auth.accessDenied.title": "Brak dostępu",
6
6
  "auth.acl.allowAllOrganizations": "Zezwól na wszystkie organizacje",
7
+ "auth.acl.deps.missing.add": "Dodaj \"{dep}\"",
8
+ "auth.acl.deps.missing.item": "\"{feature}\" wymaga:",
9
+ "auth.acl.deps.missing.title": "Niektóre przyznane uprawnienia wymagają innych uprawnień do działania:",
10
+ "auth.acl.deps.orphaned.drop": "Usuń zależne",
11
+ "auth.acl.deps.orphaned.item": "\"{dependency}\" jest wymagane przez:",
12
+ "auth.acl.deps.orphaned.restore": "Przywróć \"{dependency}\"",
13
+ "auth.acl.deps.orphaned.title": "Usuwasz uprawnienie wymagane przez inne przyznane uprawnienia:",
14
+ "auth.acl.deps.unknown.title": "Niektóre zadeklarowane zależności nie pasują do żadnego znanego uprawnienia:",
7
15
  "auth.acl.organizationWarning": "Ograniczenia organizacyjne są zapisywane tylko wtedy, gdy wybrano co najmniej jedno nadpisanie uprawnień. Dodaj uprawnienie lub włącz wildcard modułu przed zapisaniem.",
8
16
  "auth.acl.organizationsScope": "Zakres organizacji",
9
17
  "auth.acl.restricted": "Ograniczone",
@@ -164,6 +172,7 @@
164
172
  "auth.users.form.action.resendInvite": "Wyślij zaproszenie ponownie",
165
173
  "auth.users.form.action.resendingInvite": "Wysyłanie...",
166
174
  "auth.users.form.action.save": "Zapisz",
175
+ "auth.users.form.actions.backToList": "Powrót do listy użytkowników",
167
176
  "auth.users.form.errors.aclUpdate": "Nie udało się zaktualizować uprawnień użytkownika",
168
177
  "auth.users.form.errors.delete": "Nie udało się usunąć użytkownika",
169
178
  "auth.users.form.errors.inviteResend": "Nie udało się wysłać e-maila z zaproszeniem",
@@ -1,10 +1,35 @@
1
1
  export const features = [
2
- { id: 'catalog.products.view', title: 'View catalog products', module: 'catalog' },
3
- { id: 'catalog.products.manage', title: 'Manage catalog products', module: 'catalog' },
2
+ {
3
+ id: 'catalog.products.view',
4
+ title: 'View catalog products',
5
+ module: 'catalog',
6
+ dependsOn: ['currencies.view', 'dictionaries.view'],
7
+ },
8
+ {
9
+ id: 'catalog.products.manage',
10
+ title: 'Manage catalog products',
11
+ module: 'catalog',
12
+ dependsOn: ['catalog.products.view'],
13
+ },
4
14
  { id: 'catalog.categories.view', title: 'View catalog categories', module: 'catalog' },
5
- { id: 'catalog.categories.manage', title: 'Manage catalog categories', module: 'catalog' },
6
- { id: 'catalog.variants.manage', title: 'Manage catalog variants', module: 'catalog' },
7
- { id: 'catalog.pricing.manage', title: 'Manage catalog pricing', module: 'catalog' },
15
+ {
16
+ id: 'catalog.categories.manage',
17
+ title: 'Manage catalog categories',
18
+ module: 'catalog',
19
+ dependsOn: ['catalog.categories.view'],
20
+ },
21
+ {
22
+ id: 'catalog.variants.manage',
23
+ title: 'Manage catalog variants',
24
+ module: 'catalog',
25
+ dependsOn: ['catalog.products.view'],
26
+ },
27
+ {
28
+ id: 'catalog.pricing.manage',
29
+ title: 'Manage catalog pricing',
30
+ module: 'catalog',
31
+ dependsOn: ['catalog.products.view', 'currencies.view'],
32
+ },
8
33
  { id: 'catalog.settings.manage', title: 'Manage catalog settings', module: 'catalog' },
9
34
  ]
10
35
 
@@ -4,7 +4,7 @@ import * as React from "react";
4
4
  import Link from "next/link";
5
5
  import dynamic from "next/dynamic";
6
6
  import { Page, PageBody } from "@open-mercato/ui/backend/Page";
7
- import { ErrorMessage } from "@open-mercato/ui/backend/detail";
7
+ import { ErrorMessage, RecordNotFoundState } from "@open-mercato/ui/backend/detail";
8
8
  import {
9
9
  CrudForm,
10
10
  type CrudFormGroup,
@@ -327,6 +327,7 @@ export default function EditCatalogProductPage({
327
327
  React.useState<Partial<ProductFormValues> | null>(null);
328
328
  const [loading, setLoading] = React.useState(true);
329
329
  const [error, setError] = React.useState<string | null>(null);
330
+ const [isNotFound, setIsNotFound] = React.useState(false);
330
331
  const offerSnapshotsRef = React.useRef<OfferSnapshot[]>([]);
331
332
  const initialConversionsRef = React.useRef<ProductUnitConversionDraft[]>([]);
332
333
  const [categorizeOptions, setCategorizeOptions] = React.useState<{
@@ -552,6 +553,7 @@ export default function EditCatalogProductPage({
552
553
  async function loadProduct() {
553
554
  setLoading(true);
554
555
  setError(null);
556
+ setIsNotFound(false);
555
557
  try {
556
558
  const productRes = await apiCall<ProductResponse>(
557
559
  `/api/catalog/products?id=${encodeURIComponent(productId!)}&page=1&pageSize=1&withDeleted=false`,
@@ -567,10 +569,10 @@ export default function EditCatalogProductPage({
567
569
  const record = Array.isArray(productRes.result?.items)
568
570
  ? productRes.result?.items?.[0]
569
571
  : undefined;
570
- if (!record)
571
- throw new Error(
572
- t("catalog.products.edit.errors.notFound", "Product not found."),
573
- );
572
+ if (!record) {
573
+ if (!cancelled) setIsNotFound(true);
574
+ return;
575
+ }
574
576
  const rawMetadata = isRecord(record.metadata)
575
577
  ? (record.metadata as Record<string, unknown>)
576
578
  : null;
@@ -1341,6 +1343,20 @@ export default function EditCatalogProductPage({
1341
1343
  );
1342
1344
  }
1343
1345
 
1346
+ if (isNotFound && !loading) {
1347
+ return (
1348
+ <Page>
1349
+ <PageBody>
1350
+ <RecordNotFoundState
1351
+ label={t("catalog.products.edit.errors.notFound", "Product not found.")}
1352
+ backHref="/backend/catalog/products"
1353
+ backLabel={t("catalog.products.edit.actions.backToList", "Back to products")}
1354
+ />
1355
+ </PageBody>
1356
+ </Page>
1357
+ );
1358
+ }
1359
+
1344
1360
  if (error && !loading) {
1345
1361
  return (
1346
1362
  <Page>