create-astram-app 0.12.5 → 0.12.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astram-app",
3
- "version": "0.12.5",
3
+ "version": "0.12.7",
4
4
  "license": "MIT",
5
5
  "description": "Scaffolder for a new Astram app — emits a running, framework-enforced NestJS server (deny-by-default, built-in auth/identity to switch on) and, with --with-frontend, a React admin/auth UI, over the immutable @astram/* framework packages.",
6
6
  "type": "module",
@@ -12,7 +12,7 @@
12
12
  "test": "node scripts/run-tests.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@astram/frontend-core": "^0.8.0",
15
+ "@astram/frontend-core": "^0.9.0",
16
16
  "@astram/sdk": "^0.2.1",
17
17
  "@radix-ui/react-alert-dialog": "^1.1.19",
18
18
  "@radix-ui/react-avatar": "^1.1.10",
@@ -100,7 +100,7 @@ export default function EntityFormModal({
100
100
  e.preventDefault();
101
101
  if (!busy) onSubmit?.();
102
102
  }}
103
- className="flex min-w-0 flex-1 flex-col overflow-hidden"
103
+ className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
104
104
  >
105
105
  <DialogHeader>
106
106
  <DialogTitle>{title}</DialogTitle>
@@ -108,8 +108,10 @@ export default function EntityFormModal({
108
108
  </DialogHeader>
109
109
 
110
110
  {/* Scrollable body: `min-w-0` propagates the shrink to children; `overflow-y-auto`
111
- keeps a tall assignments editor inside the modal. */}
112
- <div className="mt-4 mb-6 min-w-0 flex-1 space-y-4 overflow-y-auto">
111
+ keeps a tall assignments editor inside the modal. A flex column with `min-h-0`, so
112
+ a child that takes `flex-1 min-h-0` (the access matrix) fills the height the modal
113
+ has — up to the viewport in full screen — and scrolls inside itself. */}
114
+ <div className="mt-4 mb-6 flex min-h-0 min-w-0 flex-1 flex-col space-y-4 overflow-y-auto">
113
115
  {children}
114
116
  {error && (
115
117
  <Alert variant="destructive">
@@ -46,6 +46,9 @@
46
46
  * onAdd — () => void
47
47
  * onEdit — (row) => void
48
48
  * onDelete — (row) => void
49
+ * onRowClick — (row) => void; defaults to `onEdit`. When set, a click on the row (outside its
50
+ * buttons, links and menus) or Enter/Space on the focused row opens it. Pass it,
51
+ * like `onEdit`, only when the caller may edit (UX only; the server decides).
49
52
  * onSearchChange — (term: string) => void (page resets to 1)
50
53
  * onSortChange — (sortBy: string, sortOrder) => void
51
54
  * onPageChange — (page: number) => void
@@ -64,6 +67,7 @@ import {
64
67
  Search,
65
68
  Trash2,
66
69
  } from 'lucide-react';
70
+ import { shouldOpenRowOnClick, shouldOpenRowOnKey } from './rowActivation';
67
71
  import { Button } from '@/components/ui/button';
68
72
  import { Input } from '@/components/ui/input';
69
73
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -332,9 +336,30 @@ export default function EntityList({
332
336
  emptyLabel = 'Nothing here yet.',
333
337
  onEdit,
334
338
  onDelete,
339
+ onRowClick,
335
340
  onSortChange,
336
341
  }) {
337
342
  const hasActions = Boolean(onEdit || onDelete);
343
+ const openRow = onRowClick ?? onEdit;
344
+ // Click / keyboard props for an openable row (desktop row and mobile card alike).
345
+ const rowOpenProps = (row) =>
346
+ openRow
347
+ ? {
348
+ tabIndex: 0,
349
+ onClick: (e) => {
350
+ const selection = typeof window !== 'undefined' ? window.getSelection?.() : null;
351
+ if (shouldOpenRowOnClick(e.target, e.currentTarget, selection?.toString() ?? '')) {
352
+ openRow(row);
353
+ }
354
+ },
355
+ onKeyDown: (e) => {
356
+ if (shouldOpenRowOnKey(e.key, e.target, e.currentTarget)) {
357
+ e.preventDefault();
358
+ openRow(row);
359
+ }
360
+ },
361
+ }
362
+ : {};
338
363
 
339
364
  return (
340
365
  <Card className="gap-0 overflow-hidden py-0">
@@ -405,7 +430,15 @@ export default function EntityList({
405
430
  </TableRow>
406
431
  ) : (
407
432
  rows.map((row, i) => (
408
- <TableRow key={typeof row.id === 'string' ? row.id : i}>
433
+ <TableRow
434
+ key={typeof row.id === 'string' ? row.id : i}
435
+ {...rowOpenProps(row)}
436
+ className={
437
+ openRow
438
+ ? 'cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring'
439
+ : undefined
440
+ }
441
+ >
409
442
  {columns.map((col) => (
410
443
  <TableCell key={col.key ?? col.label}>{cellValue(col, row)}</TableCell>
411
444
  ))}
@@ -438,7 +471,12 @@ export default function EntityList({
438
471
  rows.map((row, i) => (
439
472
  <div
440
473
  key={typeof row.id === 'string' ? row.id : i}
441
- className="rounded-lg border border-border bg-background p-3"
474
+ {...rowOpenProps(row)}
475
+ className={`rounded-lg border border-border bg-background p-3${
476
+ openRow
477
+ ? ' cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
478
+ : ''
479
+ }`}
442
480
  >
443
481
  <dl className="space-y-2">
444
482
  {columns.map((col) => (
@@ -0,0 +1,52 @@
1
+ // rowActivation.js — when a click or key press on a list row should open the row (EntityList's
2
+ // `onRowClick`). Pure logic over DOM-like objects, tested by rowActivation.test.mjs. Opening a row is
3
+ // UX only: the page passes `onRowClick` only when the caller may edit, and the server still decides.
4
+
5
+ /** Elements that act on their own: a click on (or inside) one never also opens the row. */
6
+ export const INTERACTIVE_SELECTOR = [
7
+ 'a',
8
+ 'button',
9
+ 'input',
10
+ 'select',
11
+ 'textarea',
12
+ 'label',
13
+ '[role="button"]',
14
+ '[role="checkbox"]',
15
+ '[role="menuitem"]',
16
+ '[role="menu"]',
17
+ '[role="switch"]',
18
+ '[role="link"]',
19
+ '[contenteditable="true"]',
20
+ ].join(',');
21
+
22
+ /**
23
+ * Should this click open the row?
24
+ * - The target must be inside the row element: React bubbles clicks from portalled content (a
25
+ * row's dropdown menu or dialog) through the row, and those must never open it.
26
+ * - A click on an interactive element inside the row (a button, link, checkbox, menu) does its own
27
+ * thing only.
28
+ * - A click that ends a text selection (the user is copying a value) does not open the row.
29
+ * @param {{ closest?: (selector: string) => unknown } | null | undefined} target event target
30
+ * @param {{ contains?: (node: unknown) => boolean } | null | undefined} rowEl the row element
31
+ * @param {string} [selectedText] the current text selection, if any
32
+ */
33
+ export function shouldOpenRowOnClick(target, rowEl, selectedText = '') {
34
+ if (!target || !rowEl || typeof rowEl.contains !== 'function') return false;
35
+ if (!rowEl.contains(target)) return false;
36
+ if (typeof target.closest === 'function') {
37
+ const interactive = target.closest(INTERACTIVE_SELECTOR);
38
+ if (interactive && interactive !== rowEl && rowEl.contains(interactive)) return false;
39
+ }
40
+ return !(typeof selectedText === 'string' && selectedText.trim().length > 0);
41
+ }
42
+
43
+ /**
44
+ * Should this key press open the row? Enter or Space on the focused row itself (not on a control
45
+ * inside it, which handles its own keys).
46
+ * @param {string} key `KeyboardEvent.key`
47
+ * @param {unknown} target event target
48
+ * @param {unknown} rowEl the row element
49
+ */
50
+ export function shouldOpenRowOnKey(key, target, rowEl) {
51
+ return target === rowEl && (key === 'Enter' || key === ' ');
52
+ }
@@ -0,0 +1,69 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { shouldOpenRowOnClick, shouldOpenRowOnKey } from './rowActivation.js';
4
+
5
+ /** A tiny DOM-like tree: `closest` walks parents matching a set of "interactive" node names. */
6
+ function node(name, parent = null, interactive = false) {
7
+ const n = {
8
+ name,
9
+ parent,
10
+ interactive,
11
+ contains(other) {
12
+ for (let cur = other; cur; cur = cur.parent) if (cur === n) return true;
13
+ return false;
14
+ },
15
+ closest() {
16
+ for (let cur = n; cur; cur = cur.parent) if (cur.interactive) return cur;
17
+ return null;
18
+ },
19
+ };
20
+ return n;
21
+ }
22
+
23
+ test('a click on plain cell text opens the row', () => {
24
+ const row = node('tr');
25
+ const cell = node('td', row);
26
+ const text = node('span', cell);
27
+ assert.equal(shouldOpenRowOnClick(text, row), true);
28
+ assert.equal(shouldOpenRowOnClick(row, row), true);
29
+ });
30
+
31
+ test('a click on a button, link or checkbox inside the row does not open it', () => {
32
+ const row = node('tr');
33
+ const cell = node('td', row);
34
+ const button = node('button', cell, true);
35
+ const icon = node('svg', button);
36
+ assert.equal(shouldOpenRowOnClick(button, row), false);
37
+ assert.equal(shouldOpenRowOnClick(icon, row), false);
38
+ });
39
+
40
+ test('a click bubbling from portalled content (a dropdown item outside the row) never opens it', () => {
41
+ const row = node('tr');
42
+ const portal = node('div');
43
+ const item = node('div', portal, true);
44
+ const plain = node('span', portal);
45
+ assert.equal(shouldOpenRowOnClick(item, row), false);
46
+ assert.equal(shouldOpenRowOnClick(plain, row), false);
47
+ });
48
+
49
+ test('a click that ends a text selection does not open the row', () => {
50
+ const row = node('tr');
51
+ const cell = node('td', row);
52
+ assert.equal(shouldOpenRowOnClick(cell, row, 'Acme Ltd'), false);
53
+ assert.equal(shouldOpenRowOnClick(cell, row, ' '), true);
54
+ });
55
+
56
+ test('missing target or row never opens', () => {
57
+ const row = node('tr');
58
+ assert.equal(shouldOpenRowOnClick(null, row), false);
59
+ assert.equal(shouldOpenRowOnClick(node('td', row), null), false);
60
+ });
61
+
62
+ test('Enter or Space on the focused row opens it; on a control inside it does not', () => {
63
+ const row = node('tr');
64
+ const button = node('button', row, true);
65
+ assert.equal(shouldOpenRowOnKey('Enter', row, row), true);
66
+ assert.equal(shouldOpenRowOnKey(' ', row, row), true);
67
+ assert.equal(shouldOpenRowOnKey('Enter', button, row), false);
68
+ assert.equal(shouldOpenRowOnKey('a', row, row), false);
69
+ });
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { useCallback, useEffect, useMemo, useState } from 'react';
17
17
  import { useNavigate, useOutletContext, useParams, useSearchParams } from 'react-router-dom';
18
+ import { Check } from 'lucide-react';
18
19
  import { toast } from 'sonner';
19
20
  import { useAuth } from '@/auth/AuthContext';
20
21
  import EntityFormModal from '@/components/EntityFormModal';
@@ -22,20 +23,28 @@ import { Input } from '@/components/ui/input';
22
23
  import { Checkbox } from '@/components/ui/checkbox';
23
24
  import { SearchableSelect } from '@/components/ui/searchable-select';
24
25
  import CollapsibleGroup from '@/components/access/CollapsibleGroup';
25
- import { extraActionsByType, matrixActions } from './accessMatrix';
26
+ import { actionHeading, extraActionsByType, matrixActions, typeLabelOf } from './accessMatrix';
26
27
 
27
28
  const ACTIONS = ['read', 'create', 'update', 'delete'];
28
29
 
29
- // Column header for an action key: `manage_keys` → `manage keys` (cells carry the catalog label).
30
- const actionHeading = (a) => a.replace(/_/g, ' ');
31
-
32
- // Object → group mapping. An unmapped typeKey falls into "Other" (fail-safe).
30
+ // Object → group mapping. An unmapped typeKey (an app's own types) falls into "Other".
33
31
  const GROUPS = [
34
32
  {
35
33
  label: 'Organization',
36
34
  types: ['company', 'company_branch', 'company_department', 'department', 'branch'],
37
35
  },
38
36
  { label: 'People & Access', types: ['user', 'role', 'company_membership', 'membership_posting'] },
37
+ {
38
+ label: 'Security & Sharing',
39
+ types: [
40
+ 'security_policy',
41
+ 'service_account',
42
+ 'cross_tenant_grant',
43
+ 'record_share',
44
+ 'shared_resource',
45
+ 'stored_file',
46
+ ],
47
+ },
39
48
  ];
40
49
 
41
50
  export default function AccessForm() {
@@ -88,6 +97,11 @@ export default function AccessForm() {
88
97
  return m;
89
98
  }, [catalog]);
90
99
  const keyExists = useCallback((key) => Boolean(labelOf[key]), [labelOf]);
100
+ // The object column shows a name from the catalog labels, never the raw type key.
101
+ const typeLabels = useMemo(
102
+ () => Object.fromEntries(typeKeys.map((t) => [t, typeLabelOf(catalog, t)])),
103
+ [catalog, typeKeys],
104
+ );
91
105
  const cellKey = useCallback((t, a) => `${moduleName}:${t}:${a}`, [moduleName]);
92
106
 
93
107
  // Declared extra (non-CRUD) actions per type, catalog order. A type without extras keeps
@@ -273,8 +287,10 @@ export default function AccessForm() {
273
287
  }
274
288
  };
275
289
 
276
- const matchesSearch = (t) =>
277
- !objectSearch.trim() || t.toLowerCase().includes(objectSearch.trim().toLowerCase());
290
+ const matchesSearch = (t) => {
291
+ const q = objectSearch.trim().toLowerCase();
292
+ return !q || t.toLowerCase().includes(q) || (typeLabels[t] ?? '').toLowerCase().includes(q);
293
+ };
278
294
 
279
295
  const title =
280
296
  mode === 'edit'
@@ -299,11 +315,13 @@ export default function AccessForm() {
299
315
  submitLabel="Save access"
300
316
  busy={saving || loading}
301
317
  error={error}
302
- contentClassName="sm:max-w-4xl"
318
+ contentClassName="sm:max-w-6xl"
303
319
  >
304
- <div className="grid gap-4 md:grid-cols-[240px_1fr]">
305
- {/* Left rail — presets */}
306
- <div className="flex flex-col gap-2 md:border-r md:border-border md:pr-4">
320
+ {/* Fills the modal body (`flex-1 min-h-0`); on md+ the single grid row is the full body
321
+ 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">
307
325
  <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
308
326
  Presets
309
327
  </div>
@@ -316,19 +334,24 @@ export default function AccessForm() {
316
334
  <button
317
335
  key={b.key}
318
336
  type="button"
337
+ aria-pressed={isApplied}
319
338
  onClick={() => togglePreset(b)}
320
- className={`flex items-start gap-2.5 rounded-lg border p-3 text-left transition-colors ${
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 ${
321
340
  isApplied ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'
322
341
  }`}
323
342
  >
324
343
  {/* Checkbox-like: presets stack (multiple can be applied); this reflects +
325
- toggles whether ALL of the preset's keys are held. Non-interactive
326
- itself (the whole card is the button) — pointer-events-none. */}
327
- <Checkbox
328
- checked={isApplied}
329
- className="pointer-events-none mt-0.5"
330
- tabIndex={-1}
331
- />
344
+ toggles whether ALL of the preset's keys are held. The whole card is ONE
345
+ toggle button (`aria-pressed`); the box is decorative, not a nested
346
+ 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>
332
355
  <div className="min-w-0">
333
356
  <span className="text-sm font-medium text-foreground">{b.label}</span>
334
357
  {b.description && (
@@ -344,7 +367,7 @@ export default function AccessForm() {
344
367
  </div>
345
368
 
346
369
  {/* Right pane — role + matrix */}
347
- <div className="flex min-w-0 flex-col gap-3">
370
+ <div className="flex min-h-0 min-w-0 flex-col gap-3">
348
371
  <div className="flex flex-col gap-1">
349
372
  <label className="text-xs font-medium text-muted-foreground">Role</label>
350
373
  <SearchableSelect
@@ -365,7 +388,9 @@ export default function AccessForm() {
365
388
  placeholder="Search objects"
366
389
  />
367
390
 
368
- <div className="flex max-h-[46vh] flex-col gap-2 overflow-y-auto pr-1">
391
+ {/* The matrix takes the remaining height and scrolls inside itself (it used to be
392
+ 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">
369
394
  {groups.map((group) => {
370
395
  const visibleTypes = group.types.filter(matchesSearch);
371
396
  if (visibleTypes.length === 0) return null;
@@ -412,7 +437,10 @@ export default function AccessForm() {
412
437
  <tr className="text-left text-xs uppercase tracking-wide text-muted-foreground">
413
438
  <th className="py-1 pr-3 font-medium">Object</th>
414
439
  {columns.map((a) => (
415
- <th key={a} className="px-2 py-1 text-center font-medium capitalize">
440
+ <th
441
+ key={a}
442
+ className="px-2 py-1 text-center font-medium whitespace-nowrap"
443
+ >
416
444
  {actionHeading(a)}
417
445
  </th>
418
446
  ))}
@@ -421,7 +449,9 @@ export default function AccessForm() {
421
449
  <tbody>
422
450
  {visibleTypes.map((t) => (
423
451
  <tr key={t} className="border-t border-border">
424
- <td className="py-1.5 pr-3 text-foreground">{t}</td>
452
+ <td className="py-1.5 pr-3 whitespace-nowrap text-foreground">
453
+ {typeLabels[t] ?? t}
454
+ </td>
425
455
  {columns.map((a) => {
426
456
  const key = cellKey(t, a);
427
457
  const exists = keyExists(key);
@@ -14,7 +14,7 @@ import { useCallback, useEffect, useState } from 'react';
14
14
  import { Outlet, useNavigate, useSearchParams } from 'react-router-dom';
15
15
  import { KeyRound } from 'lucide-react';
16
16
  import { toast } from 'sonner';
17
- import { useAuth } from '@/auth/AuthContext';
17
+ import { useAuth, useCan } from '@/auth/AuthContext';
18
18
  import AppShell from '@/shell/AppShell';
19
19
  import PageHeader from '@/components/PageHeader';
20
20
  import EntityList, { EntityListControls, EntityListPager } from '@/components/EntityList';
@@ -22,6 +22,8 @@ import ConfirmDialog from '@/components/ConfirmDialog';
22
22
  import { Badge } from '@/components/ui/badge';
23
23
  import { Avatar, AvatarFallback } from '@/components/ui/avatar';
24
24
  import RoleActionsMenu from './RoleActionsMenu';
25
+ // Access chips name object types; show a readable name, never the raw type key.
26
+ import { humanizeKey } from './accessMatrix';
25
27
 
26
28
  const MAX_CHIPS = 4;
27
29
  const DEFAULT_LIMIT = 25;
@@ -110,6 +112,11 @@ export default function AccessPage() {
110
112
  const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
111
113
  const onLimitChange = useCallback((limit) => setQuery((q) => ({ ...q, limit, page: 1 })), []);
112
114
 
115
+ // Grant, revoke and duplicate all run through role-permission routes gated on
116
+ // identity:role:update. Without it the page is read-only: no menu, no row click, no Assign.
117
+ // UX only; the server decides.
118
+ const canEditAccess = useCan('identity:role:update');
119
+
113
120
  // Row menu actions.
114
121
  const onEdit = useCallback((row) => navigate(`/access/edit/${row.roleUuid}`), [navigate]);
115
122
  const onDuplicate = useCallback(
@@ -170,7 +177,7 @@ export default function AccessPage() {
170
177
  <div className="flex flex-wrap gap-1">
171
178
  {(row.chips || []).slice(0, MAX_CHIPS).map((c) => (
172
179
  <Badge key={c} variant="secondary" className="font-normal">
173
- {c}
180
+ {humanizeKey(c)}
174
181
  </Badge>
175
182
  ))}
176
183
  {extra > 0 && <span className="text-xs text-muted-foreground">+{extra}</span>}
@@ -208,22 +215,27 @@ export default function AccessPage() {
208
215
  );
209
216
  },
210
217
  },
211
- {
212
- // Actions as a normal column (not EntityList's built-in Edit/Delete slot), so we can
213
- // render the ⋯ menu (Edit / Duplicate / Revoke all). onEdit/onDelete stay UNSET.
214
- key: 'actions',
215
- label: '',
216
- render: (row) => (
217
- <div className="flex justify-end">
218
- <RoleActionsMenu
219
- role={row}
220
- onEdit={onEdit}
221
- onDuplicate={onDuplicate}
222
- onRevokeAll={setRevokeTarget}
223
- />
224
- </div>
225
- ),
226
- },
218
+ // Actions as a normal column (not EntityList's built-in Edit/Delete slot), so we can
219
+ // render the ⋯ menu (Edit / Duplicate / Revoke all). onEdit/onDelete stay UNSET; the row
220
+ // itself opens Edit through `onRowClick`.
221
+ ...(canEditAccess
222
+ ? [
223
+ {
224
+ key: 'actions',
225
+ label: '',
226
+ render: (row) => (
227
+ <div className="flex justify-end">
228
+ <RoleActionsMenu
229
+ role={row}
230
+ onEdit={onEdit}
231
+ onDuplicate={onDuplicate}
232
+ onRevokeAll={setRevokeTarget}
233
+ />
234
+ </div>
235
+ ),
236
+ },
237
+ ]
238
+ : []),
227
239
  ];
228
240
 
229
241
  return (
@@ -239,7 +251,7 @@ export default function AccessPage() {
239
251
  onSearchChange={onSearchChange}
240
252
  searchPlaceholder="Search roles…"
241
253
  addLabel="Assign access"
242
- onAdd={() => navigate('/access/add')}
254
+ onAdd={canEditAccess ? () => navigate('/access/add') : undefined}
243
255
  />
244
256
  }
245
257
  />
@@ -253,6 +265,7 @@ export default function AccessPage() {
253
265
  loading={loading}
254
266
  error={error}
255
267
  emptyLabel="No roles have access yet. Assign access to get started."
268
+ onRowClick={canEditAccess ? onEdit : undefined}
256
269
  onSortChange={onSortChange}
257
270
  />
258
271
 
@@ -53,3 +53,46 @@ export function matrixActions(catalog, types) {
53
53
  }
54
54
  return out;
55
55
  }
56
+
57
+ /** `company_branch` → `Company branch` (the fallback when the catalog gives no type name). */
58
+ export function humanizeKey(key) {
59
+ const words = String(key ?? '')
60
+ .split('_')
61
+ .filter(Boolean)
62
+ .join(' ');
63
+ return words ? words[0].toUpperCase() + words.slice(1) : '';
64
+ }
65
+
66
+ /**
67
+ * The object column's name for a type (G-027): the part before ": " that every catalog label of
68
+ * the type shares ("Users: view", "Users: invite" ⇒ "Users"), else the humanized type key. Never
69
+ * the raw key, which is an identifier, not a label.
70
+ */
71
+ export function typeLabelOf(catalog, typeKey) {
72
+ let shared = null;
73
+ for (const row of Array.isArray(catalog) ? catalog : []) {
74
+ if (!row || row.typeKey !== typeKey || typeof row.label !== 'string') continue;
75
+ const at = row.label.indexOf(': ');
76
+ const prefix = at > 0 ? row.label.slice(0, at).trim() : '';
77
+ if (!prefix) return humanizeKey(typeKey);
78
+ if (shared === null) shared = prefix;
79
+ else if (shared !== prefix) return humanizeKey(typeKey);
80
+ }
81
+ return shared ?? humanizeKey(typeKey);
82
+ }
83
+
84
+ const ACRONYMS = new Map([
85
+ ['mfa', 'MFA'],
86
+ ['api', 'API'],
87
+ ['id', 'ID'],
88
+ ['url', 'URL'],
89
+ ]);
90
+
91
+ /** A matrix column heading for an action key: `reset_mfa` → `Reset MFA`, `end_sessions` → `End sessions`. */
92
+ export function actionHeading(action) {
93
+ const words = String(action ?? '')
94
+ .split('_')
95
+ .filter(Boolean)
96
+ .map((w, i) => ACRONYMS.get(w) ?? (i === 0 ? w[0].toUpperCase() + w.slice(1) : w));
97
+ return words.join(' ');
98
+ }
@@ -5,7 +5,13 @@
5
5
  // node --test src/pages/access/accessMatrix.test.mjs
6
6
  import { test, describe } from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
- import { extraActionsByType, matrixActions } from './accessMatrix.js';
8
+ import {
9
+ actionHeading,
10
+ extraActionsByType,
11
+ humanizeKey,
12
+ matrixActions,
13
+ typeLabelOf,
14
+ } from './accessMatrix.js';
9
15
 
10
16
  const row = (typeKey, action, label = `${typeKey} ${action}`) => ({
11
17
  key: `app:${typeKey}:${action}`,
@@ -166,3 +172,31 @@ describe('matrixActions', () => {
166
172
  assert.deepEqual([...matrixActions([null, {}, row('t', 'go')])], ['go']);
167
173
  });
168
174
  });
175
+
176
+ describe('typeLabelOf / actionHeading (G-027 labels in the matrix)', () => {
177
+ test('the shared label prefix names the type', () => {
178
+ const catalog = [row('user', 'read', 'Users: view'), row('user', 'invite', 'Users: invite')];
179
+ assert.equal(typeLabelOf(catalog, 'user'), 'Users');
180
+ });
181
+
182
+ test('labels without a shared prefix fall back to the humanized key, never the raw key', () => {
183
+ const mixed = [row('job', 'read', 'Jobs: view'), row('job', 'create', 'Create a job')];
184
+ assert.equal(typeLabelOf(mixed, 'job'), 'Job');
185
+ const differing = [row('job', 'read', 'Jobs: view'), row('job', 'update', 'Work: edit')];
186
+ assert.equal(typeLabelOf(differing, 'job'), 'Job');
187
+ assert.equal(typeLabelOf([], 'company_branch'), 'Company branch');
188
+ assert.equal(typeLabelOf(null, 'company_branch'), 'Company branch');
189
+ });
190
+
191
+ test('humanizeKey', () => {
192
+ assert.equal(humanizeKey('membership_posting'), 'Membership posting');
193
+ assert.equal(humanizeKey(''), '');
194
+ });
195
+
196
+ test('action headings keep acronyms', () => {
197
+ assert.equal(actionHeading('reset_mfa'), 'Reset MFA');
198
+ assert.equal(actionHeading('end_sessions'), 'End sessions');
199
+ assert.equal(actionHeading('manage_keys'), 'Manage keys');
200
+ assert.equal(actionHeading('read'), 'Read');
201
+ });
202
+ });
@@ -155,6 +155,9 @@ export default function CompaniesPage() {
155
155
 
156
156
  // Move dialog state (G-011): the company being moved and the chosen parent option.
157
157
  const holdsUpdate = useCan(MOVE_PERMISSION);
158
+ // Add / Edit (and the row click) / Delete only for callers who hold the matching key. UX only.
159
+ const holdsCreate = useCan('identity:company:create');
160
+ const holdsDelete = useCan('identity:company:delete');
158
161
  const [moveTarget, setMoveTarget] = useState(null);
159
162
  const [moveParent, setMoveParent] = useState('');
160
163
  const [moveBusy, setMoveBusy] = useState(false);
@@ -252,7 +255,7 @@ export default function CompaniesPage() {
252
255
  onSearchChange={onSearchChange}
253
256
  searchPlaceholder="Search companies…"
254
257
  addLabel="Add company"
255
- onAdd={() => navigate('/companies/add')}
258
+ onAdd={holdsCreate ? () => navigate('/companies/add') : undefined}
256
259
  />
257
260
  }
258
261
  />
@@ -266,8 +269,8 @@ export default function CompaniesPage() {
266
269
  loading={loading}
267
270
  error={error}
268
271
  emptyLabel="No companies yet. Add one to get started."
269
- onEdit={(row) => navigate(`/companies/edit/${row.id}`)}
270
- onDelete={(row) => setDeleteTarget(row)}
272
+ onEdit={holdsUpdate ? (row) => navigate(`/companies/edit/${row.id}`) : undefined}
273
+ onDelete={holdsDelete ? (row) => setDeleteTarget(row) : undefined}
271
274
  onSortChange={onSortChange}
272
275
  />
273
276
 
@@ -15,7 +15,7 @@
15
15
  import { useCallback, useEffect, useMemo, useState } from 'react';
16
16
  import { Bot, KeyRound } from 'lucide-react';
17
17
  import { toast } from 'sonner';
18
- import { useAuth } from '@/auth/AuthContext';
18
+ import { useAuth, useCan } from '@/auth/AuthContext';
19
19
  import AppShell from '@/shell/AppShell';
20
20
  import PageHeader from '@/components/PageHeader';
21
21
  import EntityList, { EntityListControls } from '@/components/EntityList';
@@ -63,6 +63,10 @@ export default function ServiceAccountsPage() {
63
63
 
64
64
  // Create / edit-access dialog: `null` closed, `{ account: null }` create, `{ account }` edit.
65
65
  const [accessDialog, setAccessDialog] = useState(null);
66
+ // Create needs service_account:create; the row click opens "Edit access" (roles + scope, a
67
+ // service_account:update write). UX only; the server decides.
68
+ const holdsCreate = useCan('identity:service_account:create');
69
+ const holdsUpdate = useCan('identity:service_account:update');
66
70
 
67
71
  // One-time key reveal + destructive-confirm state.
68
72
  const [issuedKey, setIssuedKey] = useState(null);
@@ -303,7 +307,7 @@ export default function ServiceAccountsPage() {
303
307
  onSearchChange={setSearch}
304
308
  searchPlaceholder="Search service accounts…"
305
309
  addLabel="New service account"
306
- onAdd={() => setAccessDialog({ account: null })}
310
+ onAdd={holdsCreate ? () => setAccessDialog({ account: null }) : undefined}
307
311
  />
308
312
  }
309
313
  />
@@ -315,6 +319,7 @@ export default function ServiceAccountsPage() {
315
319
  loading={loading}
316
320
  error={error}
317
321
  emptyLabel="No service accounts yet. Create one to give an agent or script scoped API access."
322
+ onRowClick={holdsUpdate ? (row) => setAccessDialog({ account: row.account }) : undefined}
318
323
  />
319
324
  </div>
320
325
 
@@ -224,6 +224,14 @@ export default function UsersPage() {
224
224
  }, [query, setSearchParams]);
225
225
 
226
226
  const holdsInvite = useCan(INVITE_PERMISSION);
227
+ // Add / Delete need their user key. The edit page covers the profile AND the assignments
228
+ // (memberships, postings), so Edit and the row click show for either. UX only.
229
+ const holdsCreate = useCan('identity:user:create');
230
+ const holdsDelete = useCan('identity:user:delete');
231
+ const holdsUserUpdate = useCan('identity:user:update');
232
+ const holdsMembershipUpdate = useCan('identity:company_membership:update');
233
+ const holdsPostingCreate = useCan('identity:membership_posting:create');
234
+ const canOpenEdit = holdsUserUpdate || holdsMembershipUpdate || holdsPostingCreate;
227
235
  const [invitingId, setInvitingId] = useState(null);
228
236
 
229
237
  // Admin sign-in actions (R11): `{ action, user }` while the code dialog is open.
@@ -329,7 +337,7 @@ export default function UsersPage() {
329
337
  onSearchChange={onSearchChange}
330
338
  searchPlaceholder="Search users…"
331
339
  addLabel="Add user"
332
- onAdd={() => navigate('/users/add')}
340
+ onAdd={holdsCreate ? () => navigate('/users/add') : undefined}
333
341
  />
334
342
  }
335
343
  />
@@ -343,8 +351,8 @@ export default function UsersPage() {
343
351
  loading={loading}
344
352
  error={error}
345
353
  emptyLabel="No users yet. Add one to get started."
346
- onEdit={(row) => navigate(`/users/edit/${row.id}`)}
347
- onDelete={(row) => setDeleteTarget(row)}
354
+ onEdit={canOpenEdit ? (row) => navigate(`/users/edit/${row.id}`) : undefined}
355
+ onDelete={holdsDelete ? (row) => setDeleteTarget(row) : undefined}
348
356
  onSortChange={onSortChange}
349
357
  />
350
358
 
@@ -182,6 +182,12 @@ NODE_ENV=development
182
182
  # INVITE_LINK_TTL_HOURS=72
183
183
  # INVITE_RATE_PER_ADMIN=30
184
184
 
185
+ # --- OPTIONAL: release name for the permission catalog ---
186
+ # Stamped once on each permission key the catalog first sees (with the date), so admins can tell
187
+ # which release added it. 1-64 characters of letters, digits, . _ + - (a git tag or sha fits);
188
+ # an invalid value fails boot. Unset ⇒ only the date is stored.
189
+ # APP_RELEASE=2026-10-25
190
+
185
191
  # --- OPTIONAL: CAPTCHA on sign-in (Cloudflare Turnstile or Google reCAPTCHA) ---
186
192
  # Read only when src/app.module.ts passes ShieldModule.forRoot({ captcha: 'env' }); a missing or
187
193
  # invalid value then fails boot. One of turnstile | recaptcha-v3 | recaptcha-v2.
@@ -22,11 +22,11 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@astram/core": "1.12.0",
25
- "@astram/engine": "0.15.0",
26
- "@astram/guard": "0.22.0",
25
+ "@astram/engine": "0.16.0",
26
+ "@astram/guard": "0.23.0",
27
27
  "@astram/guide": "^0.2.8",
28
- "@astram/identity": "0.18.1",
29
- "@astram/messaging": "0.1.7",
28
+ "@astram/identity": "0.19.0",
29
+ "@astram/messaging": "0.1.8",
30
30
  "@astram/migrate": "0.7.2",
31
31
  "@astram/sdk": "^0.2.0",
32
32
  "@nestjs/common": "^11.2.7",