feat: add optional TOTP MFA for email/password accounts
All checks were successful
CI / backend-tests (pull_request) Successful in 26s
CI / frontend-tests (pull_request) Successful in 28s

- Add MFA enrollment UI in new Settings page with QR code and backup secret
- Add TOTP challenge step to login flow for enrolled users
- Check AAL after login and show TOTP input when aal2 required
- Add disable MFA option with TOTP re-verification
- Only show MFA options for email/password users (not OAuth)
- Add Settings link to user dropdown menu

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 13:56:48 +01:00
parent a12958ae32
commit 7a828d7215
7 changed files with 610 additions and 11 deletions

View File

@@ -0,0 +1,303 @@
import { useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
type MfaStep = 'idle' | 'enrolling' | 'verifying' | 'success' | 'disabling'
interface EnrollmentData {
factorId: string
qrCode: string
secret: string
}
export function Settings() {
const { user, loading, mfa, isOAuthUser, enrollMfa, verifyMfaEnrollment, unenrollMfa } = useAuth()
const [step, setStep] = useState<MfaStep>('idle')
const [enrollmentData, setEnrollmentData] = useState<EnrollmentData | null>(null)
const [code, setCode] = useState('')
const [error, setError] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState(false)
const [disableFactorId, setDisableFactorId] = useState<string | null>(null)
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-4 border-accent-600 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!user) {
return <Navigate to="/login" replace />
}
const hasMfa = mfa.enrolledFactors.length > 0
async function handleEnroll() {
setError(null)
setActionLoading(true)
const { data, error: enrollError } = await enrollMfa()
setActionLoading(false)
if (enrollError || !data) {
setError(enrollError?.message ?? 'Failed to start MFA enrollment')
return
}
setEnrollmentData(data)
setStep('enrolling')
}
async function handleVerifyEnrollment(e: React.FormEvent) {
e.preventDefault()
if (!enrollmentData) return
setError(null)
setActionLoading(true)
const { error: verifyError } = await verifyMfaEnrollment(enrollmentData.factorId, code)
setActionLoading(false)
if (verifyError) {
setError(verifyError.message)
return
}
setStep('success')
setCode('')
}
async function handleStartDisable(factorId: string) {
setDisableFactorId(factorId)
setStep('disabling')
setCode('')
setError(null)
}
async function handleConfirmDisable(e: React.FormEvent) {
e.preventDefault()
if (!disableFactorId) return
setError(null)
setActionLoading(true)
const { error: unenrollError } = await unenrollMfa(disableFactorId)
setActionLoading(false)
if (unenrollError) {
setError(unenrollError.message)
return
}
setStep('idle')
setDisableFactorId(null)
setCode('')
}
function handleCancel() {
setStep('idle')
setEnrollmentData(null)
setDisableFactorId(null)
setCode('')
setError(null)
}
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
<section className="bg-surface-1 rounded-lg border border-border-default p-6">
<h2 className="text-lg font-semibold mb-4">Two-Factor Authentication</h2>
{isOAuthUser ? (
<p className="text-text-secondary text-sm">
You signed in with an OAuth provider (Google/Discord). MFA is managed by your provider.
</p>
) : (
<>
{step === 'idle' && (
<>
{hasMfa ? (
<div className="space-y-4">
<div className="flex items-center gap-2 text-green-400">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
<span className="font-medium">MFA is enabled</span>
</div>
<p className="text-text-secondary text-sm">
Your account is protected with two-factor authentication.
</p>
<button
type="button"
onClick={() => handleStartDisable(mfa.enrolledFactors[0]?.id ?? '')}
className="px-4 py-2 border border-red-500/50 text-red-400 rounded-lg hover:bg-red-500/10 transition-colors"
>
Disable MFA
</button>
</div>
) : (
<div className="space-y-4">
<p className="text-text-secondary text-sm">
Add an extra layer of security to your account by enabling two-factor
authentication with an authenticator app.
</p>
<button
type="button"
onClick={handleEnroll}
disabled={actionLoading}
className="px-4 py-2 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 text-white rounded-lg transition-colors"
>
{actionLoading ? 'Setting up...' : 'Enable MFA'}
</button>
{error && <p className="text-red-400 text-sm">{error}</p>}
</div>
)}
</>
)}
{step === 'enrolling' && enrollmentData && (
<div className="space-y-4">
<p className="text-text-secondary text-sm">
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.):
</p>
<div className="flex justify-center p-4 bg-white rounded-lg">
<img src={enrollmentData.qrCode} alt="MFA QR Code" className="w-48 h-48" />
</div>
<div className="bg-surface-2 rounded-lg p-4 space-y-2">
<p className="text-xs text-text-tertiary">
Manual entry code (save this as a backup):
</p>
<code className="block text-sm font-mono bg-surface-3 px-3 py-2 rounded select-all text-center break-all">
{enrollmentData.secret}
</code>
<p className="text-xs text-yellow-500">
Save this code securely. You can use it to restore your authenticator if you
lose access to your device.
</p>
</div>
<form onSubmit={handleVerifyEnrollment} className="space-y-4">
<div>
<label
htmlFor="totp-code"
className="block text-sm font-medium text-text-secondary mb-1"
>
Enter the 6-digit code from your app
</label>
<input
id="totp-code"
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 text-center text-lg tracking-widest font-mono"
autoComplete="one-time-code"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={code.length !== 6 || actionLoading}
className="flex-1 px-4 py-2 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 text-white rounded-lg transition-colors"
>
{actionLoading ? 'Verifying...' : 'Verify & Enable'}
</button>
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 border border-border-default rounded-lg hover:bg-surface-2 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
)}
{step === 'success' && (
<div className="space-y-4">
<div className="flex items-center gap-2 text-green-400">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span className="font-semibold">MFA enabled successfully!</span>
</div>
<p className="text-text-secondary text-sm">
Your account is now protected with two-factor authentication. You&apos;ll need to
enter a code from your authenticator app each time you sign in.
</p>
<button
type="button"
onClick={() => setStep('idle')}
className="px-4 py-2 bg-accent-600 hover:bg-accent-700 text-white rounded-lg transition-colors"
>
Done
</button>
</div>
)}
{step === 'disabling' && (
<form onSubmit={handleConfirmDisable} className="space-y-4">
<p className="text-text-secondary text-sm">
To disable MFA, enter a code from your authenticator app to confirm.
</p>
<div>
<label
htmlFor="disable-code"
className="block text-sm font-medium text-text-secondary mb-1"
>
Authentication code
</label>
<input
id="disable-code"
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 text-center text-lg tracking-widest font-mono"
autoComplete="one-time-code"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={code.length !== 6 || actionLoading}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:opacity-50 text-white rounded-lg transition-colors"
>
{actionLoading ? 'Disabling...' : 'Disable MFA'}
</button>
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 border border-border-default rounded-lg hover:bg-surface-2 transition-colors"
>
Cancel
</button>
</div>
</form>
)}
</>
)}
</section>
</div>
)
}