create-astram-app 0.11.1 → 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 +1 -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
|
@@ -3,18 +3,38 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* The scope the ShieldClient caches a projection under: the session's tenant and session id,
|
|
7
|
+
* so a projection fetched for one session or tenant is never served to another (KI #139).
|
|
8
|
+
*
|
|
9
|
+
* @param {{ tenantId?: string, sessionId?: string } | null | undefined} session
|
|
10
|
+
* @returns {{ tenantId: string | null, sessionId: string | null }}
|
|
11
|
+
*/
|
|
12
|
+
export function factsScopeOf(session) {
|
|
13
|
+
return { tenantId: session?.tenantId ?? null, sessionId: session?.sessionId ?? null };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Establish a session after login / MFA: reset the ShieldClient, persist the session, then
|
|
18
|
+
* fetch the display profile and the auth-facts projection.
|
|
19
|
+
*
|
|
20
|
+
* The reset comes first (KI #139): the ShieldClient is built once and outlives sessions, and a
|
|
21
|
+
* projection cached for the previous session would otherwise be served while the permission
|
|
22
|
+
* version has not moved (a root grant applied by a restart moves nothing).
|
|
8
23
|
*
|
|
9
24
|
* @param {{ session: object, tokens: { accessToken?: string }, identityHint?: string }} input
|
|
10
25
|
* @param {{
|
|
26
|
+
* resetShield: () => void,
|
|
11
27
|
* persist: (state: object) => void,
|
|
12
28
|
* setCurrentToken: (token: string | null) => void,
|
|
13
29
|
* loadProfile: (accessToken: string | undefined) => Promise<void>,
|
|
14
|
-
* refreshAuthFacts: (
|
|
30
|
+
* refreshAuthFacts: (
|
|
31
|
+
* principalId: string | null,
|
|
32
|
+
* scope: { tenantId: string | null, sessionId: string | null },
|
|
33
|
+
* ) => Promise<void>,
|
|
15
34
|
* }} deps
|
|
16
35
|
*/
|
|
17
36
|
export async function establishSession({ session, tokens, identityHint }, deps) {
|
|
37
|
+
deps.resetShield();
|
|
18
38
|
deps.persist({ session, tokens, identityHint });
|
|
19
39
|
// `persist` is a React setState: the token ref the shield transport reads is only synced on
|
|
20
40
|
// the NEXT render. Publish the new token now, or the first auth-facts fetch goes out with no
|
|
@@ -22,6 +42,62 @@ export async function establishSession({ session, tokens, identityHint }, deps)
|
|
|
22
42
|
deps.setCurrentToken(tokens?.accessToken ?? null);
|
|
23
43
|
await Promise.all([
|
|
24
44
|
deps.loadProfile(tokens?.accessToken),
|
|
25
|
-
deps.refreshAuthFacts(session?.principalId ?? null),
|
|
45
|
+
deps.refreshAuthFacts(session?.principalId ?? null, factsScopeOf(session)),
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Drop everything derived from a session that must not outlive it: the ShieldClient's cached
|
|
51
|
+
* projection and step-up state (KI #139), the display profile, the auth facts and the
|
|
52
|
+
* re-enroll restriction. Runs on sign-out, session expiry and a session change in another tab.
|
|
53
|
+
*
|
|
54
|
+
* @param {{
|
|
55
|
+
* resetShield: () => void,
|
|
56
|
+
* setProfile: (profile: null) => void,
|
|
57
|
+
* setAuthFacts: (facts: null) => void,
|
|
58
|
+
* clearReenroll: () => void,
|
|
59
|
+
* }} deps
|
|
60
|
+
*/
|
|
61
|
+
export function clearSessionViews(deps) {
|
|
62
|
+
deps.resetShield();
|
|
63
|
+
deps.setProfile(null);
|
|
64
|
+
deps.setAuthFacts(null); // fail-closed: no session -> no cached facts to (mis)trust
|
|
65
|
+
deps.clearReenroll();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* After a device confirm (account Two-factor page, or the restricted re-enroll screen): the server
|
|
70
|
+
* has already lifted a `reenroll` / `mfa_enrollment` restriction and dropped the grace deadline on
|
|
71
|
+
* the SAME token, but the client still holds what it fetched before. Reset the ShieldClient (its
|
|
72
|
+
* cached projection is the restricted one and the permission version has not moved, KI #139),
|
|
73
|
+
* refetch the auth facts and `/auth/me` (the grace banner reads its deadline), and only then lift
|
|
74
|
+
* the local restriction, so the user lands on a page whose nav and gates are already loaded (no
|
|
75
|
+
* flash of "denied"). A failed refetch still lifts it: the server has, and the poll catches up.
|
|
76
|
+
*
|
|
77
|
+
* @param {{
|
|
78
|
+
* resetShield: () => void,
|
|
79
|
+
* refreshAuthFacts: () => Promise<void>,
|
|
80
|
+
* loadProfile: () => Promise<void>,
|
|
81
|
+
* clearRestriction: () => void,
|
|
82
|
+
* }} deps
|
|
83
|
+
*/
|
|
84
|
+
export async function afterDeviceConfirmed(deps) {
|
|
85
|
+
deps.resetShield();
|
|
86
|
+
await Promise.allSettled([
|
|
87
|
+
Promise.resolve().then(() => deps.refreshAuthFacts()),
|
|
88
|
+
Promise.resolve().then(() => deps.loadProfile()),
|
|
26
89
|
]);
|
|
90
|
+
deps.clearRestriction();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Classify a session change another tab wrote to storage.
|
|
95
|
+
*
|
|
96
|
+
* @param {string | null | undefined} prevSessionId this tab's session id before the change
|
|
97
|
+
* @param {{ session?: { sessionId?: string } } | null | undefined} next the stored session
|
|
98
|
+
* @returns {'signed-out' | 'switched' | 'rotated'} `rotated` = same session, new tokens
|
|
99
|
+
*/
|
|
100
|
+
export function crossTabChange(prevSessionId, next) {
|
|
101
|
+
if (!next) return 'signed-out';
|
|
102
|
+
return next.session?.sessionId !== prevSessionId ? 'switched' : 'rotated';
|
|
27
103
|
}
|
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
// node --test src/auth/sessionFlow.test.mjs
|
|
6
6
|
import { test, describe } from 'node:test';
|
|
7
7
|
import assert from 'node:assert/strict';
|
|
8
|
-
import {
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { createShieldClient } from '@astram/sdk';
|
|
10
|
+
import {
|
|
11
|
+
afterDeviceConfirmed,
|
|
12
|
+
clearSessionViews,
|
|
13
|
+
crossTabChange,
|
|
14
|
+
establishSession,
|
|
15
|
+
factsScopeOf,
|
|
16
|
+
} from './sessionFlow.js';
|
|
9
17
|
import { createWebShieldTransport } from '../platform/shieldTransport.js';
|
|
10
18
|
|
|
11
19
|
const fake = (label) => `fake-${label}`;
|
|
@@ -31,6 +39,7 @@ describe('establishSession', () => {
|
|
|
31
39
|
await establishSession(
|
|
32
40
|
{ session: { principalId: 'p1' }, tokens: { accessToken: ACCESS }, identityHint: 'a' },
|
|
33
41
|
{
|
|
42
|
+
resetShield: () => {},
|
|
34
43
|
persist: () => {}, // setState: the ref is NOT updated until a later render
|
|
35
44
|
setCurrentToken: (t) => {
|
|
36
45
|
tokenRef.current = t;
|
|
@@ -51,6 +60,7 @@ describe('establishSession', () => {
|
|
|
51
60
|
await establishSession(
|
|
52
61
|
{ session: { principalId: 'p1' }, tokens: { accessToken: ACCESS } },
|
|
53
62
|
{
|
|
63
|
+
resetShield: () => {},
|
|
54
64
|
persist: () => order.push('persist'),
|
|
55
65
|
setCurrentToken: () => order.push('token'),
|
|
56
66
|
loadProfile: async (t) => order.push(`profile:${t === ACCESS}`),
|
|
@@ -61,3 +71,204 @@ describe('establishSession', () => {
|
|
|
61
71
|
assert.deepEqual(order.slice(2).sort(), ['facts:p1', 'profile:true']);
|
|
62
72
|
});
|
|
63
73
|
});
|
|
74
|
+
|
|
75
|
+
// KI #139 (NAES beta 2026-10-15): a user was added to SHIELD_ROOT_IDS, the server restarted, and
|
|
76
|
+
// the user signed out and in again in the same tab. /identity/auth-facts returned the admin keys,
|
|
77
|
+
// but the admin nav stayed empty until a full reload: the ShieldClient (built once) kept the
|
|
78
|
+
// previous session's projection and served it while the permission version had not moved.
|
|
79
|
+
describe('session boundaries reset the ShieldClient (KI #139)', () => {
|
|
80
|
+
const S1 = { sessionId: 's-1', principalId: 'p1', tenantId: 't-1' };
|
|
81
|
+
const S2 = { sessionId: 's-2', principalId: 'p1', tenantId: 't-1' };
|
|
82
|
+
|
|
83
|
+
test('establishSession resets the client before fetching facts, scoped to the session', async () => {
|
|
84
|
+
const order = [];
|
|
85
|
+
await establishSession(
|
|
86
|
+
{ session: S1, tokens: { accessToken: ACCESS } },
|
|
87
|
+
{
|
|
88
|
+
resetShield: () => order.push('reset'),
|
|
89
|
+
persist: () => order.push('persist'),
|
|
90
|
+
setCurrentToken: () => order.push('token'),
|
|
91
|
+
loadProfile: async () => order.push('profile'),
|
|
92
|
+
refreshAuthFacts: async (p, scope) => order.push(`facts:${p}:${JSON.stringify(scope)}`),
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
assert.equal(order[0], 'reset');
|
|
96
|
+
assert.ok(order.includes('facts:p1:{"tenantId":"t-1","sessionId":"s-1"}'), order.join(' '));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('clearSessionViews resets the client and clears every session view', () => {
|
|
100
|
+
const calls = [];
|
|
101
|
+
clearSessionViews({
|
|
102
|
+
resetShield: () => calls.push('reset'),
|
|
103
|
+
setProfile: (v) => calls.push(`profile:${v}`),
|
|
104
|
+
setAuthFacts: (v) => calls.push(`facts:${v}`),
|
|
105
|
+
clearReenroll: () => calls.push('reenroll'),
|
|
106
|
+
});
|
|
107
|
+
assert.deepEqual(calls, ['reset', 'profile:null', 'facts:null', 'reenroll']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('crossTabChange tells a sign-out and a different session from a token rotation', () => {
|
|
111
|
+
assert.equal(crossTabChange('s-1', null), 'signed-out');
|
|
112
|
+
assert.equal(crossTabChange('s-1', { session: S2 }), 'switched');
|
|
113
|
+
assert.equal(crossTabChange(null, { session: S1 }), 'switched');
|
|
114
|
+
assert.equal(crossTabChange('s-1', { session: S1 }), 'rotated');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('factsScopeOf keys the projection by tenant and session', () => {
|
|
118
|
+
assert.deepEqual(factsScopeOf(S1), { tenantId: 't-1', sessionId: 's-1' });
|
|
119
|
+
assert.deepEqual(factsScopeOf(null), { tenantId: null, sessionId: null });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('sign out, become root, sign in again: the new session sees the root facts', async () => {
|
|
123
|
+
// The server: same tenant version before and after the restart (nothing bumped it).
|
|
124
|
+
const server = { facts: [{ action: 'identity:user:read', allowed: true }], version: 4 };
|
|
125
|
+
const http = {
|
|
126
|
+
get: async (path) =>
|
|
127
|
+
path === '/identity/auth-facts'
|
|
128
|
+
? {
|
|
129
|
+
ok: true,
|
|
130
|
+
status: 200,
|
|
131
|
+
data: {
|
|
132
|
+
principalId: 'p1',
|
|
133
|
+
facts: server.facts,
|
|
134
|
+
projectedAt: 1,
|
|
135
|
+
permissionsVersion: server.version,
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
: { ok: true, status: 200, data: { version: server.version } },
|
|
139
|
+
};
|
|
140
|
+
const shield = createShieldClient({
|
|
141
|
+
transport: createWebShieldTransport({ http, getToken: () => ACCESS }),
|
|
142
|
+
});
|
|
143
|
+
let authFacts = null;
|
|
144
|
+
const sessionDeps = {
|
|
145
|
+
resetShield: () => shield.reset(),
|
|
146
|
+
persist: () => {},
|
|
147
|
+
setCurrentToken: () => {},
|
|
148
|
+
loadProfile: async () => {},
|
|
149
|
+
refreshAuthFacts: async (p, scope) => {
|
|
150
|
+
authFacts = await shield.getAuthFacts(p, scope);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
await establishSession({ session: S1, tokens: { accessToken: ACCESS } }, sessionDeps);
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
authFacts.facts.map((f) => f.action),
|
|
157
|
+
['identity:user:read'],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Sign out (AuthContext.logout → resetSessionViews), then the server restarts with the
|
|
161
|
+
// user in SHIELD_ROOT_IDS.
|
|
162
|
+
clearSessionViews({
|
|
163
|
+
resetShield: () => shield.reset(),
|
|
164
|
+
setProfile: () => {},
|
|
165
|
+
setAuthFacts: (v) => {
|
|
166
|
+
authFacts = v;
|
|
167
|
+
},
|
|
168
|
+
clearReenroll: () => {},
|
|
169
|
+
});
|
|
170
|
+
server.facts = [
|
|
171
|
+
{ action: 'identity:user:read', allowed: true },
|
|
172
|
+
{ action: 'identity:role:update', allowed: true },
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
await establishSession({ session: S2, tokens: { accessToken: ACCESS } }, sessionDeps);
|
|
176
|
+
assert.deepEqual(
|
|
177
|
+
authFacts.facts.map((f) => f.action),
|
|
178
|
+
['identity:user:read', 'identity:role:update'],
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// AuthContext.jsx is JSX (no runner here), so check its wiring to the helpers above.
|
|
183
|
+
test('AuthContext wires every session boundary to the reset', () => {
|
|
184
|
+
const src = readFileSync(new URL('./AuthContext.jsx', import.meta.url), 'utf8');
|
|
185
|
+
const count = (needle) => src.split(needle).length - 1;
|
|
186
|
+
// resetSessionViews (logout, expiry, cross-tab), establishSession (login, MFA) and
|
|
187
|
+
// deviceConfirmed (a device confirm lifts a restriction) all reset.
|
|
188
|
+
assert.equal(count('resetShield: () => shield.reset()'), 3);
|
|
189
|
+
assert.ok(src.includes('clearSessionViews({'), 'resetSessionViews uses clearSessionViews');
|
|
190
|
+
const logout = src.slice(src.indexOf('const logout = async'), src.indexOf('rawAuth.logout('));
|
|
191
|
+
assert.ok(logout.includes('resetSessionViews()'), 'logout resets the session views');
|
|
192
|
+
const at = src.lastIndexOf('expired: () => {'); // the first one is the ref's no-op default
|
|
193
|
+
const expired = src.slice(at, at + 300);
|
|
194
|
+
assert.ok(expired.includes('resetSessionViews()'), 'expiry resets the session views');
|
|
195
|
+
assert.ok(src.includes('crossTabChange(prevSessionId, next)'), 'cross-tab change classified');
|
|
196
|
+
// Every facts fetch is scoped to the session it is for.
|
|
197
|
+
assert.equal(count('shield.getAuthFacts(principalId, scope)'), 1);
|
|
198
|
+
assert.ok(!/refreshAuthFacts\(principalOf\([a-z]+\)\)/.test(src), 'no unscoped facts fetch');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// QA pass 1 (P1): after a device confirm on a restricted session (reenroll / mfa_enrollment) the
|
|
203
|
+
// nav and gated pages stayed denied until the 60 s poll, and the grace banner stayed: nothing
|
|
204
|
+
// refetched the auth facts (the ShieldClient still held the restricted projection) or /auth/me.
|
|
205
|
+
describe('afterDeviceConfirmed', () => {
|
|
206
|
+
test('resets the client, refetches facts and profile, then lifts the restriction', async () => {
|
|
207
|
+
const calls = [];
|
|
208
|
+
let releaseFacts;
|
|
209
|
+
const factsDone = new Promise((r) => (releaseFacts = r));
|
|
210
|
+
const run = afterDeviceConfirmed({
|
|
211
|
+
resetShield: () => calls.push('reset'),
|
|
212
|
+
refreshAuthFacts: async () => {
|
|
213
|
+
calls.push('facts:start');
|
|
214
|
+
await factsDone;
|
|
215
|
+
calls.push('facts:done');
|
|
216
|
+
},
|
|
217
|
+
loadProfile: async () => calls.push('profile'),
|
|
218
|
+
clearRestriction: () => calls.push('clear'),
|
|
219
|
+
});
|
|
220
|
+
await Promise.resolve();
|
|
221
|
+
assert.ok(
|
|
222
|
+
!calls.includes('clear'),
|
|
223
|
+
'restriction kept until the facts are back (no deny flash)',
|
|
224
|
+
);
|
|
225
|
+
releaseFacts();
|
|
226
|
+
await run;
|
|
227
|
+
assert.equal(calls[0], 'reset', 'reset before any fetch (KI #139)');
|
|
228
|
+
assert.ok(calls.indexOf('facts:done') < calls.indexOf('clear'));
|
|
229
|
+
assert.ok(calls.indexOf('profile') < calls.indexOf('clear'));
|
|
230
|
+
assert.equal(calls.at(-1), 'clear');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('a failed refetch still lifts the restriction (server already lifted it)', async () => {
|
|
234
|
+
const calls = [];
|
|
235
|
+
await afterDeviceConfirmed({
|
|
236
|
+
resetShield: () => {},
|
|
237
|
+
refreshAuthFacts: async () => {
|
|
238
|
+
throw new Error('down');
|
|
239
|
+
},
|
|
240
|
+
loadProfile: async () => {
|
|
241
|
+
throw new Error('down');
|
|
242
|
+
},
|
|
243
|
+
clearRestriction: () => calls.push('clear'),
|
|
244
|
+
});
|
|
245
|
+
assert.deepEqual(calls, ['clear']);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('the template wires every device confirm through it', () => {
|
|
249
|
+
const ctx = readFileSync(new URL('./AuthContext.jsx', import.meta.url), 'utf8');
|
|
250
|
+
assert.ok(ctx.includes('afterDeviceConfirmed({'), 'AuthContext runs afterDeviceConfirmed');
|
|
251
|
+
assert.ok(/deviceConfirmed[,:]/.test(ctx), 'useAuth() exposes deviceConfirmed');
|
|
252
|
+
const two = readFileSync(
|
|
253
|
+
new URL('../pages/account/TwoFactorPage.jsx', import.meta.url),
|
|
254
|
+
'utf8',
|
|
255
|
+
);
|
|
256
|
+
const confirm = two.slice(
|
|
257
|
+
two.indexOf('const onConfirm = async'),
|
|
258
|
+
two.indexOf('// ---- Rename'),
|
|
259
|
+
);
|
|
260
|
+
assert.ok(
|
|
261
|
+
confirm.includes('await deviceConfirmed()'),
|
|
262
|
+
'TwoFactorPage refetches on every confirm, with or without a parent callback',
|
|
263
|
+
);
|
|
264
|
+
assert.ok(
|
|
265
|
+
confirm.indexOf('await deviceConfirmed()') < confirm.indexOf('onDeviceConfirmed?.()'),
|
|
266
|
+
'parent callback after the refetch',
|
|
267
|
+
);
|
|
268
|
+
const reenroll = readFileSync(
|
|
269
|
+
new URL('../pages/account/ReenrollPage.jsx', import.meta.url),
|
|
270
|
+
'utf8',
|
|
271
|
+
);
|
|
272
|
+
assert.ok(!reenroll.includes('clearReenroll()'), 'ReenrollPage no longer lifts the gate early');
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// signInOptions.js — the sign-in pages' label and CAPTCHA decisions (R13.2, R7.6, D-10).
|
|
2
|
+
//
|
|
3
|
+
// `GET /auth/login-options` (via `authService.loginOptions()`) says which names the server
|
|
4
|
+
// accepts and whether CAPTCHA is on. The page label follows `signInWith`; the CAPTCHA widget
|
|
5
|
+
// loads only when the server's provider matches the one this build's CSP allows
|
|
6
|
+
// (`BUILD_CAPTCHA_PROVIDER`), otherwise the page shows a fixed notice and loads nothing.
|
|
7
|
+
// React-free so `node --test` can run it; the pages stay thin views over these rules.
|
|
8
|
+
import { captchaMismatch } from './captchaProvider.js';
|
|
9
|
+
|
|
10
|
+
/** The fixed misconfiguration notice (R7.6): server and build name different providers. */
|
|
11
|
+
export const MISCONFIGURED_NOTICE =
|
|
12
|
+
'Sign-in protection is misconfigured, contact your administrator';
|
|
13
|
+
|
|
14
|
+
/** The CAPTCHA action per endpoint; the server checks it (R7.4). */
|
|
15
|
+
export const CAPTCHA_ACTIONS = Object.freeze({
|
|
16
|
+
login: 'login',
|
|
17
|
+
forgot: 'forgot_password',
|
|
18
|
+
reset: 'reset_password',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The sign-in field label. "Email" until the options are known (or when they fail to load),
|
|
23
|
+
* "Email or username" once the server says login names are accepted.
|
|
24
|
+
* @param {string[] | null | undefined} signInWith
|
|
25
|
+
*/
|
|
26
|
+
export function signInLabel(signInWith) {
|
|
27
|
+
return Array.isArray(signInWith) && signInWith.includes('username')
|
|
28
|
+
? 'Email or username'
|
|
29
|
+
: 'Email';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The CAPTCHA state for this page.
|
|
34
|
+
* { status: 'off' } — the server has no CAPTCHA
|
|
35
|
+
* { status: 'on', provider, siteKey, mode } — load the widget when visible
|
|
36
|
+
* { status: 'misconfigured', serverProvider, buildProvider } — show the notice, load nothing
|
|
37
|
+
* @param {{ provider: string, siteKey: string, mode: string } | null | undefined} captcha
|
|
38
|
+
* the `captcha` block from loginOptions()
|
|
39
|
+
* @param {string | null} buildProvider `BUILD_CAPTCHA_PROVIDER`
|
|
40
|
+
*/
|
|
41
|
+
export function captchaSetup(captcha, buildProvider) {
|
|
42
|
+
if (!captcha) return { status: 'off' };
|
|
43
|
+
if (captchaMismatch(captcha.provider, buildProvider)) {
|
|
44
|
+
return {
|
|
45
|
+
status: 'misconfigured',
|
|
46
|
+
serverProvider: captcha.provider,
|
|
47
|
+
buildProvider: buildProvider ?? null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { status: 'on', provider: captcha.provider, siteKey: captcha.siteKey, mode: captcha.mode };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether the widget is shown: CAPTCHA on and either `always` mode or the server asked for it
|
|
55
|
+
* after a failed attempt (`captchaRequired`).
|
|
56
|
+
*/
|
|
57
|
+
export function captchaVisible(setup, required) {
|
|
58
|
+
return setup?.status === 'on' && (setup.mode === 'always' || required === true);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The service-call options for a token: `{ captchaToken }`, or undefined when there is none. */
|
|
62
|
+
export function captchaOpts(token) {
|
|
63
|
+
return typeof token === 'string' && token !== '' ? { captchaToken: token } : undefined;
|
|
64
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// signInOptions.test.mjs — the login page's label and CAPTCHA decisions (R13.2, R7.6, D-10,
|
|
2
|
+
// AC-23, AC-24). React-free, so the page stays a thin view over these rules.
|
|
3
|
+
//
|
|
4
|
+
// node --test src/auth/signInOptions.test.mjs
|
|
5
|
+
import { test, describe } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import {
|
|
8
|
+
CAPTCHA_ACTIONS,
|
|
9
|
+
MISCONFIGURED_NOTICE,
|
|
10
|
+
captchaOpts,
|
|
11
|
+
captchaSetup,
|
|
12
|
+
captchaVisible,
|
|
13
|
+
signInLabel,
|
|
14
|
+
} from './signInOptions.js';
|
|
15
|
+
|
|
16
|
+
const turnstile = { provider: 'turnstile', siteKey: 'site-key', mode: 'adaptive' };
|
|
17
|
+
|
|
18
|
+
describe('signInLabel — the label follows signInWith (AC-23)', () => {
|
|
19
|
+
test('email only → "Email"', () => {
|
|
20
|
+
assert.equal(signInLabel(['email']), 'Email');
|
|
21
|
+
});
|
|
22
|
+
test('email + username → "Email or username"', () => {
|
|
23
|
+
assert.equal(signInLabel(['email', 'username']), 'Email or username');
|
|
24
|
+
});
|
|
25
|
+
test('not loaded yet (or failed) → "Email"', () => {
|
|
26
|
+
assert.equal(signInLabel(null), 'Email');
|
|
27
|
+
assert.equal(signInLabel(undefined), 'Email');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('captchaSetup — server provider vs the build (R7.6, D-10)', () => {
|
|
32
|
+
test('server CAPTCHA off → off, even when the build names a provider', () => {
|
|
33
|
+
assert.deepEqual(captchaSetup(null, 'turnstile'), { status: 'off' });
|
|
34
|
+
assert.deepEqual(captchaSetup(undefined, null), { status: 'off' });
|
|
35
|
+
});
|
|
36
|
+
test('same provider → on, with the site key and mode', () => {
|
|
37
|
+
assert.deepEqual(captchaSetup(turnstile, 'turnstile'), {
|
|
38
|
+
status: 'on',
|
|
39
|
+
provider: 'turnstile',
|
|
40
|
+
siteKey: 'site-key',
|
|
41
|
+
mode: 'adaptive',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
test('different provider → misconfigured, naming both values', () => {
|
|
45
|
+
assert.deepEqual(captchaSetup(turnstile, 'recaptcha-v3'), {
|
|
46
|
+
status: 'misconfigured',
|
|
47
|
+
serverProvider: 'turnstile',
|
|
48
|
+
buildProvider: 'recaptcha-v3',
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
test('server on, build made without a provider → misconfigured (no widget can load under the CSP)', () => {
|
|
52
|
+
assert.equal(captchaSetup(turnstile, null).status, 'misconfigured');
|
|
53
|
+
});
|
|
54
|
+
test('the notice is the fixed text', () => {
|
|
55
|
+
assert.equal(
|
|
56
|
+
MISCONFIGURED_NOTICE,
|
|
57
|
+
'Sign-in protection is misconfigured, contact your administrator',
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('captchaVisible — renders captcha on demand (AC-24)', () => {
|
|
63
|
+
const on = captchaSetup(turnstile, 'turnstile');
|
|
64
|
+
const always = captchaSetup({ ...turnstile, mode: 'always' }, 'turnstile');
|
|
65
|
+
test('adaptive: hidden until the server asks', () => {
|
|
66
|
+
assert.equal(captchaVisible(on, false), false);
|
|
67
|
+
assert.equal(captchaVisible(on, true), true);
|
|
68
|
+
});
|
|
69
|
+
test('always: shown from the start', () => {
|
|
70
|
+
assert.equal(captchaVisible(always, false), true);
|
|
71
|
+
});
|
|
72
|
+
test('off or misconfigured: never shown, even when the server asks', () => {
|
|
73
|
+
assert.equal(captchaVisible({ status: 'off' }, true), false);
|
|
74
|
+
assert.equal(captchaVisible(captchaSetup(turnstile, 'recaptcha-v2'), true), false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('captchaOpts — the retry sends the token', () => {
|
|
79
|
+
test('a token → { captchaToken }', () => {
|
|
80
|
+
assert.deepEqual(captchaOpts('tok'), { captchaToken: 'tok' });
|
|
81
|
+
});
|
|
82
|
+
test('no token → undefined (nothing sent)', () => {
|
|
83
|
+
assert.equal(captchaOpts(''), undefined);
|
|
84
|
+
assert.equal(captchaOpts(null), undefined);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('action names match the endpoints the server checks (R7.4)', () => {
|
|
89
|
+
assert.deepEqual(CAPTCHA_ACTIONS, {
|
|
90
|
+
login: 'login',
|
|
91
|
+
forgot: 'forgot_password',
|
|
92
|
+
reset: 'reset_password',
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// useSignInOptions — reads `GET /auth/login-options` once per mount for the sign-in pages
|
|
2
|
+
// (login, forgot password, reset / set password): the login label's `signInWith` and the
|
|
3
|
+
// CAPTCHA setup checked against this build's provider (R7.6, D-10). On a provider mismatch it
|
|
4
|
+
// logs one console error naming both values; the page shows the fixed notice and loads no widget.
|
|
5
|
+
// A failed load reads as "email only, CAPTCHA off" (the server still enforces its own rules).
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
import { BUILD_CAPTCHA_PROVIDER } from './captchaProvider.js';
|
|
8
|
+
import { captchaSetup } from './signInOptions.js';
|
|
9
|
+
|
|
10
|
+
const INITIAL = { loaded: false, signInWith: null, captcha: { status: 'off' } };
|
|
11
|
+
|
|
12
|
+
/** @param {import('@astram/frontend-core').AuthService} auth */
|
|
13
|
+
export function useSignInOptions(auth) {
|
|
14
|
+
const [options, setOptions] = useState(INITIAL);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let live = true;
|
|
17
|
+
void auth.loginOptions().then((r) => {
|
|
18
|
+
if (!live) return;
|
|
19
|
+
const captcha = r.ok ? captchaSetup(r.captcha, BUILD_CAPTCHA_PROVIDER) : { status: 'off' };
|
|
20
|
+
if (captcha.status === 'misconfigured') {
|
|
21
|
+
console.error(
|
|
22
|
+
`Sign-in protection is misconfigured: the server uses CAPTCHA provider ` +
|
|
23
|
+
`"${captcha.serverProvider}" but this build was made for ` +
|
|
24
|
+
`"${captcha.buildProvider ?? 'none'}" (VITE_CAPTCHA_PROVIDER). No widget is loaded.`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
setOptions({ loaded: true, signInWith: r.ok ? r.signInWith : null, captcha });
|
|
28
|
+
});
|
|
29
|
+
return () => {
|
|
30
|
+
live = false;
|
|
31
|
+
};
|
|
32
|
+
}, [auth]);
|
|
33
|
+
return options;
|
|
34
|
+
}
|
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
* (through AuthContext). ANTI-ENUMERATION: the server always returns the same 204 and
|
|
5
5
|
* this page ALWAYS shows the SAME neutral confirmation — it never reveals whether an
|
|
6
6
|
* account matched.
|
|
7
|
+
*
|
|
8
|
+
* CAPTCHA (R7.6): when the server has CAPTCHA on, the widget always shows here and its token
|
|
9
|
+
* goes with the request (the server answers 204 either way and checks the token after). A
|
|
10
|
+
* provider mismatch with this build shows the fixed notice and loads no widget.
|
|
7
11
|
*/
|
|
8
|
-
import { useState } from 'react';
|
|
12
|
+
import { useRef, useState } from 'react';
|
|
9
13
|
import { Link } from 'react-router-dom';
|
|
10
|
-
import { ArrowLeft, Loader2, MailCheck } from 'lucide-react';
|
|
14
|
+
import { AlertCircle, ArrowLeft, Loader2, MailCheck } from 'lucide-react';
|
|
11
15
|
import { useAuth } from '@/auth/AuthContext';
|
|
16
|
+
import CaptchaWidget from '@/auth/CaptchaWidget';
|
|
17
|
+
import { useSignInOptions } from '@/auth/useSignInOptions';
|
|
18
|
+
import { CAPTCHA_ACTIONS, MISCONFIGURED_NOTICE, captchaOpts } from '@/auth/signInOptions';
|
|
12
19
|
import { Button } from '@/components/ui/button';
|
|
13
20
|
import { Input } from '@/components/ui/input';
|
|
14
21
|
import { Label } from '@/components/ui/label';
|
|
@@ -25,14 +32,29 @@ export default function ForgotPassword() {
|
|
|
25
32
|
const [identityHint, setIdentityHint] = useState('');
|
|
26
33
|
const [sent, setSent] = useState(false);
|
|
27
34
|
const [busy, setBusy] = useState(false);
|
|
35
|
+
const [error, setError] = useState('');
|
|
36
|
+
const signIn = useSignInOptions(auth);
|
|
37
|
+
const showCaptcha = signIn.captcha.status === 'on';
|
|
38
|
+
const captchaRef = useRef(null);
|
|
28
39
|
|
|
29
40
|
const onSubmit = async (e) => {
|
|
30
41
|
e.preventDefault();
|
|
42
|
+
setError('');
|
|
31
43
|
if (busy || !identityHint.trim()) return;
|
|
32
44
|
setBusy(true);
|
|
45
|
+
let token = '';
|
|
46
|
+
if (showCaptcha) {
|
|
47
|
+
token = (await captchaRef.current?.getToken()) ?? '';
|
|
48
|
+
if (!token) {
|
|
49
|
+
setBusy(false);
|
|
50
|
+
setError('Complete the verification check, then send the link.');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
33
54
|
// requestReset ALWAYS resolves ok (mirrors the server's anti-enumeration); we
|
|
34
55
|
// show the SAME neutral confirmation whether or not an account matched.
|
|
35
|
-
await auth.requestReset(identityHint.trim());
|
|
56
|
+
await auth.requestReset(identityHint.trim(), captchaOpts(token));
|
|
57
|
+
if (showCaptcha) captchaRef.current?.reset(); // single-use token
|
|
36
58
|
setBusy(false);
|
|
37
59
|
setSent(true);
|
|
38
60
|
};
|
|
@@ -65,6 +87,23 @@ export default function ForgotPassword() {
|
|
|
65
87
|
</div>
|
|
66
88
|
</div>
|
|
67
89
|
|
|
90
|
+
{!sent && signIn.captcha.status === 'misconfigured' && (
|
|
91
|
+
<div
|
|
92
|
+
role="alert"
|
|
93
|
+
className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive"
|
|
94
|
+
>
|
|
95
|
+
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
96
|
+
<span>{MISCONFIGURED_NOTICE}</span>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{!sent && error && (
|
|
101
|
+
<div className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
|
|
102
|
+
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
103
|
+
<span>{error}</span>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
|
|
68
107
|
{sent ? (
|
|
69
108
|
<div className="flex items-start gap-2 rounded-lg border border-primary/30 bg-primary/10 px-3 py-3 text-sm text-primary">
|
|
70
109
|
<MailCheck className="mt-0.5 size-4 shrink-0" />
|
|
@@ -82,6 +121,13 @@ export default function ForgotPassword() {
|
|
|
82
121
|
placeholder="you@example.com"
|
|
83
122
|
/>
|
|
84
123
|
</div>
|
|
124
|
+
{showCaptcha && (
|
|
125
|
+
<CaptchaWidget
|
|
126
|
+
ref={captchaRef}
|
|
127
|
+
setup={signIn.captcha}
|
|
128
|
+
action={CAPTCHA_ACTIONS.forgot}
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
85
131
|
<Button type="submit" disabled={busy} className="w-full">
|
|
86
132
|
{busy ? (
|
|
87
133
|
<Loader2 className="size-4 animate-spin" />
|