create-astram-app 0.12.9 → 0.13.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/package.json +1 -1
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/auth/AuthContext.jsx +8 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +380 -181
- package/template/frontend/src/pages/access/accessMatrix.js +49 -0
- package/template/frontend/src/pages/access/accessMatrix.test.mjs +118 -0
- package/template/frontend/src/shell/AnnouncementsMenu.jsx +190 -0
- package/template/frontend/src/shell/AppShell.jsx +6 -3
- package/template/frontend/src/shell/NotificationsMenu.jsx +263 -0
- package/template/frontend/src/shell/notificationsView.js +138 -0
- package/template/frontend/src/shell/notificationsView.test.mjs +182 -0
- package/template/frontend/src/shell/useNotificationCounts.js +211 -0
- package/template/server/package.json +6 -6
|
@@ -8,22 +8,36 @@
|
|
|
8
8
|
* /access/edit/:uuid → edit (role fixed = :uuid, matrix seeded from its grants)
|
|
9
9
|
* /access/add?from=<uuid> → duplicate (matrix seeded from the source, role empty → pick a target)
|
|
10
10
|
* /access/add → assign (empty role, empty matrix)
|
|
11
|
+
* /access/add?key=<key> → assign with that one catalog key pre-ticked (an Announcements
|
|
12
|
+
* task's "Grant…"); a key not in the catalog is ignored silently
|
|
11
13
|
*
|
|
12
14
|
* Save diffs the working matrix vs. the role's loaded grants → grant/revokePermission per
|
|
13
|
-
* key. decide()/grant model unchanged.
|
|
14
|
-
*
|
|
15
|
+
* key. decide()/grant model unchanged. When newly granted keys carry a catalog caution
|
|
16
|
+
* (G-027), a ConfirmDialog lists "label — caution" BEFORE any grant is sent. ⛔ No raw
|
|
17
|
+
* UUIDs or keys in text: roles shown by name, permissions by label; a uuid/key is a route
|
|
18
|
+
* param / select value / API arg only.
|
|
15
19
|
*/
|
|
16
|
-
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
20
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
17
21
|
import { useNavigate, useOutletContext, useParams, useSearchParams } from 'react-router-dom';
|
|
18
|
-
import { Check } from 'lucide-react';
|
|
22
|
+
import { Check, TriangleAlert } from 'lucide-react';
|
|
19
23
|
import { toast } from 'sonner';
|
|
20
24
|
import { useAuth } from '@/auth/AuthContext';
|
|
25
|
+
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
21
26
|
import EntityFormModal from '@/components/EntityFormModal';
|
|
22
27
|
import { Input } from '@/components/ui/input';
|
|
23
28
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
24
29
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
|
30
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
25
31
|
import CollapsibleGroup from '@/components/access/CollapsibleGroup';
|
|
26
|
-
import {
|
|
32
|
+
import { refreshNotificationCounts } from '@/shell/useNotificationCounts';
|
|
33
|
+
import {
|
|
34
|
+
actionHeading,
|
|
35
|
+
cautionsFor,
|
|
36
|
+
extraActionsByType,
|
|
37
|
+
matrixActions,
|
|
38
|
+
mergePicked,
|
|
39
|
+
typeLabelOf,
|
|
40
|
+
} from './accessMatrix';
|
|
27
41
|
|
|
28
42
|
const ACTIONS = ['read', 'create', 'update', 'delete'];
|
|
29
43
|
|
|
@@ -55,9 +69,19 @@ export default function AccessForm() {
|
|
|
55
69
|
const [searchParams] = useSearchParams();
|
|
56
70
|
const { onRefresh } = useOutletContext() ?? {};
|
|
57
71
|
|
|
58
|
-
|
|
72
|
+
// `?from=` and `?key=` are read ONCE, at first render: AccessPage (the parent route) rewrites
|
|
73
|
+
// the URL to its own list params on mount, which drops both before the load effect could
|
|
74
|
+
// read them again.
|
|
75
|
+
const [initialParams] = useState(() => ({
|
|
76
|
+
from: searchParams.get('from'),
|
|
77
|
+
key: searchParams.get('key'),
|
|
78
|
+
}));
|
|
79
|
+
const fromUuid = initialParams.from;
|
|
59
80
|
const mode = uuid ? 'edit' : fromUuid ? 'duplicate' : 'assign';
|
|
60
81
|
const sourceUuid = uuid || fromUuid || null;
|
|
82
|
+
// `?key=` only pre-ticks in plain assign mode; never rendered.
|
|
83
|
+
const presetKey = mode === 'assign' ? initialParams.key : null;
|
|
84
|
+
const idBase = useId();
|
|
61
85
|
|
|
62
86
|
const [catalog, setCatalog] = useState([]);
|
|
63
87
|
const [bundles, setBundles] = useState([]);
|
|
@@ -72,6 +96,12 @@ export default function AccessForm() {
|
|
|
72
96
|
const [loading, setLoading] = useState(true);
|
|
73
97
|
const [saving, setSaving] = useState(false);
|
|
74
98
|
const [error, setError] = useState('');
|
|
99
|
+
// Cautions awaiting the user's confirm before Save sends anything (null = no dialog).
|
|
100
|
+
const [pendingCautions, setPendingCautions] = useState(null);
|
|
101
|
+
// Focus return for the caution confirm: what opened it, and what to refocus after a cancel.
|
|
102
|
+
const confirmOpener = useRef(null);
|
|
103
|
+
const restoreFocusTo = useRef(null);
|
|
104
|
+
const bodyRef = useRef(null);
|
|
75
105
|
|
|
76
106
|
const moduleName = catalog[0]?.module || 'identity';
|
|
77
107
|
|
|
@@ -96,6 +126,19 @@ export default function AccessForm() {
|
|
|
96
126
|
for (const c of catalog) m[c.key] = c.label;
|
|
97
127
|
return m;
|
|
98
128
|
}, [catalog]);
|
|
129
|
+
// key → { label, description, caution } for the cells whose key carries a caution.
|
|
130
|
+
const cautionOf = useMemo(() => {
|
|
131
|
+
const descriptionOf = new Map(catalog.map((r) => [r.key, r.description]));
|
|
132
|
+
const m = {};
|
|
133
|
+
for (const c of cautionsFor(
|
|
134
|
+
catalog,
|
|
135
|
+
catalog.map((r) => r.key),
|
|
136
|
+
)) {
|
|
137
|
+
const d = descriptionOf.get(c.key);
|
|
138
|
+
m[c.key] = { ...c, description: typeof d === 'string' ? d.trim() : '' };
|
|
139
|
+
}
|
|
140
|
+
return m;
|
|
141
|
+
}, [catalog]);
|
|
99
142
|
const keyExists = useCallback((key) => Boolean(labelOf[key]), [labelOf]);
|
|
100
143
|
// The object column shows a name from the catalog labels, never the raw type key.
|
|
101
144
|
const typeLabels = useMemo(
|
|
@@ -158,12 +201,16 @@ export default function AccessForm() {
|
|
|
158
201
|
: Promise.resolve({ ok: false }),
|
|
159
202
|
]);
|
|
160
203
|
if (!active) return;
|
|
161
|
-
|
|
204
|
+
const catalogRows = catRes.ok ? catRes.catalog || [] : [];
|
|
205
|
+
if (catRes.ok) setCatalog(catalogRows);
|
|
162
206
|
else setError(catRes.error || 'Could not load the permission catalog.');
|
|
163
207
|
const bundleList = bundlesRes.ok ? bundlesRes.bundles || [] : [];
|
|
164
208
|
if (bundlesRes.ok) setBundles(bundleList);
|
|
165
209
|
|
|
166
210
|
const seedKeys = new Set(grantsRes.ok ? grantsRes.grants || [] : []);
|
|
211
|
+
// `/access/add?key=` (assign only): pre-tick that key when it is a catalog (grantable)
|
|
212
|
+
// key; anything else is ignored silently.
|
|
213
|
+
if (presetKey && catalogRows.some((r) => r?.key === presetKey)) seedKeys.add(presetKey);
|
|
167
214
|
if (mode === 'edit') {
|
|
168
215
|
setRoleUuid(sourceUuid);
|
|
169
216
|
setRoleLabel(roleRes.ok ? roleRes.record?.fields?.name || null : null);
|
|
@@ -181,17 +228,37 @@ export default function AccessForm() {
|
|
|
181
228
|
return () => {
|
|
182
229
|
active = false;
|
|
183
230
|
};
|
|
184
|
-
}, [identity, token, sourceUuid, mode]);
|
|
231
|
+
}, [identity, token, sourceUuid, mode, presetKey]);
|
|
232
|
+
|
|
233
|
+
// Assign mode: the grants of the role currently picked (merged into `working`), and a counter
|
|
234
|
+
// so a slow response for an earlier pick never overwrites a later one.
|
|
235
|
+
const pickedGrants = useRef(new Set());
|
|
236
|
+
const pickSeq = useRef(0);
|
|
237
|
+
|
|
238
|
+
// Assign mode shows the picked role's existing grants ticked, plus the keys already ticked in
|
|
239
|
+
// the form, so Save only adds (revoking needs an explicit untick). Duplicate mode keeps the
|
|
240
|
+
// source's set as the working matrix (copy semantics); edit mode never picks.
|
|
241
|
+
const applyPicked = (grants) => {
|
|
242
|
+
const next = new Set(grants);
|
|
243
|
+
setLoaded(next);
|
|
244
|
+
if (mode === 'assign') {
|
|
245
|
+
const prev = pickedGrants.current;
|
|
246
|
+
pickedGrants.current = next;
|
|
247
|
+
setWorking((w) => mergePicked(prev, next, w));
|
|
248
|
+
}
|
|
249
|
+
};
|
|
185
250
|
|
|
186
251
|
const onPickRole = async (picked) => {
|
|
252
|
+
const seq = ++pickSeq.current;
|
|
187
253
|
setRoleUuid(picked || '');
|
|
188
254
|
if (!picked) {
|
|
189
|
-
|
|
255
|
+
applyPicked([]);
|
|
190
256
|
return;
|
|
191
257
|
}
|
|
192
258
|
if (mode !== 'edit') {
|
|
193
259
|
const r = await identity.listRolePermissions(token, picked);
|
|
194
|
-
|
|
260
|
+
if (seq !== pickSeq.current) return;
|
|
261
|
+
applyPicked(r.ok ? r.grants || [] : []);
|
|
195
262
|
}
|
|
196
263
|
};
|
|
197
264
|
|
|
@@ -281,12 +348,79 @@ export default function AccessForm() {
|
|
|
281
348
|
if (failed === 0) {
|
|
282
349
|
toast.success('Access saved');
|
|
283
350
|
onRefresh?.();
|
|
351
|
+
// The Announcements task badge moves at once (a grant can close an open task).
|
|
352
|
+
void refreshNotificationCounts();
|
|
284
353
|
close();
|
|
285
354
|
} else {
|
|
286
355
|
setError('Some permissions could not be saved.');
|
|
287
356
|
}
|
|
288
357
|
};
|
|
289
358
|
|
|
359
|
+
// Save entry point: when the keys this save newly grants carry a caution, confirm first
|
|
360
|
+
// (nothing is sent until Confirm). "Newly granted" = every catalog key held after the save
|
|
361
|
+
// but not before, so a ticked cell, a preset and a wildcard all count.
|
|
362
|
+
const requestSave = () => {
|
|
363
|
+
if (!roleUuid) {
|
|
364
|
+
setError('Select a role first.');
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const added = catalog
|
|
368
|
+
.map((r) => r.key)
|
|
369
|
+
.filter((k) => keyHeld(k, working) && !keyHeld(k, loaded));
|
|
370
|
+
const cautions = cautionsFor(catalog, added);
|
|
371
|
+
if (cautions.length > 0) {
|
|
372
|
+
// Remember what opened the confirm (the Save button, or the field Enter was pressed
|
|
373
|
+
// in) so a Cancel/Escape can hand focus back to the form.
|
|
374
|
+
const active = document.activeElement;
|
|
375
|
+
confirmOpener.current =
|
|
376
|
+
active instanceof HTMLElement && active !== document.body
|
|
377
|
+
? active
|
|
378
|
+
: (bodyRef.current?.closest('form')?.querySelector('button[type="submit"]') ?? null);
|
|
379
|
+
setPendingCautions(cautions);
|
|
380
|
+
} else save();
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const confirmCautions = () => {
|
|
384
|
+
confirmOpener.current = null;
|
|
385
|
+
setPendingCautions(null);
|
|
386
|
+
save();
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// Idempotent: ConfirmDialog calls onCancel twice for the Cancel button (its onClick and
|
|
390
|
+
// onOpenChange(false)); the second call must not clear the focus target the first one saved.
|
|
391
|
+
const cancelCautions = () => {
|
|
392
|
+
restoreFocusTo.current = confirmOpener.current ?? restoreFocusTo.current;
|
|
393
|
+
confirmOpener.current = null;
|
|
394
|
+
setPendingCautions(null);
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// The confirm has no Radix trigger, so on close Radix focuses nothing and focus falls to
|
|
398
|
+
// <body>, outside the form dialog. Once the alert dialog has left the DOM (after its exit
|
|
399
|
+
// animation and Radix's own deferred focus step), put focus back on the opener, unless
|
|
400
|
+
// something else already took it.
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
if (pendingCautions !== null || !restoreFocusTo.current) return undefined;
|
|
403
|
+
const target = restoreFocusTo.current;
|
|
404
|
+
restoreFocusTo.current = null;
|
|
405
|
+
let frame = 0;
|
|
406
|
+
let timer = 0;
|
|
407
|
+
const tick = () => {
|
|
408
|
+
if (document.querySelector('[role="alertdialog"]')) {
|
|
409
|
+
frame = requestAnimationFrame(tick);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
timer = window.setTimeout(() => {
|
|
413
|
+
const active = document.activeElement;
|
|
414
|
+
if (target.isConnected && (!active || active === document.body)) target.focus();
|
|
415
|
+
}, 0);
|
|
416
|
+
};
|
|
417
|
+
frame = requestAnimationFrame(tick);
|
|
418
|
+
return () => {
|
|
419
|
+
cancelAnimationFrame(frame);
|
|
420
|
+
window.clearTimeout(timer);
|
|
421
|
+
};
|
|
422
|
+
}, [pendingCautions]);
|
|
423
|
+
|
|
290
424
|
const matchesSearch = (t) => {
|
|
291
425
|
const q = objectSearch.trim().toLowerCase();
|
|
292
426
|
return !q || t.toLowerCase().includes(q) || (typeLabels[t] ?? '').toLowerCase().includes(q);
|
|
@@ -306,190 +440,255 @@ export default function AccessForm() {
|
|
|
306
440
|
: 'Pick a role, apply a preset, then fine-tune the permissions.';
|
|
307
441
|
|
|
308
442
|
return (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
443
|
+
<>
|
|
444
|
+
<EntityFormModal
|
|
445
|
+
title={title}
|
|
446
|
+
description={description}
|
|
447
|
+
open
|
|
448
|
+
onClose={close}
|
|
449
|
+
onSubmit={requestSave}
|
|
450
|
+
submitLabel="Save access"
|
|
451
|
+
busy={saving || loading}
|
|
452
|
+
error={error}
|
|
453
|
+
contentClassName="sm:max-w-6xl"
|
|
454
|
+
>
|
|
455
|
+
{/* Fills the modal body (`flex-1 min-h-0`); on md+ the single grid row is the full body
|
|
321
456
|
height, so the matrix below can take whatever height the modal has (full screen too). */}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
isApplied
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
457
|
+
<div
|
|
458
|
+
ref={bodyRef}
|
|
459
|
+
className="grid min-h-0 flex-1 gap-4 md:grid-cols-[240px_1fr] md:grid-rows-[minmax(0,1fr)]"
|
|
460
|
+
>
|
|
461
|
+
{/* Left rail — presets (scrolls on its own when there are many) */}
|
|
462
|
+
<div className="flex flex-col gap-2 md:min-h-0 md:overflow-y-auto md:border-r md:border-border md:pr-4">
|
|
463
|
+
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
464
|
+
Presets
|
|
465
|
+
</div>
|
|
466
|
+
{bundles.length === 0 && (
|
|
467
|
+
<p className="text-xs text-muted-foreground">No presets available.</p>
|
|
468
|
+
)}
|
|
469
|
+
{bundles.map((b) => {
|
|
470
|
+
const isApplied = isBundleApplied(b);
|
|
471
|
+
return (
|
|
472
|
+
<button
|
|
473
|
+
key={b.key}
|
|
474
|
+
type="button"
|
|
475
|
+
aria-pressed={isApplied}
|
|
476
|
+
onClick={() => togglePreset(b)}
|
|
477
|
+
className={`flex items-start gap-2.5 rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
|
478
|
+
isApplied ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'
|
|
479
|
+
}`}
|
|
480
|
+
>
|
|
481
|
+
{/* Checkbox-like: presets stack (multiple can be applied); this reflects +
|
|
344
482
|
toggles whether ALL of the preset's keys are held. The whole card is ONE
|
|
345
483
|
toggle button (`aria-pressed`); the box is decorative, not a nested
|
|
346
484
|
checkbox button (a <button> inside a <button> is invalid HTML). */}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
<
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
{/* Right pane — role + matrix */}
|
|
370
|
-
<div className="flex min-h-0 min-w-0 flex-col gap-3">
|
|
371
|
-
<div className="flex flex-col gap-1">
|
|
372
|
-
<label className="text-xs font-medium text-muted-foreground">Role</label>
|
|
373
|
-
<SearchableSelect
|
|
374
|
-
value={roleUuid || null}
|
|
375
|
-
selectedLabel={roleLabel}
|
|
376
|
-
onChange={onPickRole}
|
|
377
|
-
onLabelChange={(label) => setRoleLabel(label)}
|
|
378
|
-
fetchPage={fetchRoles}
|
|
379
|
-
placeholder="Select a role"
|
|
380
|
-
searchPlaceholder="Search roles"
|
|
381
|
-
disabled={mode === 'edit'}
|
|
382
|
-
/>
|
|
485
|
+
<span
|
|
486
|
+
aria-hidden="true"
|
|
487
|
+
className={`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-[4px] border shadow-xs ${
|
|
488
|
+
isApplied
|
|
489
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
490
|
+
: 'border-input'
|
|
491
|
+
}`}
|
|
492
|
+
>
|
|
493
|
+
{isApplied && <Check className="size-3.5" />}
|
|
494
|
+
</span>
|
|
495
|
+
<div className="min-w-0">
|
|
496
|
+
<span className="text-sm font-medium text-foreground">{b.label}</span>
|
|
497
|
+
{b.description && (
|
|
498
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{b.description}</p>
|
|
499
|
+
)}
|
|
500
|
+
</div>
|
|
501
|
+
</button>
|
|
502
|
+
);
|
|
503
|
+
})}
|
|
504
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
505
|
+
Presets add their permissions. Tick more than one, then fine-tune below.
|
|
506
|
+
</p>
|
|
383
507
|
</div>
|
|
384
508
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
509
|
+
{/* Right pane — role + matrix */}
|
|
510
|
+
<div className="flex min-h-0 min-w-0 flex-col gap-3">
|
|
511
|
+
<div className="flex flex-col gap-1">
|
|
512
|
+
<label className="text-xs font-medium text-muted-foreground">Role</label>
|
|
513
|
+
<SearchableSelect
|
|
514
|
+
value={roleUuid || null}
|
|
515
|
+
selectedLabel={roleLabel}
|
|
516
|
+
onChange={onPickRole}
|
|
517
|
+
onLabelChange={(label) => setRoleLabel(label)}
|
|
518
|
+
fetchPage={fetchRoles}
|
|
519
|
+
placeholder="Select a role"
|
|
520
|
+
searchPlaceholder="Search roles"
|
|
521
|
+
disabled={mode === 'edit'}
|
|
522
|
+
/>
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<Input
|
|
526
|
+
value={objectSearch}
|
|
527
|
+
onChange={(e) => setObjectSearch(e.target.value)}
|
|
528
|
+
placeholder="Search objects"
|
|
529
|
+
/>
|
|
390
530
|
|
|
391
|
-
|
|
531
|
+
{/* The matrix takes the remaining height and scrolls inside itself (it used to be
|
|
392
532
|
capped at 46vh, so full screen left most of the modal empty). */}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
e.stopPropagation();
|
|
420
|
-
toggleGroupAll(group);
|
|
421
|
-
}}
|
|
422
|
-
onKeyDown={(e) => {
|
|
423
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
533
|
+
<div className="flex min-h-48 flex-1 flex-col gap-2 overflow-y-auto pr-1">
|
|
534
|
+
{groups.map((group) => {
|
|
535
|
+
const visibleTypes = group.types.filter(matchesSearch);
|
|
536
|
+
if (visibleTypes.length === 0) return null;
|
|
537
|
+
const groupCells = group.types
|
|
538
|
+
.flatMap((t) => actionsOf(t).map((a) => cellKey(t, a)))
|
|
539
|
+
.filter(keyExists);
|
|
540
|
+
// Extra-action columns this group needs (union over its types); empty for a
|
|
541
|
+
// CRUD-only group, which then renders exactly as before.
|
|
542
|
+
const columns = [...ACTIONS, ...matrixActions(catalog, group.types)];
|
|
543
|
+
const heldCount = groupCells.filter((k) => keyHeld(k, working)).length;
|
|
544
|
+
return (
|
|
545
|
+
<CollapsibleGroup
|
|
546
|
+
key={group.label}
|
|
547
|
+
title={group.label}
|
|
548
|
+
count={heldCount}
|
|
549
|
+
total={groupCells.length}
|
|
550
|
+
open={openGroups[group.label] ?? true}
|
|
551
|
+
onToggle={() =>
|
|
552
|
+
setOpenGroups((p) => ({ ...p, [group.label]: !(p[group.label] ?? true) }))
|
|
553
|
+
}
|
|
554
|
+
right={
|
|
555
|
+
<span
|
|
556
|
+
role="button"
|
|
557
|
+
tabIndex={0}
|
|
558
|
+
onClick={(e) => {
|
|
424
559
|
e.stopPropagation();
|
|
425
560
|
toggleGroupAll(group);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
>
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
<td className="py-1.5 pr-3 whitespace-nowrap text-foreground">
|
|
453
|
-
{typeLabels[t] ?? t}
|
|
454
|
-
</td>
|
|
455
|
-
{columns.map((a) => {
|
|
456
|
-
const key = cellKey(t, a);
|
|
457
|
-
const exists = keyExists(key);
|
|
458
|
-
const covered = keyHeld(key, working) && !working.has(key);
|
|
459
|
-
return (
|
|
460
|
-
<td key={a} className="px-2 py-1.5 text-center">
|
|
461
|
-
{exists ? (
|
|
462
|
-
<Checkbox
|
|
463
|
-
checked={keyHeld(key, working)}
|
|
464
|
-
disabled={covered}
|
|
465
|
-
title={covered ? 'granted via a wildcard' : labelOf[key]}
|
|
466
|
-
onCheckedChange={() => toggle(key)}
|
|
467
|
-
/>
|
|
468
|
-
) : (
|
|
469
|
-
<span className="text-muted-foreground/50">—</span>
|
|
470
|
-
)}
|
|
471
|
-
</td>
|
|
472
|
-
);
|
|
473
|
-
})}
|
|
561
|
+
}}
|
|
562
|
+
onKeyDown={(e) => {
|
|
563
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
564
|
+
e.stopPropagation();
|
|
565
|
+
toggleGroupAll(group);
|
|
566
|
+
}
|
|
567
|
+
}}
|
|
568
|
+
className="text-xs text-primary hover:underline"
|
|
569
|
+
>
|
|
570
|
+
Toggle all
|
|
571
|
+
</span>
|
|
572
|
+
}
|
|
573
|
+
>
|
|
574
|
+
<div className="overflow-x-auto">
|
|
575
|
+
<table className="w-full text-sm">
|
|
576
|
+
<thead>
|
|
577
|
+
<tr className="text-left text-xs uppercase tracking-wide text-muted-foreground">
|
|
578
|
+
<th className="py-1 pr-3 font-medium">Object</th>
|
|
579
|
+
{columns.map((a) => (
|
|
580
|
+
<th
|
|
581
|
+
key={a}
|
|
582
|
+
className="px-2 py-1 text-center font-medium whitespace-nowrap"
|
|
583
|
+
>
|
|
584
|
+
{actionHeading(a)}
|
|
585
|
+
</th>
|
|
586
|
+
))}
|
|
474
587
|
</tr>
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
588
|
+
</thead>
|
|
589
|
+
<tbody>
|
|
590
|
+
{visibleTypes.map((t) => (
|
|
591
|
+
<tr key={t} className="border-t border-border">
|
|
592
|
+
<td className="py-1.5 pr-3 whitespace-nowrap text-foreground">
|
|
593
|
+
{typeLabels[t] ?? t}
|
|
594
|
+
</td>
|
|
595
|
+
{columns.map((a) => {
|
|
596
|
+
const key = cellKey(t, a);
|
|
597
|
+
const exists = keyExists(key);
|
|
598
|
+
const covered = keyHeld(key, working) && !working.has(key);
|
|
599
|
+
const caution = exists ? cautionOf[key] : undefined;
|
|
600
|
+
const cautionId = `${idBase}-caution-${t}-${a}`;
|
|
601
|
+
const box = exists ? (
|
|
602
|
+
<Checkbox
|
|
603
|
+
checked={keyHeld(key, working)}
|
|
604
|
+
disabled={covered}
|
|
605
|
+
title={covered ? 'granted via a wildcard' : labelOf[key]}
|
|
606
|
+
aria-describedby={caution ? cautionId : undefined}
|
|
607
|
+
onCheckedChange={() => toggle(key)}
|
|
608
|
+
/>
|
|
609
|
+
) : null;
|
|
610
|
+
return (
|
|
611
|
+
<td key={a} className="px-2 py-1.5 text-center">
|
|
612
|
+
{caution ? (
|
|
613
|
+
<span className="inline-flex items-center gap-1">
|
|
614
|
+
{box}
|
|
615
|
+
<span id={cautionId} className="sr-only">
|
|
616
|
+
Caution: {caution.caution}
|
|
617
|
+
</span>
|
|
618
|
+
<Tooltip>
|
|
619
|
+
<TooltipTrigger asChild>
|
|
620
|
+
<span
|
|
621
|
+
role="img"
|
|
622
|
+
tabIndex={0}
|
|
623
|
+
aria-label={`Caution for ${caution.label}`}
|
|
624
|
+
className="inline-flex rounded-sm text-warn focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
625
|
+
>
|
|
626
|
+
<TriangleAlert
|
|
627
|
+
className="size-3.5"
|
|
628
|
+
aria-hidden="true"
|
|
629
|
+
/>
|
|
630
|
+
</span>
|
|
631
|
+
</TooltipTrigger>
|
|
632
|
+
<TooltipContent className="max-w-xs text-left">
|
|
633
|
+
<p className="font-semibold">{caution.label}</p>
|
|
634
|
+
{caution.description && <p>{caution.description}</p>}
|
|
635
|
+
<p className="mt-1">Caution: {caution.caution}</p>
|
|
636
|
+
</TooltipContent>
|
|
637
|
+
</Tooltip>
|
|
638
|
+
</span>
|
|
639
|
+
) : exists ? (
|
|
640
|
+
box
|
|
641
|
+
) : (
|
|
642
|
+
<span className="text-muted-foreground/50">—</span>
|
|
643
|
+
)}
|
|
644
|
+
</td>
|
|
645
|
+
);
|
|
646
|
+
})}
|
|
647
|
+
</tr>
|
|
648
|
+
))}
|
|
649
|
+
</tbody>
|
|
650
|
+
</table>
|
|
651
|
+
</div>
|
|
652
|
+
</CollapsibleGroup>
|
|
653
|
+
);
|
|
654
|
+
})}
|
|
655
|
+
</div>
|
|
483
656
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
657
|
+
<div className="text-sm text-muted-foreground">
|
|
658
|
+
<span className="font-medium text-foreground">
|
|
659
|
+
{permCount} permission{permCount === 1 ? '' : 's'}
|
|
660
|
+
</span>{' '}
|
|
661
|
+
across {objectCount} object{objectCount === 1 ? '' : 's'}
|
|
662
|
+
{appliedPresetLabels.length > 0 && <span> · {appliedPresetLabels.join(', ')}</span>}
|
|
663
|
+
</div>
|
|
490
664
|
</div>
|
|
491
665
|
</div>
|
|
492
|
-
</
|
|
493
|
-
|
|
666
|
+
</EntityFormModal>
|
|
667
|
+
<ConfirmDialog
|
|
668
|
+
open={pendingCautions !== null}
|
|
669
|
+
title="Grant permissions with cautions?"
|
|
670
|
+
message={
|
|
671
|
+
roleLabel
|
|
672
|
+
? `These permissions carry a caution. Review them before granting them to ${roleLabel}.`
|
|
673
|
+
: 'These permissions carry a caution. Review them before granting them.'
|
|
674
|
+
}
|
|
675
|
+
confirmLabel="Grant"
|
|
676
|
+
busy={saving}
|
|
677
|
+
onConfirm={confirmCautions}
|
|
678
|
+
onCancel={cancelCautions}
|
|
679
|
+
>
|
|
680
|
+
<ul className="flex max-h-64 flex-col gap-2 overflow-y-auto text-sm">
|
|
681
|
+
{(pendingCautions ?? []).map((c) => (
|
|
682
|
+
<li key={c.key} className="flex items-start gap-2">
|
|
683
|
+
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-warn" aria-hidden="true" />
|
|
684
|
+
<span>
|
|
685
|
+
<span className="font-medium text-foreground">{c.label}</span>
|
|
686
|
+
<span className="text-muted-foreground"> — {c.caution}</span>
|
|
687
|
+
</span>
|
|
688
|
+
</li>
|
|
689
|
+
))}
|
|
690
|
+
</ul>
|
|
691
|
+
</ConfirmDialog>
|
|
692
|
+
</>
|
|
494
693
|
);
|
|
495
694
|
}
|