@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.
- package/CHANGELOG.md +28 -0
- package/dist/activity-diff.d.ts.map +1 -1
- package/dist/activity-diff.js +8 -5
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +8 -3
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +20 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +6 -1
- package/dist/permissions-context.d.ts +48 -0
- package/dist/permissions-context.d.ts.map +1 -0
- package/dist/permissions-context.js +124 -0
- package/dist/permissions-manager.d.ts +74 -0
- package/dist/permissions-manager.d.ts.map +1 -0
- package/dist/permissions-manager.js +358 -0
- package/dist/record-history.d.ts +5 -0
- package/dist/record-history.d.ts.map +1 -1
- package/dist/record-history.js +2 -2
- package/package.json +9 -7
- package/src/__tests__/dynamic-table-permissions.test.tsx +147 -0
- package/src/__tests__/permissions-context.test.tsx +102 -0
- package/src/__tests__/permissions-manager.test.tsx +172 -0
- package/src/activity-diff.tsx +69 -44
- package/src/dynamic-crud-page.tsx +8 -3
- package/src/dynamic-table.tsx +22 -9
- package/src/index.ts +26 -0
- package/src/model-action-toolbar.tsx +9 -1
- package/src/permissions-context.tsx +158 -0
- package/src/permissions-manager.tsx +984 -0
- package/src/record-history.tsx +8 -2
|
@@ -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
|
+
})
|
package/src/activity-diff.tsx
CHANGED
|
@@ -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
|
-
|
|
241
|
-
<
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
:
|
|
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
|
|
288
|
-
|
|
289
|
-
<
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
<
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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(() => {
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -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 (!
|
|
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 =
|
|
648
|
-
? { ...
|
|
649
|
-
:
|
|
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
|
-
}, [
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
1013
|
+
{viewMetadata?.canExport && (
|
|
1001
1014
|
<ExportDialog open={exportOpen} onOpenChange={setExportOpen} model={model} metadata={metadata} currentFilters={buildFilterParams()} hasActiveFilters={hasActiveFilters} />
|
|
1002
1015
|
)}
|
|
1003
|
-
{
|
|
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
|
|
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
|
|