@asteby/metacore-runtime-react 18.14.0 → 18.16.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 +24 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/permissions-manager.d.ts +58 -11
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +146 -47
- package/package.json +1 -1
- package/src/__tests__/permissions-manager.test.tsx +245 -35
- package/src/index.ts +6 -0
- package/src/permissions-manager.tsx +417 -222
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
// Transport-agnostic: every read/write arrives via props (loaders/mutators),
|
|
4
4
|
// so each host wires them to its own api client (ops → /api/permissions/*).
|
|
5
5
|
// The capability universe (modules × actions + general flags) is derived from
|
|
6
|
-
// the installed manifests server-side; this
|
|
6
|
+
// the installed manifests + the real sidebar nav server/host-side; this
|
|
7
|
+
// component only renders it.
|
|
7
8
|
//
|
|
8
|
-
// Layout
|
|
9
|
+
// Layout — a *flat list* that mirrors the app sidebar (NO accordions/folders):
|
|
9
10
|
// header — title + "Nuevo rol" (primary) + "Guardar permisos" (green).
|
|
10
|
-
// left — Card "Rol":
|
|
11
|
-
//
|
|
12
|
-
// — Card "
|
|
13
|
-
//
|
|
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 flat list. Each group renders a
|
|
14
|
+
// non-collapsible grey header (uppercase tracking, like "Módulos"
|
|
15
|
+
// / "Sistema" in the sidebar) followed by its modules as clickable
|
|
16
|
+
// rows (icon + label + granted-count badge). Clicking a row selects
|
|
17
|
+
// that module and reveals its action grid on the right.
|
|
14
18
|
// right — Card "Acciones permitidas": granted counter N/M, mark-all /
|
|
15
|
-
// clear
|
|
19
|
+
// clear, checkbox grid (icon + label per action). Clear empty
|
|
20
|
+
// states for "pick a role" / "pick a module" / loading.
|
|
16
21
|
//
|
|
17
22
|
// Saving calls `syncRolePermissions(roleId, capabilities)` with the FULL
|
|
18
23
|
// granted set of the active role (baseline + the edits made here). Dirty
|
|
@@ -27,9 +32,9 @@ import {
|
|
|
27
32
|
Pencil,
|
|
28
33
|
Plus,
|
|
29
34
|
Save,
|
|
35
|
+
Search,
|
|
30
36
|
Shield,
|
|
31
37
|
Trash2,
|
|
32
|
-
X,
|
|
33
38
|
} from 'lucide-react'
|
|
34
39
|
import { toast } from 'sonner'
|
|
35
40
|
import { cn } from '@asteby/metacore-ui/lib'
|
|
@@ -72,32 +77,52 @@ import {
|
|
|
72
77
|
import { DynamicIcon } from './dynamic-icon'
|
|
73
78
|
|
|
74
79
|
// ---------------------------------------------------------------------------
|
|
75
|
-
// Types (mirror of `GET /api/permissions/modules`
|
|
80
|
+
// Types (mirror of `GET /api/permissions/modules` + the host sidebar nav)
|
|
76
81
|
// ---------------------------------------------------------------------------
|
|
77
82
|
|
|
78
83
|
export interface PermissionActionDef {
|
|
79
|
-
/** Canonical action key (`index`, `create`, …,
|
|
84
|
+
/** Canonical action key (`index`, `create`, …, a custom `pagar`, or `access`). */
|
|
80
85
|
key: string
|
|
81
|
-
/** Localized label ("Listar", "Pagar"). */
|
|
86
|
+
/** Localized label ("Listar", "Pagar", "Acceder"). */
|
|
82
87
|
label: string
|
|
83
88
|
/** Lucide icon name from the manifest action (optional). */
|
|
84
89
|
icon?: string
|
|
85
|
-
/**
|
|
86
|
-
|
|
90
|
+
/**
|
|
91
|
+
* `crud` for the derived CRUD set, `custom` for manifest actions,
|
|
92
|
+
* `screen` for the single `access` action of a non-model screen.
|
|
93
|
+
*/
|
|
94
|
+
kind?: 'crud' | 'custom' | 'screen' | string
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
export interface PermissionModuleDef {
|
|
90
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Module key.
|
|
100
|
+
* - model: lowercase model table (`pos_orders`).
|
|
101
|
+
* - screen: `screen.<navKey>` (the host prefixes it).
|
|
102
|
+
*/
|
|
91
103
|
key: string
|
|
92
|
-
/** Localized module label ("Pedidos POS"). */
|
|
104
|
+
/** Localized module label ("Pedidos POS", "Terminal"). */
|
|
93
105
|
label: string
|
|
94
|
-
/**
|
|
106
|
+
/** Module icon (lucide name) — mirrors the sidebar entry. */
|
|
107
|
+
icon?: string
|
|
108
|
+
/** Whether this entry is a data model or a non-model screen. */
|
|
109
|
+
kind?: 'model' | 'screen'
|
|
110
|
+
/** Owning addon key (`pos`) — legacy shape only, used for grouping. */
|
|
95
111
|
addon_key?: string
|
|
96
|
-
/** Localized addon label ("Punto de venta") —
|
|
112
|
+
/** Localized addon label ("Punto de venta") — legacy shape only. */
|
|
97
113
|
addon_label?: string
|
|
98
114
|
actions: PermissionActionDef[]
|
|
99
115
|
}
|
|
100
116
|
|
|
117
|
+
/**
|
|
118
|
+
* A sidebar-style group: a grey (non-collapsible) header + its modules.
|
|
119
|
+
* `title === ''` → no header (e.g. core/infra modules).
|
|
120
|
+
*/
|
|
121
|
+
export interface ModuleGroup {
|
|
122
|
+
title: string
|
|
123
|
+
modules: PermissionModuleDef[]
|
|
124
|
+
}
|
|
125
|
+
|
|
101
126
|
export interface GeneralPermissionDef {
|
|
102
127
|
/** Full capability key (`general.work_after_hours`). */
|
|
103
128
|
key: string
|
|
@@ -105,11 +130,27 @@ export interface GeneralPermissionDef {
|
|
|
105
130
|
description?: string
|
|
106
131
|
}
|
|
107
132
|
|
|
108
|
-
|
|
133
|
+
/**
|
|
134
|
+
* What `loadModules()` may return.
|
|
135
|
+
*
|
|
136
|
+
* New (preferred) shape — pre-grouped flat list, mirrors the host sidebar:
|
|
137
|
+
* { groups: ModuleGroup[], general }
|
|
138
|
+
*
|
|
139
|
+
* Legacy shape (still accepted, wrapped into a single untitled group) —
|
|
140
|
+
* { modules: PermissionModuleDef[], general }
|
|
141
|
+
*/
|
|
142
|
+
export interface GroupedPermissionsCatalog {
|
|
143
|
+
groups: ModuleGroup[]
|
|
144
|
+
general: GeneralPermissionDef[]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface FlatPermissionsCatalog {
|
|
109
148
|
modules: PermissionModuleDef[]
|
|
110
149
|
general: GeneralPermissionDef[]
|
|
111
150
|
}
|
|
112
151
|
|
|
152
|
+
export type PermissionsCatalog = GroupedPermissionsCatalog | FlatPermissionsCatalog
|
|
153
|
+
|
|
113
154
|
export interface RoleDef {
|
|
114
155
|
id: string
|
|
115
156
|
/** Stable role key ("cashier"). */
|
|
@@ -127,7 +168,7 @@ export interface RoleInput {
|
|
|
127
168
|
}
|
|
128
169
|
|
|
129
170
|
export interface PermissionsManagerProps {
|
|
130
|
-
/** Loads the module×action universe + general flags. */
|
|
171
|
+
/** Loads the module×action universe + general flags (grouped or flat). */
|
|
131
172
|
loadModules: () => Promise<PermissionsCatalog>
|
|
132
173
|
/** Loads every assignable role. */
|
|
133
174
|
loadRoles: () => Promise<RoleDef[]>
|
|
@@ -135,7 +176,7 @@ export interface PermissionsManagerProps {
|
|
|
135
176
|
loadRolePermissions: (roleId: string) => Promise<string[]>
|
|
136
177
|
/** Persists the FULL granted capability set of a role. */
|
|
137
178
|
syncRolePermissions: (roleId: string, capabilities: string[]) => Promise<void>
|
|
138
|
-
/** Optional role CRUD — omitting one hides its
|
|
179
|
+
/** Optional role CRUD — omitting one hides its control. */
|
|
139
180
|
createRole?: (input: RoleInput) => Promise<RoleDef | void>
|
|
140
181
|
updateRole?: (roleId: string, input: RoleInput) => Promise<RoleDef | void>
|
|
141
182
|
deleteRole?: (roleId: string) => Promise<void>
|
|
@@ -187,16 +228,32 @@ export function defaultActionIcon(actionKey: string, kind?: string): string {
|
|
|
187
228
|
return 'Download'
|
|
188
229
|
case 'import':
|
|
189
230
|
return 'Upload'
|
|
231
|
+
case 'access':
|
|
232
|
+
return 'Eye'
|
|
190
233
|
default:
|
|
191
|
-
|
|
234
|
+
if (kind === 'crud') return 'List'
|
|
235
|
+
if (kind === 'screen') return 'Eye'
|
|
236
|
+
return 'Zap'
|
|
192
237
|
}
|
|
193
238
|
}
|
|
194
239
|
|
|
195
|
-
|
|
196
|
-
|
|
240
|
+
/** Group label fallback when a legacy module has no addon ("Sistema" = core). */
|
|
241
|
+
const SYSTEM_GROUP = 'Sistema'
|
|
242
|
+
|
|
243
|
+
function legacyGroupLabel(mod: PermissionModuleDef): string {
|
|
244
|
+
return mod.addon_label || mod.addon_key || SYSTEM_GROUP
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Accent-insensitive, lowercase fold for search. */
|
|
248
|
+
function fold(s: string): string {
|
|
249
|
+
return s
|
|
197
250
|
.normalize('NFD')
|
|
198
|
-
.replace(/[
|
|
251
|
+
.replace(/[̀-ͯ]/g, '')
|
|
199
252
|
.toLowerCase()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function slugify(label: string): string {
|
|
256
|
+
return fold(label)
|
|
200
257
|
.trim()
|
|
201
258
|
.replace(/[^a-z0-9]+/g, '_')
|
|
202
259
|
.replace(/^_+|_+$/g, '')
|
|
@@ -214,6 +271,63 @@ const ROLE_COLORS = [
|
|
|
214
271
|
'#6b7280',
|
|
215
272
|
]
|
|
216
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Normalize whatever `loadModules` returned into the canonical grouped shape.
|
|
276
|
+
*
|
|
277
|
+
* - New shape (`{ groups }`): passed through (modules default to kind:'model').
|
|
278
|
+
* - Legacy flat shape (`{ modules }`): grouped by `addon_label`/`addon_key`
|
|
279
|
+
* (falling back to "Sistema") so old hosts keep their familiar buckets,
|
|
280
|
+
* every module defaulting to kind:'model'. The grey headers still render,
|
|
281
|
+
* just derived from the addon instead of the sidebar group.
|
|
282
|
+
*/
|
|
283
|
+
export function normalizeCatalogGroups(catalog: PermissionsCatalog): ModuleGroup[] {
|
|
284
|
+
const withKind = (m: PermissionModuleDef): PermissionModuleDef => ({
|
|
285
|
+
...m,
|
|
286
|
+
kind: m.kind ?? 'model',
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
if ('groups' in catalog && Array.isArray(catalog.groups)) {
|
|
290
|
+
return catalog.groups.map((g) => ({
|
|
291
|
+
title: g.title ?? '',
|
|
292
|
+
modules: g.modules.map(withKind),
|
|
293
|
+
}))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const modules = ('modules' in catalog && catalog.modules) || []
|
|
297
|
+
const order: string[] = []
|
|
298
|
+
const byGroup = new Map<string, PermissionModuleDef[]>()
|
|
299
|
+
for (const raw of modules) {
|
|
300
|
+
const mod = withKind(raw)
|
|
301
|
+
const g = legacyGroupLabel(mod)
|
|
302
|
+
if (!byGroup.has(g)) {
|
|
303
|
+
byGroup.set(g, [])
|
|
304
|
+
order.push(g)
|
|
305
|
+
}
|
|
306
|
+
byGroup.get(g)!.push(mod)
|
|
307
|
+
}
|
|
308
|
+
return order.map((title) => ({ title, modules: byGroup.get(title)! }))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Flat list of every module across groups, in render order. */
|
|
312
|
+
export function flattenGroups(groups: ModuleGroup[]): PermissionModuleDef[] {
|
|
313
|
+
return groups.flatMap((g) => g.modules)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Filter the grouped flat list by a folded query against module + group titles. */
|
|
317
|
+
export function filterModuleGroups(groups: ModuleGroup[], query: string): ModuleGroup[] {
|
|
318
|
+
const q = fold(query).trim()
|
|
319
|
+
if (!q) return groups
|
|
320
|
+
const out: ModuleGroup[] = []
|
|
321
|
+
for (const g of groups) {
|
|
322
|
+
const groupMatches = g.title.length > 0 && fold(g.title).includes(q)
|
|
323
|
+
const mods = groupMatches
|
|
324
|
+
? g.modules
|
|
325
|
+
: g.modules.filter((m) => fold(m.label).includes(q) || fold(m.key).includes(q))
|
|
326
|
+
if (mods.length) out.push({ title: g.title, modules: mods })
|
|
327
|
+
}
|
|
328
|
+
return out
|
|
329
|
+
}
|
|
330
|
+
|
|
217
331
|
// ---------------------------------------------------------------------------
|
|
218
332
|
// Internal sub-components
|
|
219
333
|
// ---------------------------------------------------------------------------
|
|
@@ -276,37 +390,46 @@ function CapabilityCheck({
|
|
|
276
390
|
)
|
|
277
391
|
}
|
|
278
392
|
|
|
279
|
-
/**
|
|
280
|
-
function
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
393
|
+
/** One clickable module row in the flat list (mirrors a sidebar item). */
|
|
394
|
+
function ModuleRow({
|
|
395
|
+
module,
|
|
396
|
+
active,
|
|
397
|
+
granted,
|
|
398
|
+
total,
|
|
399
|
+
onSelect,
|
|
285
400
|
}: {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
401
|
+
module: PermissionModuleDef
|
|
402
|
+
active: boolean
|
|
403
|
+
granted: number
|
|
404
|
+
total: number
|
|
405
|
+
onSelect: () => void
|
|
290
406
|
}) {
|
|
291
407
|
return (
|
|
292
|
-
<
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
onClick={onSelect}
|
|
411
|
+
aria-current={active || undefined}
|
|
412
|
+
className={cn(
|
|
413
|
+
'group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
|
|
414
|
+
active
|
|
415
|
+
? 'bg-primary/10 font-medium text-foreground'
|
|
416
|
+
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
|
|
299
417
|
)}
|
|
300
|
-
|
|
301
|
-
<
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
>
|
|
307
|
-
<
|
|
308
|
-
|
|
309
|
-
|
|
418
|
+
>
|
|
419
|
+
<DynamicIcon
|
|
420
|
+
name={module.icon || (module.kind === 'screen' ? 'Eye' : 'Square')}
|
|
421
|
+
className={cn('h-4 w-4 shrink-0', active ? 'text-primary' : 'text-muted-foreground')}
|
|
422
|
+
/>
|
|
423
|
+
<span className="min-w-0 flex-1 truncate">{module.label}</span>
|
|
424
|
+
{granted > 0 && (
|
|
425
|
+
<Badge
|
|
426
|
+
variant={granted === total ? 'default' : 'secondary'}
|
|
427
|
+
className="h-5 shrink-0 px-1.5 text-[10px] tabular-nums"
|
|
428
|
+
>
|
|
429
|
+
{granted}/{total}
|
|
430
|
+
</Badge>
|
|
431
|
+
)}
|
|
432
|
+
</button>
|
|
310
433
|
)
|
|
311
434
|
}
|
|
312
435
|
|
|
@@ -325,7 +448,8 @@ export function PermissionsManager({
|
|
|
325
448
|
title = 'Permisos y Roles',
|
|
326
449
|
className,
|
|
327
450
|
}: PermissionsManagerProps) {
|
|
328
|
-
const [
|
|
451
|
+
const [groups, setGroups] = React.useState<ModuleGroup[] | null>(null)
|
|
452
|
+
const [general, setGeneral] = React.useState<GeneralPermissionDef[] | null>(null)
|
|
329
453
|
const [roles, setRoles] = React.useState<RoleDef[] | null>(null)
|
|
330
454
|
const [loadError, setLoadError] = React.useState(false)
|
|
331
455
|
|
|
@@ -339,7 +463,7 @@ export function PermissionsManager({
|
|
|
339
463
|
const [saving, setSaving] = React.useState(false)
|
|
340
464
|
|
|
341
465
|
const [roleOpen, setRoleOpen] = React.useState(false)
|
|
342
|
-
const [
|
|
466
|
+
const [moduleQuery, setModuleQuery] = React.useState('')
|
|
343
467
|
|
|
344
468
|
// Pending role switch while there are unsaved changes.
|
|
345
469
|
const [pendingRoleId, setPendingRoleId] = React.useState<string | null>(null)
|
|
@@ -354,7 +478,9 @@ export function PermissionsManager({
|
|
|
354
478
|
const [deleteOpen, setDeleteOpen] = React.useState(false)
|
|
355
479
|
const [deleting, setDeleting] = React.useState(false)
|
|
356
480
|
|
|
357
|
-
const loading =
|
|
481
|
+
const loading = groups === null || roles === null
|
|
482
|
+
|
|
483
|
+
const allModules = React.useMemo(() => (groups ? flattenGroups(groups) : []), [groups])
|
|
358
484
|
|
|
359
485
|
// ---- initial load: catalog + roles in parallel -------------------------
|
|
360
486
|
React.useEffect(() => {
|
|
@@ -362,10 +488,14 @@ export function PermissionsManager({
|
|
|
362
488
|
Promise.all([loadModules(), loadRoles()])
|
|
363
489
|
.then(([cat, rs]) => {
|
|
364
490
|
if (cancelled) return
|
|
365
|
-
|
|
491
|
+
const grouped = normalizeCatalogGroups(cat)
|
|
492
|
+
setGroups(grouped)
|
|
493
|
+
setGeneral(cat.general ?? [])
|
|
366
494
|
setRoles(rs)
|
|
367
495
|
setActiveRoleId((prev) => prev ?? rs[0]?.id ?? null)
|
|
368
|
-
setActiveModuleKey(
|
|
496
|
+
setActiveModuleKey(
|
|
497
|
+
(prev) => prev ?? flattenGroups(grouped)[0]?.key ?? null,
|
|
498
|
+
)
|
|
369
499
|
})
|
|
370
500
|
.catch(() => {
|
|
371
501
|
if (!cancelled) setLoadError(true)
|
|
@@ -411,23 +541,17 @@ export function PermissionsManager({
|
|
|
411
541
|
[roles, activeRoleId],
|
|
412
542
|
)
|
|
413
543
|
const activeModule = React.useMemo(
|
|
414
|
-
() =>
|
|
415
|
-
[
|
|
544
|
+
() => allModules.find((m) => m.key === activeModuleKey) ?? null,
|
|
545
|
+
[allModules, activeModuleKey],
|
|
416
546
|
)
|
|
417
547
|
|
|
418
548
|
const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
|
|
419
549
|
|
|
420
|
-
//
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const list = groups.get(group) ?? []
|
|
426
|
-
list.push(mod)
|
|
427
|
-
groups.set(group, list)
|
|
428
|
-
}
|
|
429
|
-
return Array.from(groups.entries())
|
|
430
|
-
}, [catalog])
|
|
550
|
+
// Flat module list, optionally filtered by the search.
|
|
551
|
+
const visibleGroups = React.useMemo(
|
|
552
|
+
() => filterModuleGroups(groups ?? [], moduleQuery),
|
|
553
|
+
[groups, moduleQuery],
|
|
554
|
+
)
|
|
431
555
|
|
|
432
556
|
// ---- capability edits ---------------------------------------------------
|
|
433
557
|
const toggleCapability = React.useCallback((cap: string) => {
|
|
@@ -518,7 +642,9 @@ export function PermissionsManager({
|
|
|
518
642
|
}
|
|
519
643
|
setRoleDialog((d) => ({ ...d, open: false }))
|
|
520
644
|
} catch {
|
|
521
|
-
toast.error(
|
|
645
|
+
toast.error(
|
|
646
|
+
roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol',
|
|
647
|
+
)
|
|
522
648
|
} finally {
|
|
523
649
|
setRoleSaving(false)
|
|
524
650
|
}
|
|
@@ -541,15 +667,35 @@ export function PermissionsManager({
|
|
|
541
667
|
}
|
|
542
668
|
}
|
|
543
669
|
|
|
670
|
+
const openEditRole = () => {
|
|
671
|
+
if (!activeRole) return
|
|
672
|
+
setRoleDialog({
|
|
673
|
+
open: true,
|
|
674
|
+
mode: 'edit',
|
|
675
|
+
label: activeRole.label || activeRole.name,
|
|
676
|
+
color: activeRole.color || ROLE_COLORS[5],
|
|
677
|
+
})
|
|
678
|
+
}
|
|
679
|
+
|
|
544
680
|
// ---- derived for the right panel ----------------------------------------
|
|
545
681
|
const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0
|
|
546
682
|
const moduleTotal = activeModule?.actions.length ?? 0
|
|
547
683
|
const checksDisabled = !activeRole || !draft || loadingPerms || saving
|
|
684
|
+
const activeModuleGroupTitle = React.useMemo(() => {
|
|
685
|
+
if (!activeModule || !groups) return ''
|
|
686
|
+
const g = groups.find((grp) => grp.modules.some((m) => m.key === activeModule.key))
|
|
687
|
+
return g?.title ?? ''
|
|
688
|
+
}, [activeModule, groups])
|
|
548
689
|
|
|
549
690
|
// ---- render --------------------------------------------------------------
|
|
550
691
|
if (loadError) {
|
|
551
692
|
return (
|
|
552
|
-
<div
|
|
693
|
+
<div
|
|
694
|
+
className={cn(
|
|
695
|
+
'flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground',
|
|
696
|
+
className,
|
|
697
|
+
)}
|
|
698
|
+
>
|
|
553
699
|
<Shield className="h-8 w-8 opacity-40" />
|
|
554
700
|
<p className="text-sm">No se pudo cargar el catálogo de permisos.</p>
|
|
555
701
|
</div>
|
|
@@ -568,8 +714,8 @@ export function PermissionsManager({
|
|
|
568
714
|
</div>
|
|
569
715
|
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
|
570
716
|
<div className="flex flex-col gap-4">
|
|
571
|
-
<Skeleton className="h-
|
|
572
|
-
<Skeleton className="h-
|
|
717
|
+
<Skeleton className="h-40 w-full" />
|
|
718
|
+
<Skeleton className="h-80 w-full" />
|
|
573
719
|
</div>
|
|
574
720
|
<Skeleton className="h-96 w-full" />
|
|
575
721
|
</div>
|
|
@@ -596,7 +742,12 @@ export function PermissionsManager({
|
|
|
596
742
|
{createRole && (
|
|
597
743
|
<Button
|
|
598
744
|
onClick={() =>
|
|
599
|
-
setRoleDialog({
|
|
745
|
+
setRoleDialog({
|
|
746
|
+
open: true,
|
|
747
|
+
mode: 'create',
|
|
748
|
+
label: '',
|
|
749
|
+
color: ROLE_COLORS[5],
|
|
750
|
+
})
|
|
600
751
|
}
|
|
601
752
|
>
|
|
602
753
|
<Plus className="mr-1.5 h-4 w-4" /> Nuevo rol
|
|
@@ -623,101 +774,103 @@ export function PermissionsManager({
|
|
|
623
774
|
<CardDescription>Selecciona el rol a configurar.</CardDescription>
|
|
624
775
|
</CardHeader>
|
|
625
776
|
<CardContent className="flex flex-col gap-3">
|
|
626
|
-
{
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
<
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
</
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
777
|
+
{/* Clean role combobox with inline edit/delete. */}
|
|
778
|
+
<div className="flex items-center gap-1.5">
|
|
779
|
+
<Popover open={roleOpen} onOpenChange={setRoleOpen}>
|
|
780
|
+
<PopoverTrigger asChild>
|
|
781
|
+
<Button
|
|
782
|
+
variant="outline"
|
|
783
|
+
role="combobox"
|
|
784
|
+
aria-expanded={roleOpen}
|
|
785
|
+
className="min-w-0 flex-1 justify-between font-normal"
|
|
786
|
+
>
|
|
787
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
788
|
+
{activeRole && (
|
|
789
|
+
<span
|
|
790
|
+
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
|
791
|
+
style={{
|
|
792
|
+
background: activeRole.color || '#6b7280',
|
|
793
|
+
}}
|
|
794
|
+
aria-hidden="true"
|
|
795
|
+
/>
|
|
796
|
+
)}
|
|
797
|
+
<span className="truncate">
|
|
798
|
+
{activeRole
|
|
799
|
+
? activeRole.label || activeRole.name
|
|
800
|
+
: 'Seleccionar rol…'}
|
|
801
|
+
</span>
|
|
802
|
+
</span>
|
|
803
|
+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
804
|
+
</Button>
|
|
805
|
+
</PopoverTrigger>
|
|
806
|
+
<PopoverContent className="w-[280px] p-0" align="start">
|
|
807
|
+
<Command>
|
|
808
|
+
<CommandInput placeholder="Buscar rol…" />
|
|
809
|
+
<CommandList>
|
|
810
|
+
<CommandEmpty>Sin resultados.</CommandEmpty>
|
|
811
|
+
<CommandGroup>
|
|
812
|
+
{(roles ?? []).map((role) => (
|
|
813
|
+
<CommandItem
|
|
814
|
+
key={role.id}
|
|
815
|
+
value={`${role.label || ''} ${role.name}`}
|
|
816
|
+
onSelect={() => {
|
|
817
|
+
requestRoleSwitch(role.id)
|
|
818
|
+
setRoleOpen(false)
|
|
819
|
+
}}
|
|
820
|
+
>
|
|
821
|
+
<span
|
|
822
|
+
className="mr-2 h-2 w-2 shrink-0 rounded-full"
|
|
823
|
+
style={{
|
|
824
|
+
background: role.color || '#6b7280',
|
|
825
|
+
}}
|
|
826
|
+
aria-hidden="true"
|
|
827
|
+
/>
|
|
828
|
+
<span className="truncate">
|
|
829
|
+
{role.label || role.name}
|
|
830
|
+
</span>
|
|
831
|
+
{role.id === activeRoleId && (
|
|
832
|
+
<Check className="ml-auto h-4 w-4" />
|
|
833
|
+
)}
|
|
834
|
+
</CommandItem>
|
|
835
|
+
))}
|
|
836
|
+
</CommandGroup>
|
|
837
|
+
</CommandList>
|
|
838
|
+
</Command>
|
|
839
|
+
</PopoverContent>
|
|
840
|
+
</Popover>
|
|
841
|
+
{updateRole && (
|
|
672
842
|
<Button
|
|
673
843
|
variant="outline"
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
844
|
+
size="icon"
|
|
845
|
+
className="h-9 w-9 shrink-0"
|
|
846
|
+
aria-label="Editar rol"
|
|
847
|
+
disabled={!activeRole}
|
|
848
|
+
onClick={openEditRole}
|
|
677
849
|
>
|
|
678
|
-
|
|
679
|
-
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
850
|
+
<Pencil className="h-4 w-4" />
|
|
680
851
|
</Button>
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
<
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
<span
|
|
698
|
-
className="mr-2 h-2 w-2 shrink-0 rounded-full"
|
|
699
|
-
style={{ background: role.color || '#6b7280' }}
|
|
700
|
-
aria-hidden="true"
|
|
701
|
-
/>
|
|
702
|
-
<span className="truncate">{role.label || role.name}</span>
|
|
703
|
-
{role.id === activeRoleId && (
|
|
704
|
-
<Check className="ml-auto h-4 w-4" />
|
|
705
|
-
)}
|
|
706
|
-
</CommandItem>
|
|
707
|
-
))}
|
|
708
|
-
</CommandGroup>
|
|
709
|
-
</CommandList>
|
|
710
|
-
</Command>
|
|
711
|
-
</PopoverContent>
|
|
712
|
-
</Popover>
|
|
713
|
-
|
|
714
|
-
{(catalog?.general.length ?? 0) > 0 && (
|
|
852
|
+
)}
|
|
853
|
+
{deleteRole && (
|
|
854
|
+
<Button
|
|
855
|
+
variant="outline"
|
|
856
|
+
size="icon"
|
|
857
|
+
className="h-9 w-9 shrink-0 text-destructive hover:text-destructive"
|
|
858
|
+
aria-label="Eliminar rol"
|
|
859
|
+
disabled={!activeRole}
|
|
860
|
+
onClick={() => setDeleteOpen(true)}
|
|
861
|
+
>
|
|
862
|
+
<Trash2 className="h-4 w-4" />
|
|
863
|
+
</Button>
|
|
864
|
+
)}
|
|
865
|
+
</div>
|
|
866
|
+
|
|
867
|
+
{(general?.length ?? 0) > 0 && (
|
|
715
868
|
<>
|
|
716
869
|
<Separator />
|
|
717
870
|
<div>
|
|
718
871
|
<h3 className="mb-2 text-sm font-semibold">Permisos Generales</h3>
|
|
719
872
|
<div className="flex flex-col gap-2">
|
|
720
|
-
{
|
|
873
|
+
{general!.map((g) => (
|
|
721
874
|
<CapabilityCheck
|
|
722
875
|
key={g.key}
|
|
723
876
|
checked={draft?.has(g.key) ?? false}
|
|
@@ -734,62 +887,73 @@ export function PermissionsManager({
|
|
|
734
887
|
</CardContent>
|
|
735
888
|
</Card>
|
|
736
889
|
|
|
737
|
-
{/* Card:
|
|
890
|
+
{/* Card: Módulos (flat list, mirrors the sidebar — no folders) */}
|
|
738
891
|
<Card>
|
|
739
892
|
<CardHeader>
|
|
740
|
-
<CardTitle className="text-base">
|
|
741
|
-
<CardDescription>
|
|
893
|
+
<CardTitle className="text-base">Módulos</CardTitle>
|
|
894
|
+
<CardDescription>
|
|
895
|
+
Elige el módulo cuyas acciones quieres configurar.
|
|
896
|
+
</CardDescription>
|
|
742
897
|
</CardHeader>
|
|
743
898
|
<CardContent className="flex flex-col gap-3">
|
|
744
|
-
|
|
745
|
-
<
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
899
|
+
<div className="relative">
|
|
900
|
+
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
901
|
+
<Input
|
|
902
|
+
value={moduleQuery}
|
|
903
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
904
|
+
setModuleQuery(e.target.value)
|
|
905
|
+
}
|
|
906
|
+
placeholder="Buscar módulo…"
|
|
907
|
+
aria-label="Buscar módulo"
|
|
908
|
+
className="pl-8"
|
|
749
909
|
/>
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
>
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
910
|
+
</div>
|
|
911
|
+
|
|
912
|
+
<div
|
|
913
|
+
role="list"
|
|
914
|
+
aria-label="Módulos"
|
|
915
|
+
className="-mx-1 flex max-h-[460px] flex-col gap-0.5 overflow-y-auto px-1"
|
|
916
|
+
>
|
|
917
|
+
{visibleGroups.length === 0 ? (
|
|
918
|
+
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
|
|
919
|
+
Sin módulos.
|
|
920
|
+
</p>
|
|
921
|
+
) : (
|
|
922
|
+
visibleGroups.map((group, gi) => (
|
|
923
|
+
<div
|
|
924
|
+
key={group.title || `__untitled_${gi}`}
|
|
925
|
+
className="flex flex-col gap-0.5"
|
|
926
|
+
>
|
|
927
|
+
{group.title && (
|
|
928
|
+
<div
|
|
929
|
+
role="heading"
|
|
930
|
+
aria-level={3}
|
|
931
|
+
className={cn(
|
|
932
|
+
'px-2 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground',
|
|
933
|
+
gi === 0 && 'pt-1',
|
|
934
|
+
)}
|
|
935
|
+
>
|
|
936
|
+
{group.title}
|
|
937
|
+
</div>
|
|
938
|
+
)}
|
|
939
|
+
{group.modules.map((mod) => (
|
|
940
|
+
<ModuleRow
|
|
941
|
+
key={mod.key}
|
|
942
|
+
module={mod}
|
|
943
|
+
active={mod.key === activeModuleKey}
|
|
944
|
+
granted={
|
|
945
|
+
draft
|
|
946
|
+
? grantedCountForModule(draft, mod)
|
|
947
|
+
: 0
|
|
948
|
+
}
|
|
949
|
+
total={mod.actions.length}
|
|
950
|
+
onSelect={() => setActiveModuleKey(mod.key)}
|
|
951
|
+
/>
|
|
788
952
|
))}
|
|
789
|
-
</
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
</
|
|
953
|
+
</div>
|
|
954
|
+
))
|
|
955
|
+
)}
|
|
956
|
+
</div>
|
|
793
957
|
</CardContent>
|
|
794
958
|
</Card>
|
|
795
959
|
</div>
|
|
@@ -798,11 +962,30 @@ export function PermissionsManager({
|
|
|
798
962
|
<Card>
|
|
799
963
|
<CardHeader>
|
|
800
964
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
801
|
-
<div>
|
|
802
|
-
<CardTitle className="text-base">
|
|
803
|
-
|
|
965
|
+
<div className="min-w-0">
|
|
966
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
967
|
+
{activeModule && (
|
|
968
|
+
<DynamicIcon
|
|
969
|
+
name={
|
|
970
|
+
activeModule.icon ||
|
|
971
|
+
(activeModule.kind === 'screen' ? 'Eye' : 'Square')
|
|
972
|
+
}
|
|
973
|
+
className="h-4 w-4 shrink-0 text-primary"
|
|
974
|
+
/>
|
|
975
|
+
)}
|
|
976
|
+
<span className="truncate">
|
|
977
|
+
{activeModule ? activeModule.label : 'Acciones permitidas'}
|
|
978
|
+
</span>
|
|
979
|
+
</CardTitle>
|
|
980
|
+
<CardDescription>
|
|
981
|
+
{activeModule
|
|
982
|
+
? `${
|
|
983
|
+
activeModuleGroupTitle || 'Sistema'
|
|
984
|
+
} · configura las acciones permitidas`
|
|
985
|
+
: 'Configura los permisos del módulo seleccionado.'}
|
|
986
|
+
</CardDescription>
|
|
804
987
|
</div>
|
|
805
|
-
{activeModule && (
|
|
988
|
+
{activeRole && activeModule && (
|
|
806
989
|
<div className="flex items-center gap-2">
|
|
807
990
|
<Badge variant="secondary" className="tabular-nums">
|
|
808
991
|
{moduleGranted}/{moduleTotal}
|
|
@@ -832,14 +1015,14 @@ export function PermissionsManager({
|
|
|
832
1015
|
<CardContent>
|
|
833
1016
|
{!activeRole ? (
|
|
834
1017
|
<EmptyHint text="Selecciona un rol para configurar sus permisos." />
|
|
835
|
-
) : !activeModule ? (
|
|
836
|
-
<EmptyHint text="Selecciona un módulo para ver sus acciones." />
|
|
837
1018
|
) : loadingPerms ? (
|
|
838
1019
|
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
|
839
1020
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
840
1021
|
<Skeleton key={i} className="h-11 w-full" />
|
|
841
1022
|
))}
|
|
842
1023
|
</div>
|
|
1024
|
+
) : !activeModule ? (
|
|
1025
|
+
<EmptyHint text="Selecciona un módulo de la lista para ver sus acciones." />
|
|
843
1026
|
) : (
|
|
844
1027
|
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
|
845
1028
|
{activeModule.actions.map((action) => {
|
|
@@ -894,7 +1077,9 @@ export function PermissionsManager({
|
|
|
894
1077
|
>
|
|
895
1078
|
<DialogContent className="sm:max-w-md">
|
|
896
1079
|
<DialogHeader>
|
|
897
|
-
<DialogTitle>
|
|
1080
|
+
<DialogTitle>
|
|
1081
|
+
{roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol'}
|
|
1082
|
+
</DialogTitle>
|
|
898
1083
|
</DialogHeader>
|
|
899
1084
|
<div className="flex flex-col gap-4 py-2">
|
|
900
1085
|
<div className="flex flex-col gap-2">
|
|
@@ -937,22 +1122,32 @@ export function PermissionsManager({
|
|
|
937
1122
|
>
|
|
938
1123
|
Cancelar
|
|
939
1124
|
</Button>
|
|
940
|
-
<Button
|
|
941
|
-
{
|
|
1125
|
+
<Button
|
|
1126
|
+
onClick={handleRoleSubmit}
|
|
1127
|
+
disabled={roleSaving || !roleDialog.label.trim()}
|
|
1128
|
+
>
|
|
1129
|
+
{roleSaving
|
|
1130
|
+
? 'Guardando…'
|
|
1131
|
+
: roleDialog.mode === 'create'
|
|
1132
|
+
? 'Crear rol'
|
|
1133
|
+
: 'Guardar'}
|
|
942
1134
|
</Button>
|
|
943
1135
|
</DialogFooter>
|
|
944
1136
|
</DialogContent>
|
|
945
1137
|
</Dialog>
|
|
946
1138
|
|
|
947
1139
|
{/* Role delete confirm */}
|
|
948
|
-
<AlertDialog
|
|
1140
|
+
<AlertDialog
|
|
1141
|
+
open={deleteOpen}
|
|
1142
|
+
onOpenChange={(open: boolean) => !deleting && setDeleteOpen(open)}
|
|
1143
|
+
>
|
|
949
1144
|
<AlertDialogContent>
|
|
950
1145
|
<AlertDialogHeader>
|
|
951
1146
|
<AlertDialogTitle>¿Eliminar el rol?</AlertDialogTitle>
|
|
952
1147
|
<AlertDialogDescription>
|
|
953
1148
|
Se eliminará el rol{' '}
|
|
954
|
-
<strong>{activeRole ? activeRole.label || activeRole.name : ''}</strong> y
|
|
955
|
-
asignaciones de permisos. Esta acción no se puede deshacer.
|
|
1149
|
+
<strong>{activeRole ? activeRole.label || activeRole.name : ''}</strong> y
|
|
1150
|
+
sus asignaciones de permisos. Esta acción no se puede deshacer.
|
|
956
1151
|
</AlertDialogDescription>
|
|
957
1152
|
</AlertDialogHeader>
|
|
958
1153
|
<AlertDialogFooter>
|