@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
@@ -30,6 +30,7 @@ import { RelationshipField } from '../fields/RelationshipField.js'
30
30
  import { SEOConfigPanel } from '../components/SEOConfigPanel.js'
31
31
  import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
32
32
  import { AppearanceTab } from './settings/AppearanceTab.js'
33
+ import { SecurityTab } from './settings/SecurityTab.js'
33
34
  import { Users as UsersView } from './Users.js'
34
35
  import { ApiKeys as ApiKeysView } from './ApiKeys.js'
35
36
 
@@ -44,7 +45,7 @@ export interface SettingsProps {
44
45
  }
45
46
 
46
47
  /** Tabs that drive their own persistence (no global "Save Changes" footer). */
47
- const SELF_MANAGED_TABS = new Set(['general', 'appearance', 'users', 'api-keys'])
48
+ const SELF_MANAGED_TABS = new Set(['general', 'appearance', 'security', 'users', 'api-keys'])
48
49
 
49
50
  function readSettingsTabFromUrl(): string | null {
50
51
  if (typeof window === 'undefined') return null
@@ -59,9 +60,6 @@ export function Settings({
59
60
  }: SettingsProps = {}) {
60
61
  const { data, loading, error, refetch } = useApiData<any>('/globals/settings')
61
62
 
62
- const [twoFactorEnabled, setTwoFactorEnabled] = useState(false)
63
- const [sessionTimeout, setSessionTimeout] = useState(false)
64
- const [ipWhitelist, setIpWhitelist] = useState(false)
65
63
  const [activeTab, setActiveTab] = useState<string>(() => readSettingsTabFromUrl() ?? initialTab)
66
64
  const [saving, setSaving] = useState(false)
67
65
 
@@ -138,9 +136,6 @@ export function Settings({
138
136
 
139
137
  useEffect(() => {
140
138
  if (data) {
141
- setTwoFactorEnabled(data.twoFactorEnabled ?? false)
142
- setSessionTimeout(data.sessionTimeout ?? false)
143
- setIpWhitelist(data.ipWhitelist ?? false)
144
139
  setAiProvider(data.aiProvider ?? 'anthropic')
145
140
  setAiAltTags(data.aiAltTags ?? true)
146
141
  setAiMediaCategorize(data.aiMediaCategorize ?? false)
@@ -167,9 +162,6 @@ export function Settings({
167
162
  method: 'PUT',
168
163
  body: JSON.stringify({
169
164
  ...(current.data ?? {}),
170
- twoFactorEnabled,
171
- sessionTimeout,
172
- ipWhitelist,
173
165
  aiProvider,
174
166
  aiAltTags,
175
167
  aiMediaCategorize,
@@ -351,29 +343,7 @@ export function Settings({
351
343
  )}
352
344
 
353
345
  <Tabs.Content value="security" className="space-y-4">
354
- <div className="rounded-lg border border-gray-200 bg-white p-4">
355
- <h3 className="mb-4 text-sm font-semibold text-gray-900">Security Settings</h3>
356
- <div className="space-y-4">
357
- <ToggleSetting
358
- label="Two-Factor Authentication"
359
- description="Require 2FA for all admin users"
360
- checked={twoFactorEnabled}
361
- onChange={setTwoFactorEnabled}
362
- />
363
- <ToggleSetting
364
- label="Session Timeout"
365
- description="Automatically log out inactive users after 30 minutes"
366
- checked={sessionTimeout}
367
- onChange={setSessionTimeout}
368
- />
369
- <ToggleSetting
370
- label="IP Whitelist"
371
- description="Only allow access from approved IP addresses"
372
- checked={ipWhitelist}
373
- onChange={setIpWhitelist}
374
- />
375
- </div>
376
- </div>
346
+ <SecurityTab role={session?.user?.role} onNavigate={onNavigate} />
377
347
  </Tabs.Content>
378
348
 
379
349
  <Tabs.Content value="seo" className="space-y-4">
@@ -0,0 +1,406 @@
1
+ 'use client'
2
+
3
+ import { useId, useRef, useState } from 'react'
4
+ import { QRCodeSVG } from 'qrcode.react'
5
+ import { toast } from 'sonner'
6
+ import {
7
+ AlertTriangle,
8
+ Check,
9
+ Copy,
10
+ Download,
11
+ Loader2,
12
+ ShieldCheck,
13
+ ShieldAlert,
14
+ } from 'lucide-react'
15
+ import { SecurityCard, StatusRow } from '../settings/securityPrimitives.js'
16
+ import { useMfaEnrollment } from './useMfaEnrollment.js'
17
+
18
+ type SetupStep = 'idle' | 'reauth' | 'enroll'
19
+
20
+ function FieldError({ id, message }: { id: string; message: string | null }) {
21
+ if (!message) return null
22
+ return (
23
+ <p id={id} role="alert" className="text-destructive mt-1.5 text-sm">
24
+ {message}
25
+ </p>
26
+ )
27
+ }
28
+
29
+ export function AccountSecurity() {
30
+ const headingId = useId()
31
+ const pwId = useId()
32
+ const pwErrId = useId()
33
+ const codeId = useId()
34
+ const codeErrId = useId()
35
+ const disablePwId = useId()
36
+ const disableErrId = useId()
37
+
38
+ const mfa = useMfaEnrollment()
39
+
40
+ const [step, setStep] = useState<SetupStep>('idle')
41
+ const [password, setPassword] = useState('')
42
+ const [verifyCode, setVerifyCode] = useState('')
43
+ const [setupError, setSetupError] = useState<string | null>(null)
44
+ const [codeError, setCodeError] = useState<string | null>(null)
45
+ const [savedAcknowledged, setSavedAcknowledged] = useState(false)
46
+
47
+ const [disabling, setDisabling] = useState(false)
48
+ const [disablePassword, setDisablePassword] = useState('')
49
+ const [disableError, setDisableError] = useState<string | null>(null)
50
+ const codeInputRef = useRef<HTMLInputElement>(null)
51
+
52
+ function resetSetup() {
53
+ mfa.cancelSetup()
54
+ setStep('idle')
55
+ setPassword('')
56
+ setVerifyCode('')
57
+ setSetupError(null)
58
+ setCodeError(null)
59
+ setSavedAcknowledged(false)
60
+ }
61
+
62
+ async function handleBeginSetup() {
63
+ setSetupError(null)
64
+ const result = await mfa.beginSetup(password)
65
+ if (!result.ok) {
66
+ setSetupError(result.error ?? 'Could not start MFA setup.')
67
+ return
68
+ }
69
+ setPassword('')
70
+ setStep('enroll')
71
+ // Move focus to the code field once the QR is shown.
72
+ requestAnimationFrame(() => codeInputRef.current?.focus())
73
+ }
74
+
75
+ async function handleConfirmSetup() {
76
+ setCodeError(null)
77
+ const result = await mfa.confirmSetup(verifyCode)
78
+ if (!result.ok) {
79
+ setCodeError(result.error ?? 'That code did not match. Try the current one.')
80
+ return
81
+ }
82
+ toast.success('Two-factor authentication is now enabled.')
83
+ resetSetup()
84
+ }
85
+
86
+ async function handleDisable() {
87
+ setDisableError(null)
88
+ const result = await mfa.disable(disablePassword)
89
+ if (!result.ok) {
90
+ setDisableError(result.error ?? 'Could not disable MFA.')
91
+ return
92
+ }
93
+ toast.success('Two-factor authentication has been disabled.')
94
+ setDisabling(false)
95
+ setDisablePassword('')
96
+ }
97
+
98
+ function copyBackupCodes() {
99
+ if (!mfa.enrollment) return
100
+ void navigator.clipboard
101
+ ?.writeText(mfa.enrollment.backupCodes.join('\n'))
102
+ .then(() => toast.success('Backup codes copied to clipboard.'))
103
+ .catch(() => toast.error('Could not copy codes. Copy them manually.'))
104
+ }
105
+
106
+ function downloadBackupCodes() {
107
+ if (!mfa.enrollment) return
108
+ const blob = new Blob(
109
+ [
110
+ 'Actuate CMS — two-factor backup codes\n',
111
+ 'Each code can be used once if you lose access to your authenticator app.\n\n',
112
+ mfa.enrollment.backupCodes.join('\n'),
113
+ '\n',
114
+ ],
115
+ { type: 'text/plain' },
116
+ )
117
+ const url = URL.createObjectURL(blob)
118
+ const a = document.createElement('a')
119
+ a.href = url
120
+ a.download = 'actuate-backup-codes.txt'
121
+ a.click()
122
+ URL.revokeObjectURL(url)
123
+ }
124
+
125
+ return (
126
+ <SecurityCard
127
+ headingId={headingId}
128
+ title="Two-factor authentication"
129
+ description="Add a one-time code from an authenticator app to your sign-in for an extra layer of protection."
130
+ >
131
+ {mfa.status === 'loading' && (
132
+ <div className="text-muted-foreground flex items-center gap-2 text-sm">
133
+ <Loader2 size={16} className="animate-spin" aria-hidden="true" />
134
+ Checking your MFA status…
135
+ </div>
136
+ )}
137
+
138
+ {mfa.status === 'error' && (
139
+ <div className="flex items-center justify-between gap-3">
140
+ <p className="text-muted-foreground text-sm">Could not load your MFA status.</p>
141
+ <button
142
+ type="button"
143
+ onClick={() => void mfa.refresh()}
144
+ className="border-border text-foreground hover:bg-accent rounded-md border px-3 py-1.5 text-sm transition-colors"
145
+ >
146
+ Retry
147
+ </button>
148
+ </div>
149
+ )}
150
+
151
+ {/* ── Enabled ─────────────────────────────────────────────── */}
152
+ {mfa.status === 'enabled' && (
153
+ <div className="space-y-4">
154
+ <StatusRow status="pass" label="Two-factor authentication" value="Enabled" />
155
+ {!disabling ? (
156
+ <button
157
+ type="button"
158
+ onClick={() => {
159
+ setDisabling(true)
160
+ setDisableError(null)
161
+ }}
162
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors"
163
+ >
164
+ <ShieldAlert size={16} aria-hidden="true" />
165
+ Disable two-factor
166
+ </button>
167
+ ) : (
168
+ <div className="border-destructive/30 bg-destructive/5 space-y-3 rounded-md border p-3">
169
+ <div className="flex items-start gap-2">
170
+ <AlertTriangle
171
+ size={16}
172
+ className="text-destructive mt-0.5 shrink-0"
173
+ aria-hidden="true"
174
+ />
175
+ <p className="text-foreground text-sm">
176
+ Disabling two-factor makes your account password-only and signs out your other
177
+ sessions. Confirm your password to continue.
178
+ </p>
179
+ </div>
180
+ <div>
181
+ <label
182
+ htmlFor={disablePwId}
183
+ className="text-foreground mb-1 block text-sm font-medium"
184
+ >
185
+ Current password
186
+ </label>
187
+ <input
188
+ id={disablePwId}
189
+ type="password"
190
+ autoComplete="current-password"
191
+ value={disablePassword}
192
+ onChange={(e) => setDisablePassword(e.target.value)}
193
+ aria-invalid={disableError ? true : undefined}
194
+ aria-describedby={disableError ? disableErrId : undefined}
195
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
196
+ />
197
+ <FieldError id={disableErrId} message={disableError} />
198
+ </div>
199
+ <div className="flex items-center justify-end gap-3">
200
+ <button
201
+ type="button"
202
+ onClick={() => {
203
+ setDisabling(false)
204
+ setDisablePassword('')
205
+ setDisableError(null)
206
+ }}
207
+ className="text-muted-foreground hover:text-foreground rounded-md px-3 py-2 text-sm transition-colors"
208
+ >
209
+ Cancel
210
+ </button>
211
+ <button
212
+ type="button"
213
+ onClick={() => void handleDisable()}
214
+ disabled={mfa.busy || !disablePassword}
215
+ className="bg-destructive text-destructive-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
216
+ >
217
+ {mfa.busy && <Loader2 size={16} className="animate-spin" aria-hidden="true" />}
218
+ Disable two-factor
219
+ </button>
220
+ </div>
221
+ </div>
222
+ )}
223
+ </div>
224
+ )}
225
+
226
+ {/* ── Disabled: idle ──────────────────────────────────────── */}
227
+ {mfa.status === 'disabled' && step === 'idle' && (
228
+ <div className="space-y-4">
229
+ <StatusRow status="warning" label="Two-factor authentication" value="Not set up" />
230
+ <button
231
+ type="button"
232
+ onClick={() => {
233
+ setStep('reauth')
234
+ setSetupError(null)
235
+ }}
236
+ className="bg-brand text-brand-foreground focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:outline-none"
237
+ >
238
+ <ShieldCheck size={16} aria-hidden="true" />
239
+ Set up two-factor
240
+ </button>
241
+ </div>
242
+ )}
243
+
244
+ {/* ── Disabled: reauth ────────────────────────────────────── */}
245
+ {mfa.status === 'disabled' && step === 'reauth' && (
246
+ <div className="space-y-3">
247
+ <p className="text-muted-foreground text-sm">
248
+ Confirm your current password to begin setting up two-factor authentication.
249
+ </p>
250
+ <div>
251
+ <label htmlFor={pwId} className="text-foreground mb-1 block text-sm font-medium">
252
+ Current password
253
+ </label>
254
+ <input
255
+ id={pwId}
256
+ type="password"
257
+ autoComplete="current-password"
258
+ value={password}
259
+ onChange={(e) => setPassword(e.target.value)}
260
+ autoFocus
261
+ aria-invalid={setupError ? true : undefined}
262
+ aria-describedby={setupError ? pwErrId : undefined}
263
+ onKeyDown={(e) => {
264
+ if (e.key === 'Enter' && password && !mfa.busy) void handleBeginSetup()
265
+ }}
266
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
267
+ />
268
+ <FieldError id={pwErrId} message={setupError} />
269
+ </div>
270
+ <div className="flex items-center justify-end gap-3">
271
+ <button
272
+ type="button"
273
+ onClick={resetSetup}
274
+ className="text-muted-foreground hover:text-foreground rounded-md px-3 py-2 text-sm transition-colors"
275
+ >
276
+ Cancel
277
+ </button>
278
+ <button
279
+ type="button"
280
+ onClick={() => void handleBeginSetup()}
281
+ disabled={mfa.busy || !password}
282
+ className="bg-brand text-brand-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
283
+ >
284
+ {mfa.busy && <Loader2 size={16} className="animate-spin" aria-hidden="true" />}
285
+ Continue
286
+ </button>
287
+ </div>
288
+ </div>
289
+ )}
290
+
291
+ {/* ── Disabled: enroll (QR + backup codes + verify) ───────── */}
292
+ {mfa.status === 'disabled' && step === 'enroll' && mfa.enrollment && (
293
+ <div className="space-y-5">
294
+ <ol className="text-foreground list-decimal space-y-4 pl-5 text-sm">
295
+ <li>
296
+ <p className="font-medium">Scan this QR code with your authenticator app.</p>
297
+ <div className="border-border mt-2 inline-flex rounded-lg border bg-white p-3">
298
+ <QRCodeSVG
299
+ value={mfa.enrollment.uri}
300
+ size={160}
301
+ role="img"
302
+ aria-label="Two-factor setup QR code"
303
+ />
304
+ </div>
305
+ <p className="text-muted-foreground mt-2">
306
+ Can&apos;t scan? Enter this key manually:
307
+ </p>
308
+ <code className="border-border bg-muted text-foreground mt-1 inline-block rounded border px-2 py-1 font-mono text-xs break-all">
309
+ {mfa.enrollment.secret}
310
+ </code>
311
+ </li>
312
+ <li>
313
+ <p className="font-medium">Save your backup codes.</p>
314
+ <p className="text-muted-foreground mt-0.5">
315
+ Each code works once if you lose your authenticator. They won&apos;t be shown again.
316
+ </p>
317
+ <ul className="border-border bg-muted/40 mt-2 grid grid-cols-2 gap-x-6 gap-y-1 rounded-md border p-3 font-mono text-xs sm:grid-cols-4">
318
+ {mfa.enrollment.backupCodes.map((code) => (
319
+ <li key={code} className="text-foreground">
320
+ {code}
321
+ </li>
322
+ ))}
323
+ </ul>
324
+ <div className="mt-2 flex flex-wrap gap-2">
325
+ <button
326
+ type="button"
327
+ onClick={copyBackupCodes}
328
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition-colors"
329
+ >
330
+ <Copy size={14} aria-hidden="true" />
331
+ Copy codes
332
+ </button>
333
+ <button
334
+ type="button"
335
+ onClick={downloadBackupCodes}
336
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition-colors"
337
+ >
338
+ <Download size={14} aria-hidden="true" />
339
+ Download
340
+ </button>
341
+ </div>
342
+ <label className="text-foreground mt-2 flex items-center gap-2 text-sm">
343
+ <input
344
+ type="checkbox"
345
+ checked={savedAcknowledged}
346
+ onChange={(e) => setSavedAcknowledged(e.target.checked)}
347
+ className="accent-brand h-4 w-4"
348
+ />
349
+ I&apos;ve saved my backup codes somewhere safe.
350
+ </label>
351
+ </li>
352
+ <li>
353
+ <p className="font-medium">Enter the 6-digit code from your app to finish.</p>
354
+ <div className="mt-2">
355
+ <label htmlFor={codeId} className="sr-only">
356
+ Authentication code
357
+ </label>
358
+ <input
359
+ id={codeId}
360
+ ref={codeInputRef}
361
+ inputMode="numeric"
362
+ autoComplete="one-time-code"
363
+ maxLength={6}
364
+ placeholder="123456"
365
+ value={verifyCode}
366
+ onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, ''))}
367
+ aria-invalid={codeError ? true : undefined}
368
+ aria-describedby={codeError ? codeErrId : undefined}
369
+ onKeyDown={(e) => {
370
+ if (e.key === 'Enter' && savedAcknowledged && !mfa.busy)
371
+ void handleConfirmSetup()
372
+ }}
373
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-40 rounded-md border px-3 py-2 text-center font-mono text-lg tracking-[0.3em] focus-visible:ring-2 focus-visible:outline-none"
374
+ />
375
+ <FieldError id={codeErrId} message={codeError} />
376
+ </div>
377
+ </li>
378
+ </ol>
379
+
380
+ <div className="flex items-center justify-end gap-3">
381
+ <button
382
+ type="button"
383
+ onClick={resetSetup}
384
+ className="text-muted-foreground hover:text-foreground rounded-md px-3 py-2 text-sm transition-colors"
385
+ >
386
+ Cancel
387
+ </button>
388
+ <button
389
+ type="button"
390
+ onClick={() => void handleConfirmSetup()}
391
+ disabled={mfa.busy || !savedAcknowledged || verifyCode.length !== 6}
392
+ className="bg-brand text-brand-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
393
+ >
394
+ {mfa.busy ? (
395
+ <Loader2 size={16} className="animate-spin" aria-hidden="true" />
396
+ ) : (
397
+ <Check size={16} aria-hidden="true" />
398
+ )}
399
+ Verify &amp; enable
400
+ </button>
401
+ </div>
402
+ </div>
403
+ )}
404
+ </SecurityCard>
405
+ )
406
+ }
@@ -0,0 +1,120 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+ import { cmsApi } from '../../lib/api.js'
5
+
6
+ export type MfaStatus = 'loading' | 'enabled' | 'disabled' | 'error'
7
+
8
+ export interface MfaEnrollment {
9
+ /** otpauth:// URI to render as a QR code. */
10
+ uri: string
11
+ /** Base32 secret for manual entry when a camera isn't available. */
12
+ secret: string
13
+ /** One-time recovery codes — shown once, never retrievable again. */
14
+ backupCodes: string[]
15
+ }
16
+
17
+ interface ActionResult {
18
+ ok: boolean
19
+ error?: string
20
+ }
21
+
22
+ /**
23
+ * Drives the per-user MFA (TOTP) enrollment loop against the real auth API:
24
+ * 1. `beginSetup(password)` — password reauth → POST /auth/totp/setup, which
25
+ * returns the provisioning URI + one-time backup codes.
26
+ * 2. `confirmSetup(code)` — POST /auth/totp/verify to flip totpEnabled on.
27
+ * 3. `disable(password)` — password reauth → POST /auth/totp/disable.
28
+ *
29
+ * The hook never holds the user's password and never persists the secret or
30
+ * backup codes beyond the in-memory enrollment object (cleared on cancel/finish).
31
+ */
32
+ export function useMfaEnrollment() {
33
+ const [status, setStatus] = useState<MfaStatus>('loading')
34
+ const [enrollment, setEnrollment] = useState<MfaEnrollment | null>(null)
35
+ const [busy, setBusy] = useState(false)
36
+
37
+ const refresh = useCallback(async () => {
38
+ const res = await cmsApi<{ totpEnabled?: boolean }>('/auth/me')
39
+ if (res.error || !res.data) {
40
+ setStatus('error')
41
+ return
42
+ }
43
+ setStatus(res.data.totpEnabled ? 'enabled' : 'disabled')
44
+ }, [])
45
+
46
+ useEffect(() => {
47
+ void refresh()
48
+ }, [refresh])
49
+
50
+ const beginSetup = useCallback(async (password: string): Promise<ActionResult> => {
51
+ if (!password) return { ok: false, error: 'Enter your current password to continue.' }
52
+ setBusy(true)
53
+ try {
54
+ const res = await cmsApi<MfaEnrollment>('/auth/totp/setup', {
55
+ method: 'POST',
56
+ headers: { 'x-reauth-password': password },
57
+ body: JSON.stringify({}),
58
+ })
59
+ if (res.error || !res.data) {
60
+ return { ok: false, error: res.error ?? 'Could not start MFA setup.' }
61
+ }
62
+ setEnrollment(res.data)
63
+ return { ok: true }
64
+ } finally {
65
+ setBusy(false)
66
+ }
67
+ }, [])
68
+
69
+ const confirmSetup = useCallback(async (code: string): Promise<ActionResult> => {
70
+ const trimmed = code.trim()
71
+ if (!/^\d{6}$/.test(trimmed)) {
72
+ return { ok: false, error: 'Enter the 6-digit code from your authenticator app.' }
73
+ }
74
+ setBusy(true)
75
+ try {
76
+ const res = await cmsApi<{ enabled?: boolean }>('/auth/totp/verify', {
77
+ method: 'POST',
78
+ body: JSON.stringify({ code: trimmed }),
79
+ })
80
+ if (res.error) return { ok: false, error: res.error }
81
+ setEnrollment(null)
82
+ setStatus('enabled')
83
+ return { ok: true }
84
+ } finally {
85
+ setBusy(false)
86
+ }
87
+ }, [])
88
+
89
+ const cancelSetup = useCallback(() => {
90
+ setEnrollment(null)
91
+ }, [])
92
+
93
+ const disable = useCallback(async (password: string): Promise<ActionResult> => {
94
+ if (!password) return { ok: false, error: 'Enter your current password to continue.' }
95
+ setBusy(true)
96
+ try {
97
+ const res = await cmsApi<{ enabled?: boolean }>('/auth/totp/disable', {
98
+ method: 'POST',
99
+ headers: { 'x-reauth-password': password },
100
+ body: JSON.stringify({}),
101
+ })
102
+ if (res.error) return { ok: false, error: res.error }
103
+ setStatus('disabled')
104
+ return { ok: true }
105
+ } finally {
106
+ setBusy(false)
107
+ }
108
+ }, [])
109
+
110
+ return {
111
+ status,
112
+ enrollment,
113
+ busy,
114
+ refresh,
115
+ beginSetup,
116
+ confirmSetup,
117
+ cancelSetup,
118
+ disable,
119
+ }
120
+ }