@actuate-media/cms-admin 0.27.0 → 0.29.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/AdminRoot.d.ts +7 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +7 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/views/account-security.render.test.d.ts +2 -0
- package/dist/__tests__/views/account-security.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/account-security.render.test.js +98 -0
- package/dist/__tests__/views/account-security.render.test.js.map +1 -0
- package/dist/__tests__/views/login-mfa.render.test.d.ts +2 -0
- package/dist/__tests__/views/login-mfa.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/login-mfa.render.test.js +73 -0
- package/dist/__tests__/views/login-mfa.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/security-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +175 -0
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +1 -0
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/views/Login.d.ts +16 -1
- package/dist/views/Login.d.ts.map +1 -1
- package/dist/views/Login.js +55 -3
- package/dist/views/Login.js.map +1 -1
- package/dist/views/Profile.d.ts +12 -0
- package/dist/views/Profile.d.ts.map +1 -0
- package/dist/views/Profile.js +16 -0
- package/dist/views/Profile.js.map +1 -0
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +3 -11
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/profile/AccountSecurity.d.ts +2 -0
- package/dist/views/profile/AccountSecurity.d.ts.map +1 -0
- package/dist/views/profile/AccountSecurity.js +117 -0
- package/dist/views/profile/AccountSecurity.js.map +1 -0
- package/dist/views/profile/useMfaEnrollment.d.ts +35 -0
- package/dist/views/profile/useMfaEnrollment.d.ts.map +1 -0
- package/dist/views/profile/useMfaEnrollment.js +103 -0
- package/dist/views/profile/useMfaEnrollment.js.map +1 -0
- package/dist/views/settings/AccessProtectionCard.d.ts +10 -0
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -0
- package/dist/views/settings/AccessProtectionCard.js +20 -0
- package/dist/views/settings/AccessProtectionCard.js.map +1 -0
- package/dist/views/settings/AuthMfaCard.d.ts +11 -0
- package/dist/views/settings/AuthMfaCard.d.ts.map +1 -0
- package/dist/views/settings/AuthMfaCard.js +34 -0
- package/dist/views/settings/AuthMfaCard.js.map +1 -0
- package/dist/views/settings/SecretsAuditCard.d.ts +11 -0
- package/dist/views/settings/SecretsAuditCard.d.ts.map +1 -0
- package/dist/views/settings/SecretsAuditCard.js +22 -0
- package/dist/views/settings/SecretsAuditCard.js.map +1 -0
- package/dist/views/settings/SecurityStatusCard.d.ts +6 -0
- package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -0
- package/dist/views/settings/SecurityStatusCard.js +35 -0
- package/dist/views/settings/SecurityStatusCard.js.map +1 -0
- package/dist/views/settings/SecurityTab.d.ts +5 -0
- package/dist/views/settings/SecurityTab.d.ts.map +1 -0
- package/dist/views/settings/SecurityTab.js +41 -0
- package/dist/views/settings/SecurityTab.js.map +1 -0
- package/dist/views/settings/SessionPolicyCard.d.ts +11 -0
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -0
- package/dist/views/settings/SessionPolicyCard.js +21 -0
- package/dist/views/settings/SessionPolicyCard.js.map +1 -0
- package/dist/views/settings/securityPrimitives.d.ts +21 -0
- package/dist/views/settings/securityPrimitives.d.ts.map +1 -0
- package/dist/views/settings/securityPrimitives.js +31 -0
- package/dist/views/settings/securityPrimitives.js.map +1 -0
- package/dist/views/settings/useSecuritySettings.d.ts +37 -0
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -0
- package/dist/views/settings/useSecuritySettings.js +154 -0
- package/dist/views/settings/useSecuritySettings.js.map +1 -0
- package/package.json +3 -2
- package/src/AdminRoot.tsx +18 -0
- package/src/__tests__/views/account-security.render.test.tsx +140 -0
- package/src/__tests__/views/login-mfa.render.test.tsx +88 -0
- package/src/__tests__/views/security-settings.render.test.tsx +212 -0
- package/src/components/Breadcrumbs.tsx +1 -0
- package/src/views/Login.tsx +247 -94
- package/src/views/Profile.tsx +54 -0
- package/src/views/Settings.tsx +3 -33
- package/src/views/profile/AccountSecurity.tsx +406 -0
- package/src/views/profile/useMfaEnrollment.ts +120 -0
- package/src/views/settings/AccessProtectionCard.tsx +143 -0
- package/src/views/settings/AuthMfaCard.tsx +133 -0
- package/src/views/settings/SecretsAuditCard.tsx +129 -0
- package/src/views/settings/SecurityStatusCard.tsx +110 -0
- package/src/views/settings/SecurityTab.tsx +163 -0
- package/src/views/settings/SessionPolicyCard.tsx +123 -0
- package/src/views/settings/securityPrimitives.tsx +75 -0
- package/src/views/settings/useSecuritySettings.ts +197 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { validateSecuritySettings, } from '@actuate-media/cms-core/security-center';
|
|
4
|
+
import { cmsApi } from '../../lib/api.js';
|
|
5
|
+
const EMPTY_FORM = {
|
|
6
|
+
mfaRequired: false,
|
|
7
|
+
mfaGracePeriodDays: 7,
|
|
8
|
+
maxConcurrentSessions: 5,
|
|
9
|
+
};
|
|
10
|
+
function formFromSettings(s) {
|
|
11
|
+
return {
|
|
12
|
+
mfaRequired: s.mfa.required,
|
|
13
|
+
mfaGracePeriodDays: s.mfa.gracePeriodDays,
|
|
14
|
+
maxConcurrentSessions: s.session.maxConcurrentSessions,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function formToSettings(f) {
|
|
18
|
+
return {
|
|
19
|
+
mfa: { required: f.mfaRequired, gracePeriodDays: f.mfaGracePeriodDays },
|
|
20
|
+
session: { maxConcurrentSessions: f.maxConcurrentSessions },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Loads the Security Center (`GET /security`) — computed posture, cross-module
|
|
25
|
+
* summaries, secret/encryption verdicts — and tracks the small editable policy
|
|
26
|
+
* (MFA requirement + grace, concurrent-session cap). Save is lockout-safe: the
|
|
27
|
+
* client mirrors the server's MFA guard so the operator gets immediate feedback,
|
|
28
|
+
* and the server re-checks authoritatively.
|
|
29
|
+
*/
|
|
30
|
+
export function useSecuritySettings(canEdit) {
|
|
31
|
+
const [loading, setLoading] = useState(true);
|
|
32
|
+
const [loadError, setLoadError] = useState(null);
|
|
33
|
+
const [data, setData] = useState(null);
|
|
34
|
+
const [baseline, setBaseline] = useState(EMPTY_FORM);
|
|
35
|
+
const [form, setForm] = useState(EMPTY_FORM);
|
|
36
|
+
const [saveState, setSaveState] = useState('idle');
|
|
37
|
+
const [saveError, setSaveError] = useState(null);
|
|
38
|
+
const [revokeState, setRevokeState] = useState('idle');
|
|
39
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
setLoading(true);
|
|
43
|
+
setLoadError(null);
|
|
44
|
+
cmsApi('/security')
|
|
45
|
+
.then((res) => {
|
|
46
|
+
if (cancelled)
|
|
47
|
+
return;
|
|
48
|
+
if (res.error || !res.data) {
|
|
49
|
+
setLoadError(res.error ?? 'Failed to load security settings.');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setData(res.data);
|
|
53
|
+
const f = formFromSettings(res.data.settings);
|
|
54
|
+
setBaseline(f);
|
|
55
|
+
setForm(f);
|
|
56
|
+
})
|
|
57
|
+
.catch(() => {
|
|
58
|
+
if (!cancelled)
|
|
59
|
+
setLoadError('Failed to load security settings.');
|
|
60
|
+
})
|
|
61
|
+
.finally(() => {
|
|
62
|
+
if (!cancelled)
|
|
63
|
+
setLoading(false);
|
|
64
|
+
});
|
|
65
|
+
return () => {
|
|
66
|
+
cancelled = true;
|
|
67
|
+
};
|
|
68
|
+
}, [reloadKey]);
|
|
69
|
+
const setField = useCallback((key, value) => {
|
|
70
|
+
setForm((prev) => ({ ...prev, [key]: value }));
|
|
71
|
+
setSaveState('idle');
|
|
72
|
+
}, []);
|
|
73
|
+
const dirty = useMemo(() => Object.keys(form).some((k) => form[k] !== baseline[k]), [form, baseline]);
|
|
74
|
+
const issues = useMemo(() => {
|
|
75
|
+
const list = validateSecuritySettings(formToSettings(form));
|
|
76
|
+
// Client mirror of the server's lockout-safe MFA guard: block enabling
|
|
77
|
+
// org-wide MFA when the current admin has not set up MFA themselves.
|
|
78
|
+
if (form.mfaRequired && !baseline.mfaRequired && data && !data.viewerHasMfa) {
|
|
79
|
+
list.push({
|
|
80
|
+
field: 'mfaRequired',
|
|
81
|
+
message: 'Set up MFA for your own account before requiring it for everyone — otherwise you would lock yourself out.',
|
|
82
|
+
severity: 'error',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return list;
|
|
86
|
+
}, [form, baseline.mfaRequired, data]);
|
|
87
|
+
const hasErrors = issues.some((i) => i.severity === 'error');
|
|
88
|
+
const save = useCallback(async () => {
|
|
89
|
+
if (!canEdit || hasErrors || !dirty)
|
|
90
|
+
return;
|
|
91
|
+
setSaveState('saving');
|
|
92
|
+
setSaveError(null);
|
|
93
|
+
try {
|
|
94
|
+
const res = await cmsApi('/security', {
|
|
95
|
+
method: 'PUT',
|
|
96
|
+
body: JSON.stringify(formToSettings(form)),
|
|
97
|
+
});
|
|
98
|
+
if (res.error || !res.data)
|
|
99
|
+
throw new Error(res.error ?? 'Failed to save security settings.');
|
|
100
|
+
setData(res.data);
|
|
101
|
+
const f = formFromSettings(res.data.settings);
|
|
102
|
+
setBaseline(f);
|
|
103
|
+
setForm(f);
|
|
104
|
+
setSaveState('success');
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
setSaveState('error');
|
|
108
|
+
setSaveError(err instanceof Error ? err.message : 'Failed to save security settings.');
|
|
109
|
+
}
|
|
110
|
+
}, [canEdit, hasErrors, dirty, form]);
|
|
111
|
+
const reset = useCallback(() => {
|
|
112
|
+
setForm(baseline);
|
|
113
|
+
setSaveState('idle');
|
|
114
|
+
setSaveError(null);
|
|
115
|
+
}, [baseline]);
|
|
116
|
+
const refetch = useCallback(() => setReloadKey((k) => k + 1), []);
|
|
117
|
+
const revokeAllOtherSessions = useCallback(async () => {
|
|
118
|
+
setRevokeState('working');
|
|
119
|
+
try {
|
|
120
|
+
const res = await cmsApi('/security/sessions/revoke-all', {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
});
|
|
123
|
+
if (res.error)
|
|
124
|
+
throw new Error(res.error);
|
|
125
|
+
setRevokeState('done');
|
|
126
|
+
// Refresh the active-session count.
|
|
127
|
+
setReloadKey((k) => k + 1);
|
|
128
|
+
return res.data?.revoked ?? 0;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
setRevokeState('error');
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}, []);
|
|
135
|
+
return {
|
|
136
|
+
loading,
|
|
137
|
+
loadError,
|
|
138
|
+
data,
|
|
139
|
+
form,
|
|
140
|
+
setField,
|
|
141
|
+
dirty,
|
|
142
|
+
issues,
|
|
143
|
+
hasErrors,
|
|
144
|
+
canEdit,
|
|
145
|
+
saveState,
|
|
146
|
+
saveError,
|
|
147
|
+
save,
|
|
148
|
+
reset,
|
|
149
|
+
refetch,
|
|
150
|
+
revokeState,
|
|
151
|
+
revokeAllOtherSessions,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=useSecuritySettings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useSecuritySettings.js","sourceRoot":"","sources":["../../../src/views/settings/useSecuritySettings.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"cmdk": "^1.1.1",
|
|
59
59
|
"lowlight": "^3.0.0",
|
|
60
60
|
"lucide-react": "^1.7.0",
|
|
61
|
+
"qrcode.react": "^4.2.0",
|
|
61
62
|
"react": "^19.2.0",
|
|
62
63
|
"react-dom": "^19.2.0",
|
|
63
64
|
"recharts": "^3.8.1",
|
|
@@ -87,7 +88,7 @@
|
|
|
87
88
|
"tailwindcss": "^4.0.0",
|
|
88
89
|
"typescript": "^5.7.0",
|
|
89
90
|
"vitest": "^4.1.0",
|
|
90
|
-
"@actuate-media/cms-core": "0.
|
|
91
|
+
"@actuate-media/cms-core": "0.38.0",
|
|
91
92
|
"@actuate-media/component-blocks": "0.2.0"
|
|
92
93
|
},
|
|
93
94
|
"scripts": {
|
package/src/AdminRoot.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { CollectionList } from './views/CollectionList.js'
|
|
|
10
10
|
import { DocumentEdit } from './views/DocumentEdit.js'
|
|
11
11
|
import { MediaBrowser } from './views/MediaBrowser.js'
|
|
12
12
|
import { Settings } from './views/Settings.js'
|
|
13
|
+
import { Profile } from './views/Profile.js'
|
|
13
14
|
import { Forms } from './views/Forms.js'
|
|
14
15
|
import { FormEditor } from './views/FormEditor.js'
|
|
15
16
|
import { FormSubmissions } from './views/FormSubmissions.js'
|
|
@@ -56,6 +57,16 @@ export interface AdminRootProps {
|
|
|
56
57
|
email: string,
|
|
57
58
|
password: string,
|
|
58
59
|
captchaToken?: string,
|
|
60
|
+
) => Promise<{
|
|
61
|
+
success: boolean
|
|
62
|
+
error?: string
|
|
63
|
+
requiresTOTP?: boolean
|
|
64
|
+
mfaPendingToken?: string
|
|
65
|
+
}>
|
|
66
|
+
/** Completes the TOTP second-factor step for accounts with MFA enabled. */
|
|
67
|
+
onVerifyTotp?: (
|
|
68
|
+
mfaPendingToken: string,
|
|
69
|
+
code: string,
|
|
59
70
|
) => Promise<{ success: boolean; error?: string }>
|
|
60
71
|
captchaConfig?: { provider: 'recaptcha' | 'turnstile' | 'none'; siteKey: string | null }
|
|
61
72
|
}
|
|
@@ -68,6 +79,7 @@ function AdminShell({
|
|
|
68
79
|
setupRequired,
|
|
69
80
|
onSetupComplete,
|
|
70
81
|
onLogin,
|
|
82
|
+
onVerifyTotp,
|
|
71
83
|
captchaConfig,
|
|
72
84
|
}: AdminRootProps) {
|
|
73
85
|
const { currentPath, navigate, matchRoute, buildHref } = useAdminRouter(basePath, initialPath)
|
|
@@ -197,6 +209,7 @@ function AdminShell({
|
|
|
197
209
|
return (
|
|
198
210
|
<Login
|
|
199
211
|
onLogin={onLogin}
|
|
212
|
+
onVerifyTotp={onVerifyTotp}
|
|
200
213
|
onNavigate={navigate}
|
|
201
214
|
captchaConfig={captchaConfig}
|
|
202
215
|
branding={branding}
|
|
@@ -437,6 +450,10 @@ function AdminShell({
|
|
|
437
450
|
return <Settings onNavigate={navigate} config={config} session={session} />
|
|
438
451
|
}
|
|
439
452
|
|
|
453
|
+
if (matchRoute('/profile')) {
|
|
454
|
+
return <Profile onNavigate={navigate} session={session} />
|
|
455
|
+
}
|
|
456
|
+
|
|
440
457
|
// Page Builder routes — explicit /page-builder/* takes precedence over
|
|
441
458
|
// the collection-derived routing below, but we ALSO route a collection
|
|
442
459
|
// declared as `type: 'page'` through the Page Builder by default.
|
|
@@ -467,6 +484,7 @@ function AdminShell({
|
|
|
467
484
|
'widgets',
|
|
468
485
|
'users',
|
|
469
486
|
'settings',
|
|
487
|
+
'profile',
|
|
470
488
|
'page-builder',
|
|
471
489
|
'collections',
|
|
472
490
|
// `pages` is reserved so the Pages list/editor/tag routes above
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Exercises the per-user MFA enrollment loop in AccountSecurity against a
|
|
7
|
+
* mocked auth API:
|
|
8
|
+
* GET /auth/me → current totpEnabled state
|
|
9
|
+
* POST /auth/totp/setup → provisioning URI + one-time backup codes
|
|
10
|
+
* POST /auth/totp/verify → flips MFA on
|
|
11
|
+
* POST /auth/totp/disable → turns MFA off
|
|
12
|
+
*/
|
|
13
|
+
interface ApiCall {
|
|
14
|
+
endpoint: string
|
|
15
|
+
options?: { method?: string; headers?: Record<string, string>; body?: unknown }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let totpEnabled = false
|
|
19
|
+
const calls: ApiCall[] = []
|
|
20
|
+
|
|
21
|
+
const cmsApi = vi.fn(async (endpoint: string, options?: ApiCall['options']) => {
|
|
22
|
+
calls.push({ endpoint, options })
|
|
23
|
+
const method = options?.method ?? 'GET'
|
|
24
|
+
if (endpoint === '/auth/me') return { data: { totpEnabled }, status: 200 }
|
|
25
|
+
if (endpoint === '/auth/totp/setup' && method === 'POST') {
|
|
26
|
+
const pw = options?.headers?.['x-reauth-password']
|
|
27
|
+
if (!pw || pw === 'wrong') return { error: 'Re-authentication failed.', status: 401 }
|
|
28
|
+
return {
|
|
29
|
+
data: {
|
|
30
|
+
uri: 'otpauth://totp/Actuate%20CMS:admin@example.com?secret=ABCDEF',
|
|
31
|
+
secret: 'ABCDEF234567',
|
|
32
|
+
backupCodes: ['aaaa1111', 'bbbb2222', 'cccc3333', 'dddd4444'],
|
|
33
|
+
},
|
|
34
|
+
status: 200,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (endpoint === '/auth/totp/verify' && method === 'POST') {
|
|
38
|
+
totpEnabled = true
|
|
39
|
+
return { data: { enabled: true }, status: 200 }
|
|
40
|
+
}
|
|
41
|
+
if (endpoint === '/auth/totp/disable' && method === 'POST') {
|
|
42
|
+
totpEnabled = false
|
|
43
|
+
return { data: { enabled: false }, status: 200 }
|
|
44
|
+
}
|
|
45
|
+
return { data: {}, status: 200 }
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
vi.mock('../../lib/api.js', () => ({ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any) }))
|
|
49
|
+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
|
50
|
+
vi.mock('qrcode.react', () => ({
|
|
51
|
+
QRCodeSVG: ({ value }: { value: string }) => <svg data-testid="qr" data-value={value} />,
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
const { AccountSecurity } = await import('../../views/profile/AccountSecurity.js')
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
cmsApi.mockClear()
|
|
58
|
+
calls.length = 0
|
|
59
|
+
totpEnabled = false
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('AccountSecurity — enrollment', () => {
|
|
63
|
+
it('walks through reauth → QR + backup codes → verify and enables MFA', async () => {
|
|
64
|
+
render(<AccountSecurity />)
|
|
65
|
+
await screen.findByText('Not set up')
|
|
66
|
+
|
|
67
|
+
fireEvent.click(screen.getByRole('button', { name: /Set up two-factor/i }))
|
|
68
|
+
|
|
69
|
+
// Reauth step: enter current password.
|
|
70
|
+
const pw = await screen.findByLabelText('Current password')
|
|
71
|
+
fireEvent.change(pw, { target: { value: 'correct-horse' } })
|
|
72
|
+
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
|
|
73
|
+
|
|
74
|
+
// Setup posts the password as a reauth header.
|
|
75
|
+
await waitFor(() =>
|
|
76
|
+
expect(
|
|
77
|
+
calls.some(
|
|
78
|
+
(c) =>
|
|
79
|
+
c.endpoint === '/auth/totp/setup' &&
|
|
80
|
+
c.options?.headers?.['x-reauth-password'] === 'correct-horse',
|
|
81
|
+
),
|
|
82
|
+
).toBe(true),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
// Enrollment step shows the QR, the manual key and the backup codes.
|
|
86
|
+
await screen.findByTestId('qr')
|
|
87
|
+
expect(screen.getByText('ABCDEF234567')).toBeTruthy()
|
|
88
|
+
expect(screen.getByText('aaaa1111')).toBeTruthy()
|
|
89
|
+
|
|
90
|
+
// Verify is gated on acknowledging the backup codes + a 6-digit code.
|
|
91
|
+
const verify = screen.getByRole('button', { name: /Verify & enable/i }) as HTMLButtonElement
|
|
92
|
+
expect(verify.disabled).toBe(true)
|
|
93
|
+
|
|
94
|
+
fireEvent.click(screen.getByLabelText(/saved my backup codes/i))
|
|
95
|
+
fireEvent.change(screen.getByLabelText('Authentication code'), { target: { value: '123456' } })
|
|
96
|
+
expect(verify.disabled).toBe(false)
|
|
97
|
+
fireEvent.click(verify)
|
|
98
|
+
|
|
99
|
+
await waitFor(() =>
|
|
100
|
+
expect(
|
|
101
|
+
calls.some(
|
|
102
|
+
(c) =>
|
|
103
|
+
c.endpoint === '/auth/totp/verify' && String(c.options?.body ?? '').includes('123456'),
|
|
104
|
+
),
|
|
105
|
+
).toBe(true),
|
|
106
|
+
)
|
|
107
|
+
await screen.findByText('Enabled')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('surfaces a reauth failure without advancing to the QR step', async () => {
|
|
111
|
+
render(<AccountSecurity />)
|
|
112
|
+
await screen.findByText('Not set up')
|
|
113
|
+
|
|
114
|
+
fireEvent.click(screen.getByRole('button', { name: /Set up two-factor/i }))
|
|
115
|
+
const pw = await screen.findByLabelText('Current password')
|
|
116
|
+
fireEvent.change(pw, { target: { value: 'wrong' } })
|
|
117
|
+
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
|
|
118
|
+
|
|
119
|
+
await screen.findByText('Re-authentication failed.')
|
|
120
|
+
expect(screen.queryByTestId('qr')).toBeNull()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('AccountSecurity — disable', () => {
|
|
125
|
+
it('shows Enabled and disables with a password confirmation', async () => {
|
|
126
|
+
totpEnabled = true
|
|
127
|
+
render(<AccountSecurity />)
|
|
128
|
+
await screen.findByText('Enabled')
|
|
129
|
+
|
|
130
|
+
fireEvent.click(screen.getByRole('button', { name: /Disable two-factor/i }))
|
|
131
|
+
const pw = await screen.findByLabelText('Current password')
|
|
132
|
+
fireEvent.change(pw, { target: { value: 'correct-horse' } })
|
|
133
|
+
// The confirm button is the destructive one inside the panel.
|
|
134
|
+
const confirmButtons = screen.getAllByRole('button', { name: /Disable two-factor/i })
|
|
135
|
+
fireEvent.click(confirmButtons[confirmButtons.length - 1]!)
|
|
136
|
+
|
|
137
|
+
await waitFor(() => expect(calls.some((c) => c.endpoint === '/auth/totp/disable')).toBe(true))
|
|
138
|
+
await screen.findByText('Not set up')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
import { Login } from '../../views/Login.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The login screen must advance to a second-factor challenge when the account
|
|
9
|
+
* has MFA enabled (`onLogin` returns `requiresTOTP` + `mfaPendingToken`), and
|
|
10
|
+
* must complete it via `onVerifyTotp` — including the backup-code fallback.
|
|
11
|
+
*/
|
|
12
|
+
function fillCredentials() {
|
|
13
|
+
fireEvent.change(screen.getByLabelText('Email Address'), {
|
|
14
|
+
target: { value: 'admin@example.com' },
|
|
15
|
+
})
|
|
16
|
+
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pw' } })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('Login — MFA challenge', () => {
|
|
20
|
+
it('shows the TOTP step after the password step requires it', async () => {
|
|
21
|
+
const onLogin = vi.fn(async () => ({
|
|
22
|
+
success: false,
|
|
23
|
+
requiresTOTP: true,
|
|
24
|
+
mfaPendingToken: 'pending-token',
|
|
25
|
+
}))
|
|
26
|
+
const onVerifyTotp = vi.fn(async () => ({ success: true }))
|
|
27
|
+
|
|
28
|
+
render(<Login onLogin={onLogin} onVerifyTotp={onVerifyTotp} />)
|
|
29
|
+
fillCredentials()
|
|
30
|
+
fireEvent.click(screen.getByRole('button', { name: 'Sign In' }))
|
|
31
|
+
|
|
32
|
+
await screen.findByText('Two-factor authentication')
|
|
33
|
+
|
|
34
|
+
fireEvent.change(screen.getByLabelText('Authentication code'), { target: { value: '654321' } })
|
|
35
|
+
fireEvent.click(screen.getByRole('button', { name: 'Verify' }))
|
|
36
|
+
|
|
37
|
+
await waitFor(() => expect(onVerifyTotp).toHaveBeenCalledWith('pending-token', '654321'))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('supports the backup-code fallback', async () => {
|
|
41
|
+
const onLogin = vi.fn(async () => ({
|
|
42
|
+
success: false,
|
|
43
|
+
requiresTOTP: true,
|
|
44
|
+
mfaPendingToken: 'pending-token',
|
|
45
|
+
}))
|
|
46
|
+
const onVerifyTotp = vi.fn(async () => ({ success: true }))
|
|
47
|
+
|
|
48
|
+
render(<Login onLogin={onLogin} onVerifyTotp={onVerifyTotp} />)
|
|
49
|
+
fillCredentials()
|
|
50
|
+
fireEvent.click(screen.getByRole('button', { name: 'Sign In' }))
|
|
51
|
+
await screen.findByText('Two-factor authentication')
|
|
52
|
+
|
|
53
|
+
fireEvent.click(screen.getByRole('button', { name: 'Use a backup code' }))
|
|
54
|
+
fireEvent.change(screen.getByLabelText('Backup code'), { target: { value: 'aaaa1111' } })
|
|
55
|
+
fireEvent.click(screen.getByRole('button', { name: 'Verify' }))
|
|
56
|
+
|
|
57
|
+
await waitFor(() => expect(onVerifyTotp).toHaveBeenCalledWith('pending-token', 'aaaa1111'))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('shows an error returned from the verify step', async () => {
|
|
61
|
+
const onLogin = vi.fn(async () => ({
|
|
62
|
+
success: false,
|
|
63
|
+
requiresTOTP: true,
|
|
64
|
+
mfaPendingToken: 'pending-token',
|
|
65
|
+
}))
|
|
66
|
+
const onVerifyTotp = vi.fn(async () => ({ success: false, error: 'Invalid code' }))
|
|
67
|
+
|
|
68
|
+
render(<Login onLogin={onLogin} onVerifyTotp={onVerifyTotp} />)
|
|
69
|
+
fillCredentials()
|
|
70
|
+
fireEvent.click(screen.getByRole('button', { name: 'Sign In' }))
|
|
71
|
+
await screen.findByText('Two-factor authentication')
|
|
72
|
+
|
|
73
|
+
fireEvent.change(screen.getByLabelText('Authentication code'), { target: { value: '000000' } })
|
|
74
|
+
fireEvent.click(screen.getByRole('button', { name: 'Verify' }))
|
|
75
|
+
|
|
76
|
+
await screen.findByText('Invalid code')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('does not show the challenge for a normal successful login', async () => {
|
|
80
|
+
const onLogin = vi.fn(async () => ({ success: true }))
|
|
81
|
+
render(<Login onLogin={onLogin} />)
|
|
82
|
+
fillCredentials()
|
|
83
|
+
fireEvent.click(screen.getByRole('button', { name: 'Sign In' }))
|
|
84
|
+
|
|
85
|
+
await waitFor(() => expect(onLogin).toHaveBeenCalled())
|
|
86
|
+
expect(screen.queryByText('Two-factor authentication')).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
})
|