create-astram-app 0.4.0 → 0.6.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.
Files changed (87) hide show
  1. package/README.md +211 -38
  2. package/dist/add-frontend.js.map +1 -1
  3. package/dist/index.js +14 -9
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/template/README.md +51 -20
  7. package/template/frontend/README.md +21 -11
  8. package/template/frontend/index.html +16 -0
  9. package/template/frontend/package.json +1 -1
  10. package/template/frontend/src/App.jsx +95 -20
  11. package/template/frontend/src/auth/AuthContext.jsx +145 -21
  12. package/template/frontend/src/auth/routeGate.js +31 -0
  13. package/template/frontend/src/auth/routeGate.test.mjs +59 -0
  14. package/template/frontend/src/auth/sessionFlow.js +27 -0
  15. package/template/frontend/src/auth/sessionFlow.test.mjs +63 -0
  16. package/template/frontend/src/components/EntityList.jsx +1 -7
  17. package/template/frontend/src/components/MultiSelectChecklist.jsx +166 -0
  18. package/template/frontend/src/components/PageHeader.jsx +5 -4
  19. package/template/frontend/src/components/ShareDialog.jsx +3 -5
  20. package/template/frontend/src/components/access/CollapsibleGroup.jsx +4 -1
  21. package/template/frontend/src/components/ui/alert-dialog.tsx +27 -50
  22. package/template/frontend/src/components/ui/alert.tsx +17 -23
  23. package/template/frontend/src/components/ui/avatar.tsx +8 -20
  24. package/template/frontend/src/components/ui/badge.tsx +17 -26
  25. package/template/frontend/src/components/ui/button.tsx +22 -24
  26. package/template/frontend/src/components/ui/card.tsx +19 -36
  27. package/template/frontend/src/components/ui/checkbox.tsx +7 -10
  28. package/template/frontend/src/components/ui/date-field.tsx +26 -27
  29. package/template/frontend/src/components/ui/dialog.tsx +24 -40
  30. package/template/frontend/src/components/ui/dropdown-menu.tsx +25 -54
  31. package/template/frontend/src/components/ui/input-otp.tsx +1 -4
  32. package/template/frontend/src/components/ui/label.tsx +10 -12
  33. package/template/frontend/src/components/ui/searchable-select.tsx +1 -3
  34. package/template/frontend/src/components/ui/select.tsx +31 -43
  35. package/template/frontend/src/components/ui/separator.tsx +6 -6
  36. package/template/frontend/src/components/ui/sheet.tsx +29 -38
  37. package/template/frontend/src/components/ui/skeleton.tsx +3 -3
  38. package/template/frontend/src/components/ui/sonner.tsx +7 -7
  39. package/template/frontend/src/components/ui/table.tsx +56 -70
  40. package/template/frontend/src/components/ui/tooltip.tsx +7 -11
  41. package/template/frontend/src/config/branding.js +1 -4
  42. package/template/frontend/src/hooks/useMobile.tsx +4 -6
  43. package/template/frontend/src/index.css +9 -4
  44. package/template/frontend/src/pages/Dashboard.jsx +13 -7
  45. package/template/frontend/src/pages/ForgotPassword.jsx +9 -2
  46. package/template/frontend/src/pages/Login.jsx +85 -70
  47. package/template/frontend/src/pages/ResetPassword.jsx +9 -2
  48. package/template/frontend/src/pages/access/AccessForm.jsx +49 -15
  49. package/template/frontend/src/pages/access/AccessPage.jsx +10 -6
  50. package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +9 -2
  51. package/template/frontend/src/pages/account/AccountLayout.jsx +11 -6
  52. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +2 -2
  53. package/template/frontend/src/pages/account/ReenrollPage.jsx +3 -5
  54. package/template/frontend/src/pages/account/TwoFactorPage.jsx +3 -8
  55. package/template/frontend/src/pages/companies/CompaniesPage.jsx +7 -9
  56. package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +41 -8
  57. package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +30 -7
  58. package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +12 -3
  59. package/template/frontend/src/pages/roles/RolesPage.jsx +5 -9
  60. package/template/frontend/src/pages/service-accounts/KeyRevealDialog.jsx +104 -0
  61. package/template/frontend/src/pages/service-accounts/ServiceAccountAccessDialog.jsx +231 -0
  62. package/template/frontend/src/pages/service-accounts/ServiceAccountActionsMenu.jsx +56 -0
  63. package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +368 -0
  64. package/template/frontend/src/pages/service-accounts/accessModel.js +158 -0
  65. package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +189 -0
  66. package/template/frontend/src/pages/users/MembershipCard.jsx +3 -1
  67. package/template/frontend/src/pages/users/PostingRow.jsx +8 -14
  68. package/template/frontend/src/pages/users/UsersPage.jsx +7 -9
  69. package/template/frontend/src/platform/httpClient.js +5 -0
  70. package/template/frontend/src/platform/sessionStore.js +55 -13
  71. package/template/frontend/src/platform/sessionStore.test.mjs +89 -0
  72. package/template/frontend/src/platform/shieldTransport.js +6 -0
  73. package/template/frontend/src/platform/shieldTransport.test.mjs +84 -5
  74. package/template/frontend/src/shell/AppShell.jsx +10 -12
  75. package/template/frontend/src/shell/Sidebar.jsx +15 -3
  76. package/template/frontend/src/shell/nav.js +13 -3
  77. package/template/frontend/vite.config.js +19 -1
  78. package/template/server/_env.example +22 -1
  79. package/template/server/eslint.config.mjs +194 -0
  80. package/template/server/package.json +11 -11
  81. package/template/server/scripts/dev.mjs +17 -6
  82. package/template/server/scripts/sync-astram-guide.mjs +45 -19
  83. package/template/server/src/app.module.ts +11 -5
  84. package/template/server/src/health/demo.controller.ts +16 -12
  85. package/template/server/src/main.ts +23 -1
  86. package/template/server/test/enforcement.e2e.spec.ts +73 -6
  87. package/template/server/eslint.config.js +0 -95
@@ -0,0 +1,158 @@
1
+ /**
2
+ * accessModel — the pure (no React, no I/O) logic behind the service-account access dialog
3
+ * (Phase 4): turn the server's `assignable` set + an existing account into picker options, work out
4
+ * which of the agent's items are LOCKED (assigned by someone with more access), validate, and build
5
+ * a minimal PATCH. Kept separate so it is unit-tested with `node --test` (accessModel.test.mjs).
6
+ *
7
+ * The server is the authority (ADR-0029 ceiling): `assignable` is computed from the same ceiling the
8
+ * write enforces. An agent's existing role/node that is NOT assignable to the editor is shown checked
9
+ * + disabled and is NEVER sent — a non-root PATCH keeps it server-side (sending it would be a 403).
10
+ * Options carry `name` for display; `id` is only the value sent back (never rendered, ADR-0012).
11
+ */
12
+
13
+ export const LOCKED_HINT = 'assigned by someone with more access';
14
+ export const NO_ROLES_WARNING = 'This agent will be denied everything until it has a role.';
15
+ export const SCOPE_REQUIRED = 'Choose at least one scope node.';
16
+
17
+ /** Depth of each node by walking `parentId` inside the set (cycle-safe; unknown parent ⇒ top). */
18
+ export function withDepth(nodes) {
19
+ const byId = new Map(nodes.map((n) => [n.id, n]));
20
+ const depthOf = (n) => {
21
+ let depth = 0;
22
+ const seen = new Set([n.id]);
23
+ let p = n.parentId;
24
+ while (p && byId.has(p) && !seen.has(p)) {
25
+ seen.add(p);
26
+ depth += 1;
27
+ p = byId.get(p).parentId;
28
+ }
29
+ return depth;
30
+ };
31
+ return nodes.map((n) => ({ id: n.id, name: n.name, depth: depthOf(n) }));
32
+ }
33
+
34
+ /** Items the agent holds that the editor cannot grant, labelled from the view (same order as ids). */
35
+ function lockedItems(ids, labels, assignableIds) {
36
+ const out = [];
37
+ (ids || []).forEach((id, i) => {
38
+ if (!assignableIds.has(id))
39
+ out.push({ id, name: (labels && labels[i]) || 'Unnamed', depth: 0, locked: true });
40
+ });
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Build the picker model. `account` is null for create.
46
+ * - create: no roles ticked; every assignable scope node ticked ("defaults to everything you can reach").
47
+ * - edit: the agent's assignable items ticked; its non-assignable items appended as locked.
48
+ */
49
+ export function buildAccessModel(assignable, account) {
50
+ const roles = (assignable && assignable.roles) || [];
51
+ const nodes = (assignable && assignable.scopeNodes) || [];
52
+ const roleIds = new Set(roles.map((r) => r.id));
53
+ const nodeIds = new Set(nodes.map((n) => n.id));
54
+
55
+ const roleOptions = roles.map((r) => ({ id: r.id, name: r.name, depth: 0 }));
56
+ const scopeOptions = withDepth(nodes);
57
+
58
+ if (!account) {
59
+ return {
60
+ roleOptions,
61
+ scopeOptions,
62
+ initialRoles: [],
63
+ initialScope: nodes.map((n) => n.id),
64
+ };
65
+ }
66
+ const lockedRoles = lockedItems(account.roleIds, account.roleLabels, roleIds);
67
+ const lockedScope = lockedItems(account.scopeSubtreeNodeIds, account.scopeLabels, nodeIds);
68
+ return {
69
+ roleOptions: [...roleOptions, ...lockedRoles],
70
+ scopeOptions: [...scopeOptions, ...lockedScope],
71
+ initialRoles: (account.roleIds || []).filter((id) => roleIds.has(id)),
72
+ initialScope: (account.scopeSubtreeNodeIds || []).filter((id) => nodeIds.has(id)),
73
+ };
74
+ }
75
+
76
+ /** Number of locked options (always held by the agent, never sent). */
77
+ export function lockedCount(options) {
78
+ return options.filter((o) => o.locked).length;
79
+ }
80
+
81
+ /** Order-insensitive equality of two id lists. */
82
+ export function sameIds(a, b) {
83
+ const sa = new Set(a);
84
+ const sb = new Set(b);
85
+ if (sa.size !== sb.size) return false;
86
+ for (const x of sa) if (!sb.has(x)) return false;
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Field errors for the form (empty object ⇒ valid). When the scope will be sent (`requireScope`,
92
+ * default true) it needs ≥1 node — the server rejects an empty scope on edit with a 400.
93
+ */
94
+ export function validateAccess({ label, scope, requireScope = true }) {
95
+ const errors = {};
96
+ if (!label || !label.trim()) errors.label = 'Give the service account a name.';
97
+ if (requireScope && (!scope || scope.length === 0)) errors.scope = SCOPE_REQUIRED;
98
+ return errors;
99
+ }
100
+
101
+ /** Does the agent end up with no role at all (ticked + locked)? → show the non-blocking warning. */
102
+ export function hasNoRoles(roles, roleOptions) {
103
+ return roles.length === 0 && lockedCount(roleOptions) === 0;
104
+ }
105
+
106
+ /** Create body: always sends roles + ≥1 scope node (only assignable ids — never locked ones). */
107
+ export function createBody({ label, roles, scope }) {
108
+ return { label: label.trim(), roleIds: [...roles], scopeSubtreeNodeIds: [...scope] };
109
+ }
110
+
111
+ /**
112
+ * Minimal PATCH for an edit: only the fields that changed. `roles` / `scope` hold assignable ids
113
+ * only (locked items are preserved by the server). Returns `{}` when nothing changed.
114
+ */
115
+ export function editPatch({ account, label, roles, scope, initialRoles, initialScope }) {
116
+ const patch = {};
117
+ const nextLabel = label.trim();
118
+ if (nextLabel !== account.label) patch.label = nextLabel;
119
+ if (!sameIds(roles, initialRoles)) patch.roleIds = [...roles];
120
+ if (!sameIds(scope, initialScope)) patch.scopeSubtreeNodeIds = [...scope];
121
+ return patch;
122
+ }
123
+
124
+ /** Route a 400 server message to the field it is about (else the form-level error). */
125
+ export function fieldOfServerError(message) {
126
+ const m = (message || '').toLowerCase();
127
+ if (m.includes('scope')) return 'scope';
128
+ if (m.includes('label') || m.includes('name')) return 'label';
129
+ return null;
130
+ }
131
+
132
+ /** First `max` names + a "+N" overflow count, for the row badges. */
133
+ export function summarizeNames(names, max = 3) {
134
+ const list = names || [];
135
+ return { shown: list.slice(0, max), more: Math.max(0, list.length - max) };
136
+ }
137
+
138
+ const DAY_MS = 24 * 60 * 60 * 1000;
139
+
140
+ /**
141
+ * Key expiry choices for issue/rotate. The server REQUIRES a choice (ADR-0033 §4): a future
142
+ * `expiresAtMs`, or an explicit `neverExpires: true` — a non-expiring key is never the default.
143
+ */
144
+ export const KEY_EXPIRY_CHOICES = [
145
+ { value: '30', label: '30 days' },
146
+ { value: '90', label: '90 days' },
147
+ { value: '365', label: '1 year' },
148
+ { value: 'never', label: 'Never expires (not recommended)' },
149
+ ];
150
+ export const DEFAULT_KEY_EXPIRY = '90';
151
+
152
+ /** The issue/rotate request body for a choice; an unknown choice uses the default. */
153
+ export function keyExpiryRequest(choice, nowMs = Date.now()) {
154
+ if (choice === 'never') return { neverExpires: true };
155
+ const known = KEY_EXPIRY_CHOICES.some((c) => c.value === choice && c.value !== 'never');
156
+ const days = Number(known ? choice : DEFAULT_KEY_EXPIRY);
157
+ return { expiresAtMs: nowMs + days * DAY_MS };
158
+ }
@@ -0,0 +1,189 @@
1
+ // accessModel.test.mjs — Phase 4: the service-account access dialog's pure logic (picker
2
+ // options, locked items, validation, minimal PATCH). No React/DOM; runs under Node's built-in
3
+ // runner with zero dependencies:
4
+ //
5
+ // node --test src/pages/service-accounts/accessModel.test.mjs
6
+ import { test, describe } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import {
9
+ withDepth,
10
+ buildAccessModel,
11
+ lockedCount,
12
+ sameIds,
13
+ validateAccess,
14
+ hasNoRoles,
15
+ createBody,
16
+ editPatch,
17
+ fieldOfServerError,
18
+ summarizeNames,
19
+ SCOPE_REQUIRED,
20
+ KEY_EXPIRY_CHOICES,
21
+ DEFAULT_KEY_EXPIRY,
22
+ keyExpiryRequest,
23
+ } from './accessModel.js';
24
+
25
+ const ASSIGNABLE = {
26
+ roles: [
27
+ { id: 'r-ingest', name: 'Ingest' },
28
+ { id: 'r-audit', name: 'Auditor' },
29
+ ],
30
+ scopeNodes: [
31
+ { id: 'n-acme', name: 'Acme', parentId: null },
32
+ { id: 'n-east', name: 'Acme East', parentId: 'n-acme' },
33
+ { id: 'n-east-1', name: 'East 1', parentId: 'n-east' },
34
+ ],
35
+ };
36
+
37
+ const ACCOUNT = {
38
+ id: 'sa-1',
39
+ label: 'Ingest Bot',
40
+ roleIds: ['r-ingest', 'r-finance'],
41
+ roleLabels: ['Ingest', 'Finance Admin'],
42
+ scopeSubtreeNodeIds: ['n-east', 'n-beta'],
43
+ scopeLabels: ['Acme East', 'Beta'],
44
+ };
45
+
46
+ describe('withDepth', () => {
47
+ test('indents by parent chain inside the set', () => {
48
+ assert.deepEqual(
49
+ withDepth(ASSIGNABLE.scopeNodes).map((n) => n.depth),
50
+ [0, 1, 2],
51
+ );
52
+ });
53
+ test('a parent outside the set, or a cycle, does not loop or over-indent', () => {
54
+ const d = withDepth([
55
+ { id: 'a', name: 'A', parentId: 'b' },
56
+ { id: 'b', name: 'B', parentId: 'a' },
57
+ { id: 'c', name: 'C', parentId: 'zzz' },
58
+ ]);
59
+ assert.equal(d.find((n) => n.id === 'c').depth, 0);
60
+ assert.ok(d.every((n) => n.depth <= 1));
61
+ });
62
+ });
63
+
64
+ describe('buildAccessModel', () => {
65
+ test('create: no roles ticked, every reachable node ticked, nothing locked', () => {
66
+ const m = buildAccessModel(ASSIGNABLE, null);
67
+ assert.deepEqual(m.initialRoles, []);
68
+ assert.deepEqual(m.initialScope, ['n-acme', 'n-east', 'n-east-1']);
69
+ assert.equal(lockedCount(m.roleOptions) + lockedCount(m.scopeOptions), 0);
70
+ });
71
+
72
+ test('edit: assignable items ticked; out-of-ceiling items appended LOCKED with their names', () => {
73
+ const m = buildAccessModel(ASSIGNABLE, ACCOUNT);
74
+ assert.deepEqual(m.initialRoles, ['r-ingest']);
75
+ assert.deepEqual(m.initialScope, ['n-east']);
76
+ const lockedRole = m.roleOptions.find((o) => o.locked);
77
+ assert.deepEqual(lockedRole, {
78
+ id: 'r-finance',
79
+ name: 'Finance Admin',
80
+ depth: 0,
81
+ locked: true,
82
+ });
83
+ const lockedNode = m.scopeOptions.find((o) => o.locked);
84
+ assert.equal(lockedNode.name, 'Beta');
85
+ });
86
+
87
+ test('edit by root (everything assignable): nothing locked', () => {
88
+ const all = {
89
+ roles: [...ASSIGNABLE.roles, { id: 'r-finance', name: 'Finance Admin' }],
90
+ scopeNodes: [...ASSIGNABLE.scopeNodes, { id: 'n-beta', name: 'Beta', parentId: null }],
91
+ };
92
+ const m = buildAccessModel(all, ACCOUNT);
93
+ assert.equal(lockedCount(m.roleOptions) + lockedCount(m.scopeOptions), 0);
94
+ assert.deepEqual(m.initialRoles.sort(), ['r-finance', 'r-ingest']);
95
+ });
96
+ });
97
+
98
+ describe('validation + warnings', () => {
99
+ test('a name and at least one scope node are required', () => {
100
+ assert.deepEqual(validateAccess({ label: ' ', scope: [] }), {
101
+ label: 'Give the service account a name.',
102
+ scope: SCOPE_REQUIRED,
103
+ });
104
+ assert.deepEqual(validateAccess({ label: 'Bot', scope: ['n-acme'] }), {});
105
+ });
106
+ test('an unsent (unchanged) scope is not validated — rename of an all-locked agent still works', () => {
107
+ assert.deepEqual(validateAccess({ label: 'Bot', scope: [], requireScope: false }), {});
108
+ });
109
+ test('no-roles warning only when nothing is ticked AND nothing is locked', () => {
110
+ const create = buildAccessModel(ASSIGNABLE, null);
111
+ assert.equal(hasNoRoles([], create.roleOptions), true);
112
+ const edit = buildAccessModel(ASSIGNABLE, ACCOUNT);
113
+ assert.equal(hasNoRoles([], edit.roleOptions), false); // the locked role still applies
114
+ });
115
+ });
116
+
117
+ describe('request bodies never carry locked items', () => {
118
+ test('createBody sends trimmed name + roles + scope', () => {
119
+ assert.deepEqual(createBody({ label: ' Bot ', roles: ['r-ingest'], scope: ['n-acme'] }), {
120
+ label: 'Bot',
121
+ roleIds: ['r-ingest'],
122
+ scopeSubtreeNodeIds: ['n-acme'],
123
+ });
124
+ });
125
+
126
+ test('editPatch: unchanged → {}; only changed fields are sent; locked ids never appear', () => {
127
+ const m = buildAccessModel(ASSIGNABLE, ACCOUNT);
128
+ const base = { account: ACCOUNT, initialRoles: m.initialRoles, initialScope: m.initialScope };
129
+ assert.deepEqual(
130
+ editPatch({ ...base, label: 'Ingest Bot', roles: ['r-ingest'], scope: ['n-east'] }),
131
+ {},
132
+ );
133
+ assert.deepEqual(
134
+ editPatch({ ...base, label: 'Renamed', roles: ['r-ingest'], scope: ['n-east'] }),
135
+ {
136
+ label: 'Renamed',
137
+ },
138
+ );
139
+ const p = editPatch({ ...base, label: 'Ingest Bot', roles: [], scope: ['n-east', 'n-acme'] });
140
+ assert.deepEqual(p, { roleIds: [], scopeSubtreeNodeIds: ['n-east', 'n-acme'] });
141
+ assert.ok(!JSON.stringify(p).includes('r-finance'));
142
+ assert.ok(!JSON.stringify(p).includes('n-beta'));
143
+ });
144
+
145
+ test('sameIds is order-insensitive', () => {
146
+ assert.equal(sameIds(['a', 'b'], ['b', 'a']), true);
147
+ assert.equal(sameIds(['a'], ['a', 'b']), false);
148
+ });
149
+ });
150
+
151
+ describe('misc', () => {
152
+ test('fieldOfServerError routes 400s to the right field', () => {
153
+ assert.equal(fieldOfServerError('choose at least one scope node'), 'scope');
154
+ assert.equal(fieldOfServerError('label cannot be empty'), 'label');
155
+ assert.equal(fieldOfServerError('no fields to update'), null);
156
+ });
157
+ test('summarizeNames: first 3 + overflow count', () => {
158
+ assert.deepEqual(summarizeNames(['a', 'b', 'c', 'd', 'e']), {
159
+ shown: ['a', 'b', 'c'],
160
+ more: 2,
161
+ });
162
+ assert.deepEqual(summarizeNames(undefined), { shown: [], more: 0 });
163
+ });
164
+ });
165
+
166
+ describe('key expiry (the server requires an expiry OR an explicit neverExpires)', () => {
167
+ const NOW = Date.UTC(2026, 0, 1);
168
+ const DAY = 24 * 60 * 60 * 1000;
169
+
170
+ test('every choice maps to exactly one of expiresAtMs (future) / neverExpires', () => {
171
+ for (const c of KEY_EXPIRY_CHOICES) {
172
+ const body = keyExpiryRequest(c.value, NOW);
173
+ const keys = Object.keys(body);
174
+ assert.equal(keys.length, 1, c.value);
175
+ if ('expiresAtMs' in body) assert.ok(body.expiresAtMs > NOW, c.value);
176
+ else assert.deepEqual(body, { neverExpires: true });
177
+ }
178
+ });
179
+
180
+ test('day choices land N days ahead; the default is a finite expiry', () => {
181
+ assert.deepEqual(keyExpiryRequest('30', NOW), { expiresAtMs: NOW + 30 * DAY });
182
+ assert.deepEqual(keyExpiryRequest('never', NOW), { neverExpires: true });
183
+ assert.ok('expiresAtMs' in keyExpiryRequest(DEFAULT_KEY_EXPIRY, NOW));
184
+ });
185
+
186
+ test('an unknown choice falls back to the default (never silently non-expiring)', () => {
187
+ assert.deepEqual(keyExpiryRequest('bogus', NOW), keyExpiryRequest(DEFAULT_KEY_EXPIRY, NOW));
188
+ });
189
+ });
@@ -185,7 +185,9 @@ export default function MembershipCard({
185
185
  size="sm"
186
186
  onClick={onToggleActive}
187
187
  disabled={isActive || activating || saving}
188
- title={isActive ? 'This is the active login company' : 'Make this the active login company'}
188
+ title={
189
+ isActive ? 'This is the active login company' : 'Make this the active login company'
190
+ }
189
191
  >
190
192
  {activating ? (
191
193
  <Loader2 className="size-4 animate-spin" />
@@ -115,20 +115,14 @@ export default function PostingRow({
115
115
 
116
116
  // Department / Branch: small, per-company catalogs (passed in). The SAME control in
117
117
  // client-filter mode — `fetchPage` returns the whole list once; the panel filters locally.
118
- const fetchDepartments = useCallback(
119
- async () => {
120
- const options = (departments || []).map((d) => ({ value: d.uuid, label: d.name }));
121
- return { options, total: options.length, page: 1 };
122
- },
123
- [departments],
124
- );
125
- const fetchBranches = useCallback(
126
- async () => {
127
- const options = (branches || []).map((b) => ({ value: b.uuid, label: b.name }));
128
- return { options, total: options.length, page: 1 };
129
- },
130
- [branches],
131
- );
118
+ const fetchDepartments = useCallback(async () => {
119
+ const options = (departments || []).map((d) => ({ value: d.uuid, label: d.name }));
120
+ return { options, total: options.length, page: 1 };
121
+ }, [departments]);
122
+ const fetchBranches = useCallback(async () => {
123
+ const options = (branches || []).map((b) => ({ value: b.uuid, label: b.name }));
124
+ return { options, total: options.length, page: 1 };
125
+ }, [branches]);
132
126
 
133
127
  // Shared field controls — reused by both the desktop row and the mobile card.
134
128
  const roleSelect = (
@@ -144,19 +144,13 @@ export default function UsersPage() {
144
144
  load();
145
145
  }, [load]);
146
146
 
147
- const onSearchChange = useCallback(
148
- (search) => setQuery((q) => ({ ...q, search, page: 1 })),
149
- [],
150
- );
147
+ const onSearchChange = useCallback((search) => setQuery((q) => ({ ...q, search, page: 1 })), []);
151
148
  const onSortChange = useCallback(
152
149
  (sortBy, sortOrder) => setQuery((q) => ({ ...q, sortBy, sortOrder })),
153
150
  [],
154
151
  );
155
152
  const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
156
- const onLimitChange = useCallback(
157
- (limit) => setQuery((q) => ({ ...q, limit, page: 1 })),
158
- [],
159
- );
153
+ const onLimitChange = useCallback((limit) => setQuery((q) => ({ ...q, limit, page: 1 })), []);
160
154
 
161
155
  const onConfirmDelete = async () => {
162
156
  if (!deleteTarget) return;
@@ -219,7 +213,11 @@ export default function UsersPage() {
219
213
 
220
214
  <ConfirmDialog
221
215
  open={!!deleteTarget}
222
- title={deleteTarget ? `Delete “${deleteTarget.fields?.username || 'this user'}”?` : 'Delete user?'}
216
+ title={
217
+ deleteTarget
218
+ ? `Delete “${deleteTarget.fields?.username || 'this user'}”?`
219
+ : 'Delete user?'
220
+ }
223
221
  message="This permanently removes the user and all their company memberships. This cannot be undone."
224
222
  confirmLabel="Delete"
225
223
  destructive
@@ -3,6 +3,11 @@
3
3
  * contract. Uses the browser `fetch`, same-origin `/api` (proxied by Vite to the
4
4
  * server's /auth/* routes). A mobile app provides its OWN adapter (RN fetch + its
5
5
  * base URL) implementing the same shape; the shared logic is identical for both.
6
+ *
7
+ * This is the RAW transport: it does not refresh tokens. AuthContext wraps it with
8
+ * @astram/frontend-core's `createRefreshingHttp` (single-flight refresh on a 401, one retry,
9
+ * cross-tab lock) and hands only the wrapped client to the services and the shield
10
+ * transport. Use the raw client directly only for the refresh call itself.
6
11
  */
7
12
 
8
13
  const BASE = '/api';
@@ -2,28 +2,70 @@
2
2
  * The WEB session store — the platform half of @astram/frontend-core's SessionStore contract,
3
3
  * backed by localStorage. A mobile app provides its own (AsyncStorage). The shared
4
4
  * session helpers never touch storage directly; the app injects this.
5
+ *
6
+ * localStorage is shared by every tab of the app, so a token rotation or logout in one tab
7
+ * is visible to the others: `createRefreshingHttp` re-reads it inside its cross-tab lock,
8
+ * and `subscribe` turns the browser's `storage` event (fired in the OTHER tabs) into a
9
+ * session-change callback for AuthContext.
10
+ *
11
+ * Token storage note (D11): the refresh token sits in localStorage, readable by any script
12
+ * on the origin, which is why index.html ships a strict Content-Security-Policy. Moving the
13
+ * refresh token to an HttpOnly cookie is a planned follow-up ADR.
5
14
  */
6
15
 
7
- const KEY = 'app-session';
16
+ export const SESSION_STORAGE_KEY = 'app-session';
17
+
18
+ /**
19
+ * @param {{ storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>,
20
+ * events?: Pick<EventTarget, 'addEventListener' | 'removeEventListener'> }} [deps]
21
+ * Injected for tests; default to the browser's `localStorage` and `window`.
22
+ * @returns {import('@astram/frontend-core').SessionStore & {
23
+ * subscribe: (listener: (state: object | null) => void) => () => void }}
24
+ */
25
+ export function createWebSessionStore(deps = {}) {
26
+ const storage = () => deps.storage ?? globalThis.localStorage;
27
+ const events = () => deps.events ?? globalThis.window;
28
+
29
+ const load = () => {
30
+ try {
31
+ const raw = storage().getItem(SESSION_STORAGE_KEY);
32
+ return raw ? JSON.parse(raw) : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ };
8
37
 
9
- /** @returns {import('@astram/frontend-core').SessionStore} */
10
- export function createWebSessionStore() {
11
38
  return {
12
- load() {
13
- try {
14
- const raw = localStorage.getItem(KEY);
15
- return raw ? JSON.parse(raw) : null;
16
- } catch {
17
- return null;
18
- }
19
- },
39
+ load,
20
40
  save(state) {
21
41
  try {
22
- if (state) localStorage.setItem(KEY, JSON.stringify(state));
23
- else localStorage.removeItem(KEY);
42
+ if (state) storage().setItem(SESSION_STORAGE_KEY, JSON.stringify(state));
43
+ else storage().removeItem(SESSION_STORAGE_KEY);
24
44
  } catch {
25
45
  /* storage unavailable — session stays in memory only */
26
46
  }
27
47
  },
48
+ /**
49
+ * Call `listener(state | null)` when ANOTHER tab changes the session (rotation, logout,
50
+ * a different sign-in). Returns an unsubscribe function.
51
+ */
52
+ subscribe(listener) {
53
+ const target = events();
54
+ if (!target || typeof target.addEventListener !== 'function') return () => {};
55
+ const onStorage = (event) => {
56
+ // `key === null` means the other tab called localStorage.clear().
57
+ if (event.key !== SESSION_STORAGE_KEY && event.key !== null) return;
58
+ let area;
59
+ try {
60
+ area = storage();
61
+ } catch {
62
+ return;
63
+ }
64
+ if (event.storageArea && event.storageArea !== area) return;
65
+ listener(load());
66
+ };
67
+ target.addEventListener('storage', onStorage);
68
+ return () => target.removeEventListener('storage', onStorage);
69
+ },
28
70
  };
29
71
  }
@@ -0,0 +1,89 @@
1
+ // sessionStore.test.mjs — the web session store's cross-tab half (#12): a logout or a
2
+ // token rotation in one tab must reach the others through the `storage` event.
3
+ //
4
+ // node --test src/platform/sessionStore.test.mjs
5
+ //
6
+ // Storage and the event target are injected, so this runs under plain Node.
7
+ import { test, describe } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { createWebSessionStore, SESSION_STORAGE_KEY } from './sessionStore.js';
10
+
11
+ function memoryStorage() {
12
+ const m = new Map();
13
+ return {
14
+ getItem: (k) => (m.has(k) ? m.get(k) : null),
15
+ setItem: (k, v) => m.set(k, String(v)),
16
+ removeItem: (k) => m.delete(k),
17
+ };
18
+ }
19
+
20
+ function eventTarget() {
21
+ const listeners = new Set();
22
+ return {
23
+ addEventListener: (type, fn) => type === 'storage' && listeners.add(fn),
24
+ removeEventListener: (type, fn) => type === 'storage' && listeners.delete(fn),
25
+ fire: (event) => [...listeners].forEach((fn) => fn(event)),
26
+ get size() {
27
+ return listeners.size;
28
+ },
29
+ };
30
+ }
31
+
32
+ const fake = (label) => `fake-${label}`;
33
+ const STATE = {
34
+ session: { sessionId: 's1' },
35
+ tokens: { accessToken: fake('a2'), refreshToken: fake('r2') },
36
+ };
37
+
38
+ describe('createWebSessionStore.subscribe — cross-tab session changes', () => {
39
+ test('another tab rotated the tokens → listener gets the new state', () => {
40
+ const storage = memoryStorage();
41
+ const events = eventTarget();
42
+ const store = createWebSessionStore({ storage, events });
43
+ const seen = [];
44
+ store.subscribe((s) => seen.push(s));
45
+
46
+ storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(STATE)); // the other tab's write
47
+ events.fire({ key: SESSION_STORAGE_KEY, storageArea: storage });
48
+
49
+ assert.deepEqual(seen, [STATE]);
50
+ });
51
+
52
+ test('another tab logged out (key removed, or storage cleared) → listener gets null', () => {
53
+ const storage = memoryStorage();
54
+ const events = eventTarget();
55
+ const store = createWebSessionStore({ storage, events });
56
+ const seen = [];
57
+ store.subscribe((s) => seen.push(s));
58
+
59
+ events.fire({ key: SESSION_STORAGE_KEY, storageArea: storage });
60
+ events.fire({ key: null, storageArea: storage }); // localStorage.clear()
61
+
62
+ assert.deepEqual(seen, [null, null]);
63
+ });
64
+
65
+ test('unrelated keys and other storage areas are ignored; unsubscribe detaches', () => {
66
+ const storage = memoryStorage();
67
+ const events = eventTarget();
68
+ const store = createWebSessionStore({ storage, events });
69
+ const seen = [];
70
+ const off = store.subscribe((s) => seen.push(s));
71
+
72
+ events.fire({ key: 'app-return-path', storageArea: storage });
73
+ events.fire({ key: SESSION_STORAGE_KEY, storageArea: memoryStorage() }); // sessionStorage
74
+ assert.deepEqual(seen, []);
75
+
76
+ off();
77
+ assert.equal(events.size, 0);
78
+ });
79
+
80
+ test('load/save round-trip through the injected storage', () => {
81
+ const storage = memoryStorage();
82
+ const store = createWebSessionStore({ storage, events: eventTarget() });
83
+ assert.equal(store.load(), null);
84
+ store.save(STATE);
85
+ assert.deepEqual(store.load(), STATE);
86
+ store.save(null);
87
+ assert.equal(store.load(), null);
88
+ });
89
+ });
@@ -22,6 +22,12 @@
22
22
  * deny-everything projection past a token refresh. Treated as TRANSIENT (throw), so
23
23
  * the engine holds its last-good/empty projection and the NEXT session-established
24
24
  * fetch (post-refresh/re-login) supersedes it, rather than baking in a deny.
25
+ * AuthContext hands this transport the REFRESHING http client (`createRefreshingHttp`,
26
+ * #12), so an expired access token is refreshed and the call retried BEFORE a status
27
+ * reaches here: a 401 seen here means the refresh itself failed (the session is being
28
+ * cleared → login) or the session was replaced, and a refresh outage arrives as a 503.
29
+ * Never build this transport over the raw client: a 401 would then be retried forever
30
+ * as "transient" instead of refreshing.
25
31
  * - a real 403 (or any other non-5xx !ok) → TERMINAL deny → return an EMPTY projection
26
32
  * `{ principalId:'', facts:[], projectedAt: Date.now() }` (deny-all).
27
33
  * - 200 with a body → return `r.data` (the projection) AS-IS.