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,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CaptchaWidget — the CAPTCHA challenge on the sign-in pages (R7.6). Loads the provider's script
|
|
3
|
+
* once (an external <script src>; the CSP has no 'unsafe-inline'), renders explicitly into its
|
|
4
|
+
* own container with the endpoint's action, and keeps the solved token. The page reads it
|
|
5
|
+
* through the ref at submit time and resets the widget after each attempt (tokens are single
|
|
6
|
+
* use). reCAPTCHA v3 has no visible widget: `getToken()` runs `execute` with the action.
|
|
7
|
+
*
|
|
8
|
+
* <CaptchaWidget ref={captchaRef} setup={setup} action={CAPTCHA_ACTIONS.login} />
|
|
9
|
+
* const token = await captchaRef.current.getToken(); … captchaRef.current.reset();
|
|
10
|
+
*
|
|
11
|
+
* `setup` is a `{ status: 'on', provider, siteKey }` from `captchaSetup` (signInOptions.js); the
|
|
12
|
+
* page never mounts this for an off or misconfigured setup. The provider logic lives in
|
|
13
|
+
* captchaRender.js (tested with `node --test`).
|
|
14
|
+
*/
|
|
15
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
16
|
+
import {
|
|
17
|
+
captchaScriptUrl,
|
|
18
|
+
loadCaptchaScript,
|
|
19
|
+
renderCaptcha,
|
|
20
|
+
whenProviderReady,
|
|
21
|
+
} from './captchaRender.js';
|
|
22
|
+
|
|
23
|
+
const CaptchaWidget = forwardRef(function CaptchaWidget({ setup, action }, ref) {
|
|
24
|
+
const containerRef = useRef(null);
|
|
25
|
+
const handleRef = useRef(null);
|
|
26
|
+
const tokenRef = useRef('');
|
|
27
|
+
const [failed, setFailed] = useState(false);
|
|
28
|
+
const { provider, siteKey } = setup;
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
let live = true;
|
|
32
|
+
const url = captchaScriptUrl(provider, siteKey);
|
|
33
|
+
if (!url) {
|
|
34
|
+
setFailed(true);
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
loadCaptchaScript(url)
|
|
38
|
+
.then(() => whenProviderReady(window, provider))
|
|
39
|
+
.then(() => {
|
|
40
|
+
if (!live || !containerRef.current) return;
|
|
41
|
+
handleRef.current = renderCaptcha({
|
|
42
|
+
win: window,
|
|
43
|
+
container: containerRef.current,
|
|
44
|
+
provider,
|
|
45
|
+
siteKey,
|
|
46
|
+
action,
|
|
47
|
+
onToken: (token) => {
|
|
48
|
+
tokenRef.current = token;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
})
|
|
52
|
+
.catch(() => {
|
|
53
|
+
if (live) setFailed(true);
|
|
54
|
+
});
|
|
55
|
+
return () => {
|
|
56
|
+
live = false;
|
|
57
|
+
try {
|
|
58
|
+
handleRef.current?.remove();
|
|
59
|
+
} catch {
|
|
60
|
+
/* the provider may already have torn the widget down */
|
|
61
|
+
}
|
|
62
|
+
handleRef.current = null;
|
|
63
|
+
tokenRef.current = '';
|
|
64
|
+
};
|
|
65
|
+
}, [provider, siteKey, action]);
|
|
66
|
+
|
|
67
|
+
useImperativeHandle(
|
|
68
|
+
ref,
|
|
69
|
+
() => ({
|
|
70
|
+
/** The token for this attempt, or '' when the challenge is not solved (or failed to load). */
|
|
71
|
+
async getToken() {
|
|
72
|
+
const handle = handleRef.current;
|
|
73
|
+
if (!handle) return '';
|
|
74
|
+
if (handle.invisible) {
|
|
75
|
+
try {
|
|
76
|
+
return (await handle.execute()) || '';
|
|
77
|
+
} catch {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return tokenRef.current;
|
|
82
|
+
},
|
|
83
|
+
/** Clear the spent token and show a fresh challenge. */
|
|
84
|
+
reset() {
|
|
85
|
+
tokenRef.current = '';
|
|
86
|
+
try {
|
|
87
|
+
handleRef.current?.reset();
|
|
88
|
+
} catch {
|
|
89
|
+
/* best-effort */
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
[],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="space-y-1.5">
|
|
98
|
+
<div ref={containerRef} className="flex justify-center" />
|
|
99
|
+
{failed && (
|
|
100
|
+
<p role="alert" className="text-center text-sm text-destructive">
|
|
101
|
+
The verification check could not load. Check your connection and reload the page.
|
|
102
|
+
</p>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export default CaptchaWidget;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// captchaProvider.js — which CAPTCHA provider this build's CSP allows (R7.6, D-10).
|
|
2
|
+
//
|
|
3
|
+
// The CSP <meta> is fixed at build time from VITE_CAPTCHA_PROVIDER (vite/captchaCsp.js), while the
|
|
4
|
+
// server's provider comes from `GET /auth/login-options` at run time. The login page compares the
|
|
5
|
+
// two with `captchaMismatch`: on any difference it shows the fixed misconfiguration notice, logs
|
|
6
|
+
// both values and loads no widget. React-free so `node --test` can run it.
|
|
7
|
+
|
|
8
|
+
/** Empty or missing → null; anything else is kept as is. */
|
|
9
|
+
export function normalizeProvider(value) {
|
|
10
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// `import.meta.env` exists only under Vite (it is inlined at build time); under `node --test` it is
|
|
14
|
+
// undefined and the build provider is null.
|
|
15
|
+
const env = import.meta.env ?? {};
|
|
16
|
+
|
|
17
|
+
/** The provider this build was made for, or null when VITE_CAPTCHA_PROVIDER was unset. */
|
|
18
|
+
export const BUILD_CAPTCHA_PROVIDER = normalizeProvider(env.VITE_CAPTCHA_PROVIDER);
|
|
19
|
+
|
|
20
|
+
/** True when the server's and the build's providers differ, including one side unset. */
|
|
21
|
+
export function captchaMismatch(serverProvider, buildProvider) {
|
|
22
|
+
return normalizeProvider(serverProvider) !== normalizeProvider(buildProvider);
|
|
23
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// captchaProvider.test.mjs — R7.6 / AC-15: the login page compares the server's CAPTCHA provider
|
|
2
|
+
// (`GET /auth/login-options`) with the one this build's CSP was made for (VITE_CAPTCHA_PROVIDER).
|
|
3
|
+
// Any difference, including a provider on one side only, is a mismatch.
|
|
4
|
+
//
|
|
5
|
+
// node --test src/auth/captchaProvider.test.mjs
|
|
6
|
+
import { test, describe } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { BUILD_CAPTCHA_PROVIDER, captchaMismatch, normalizeProvider } from './captchaProvider.js';
|
|
9
|
+
|
|
10
|
+
describe('captchaMismatch', () => {
|
|
11
|
+
test('same provider on both sides → no mismatch', () => {
|
|
12
|
+
assert.equal(captchaMismatch('turnstile', 'turnstile'), false);
|
|
13
|
+
assert.equal(captchaMismatch('recaptcha-v3', 'recaptcha-v3'), false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('none on both sides → no mismatch', () => {
|
|
17
|
+
assert.equal(captchaMismatch(null, null), false);
|
|
18
|
+
assert.equal(captchaMismatch(undefined, ''), false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('different providers → mismatch', () => {
|
|
22
|
+
assert.equal(captchaMismatch('turnstile', 'recaptcha-v3'), true);
|
|
23
|
+
assert.equal(captchaMismatch('recaptcha-v2', 'recaptcha-v3'), true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('provider on one side only → mismatch, either way', () => {
|
|
27
|
+
assert.equal(captchaMismatch('turnstile', null), true);
|
|
28
|
+
assert.equal(captchaMismatch(null, 'turnstile'), true);
|
|
29
|
+
assert.equal(captchaMismatch('recaptcha-v2', ''), true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('normalizeProvider / BUILD_CAPTCHA_PROVIDER', () => {
|
|
34
|
+
test('empty or missing → null; a value is kept as is', () => {
|
|
35
|
+
assert.equal(normalizeProvider(undefined), null);
|
|
36
|
+
assert.equal(normalizeProvider(null), null);
|
|
37
|
+
assert.equal(normalizeProvider(''), null);
|
|
38
|
+
assert.equal(normalizeProvider('turnstile'), 'turnstile');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('outside a Vite build (no import.meta.env) the build provider is null', () => {
|
|
42
|
+
assert.equal(BUILD_CAPTCHA_PROVIDER, null);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// captchaRender.js — the provider half of <CaptchaWidget> (R7.6): which script to load, loading
|
|
2
|
+
// it once as an external <script src> (the CSP has no 'unsafe-inline', so never an inline script
|
|
3
|
+
// or an `onload=` URL callback), and the explicit render per provider. React-free; the browser
|
|
4
|
+
// globals are passed in so `node --test` can run it with fakes.
|
|
5
|
+
//
|
|
6
|
+
// turnstile https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit
|
|
7
|
+
// turnstile.render(el, { sitekey, action, callback, … }) → widget id
|
|
8
|
+
// recaptcha-v2 https://www.google.com/recaptcha/api.js?render=explicit (checkbox)
|
|
9
|
+
// grecaptcha.render(el, { sitekey, callback, … }) → widget id
|
|
10
|
+
// recaptcha-v3 https://www.google.com/recaptcha/api.js?render=<siteKey> (no visible widget)
|
|
11
|
+
// grecaptcha.execute(siteKey, { action }) at submit time
|
|
12
|
+
//
|
|
13
|
+
// Tokens are single use: the caller resets the widget after every attempt.
|
|
14
|
+
|
|
15
|
+
const TURNSTILE_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
|
16
|
+
const RECAPTCHA_URL = 'https://www.google.com/recaptcha/api.js';
|
|
17
|
+
|
|
18
|
+
/** The provider's script URL, or null for an unknown provider. */
|
|
19
|
+
export function captchaScriptUrl(provider, siteKey) {
|
|
20
|
+
if (provider === 'turnstile') return TURNSTILE_URL;
|
|
21
|
+
if (provider === 'recaptcha-v2') return `${RECAPTCHA_URL}?render=explicit`;
|
|
22
|
+
if (provider === 'recaptcha-v3') return `${RECAPTCHA_URL}?render=${encodeURIComponent(siteKey)}`;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A loader that appends each script URL once and shares the pending promise. A failed load is
|
|
28
|
+
* forgotten so a later mount can retry.
|
|
29
|
+
* @param {Document} doc
|
|
30
|
+
* @returns {(url: string) => Promise<void>}
|
|
31
|
+
*/
|
|
32
|
+
export function createScriptLoader(doc) {
|
|
33
|
+
const pending = new Map();
|
|
34
|
+
return (url) => {
|
|
35
|
+
const known = pending.get(url);
|
|
36
|
+
if (known) return known;
|
|
37
|
+
const loaded = new Promise((resolve, reject) => {
|
|
38
|
+
const el = doc.createElement('script');
|
|
39
|
+
el.src = url;
|
|
40
|
+
el.async = true;
|
|
41
|
+
el.onload = () => resolve();
|
|
42
|
+
el.onerror = () => {
|
|
43
|
+
pending.delete(url);
|
|
44
|
+
reject(new Error('CAPTCHA script failed to load'));
|
|
45
|
+
};
|
|
46
|
+
doc.head.appendChild(el);
|
|
47
|
+
});
|
|
48
|
+
pending.set(url, loaded);
|
|
49
|
+
return loaded;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolves once the provider's API can render (reCAPTCHA needs `grecaptcha.ready`). */
|
|
54
|
+
export function whenProviderReady(win, provider) {
|
|
55
|
+
if (provider === 'turnstile') return Promise.resolve();
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
if (win.grecaptcha && typeof win.grecaptcha.ready === 'function') win.grecaptcha.ready(resolve);
|
|
58
|
+
else resolve();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Render the widget into `container`. `onToken(token)` gets each solved token and '' when it
|
|
64
|
+
* expires or errors. Throws when the provider's global is missing (the script did not load).
|
|
65
|
+
* @returns {{ invisible: boolean, execute: () => Promise<string>, reset: () => void,
|
|
66
|
+
* remove: () => void }}
|
|
67
|
+
*/
|
|
68
|
+
export function renderCaptcha({ win, container, provider, siteKey, action, onToken }) {
|
|
69
|
+
const clear = () => onToken('');
|
|
70
|
+
if (provider === 'turnstile') {
|
|
71
|
+
const api = win.turnstile;
|
|
72
|
+
if (!api) throw new Error('Turnstile is not loaded');
|
|
73
|
+
const id = api.render(container, {
|
|
74
|
+
sitekey: siteKey,
|
|
75
|
+
action,
|
|
76
|
+
callback: (token) => onToken(token),
|
|
77
|
+
'expired-callback': clear,
|
|
78
|
+
'error-callback': clear,
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
invisible: false,
|
|
82
|
+
execute: async () => '',
|
|
83
|
+
reset: () => api.reset(id),
|
|
84
|
+
remove: () => api.remove?.(id),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const api = win.grecaptcha;
|
|
88
|
+
if (!api) throw new Error('reCAPTCHA is not loaded');
|
|
89
|
+
if (provider === 'recaptcha-v2') {
|
|
90
|
+
// The v2 checkbox has no action; the server checks the hostname (R7.4).
|
|
91
|
+
const id = api.render(container, {
|
|
92
|
+
sitekey: siteKey,
|
|
93
|
+
callback: (token) => onToken(token),
|
|
94
|
+
'expired-callback': clear,
|
|
95
|
+
'error-callback': clear,
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
invisible: false,
|
|
99
|
+
execute: async () => '',
|
|
100
|
+
reset: () => api.reset(id),
|
|
101
|
+
remove: () => {},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (provider === 'recaptcha-v3') {
|
|
105
|
+
return {
|
|
106
|
+
invisible: true,
|
|
107
|
+
execute: () =>
|
|
108
|
+
new Promise((resolve, reject) => {
|
|
109
|
+
api.ready(() => {
|
|
110
|
+
Promise.resolve(api.execute(siteKey, { action })).then(resolve, reject);
|
|
111
|
+
});
|
|
112
|
+
}),
|
|
113
|
+
reset: () => {},
|
|
114
|
+
remove: () => {},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`Unknown CAPTCHA provider: ${provider}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let browserLoader = null;
|
|
121
|
+
/** The page-wide loader over `document` (created on first use). */
|
|
122
|
+
export function loadCaptchaScript(url) {
|
|
123
|
+
if (!browserLoader) browserLoader = createScriptLoader(document);
|
|
124
|
+
return browserLoader(url);
|
|
125
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// captchaRender.test.mjs — the CAPTCHA widget's provider logic (R7.6): which script, loaded once,
|
|
2
|
+
// explicit render with the endpoint's action, single-use tokens reset after each attempt.
|
|
3
|
+
// The browser globals are fakes, so this runs under plain Node.
|
|
4
|
+
//
|
|
5
|
+
// node --test src/auth/captchaRender.test.mjs
|
|
6
|
+
import { test, describe } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { captchaScriptUrl, createScriptLoader, renderCaptcha } from './captchaRender.js';
|
|
9
|
+
|
|
10
|
+
describe('captchaScriptUrl', () => {
|
|
11
|
+
test('turnstile → explicit render', () => {
|
|
12
|
+
assert.equal(
|
|
13
|
+
captchaScriptUrl('turnstile', 'k'),
|
|
14
|
+
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit',
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
test('recaptcha-v2 → explicit render', () => {
|
|
18
|
+
assert.equal(
|
|
19
|
+
captchaScriptUrl('recaptcha-v2', 'k'),
|
|
20
|
+
'https://www.google.com/recaptcha/api.js?render=explicit',
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
test('recaptcha-v3 → render=<siteKey>, encoded', () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
captchaScriptUrl('recaptcha-v3', 'a b&c'),
|
|
26
|
+
'https://www.google.com/recaptcha/api.js?render=a%20b%26c',
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
test('unknown provider → null (no script)', () => {
|
|
30
|
+
assert.equal(captchaScriptUrl('hcaptcha', 'k'), null);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function fakeDocument() {
|
|
35
|
+
const appended = [];
|
|
36
|
+
return {
|
|
37
|
+
appended,
|
|
38
|
+
createElement: (tag) => ({
|
|
39
|
+
tag,
|
|
40
|
+
set src(v) {
|
|
41
|
+
this._src = v;
|
|
42
|
+
},
|
|
43
|
+
get src() {
|
|
44
|
+
return this._src;
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
head: { appendChild: (el) => appended.push(el) },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('createScriptLoader — one <script src> per URL, never inline', () => {
|
|
52
|
+
test('appends a single external script and shares the promise', async () => {
|
|
53
|
+
const doc = fakeDocument();
|
|
54
|
+
const load = createScriptLoader(doc);
|
|
55
|
+
const a = load('https://x/api.js');
|
|
56
|
+
const b = load('https://x/api.js');
|
|
57
|
+
assert.equal(a, b);
|
|
58
|
+
assert.equal(doc.appended.length, 1);
|
|
59
|
+
const el = doc.appended[0];
|
|
60
|
+
assert.equal(el.tag, 'script');
|
|
61
|
+
assert.equal(el.src, 'https://x/api.js');
|
|
62
|
+
assert.equal(el.async, true);
|
|
63
|
+
assert.equal(el.text ?? '', '');
|
|
64
|
+
el.onload();
|
|
65
|
+
await a;
|
|
66
|
+
});
|
|
67
|
+
test('a failed load rejects and a later call retries', async () => {
|
|
68
|
+
const doc = fakeDocument();
|
|
69
|
+
const load = createScriptLoader(doc);
|
|
70
|
+
const a = load('https://x/api.js');
|
|
71
|
+
doc.appended[0].onerror();
|
|
72
|
+
await assert.rejects(a);
|
|
73
|
+
load('https://x/api.js');
|
|
74
|
+
assert.equal(doc.appended.length, 2);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('renderCaptcha', () => {
|
|
79
|
+
test('turnstile: explicit render with the action; token via callback; reset clears it', () => {
|
|
80
|
+
const calls = [];
|
|
81
|
+
let opts;
|
|
82
|
+
const win = {
|
|
83
|
+
turnstile: {
|
|
84
|
+
render: (el, o) => {
|
|
85
|
+
opts = o;
|
|
86
|
+
calls.push(['render', el]);
|
|
87
|
+
return 'w1';
|
|
88
|
+
},
|
|
89
|
+
reset: (id) => calls.push(['reset', id]),
|
|
90
|
+
remove: (id) => calls.push(['remove', id]),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
const tokens = [];
|
|
94
|
+
const h = renderCaptcha({
|
|
95
|
+
win,
|
|
96
|
+
container: 'EL',
|
|
97
|
+
provider: 'turnstile',
|
|
98
|
+
siteKey: 'sk',
|
|
99
|
+
action: 'login',
|
|
100
|
+
onToken: (t) => tokens.push(t),
|
|
101
|
+
});
|
|
102
|
+
assert.equal(opts.sitekey, 'sk');
|
|
103
|
+
assert.equal(opts.action, 'login');
|
|
104
|
+
opts.callback('tok-1');
|
|
105
|
+
assert.deepEqual(tokens, ['tok-1']);
|
|
106
|
+
opts['expired-callback']();
|
|
107
|
+
assert.deepEqual(tokens, ['tok-1', '']);
|
|
108
|
+
h.reset();
|
|
109
|
+
h.remove();
|
|
110
|
+
assert.deepEqual(calls, [
|
|
111
|
+
['render', 'EL'],
|
|
112
|
+
['reset', 'w1'],
|
|
113
|
+
['remove', 'w1'],
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('recaptcha-v2: checkbox render (no action), reset by widget id', () => {
|
|
118
|
+
let opts;
|
|
119
|
+
const resets = [];
|
|
120
|
+
const win = {
|
|
121
|
+
grecaptcha: {
|
|
122
|
+
render: (_el, o) => {
|
|
123
|
+
opts = o;
|
|
124
|
+
return 7;
|
|
125
|
+
},
|
|
126
|
+
reset: (id) => resets.push(id),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
const tokens = [];
|
|
130
|
+
const h = renderCaptcha({
|
|
131
|
+
win,
|
|
132
|
+
container: 'EL',
|
|
133
|
+
provider: 'recaptcha-v2',
|
|
134
|
+
siteKey: 'sk',
|
|
135
|
+
action: 'login',
|
|
136
|
+
onToken: (t) => tokens.push(t),
|
|
137
|
+
});
|
|
138
|
+
assert.equal(opts.sitekey, 'sk');
|
|
139
|
+
assert.equal('action' in opts, false);
|
|
140
|
+
opts.callback('tok-2');
|
|
141
|
+
assert.deepEqual(tokens, ['tok-2']);
|
|
142
|
+
h.reset();
|
|
143
|
+
assert.deepEqual(resets, [7]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('recaptcha-v3: no visible widget; execute(siteKey, {action}) gives the token', async () => {
|
|
147
|
+
const executed = [];
|
|
148
|
+
const win = {
|
|
149
|
+
grecaptcha: {
|
|
150
|
+
ready: (cb) => cb(),
|
|
151
|
+
execute: async (key, o) => {
|
|
152
|
+
executed.push([key, o.action]);
|
|
153
|
+
return 'tok-3';
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
const h = renderCaptcha({
|
|
158
|
+
win,
|
|
159
|
+
container: 'EL',
|
|
160
|
+
provider: 'recaptcha-v3',
|
|
161
|
+
siteKey: 'sk',
|
|
162
|
+
action: 'forgot_password',
|
|
163
|
+
onToken: () => {},
|
|
164
|
+
});
|
|
165
|
+
assert.equal(h.invisible, true);
|
|
166
|
+
assert.equal(await h.execute(), 'tok-3');
|
|
167
|
+
assert.deepEqual(executed, [['sk', 'forgot_password']]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('provider global missing → throws (the caller shows an error, never a fake token)', () => {
|
|
171
|
+
assert.throws(() =>
|
|
172
|
+
renderCaptcha({
|
|
173
|
+
win: {},
|
|
174
|
+
container: 'EL',
|
|
175
|
+
provider: 'turnstile',
|
|
176
|
+
siteKey: 'sk',
|
|
177
|
+
action: 'login',
|
|
178
|
+
onToken: () => {},
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// passwordVisibility.js — the show/hide toggle on a password field (R13.5, AC-22): the input
|
|
2
|
+
// type and the toggle button's accessible label, the same wording as the login page.
|
|
3
|
+
|
|
4
|
+
/** @param {boolean} shown */
|
|
5
|
+
export function passwordVisibility(shown) {
|
|
6
|
+
return shown
|
|
7
|
+
? { type: 'text', toggleLabel: 'Hide password' }
|
|
8
|
+
: { type: 'password', toggleLabel: 'Show password' };
|
|
9
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// passwordVisibility.test.mjs — the show/hide toggle on the reset / set-password fields (R13.5,
|
|
2
|
+
// AC-22): "toggles password visibility" with an accessible label.
|
|
3
|
+
//
|
|
4
|
+
// node --test src/auth/passwordVisibility.test.mjs
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { passwordVisibility } from './passwordVisibility.js';
|
|
8
|
+
|
|
9
|
+
test('toggles password visibility', () => {
|
|
10
|
+
assert.deepEqual(passwordVisibility(false), { type: 'password', toggleLabel: 'Show password' });
|
|
11
|
+
assert.deepEqual(passwordVisibility(true), { type: 'text', toggleLabel: 'Hide password' });
|
|
12
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// reenrollWording.js — the enrolment screen's words per restriction reason (R2.3, R13.2).
|
|
2
|
+
// Both reasons pin the session to the same page (`/account/reenroll`); only the framing differs:
|
|
3
|
+
// `reenroll` after a recovery-code sign-in, `mfa_enrollment` when a security policy requires 2FA
|
|
4
|
+
// and the account has none.
|
|
5
|
+
|
|
6
|
+
const REENROLL = Object.freeze({
|
|
7
|
+
title: 'Finish securing your account',
|
|
8
|
+
body:
|
|
9
|
+
'You signed in with a recovery code. Add a new authenticator device below to re-secure your ' +
|
|
10
|
+
'account — the rest of the app stays locked until you do.',
|
|
11
|
+
securedTitle: 'Your account is secured again',
|
|
12
|
+
securedBody:
|
|
13
|
+
'A new authenticator device is active. Save any recovery codes shown below, then continue.',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const MFA_ENROLLMENT = Object.freeze({
|
|
17
|
+
title: 'Set up two-step verification',
|
|
18
|
+
body:
|
|
19
|
+
'Your organisation requires two-step verification for your account. Add an authenticator ' +
|
|
20
|
+
'device below — the rest of the app stays locked until you do.',
|
|
21
|
+
securedTitle: 'Two-step verification is on',
|
|
22
|
+
securedBody:
|
|
23
|
+
'Your authenticator device is active. Save the recovery codes shown below, then continue.',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** @param {'reenroll' | 'mfa_enrollment' | null | undefined} reason */
|
|
27
|
+
export function reenrollWording(reason) {
|
|
28
|
+
return reason === 'mfa_enrollment' ? MFA_ENROLLMENT : REENROLL;
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// reenrollWording.test.mjs — the enrolment screen's heading follows the restriction reason
|
|
2
|
+
// (R2.3, R13.2): re-enrol after a recovery-code login, or first enrolment required by policy.
|
|
3
|
+
//
|
|
4
|
+
// node --test src/auth/reenrollWording.test.mjs
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { reenrollWording } from './reenrollWording.js';
|
|
8
|
+
|
|
9
|
+
test('mfa_enrollment → the policy wording', () => {
|
|
10
|
+
const w = reenrollWording('mfa_enrollment');
|
|
11
|
+
assert.equal(w.title, 'Set up two-step verification');
|
|
12
|
+
assert.match(w.body, /organisation requires two-step verification/);
|
|
13
|
+
assert.equal(w.securedTitle, 'Two-step verification is on');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('reenroll, null or unknown → the recovery-code wording (unchanged)', () => {
|
|
17
|
+
for (const reason of ['reenroll', null, undefined, 'other']) {
|
|
18
|
+
const w = reenrollWording(reason);
|
|
19
|
+
assert.equal(w.title, 'Finish securing your account');
|
|
20
|
+
assert.match(w.body, /recovery code/);
|
|
21
|
+
assert.equal(w.securedTitle, 'Your account is secured again');
|
|
22
|
+
}
|
|
23
|
+
});
|
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
/** The single forced-re-enroll screen path (the enforcement's frontend twin). */
|
|
7
7
|
export const REENROLL_PATH = '/account/reenroll';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Where a restricted session is pinned (R2.3, R13.2). Both reasons — `reenroll` (after a
|
|
11
|
+
* recovery-code sign-in) and `mfa_enrollment` (a policy requires 2FA and none is set up) — use
|
|
12
|
+
* the same enrolment page; the page words its heading per reason. No reason → null.
|
|
13
|
+
* @param {'reenroll' | 'mfa_enrollment' | null | undefined} reason
|
|
14
|
+
*/
|
|
15
|
+
export function restrictionPath(reason) {
|
|
16
|
+
return reason === 'reenroll' || reason === 'mfa_enrollment' ? REENROLL_PATH : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
/**
|
|
10
20
|
* `factsReady` is true once the auth-facts projection has resolved for this session. A failed
|
|
11
21
|
* load resolves to an EMPTY projection (the SDK is fail-closed), so it is "ready" with no
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// node --test src/auth/routeGate.test.mjs
|
|
6
6
|
import { test, describe } from 'node:test';
|
|
7
7
|
import assert from 'node:assert/strict';
|
|
8
|
-
import { decideRoute, REENROLL_PATH } from './routeGate.js';
|
|
8
|
+
import { decideRoute, REENROLL_PATH, restrictionPath } from './routeGate.js';
|
|
9
9
|
|
|
10
10
|
const base = { isAuthenticated: true, mustReenroll: false, pathname: '/users', canGate: false };
|
|
11
11
|
|
|
@@ -57,3 +57,24 @@ describe('decideRoute', () => {
|
|
|
57
57
|
);
|
|
58
58
|
});
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
// R2.3, R13.2, AC-25: a 403 mfa_enrollment_required routes to the same enrolment page as
|
|
62
|
+
// reenrollment_required.
|
|
63
|
+
describe('restrictionPath', () => {
|
|
64
|
+
test('both restriction reasons → the enrolment page', () => {
|
|
65
|
+
assert.equal(restrictionPath('reenroll'), REENROLL_PATH);
|
|
66
|
+
assert.equal(restrictionPath('mfa_enrollment'), REENROLL_PATH);
|
|
67
|
+
});
|
|
68
|
+
test('no reason → null', () => {
|
|
69
|
+
assert.equal(restrictionPath(null), null);
|
|
70
|
+
assert.equal(restrictionPath(undefined), null);
|
|
71
|
+
});
|
|
72
|
+
test('an mfa_enrollment restriction pins every other route like re-enrol', () => {
|
|
73
|
+
const mustReenroll = restrictionPath('mfa_enrollment') !== null;
|
|
74
|
+
assert.equal(decideRoute({ ...base, mustReenroll, factsReady: true }), 'reenroll');
|
|
75
|
+
assert.equal(
|
|
76
|
+
decideRoute({ ...base, mustReenroll, pathname: REENROLL_PATH, factsReady: true }),
|
|
77
|
+
'render',
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|