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.
@@ -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. ⛔ No raw UUIDs: roles shown by name; a uuid is a
14
- * route param / select value / API arg only.
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 { actionHeading, extraActionsByType, matrixActions, typeLabelOf } from './accessMatrix';
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
- const fromUuid = searchParams.get('from');
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
- if (catRes.ok) setCatalog(catRes.catalog || []);
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
- setLoaded(new Set());
255
+ applyPicked([]);
190
256
  return;
191
257
  }
192
258
  if (mode !== 'edit') {
193
259
  const r = await identity.listRolePermissions(token, picked);
194
- setLoaded(new Set(r.ok ? r.grants : []));
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
- <EntityFormModal
310
- title={title}
311
- description={description}
312
- open
313
- onClose={close}
314
- onSubmit={save}
315
- submitLabel="Save access"
316
- busy={saving || loading}
317
- error={error}
318
- contentClassName="sm:max-w-6xl"
319
- >
320
- {/* Fills the modal body (`flex-1 min-h-0`); on md+ the single grid row is the full body
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
- <div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[240px_1fr] md:grid-rows-[minmax(0,1fr)]">
323
- {/* Left rail — presets (scrolls on its own when there are many) */}
324
- <div className="flex flex-col gap-2 md:min-h-0 md:overflow-y-auto md:border-r md:border-border md:pr-4">
325
- <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
326
- Presets
327
- </div>
328
- {bundles.length === 0 && (
329
- <p className="text-xs text-muted-foreground">No presets available.</p>
330
- )}
331
- {bundles.map((b) => {
332
- const isApplied = isBundleApplied(b);
333
- return (
334
- <button
335
- key={b.key}
336
- type="button"
337
- aria-pressed={isApplied}
338
- onClick={() => togglePreset(b)}
339
- 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 ${
340
- isApplied ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'
341
- }`}
342
- >
343
- {/* Checkbox-like: presets stack (multiple can be applied); this reflects +
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
- <span
348
- aria-hidden="true"
349
- className={`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-[4px] border shadow-xs ${
350
- isApplied ? 'border-primary bg-primary text-primary-foreground' : 'border-input'
351
- }`}
352
- >
353
- {isApplied && <Check className="size-3.5" />}
354
- </span>
355
- <div className="min-w-0">
356
- <span className="text-sm font-medium text-foreground">{b.label}</span>
357
- {b.description && (
358
- <p className="mt-0.5 text-xs text-muted-foreground">{b.description}</p>
359
- )}
360
- </div>
361
- </button>
362
- );
363
- })}
364
- <p className="mt-1 text-xs text-muted-foreground">
365
- Presets add their permissions. Tick more than one, then fine-tune below.
366
- </p>
367
- </div>
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
- <Input
386
- value={objectSearch}
387
- onChange={(e) => setObjectSearch(e.target.value)}
388
- placeholder="Search objects"
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
- {/* The matrix takes the remaining height and scrolls inside itself (it used to be
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
- <div className="flex min-h-48 flex-1 flex-col gap-2 overflow-y-auto pr-1">
394
- {groups.map((group) => {
395
- const visibleTypes = group.types.filter(matchesSearch);
396
- if (visibleTypes.length === 0) return null;
397
- const groupCells = group.types
398
- .flatMap((t) => actionsOf(t).map((a) => cellKey(t, a)))
399
- .filter(keyExists);
400
- // Extra-action columns this group needs (union over its types); empty for a
401
- // CRUD-only group, which then renders exactly as before.
402
- const columns = [...ACTIONS, ...matrixActions(catalog, group.types)];
403
- const heldCount = groupCells.filter((k) => keyHeld(k, working)).length;
404
- return (
405
- <CollapsibleGroup
406
- key={group.label}
407
- title={group.label}
408
- count={heldCount}
409
- total={groupCells.length}
410
- open={openGroups[group.label] ?? true}
411
- onToggle={() =>
412
- setOpenGroups((p) => ({ ...p, [group.label]: !(p[group.label] ?? true) }))
413
- }
414
- right={
415
- <span
416
- role="button"
417
- tabIndex={0}
418
- onClick={(e) => {
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
- className="text-xs text-primary hover:underline"
429
- >
430
- Toggle all
431
- </span>
432
- }
433
- >
434
- <div className="overflow-x-auto">
435
- <table className="w-full text-sm">
436
- <thead>
437
- <tr className="text-left text-xs uppercase tracking-wide text-muted-foreground">
438
- <th className="py-1 pr-3 font-medium">Object</th>
439
- {columns.map((a) => (
440
- <th
441
- key={a}
442
- className="px-2 py-1 text-center font-medium whitespace-nowrap"
443
- >
444
- {actionHeading(a)}
445
- </th>
446
- ))}
447
- </tr>
448
- </thead>
449
- <tbody>
450
- {visibleTypes.map((t) => (
451
- <tr key={t} className="border-t border-border">
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
- </tbody>
477
- </table>
478
- </div>
479
- </CollapsibleGroup>
480
- );
481
- })}
482
- </div>
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
- <div className="text-sm text-muted-foreground">
485
- <span className="font-medium text-foreground">
486
- {permCount} permission{permCount === 1 ? '' : 's'}
487
- </span>{' '}
488
- across {objectCount} object{objectCount === 1 ? '' : 's'}
489
- {appliedPresetLabels.length > 0 && <span> · {appliedPresetLabels.join(', ')}</span>}
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
- </div>
493
- </EntityFormModal>
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
  }