create-astram-app 0.11.0 → 0.12.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 (68) hide show
  1. package/dist/index.js +98 -4
  2. package/dist/index.js.map +1 -1
  3. package/package.json +6 -1
  4. package/template/frontend/_env.example +7 -0
  5. package/template/frontend/package.json +2 -2
  6. package/template/frontend/src/App.jsx +24 -0
  7. package/template/frontend/src/auth/AuthContext.jsx +73 -30
  8. package/template/frontend/src/auth/CaptchaWidget.jsx +108 -0
  9. package/template/frontend/src/auth/captchaProvider.js +23 -0
  10. package/template/frontend/src/auth/captchaProvider.test.mjs +44 -0
  11. package/template/frontend/src/auth/captchaRender.js +125 -0
  12. package/template/frontend/src/auth/captchaRender.test.mjs +182 -0
  13. package/template/frontend/src/auth/passwordVisibility.js +9 -0
  14. package/template/frontend/src/auth/passwordVisibility.test.mjs +12 -0
  15. package/template/frontend/src/auth/reenrollWording.js +29 -0
  16. package/template/frontend/src/auth/reenrollWording.test.mjs +23 -0
  17. package/template/frontend/src/auth/routeGate.js +10 -0
  18. package/template/frontend/src/auth/routeGate.test.mjs +22 -1
  19. package/template/frontend/src/auth/sessionFlow.js +80 -4
  20. package/template/frontend/src/auth/sessionFlow.test.mjs +212 -1
  21. package/template/frontend/src/auth/signInOptions.js +64 -0
  22. package/template/frontend/src/auth/signInOptions.test.mjs +94 -0
  23. package/template/frontend/src/auth/useSignInOptions.js +34 -0
  24. package/template/frontend/src/pages/ForgotPassword.jsx +49 -3
  25. package/template/frontend/src/pages/Login.jsx +54 -4
  26. package/template/frontend/src/pages/ResetPassword.jsx +83 -11
  27. package/template/frontend/src/pages/account/ReenrollPage.jsx +17 -11
  28. package/template/frontend/src/pages/account/TwoFactorPage.jsx +9 -6
  29. package/template/frontend/src/pages/audit/AuditPage.jsx +17 -3
  30. package/template/frontend/src/pages/audit/actorKind.js +17 -0
  31. package/template/frontend/src/pages/audit/actorKind.test.mjs +23 -0
  32. package/template/frontend/src/pages/companies/CompaniesPage.jsx +104 -4
  33. package/template/frontend/src/pages/companies/moveAction.js +61 -0
  34. package/template/frontend/src/pages/companies/moveAction.test.mjs +91 -0
  35. package/template/frontend/src/pages/org-structure/OrgStructurePage.jsx +408 -0
  36. package/template/frontend/src/pages/org-structure/orgStructure.js +140 -0
  37. package/template/frontend/src/pages/org-structure/orgStructure.test.mjs +199 -0
  38. package/template/frontend/src/pages/roles/RoleForm.jsx +32 -14
  39. package/template/frontend/src/pages/roles/RolesPage.jsx +167 -42
  40. package/template/frontend/src/pages/roles/roleRules.js +104 -0
  41. package/template/frontend/src/pages/roles/roleRules.test.mjs +178 -0
  42. package/template/frontend/src/pages/security/PolicyFormDialog.jsx +243 -0
  43. package/template/frontend/src/pages/security/SecuritySettingsPage.jsx +229 -0
  44. package/template/frontend/src/pages/security/securityPolicies.js +246 -0
  45. package/template/frontend/src/pages/security/securityPolicies.test.mjs +429 -0
  46. package/template/frontend/src/pages/users/MembershipCard.jsx +5 -2
  47. package/template/frontend/src/pages/users/PostingRow.jsx +13 -10
  48. package/template/frontend/src/pages/users/SignInActionDialog.jsx +98 -0
  49. package/template/frontend/src/pages/users/UserForm.jsx +65 -3
  50. package/template/frontend/src/pages/users/UsersPage.jsx +91 -3
  51. package/template/frontend/src/pages/users/loginName.js +108 -0
  52. package/template/frontend/src/pages/users/loginName.test.mjs +133 -0
  53. package/template/frontend/src/pages/users/signInAdmin.js +122 -0
  54. package/template/frontend/src/pages/users/signInAdmin.test.mjs +183 -0
  55. package/template/frontend/src/platform/httpClient.js +40 -28
  56. package/template/frontend/src/platform/httpClient.test.mjs +71 -0
  57. package/template/frontend/src/platform/shieldTransport.js +12 -12
  58. package/template/frontend/src/shell/AppShell.jsx +4 -0
  59. package/template/frontend/src/shell/GraceBanner.jsx +28 -0
  60. package/template/frontend/src/shell/graceDeadline.js +50 -0
  61. package/template/frontend/src/shell/graceDeadline.test.mjs +59 -0
  62. package/template/frontend/src/shell/nav.js +30 -1
  63. package/template/frontend/src/viteConfig.test.mjs +63 -0
  64. package/template/frontend/vite/captchaCsp.js +99 -0
  65. package/template/frontend/vite.config.js +14 -3
  66. package/template/server/_env.example +25 -0
  67. package/template/server/package.json +9 -6
  68. package/template/server/src/app.module.ts +17 -6
@@ -0,0 +1,183 @@
1
+ // signInAdmin.test.mjs — auth capabilities R11.1–R11.3, R13.4, AC-27: the Users page "2FA"
2
+ // column and the admin sign-in actions (reset 2FA, unlock, end sessions), each behind its
3
+ // permission, hidden on the caller's own row, each asking for the admin's own 6-digit code.
4
+ // UX only: the server decides every action.
5
+ //
6
+ // node --test src/pages/users/signInAdmin.test.mjs
7
+ import { test, describe } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import {
10
+ SIGN_IN_ACTIONS,
11
+ mfaStatusLabel,
12
+ mfaStatusVariant,
13
+ visibleSignInActions,
14
+ normalizeAdminCode,
15
+ isAdminCode,
16
+ signInActionOutcome,
17
+ } from './signInAdmin.js';
18
+
19
+ const ROW = { id: 'u-2', fields: { username: 'thabo' }, mfaStatus: 'required' };
20
+ const ALL = () => true;
21
+
22
+ describe('the 2FA column', () => {
23
+ test('labels for every server status', () => {
24
+ assert.equal(mfaStatusLabel('enrolled'), 'Enrolled');
25
+ assert.equal(mfaStatusLabel('required'), 'Required');
26
+ assert.equal(mfaStatusLabel('grace'), 'Grace period');
27
+ assert.equal(mfaStatusLabel('not_required'), 'Not required');
28
+ });
29
+
30
+ test('absent or unknown status → a dash (server does not compute it)', () => {
31
+ assert.equal(mfaStatusLabel(undefined), '—');
32
+ assert.equal(mfaStatusLabel('ENROLLED'), '—');
33
+ });
34
+
35
+ test('required stands out, enrolled reads as good', () => {
36
+ assert.equal(mfaStatusVariant('enrolled'), 'success');
37
+ assert.equal(mfaStatusVariant('required'), 'destructive');
38
+ assert.equal(mfaStatusVariant('grace'), 'warning');
39
+ assert.equal(mfaStatusVariant('not_required'), 'secondary');
40
+ assert.equal(mfaStatusVariant(undefined), 'secondary');
41
+ });
42
+ });
43
+
44
+ describe('SIGN_IN_ACTIONS', () => {
45
+ test('three actions, each on its own permission and service method', () => {
46
+ assert.deepEqual(
47
+ SIGN_IN_ACTIONS.map((a) => [a.key, a.permission, a.method, a.label]),
48
+ [
49
+ ['reset_mfa', 'identity:user:reset_mfa', 'resetUserMfa', 'Reset 2FA'],
50
+ ['unlock', 'identity:user:unlock', 'unlockUser', 'Unlock'],
51
+ ['end_sessions', 'identity:user:end_sessions', 'endUserSessions', 'End sessions'],
52
+ ],
53
+ );
54
+ });
55
+ });
56
+
57
+ describe('visibleSignInActions', () => {
58
+ test('all three for a caller holding all three keys', () => {
59
+ assert.deepEqual(
60
+ visibleSignInActions(ROW, { can: ALL, selfIds: ['me'] }).map((a) => a.key),
61
+ ['reset_mfa', 'unlock', 'end_sessions'],
62
+ );
63
+ });
64
+
65
+ test('each action only with its own key', () => {
66
+ const can = (k) => k === 'identity:user:unlock';
67
+ assert.deepEqual(
68
+ visibleSignInActions(ROW, { can, selfIds: [] }).map((a) => a.key),
69
+ ['unlock'],
70
+ );
71
+ assert.deepEqual(visibleSignInActions(ROW, { can: () => false, selfIds: [] }), []);
72
+ });
73
+
74
+ test("never on the signed-in user's own row (the server refuses self)", () => {
75
+ assert.deepEqual(visibleSignInActions(ROW, { can: ALL, selfIds: [null, 'u-2'] }), []);
76
+ assert.deepEqual(visibleSignInActions({ id: 'me' }, { can: ALL, selfIds: ['me'] }), []);
77
+ });
78
+
79
+ test('no row → nothing', () => {
80
+ assert.deepEqual(visibleSignInActions(null, { can: ALL, selfIds: [] }), []);
81
+ });
82
+ });
83
+
84
+ describe('the admin code', () => {
85
+ test('spaces are dropped; exactly six digits is a code', () => {
86
+ assert.equal(normalizeAdminCode(' 123 456 '), '123456');
87
+ assert.equal(isAdminCode('123456'), true);
88
+ for (const bad of ['12345', '1234567', '12a456', '', undefined]) {
89
+ assert.equal(isAdminCode(bad), false);
90
+ }
91
+ });
92
+ });
93
+
94
+ describe('signInActionOutcome', () => {
95
+ const reset = SIGN_IN_ACTIONS[0];
96
+ const unlock = SIGN_IN_ACTIONS[1];
97
+ const end = SIGN_IN_ACTIONS[2];
98
+
99
+ test('success → a per-action toast naming the user, dialog closes', () => {
100
+ assert.deepEqual(signInActionOutcome(reset, { ok: true }, 'thabo'), {
101
+ kind: 'success',
102
+ text: '2FA reset for thabo. They will set it up again at their next sign-in if required.',
103
+ close: true,
104
+ });
105
+ assert.equal(
106
+ signInActionOutcome(unlock, { ok: true }, 'thabo').text,
107
+ 'thabo can sign in again.',
108
+ );
109
+ assert.equal(
110
+ signInActionOutcome(end, { ok: true }, 'thabo').text,
111
+ 'All of thabo’s sessions were ended.',
112
+ );
113
+ });
114
+
115
+ test('admin without 2FA → set it up on your own account, dialog closes', () => {
116
+ assert.deepEqual(
117
+ signInActionOutcome(reset, { ok: false, status: 409, reason: 'admin_needs_mfa', error: 'x' }),
118
+ {
119
+ kind: 'error',
120
+ text: 'Set up two-step verification on your own account first.',
121
+ close: true,
122
+ },
123
+ );
124
+ });
125
+
126
+ test('partly applied → "Partly done — try again", dialog stays for a retry', () => {
127
+ assert.deepEqual(
128
+ signInActionOutcome(reset, {
129
+ ok: false,
130
+ status: 503,
131
+ reason: 'partially_applied',
132
+ error: 'x',
133
+ }),
134
+ { kind: 'error', text: 'Partly done — try again.', close: false },
135
+ );
136
+ });
137
+
138
+ test('wrong or missing code stays open for a new code', () => {
139
+ const invalid = signInActionOutcome(unlock, { ok: false, reason: 'invalid_code', error: 'x' });
140
+ assert.equal(invalid.close, false);
141
+ assert.equal(
142
+ invalid.text,
143
+ 'That code is not valid. Enter a new code from your authenticator app.',
144
+ );
145
+ const missing = signInActionOutcome(unlock, { ok: false, reason: 'code_required', error: 'x' });
146
+ assert.equal(missing.close, false);
147
+ assert.equal(missing.text, 'Enter the 6-digit code from your authenticator app.');
148
+ });
149
+
150
+ test('rate limited, forbidden, not found close with their fixed text', () => {
151
+ assert.deepEqual(signInActionOutcome(end, { ok: false, reason: 'rate_limited', error: 'x' }), {
152
+ kind: 'error',
153
+ text: 'Too many code attempts. Wait a few minutes and try again.',
154
+ close: true,
155
+ });
156
+ assert.deepEqual(signInActionOutcome(end, { ok: false, reason: 'forbidden', error: 'x' }), {
157
+ kind: 'error',
158
+ text: 'You cannot do this for this user.',
159
+ close: true,
160
+ });
161
+ assert.equal(
162
+ signInActionOutcome(end, { ok: false, reason: 'not_found', error: 'x' }).close,
163
+ true,
164
+ );
165
+ });
166
+
167
+ test('unavailable / unreachable / unknown → the service message, stays open', () => {
168
+ const r = signInActionOutcome(end, {
169
+ ok: false,
170
+ reason: 'unavailable',
171
+ error: 'The service is temporarily unavailable. Nothing was changed; please try again.',
172
+ });
173
+ assert.deepEqual(r, {
174
+ kind: 'error',
175
+ text: 'The service is temporarily unavailable. Nothing was changed; please try again.',
176
+ close: false,
177
+ });
178
+ assert.equal(
179
+ signInActionOutcome(end, { ok: false, reason: 'failed' }).text,
180
+ 'The action failed. Please try again.',
181
+ );
182
+ });
183
+ });
@@ -10,6 +10,8 @@
10
10
  * transport. Use the raw client directly only for the refresh call itself.
11
11
  */
12
12
 
13
+ import { restrictionReasonOf } from '@astram/frontend-core';
14
+
13
15
  const BASE = '/api';
14
16
 
15
17
  async function toResponse(res) {
@@ -31,38 +33,48 @@ function withSignal(opts) {
31
33
  }
32
34
 
33
35
  /**
34
- * The app-wide enforced-re-enroll interceptor. When a request is rejected with a 403
35
- * whose body is EXACTLY `{ error: 'reenrollment_required' }` (the server's re-enroll
36
- * gate, §4), fire the supplied callback so the app can force the re-enroll screen.
37
- * Matched narrowly on that single error code so an unrelated future permission-deny 403
38
- * never trips it. Best-effort: a throwing callback must not break the HTTP result.
36
+ * The restriction a response carries (R2.3, R15.2), or null:
37
+ * - a 403 whose body is EXACTLY one of the restriction codes (`reenrollment_required`,
38
+ * `mfa_enrollment_required`), read by frontend-core's `restrictionReasonOf`, on any call;
39
+ * - the restricted `/identity/auth-facts` answer (200, `restricted: <reason>`), so a password
40
+ * sign-in into a restricted session is routed before any gated call is made.
41
+ * Matched narrowly so an unrelated permission-deny 403 never trips it.
39
42
  */
40
- function maybeSignalReenroll(result, onReenrollRequired) {
41
- if (
42
- typeof onReenrollRequired === 'function' &&
43
- result.status === 403 &&
44
- result.data &&
45
- result.data.error === 'reenrollment_required'
46
- ) {
47
- try {
48
- onReenrollRequired();
49
- } catch {
50
- /* the signal is best-effort — never let it break the request result */
51
- }
43
+ function restrictionOf(path, result) {
44
+ if (result.status === 403) return restrictionReasonOf(result.data);
45
+ if (result.ok && path.split('?')[0] === '/identity/auth-facts') {
46
+ const reason = result.data && result.data.restricted;
47
+ return reason === 'reenroll' || reason === 'mfa_enrollment' ? reason : null;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * The app-wide restriction interceptor: fire the callbacks so the app can force the enrolment
54
+ * screen. Best-effort: a throwing callback must not break the HTTP result.
55
+ */
56
+ function maybeSignalRestriction(path, result, { onRestricted, onReenrollRequired }) {
57
+ const reason = restrictionOf(path, result);
58
+ if (reason === null) return result;
59
+ try {
60
+ if (typeof onRestricted === 'function') onRestricted(reason);
61
+ if (reason === 'reenroll' && typeof onReenrollRequired === 'function') onReenrollRequired();
62
+ } catch {
63
+ /* the signal is best-effort — never let it break the request result */
52
64
  }
53
65
  return result;
54
66
  }
55
67
 
56
68
  /**
57
- * @param {{ onReenrollRequired?: () => void }} [config]
58
- * `onReenrollRequired` — invoked when any response is a 403 `reenrollment_required`
59
- * (the enforced-re-enroll gate). The consumer (AuthContext) uses it to flip the app
60
- * into the forced re-enroll flow app-wide, regardless of which call triggered it.
69
+ * @param {{ onRestricted?: (reason: 'reenroll' | 'mfa_enrollment') => void,
70
+ * onReenrollRequired?: () => void }} [config]
71
+ * `onRestricted` — invoked when a response says the session is restricted (see
72
+ * `restrictionOf`). The consumer (AuthContext) uses it to flip the app into the forced
73
+ * enrolment flow app-wide, regardless of which call triggered it.
74
+ * `onReenrollRequired` — the older callback, for `reenroll` only.
61
75
  * @returns {import('@astram/frontend-core').HttpClient}
62
76
  */
63
77
  export function createWebHttpClient(config = {}) {
64
- const { onReenrollRequired } = config;
65
-
66
78
  return {
67
79
  async post(path, body, opts = {}) {
68
80
  try {
@@ -75,7 +87,7 @@ export function createWebHttpClient(config = {}) {
75
87
  body: JSON.stringify(body),
76
88
  ...withSignal(opts),
77
89
  });
78
- return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
90
+ return maybeSignalRestriction(path, await toResponse(res), config);
79
91
  } catch {
80
92
  return { ok: false, status: 0, data: null };
81
93
  }
@@ -91,7 +103,7 @@ export function createWebHttpClient(config = {}) {
91
103
  body: JSON.stringify(body),
92
104
  ...withSignal(opts),
93
105
  });
94
- return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
106
+ return maybeSignalRestriction(path, await toResponse(res), config);
95
107
  } catch {
96
108
  return { ok: false, status: 0, data: null };
97
109
  }
@@ -107,7 +119,7 @@ export function createWebHttpClient(config = {}) {
107
119
  body: JSON.stringify(body),
108
120
  ...withSignal(opts),
109
121
  });
110
- return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
122
+ return maybeSignalRestriction(path, await toResponse(res), config);
111
123
  } catch {
112
124
  return { ok: false, status: 0, data: null };
113
125
  }
@@ -123,7 +135,7 @@ export function createWebHttpClient(config = {}) {
123
135
  body: JSON.stringify(body),
124
136
  ...withSignal(opts),
125
137
  });
126
- return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
138
+ return maybeSignalRestriction(path, await toResponse(res), config);
127
139
  } catch {
128
140
  return { ok: false, status: 0, data: null };
129
141
  }
@@ -134,7 +146,7 @@ export function createWebHttpClient(config = {}) {
134
146
  headers: opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {},
135
147
  ...withSignal(opts),
136
148
  });
137
- return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
149
+ return maybeSignalRestriction(path, await toResponse(res), config);
138
150
  } catch {
139
151
  return { ok: false, status: 0, data: null };
140
152
  }
@@ -58,3 +58,74 @@ describe('createWebHttpClient — abort signal forwarding', () => {
58
58
  });
59
59
  }
60
60
  });
61
+
62
+ // R2.3, R13.2, AC-25: both restriction reasons route to the enrolment page. A 403 carries the
63
+ // code on any call; the restricted `/identity/auth-facts` answer (R15.2) carries it in a 200, so
64
+ // a password sign-in into a restricted session is routed before any gated call is made.
65
+ describe('createWebHttpClient — restriction routing', () => {
66
+ const respond = (status, body) => {
67
+ globalThis.fetch = async () => ({ ok: status < 400, status, json: async () => body });
68
+ };
69
+
70
+ test('403 mfa_enrollment_required → onRestricted("mfa_enrollment") on any method', async () => {
71
+ for (const call of [
72
+ (c) => c.get('/identity/users'),
73
+ (c) => c.post('/x', {}),
74
+ (c) => c.patch('/x', {}),
75
+ (c) => c.put('/x', {}),
76
+ (c) => c.delete('/x', {}),
77
+ ]) {
78
+ respond(403, { error: 'mfa_enrollment_required' });
79
+ const reasons = [];
80
+ const r = await call(createWebHttpClient({ onRestricted: (why) => reasons.push(why) }));
81
+ assert.equal(r.status, 403);
82
+ assert.deepEqual(reasons, ['mfa_enrollment']);
83
+ }
84
+ });
85
+
86
+ test('403 reenrollment_required → onRestricted("reenroll") and the old onReenrollRequired', async () => {
87
+ respond(403, { error: 'reenrollment_required' });
88
+ const reasons = [];
89
+ let legacy = 0;
90
+ await createWebHttpClient({
91
+ onRestricted: (why) => reasons.push(why),
92
+ onReenrollRequired: () => legacy++,
93
+ }).get('/x');
94
+ assert.deepEqual(reasons, ['reenroll']);
95
+ assert.equal(legacy, 1);
96
+ });
97
+
98
+ test('the old onReenrollRequired alone is not fired for mfa_enrollment', async () => {
99
+ respond(403, { error: 'mfa_enrollment_required' });
100
+ let legacy = 0;
101
+ await createWebHttpClient({ onReenrollRequired: () => legacy++ }).get('/x');
102
+ assert.equal(legacy, 0);
103
+ });
104
+
105
+ test('any other 403 never routes', async () => {
106
+ respond(403, { error: 'forbidden' });
107
+ const reasons = [];
108
+ await createWebHttpClient({ onRestricted: (why) => reasons.push(why) }).get('/x');
109
+ assert.deepEqual(reasons, []);
110
+ });
111
+
112
+ test('restricted auth-facts (200, restricted: <reason>) routes; other 200s with that key do not', async () => {
113
+ respond(200, { principalId: 'p', facts: [], restricted: 'mfa_enrollment' });
114
+ const reasons = [];
115
+ const c = createWebHttpClient({ onRestricted: (why) => reasons.push(why) });
116
+ await c.get('/identity/auth-facts');
117
+ assert.deepEqual(reasons, ['mfa_enrollment']);
118
+ await c.get('/identity/users?restricted=1');
119
+ assert.deepEqual(reasons, ['mfa_enrollment']);
120
+ });
121
+
122
+ test('a throwing callback never breaks the result', async () => {
123
+ respond(403, { error: 'mfa_enrollment_required' });
124
+ const r = await createWebHttpClient({
125
+ onRestricted: () => {
126
+ throw new Error('boom');
127
+ },
128
+ }).get('/x');
129
+ assert.equal(r.status, 403);
130
+ });
131
+ });
@@ -52,15 +52,15 @@
52
52
  * never throws" fail-closed guarantee that `AuthContext.refreshAuthFacts` relies on. So
53
53
  * this implementation MUST NEVER THROW.
54
54
  *
55
- * Freshness is decided by `cached.permissionsVersion >= current` (client.ts:392) — a
56
- * cache is stale (refetch) whenever `current` is NOT LESS than the cached version. So the
57
- * fail-closed way to say "I can't tell you the real version" is to RETURN a number that
58
- * can never compare `<=` any real cached version: `Number.POSITIVE_INFINITY`. That makes
59
- * every comparison `cached.permissionsVersion >= Infinity` evaluate `false` — always
60
- * stale, always refetch — mirroring the SDK's own NaN-version adversarial test
61
- * (`adversarial.test.ts` "ATTACK 2": a version that can never compare `>=` current is
62
- * always treated as stale, never phantom-fresh). This is fail-closed WITHOUT throwing:
63
- * an unreadable version can never be mistaken for "cache is fresh".
55
+ * Freshness is decided by `cached.permissionsVersion === current` (`isProjectionFresh`;
56
+ * @astram/sdk < 0.2.1 used `>=`): the version is an opaque token (the guard folds root
57
+ * status into it, KI #139), and any difference means stale (refetch). So the fail-closed
58
+ * way to say "I can't tell you the real version" is to RETURN a number no real cached
59
+ * version can match under either rule: `Number.POSITIVE_INFINITY`. Every comparison
60
+ * then reads stale — always refetch — mirroring the SDK's own NaN-version adversarial
61
+ * test (`adversarial.test.ts` "ATTACK 2": an unmatched version is always treated as
62
+ * stale, never phantom-fresh). This is fail-closed WITHOUT throwing: an unreadable
63
+ * version can never be mistaken for "cache is fresh".
64
64
  *
65
65
  * A non-ok probe (status 0 network failure, 401 session concern, >=500 server/store
66
66
  * outage) is exactly this "can't-know" case → returns `Number.POSITIVE_INFINITY` (forces
@@ -146,9 +146,9 @@ export function createWebShieldTransport({ http, getToken }) {
146
146
  * by any try/catch, so a throw here would escape `getAuthFacts` uncaught).
147
147
  *
148
148
  * A non-ok probe (status 0 / 401 / >=500 — the SAME "can't-know" set
149
- * `fetchProjection` treats as transient) returns `Number.POSITIVE_INFINITY`: it can
150
- * never compare `<=` a real cached version, so `cached.permissionsVersion >= current`
151
- * is always `false` — always stale, always refetch. Fail-closed WITHOUT throwing:
149
+ * `fetchProjection` treats as transient) returns `Number.POSITIVE_INFINITY`: no real
150
+ * cached version equals it (or is `>=` it, for an older SDK), so the cache always
151
+ * reads stale — always refetch. Fail-closed WITHOUT throwing:
152
152
  * an unreadable version is never mistaken for "the cache is fresh".
153
153
  *
154
154
  * A malformed 200 body (missing/non-numeric `data.version`) is treated the same
@@ -22,6 +22,7 @@ import {
22
22
  } from '@/components/ui/dropdown-menu';
23
23
  import ThemeToggle from '@/theme/ThemeToggle';
24
24
  import Sidebar from './Sidebar';
25
+ import GraceBanner from './GraceBanner';
25
26
 
26
27
  /** A header icon-action button with a tooltip (ffs pattern). */
27
28
  function IconAction({ icon: Icon, label, dot }) {
@@ -228,6 +229,9 @@ export default function AppShell({ children, title: _title, section: _section })
228
229
  </header>
229
230
  </TooltipProvider>
230
231
 
232
+ {/* 2FA grace period (R2.4): the deadline and a link to set it up. */}
233
+ <GraceBanner />
234
+
231
235
  <main className="engineering-grid flex-1 overflow-y-auto">{children}</main>
232
236
  </div>
233
237
  </div>
@@ -0,0 +1,28 @@
1
+ /**
2
+ * GraceBanner — the 2FA grace-period notice on signed-in pages (R2.4, R13.2, AC-25). Shown while
3
+ * `/auth/me` carries `mfaEnrollmentDeadline`; links to Account → Two-factor; hidden on the
4
+ * enrolment pages. The wording and date formatting live in graceDeadline.js (tested).
5
+ */
6
+ import { Link, useLocation } from 'react-router-dom';
7
+ import { ShieldAlert } from 'lucide-react';
8
+ import { useAuth } from '@/auth/AuthContext';
9
+ import { graceBannerFor } from './graceDeadline';
10
+
11
+ export default function GraceBanner() {
12
+ const { profile } = useAuth();
13
+ const { pathname } = useLocation();
14
+ const banner = graceBannerFor(profile, pathname);
15
+ if (!banner) return null;
16
+ return (
17
+ <div
18
+ role="status"
19
+ className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-[var(--warn)]/40 bg-[var(--warn)]/10 px-4 py-2 text-sm"
20
+ >
21
+ <ShieldAlert className="size-4 shrink-0 text-[var(--warn)]" aria-hidden="true" />
22
+ <span className="text-foreground">{banner.text}</span>
23
+ <Link to={banner.href} className="font-medium text-primary hover:underline">
24
+ {banner.linkLabel}
25
+ </Link>
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,50 @@
1
+ // graceDeadline.js — the 2FA grace-period banner on signed-in pages (R2.4, R13.2, AC-25).
2
+ // `/auth/me` returns `mfaEnrollmentDeadline` (ISO-8601, UTC) while the user is required to set up
3
+ // two-step verification and still in grace. The banner names that deadline in the viewer's local
4
+ // date and time and links to Account → Two-factor. React-free so `node --test` can run it.
5
+
6
+ /** Account → Two-factor (the page where a device is added). */
7
+ export const TWO_FACTOR_PATH = '/account/2fa';
8
+
9
+ // The enrolment pages themselves: no banner there.
10
+ const HIDDEN_ON = new Set([TWO_FACTOR_PATH, '/account/reenroll']);
11
+
12
+ /** `/account/2fa/` and `/account/2fa` are the same page (the router matches both). */
13
+ function withoutTrailingSlash(pathname) {
14
+ const p = typeof pathname === 'string' ? pathname.replace(/\/+$/, '') : '';
15
+ return p === '' ? '/' : p;
16
+ }
17
+
18
+ /**
19
+ * The deadline as a local date and time, or null when missing / unparsable.
20
+ * @param {string | undefined} iso
21
+ * @param {{ locale?: string, timeZone?: string }} [fmt] defaults: the browser's locale and zone
22
+ */
23
+ export function formatDeadline(iso, fmt = {}) {
24
+ if (typeof iso !== 'string' || iso === '') return null;
25
+ const when = new Date(iso);
26
+ if (Number.isNaN(when.getTime())) return null;
27
+ return new Intl.DateTimeFormat(fmt.locale, {
28
+ dateStyle: 'medium',
29
+ timeStyle: 'short',
30
+ ...(fmt.timeZone ? { timeZone: fmt.timeZone } : {}),
31
+ }).format(when);
32
+ }
33
+
34
+ /**
35
+ * The banner for this profile on this page, or null.
36
+ * @param {{ mfaEnrollmentDeadline?: string } | null | undefined} profile from `/auth/me`
37
+ * @param {string} pathname the current route
38
+ * @param {{ locale?: string, timeZone?: string }} [fmt]
39
+ * @returns {{ text: string, href: string, linkLabel: string } | null}
40
+ */
41
+ export function graceBannerFor(profile, pathname, fmt) {
42
+ if (HIDDEN_ON.has(withoutTrailingSlash(pathname))) return null;
43
+ const when = formatDeadline(profile?.mfaEnrollmentDeadline, fmt);
44
+ if (when === null) return null;
45
+ return {
46
+ text: `Your organisation requires two-step verification. Set it up before ${when}.`,
47
+ href: TWO_FACTOR_PATH,
48
+ linkLabel: 'Set up two-step verification',
49
+ };
50
+ }
@@ -0,0 +1,59 @@
1
+ // graceDeadline.test.mjs — in a 2FA grace period the signed-in pages show the deadline from
2
+ // `/auth/me` (R2.4, R13.2, AC-25). Hidden on the enrolment pages themselves.
3
+ //
4
+ // node --test src/shell/graceDeadline.test.mjs
5
+ import { test, describe } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { TWO_FACTOR_PATH, formatDeadline, graceBannerFor } from './graceDeadline.js';
8
+
9
+ const fmt = { locale: 'en-GB', timeZone: 'UTC' };
10
+ const deadline = '2026-10-20T09:30:00.000Z';
11
+
12
+ describe('formatDeadline', () => {
13
+ test('local date and time in the given locale / zone', () => {
14
+ assert.equal(formatDeadline(deadline, fmt), '20 Oct 2026, 09:30');
15
+ });
16
+ test('another zone shifts the time', () => {
17
+ assert.equal(
18
+ formatDeadline(deadline, { locale: 'en-GB', timeZone: 'Asia/Kolkata' }),
19
+ '20 Oct 2026, 15:00',
20
+ );
21
+ });
22
+ test('missing or unparsable → null', () => {
23
+ assert.equal(formatDeadline(undefined, fmt), null);
24
+ assert.equal(formatDeadline('not a date', fmt), null);
25
+ });
26
+ });
27
+
28
+ describe('graceBannerFor', () => {
29
+ test('profile with a deadline → the banner text and the Two-factor link', () => {
30
+ assert.deepEqual(graceBannerFor({ mfaEnrollmentDeadline: deadline }, '/dashboard', fmt), {
31
+ text: 'Your organisation requires two-step verification. Set it up before 20 Oct 2026, 09:30.',
32
+ href: TWO_FACTOR_PATH,
33
+ linkLabel: 'Set up two-step verification',
34
+ });
35
+ assert.equal(TWO_FACTOR_PATH, '/account/2fa');
36
+ });
37
+ test('no profile or no deadline → no banner', () => {
38
+ assert.equal(graceBannerFor(null, '/dashboard', fmt), null);
39
+ assert.equal(graceBannerFor({ name: 'A' }, '/dashboard', fmt), null);
40
+ });
41
+ test('an unparsable deadline → no banner (never "before Invalid Date")', () => {
42
+ assert.equal(graceBannerFor({ mfaEnrollmentDeadline: 'x' }, '/dashboard', fmt), null);
43
+ });
44
+ test('hidden on the enrolment pages', () => {
45
+ assert.equal(graceBannerFor({ mfaEnrollmentDeadline: deadline }, '/account/2fa', fmt), null);
46
+ assert.equal(
47
+ graceBannerFor({ mfaEnrollmentDeadline: deadline }, '/account/reenroll', fmt),
48
+ null,
49
+ );
50
+ });
51
+ test('hidden on the enrolment pages with a trailing slash too', () => {
52
+ const p = { mfaEnrollmentDeadline: deadline };
53
+ assert.equal(graceBannerFor(p, '/account/2fa/', fmt), null);
54
+ assert.equal(graceBannerFor(p, '/account/reenroll/', fmt), null);
55
+ assert.equal(graceBannerFor(p, '/account/2fa//', fmt), null);
56
+ assert.notEqual(graceBannerFor(p, '/account/2fa-other', fmt), null);
57
+ assert.notEqual(graceBannerFor(p, '/', fmt), null);
58
+ });
59
+ });
@@ -23,6 +23,15 @@
23
23
  * cross-tenant-grants` / `GET .../inbox` / `GET .../offerable-types`,
24
24
  * all gated `role:read` — reading/managing cross-tenant grants IS role
25
25
  * administration; the OFFER write is separately `cross_tenant_grant:share`)
26
+ * - Org structure → `identity:company_department:read` (org-catalog.controller.ts `GET
27
+ * companies/:companyUuid/departments`; the branches list is
28
+ * `company_branch:read`, and both bundles that grant one grant the other.
29
+ * `gate` takes one key, so the entry and the route (App.jsx) use the
30
+ * department key; a caller holding only `company_branch:read` is not served
31
+ * the page. Writes are department|branch:create|update|delete, enforced
32
+ * server-side per route)
33
+ * - Security → `identity:security_policy:read` (security-policy.controller.ts `GET
34
+ * security-policies`; add/edit/delete are security_policy:create|update|delete)
26
35
  * Dashboard / Account are left UNGATED — self/owned surfaces every authenticated
27
36
  * caller may reach (no admin-only list route backs them).
28
37
  */
@@ -33,6 +42,8 @@ import {
33
42
  History,
34
43
  KeyRound,
35
44
  LayoutDashboard,
45
+ Lock,
46
+ Network,
36
47
  ShieldCheck,
37
48
  UserCog,
38
49
  Users,
@@ -54,6 +65,14 @@ export const NAV_SECTIONS = [
54
65
  matchPrefix: true,
55
66
  gate: 'identity:company:read',
56
67
  },
68
+ {
69
+ icon: Network,
70
+ label: 'Org structure',
71
+ path: '/org-structure',
72
+ badge: null,
73
+ matchPrefix: true,
74
+ gate: 'identity:company_department:read',
75
+ },
57
76
  {
58
77
  icon: Users,
59
78
  label: 'Users',
@@ -116,6 +135,16 @@ export const NAV_SECTIONS = [
116
135
  label: 'Settings',
117
136
  // `/account` redirects to `/account/profile`; `matchPrefix` keeps the nav item
118
137
  // highlighted across ALL /account/* sub-tabs (profile / password / 2fa).
119
- items: [{ icon: UserCog, label: 'Account', path: '/account', badge: null, matchPrefix: true }],
138
+ items: [
139
+ { icon: UserCog, label: 'Account', path: '/account', badge: null, matchPrefix: true },
140
+ {
141
+ icon: Lock,
142
+ label: 'Security',
143
+ path: '/security',
144
+ badge: null,
145
+ matchPrefix: true,
146
+ gate: 'identity:security_policy:read',
147
+ },
148
+ ],
120
149
  },
121
150
  ];