@asteby/metacore-runtime-react 18.13.3 → 18.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,358 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // permissions-manager — "Permisos y Roles" pro view (rol × módulo × acción).
3
+ //
4
+ // Transport-agnostic: every read/write arrives via props (loaders/mutators),
5
+ // so each host wires them to its own api client (ops → /api/permissions/*).
6
+ // The capability universe (modules × actions + general flags) is derived from
7
+ // the installed manifests server-side; this component only renders it.
8
+ //
9
+ // Layout (reference: 7leguas "Permisos y Roles"):
10
+ // header — title + "Nuevo rol" (primary) + "Guardar permisos" (green).
11
+ // left — Card "Rol": searchable role selector with removable chip,
12
+ // Editar/Eliminar rol, "Permisos Generales" flag checkboxes.
13
+ // — Card "Módulo": searchable module selector grouped by addon,
14
+ // removable chip.
15
+ // right — Card "Acciones permitidas": granted counter N/M, mark-all /
16
+ // clear buttons, checkbox grid (icon + label per action).
17
+ //
18
+ // Saving calls `syncRolePermissions(roleId, capabilities)` with the FULL
19
+ // granted set of the active role (baseline + the edits made here). Dirty
20
+ // state is tracked against the loaded baseline and surfaced next to the
21
+ // save button.
22
+ import * as React from 'react';
23
+ import { Check, ChevronsUpDown, CheckCheck, Eraser, Pencil, Plus, Save, Shield, Trash2, X, } from 'lucide-react';
24
+ import { toast } from 'sonner';
25
+ import { cn } from '@asteby/metacore-ui/lib';
26
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Checkbox, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, Popover, PopoverContent, PopoverTrigger, Separator, Skeleton, } from '@asteby/metacore-ui/primitives';
27
+ import { DynamicIcon } from './dynamic-icon';
28
+ // ---------------------------------------------------------------------------
29
+ // Pure helpers (exported for hosts/tests)
30
+ // ---------------------------------------------------------------------------
31
+ /** Capability for a catalog module action: `lowercase(moduleKey).actionKey`. */
32
+ export function moduleActionCapability(moduleKey, actionKey) {
33
+ return `${moduleKey.toLowerCase()}.${actionKey}`;
34
+ }
35
+ /** All capabilities of one module. */
36
+ export function moduleCapabilities(module) {
37
+ return module.actions.map((a) => moduleActionCapability(module.key, a.key));
38
+ }
39
+ /** How many of the module's capabilities are in the granted set. */
40
+ export function grantedCountForModule(granted, module) {
41
+ return moduleCapabilities(module).filter((c) => granted.has(c)).length;
42
+ }
43
+ export function capabilitySetsEqual(a, b) {
44
+ if (a.size !== b.size)
45
+ return false;
46
+ for (const v of a)
47
+ if (!b.has(v))
48
+ return false;
49
+ return true;
50
+ }
51
+ /** Default lucide icon when the manifest action doesn't declare one. */
52
+ export function defaultActionIcon(actionKey, kind) {
53
+ switch (actionKey) {
54
+ case 'index':
55
+ return 'List';
56
+ case 'create':
57
+ return 'Plus';
58
+ case 'update':
59
+ return 'Pencil';
60
+ case 'delete':
61
+ return 'Trash2';
62
+ case 'export':
63
+ return 'Download';
64
+ case 'import':
65
+ return 'Upload';
66
+ default:
67
+ return kind === 'crud' ? 'List' : 'Zap';
68
+ }
69
+ }
70
+ function slugify(label) {
71
+ return label
72
+ .normalize('NFD')
73
+ .replace(/[\u0300-\u036f]/g, '')
74
+ .toLowerCase()
75
+ .trim()
76
+ .replace(/[^a-z0-9]+/g, '_')
77
+ .replace(/^_+|_+$/g, '');
78
+ }
79
+ const ROLE_COLORS = [
80
+ '#ef4444',
81
+ '#f97316',
82
+ '#eab308',
83
+ '#22c55e',
84
+ '#06b6d4',
85
+ '#3b82f6',
86
+ '#8b5cf6',
87
+ '#ec4899',
88
+ '#6b7280',
89
+ ];
90
+ // ---------------------------------------------------------------------------
91
+ // Internal sub-components
92
+ // ---------------------------------------------------------------------------
93
+ /** Checkbox row used by both the action grid and the general flags. */
94
+ function CapabilityCheck({ checked, disabled, onToggle, icon, label, description, }) {
95
+ return (_jsxs("div", { role: "checkbox", "aria-checked": checked, "aria-disabled": disabled || undefined, tabIndex: disabled ? -1 : 0, onClick: disabled ? undefined : onToggle, onKeyDown: disabled
96
+ ? undefined
97
+ : (e) => {
98
+ if (e.key === ' ' || e.key === 'Enter') {
99
+ e.preventDefault();
100
+ onToggle();
101
+ }
102
+ }, className: cn('flex items-start gap-2.5 rounded-md border border-border/60 bg-card px-3 py-2.5 text-sm transition-colors', disabled ? 'opacity-50' : 'cursor-pointer hover:bg-muted/40', checked && 'border-primary/40 bg-primary/5'), children: [_jsx(Checkbox, { checked: checked, "aria-hidden": "true", tabIndex: -1, className: "pointer-events-none mt-0.5 shrink-0" }), icon && (_jsx(DynamicIcon, { name: icon, className: "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" })), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block truncate font-medium text-foreground", children: label }), description && (_jsx("span", { className: "mt-0.5 block text-xs text-muted-foreground", children: description }))] })] }));
103
+ }
104
+ /** Removable selection chip (role / module). */
105
+ function SelectionChip({ label, color, onRemove, removeAriaLabel, }) {
106
+ return (_jsxs(Badge, { variant: "secondary", className: "gap-1.5 pr-1 text-sm font-medium", children: [color && (_jsx("span", { className: "h-2 w-2 rounded-full", style: { background: color }, "aria-hidden": "true" })), _jsx("span", { className: "max-w-[180px] truncate", children: label }), _jsx("button", { type: "button", "aria-label": removeAriaLabel, onClick: onRemove, className: "rounded-sm p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", children: _jsx(X, { className: "h-3 w-3" }) })] }));
107
+ }
108
+ // ---------------------------------------------------------------------------
109
+ // Component
110
+ // ---------------------------------------------------------------------------
111
+ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions, syncRolePermissions, createRole, updateRole, deleteRole, title = 'Permisos y Roles', className, }) {
112
+ const [catalog, setCatalog] = React.useState(null);
113
+ const [roles, setRoles] = React.useState(null);
114
+ const [loadError, setLoadError] = React.useState(false);
115
+ const [activeRoleId, setActiveRoleId] = React.useState(null);
116
+ const [activeModuleKey, setActiveModuleKey] = React.useState(null);
117
+ // baseline = capabilities as persisted; draft = baseline + local edits.
118
+ const [baseline, setBaseline] = React.useState(null);
119
+ const [draft, setDraft] = React.useState(null);
120
+ const [loadingPerms, setLoadingPerms] = React.useState(false);
121
+ const [saving, setSaving] = React.useState(false);
122
+ const [roleOpen, setRoleOpen] = React.useState(false);
123
+ const [moduleOpen, setModuleOpen] = React.useState(false);
124
+ // Pending role switch while there are unsaved changes.
125
+ const [pendingRoleId, setPendingRoleId] = React.useState(null);
126
+ const [roleDialog, setRoleDialog] = React.useState({ open: false, mode: 'create', label: '', color: ROLE_COLORS[5] });
127
+ const [roleSaving, setRoleSaving] = React.useState(false);
128
+ const [deleteOpen, setDeleteOpen] = React.useState(false);
129
+ const [deleting, setDeleting] = React.useState(false);
130
+ const loading = catalog === null || roles === null;
131
+ // ---- initial load: catalog + roles in parallel -------------------------
132
+ React.useEffect(() => {
133
+ let cancelled = false;
134
+ Promise.all([loadModules(), loadRoles()])
135
+ .then(([cat, rs]) => {
136
+ if (cancelled)
137
+ return;
138
+ setCatalog(cat);
139
+ setRoles(rs);
140
+ setActiveRoleId((prev) => prev ?? rs[0]?.id ?? null);
141
+ setActiveModuleKey((prev) => prev ?? cat.modules[0]?.key ?? null);
142
+ })
143
+ .catch(() => {
144
+ if (!cancelled)
145
+ setLoadError(true);
146
+ });
147
+ return () => {
148
+ cancelled = true;
149
+ };
150
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
+ }, []);
152
+ // ---- per-role permissions ----------------------------------------------
153
+ React.useEffect(() => {
154
+ if (!activeRoleId) {
155
+ setBaseline(null);
156
+ setDraft(null);
157
+ return;
158
+ }
159
+ let cancelled = false;
160
+ setLoadingPerms(true);
161
+ loadRolePermissions(activeRoleId)
162
+ .then((caps) => {
163
+ if (cancelled)
164
+ return;
165
+ setBaseline(new Set(caps));
166
+ setDraft(new Set(caps));
167
+ })
168
+ .catch(() => {
169
+ if (cancelled)
170
+ return;
171
+ toast.error('No se pudieron cargar los permisos del rol');
172
+ setBaseline(null);
173
+ setDraft(null);
174
+ })
175
+ .finally(() => {
176
+ if (!cancelled)
177
+ setLoadingPerms(false);
178
+ });
179
+ return () => {
180
+ cancelled = true;
181
+ };
182
+ // eslint-disable-next-line react-hooks/exhaustive-deps
183
+ }, [activeRoleId]);
184
+ const activeRole = React.useMemo(() => roles?.find((r) => r.id === activeRoleId) ?? null, [roles, activeRoleId]);
185
+ const activeModule = React.useMemo(() => catalog?.modules.find((m) => m.key === activeModuleKey) ?? null, [catalog, activeModuleKey]);
186
+ const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft);
187
+ // Selector groups: modules bucketed by addon label, stable order.
188
+ const moduleGroups = React.useMemo(() => {
189
+ const groups = new Map();
190
+ for (const mod of catalog?.modules ?? []) {
191
+ const group = mod.addon_label || mod.addon_key || 'Otros';
192
+ const list = groups.get(group) ?? [];
193
+ list.push(mod);
194
+ groups.set(group, list);
195
+ }
196
+ return Array.from(groups.entries());
197
+ }, [catalog]);
198
+ // ---- capability edits ---------------------------------------------------
199
+ const toggleCapability = React.useCallback((cap) => {
200
+ setDraft((prev) => {
201
+ if (!prev)
202
+ return prev;
203
+ const next = new Set(prev);
204
+ if (next.has(cap))
205
+ next.delete(cap);
206
+ else
207
+ next.add(cap);
208
+ return next;
209
+ });
210
+ }, []);
211
+ const setModuleAll = React.useCallback((on) => {
212
+ if (!activeModule)
213
+ return;
214
+ const caps = moduleCapabilities(activeModule);
215
+ setDraft((prev) => {
216
+ if (!prev)
217
+ return prev;
218
+ const next = new Set(prev);
219
+ for (const c of caps) {
220
+ if (on)
221
+ next.add(c);
222
+ else
223
+ next.delete(c);
224
+ }
225
+ return next;
226
+ });
227
+ }, [activeModule]);
228
+ const handleSave = async () => {
229
+ if (!activeRoleId || !draft)
230
+ return;
231
+ setSaving(true);
232
+ try {
233
+ await syncRolePermissions(activeRoleId, Array.from(draft).sort());
234
+ setBaseline(new Set(draft));
235
+ toast.success('Permisos guardados');
236
+ }
237
+ catch {
238
+ toast.error('No se pudieron guardar los permisos');
239
+ }
240
+ finally {
241
+ setSaving(false);
242
+ }
243
+ };
244
+ // ---- role switching (dirty guard) ---------------------------------------
245
+ const requestRoleSwitch = (roleId) => {
246
+ if (roleId === activeRoleId)
247
+ return;
248
+ if (dirty)
249
+ setPendingRoleId(roleId);
250
+ else
251
+ setActiveRoleId(roleId);
252
+ };
253
+ // ---- role CRUD -----------------------------------------------------------
254
+ const refreshRoles = async (selectId) => {
255
+ const rs = await loadRoles();
256
+ setRoles(rs);
257
+ if (selectId !== undefined)
258
+ setActiveRoleId(selectId);
259
+ else if (activeRoleId && !rs.some((r) => r.id === activeRoleId))
260
+ setActiveRoleId(rs[0]?.id ?? null);
261
+ return rs;
262
+ };
263
+ const handleRoleSubmit = async () => {
264
+ const label = roleDialog.label.trim();
265
+ if (!label)
266
+ return;
267
+ setRoleSaving(true);
268
+ try {
269
+ if (roleDialog.mode === 'create' && createRole) {
270
+ const created = await createRole({
271
+ name: slugify(label),
272
+ label,
273
+ color: roleDialog.color,
274
+ });
275
+ const rs = await loadRoles();
276
+ setRoles(rs);
277
+ const createdId = (created && 'id' in created && created.id) ||
278
+ rs.find((r) => r.name === slugify(label))?.id ||
279
+ null;
280
+ if (createdId)
281
+ setActiveRoleId(createdId);
282
+ toast.success('Rol creado');
283
+ }
284
+ else if (roleDialog.mode === 'edit' && updateRole && activeRole) {
285
+ await updateRole(activeRole.id, {
286
+ name: activeRole.name,
287
+ label,
288
+ color: roleDialog.color,
289
+ });
290
+ await refreshRoles(activeRole.id);
291
+ toast.success('Rol actualizado');
292
+ }
293
+ setRoleDialog((d) => ({ ...d, open: false }));
294
+ }
295
+ catch {
296
+ toast.error(roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol');
297
+ }
298
+ finally {
299
+ setRoleSaving(false);
300
+ }
301
+ };
302
+ const handleDeleteRole = async () => {
303
+ if (!deleteRole || !activeRole)
304
+ return;
305
+ setDeleting(true);
306
+ try {
307
+ await deleteRole(activeRole.id);
308
+ const rs = await loadRoles();
309
+ setRoles(rs);
310
+ setActiveRoleId(rs[0]?.id ?? null);
311
+ toast.success('Rol eliminado');
312
+ setDeleteOpen(false);
313
+ }
314
+ catch {
315
+ toast.error('No se pudo eliminar el rol');
316
+ }
317
+ finally {
318
+ setDeleting(false);
319
+ }
320
+ };
321
+ // ---- derived for the right panel ----------------------------------------
322
+ const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0;
323
+ const moduleTotal = activeModule?.actions.length ?? 0;
324
+ const checksDisabled = !activeRole || !draft || loadingPerms || saving;
325
+ // ---- render --------------------------------------------------------------
326
+ if (loadError) {
327
+ return (_jsxs("div", { className: cn('flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground', className), children: [_jsx(Shield, { className: "h-8 w-8 opacity-40" }), _jsx("p", { className: "text-sm", children: "No se pudo cargar el cat\u00E1logo de permisos." })] }));
328
+ }
329
+ if (loading) {
330
+ return (_jsxs("div", { className: cn('flex flex-col gap-4', className), children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Skeleton, { className: "h-8 w-56" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Skeleton, { className: "h-9 w-28" }), _jsx(Skeleton, { className: "h-9 w-40" })] })] }), _jsxs("div", { className: "grid gap-4 lg:grid-cols-[340px_1fr]", children: [_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Skeleton, { className: "h-64 w-full" }), _jsx(Skeleton, { className: "h-28 w-full" })] }), _jsx(Skeleton, { className: "h-96 w-full" })] })] }));
331
+ }
332
+ return (_jsxs("div", { className: cn('flex flex-col gap-4', className), children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-2xl font-bold tracking-tight", children: title }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Define qu\u00E9 puede hacer cada rol en cada m\u00F3dulo." })] }), _jsxs("div", { className: "flex items-center gap-2", children: [dirty && (_jsx(Badge, { variant: "outline", className: "border-amber-500/50 text-amber-600", children: "Cambios sin guardar" })), createRole && (_jsxs(Button, { onClick: () => setRoleDialog({ open: true, mode: 'create', label: '', color: ROLE_COLORS[5] }), children: [_jsx(Plus, { className: "mr-1.5 h-4 w-4" }), " Nuevo rol"] })), _jsxs(Button, { onClick: handleSave, disabled: !dirty || saving || !activeRole, className: "bg-emerald-600 text-white hover:bg-emerald-700", children: [_jsx(Save, { className: "mr-1.5 h-4 w-4" }), saving ? 'Guardando…' : 'Guardar permisos'] })] })] }), _jsxs("div", { className: "grid items-start gap-4 lg:grid-cols-[340px_1fr]", children: [_jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: "Rol" }), _jsx(CardDescription, { children: "Selecciona el rol a configurar." })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [activeRole ? (_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx(SelectionChip, { label: activeRole.label || activeRole.name, color: activeRole.color, onRemove: () => requestRoleSwitch(null), removeAriaLabel: "Quitar rol seleccionado" }), _jsxs("div", { className: "flex items-center gap-1", children: [updateRole && (_jsx(Button, { variant: "ghost", size: "sm", className: "h-8 px-2", "aria-label": "Editar rol", onClick: () => setRoleDialog({
333
+ open: true,
334
+ mode: 'edit',
335
+ label: activeRole.label || activeRole.name,
336
+ color: activeRole.color || ROLE_COLORS[5],
337
+ }), children: _jsx(Pencil, { className: "h-3.5 w-3.5" }) })), deleteRole && (_jsx(Button, { variant: "ghost", size: "sm", className: "h-8 px-2 text-destructive hover:text-destructive", "aria-label": "Eliminar rol", onClick: () => setDeleteOpen(true), children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }))] })] })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: "Ning\u00FAn rol seleccionado." })), _jsxs(Popover, { open: roleOpen, onOpenChange: setRoleOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": roleOpen, className: "w-full justify-between font-normal", children: [activeRole ? activeRole.label || activeRole.name : 'Seleccionar rol…', _jsx(ChevronsUpDown, { className: "ml-2 h-4 w-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "w-[300px] p-0", align: "start", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: "Buscar rol\u2026" }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "Sin resultados." }), _jsx(CommandGroup, { children: (roles ?? []).map((role) => (_jsxs(CommandItem, { value: `${role.label || ''} ${role.name}`, onSelect: () => {
338
+ requestRoleSwitch(role.id);
339
+ setRoleOpen(false);
340
+ }, children: [_jsx("span", { className: "mr-2 h-2 w-2 shrink-0 rounded-full", style: { background: role.color || '#6b7280' }, "aria-hidden": "true" }), _jsx("span", { className: "truncate", children: role.label || role.name }), role.id === activeRoleId && (_jsx(Check, { className: "ml-auto h-4 w-4" }))] }, role.id))) })] })] }) })] }), (catalog?.general.length ?? 0) > 0 && (_jsxs(_Fragment, { children: [_jsx(Separator, {}), _jsxs("div", { children: [_jsx("h3", { className: "mb-2 text-sm font-semibold", children: "Permisos Generales" }), _jsx("div", { className: "flex flex-col gap-2", children: catalog.general.map((g) => (_jsx(CapabilityCheck, { checked: draft?.has(g.key) ?? false, disabled: checksDisabled, onToggle: () => toggleCapability(g.key), label: g.label, description: g.description }, g.key))) })] })] }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: "M\u00F3dulo" }), _jsx(CardDescription, { children: "Elige el m\u00F3dulo cuyas acciones quieres configurar." })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [activeModule ? (_jsx(SelectionChip, { label: activeModule.label, onRemove: () => setActiveModuleKey(null), removeAriaLabel: "Quitar m\u00F3dulo seleccionado" })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: "Ning\u00FAn m\u00F3dulo seleccionado." })), _jsxs(Popover, { open: moduleOpen, onOpenChange: setModuleOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": moduleOpen, className: "w-full justify-between font-normal", children: [activeModule ? activeModule.label : 'Seleccionar módulo…', _jsx(ChevronsUpDown, { className: "ml-2 h-4 w-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "w-[300px] p-0", align: "start", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: "Buscar m\u00F3dulo\u2026" }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "Sin resultados." }), moduleGroups.map(([group, mods]) => (_jsx(CommandGroup, { heading: group, children: mods.map((mod) => (_jsxs(CommandItem, { value: `${mod.label} ${mod.key} ${group}`, onSelect: () => {
341
+ setActiveModuleKey(mod.key);
342
+ setModuleOpen(false);
343
+ }, children: [_jsx("span", { className: "truncate", children: mod.label }), mod.key === activeModuleKey && (_jsx(Check, { className: "ml-auto h-4 w-4" }))] }, mod.key))) }, group)))] })] }) })] })] })] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs("div", { className: "flex flex-wrap items-start justify-between gap-2", children: [_jsxs("div", { children: [_jsx(CardTitle, { className: "text-base", children: "Acciones permitidas" }), _jsx(CardDescription, { children: "Configura los permisos para este m\u00F3dulo." })] }), activeModule && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Badge, { variant: "secondary", className: "tabular-nums", children: [moduleGranted, "/", moduleTotal] }), _jsxs(Button, { variant: "outline", size: "sm", className: "h-8", disabled: checksDisabled || moduleGranted === moduleTotal, onClick: () => setModuleAll(true), children: [_jsx(CheckCheck, { className: "mr-1.5 h-3.5 w-3.5" }), " Marcar todo"] }), _jsxs(Button, { variant: "outline", size: "sm", className: "h-8", disabled: checksDisabled || moduleGranted === 0, onClick: () => setModuleAll(false), children: [_jsx(Eraser, { className: "mr-1.5 h-3.5 w-3.5" }), " Limpiar"] })] }))] }) }), _jsx(CardContent, { children: !activeRole ? (_jsx(EmptyHint, { text: "Selecciona un rol para configurar sus permisos." })) : !activeModule ? (_jsx(EmptyHint, { text: "Selecciona un m\u00F3dulo para ver sus acciones." })) : loadingPerms ? (_jsx("div", { className: "grid gap-2 sm:grid-cols-2 xl:grid-cols-3", children: Array.from({ length: 6 }).map((_, i) => (_jsx(Skeleton, { className: "h-11 w-full" }, i))) })) : (_jsx("div", { className: "grid gap-2 sm:grid-cols-2 xl:grid-cols-3", children: activeModule.actions.map((action) => {
344
+ const cap = moduleActionCapability(activeModule.key, action.key);
345
+ return (_jsx(CapabilityCheck, { checked: draft?.has(cap) ?? false, disabled: checksDisabled, onToggle: () => toggleCapability(cap), icon: action.icon || defaultActionIcon(action.key, action.kind), label: action.label }, action.key));
346
+ }) })) })] })] }), _jsx(AlertDialog, { open: pendingRoleId !== null, onOpenChange: (open) => !open && setPendingRoleId(null), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "Cambios sin guardar" }), _jsx(AlertDialogDescription, { children: "Tienes cambios sin guardar en este rol. Si cambias de rol se descartar\u00E1n." })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: "Cancelar" }), _jsx(AlertDialogAction, { onClick: () => {
347
+ setActiveRoleId(pendingRoleId);
348
+ setPendingRoleId(null);
349
+ }, children: "Descartar y cambiar" })] })] }) }), _jsx(Dialog, { open: roleDialog.open, onOpenChange: (open) => setRoleDialog((d) => ({ ...d, open })), children: _jsxs(DialogContent, { className: "sm:max-w-md", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol' }) }), _jsxs("div", { className: "flex flex-col gap-4 py-2", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "pm-role-name", children: "Nombre del rol" }), _jsx(Input, { id: "pm-role-name", value: roleDialog.label, placeholder: "Ej. Cajero", onChange: (e) => setRoleDialog((d) => ({ ...d, label: e.target.value })) })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: "Color" }), _jsx("div", { className: "flex flex-wrap gap-2", children: ROLE_COLORS.map((c) => (_jsx("button", { type: "button", "aria-label": `Color ${c}`, onClick: () => setRoleDialog((d) => ({ ...d, color: c })), className: cn('h-7 w-7 rounded-full border-2 transition-transform', roleDialog.color === c
350
+ ? 'scale-110 border-foreground'
351
+ : 'border-transparent hover:scale-105'), style: { background: c } }, c))) })] })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setRoleDialog((d) => ({ ...d, open: false })), disabled: roleSaving, children: "Cancelar" }), _jsx(Button, { onClick: handleRoleSubmit, disabled: roleSaving || !roleDialog.label.trim(), children: roleSaving ? 'Guardando…' : roleDialog.mode === 'create' ? 'Crear rol' : 'Guardar' })] })] }) }), _jsx(AlertDialog, { open: deleteOpen, onOpenChange: (open) => !deleting && setDeleteOpen(open), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "\u00BFEliminar el rol?" }), _jsxs(AlertDialogDescription, { children: ["Se eliminar\u00E1 el rol", ' ', _jsx("strong", { children: activeRole ? activeRole.label || activeRole.name : '' }), " y sus asignaciones de permisos. Esta acci\u00F3n no se puede deshacer."] })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: deleting, children: "Cancelar" }), _jsx(AlertDialogAction, { className: "bg-red-600 hover:bg-red-700", disabled: deleting, onClick: (e) => {
352
+ e.preventDefault();
353
+ handleDeleteRole();
354
+ }, children: deleting ? 'Eliminando…' : 'Eliminar' })] })] }) })] }));
355
+ }
356
+ function EmptyHint({ text }) {
357
+ return (_jsxs("div", { className: "flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground", children: [_jsx(Shield, { className: "h-8 w-8 opacity-40" }), _jsx("p", { className: "text-sm", children: text })] }));
358
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "18.13.3",
3
+ "version": "18.14.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,18 +21,18 @@
21
21
  "zod": "^4.3.0"
22
22
  },
23
23
  "peerDependencies": {
24
- "react": ">=18",
25
- "react-dom": ">=18",
26
24
  "@tanstack/react-query": ">=5",
27
- "@tanstack/react-table": ">=8",
28
25
  "@tanstack/react-router": ">=1.100",
26
+ "@tanstack/react-table": ">=8",
27
+ "date-fns": ">=3",
29
28
  "i18next": ">=23",
29
+ "lucide-react": ">=0.460",
30
+ "react": ">=18",
31
+ "react-day-picker": ">=8",
32
+ "react-dom": ">=18",
30
33
  "react-i18next": ">=13",
31
34
  "sonner": ">=1.7",
32
35
  "zustand": ">=5",
33
- "lucide-react": ">=0.460",
34
- "date-fns": ">=3",
35
- "react-day-picker": ">=8",
36
36
  "@asteby/metacore-sdk": "^3.2.0",
37
37
  "@asteby/metacore-ui": "^2.5.1"
38
38
  },
@@ -48,8 +48,10 @@
48
48
  "@tanstack/react-query": "^5.95.0",
49
49
  "@tanstack/react-router": "^1.168.0",
50
50
  "@tanstack/react-table": "^8.20.0",
51
+ "@testing-library/react": "^16.3.2",
51
52
  "@types/react": "^19.0.0",
52
53
  "date-fns": "^4.1.0",
54
+ "happy-dom": "^20.10.2",
53
55
  "i18next": "^26.0.0",
54
56
  "lucide-react": "^1.0.0",
55
57
  "react": "^19.2.4",
@@ -61,8 +63,8 @@
61
63
  "typescript": "^6.0.0",
62
64
  "vitest": "^4.0.0",
63
65
  "zustand": "^5.0.0",
64
- "@asteby/metacore-ui": "2.5.1",
65
- "@asteby/metacore-sdk": "3.2.0"
66
+ "@asteby/metacore-sdk": "3.2.0",
67
+ "@asteby/metacore-ui": "2.5.1"
66
68
  },
67
69
  "scripts": {
68
70
  "build": "tsc -p tsconfig.json",
@@ -0,0 +1,147 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Gating de permisos sobre las superficies CRUD dinámicas:
4
+ // 1. `gateTableMetadata` (puro) — export/import, row actions custom y el
5
+ // trío implícito View/Edit/Delete filtrados por capability.
6
+ // 2. <DynamicCRUDPage> — el botón Crear desaparece sin `model.create`
7
+ // cuando hay <PermissionsProvider>; sin provider todo sigue visible.
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, render, screen } from '@testing-library/react'
10
+
11
+ // DynamicTable usa useNavigate; el test no navega, así que basta un stub.
12
+ vi.mock('@tanstack/react-router', () => ({
13
+ useNavigate: () => () => {},
14
+ }))
15
+
16
+ import { gateTableMetadata, makeCan, PermissionsProvider } from '../permissions-context'
17
+ import { ApiProvider, type ApiClient } from '../api-context'
18
+ import { useMetadataCache } from '../metadata-cache'
19
+ import { DynamicCRUDPage } from '../dynamic-crud-page'
20
+ import type { TableMetadata, ActionDefinition } from '../types'
21
+
22
+ // Sin `globals: true` en vitest, RTL no auto-limpia entre tests.
23
+ afterEach(cleanup)
24
+
25
+ function baseMeta(over: Partial<TableMetadata> = {}): TableMetadata {
26
+ return {
27
+ title: 'Pedidos',
28
+ endpoint: '/data/pos_orders',
29
+ columns: [],
30
+ actions: [],
31
+ perPageOptions: [10],
32
+ defaultPerPage: 10,
33
+ searchPlaceholder: 'Buscar...',
34
+ enableCRUDActions: true,
35
+ hasActions: false,
36
+ canExport: true,
37
+ canImport: true,
38
+ ...over,
39
+ }
40
+ }
41
+
42
+ const action = (key: string, placement?: ActionDefinition['placement']): ActionDefinition =>
43
+ ({ key, name: key, label: key, icon: 'Zap', placement }) as ActionDefinition
44
+
45
+ describe('gateTableMetadata', () => {
46
+ it('apaga canExport/canImport sin capability', () => {
47
+ const can = makeCan(['pos_orders.export'], false)
48
+ const gated = gateTableMetadata(baseMeta(), 'pos_orders', can)
49
+ expect(gated.canExport).toBe(true)
50
+ expect(gated.canImport).toBe(false)
51
+ })
52
+
53
+ it('filtra row actions custom por can(model.key)', () => {
54
+ const meta = baseMeta({
55
+ actions: [action('pagar'), action('cancelar')],
56
+ hasActions: true,
57
+ })
58
+ const can = makeCan(['pos_orders.pagar'], false)
59
+ const gated = gateTableMetadata(meta, 'pos_orders', can)
60
+ expect(gated.actions.map((a) => a.key)).toEqual(['pagar'])
61
+ })
62
+
63
+ it('mapea edit→update, delete→delete, view→index en actions explícitas', () => {
64
+ const meta = baseMeta({
65
+ actions: [action('view'), action('edit'), action('delete')],
66
+ hasActions: true,
67
+ })
68
+ const can = makeCan(['pos_orders.index', 'pos_orders.update'], false)
69
+ const gated = gateTableMetadata(meta, 'pos_orders', can)
70
+ expect(gated.actions.map((a) => a.key)).toEqual(['view', 'edit'])
71
+ })
72
+
73
+ it('materializa el trío implícito CRUD y filtra sus entradas', () => {
74
+ // Sin actions explícitas + enableCRUDActions → trío View/Edit/Delete.
75
+ const can = makeCan(['pos_orders.index', 'pos_orders.delete'], false)
76
+ const gated = gateTableMetadata(baseMeta(), 'pos_orders', can)
77
+ expect(gated.actions.map((a) => a.key)).toEqual(['view', 'delete'])
78
+ expect(gated.hasActions).toBe(true)
79
+ expect(gated.enableCRUDActions).toBe(true)
80
+ })
81
+
82
+ it('todo denegado → sin actions y sin trío re-sintetizado aguas abajo', () => {
83
+ const can = makeCan([], false)
84
+ const gated = gateTableMetadata(baseMeta(), 'pos_orders', can)
85
+ expect(gated.actions).toEqual([])
86
+ expect(gated.hasActions).toBe(false)
87
+ expect(gated.enableCRUDActions).toBe(false)
88
+ expect(gated.canExport).toBe(false)
89
+ expect(gated.canImport).toBe(false)
90
+ })
91
+
92
+ it('admin/wildcard → metadata intacta en lo visible', () => {
93
+ const meta = baseMeta({ actions: [action('pagar')], hasActions: true })
94
+ const gated = gateTableMetadata(meta, 'pos_orders', makeCan([], true))
95
+ expect(gated.actions.map((a) => a.key)).toEqual(['pagar'])
96
+ expect(gated.canExport).toBe(true)
97
+ expect(gated.canImport).toBe(true)
98
+ })
99
+ })
100
+
101
+ describe('DynamicCRUDPage — botón Crear', () => {
102
+ function fakeApi(meta: TableMetadata): ApiClient {
103
+ const ok = (data: unknown) => ({ data: { success: true, data, meta: { total: 0 } } })
104
+ return {
105
+ get: vi.fn(async (url: string) => {
106
+ if (url.startsWith('/metadata/table/')) return ok(meta)
107
+ return ok([])
108
+ }),
109
+ post: vi.fn(async () => ok(null)),
110
+ put: vi.fn(async () => ok(null)),
111
+ delete: vi.fn(async () => ok(null)),
112
+ }
113
+ }
114
+
115
+ function mount(model: string, permissions: string[] | null) {
116
+ const meta = baseMeta({ title: 'Pedidos', canExport: false, canImport: false })
117
+ useMetadataCache.getState().setMetadata(model, meta)
118
+ const page = <DynamicCRUDPage model={model} />
119
+ return render(
120
+ <ApiProvider client={fakeApi(meta)}>
121
+ {permissions === null ? (
122
+ page
123
+ ) : (
124
+ <PermissionsProvider permissions={permissions} isAdmin={false}>
125
+ {page}
126
+ </PermissionsProvider>
127
+ )}
128
+ </ApiProvider>,
129
+ )
130
+ }
131
+
132
+ it('sin provider → Crear visible (comportamiento actual)', async () => {
133
+ mount('orders_a', null)
134
+ expect(await screen.findByText(/New Pedido/)).toBeTruthy()
135
+ })
136
+
137
+ it('con provider y sin model.create → Crear oculto', async () => {
138
+ mount('orders_b', ['orders_b.index'])
139
+ expect(await screen.findByText('Pedidos')).toBeTruthy()
140
+ expect(screen.queryByText(/New Pedido/)).toBeNull()
141
+ })
142
+
143
+ it('con provider y model.create otorgado → Crear visible', async () => {
144
+ mount('orders_c', ['orders_c.index', 'orders_c.create'])
145
+ expect(await screen.findByText(/New Pedido/)).toBeTruthy()
146
+ })
147
+ })