@asteby/metacore-runtime-react 18.13.3 → 18.15.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,258 @@
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
+ groupModules,
14
+ filterModuleGroups,
15
+ type PermissionsCatalog,
16
+ type RoleDef,
17
+ } from '../permissions-manager'
18
+
19
+ const catalog: PermissionsCatalog = {
20
+ modules: [
21
+ {
22
+ key: 'pos_orders',
23
+ label: 'Pedidos POS',
24
+ icon: 'ShoppingCart',
25
+ addon_key: 'pos',
26
+ addon_label: 'Punto de venta',
27
+ actions: [
28
+ { key: 'index', label: 'Listar', icon: 'List', kind: 'crud' },
29
+ { key: 'create', label: 'Crear', icon: 'Plus', kind: 'crud' },
30
+ { key: 'pagar', label: 'Pagar', icon: 'CreditCard', kind: 'custom' },
31
+ ],
32
+ },
33
+ {
34
+ key: 'pos_sessions',
35
+ label: 'Sesiones POS',
36
+ icon: 'Clock',
37
+ addon_key: 'pos',
38
+ addon_label: 'Punto de venta',
39
+ actions: [{ key: 'index', label: 'Listar', icon: 'List', kind: 'crud' }],
40
+ },
41
+ {
42
+ key: 'users',
43
+ label: 'Usuarios',
44
+ icon: 'Users',
45
+ actions: [{ key: 'index', label: 'Listar', icon: 'List', kind: 'crud' }],
46
+ },
47
+ ],
48
+ general: [
49
+ {
50
+ key: 'general.work_after_hours',
51
+ label: 'Trabajar fuera de horario',
52
+ description: 'Permite operar fuera del horario configurado.',
53
+ },
54
+ ],
55
+ }
56
+
57
+ const roles: RoleDef[] = [{ id: 'r1', name: 'cashier', label: 'Cajero', color: '#22c55e' }]
58
+
59
+ function makeProps(overrides: Partial<Parameters<typeof PermissionsManager>[0]> = {}) {
60
+ return {
61
+ loadModules: vi.fn(async () => catalog),
62
+ loadRoles: vi.fn(async () => roles),
63
+ loadRolePermissions: vi.fn(async () => ['pos_orders.index']),
64
+ syncRolePermissions: vi.fn(async () => {}),
65
+ ...overrides,
66
+ }
67
+ }
68
+
69
+ describe('helpers puros', () => {
70
+ it('moduleActionCapability lowercasea el módulo', () => {
71
+ expect(moduleActionCapability('Pos_Orders', 'pagar')).toBe('pos_orders.pagar')
72
+ })
73
+
74
+ it('moduleCapabilities y grantedCountForModule', () => {
75
+ const mod = catalog.modules[0]
76
+ expect(moduleCapabilities(mod)).toEqual([
77
+ 'pos_orders.index',
78
+ 'pos_orders.create',
79
+ 'pos_orders.pagar',
80
+ ])
81
+ expect(grantedCountForModule(new Set(['pos_orders.index', 'otra.cosa']), mod)).toBe(1)
82
+ })
83
+
84
+ it('capabilitySetsEqual', () => {
85
+ expect(capabilitySetsEqual(new Set(['a', 'b']), new Set(['b', 'a']))).toBe(true)
86
+ expect(capabilitySetsEqual(new Set(['a']), new Set(['a', 'b']))).toBe(false)
87
+ })
88
+
89
+ it('groupModules agrupa por addon_label y manda los sin addon a "Sistema"', () => {
90
+ const groups = groupModules(catalog.modules)
91
+ expect(groups.map((g) => g.label)).toEqual(['Punto de venta', 'Sistema'])
92
+ expect(groups[0].modules.map((m) => m.key)).toEqual(['pos_orders', 'pos_sessions'])
93
+ expect(groups[1].modules.map((m) => m.key)).toEqual(['users'])
94
+ })
95
+
96
+ it('filterModuleGroups busca por módulo (accent/case-insensitive) o por grupo', () => {
97
+ const groups = groupModules(catalog.modules)
98
+ // Por nombre de módulo.
99
+ const bySession = filterModuleGroups(groups, 'sesiones')
100
+ expect(bySession).toHaveLength(1)
101
+ expect(bySession[0].modules.map((m) => m.key)).toEqual(['pos_sessions'])
102
+ // Por nombre de grupo trae todos sus módulos.
103
+ const byGroup = filterModuleGroups(groups, 'venta')
104
+ expect(byGroup[0].modules).toHaveLength(2)
105
+ // Query vacía = pasa todo.
106
+ expect(filterModuleGroups(groups, ' ')).toEqual(groups)
107
+ // Sin match = vacío.
108
+ expect(filterModuleGroups(groups, 'zzz')).toEqual([])
109
+ })
110
+ })
111
+
112
+ describe('PermissionsManager', () => {
113
+ it('renderiza catálogo con mocks, auto-selecciona rol y módulo, contador N/M', async () => {
114
+ const props = makeProps()
115
+ render(<PermissionsManager {...props} />)
116
+
117
+ // Auto-selección: primer rol + primer módulo, grid con las acciones.
118
+ // El panel derecho titula con el módulo activo ("Pedidos POS").
119
+ expect(await screen.findAllByText('Pedidos POS')).toBeTruthy()
120
+ expect(await screen.findByText('Pagar')).toBeTruthy()
121
+ // El contador N/M del panel está presente (también lo refleja el árbol).
122
+ expect(screen.getAllByText('1/3').length).toBeGreaterThan(0)
123
+ expect(props.loadRolePermissions).toHaveBeenCalledWith('r1')
124
+
125
+ // Generales presentes con descripción.
126
+ expect(screen.getByText('Permisos Generales')).toBeTruthy()
127
+ expect(screen.getByText('Trabajar fuera de horario')).toBeTruthy()
128
+ })
129
+
130
+ it('marcar una acción + un general y guardar llama sync con el set completo correcto', async () => {
131
+ const props = makeProps()
132
+ render(<PermissionsManager {...props} />)
133
+ await screen.findByText('Pagar')
134
+
135
+ fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
136
+ fireEvent.click(screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ }))
137
+
138
+ // Dirty visible y guardar habilitado.
139
+ expect(screen.getByText('Cambios sin guardar')).toBeTruthy()
140
+ fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
141
+
142
+ await waitFor(() =>
143
+ expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
144
+ 'general.work_after_hours',
145
+ 'pos_orders.index',
146
+ 'pos_orders.pagar',
147
+ ]),
148
+ )
149
+ // Tras guardar, baseline = draft → dirty desaparece.
150
+ await waitFor(() => expect(screen.queryByText('Cambios sin guardar')).toBeNull())
151
+ })
152
+
153
+ it('desmarcar una otorgada también entra al delta', async () => {
154
+ const props = makeProps()
155
+ render(<PermissionsManager {...props} />)
156
+ await screen.findByText('Pagar')
157
+
158
+ fireEvent.click(screen.getByRole('checkbox', { name: /Listar/ }))
159
+ fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
160
+ await waitFor(() => expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', []))
161
+ })
162
+
163
+ it('marcar todo / limpiar operan sobre el módulo activo', async () => {
164
+ const props = makeProps()
165
+ render(<PermissionsManager {...props} />)
166
+ await screen.findByText('Pagar')
167
+
168
+ fireEvent.click(screen.getByRole('button', { name: /Marcar todo/ }))
169
+ // 3/3 aparece en el panel y en el badge del árbol.
170
+ expect(screen.getAllByText('3/3').length).toBeGreaterThan(0)
171
+
172
+ fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
173
+ await waitFor(() =>
174
+ expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
175
+ 'pos_orders.create',
176
+ 'pos_orders.index',
177
+ 'pos_orders.pagar',
178
+ ]),
179
+ )
180
+
181
+ fireEvent.click(screen.getByRole('button', { name: /Limpiar/ }))
182
+ expect(screen.getByText('0/3')).toBeTruthy()
183
+ })
184
+
185
+ it('guardar deshabilitado sin cambios', async () => {
186
+ const props = makeProps()
187
+ render(<PermissionsManager {...props} />)
188
+ await screen.findByText('Pagar')
189
+ const save = screen.getByRole('button', { name: /Guardar permisos/ }) as HTMLButtonElement
190
+ expect(save.disabled).toBe(true)
191
+ })
192
+
193
+ it('oculta Nuevo rol / Editar / Eliminar cuando no hay mutators de rol', async () => {
194
+ const props = makeProps()
195
+ render(<PermissionsManager {...props} />)
196
+ await screen.findByText('Pagar')
197
+ expect(screen.queryByRole('button', { name: /Nuevo rol/ })).toBeNull()
198
+ expect(screen.queryByRole('button', { name: 'Editar rol' })).toBeNull()
199
+ expect(screen.queryByRole('button', { name: 'Eliminar rol' })).toBeNull()
200
+ })
201
+
202
+ it('renderiza el árbol agrupado y permite seleccionar un módulo de otro grupo', async () => {
203
+ const props = makeProps()
204
+ render(<PermissionsManager {...props} />)
205
+ await screen.findByText('Pagar')
206
+
207
+ // Grupos visibles (encabezados del árbol).
208
+ expect(screen.getByText('Punto de venta')).toBeTruthy()
209
+ expect(screen.getByText('Sistema')).toBeTruthy()
210
+
211
+ // Click en "Usuarios" (grupo Sistema) cambia el grid de acciones.
212
+ fireEvent.click(screen.getByRole('button', { name: /Usuarios/ }))
213
+ // El grid ahora muestra solo la acción de users; "Pagar" (de pos_orders) ya no.
214
+ await waitFor(() => expect(screen.queryByText('Pagar')).toBeNull())
215
+ // "Usuarios" titula el panel derecho además del árbol.
216
+ expect(screen.getAllByText('Usuarios').length).toBeGreaterThan(0)
217
+ })
218
+
219
+ it('la búsqueda filtra el árbol de módulos', async () => {
220
+ const props = makeProps()
221
+ render(<PermissionsManager {...props} />)
222
+ await screen.findByText('Pagar')
223
+
224
+ fireEvent.change(screen.getByLabelText('Buscar módulo'), {
225
+ target: { value: 'sesiones' },
226
+ })
227
+ // Solo el grupo con match permanece.
228
+ expect(screen.getByText('Sesiones POS')).toBeTruthy()
229
+ expect(screen.queryByText('Usuarios')).toBeNull()
230
+ })
231
+
232
+ it('selector de rol limpio: edit/delete inline, sin chip removible', async () => {
233
+ const props = makeProps({
234
+ updateRole: vi.fn(async () => {}),
235
+ deleteRole: vi.fn(async () => {}),
236
+ })
237
+ render(<PermissionsManager {...props} />)
238
+ await screen.findByText('Pagar')
239
+ // No existe el botón de quitar rol del chip antiguo.
240
+ expect(screen.queryByRole('button', { name: 'Quitar rol seleccionado' })).toBeNull()
241
+ // Iconos inline presentes.
242
+ expect(screen.getByRole('button', { name: 'Editar rol' })).toBeTruthy()
243
+ expect(screen.getByRole('button', { name: 'Eliminar rol' })).toBeTruthy()
244
+ })
245
+
246
+ it('muestra los CRUD de rol cuando los mutators existen', async () => {
247
+ const props = makeProps({
248
+ createRole: vi.fn(async () => {}),
249
+ updateRole: vi.fn(async () => {}),
250
+ deleteRole: vi.fn(async () => {}),
251
+ })
252
+ render(<PermissionsManager {...props} />)
253
+ await screen.findByText('Pagar')
254
+ expect(screen.getByRole('button', { name: /Nuevo rol/ })).toBeTruthy()
255
+ expect(screen.getByRole('button', { name: 'Editar rol' })).toBeTruthy()
256
+ expect(screen.getByRole('button', { name: 'Eliminar rol' })).toBeTruthy()
257
+ })
258
+ })
@@ -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