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.
- package/dist/index.js +98 -4
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
- package/template/frontend/_env.example +7 -0
- package/template/frontend/package.json +2 -2
- package/template/frontend/src/App.jsx +24 -0
- package/template/frontend/src/auth/AuthContext.jsx +73 -30
- package/template/frontend/src/auth/CaptchaWidget.jsx +108 -0
- package/template/frontend/src/auth/captchaProvider.js +23 -0
- package/template/frontend/src/auth/captchaProvider.test.mjs +44 -0
- package/template/frontend/src/auth/captchaRender.js +125 -0
- package/template/frontend/src/auth/captchaRender.test.mjs +182 -0
- package/template/frontend/src/auth/passwordVisibility.js +9 -0
- package/template/frontend/src/auth/passwordVisibility.test.mjs +12 -0
- package/template/frontend/src/auth/reenrollWording.js +29 -0
- package/template/frontend/src/auth/reenrollWording.test.mjs +23 -0
- package/template/frontend/src/auth/routeGate.js +10 -0
- package/template/frontend/src/auth/routeGate.test.mjs +22 -1
- package/template/frontend/src/auth/sessionFlow.js +80 -4
- package/template/frontend/src/auth/sessionFlow.test.mjs +212 -1
- package/template/frontend/src/auth/signInOptions.js +64 -0
- package/template/frontend/src/auth/signInOptions.test.mjs +94 -0
- package/template/frontend/src/auth/useSignInOptions.js +34 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +49 -3
- package/template/frontend/src/pages/Login.jsx +54 -4
- package/template/frontend/src/pages/ResetPassword.jsx +83 -11
- package/template/frontend/src/pages/account/ReenrollPage.jsx +17 -11
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +9 -6
- package/template/frontend/src/pages/audit/AuditPage.jsx +17 -3
- package/template/frontend/src/pages/audit/actorKind.js +17 -0
- package/template/frontend/src/pages/audit/actorKind.test.mjs +23 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +104 -4
- package/template/frontend/src/pages/companies/moveAction.js +61 -0
- package/template/frontend/src/pages/companies/moveAction.test.mjs +91 -0
- package/template/frontend/src/pages/org-structure/OrgStructurePage.jsx +408 -0
- package/template/frontend/src/pages/org-structure/orgStructure.js +140 -0
- package/template/frontend/src/pages/org-structure/orgStructure.test.mjs +199 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +32 -14
- package/template/frontend/src/pages/roles/RolesPage.jsx +167 -42
- package/template/frontend/src/pages/roles/roleRules.js +104 -0
- package/template/frontend/src/pages/roles/roleRules.test.mjs +178 -0
- package/template/frontend/src/pages/security/PolicyFormDialog.jsx +243 -0
- package/template/frontend/src/pages/security/SecuritySettingsPage.jsx +229 -0
- package/template/frontend/src/pages/security/securityPolicies.js +246 -0
- package/template/frontend/src/pages/security/securityPolicies.test.mjs +429 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +5 -2
- package/template/frontend/src/pages/users/PostingRow.jsx +13 -10
- package/template/frontend/src/pages/users/SignInActionDialog.jsx +98 -0
- package/template/frontend/src/pages/users/UserForm.jsx +65 -3
- package/template/frontend/src/pages/users/UsersPage.jsx +91 -3
- package/template/frontend/src/pages/users/loginName.js +108 -0
- package/template/frontend/src/pages/users/loginName.test.mjs +133 -0
- package/template/frontend/src/pages/users/signInAdmin.js +122 -0
- package/template/frontend/src/pages/users/signInAdmin.test.mjs +183 -0
- package/template/frontend/src/platform/httpClient.js +40 -28
- package/template/frontend/src/platform/httpClient.test.mjs +71 -0
- package/template/frontend/src/platform/shieldTransport.js +12 -12
- package/template/frontend/src/shell/AppShell.jsx +4 -0
- package/template/frontend/src/shell/GraceBanner.jsx +28 -0
- package/template/frontend/src/shell/graceDeadline.js +50 -0
- package/template/frontend/src/shell/graceDeadline.test.mjs +59 -0
- package/template/frontend/src/shell/nav.js +30 -1
- package/template/frontend/src/viteConfig.test.mjs +63 -0
- package/template/frontend/vite/captchaCsp.js +99 -0
- package/template/frontend/vite.config.js +14 -3
- package/template/server/_env.example +25 -0
- package/template/server/package.json +9 -6
- 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
|
|
35
|
-
* whose body is EXACTLY
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
result.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 {{
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* can
|
|
59
|
-
*
|
|
60
|
-
* stale
|
|
61
|
-
* (`adversarial.test.ts` "ATTACK 2":
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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`:
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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: [
|
|
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
|
];
|