feat: add optional TOTP MFA for email/password accounts
- 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:
@@ -7,9 +7,11 @@ const isLocalDev = import.meta.env['VITE_SUPABASE_URL']?.includes('localhost') ?
|
||||
export function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { signInWithEmail, signInWithGoogle, signInWithDiscord } = useAuth()
|
||||
const [showMfaChallenge, setShowMfaChallenge] = useState(false)
|
||||
const { signInWithEmail, signInWithGoogle, signInWithDiscord, verifyMfa } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
@@ -20,11 +22,29 @@ export function Login() {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const { error } = await signInWithEmail(email, password)
|
||||
const { error, requiresMfa } = await signInWithEmail(email, password)
|
||||
setLoading(false)
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
} else if (requiresMfa) {
|
||||
setShowMfaChallenge(true)
|
||||
} else {
|
||||
navigate(from, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMfaSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const { error } = await verifyMfa(totpCode)
|
||||
setLoading(false)
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setTotpCode('')
|
||||
} else {
|
||||
navigate(from, { replace: true })
|
||||
}
|
||||
@@ -42,6 +62,68 @@ export function Login() {
|
||||
if (error) setError(error.message)
|
||||
}
|
||||
|
||||
if (showMfaChallenge) {
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold">Two-Factor Authentication</h1>
|
||||
<p className="text-text-secondary mt-1">Enter the code from your authenticator app</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleMfaSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="totp-code"
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
Authentication code
|
||||
</label>
|
||||
<input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
autoFocus
|
||||
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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={totpCode.length !== 6 || loading}
|
||||
className="w-full py-2 px-4 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMfaChallenge(false)
|
||||
setTotpCode('')
|
||||
setError(null)
|
||||
}}
|
||||
className="w-full text-center text-sm text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
|
||||
303
frontend/src/pages/Settings.tsx
Normal file
303
frontend/src/pages/Settings.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
@@ -8,5 +8,6 @@ export { NewGenlocke } from './NewGenlocke'
|
||||
export { NewRun } from './NewRun'
|
||||
export { RunList } from './RunList'
|
||||
export { RunEncounters } from './RunEncounters'
|
||||
export { Settings } from './Settings'
|
||||
export { Signup } from './Signup'
|
||||
export { Stats } from './Stats'
|
||||
|
||||
Reference in New Issue
Block a user