@asteby/metacore-runtime-react 18.13.2 → 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
+ })
@@ -233,17 +233,25 @@ export const ActivityDiff: React.FC<ActivityDiffProps> = ({
233
233
  <p className="text-sm text-muted-foreground italic">{event.summary}</p>
234
234
  )}
235
235
 
236
- {/* Diff table */}
236
+ {/* Diff table. Created/deleted events carry a single snapshot — no
237
+ before/after pair — so they collapse to two columns (Campo +
238
+ Valor); a third placeholder column would force the value cell
239
+ to wrap onto its own row. Updated keeps Campo/Antes/Después. */}
237
240
  {displayedKeys.length > 0 && (
238
241
  <div className="rounded-lg border border-border/60 overflow-hidden text-sm">
239
242
  {/* Column headers */}
240
- <div className="grid grid-cols-[1fr_1fr_1fr] border-b border-border/40 bg-muted/40 px-3 py-1.5 text-xs font-medium text-muted-foreground">
241
- <span>Campo</span>
242
- {!isCreated && <span>{isDeleted ? 'Valor' : 'Antes'}</span>}
243
- {isCreated && <span></span>}
244
- {!isDeleted && <span>{isCreated ? 'Valor' : 'Después'}</span>}
245
- {isDeleted && <span></span>}
246
- </div>
243
+ {isCreated || isDeleted ? (
244
+ <div className="grid grid-cols-[1fr_2fr] border-b border-border/40 bg-muted/40 px-3 py-1.5 text-xs font-medium text-muted-foreground gap-x-2">
245
+ <span>Campo</span>
246
+ <span>{isDeleted ? 'Valor anterior' : 'Valor'}</span>
247
+ </div>
248
+ ) : (
249
+ <div className="grid grid-cols-[1fr_1fr_1fr] border-b border-border/40 bg-muted/40 px-3 py-1.5 text-xs font-medium text-muted-foreground gap-x-2">
250
+ <span>Campo</span>
251
+ <span>Antes</span>
252
+ <span>Después</span>
253
+ </div>
254
+ )}
247
255
 
248
256
  {displayedKeys.map((key, idx) => {
249
257
  const col = resolveColumn(key, columns)
@@ -265,14 +273,39 @@ export const ActivityDiff: React.FC<ActivityDiffProps> = ({
265
273
  ? ROW_STYLE.created
266
274
  : isDeleted
267
275
  ? ROW_STYLE.deleted
268
- : isChanged
269
- ? {}
270
- : {}
276
+ : {}
277
+
278
+ // Single-snapshot row: label + one value cell, aligned
279
+ // with the two-column header above.
280
+ if (isCreated || isDeleted) {
281
+ return (
282
+ <div
283
+ key={key}
284
+ style={rowStyle}
285
+ className={cn(
286
+ 'grid grid-cols-[1fr_2fr] items-start px-3 py-2 gap-x-2',
287
+ idx !== displayedKeys.length - 1 && 'border-b border-border/30',
288
+ )}
289
+ >
290
+ <span className="text-xs font-medium text-foreground/70 pt-0.5 truncate" title={label}>
291
+ {label}
292
+ </span>
293
+ <span className="min-w-0">
294
+ <ActivityValueRenderer
295
+ value={isDeleted ? fromVal : toVal}
296
+ col={col}
297
+ timeZone={timeZone}
298
+ currency={currency}
299
+ locale={locale}
300
+ />
301
+ </span>
302
+ </div>
303
+ )
304
+ }
271
305
 
272
306
  return (
273
307
  <div
274
308
  key={key}
275
- style={rowStyle}
276
309
  className={cn(
277
310
  'grid grid-cols-[1fr_1fr_1fr] items-start px-3 py-2 gap-x-2',
278
311
  idx !== displayedKeys.length - 1 && 'border-b border-border/30',
@@ -284,40 +317,32 @@ export const ActivityDiff: React.FC<ActivityDiffProps> = ({
284
317
  {label}
285
318
  </span>
286
319
 
287
- {/* Before value (or value for deleted/created) */}
288
- {!isCreated ? (
289
- <span className={cn(isDeleted ? 'col-span-2' : '')}>
290
- <ActivityValueRenderer
291
- value={isDeleted ? fromVal : fromVal}
292
- col={col}
293
- timeZone={timeZone}
294
- currency={currency}
295
- locale={locale}
296
- />
297
- </span>
298
- ) : (
299
- <span />
300
- )}
320
+ {/* Before value */}
321
+ <span className="min-w-0">
322
+ <ActivityValueRenderer
323
+ value={fromVal}
324
+ col={col}
325
+ timeZone={timeZone}
326
+ currency={currency}
327
+ locale={locale}
328
+ />
329
+ </span>
301
330
 
302
331
  {/* After value */}
303
- {!isDeleted ? (
304
- <span className={cn(isCreated ? 'col-span-2' : '')}>
305
- {isChanged && variant === 'updated' && (
306
- <span className="inline-flex items-center gap-1 align-middle mr-1">
307
- <ArrowRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
308
- </span>
309
- )}
310
- <ActivityValueRenderer
311
- value={isCreated ? toVal : toVal}
312
- col={col}
313
- timeZone={timeZone}
314
- currency={currency}
315
- locale={locale}
316
- />
317
- </span>
318
- ) : (
319
- <span />
320
- )}
332
+ <span className="min-w-0">
333
+ {isChanged && variant === 'updated' && (
334
+ <span className="inline-flex items-center gap-1 align-middle mr-1">
335
+ <ArrowRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
336
+ </span>
337
+ )}
338
+ <ActivityValueRenderer
339
+ value={toVal}
340
+ col={col}
341
+ timeZone={timeZone}
342
+ currency={currency}
343
+ locale={locale}
344
+ />
345
+ </span>
321
346
  </div>
322
347
  )
323
348
  })}
@@ -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