@asteby/metacore-runtime-react 18.13.3 → 18.14.0

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.
@@ -0,0 +1,102 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import { cleanup, render, screen } from '@testing-library/react'
4
+
5
+ // Sin `globals: true` en vitest, RTL no auto-limpia entre tests.
6
+ afterEach(cleanup)
7
+ import {
8
+ PermissionsProvider,
9
+ useCan,
10
+ usePermissionsActive,
11
+ makeCan,
12
+ capabilityForActionKey,
13
+ modelCapability,
14
+ } from '../permissions-context'
15
+
16
+ function Probe({ capability }: { capability: string }) {
17
+ const can = useCan()
18
+ return <span data-testid="probe">{can(capability) ? 'allowed' : 'denied'}</span>
19
+ }
20
+
21
+ function ActiveProbe() {
22
+ const active = usePermissionsActive()
23
+ return <span data-testid="active">{active ? 'yes' : 'no'}</span>
24
+ }
25
+
26
+ describe('makeCan', () => {
27
+ it('isAdmin permite todo', () => {
28
+ const can = makeCan([], true)
29
+ expect(can('pos_orders.create')).toBe(true)
30
+ expect(can('cualquier.cosa')).toBe(true)
31
+ })
32
+
33
+ it('capability exacta en la lista → true; ausente → false', () => {
34
+ const can = makeCan(['pos_orders.index', 'general.work_after_hours'], false)
35
+ expect(can('pos_orders.index')).toBe(true)
36
+ expect(can('general.work_after_hours')).toBe(true)
37
+ expect(can('pos_orders.create')).toBe(false)
38
+ })
39
+
40
+ it('wildcard "*" permite todo', () => {
41
+ const can = makeCan(['*'], false)
42
+ expect(can('pos_orders.delete')).toBe(true)
43
+ expect(can('lo.que.sea')).toBe(true)
44
+ })
45
+ })
46
+
47
+ describe('useCan', () => {
48
+ it('sin provider → siempre true (comportamiento legacy, nada se oculta)', () => {
49
+ render(<Probe capability="pos_orders.create" />)
50
+ expect(screen.getByTestId('probe').textContent).toBe('allowed')
51
+ })
52
+
53
+ it('con provider no-admin: exacta permitida, resto denegado', () => {
54
+ render(
55
+ <PermissionsProvider permissions={['pos_orders.index']} isAdmin={false}>
56
+ <Probe capability="pos_orders.index" />
57
+ </PermissionsProvider>,
58
+ )
59
+ expect(screen.getByTestId('probe').textContent).toBe('allowed')
60
+
61
+ render(
62
+ <PermissionsProvider permissions={['pos_orders.index']} isAdmin={false}>
63
+ <Probe capability="pos_orders.create" />
64
+ </PermissionsProvider>,
65
+ )
66
+ expect(screen.getAllByTestId('probe')[1].textContent).toBe('denied')
67
+ })
68
+
69
+ it('con provider isAdmin → todo permitido', () => {
70
+ render(
71
+ <PermissionsProvider permissions={[]} isAdmin={true}>
72
+ <Probe capability="pos_orders.delete" />
73
+ </PermissionsProvider>,
74
+ )
75
+ expect(screen.getByTestId('probe').textContent).toBe('allowed')
76
+ })
77
+
78
+ it('usePermissionsActive refleja la presencia del provider', () => {
79
+ render(<ActiveProbe />)
80
+ expect(screen.getByTestId('active').textContent).toBe('no')
81
+ render(
82
+ <PermissionsProvider permissions={[]} isAdmin={false}>
83
+ <ActiveProbe />
84
+ </PermissionsProvider>,
85
+ )
86
+ expect(screen.getAllByTestId('active')[1].textContent).toBe('yes')
87
+ })
88
+ })
89
+
90
+ describe('capability mapping', () => {
91
+ it('view→index, edit→update, resto verbatim', () => {
92
+ expect(capabilityForActionKey('view')).toBe('index')
93
+ expect(capabilityForActionKey('edit')).toBe('update')
94
+ expect(capabilityForActionKey('delete')).toBe('delete')
95
+ expect(capabilityForActionKey('pagar')).toBe('pagar')
96
+ })
97
+
98
+ it('modelCapability lowercasea el modelo', () => {
99
+ expect(modelCapability('PosOrders', 'create')).toBe('posorders.create')
100
+ expect(modelCapability('pos_orders', 'edit')).toBe('pos_orders.update')
101
+ })
102
+ })
@@ -0,0 +1,172 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ // Sin `globals: true` en vitest, RTL no auto-limpia entre tests.
6
+ afterEach(cleanup)
7
+ import {
8
+ PermissionsManager,
9
+ moduleActionCapability,
10
+ moduleCapabilities,
11
+ grantedCountForModule,
12
+ capabilitySetsEqual,
13
+ type PermissionsCatalog,
14
+ type RoleDef,
15
+ } from '../permissions-manager'
16
+
17
+ const catalog: PermissionsCatalog = {
18
+ modules: [
19
+ {
20
+ key: 'pos_orders',
21
+ label: 'Pedidos POS',
22
+ addon_key: 'pos',
23
+ addon_label: 'Punto de venta',
24
+ actions: [
25
+ { key: 'index', label: 'Listar', icon: 'List', kind: 'crud' },
26
+ { key: 'create', label: 'Crear', icon: 'Plus', kind: 'crud' },
27
+ { key: 'pagar', label: 'Pagar', icon: 'CreditCard', kind: 'custom' },
28
+ ],
29
+ },
30
+ ],
31
+ general: [
32
+ {
33
+ key: 'general.work_after_hours',
34
+ label: 'Trabajar fuera de horario',
35
+ description: 'Permite operar fuera del horario configurado.',
36
+ },
37
+ ],
38
+ }
39
+
40
+ const roles: RoleDef[] = [{ id: 'r1', name: 'cashier', label: 'Cajero', color: '#22c55e' }]
41
+
42
+ function makeProps(overrides: Partial<Parameters<typeof PermissionsManager>[0]> = {}) {
43
+ return {
44
+ loadModules: vi.fn(async () => catalog),
45
+ loadRoles: vi.fn(async () => roles),
46
+ loadRolePermissions: vi.fn(async () => ['pos_orders.index']),
47
+ syncRolePermissions: vi.fn(async () => {}),
48
+ ...overrides,
49
+ }
50
+ }
51
+
52
+ describe('helpers puros', () => {
53
+ it('moduleActionCapability lowercasea el módulo', () => {
54
+ expect(moduleActionCapability('Pos_Orders', 'pagar')).toBe('pos_orders.pagar')
55
+ })
56
+
57
+ it('moduleCapabilities y grantedCountForModule', () => {
58
+ const mod = catalog.modules[0]
59
+ expect(moduleCapabilities(mod)).toEqual([
60
+ 'pos_orders.index',
61
+ 'pos_orders.create',
62
+ 'pos_orders.pagar',
63
+ ])
64
+ expect(grantedCountForModule(new Set(['pos_orders.index', 'otra.cosa']), mod)).toBe(1)
65
+ })
66
+
67
+ it('capabilitySetsEqual', () => {
68
+ expect(capabilitySetsEqual(new Set(['a', 'b']), new Set(['b', 'a']))).toBe(true)
69
+ expect(capabilitySetsEqual(new Set(['a']), new Set(['a', 'b']))).toBe(false)
70
+ })
71
+ })
72
+
73
+ describe('PermissionsManager', () => {
74
+ it('renderiza catálogo con mocks, auto-selecciona rol y módulo, contador N/M', async () => {
75
+ const props = makeProps()
76
+ render(<PermissionsManager {...props} />)
77
+
78
+ // Auto-selección: primer rol + primer módulo, grid con las acciones.
79
+ expect(await screen.findByText('Acciones permitidas')).toBeTruthy()
80
+ expect(await screen.findByText('Pagar')).toBeTruthy()
81
+ expect(screen.getByText('1/3')).toBeTruthy()
82
+ expect(props.loadRolePermissions).toHaveBeenCalledWith('r1')
83
+
84
+ // Generales presentes con descripción.
85
+ expect(screen.getByText('Permisos Generales')).toBeTruthy()
86
+ expect(screen.getByText('Trabajar fuera de horario')).toBeTruthy()
87
+ })
88
+
89
+ it('marcar una acción + un general y guardar llama sync con el set completo correcto', async () => {
90
+ const props = makeProps()
91
+ render(<PermissionsManager {...props} />)
92
+ await screen.findByText('Pagar')
93
+
94
+ fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
95
+ fireEvent.click(screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ }))
96
+
97
+ // Dirty visible y guardar habilitado.
98
+ expect(screen.getByText('Cambios sin guardar')).toBeTruthy()
99
+ fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
100
+
101
+ await waitFor(() =>
102
+ expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
103
+ 'general.work_after_hours',
104
+ 'pos_orders.index',
105
+ 'pos_orders.pagar',
106
+ ]),
107
+ )
108
+ // Tras guardar, baseline = draft → dirty desaparece.
109
+ await waitFor(() => expect(screen.queryByText('Cambios sin guardar')).toBeNull())
110
+ })
111
+
112
+ it('desmarcar una otorgada también entra al delta', async () => {
113
+ const props = makeProps()
114
+ render(<PermissionsManager {...props} />)
115
+ await screen.findByText('Pagar')
116
+
117
+ fireEvent.click(screen.getByRole('checkbox', { name: /Listar/ }))
118
+ fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
119
+ await waitFor(() => expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', []))
120
+ })
121
+
122
+ it('marcar todo / limpiar operan sobre el módulo activo', async () => {
123
+ const props = makeProps()
124
+ render(<PermissionsManager {...props} />)
125
+ await screen.findByText('Pagar')
126
+
127
+ fireEvent.click(screen.getByRole('button', { name: /Marcar todo/ }))
128
+ expect(screen.getByText('3/3')).toBeTruthy()
129
+
130
+ fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
131
+ await waitFor(() =>
132
+ expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
133
+ 'pos_orders.create',
134
+ 'pos_orders.index',
135
+ 'pos_orders.pagar',
136
+ ]),
137
+ )
138
+
139
+ fireEvent.click(screen.getByRole('button', { name: /Limpiar/ }))
140
+ expect(screen.getByText('0/3')).toBeTruthy()
141
+ })
142
+
143
+ it('guardar deshabilitado sin cambios', async () => {
144
+ const props = makeProps()
145
+ render(<PermissionsManager {...props} />)
146
+ await screen.findByText('Pagar')
147
+ const save = screen.getByRole('button', { name: /Guardar permisos/ }) as HTMLButtonElement
148
+ expect(save.disabled).toBe(true)
149
+ })
150
+
151
+ it('oculta Nuevo rol / Editar / Eliminar cuando no hay mutators de rol', async () => {
152
+ const props = makeProps()
153
+ render(<PermissionsManager {...props} />)
154
+ await screen.findByText('Pagar')
155
+ expect(screen.queryByRole('button', { name: /Nuevo rol/ })).toBeNull()
156
+ expect(screen.queryByRole('button', { name: 'Editar rol' })).toBeNull()
157
+ expect(screen.queryByRole('button', { name: 'Eliminar rol' })).toBeNull()
158
+ })
159
+
160
+ it('muestra los CRUD de rol cuando los mutators existen', async () => {
161
+ const props = makeProps({
162
+ createRole: vi.fn(async () => {}),
163
+ updateRole: vi.fn(async () => {}),
164
+ deleteRole: vi.fn(async () => {}),
165
+ })
166
+ render(<PermissionsManager {...props} />)
167
+ await screen.findByText('Pagar')
168
+ expect(screen.getByRole('button', { name: /Nuevo rol/ })).toBeTruthy()
169
+ expect(screen.getByRole('button', { name: 'Editar rol' })).toBeTruthy()
170
+ expect(screen.getByRole('button', { name: 'Eliminar rol' })).toBeTruthy()
171
+ })
172
+ })
@@ -37,6 +37,7 @@ import { ExportDialog } from './dialogs/export'
37
37
  import { ImportDialog } from './dialogs/import'
38
38
  import { getModelExtension } from './model-extension-registry'
39
39
  import { ModelActionToolbar } from './model-action-toolbar'
40
+ import { useCan, modelCapability } from './permissions-context'
40
41
  import type { TableMetadata } from './types'
41
42
 
42
43
  export interface DynamicCRUDPageStrings {
@@ -165,9 +166,13 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
165
166
  // showing one in the page chrome is just visual duplication. Apps that
166
167
  // want it back can pass `hideRefresh={false}`.
167
168
  const effectiveHideRefresh = hideRefresh ?? ext?.hideRefresh ?? true
168
- const showCreate = enableCRUD && !effectiveHideCreate
169
- const showImport = enableCRUD && !effectiveHideImport
170
- const showExport = !effectiveHideExport
169
+ // Capability gating no-op unless the host mounts <PermissionsProvider>
170
+ // (useCan defaults to always-true), in which case create/export/import
171
+ // require `lowercase(model).create|export|import`.
172
+ const can = useCan()
173
+ const showCreate = enableCRUD && !effectiveHideCreate && can(modelCapability(model, 'create'))
174
+ const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
175
+ const showExport = !effectiveHideExport && can(modelCapability(model, 'export'))
171
176
  const showRefresh = !effectiveHideRefresh
172
177
 
173
178
  const handleRefresh = useCallback(() => {
@@ -71,6 +71,7 @@ import { OptionsContext } from './options-context'
71
71
  import { ActionModalDispatcher } from './action-modal-dispatcher'
72
72
  import type { TableMetadata, ApiResponse, ActionMetadata } from './types'
73
73
  import { getSearchableColumnKeys } from './column-visibility'
74
+ import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context'
74
75
  import { DynamicRecordDialog } from './dialogs/dynamic-record'
75
76
  import { ExportDialog } from './dialogs/export'
76
77
  import { ImportDialog } from './dialogs/import'
@@ -369,6 +370,18 @@ export function DynamicTable({
369
370
  [metadata],
370
371
  )
371
372
 
373
+ // Permission gating — only active when the host mounts <PermissionsProvider>.
374
+ // Without it `viewMetadata === metadata` and nothing changes (everything
375
+ // stays visible, exactly the legacy behaviour). With it, export/import
376
+ // buttons and row actions (incl. the implicit View/Edit/Delete trio) are
377
+ // filtered by `can(lowercase(model).<action>)`.
378
+ const can = useCan()
379
+ const permissionsActive = usePermissionsActive()
380
+ const viewMetadata = useMemo(() => {
381
+ if (!metadata || !permissionsActive) return metadata
382
+ return gateTableMetadata(metadata, model, can, (key, fallback) => t(key, { defaultValue: fallback }))
383
+ }, [metadata, permissionsActive, can, model, t])
384
+
372
385
  const buildFilterParams = useCallback(() => {
373
386
  const params: Record<string, any> = {}
374
387
  if (sorting.length > 0) {
@@ -640,19 +653,19 @@ export function DynamicTable({
640
653
  }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange])
641
654
 
642
655
  const columns = useMemo(() => {
643
- if (!metadata) return []
656
+ if (!viewMetadata) return []
644
657
  // Row-action column only renders per-row actions. Table-level placements
645
658
  // ("table"/"create") are surfaced by <ModelActionToolbar> at the page
646
659
  // level, so strip them here to avoid a meaningless per-row button.
647
- const rowMetadata = metadata.actions?.some((a) => a.placement === 'table' || a.placement === 'create')
648
- ? { ...metadata, actions: metadata.actions.filter((a) => !a.placement || a.placement === 'row') }
649
- : metadata
660
+ const rowMetadata = viewMetadata.actions?.some((a) => a.placement === 'table' || a.placement === 'create')
661
+ ? { ...viewMetadata, actions: viewMetadata.actions.filter((a) => !a.placement || a.placement === 'row') }
662
+ : viewMetadata
650
663
  const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs, timeZone, currency)
651
664
  const filteredBase = baseColumns.filter((col: ColumnDef<any>) => !hiddenColumns.includes(col.id as string))
652
665
  const actionsCol = filteredBase.find((c: ColumnDef<any>) => c.id === 'actions')
653
666
  const otherCols = filteredBase.filter((c: ColumnDef<any>) => c.id !== 'actions')
654
667
  return [...otherCols, ...extraColumns, ...(actionsCol ? [actionsCol] : [])]
655
- }, [metadata, handleInternalAction, hiddenColumns, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency])
668
+ }, [viewMetadata, handleInternalAction, hiddenColumns, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency])
656
669
 
657
670
  const filters = useMemo(() => [], [])
658
671
 
@@ -745,12 +758,12 @@ export function DynamicTable({
745
758
  onBulkDelete={() => setShowBulkDeleteConfirm(true)}
746
759
  extraActions={
747
760
  <>
748
- {metadata.canExport && (
761
+ {viewMetadata?.canExport && (
749
762
  <Button variant="outline" size="sm" className="h-8" onClick={() => setExportOpen(true)}>
750
763
  <Download className="h-4 w-4 mr-1" /> Exportar
751
764
  </Button>
752
765
  )}
753
- {metadata.canImport && (
766
+ {viewMetadata?.canImport && (
754
767
  <Button variant="outline" size="sm" className="h-8" onClick={() => setImportOpen(true)}>
755
768
  <Upload className="h-4 w-4 mr-1" /> Importar
756
769
  </Button>
@@ -997,10 +1010,10 @@ export function DynamicTable({
997
1010
  onSaved={handleRefresh}
998
1011
  />
999
1012
 
1000
- {metadata.canExport && (
1013
+ {viewMetadata?.canExport && (
1001
1014
  <ExportDialog open={exportOpen} onOpenChange={setExportOpen} model={model} metadata={metadata} currentFilters={buildFilterParams()} hasActiveFilters={hasActiveFilters} />
1002
1015
  )}
1003
- {metadata.canImport && (
1016
+ {viewMetadata?.canImport && (
1004
1017
  <ImportDialog open={importOpen} onOpenChange={setImportOpen} model={model} metadata={metadata} onImported={handleRefresh} />
1005
1018
  )}
1006
1019
  {actionModal.action && (
package/src/index.ts CHANGED
@@ -28,6 +28,32 @@ export {
28
28
  } from './addon-layout-context'
29
29
  export * from './slot'
30
30
  export * from './capability-gate'
31
+ export {
32
+ PermissionsProvider,
33
+ useCan,
34
+ usePermissionsActive,
35
+ makeCan,
36
+ capabilityForActionKey,
37
+ modelCapability,
38
+ gateTableMetadata,
39
+ type CanFn,
40
+ type PermissionsProviderProps,
41
+ } from './permissions-context'
42
+ export {
43
+ PermissionsManager,
44
+ moduleActionCapability,
45
+ moduleCapabilities,
46
+ grantedCountForModule,
47
+ capabilitySetsEqual,
48
+ defaultActionIcon,
49
+ type PermissionsManagerProps,
50
+ type PermissionsCatalog,
51
+ type PermissionModuleDef,
52
+ type PermissionActionDef,
53
+ type GeneralPermissionDef,
54
+ type RoleDef,
55
+ type RoleInput,
56
+ } from './permissions-manager'
31
57
  export * from './org-runtime-context'
32
58
  export * from './org-runtime-provider'
33
59
  export * from './navigation-builder'
@@ -23,6 +23,7 @@ import { useApi } from './api-context'
23
23
  import { useMetadataCache } from './metadata-cache'
24
24
  import { DynamicIcon } from './dynamic-icon'
25
25
  import { ActionModalDispatcher } from './action-modal-dispatcher'
26
+ import { useCan, modelCapability } from './permissions-context'
26
27
  import type { ActionDefinition, ActionMetadata, TableMetadata } from './types'
27
28
 
28
29
  export type ActionPlacement = 'row' | 'table' | 'create'
@@ -108,7 +109,14 @@ export function ModelActionToolbar({
108
109
  onChange,
109
110
  className,
110
111
  }: ModelActionToolbarProps) {
111
- const surfaced = useModelActions(model, placements, actions)
112
+ const all = useModelActions(model, placements, actions)
113
+ // Capability gating — always-true without a <PermissionsProvider>. Custom
114
+ // table/create actions map onto `lowercase(model).<action_key>`.
115
+ const can = useCan()
116
+ const surfaced = useMemo(
117
+ () => all.filter((a) => can(modelCapability(model, a.key))),
118
+ [all, can, model],
119
+ )
112
120
  const [active, setActive] = useState<ActionMetadata | null>(null)
113
121
  const dataEndpoint = endpoint ?? `/data/${model}/me`
114
122
 
@@ -0,0 +1,158 @@
1
+ // permissions-context — runtime permission primitives for dynamic hosts.
2
+ //
3
+ // The host loads the session's capability set (e.g. ops `GET /permissions/me`)
4
+ // and mounts <PermissionsProvider permissions={caps} isAdmin={me.is_admin}>
5
+ // once at the root. Any SDK component (or host code) then calls `useCan()` to
6
+ // gate UI by capability:
7
+ //
8
+ // const can = useCan()
9
+ // can('pos_orders.create') // → boolean
10
+ //
11
+ // Capability format is the canonical `lowercase(<model_table>).<action_key>`
12
+ // derived from installed manifests (CRUD: index|create|update|delete|export|
13
+ // import; custom actions use their own key, e.g. `pos_orders.pagar`). General
14
+ // flags live under the `general` module (`general.work_after_hours`).
15
+ //
16
+ // Semantics:
17
+ // - isAdmin → every capability allowed (superrole bypass mirror).
18
+ // - the list contains the exact capability or the `*` wildcard → allowed.
19
+ // - NO provider mounted → `useCan()` returns an always-true function, so
20
+ // every existing host keeps its current behaviour (nothing is hidden).
21
+ // Deny-by-default only kicks in once the host opts in by mounting the
22
+ // provider; the backend enforcement remains the source of truth.
23
+ import React, { createContext, useContext, useMemo } from 'react'
24
+ import type { TableMetadata, ActionDefinition } from './types'
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Predicate answering "can the current user use this capability?". */
31
+ export type CanFn = (capability: string) => boolean
32
+
33
+ export interface PermissionsProviderProps {
34
+ /** Granted capabilities (`"pos_orders.create"`, `"general.x"`, or `"*"`). */
35
+ permissions: string[]
36
+ /** Superrole bypass — admins/owners see everything, no filtering at all. */
37
+ isAdmin: boolean
38
+ children: React.ReactNode
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Core
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Builds the capability predicate from a raw permission list. Pure — exported
47
+ * so hosts/tests can evaluate permissions outside React.
48
+ */
49
+ export function makeCan(permissions: string[], isAdmin: boolean): CanFn {
50
+ if (isAdmin) return () => true
51
+ const set = new Set(permissions)
52
+ if (set.has('*')) return () => true
53
+ return (capability) => set.has(capability)
54
+ }
55
+
56
+ const ALWAYS_ALLOW: CanFn = () => true
57
+
58
+ const PermissionsContext = createContext<CanFn | null>(null)
59
+
60
+ export function PermissionsProvider({ permissions, isAdmin, children }: PermissionsProviderProps) {
61
+ const can = useMemo(() => makeCan(permissions, isAdmin), [permissions, isAdmin])
62
+ return <PermissionsContext.Provider value={can}>{children}</PermissionsContext.Provider>
63
+ }
64
+
65
+ /**
66
+ * Returns the capability predicate. Without a <PermissionsProvider> ancestor
67
+ * it returns an always-true function — existing hosts that never mount the
68
+ * provider keep today's "everything visible" behaviour.
69
+ */
70
+ export function useCan(): CanFn {
71
+ return useContext(PermissionsContext) ?? ALWAYS_ALLOW
72
+ }
73
+
74
+ /** True when a <PermissionsProvider> is mounted above (permission gating active). */
75
+ export function usePermissionsActive(): boolean {
76
+ return useContext(PermissionsContext) !== null
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Table-metadata gating (consumed by DynamicTable / DynamicCRUDPage /
81
+ // ModelActionToolbar — exported for hosts with bespoke tables)
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /**
85
+ * Maps a row/table action key onto the capability action segment. The UI's
86
+ * legacy `view`/`edit` keys correspond to the kernel's `index`/`update`
87
+ * capabilities; everything else (delete, custom keys) maps verbatim.
88
+ */
89
+ export function capabilityForActionKey(actionKey: string): string {
90
+ if (actionKey === 'view') return 'index'
91
+ if (actionKey === 'edit') return 'update'
92
+ return actionKey
93
+ }
94
+
95
+ /** Canonical capability for an action on a model: `lowercase(model).<action>`. */
96
+ export function modelCapability(model: string, actionKey: string): string {
97
+ return `${model.toLowerCase()}.${capabilityForActionKey(actionKey)}`
98
+ }
99
+
100
+ const DEFAULT_TRIO: { key: string; i18nKey: string; fallback: string; icon: string }[] = [
101
+ { key: 'view', i18nKey: 'datatable.view', fallback: 'Ver', icon: 'Eye' },
102
+ { key: 'edit', i18nKey: 'datatable.edit', fallback: 'Editar', icon: 'Pencil' },
103
+ { key: 'delete', i18nKey: 'datatable.delete', fallback: 'Eliminar', icon: 'Trash2' },
104
+ ]
105
+
106
+ /**
107
+ * Applies the capability predicate to a model's table metadata:
108
+ * - `canExport` / `canImport` are ANDed with `can(model.export|import)`.
109
+ * - explicit row/table actions are filtered by `can(model.<key>)` (with the
110
+ * view→index / edit→update mapping above).
111
+ * - when the metadata has NO explicit actions but `enableCRUDActions` is on,
112
+ * the implicit View/Edit/Delete trio is materialized here as explicit
113
+ * actions so individual entries can be dropped; `tx` resolves their labels
114
+ * (defaults to the Spanish fallbacks used by the column factory).
115
+ *
116
+ * Pure + idempotent. Callers should only invoke it when a provider is active
117
+ * (`usePermissionsActive()`), otherwise pass the metadata through untouched.
118
+ */
119
+ export function gateTableMetadata(
120
+ metadata: TableMetadata,
121
+ model: string,
122
+ can: CanFn,
123
+ tx: (i18nKey: string, fallback: string) => string = (_k, fallback) => fallback,
124
+ ): TableMetadata {
125
+ const allowed = (key: string) => can(modelCapability(model, key))
126
+
127
+ const explicit = metadata.actions ?? []
128
+ const hasExplicit = (metadata.hasActions ?? explicit.length > 0) && explicit.length > 0
129
+ const base: ActionDefinition[] = hasExplicit
130
+ ? explicit
131
+ : metadata.enableCRUDActions
132
+ ? DEFAULT_TRIO.map(
133
+ (a) =>
134
+ ({
135
+ key: a.key,
136
+ name: a.key,
137
+ label: tx(a.i18nKey, a.fallback),
138
+ icon: a.icon,
139
+ }) as ActionDefinition,
140
+ )
141
+ : []
142
+ const actions = base.filter((a) => allowed(a.key))
143
+
144
+ return {
145
+ ...metadata,
146
+ actions,
147
+ hasActions: actions.length > 0,
148
+ // The column factory synthesizes the implicit CRUD trio whenever the
149
+ // action list is empty and this flag is on — turn it off once gating
150
+ // has materialized (and possibly emptied) the list so nothing leaks
151
+ // back in.
152
+ enableCRUDActions: metadata.enableCRUDActions && actions.length > 0,
153
+ canExport: Boolean(metadata.canExport) && allowed('export'),
154
+ canImport: Boolean(metadata.canImport) && allowed('import'),
155
+ canCreate:
156
+ metadata.canCreate === undefined ? undefined : metadata.canCreate && allowed('create'),
157
+ }
158
+ }