create-astram-app 0.12.6 → 0.12.8
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/components/EntityFormModal.jsx +5 -3
- package/template/frontend/src/components/EntityList.jsx +40 -2
- package/template/frontend/src/components/rowActivation.js +52 -0
- package/template/frontend/src/components/rowActivation.test.mjs +69 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +53 -23
- package/template/frontend/src/pages/access/AccessPage.jsx +32 -19
- package/template/frontend/src/pages/access/accessMatrix.js +43 -0
- package/template/frontend/src/pages/access/accessMatrix.test.mjs +35 -1
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +6 -3
- package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +7 -2
- package/template/frontend/src/pages/users/UsersPage.jsx +11 -3
- package/template/server/_env.example +12 -0
- package/template/server/package.json +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.8",
|
|
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",
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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-
|
|
318
|
+
contentClassName="sm:max-w-6xl"
|
|
303
319
|
>
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
|
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">
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 {
|
|
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,18 @@ 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
|
+
|
|
191
|
+
# --- OPTIONAL: in-app notification retention ---
|
|
192
|
+
# Read notices are deleted after the first value (days, 1-3650), every notice after the second
|
|
193
|
+
# (at least the first, at most 3650), counted from when the notice was posted.
|
|
194
|
+
# NOTIFICATION_READ_RETENTION_DAYS=90
|
|
195
|
+
# NOTIFICATION_MAX_RETENTION_DAYS=365
|
|
196
|
+
|
|
185
197
|
# --- OPTIONAL: CAPTCHA on sign-in (Cloudflare Turnstile or Google reCAPTCHA) ---
|
|
186
198
|
# Read only when src/app.module.ts passes ShieldModule.forRoot({ captcha: 'env' }); a missing or
|
|
187
199
|
# invalid value then fails boot. One of turnstile | recaptcha-v3 | recaptcha-v2.
|
|
@@ -21,13 +21,13 @@
|
|
|
21
21
|
"postinstall": "node scripts/sync-astram-guide.mjs"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@astram/core": "1.
|
|
25
|
-
"@astram/engine": "0.
|
|
26
|
-
"@astram/guard": "0.
|
|
24
|
+
"@astram/core": "1.13.0",
|
|
25
|
+
"@astram/engine": "0.16.1",
|
|
26
|
+
"@astram/guard": "0.24.0",
|
|
27
27
|
"@astram/guide": "^0.2.8",
|
|
28
|
-
"@astram/identity": "0.
|
|
29
|
-
"@astram/messaging": "0.1.
|
|
30
|
-
"@astram/migrate": "0.7.
|
|
28
|
+
"@astram/identity": "0.20.0",
|
|
29
|
+
"@astram/messaging": "0.1.9",
|
|
30
|
+
"@astram/migrate": "0.7.3",
|
|
31
31
|
"@astram/sdk": "^0.2.0",
|
|
32
32
|
"@nestjs/common": "^11.2.7",
|
|
33
33
|
"@nestjs/core": "^11.2.7",
|