@asteby/metacore-runtime-react 18.13.3 → 18.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,429 @@
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 (mirrors the app sidebar so admins recognise what they grant):
10
+ // header — title + "Nuevo rol" (primary) + "Guardar permisos" (green).
11
+ // left — Card "Rol": clean role combobox with inline Editar/Eliminar
12
+ // icons (no removable chip) + "Permisos Generales" flags.
13
+ // — Card "Módulos": a searchable, accordion *tree* grouped by
14
+ // `addon_label` (modules without one → "Sistema"). Each group
15
+ // lists its modules with their icon + a granted-count badge.
16
+ // Clicking a module selects it and reveals its action grid.
17
+ // right — Card "Acciones permitidas": granted counter N/M, mark-all /
18
+ // clear, checkbox grid (icon + label per action). Clear empty
19
+ // states for "pick a role" / "pick a module" / loading.
20
+ //
21
+ // Saving calls `syncRolePermissions(roleId, capabilities)` with the FULL
22
+ // granted set of the active role (baseline + the edits made here). Dirty
23
+ // state is tracked against the loaded baseline and surfaced next to the
24
+ // save button.
25
+ import * as React from 'react';
26
+ import { Check, ChevronRight, ChevronsUpDown, CheckCheck, Eraser, Folder, Pencil, Plus, Save, Search, Shield, Trash2, } from 'lucide-react';
27
+ import { toast } from 'sonner';
28
+ import { cn } from '@asteby/metacore-ui/lib';
29
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Checkbox, Collapsible, CollapsibleContent, CollapsibleTrigger, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, Popover, PopoverContent, PopoverTrigger, Separator, Skeleton, } from '@asteby/metacore-ui/primitives';
30
+ import { DynamicIcon } from './dynamic-icon';
31
+ // ---------------------------------------------------------------------------
32
+ // Pure helpers (exported for hosts/tests)
33
+ // ---------------------------------------------------------------------------
34
+ /** Capability for a catalog module action: `lowercase(moduleKey).actionKey`. */
35
+ export function moduleActionCapability(moduleKey, actionKey) {
36
+ return `${moduleKey.toLowerCase()}.${actionKey}`;
37
+ }
38
+ /** All capabilities of one module. */
39
+ export function moduleCapabilities(module) {
40
+ return module.actions.map((a) => moduleActionCapability(module.key, a.key));
41
+ }
42
+ /** How many of the module's capabilities are in the granted set. */
43
+ export function grantedCountForModule(granted, module) {
44
+ return moduleCapabilities(module).filter((c) => granted.has(c)).length;
45
+ }
46
+ export function capabilitySetsEqual(a, b) {
47
+ if (a.size !== b.size)
48
+ return false;
49
+ for (const v of a)
50
+ if (!b.has(v))
51
+ return false;
52
+ return true;
53
+ }
54
+ /** Default lucide icon when the manifest action doesn't declare one. */
55
+ export function defaultActionIcon(actionKey, kind) {
56
+ switch (actionKey) {
57
+ case 'index':
58
+ return 'List';
59
+ case 'create':
60
+ return 'Plus';
61
+ case 'update':
62
+ return 'Pencil';
63
+ case 'delete':
64
+ return 'Trash2';
65
+ case 'export':
66
+ return 'Download';
67
+ case 'import':
68
+ return 'Upload';
69
+ default:
70
+ return kind === 'crud' ? 'List' : 'Zap';
71
+ }
72
+ }
73
+ /** Group label fallback when a module has no addon ("Sistema" = core/infra). */
74
+ const SYSTEM_GROUP = 'Sistema';
75
+ function moduleGroupLabel(mod) {
76
+ return mod.addon_label || mod.addon_key || SYSTEM_GROUP;
77
+ }
78
+ /** Accent-insensitive, lowercase fold for tree search. */
79
+ function fold(s) {
80
+ return s
81
+ .normalize('NFD')
82
+ .replace(/[̀-ͯ]/g, '')
83
+ .toLowerCase();
84
+ }
85
+ function slugify(label) {
86
+ return fold(label)
87
+ .trim()
88
+ .replace(/[^a-z0-9]+/g, '_')
89
+ .replace(/^_+|_+$/g, '');
90
+ }
91
+ const ROLE_COLORS = [
92
+ '#ef4444',
93
+ '#f97316',
94
+ '#eab308',
95
+ '#22c55e',
96
+ '#06b6d4',
97
+ '#3b82f6',
98
+ '#8b5cf6',
99
+ '#ec4899',
100
+ '#6b7280',
101
+ ];
102
+ export function groupModules(modules) {
103
+ const order = [];
104
+ const byGroup = new Map();
105
+ for (const mod of modules) {
106
+ const g = moduleGroupLabel(mod);
107
+ if (!byGroup.has(g)) {
108
+ byGroup.set(g, []);
109
+ order.push(g);
110
+ }
111
+ byGroup.get(g).push(mod);
112
+ }
113
+ return order.map((label) => ({ label, modules: byGroup.get(label) }));
114
+ }
115
+ /** Filter the grouped tree by a folded query against module + group labels. */
116
+ export function filterModuleGroups(groups, query) {
117
+ const q = fold(query).trim();
118
+ if (!q)
119
+ return groups;
120
+ const out = [];
121
+ for (const g of groups) {
122
+ const groupMatches = fold(g.label).includes(q);
123
+ const mods = groupMatches
124
+ ? g.modules
125
+ : g.modules.filter((m) => fold(m.label).includes(q) || fold(m.key).includes(q));
126
+ if (mods.length)
127
+ out.push({ label: g.label, modules: mods });
128
+ }
129
+ return out;
130
+ }
131
+ // ---------------------------------------------------------------------------
132
+ // Internal sub-components
133
+ // ---------------------------------------------------------------------------
134
+ /** Checkbox row used by both the action grid and the general flags. */
135
+ function CapabilityCheck({ checked, disabled, onToggle, icon, label, description, }) {
136
+ return (_jsxs("div", { role: "checkbox", "aria-checked": checked, "aria-disabled": disabled || undefined, tabIndex: disabled ? -1 : 0, onClick: disabled ? undefined : onToggle, onKeyDown: disabled
137
+ ? undefined
138
+ : (e) => {
139
+ if (e.key === ' ' || e.key === 'Enter') {
140
+ e.preventDefault();
141
+ onToggle();
142
+ }
143
+ }, 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 }))] })] }));
144
+ }
145
+ /** One module row inside the tree. */
146
+ function ModuleTreeItem({ module, active, granted, total, onSelect, }) {
147
+ return (_jsxs("button", { type: "button", onClick: onSelect, "aria-current": active || undefined, className: cn('group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors', active
148
+ ? 'bg-primary/10 font-medium text-foreground'
149
+ : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'), children: [_jsx(DynamicIcon, { name: module.icon || 'Square', className: cn('h-4 w-4 shrink-0', active ? 'text-primary' : 'text-muted-foreground') }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: module.label }), granted > 0 && (_jsxs(Badge, { variant: granted === total ? 'default' : 'secondary', className: "h-5 shrink-0 px-1.5 text-[10px] tabular-nums", children: [granted, "/", total] }))] }));
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Component
153
+ // ---------------------------------------------------------------------------
154
+ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions, syncRolePermissions, createRole, updateRole, deleteRole, title = 'Permisos y Roles', className, }) {
155
+ const [catalog, setCatalog] = React.useState(null);
156
+ const [roles, setRoles] = React.useState(null);
157
+ const [loadError, setLoadError] = React.useState(false);
158
+ const [activeRoleId, setActiveRoleId] = React.useState(null);
159
+ const [activeModuleKey, setActiveModuleKey] = React.useState(null);
160
+ // baseline = capabilities as persisted; draft = baseline + local edits.
161
+ const [baseline, setBaseline] = React.useState(null);
162
+ const [draft, setDraft] = React.useState(null);
163
+ const [loadingPerms, setLoadingPerms] = React.useState(false);
164
+ const [saving, setSaving] = React.useState(false);
165
+ const [roleOpen, setRoleOpen] = React.useState(false);
166
+ const [moduleQuery, setModuleQuery] = React.useState('');
167
+ // Groups the user explicitly collapsed (default: every group open).
168
+ const [collapsedGroups, setCollapsedGroups] = React.useState(new Set());
169
+ // Pending role switch while there are unsaved changes.
170
+ const [pendingRoleId, setPendingRoleId] = React.useState(null);
171
+ const [roleDialog, setRoleDialog] = React.useState({ open: false, mode: 'create', label: '', color: ROLE_COLORS[5] });
172
+ const [roleSaving, setRoleSaving] = React.useState(false);
173
+ const [deleteOpen, setDeleteOpen] = React.useState(false);
174
+ const [deleting, setDeleting] = React.useState(false);
175
+ const loading = catalog === null || roles === null;
176
+ // ---- initial load: catalog + roles in parallel -------------------------
177
+ React.useEffect(() => {
178
+ let cancelled = false;
179
+ Promise.all([loadModules(), loadRoles()])
180
+ .then(([cat, rs]) => {
181
+ if (cancelled)
182
+ return;
183
+ setCatalog(cat);
184
+ setRoles(rs);
185
+ setActiveRoleId((prev) => prev ?? rs[0]?.id ?? null);
186
+ setActiveModuleKey((prev) => prev ?? cat.modules[0]?.key ?? null);
187
+ })
188
+ .catch(() => {
189
+ if (!cancelled)
190
+ setLoadError(true);
191
+ });
192
+ return () => {
193
+ cancelled = true;
194
+ };
195
+ // eslint-disable-next-line react-hooks/exhaustive-deps
196
+ }, []);
197
+ // ---- per-role permissions ----------------------------------------------
198
+ React.useEffect(() => {
199
+ if (!activeRoleId) {
200
+ setBaseline(null);
201
+ setDraft(null);
202
+ return;
203
+ }
204
+ let cancelled = false;
205
+ setLoadingPerms(true);
206
+ loadRolePermissions(activeRoleId)
207
+ .then((caps) => {
208
+ if (cancelled)
209
+ return;
210
+ setBaseline(new Set(caps));
211
+ setDraft(new Set(caps));
212
+ })
213
+ .catch(() => {
214
+ if (cancelled)
215
+ return;
216
+ toast.error('No se pudieron cargar los permisos del rol');
217
+ setBaseline(null);
218
+ setDraft(null);
219
+ })
220
+ .finally(() => {
221
+ if (!cancelled)
222
+ setLoadingPerms(false);
223
+ });
224
+ return () => {
225
+ cancelled = true;
226
+ };
227
+ // eslint-disable-next-line react-hooks/exhaustive-deps
228
+ }, [activeRoleId]);
229
+ const activeRole = React.useMemo(() => roles?.find((r) => r.id === activeRoleId) ?? null, [roles, activeRoleId]);
230
+ const activeModule = React.useMemo(() => catalog?.modules.find((m) => m.key === activeModuleKey) ?? null, [catalog, activeModuleKey]);
231
+ const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft);
232
+ // Module tree: grouped by addon label, optionally filtered by the search.
233
+ const allGroups = React.useMemo(() => groupModules(catalog?.modules ?? []), [catalog]);
234
+ const visibleGroups = React.useMemo(() => filterModuleGroups(allGroups, moduleQuery), [allGroups, moduleQuery]);
235
+ const searching = moduleQuery.trim().length > 0;
236
+ // ---- capability edits ---------------------------------------------------
237
+ const toggleCapability = React.useCallback((cap) => {
238
+ setDraft((prev) => {
239
+ if (!prev)
240
+ return prev;
241
+ const next = new Set(prev);
242
+ if (next.has(cap))
243
+ next.delete(cap);
244
+ else
245
+ next.add(cap);
246
+ return next;
247
+ });
248
+ }, []);
249
+ const setModuleAll = React.useCallback((on) => {
250
+ if (!activeModule)
251
+ return;
252
+ const caps = moduleCapabilities(activeModule);
253
+ setDraft((prev) => {
254
+ if (!prev)
255
+ return prev;
256
+ const next = new Set(prev);
257
+ for (const c of caps) {
258
+ if (on)
259
+ next.add(c);
260
+ else
261
+ next.delete(c);
262
+ }
263
+ return next;
264
+ });
265
+ }, [activeModule]);
266
+ const handleSave = async () => {
267
+ if (!activeRoleId || !draft)
268
+ return;
269
+ setSaving(true);
270
+ try {
271
+ await syncRolePermissions(activeRoleId, Array.from(draft).sort());
272
+ setBaseline(new Set(draft));
273
+ toast.success('Permisos guardados');
274
+ }
275
+ catch {
276
+ toast.error('No se pudieron guardar los permisos');
277
+ }
278
+ finally {
279
+ setSaving(false);
280
+ }
281
+ };
282
+ // ---- role switching (dirty guard) ---------------------------------------
283
+ const requestRoleSwitch = (roleId) => {
284
+ if (roleId === activeRoleId)
285
+ return;
286
+ if (dirty)
287
+ setPendingRoleId(roleId);
288
+ else
289
+ setActiveRoleId(roleId);
290
+ };
291
+ const toggleGroup = (label) => setCollapsedGroups((prev) => {
292
+ const next = new Set(prev);
293
+ if (next.has(label))
294
+ next.delete(label);
295
+ else
296
+ next.add(label);
297
+ return next;
298
+ });
299
+ // ---- role CRUD -----------------------------------------------------------
300
+ const refreshRoles = async (selectId) => {
301
+ const rs = await loadRoles();
302
+ setRoles(rs);
303
+ if (selectId !== undefined)
304
+ setActiveRoleId(selectId);
305
+ else if (activeRoleId && !rs.some((r) => r.id === activeRoleId))
306
+ setActiveRoleId(rs[0]?.id ?? null);
307
+ return rs;
308
+ };
309
+ const handleRoleSubmit = async () => {
310
+ const label = roleDialog.label.trim();
311
+ if (!label)
312
+ return;
313
+ setRoleSaving(true);
314
+ try {
315
+ if (roleDialog.mode === 'create' && createRole) {
316
+ const created = await createRole({
317
+ name: slugify(label),
318
+ label,
319
+ color: roleDialog.color,
320
+ });
321
+ const rs = await loadRoles();
322
+ setRoles(rs);
323
+ const createdId = (created && 'id' in created && created.id) ||
324
+ rs.find((r) => r.name === slugify(label))?.id ||
325
+ null;
326
+ if (createdId)
327
+ setActiveRoleId(createdId);
328
+ toast.success('Rol creado');
329
+ }
330
+ else if (roleDialog.mode === 'edit' && updateRole && activeRole) {
331
+ await updateRole(activeRole.id, {
332
+ name: activeRole.name,
333
+ label,
334
+ color: roleDialog.color,
335
+ });
336
+ await refreshRoles(activeRole.id);
337
+ toast.success('Rol actualizado');
338
+ }
339
+ setRoleDialog((d) => ({ ...d, open: false }));
340
+ }
341
+ catch {
342
+ toast.error(roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol');
343
+ }
344
+ finally {
345
+ setRoleSaving(false);
346
+ }
347
+ };
348
+ const handleDeleteRole = async () => {
349
+ if (!deleteRole || !activeRole)
350
+ return;
351
+ setDeleting(true);
352
+ try {
353
+ await deleteRole(activeRole.id);
354
+ const rs = await loadRoles();
355
+ setRoles(rs);
356
+ setActiveRoleId(rs[0]?.id ?? null);
357
+ toast.success('Rol eliminado');
358
+ setDeleteOpen(false);
359
+ }
360
+ catch {
361
+ toast.error('No se pudo eliminar el rol');
362
+ }
363
+ finally {
364
+ setDeleting(false);
365
+ }
366
+ };
367
+ const openEditRole = () => {
368
+ if (!activeRole)
369
+ return;
370
+ setRoleDialog({
371
+ open: true,
372
+ mode: 'edit',
373
+ label: activeRole.label || activeRole.name,
374
+ color: activeRole.color || ROLE_COLORS[5],
375
+ });
376
+ };
377
+ // ---- derived for the right panel ----------------------------------------
378
+ const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0;
379
+ const moduleTotal = activeModule?.actions.length ?? 0;
380
+ const checksDisabled = !activeRole || !draft || loadingPerms || saving;
381
+ // ---- render --------------------------------------------------------------
382
+ if (loadError) {
383
+ 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." })] }));
384
+ }
385
+ if (loading) {
386
+ 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-40 w-full" }), _jsx(Skeleton, { className: "h-80 w-full" })] }), _jsx(Skeleton, { className: "h-96 w-full" })] })] }));
387
+ }
388
+ 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({
389
+ open: true,
390
+ mode: 'create',
391
+ label: '',
392
+ color: ROLE_COLORS[5],
393
+ }), 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: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs(Popover, { open: roleOpen, onOpenChange: setRoleOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": roleOpen, className: "min-w-0 flex-1 justify-between font-normal", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [activeRole && (_jsx("span", { className: "h-2.5 w-2.5 shrink-0 rounded-full", style: {
394
+ background: activeRole.color || '#6b7280',
395
+ }, "aria-hidden": "true" })), _jsx("span", { className: "truncate", children: activeRole
396
+ ? activeRole.label || activeRole.name
397
+ : 'Seleccionar rol…' })] }), _jsx(ChevronsUpDown, { className: "ml-2 h-4 w-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "w-[280px] 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: () => {
398
+ requestRoleSwitch(role.id);
399
+ setRoleOpen(false);
400
+ }, children: [_jsx("span", { className: "mr-2 h-2 w-2 shrink-0 rounded-full", style: {
401
+ background: role.color || '#6b7280',
402
+ }, "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))) })] })] }) })] }), updateRole && (_jsx(Button, { variant: "outline", size: "icon", className: "h-9 w-9 shrink-0", "aria-label": "Editar rol", disabled: !activeRole, onClick: openEditRole, children: _jsx(Pencil, { className: "h-4 w-4" }) })), deleteRole && (_jsx(Button, { variant: "outline", size: "icon", className: "h-9 w-9 shrink-0 text-destructive hover:text-destructive", "aria-label": "Eliminar rol", disabled: !activeRole, onClick: () => setDeleteOpen(true), children: _jsx(Trash2, { className: "h-4 w-4" }) }))] }), (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\u00F3dulos" }), _jsx(CardDescription, { children: "Elige el m\u00F3dulo cuyas acciones quieres configurar." })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: moduleQuery, onChange: (e) => setModuleQuery(e.target.value), placeholder: "Buscar m\u00F3dulo\u2026", "aria-label": "Buscar m\u00F3dulo", className: "pl-8" })] }), _jsx("div", { role: "tree", "aria-label": "M\u00F3dulos", className: "-mx-1 max-h-[460px] overflow-y-auto px-1", children: visibleGroups.length === 0 ? (_jsx("p", { className: "px-2 py-6 text-center text-sm text-muted-foreground", children: "Sin m\u00F3dulos." })) : (visibleGroups.map((group) => {
403
+ // While searching, force every matching group open.
404
+ const open = searching || !collapsedGroups.has(group.label);
405
+ return (_jsxs(Collapsible, { open: open, onOpenChange: () => !searching && toggleGroup(group.label), children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted/40", children: [_jsx(ChevronRight, { className: cn('h-3.5 w-3.5 shrink-0 transition-transform', open && 'rotate-90') }), _jsx(Folder, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "min-w-0 flex-1 truncate normal-case", children: group.label }), _jsx("span", { className: "shrink-0 text-[10px] tabular-nums opacity-70", children: group.modules.length })] }) }), _jsx(CollapsibleContent, { children: _jsx("div", { className: "ml-3 flex flex-col gap-0.5 border-l border-border/60 pl-1.5", children: group.modules.map((mod) => (_jsx(ModuleTreeItem, { module: mod, active: mod.key === activeModuleKey, granted: draft
406
+ ? grantedCountForModule(draft, mod)
407
+ : 0, total: mod.actions.length, onSelect: () => setActiveModuleKey(mod.key) }, mod.key))) }) })] }, group.label));
408
+ })) })] })] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs("div", { className: "flex flex-wrap items-start justify-between gap-2", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs(CardTitle, { className: "flex items-center gap-2 text-base", children: [activeModule && (_jsx(DynamicIcon, { name: activeModule.icon || 'Square', className: "h-4 w-4 shrink-0 text-primary" })), _jsx("span", { className: "truncate", children: activeModule ? activeModule.label : 'Acciones permitidas' })] }), _jsx(CardDescription, { children: activeModule
409
+ ? `${moduleGroupLabel(activeModule)} · configura las acciones permitidas`
410
+ : 'Configura los permisos del módulo seleccionado.' })] }), activeRole && 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." })) : 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))) })) : !activeModule ? (_jsx(EmptyHint, { text: "Selecciona un m\u00F3dulo del \u00E1rbol para ver sus acciones." })) : (_jsx("div", { className: "grid gap-2 sm:grid-cols-2 xl:grid-cols-3", children: activeModule.actions.map((action) => {
411
+ const cap = moduleActionCapability(activeModule.key, action.key);
412
+ 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));
413
+ }) })) })] })] }), _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: () => {
414
+ setActiveRoleId(pendingRoleId);
415
+ setPendingRoleId(null);
416
+ }, 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
417
+ ? 'scale-110 border-foreground'
418
+ : '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
419
+ ? 'Guardando…'
420
+ : roleDialog.mode === 'create'
421
+ ? 'Crear rol'
422
+ : '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) => {
423
+ e.preventDefault();
424
+ handleDeleteRole();
425
+ }, children: deleting ? 'Eliminando…' : 'Eliminar' })] })] }) })] }));
426
+ }
427
+ function EmptyHint({ text }) {
428
+ 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 })] }));
429
+ }
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.15.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
+ })