@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.
- package/CHANGELOG.md +35 -0
- 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 +84 -0
- package/dist/permissions-manager.d.ts.map +1 -0
- package/dist/permissions-manager.js +429 -0
- package/package.json +11 -9
- 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 +258 -0
- 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 +1143 -0
|
@@ -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
|
-
|
|
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
|
|