@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.
Files changed (90) hide show
  1. package/dist/AdminRoot.d.ts +7 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +7 -2
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/views/account-security.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/account-security.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/account-security.render.test.js +98 -0
  8. package/dist/__tests__/views/account-security.render.test.js.map +1 -0
  9. package/dist/__tests__/views/login-mfa.render.test.d.ts +2 -0
  10. package/dist/__tests__/views/login-mfa.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/views/login-mfa.render.test.js +73 -0
  12. package/dist/__tests__/views/login-mfa.render.test.js.map +1 -0
  13. package/dist/__tests__/views/security-settings.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/security-settings.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/security-settings.render.test.js +175 -0
  16. package/dist/__tests__/views/security-settings.render.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  19. package/dist/components/Breadcrumbs.js +1 -0
  20. package/dist/components/Breadcrumbs.js.map +1 -1
  21. package/dist/views/Login.d.ts +16 -1
  22. package/dist/views/Login.d.ts.map +1 -1
  23. package/dist/views/Login.js +55 -3
  24. package/dist/views/Login.js.map +1 -1
  25. package/dist/views/Profile.d.ts +12 -0
  26. package/dist/views/Profile.d.ts.map +1 -0
  27. package/dist/views/Profile.js +16 -0
  28. package/dist/views/Profile.js.map +1 -0
  29. package/dist/views/Settings.d.ts.map +1 -1
  30. package/dist/views/Settings.js +3 -11
  31. package/dist/views/Settings.js.map +1 -1
  32. package/dist/views/profile/AccountSecurity.d.ts +2 -0
  33. package/dist/views/profile/AccountSecurity.d.ts.map +1 -0
  34. package/dist/views/profile/AccountSecurity.js +117 -0
  35. package/dist/views/profile/AccountSecurity.js.map +1 -0
  36. package/dist/views/profile/useMfaEnrollment.d.ts +35 -0
  37. package/dist/views/profile/useMfaEnrollment.d.ts.map +1 -0
  38. package/dist/views/profile/useMfaEnrollment.js +103 -0
  39. package/dist/views/profile/useMfaEnrollment.js.map +1 -0
  40. package/dist/views/settings/AccessProtectionCard.d.ts +10 -0
  41. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -0
  42. package/dist/views/settings/AccessProtectionCard.js +20 -0
  43. package/dist/views/settings/AccessProtectionCard.js.map +1 -0
  44. package/dist/views/settings/AuthMfaCard.d.ts +11 -0
  45. package/dist/views/settings/AuthMfaCard.d.ts.map +1 -0
  46. package/dist/views/settings/AuthMfaCard.js +34 -0
  47. package/dist/views/settings/AuthMfaCard.js.map +1 -0
  48. package/dist/views/settings/SecretsAuditCard.d.ts +11 -0
  49. package/dist/views/settings/SecretsAuditCard.d.ts.map +1 -0
  50. package/dist/views/settings/SecretsAuditCard.js +22 -0
  51. package/dist/views/settings/SecretsAuditCard.js.map +1 -0
  52. package/dist/views/settings/SecurityStatusCard.d.ts +6 -0
  53. package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -0
  54. package/dist/views/settings/SecurityStatusCard.js +35 -0
  55. package/dist/views/settings/SecurityStatusCard.js.map +1 -0
  56. package/dist/views/settings/SecurityTab.d.ts +5 -0
  57. package/dist/views/settings/SecurityTab.d.ts.map +1 -0
  58. package/dist/views/settings/SecurityTab.js +41 -0
  59. package/dist/views/settings/SecurityTab.js.map +1 -0
  60. package/dist/views/settings/SessionPolicyCard.d.ts +11 -0
  61. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -0
  62. package/dist/views/settings/SessionPolicyCard.js +21 -0
  63. package/dist/views/settings/SessionPolicyCard.js.map +1 -0
  64. package/dist/views/settings/securityPrimitives.d.ts +21 -0
  65. package/dist/views/settings/securityPrimitives.d.ts.map +1 -0
  66. package/dist/views/settings/securityPrimitives.js +31 -0
  67. package/dist/views/settings/securityPrimitives.js.map +1 -0
  68. package/dist/views/settings/useSecuritySettings.d.ts +37 -0
  69. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -0
  70. package/dist/views/settings/useSecuritySettings.js +154 -0
  71. package/dist/views/settings/useSecuritySettings.js.map +1 -0
  72. package/package.json +3 -2
  73. package/src/AdminRoot.tsx +18 -0
  74. package/src/__tests__/views/account-security.render.test.tsx +140 -0
  75. package/src/__tests__/views/login-mfa.render.test.tsx +88 -0
  76. package/src/__tests__/views/security-settings.render.test.tsx +212 -0
  77. package/src/components/Breadcrumbs.tsx +1 -0
  78. package/src/views/Login.tsx +247 -94
  79. package/src/views/Profile.tsx +54 -0
  80. package/src/views/Settings.tsx +3 -33
  81. package/src/views/profile/AccountSecurity.tsx +406 -0
  82. package/src/views/profile/useMfaEnrollment.ts +120 -0
  83. package/src/views/settings/AccessProtectionCard.tsx +143 -0
  84. package/src/views/settings/AuthMfaCard.tsx +133 -0
  85. package/src/views/settings/SecretsAuditCard.tsx +129 -0
  86. package/src/views/settings/SecurityStatusCard.tsx +110 -0
  87. package/src/views/settings/SecurityTab.tsx +163 -0
  88. package/src/views/settings/SessionPolicyCard.tsx +123 -0
  89. package/src/views/settings/securityPrimitives.tsx +75 -0
  90. 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.27.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.36.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
+ })