@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
package/src/views/Settings.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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'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'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'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 & 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
|
+
}
|