Compare commits
2 Commits
8be9718293
...
50ed370d24
| Author | SHA1 | Date | |
|---|---|---|---|
| 50ed370d24 | |||
| 7a828d7215 |
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-f2hs
|
||||||
|
title: Optional TOTP MFA for email/password accounts
|
||||||
|
status: in-progress
|
||||||
|
type: feature
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-03-21T12:19:18Z
|
||||||
|
updated_at: 2026-03-21T12:56:34Z
|
||||||
|
parent: nuzlocke-tracker-wwnu
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Users who sign up with email/password have no MFA option. Google/Discord OAuth users get their provider's MFA, but email-only users have a weaker security posture.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Supabase has built-in TOTP MFA support via the `supabase.auth.mfa` API. This should be optional — users can enable it from their profile/settings page.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- No backend changes needed — Supabase handles MFA enrollment and verification at the auth layer
|
||||||
|
- JWT tokens from MFA-enrolled users include an `aal` (authenticator assurance level) claim; optionally validate `aal2` for sensitive operations in the future
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
1. Add MFA setup flow to user profile/settings page:
|
||||||
|
- "Enable MFA" button → calls `supabase.auth.mfa.enroll({ factorType: 'totp' })`
|
||||||
|
- Show QR code from enrollment response
|
||||||
|
- Verify with TOTP code → `supabase.auth.mfa.challengeAndVerify()`
|
||||||
|
2. Add MFA challenge during login:
|
||||||
|
- After email/password sign-in, check `supabase.auth.mfa.getAuthenticatorAssuranceLevel()`
|
||||||
|
- If `currentLevel === 'aal1'` and `nextLevel === 'aal2'`, show TOTP input
|
||||||
|
- Verify → `supabase.auth.mfa.challengeAndVerify()`
|
||||||
|
3. Add "Disable MFA" option with re-verification
|
||||||
|
4. Only show MFA options for email/password users (not OAuth)
|
||||||
|
|
||||||
|
### UX
|
||||||
|
- Settings page: toggle to enable/disable MFA
|
||||||
|
- Login flow: TOTP input step after password for enrolled users
|
||||||
|
- Recovery: Supabase provides recovery codes during enrollment — display them
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
- `frontend/src/pages/` — new MFA settings component or add to existing profile page
|
||||||
|
- `frontend/src/pages/Login.tsx` — add MFA challenge step
|
||||||
|
- `frontend/src/contexts/AuthContext.tsx` — handle AAL levels
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [x] Add MFA enrollment UI (QR code, verification) to profile/settings
|
||||||
|
- [x] Display backup secret code after enrollment (Supabase TOTP doesn't provide recovery codes)
|
||||||
|
- [x] Add TOTP challenge step to login flow
|
||||||
|
- [x] Check AAL after login and redirect to TOTP if needed
|
||||||
|
- [x] Add "Disable MFA" with re-verification
|
||||||
|
- [x] Only show MFA options for email/password users
|
||||||
|
- [ ] Test: full enrollment → login → TOTP flow
|
||||||
|
- [N/A] Test: recovery code works when TOTP unavailable (Supabase doesn't provide recovery codes; users save their secret key instead)
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
NewRun,
|
NewRun,
|
||||||
RunList,
|
RunList,
|
||||||
RunEncounters,
|
RunEncounters,
|
||||||
|
Settings,
|
||||||
Signup,
|
Signup,
|
||||||
Stats,
|
Stats,
|
||||||
} from './pages'
|
} from './pages'
|
||||||
@@ -42,6 +43,7 @@ function App() {
|
|||||||
<Route path="genlockes/new" element={<ProtectedRoute><NewGenlocke /></ProtectedRoute>} />
|
<Route path="genlockes/new" element={<ProtectedRoute><NewGenlocke /></ProtectedRoute>} />
|
||||||
<Route path="genlockes/:genlockeId" element={<GenlockeDetail />} />
|
<Route path="genlockes/:genlockeId" element={<GenlockeDetail />} />
|
||||||
<Route path="stats" element={<Stats />} />
|
<Route path="stats" element={<Stats />} />
|
||||||
|
<Route path="settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||||
<Route
|
<Route
|
||||||
path="runs/:runId/encounters"
|
path="runs/:runId/encounters"
|
||||||
element={<Navigate to=".." relative="path" replace />}
|
element={<Navigate to=".." relative="path" replace />}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useTheme } from '../hooks/useTheme'
|
import { useTheme } from '../hooks/useTheme'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
@@ -67,6 +67,7 @@ function ThemeToggle() {
|
|||||||
function UserMenu({ onAction }: { onAction?: () => void }) {
|
function UserMenu({ onAction }: { onAction?: () => void }) {
|
||||||
const { user, loading, signOut } = useAuth()
|
const { user, loading, signOut } = useAuth()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="w-8 h-8 rounded-full bg-surface-3 animate-pulse" />
|
return <div className="w-8 h-8 rounded-full bg-surface-3 animate-pulse" />
|
||||||
@@ -106,6 +107,17 @@ function UserMenu({ onAction }: { onAction?: () => void }) {
|
|||||||
<p className="text-sm text-text-primary truncate">{email}</p>
|
<p className="text-sm text-text-primary truncate">{email}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
onAction?.()
|
||||||
|
navigate('/settings')
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import type { User, Session, AuthError } from '@supabase/supabase-js'
|
import type { User, Session, AuthError, Factor } from '@supabase/supabase-js'
|
||||||
import { supabase } from '../lib/supabase'
|
import { supabase } from '../lib/supabase'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
|
|
||||||
@@ -10,19 +10,42 @@ interface UserProfile {
|
|||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MfaState {
|
||||||
|
requiresMfa: boolean
|
||||||
|
factorId: string | null
|
||||||
|
enrolledFactors: Factor[]
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null
|
user: User | null
|
||||||
session: Session | null
|
session: Session | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
mfa: MfaState
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MfaEnrollResult {
|
||||||
|
factorId: string
|
||||||
|
qrCode: string
|
||||||
|
secret: string
|
||||||
|
recoveryCodes?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextValue extends AuthState {
|
interface AuthContextValue extends AuthState {
|
||||||
signInWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
|
signInWithEmail: (
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
) => Promise<{ error: AuthError | null; requiresMfa?: boolean }>
|
||||||
signUpWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
|
signUpWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
|
||||||
signInWithGoogle: () => Promise<{ error: AuthError | null }>
|
signInWithGoogle: () => Promise<{ error: AuthError | null }>
|
||||||
signInWithDiscord: () => Promise<{ error: AuthError | null }>
|
signInWithDiscord: () => Promise<{ error: AuthError | null }>
|
||||||
signOut: () => Promise<void>
|
signOut: () => Promise<void>
|
||||||
|
verifyMfa: (code: string) => Promise<{ error: AuthError | null }>
|
||||||
|
enrollMfa: () => Promise<{ data: MfaEnrollResult | null; error: AuthError | null }>
|
||||||
|
verifyMfaEnrollment: (factorId: string, code: string) => Promise<{ error: AuthError | null }>
|
||||||
|
unenrollMfa: (factorId: string) => Promise<{ error: AuthError | null }>
|
||||||
|
isOAuthUser: boolean
|
||||||
|
refreshMfaState: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
@@ -37,25 +60,49 @@ async function syncUserProfile(session: Session | null): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getMfaState(): Promise<MfaState> {
|
||||||
|
const defaultState: MfaState = { requiresMfa: false, factorId: null, enrolledFactors: [] }
|
||||||
|
try {
|
||||||
|
const { data: aalData } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
||||||
|
if (!aalData) return defaultState
|
||||||
|
|
||||||
|
const { data: factorsData } = await supabase.auth.mfa.listFactors()
|
||||||
|
const verifiedFactors = factorsData?.totp?.filter((f) => f.status === 'verified') ?? []
|
||||||
|
|
||||||
|
const requiresMfa = aalData.currentLevel === 'aal1' && aalData.nextLevel === 'aal2'
|
||||||
|
const factorId = requiresMfa ? (verifiedFactors[0]?.id ?? null) : null
|
||||||
|
|
||||||
|
return { requiresMfa, factorId, enrolledFactors: verifiedFactors }
|
||||||
|
} catch {
|
||||||
|
return defaultState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [state, setState] = useState<AuthState>({
|
const [state, setState] = useState<AuthState>({
|
||||||
user: null,
|
user: null,
|
||||||
session: null,
|
session: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
mfa: { requiresMfa: false, factorId: null, enrolledFactors: [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const refreshMfaState = useCallback(async () => {
|
||||||
|
const mfa = await getMfaState()
|
||||||
|
setState((prev) => ({ ...prev, mfa }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
supabase.auth.getSession().then(async ({ data: { session } }) => {
|
supabase.auth.getSession().then(async ({ data: { session } }) => {
|
||||||
const isAdmin = await syncUserProfile(session)
|
const [isAdmin, mfa] = await Promise.all([syncUserProfile(session), getMfaState()])
|
||||||
setState({ user: session?.user ?? null, session, loading: false, isAdmin })
|
setState({ user: session?.user ?? null, session, loading: false, isAdmin, mfa })
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { subscription },
|
data: { subscription },
|
||||||
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
||||||
const isAdmin = await syncUserProfile(session)
|
const [isAdmin, mfa] = await Promise.all([syncUserProfile(session), getMfaState()])
|
||||||
setState({ user: session?.user ?? null, session, loading: false, isAdmin })
|
setState({ user: session?.user ?? null, session, loading: false, isAdmin, mfa })
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => subscription.unsubscribe()
|
return () => subscription.unsubscribe()
|
||||||
@@ -63,7 +110,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const signInWithEmail = useCallback(async (email: string, password: string) => {
|
const signInWithEmail = useCallback(async (email: string, password: string) => {
|
||||||
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||||
return { error }
|
if (error) return { error }
|
||||||
|
|
||||||
|
const mfa = await getMfaState()
|
||||||
|
setState((prev) => ({ ...prev, mfa }))
|
||||||
|
return { error: null, requiresMfa: mfa.requiresMfa }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const signUpWithEmail = useCallback(async (email: string, password: string) => {
|
const signUpWithEmail = useCallback(async (email: string, password: string) => {
|
||||||
@@ -91,6 +142,79 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await supabase.auth.signOut()
|
await supabase.auth.signOut()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const verifyMfa = useCallback(
|
||||||
|
async (code: string) => {
|
||||||
|
const factorId = state.mfa.factorId
|
||||||
|
if (!factorId) {
|
||||||
|
return { error: { message: 'No MFA factor found' } as AuthError }
|
||||||
|
}
|
||||||
|
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
|
||||||
|
factorId,
|
||||||
|
})
|
||||||
|
if (challengeError) return { error: challengeError }
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.mfa.verify({
|
||||||
|
factorId,
|
||||||
|
challengeId: challengeData.id,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
if (!error) {
|
||||||
|
const mfa = await getMfaState()
|
||||||
|
setState((prev) => ({ ...prev, mfa }))
|
||||||
|
}
|
||||||
|
return { error }
|
||||||
|
},
|
||||||
|
[state.mfa.factorId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const enrollMfa = useCallback(async () => {
|
||||||
|
const { data, error } = await supabase.auth.mfa.enroll({ factorType: 'totp' })
|
||||||
|
if (error || !data) {
|
||||||
|
return { data: null, error }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
factorId: data.id,
|
||||||
|
qrCode: data.totp.qr_code,
|
||||||
|
secret: data.totp.secret,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const verifyMfaEnrollment = useCallback(async (factorId: string, code: string) => {
|
||||||
|
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
|
||||||
|
factorId,
|
||||||
|
})
|
||||||
|
if (challengeError) return { error: challengeError }
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.mfa.verify({
|
||||||
|
factorId,
|
||||||
|
challengeId: challengeData.id,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
if (!error) {
|
||||||
|
const mfa = await getMfaState()
|
||||||
|
setState((prev) => ({ ...prev, mfa }))
|
||||||
|
}
|
||||||
|
return { error }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const unenrollMfa = useCallback(async (factorId: string) => {
|
||||||
|
const { error } = await supabase.auth.mfa.unenroll({ factorId })
|
||||||
|
if (!error) {
|
||||||
|
const mfa = await getMfaState()
|
||||||
|
setState((prev) => ({ ...prev, mfa }))
|
||||||
|
}
|
||||||
|
return { error }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isOAuthUser = useMemo(() => {
|
||||||
|
if (!state.user) return false
|
||||||
|
const provider = state.user.app_metadata?.['provider']
|
||||||
|
return provider === 'google' || provider === 'discord'
|
||||||
|
}, [state.user])
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...state,
|
...state,
|
||||||
@@ -99,8 +223,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
signInWithGoogle,
|
signInWithGoogle,
|
||||||
signInWithDiscord,
|
signInWithDiscord,
|
||||||
signOut,
|
signOut,
|
||||||
|
verifyMfa,
|
||||||
|
enrollMfa,
|
||||||
|
verifyMfaEnrollment,
|
||||||
|
unenrollMfa,
|
||||||
|
isOAuthUser,
|
||||||
|
refreshMfaState,
|
||||||
}),
|
}),
|
||||||
[state, signInWithEmail, signUpWithEmail, signInWithGoogle, signInWithDiscord, signOut]
|
[
|
||||||
|
state,
|
||||||
|
signInWithEmail,
|
||||||
|
signUpWithEmail,
|
||||||
|
signInWithGoogle,
|
||||||
|
signInWithDiscord,
|
||||||
|
signOut,
|
||||||
|
verifyMfa,
|
||||||
|
enrollMfa,
|
||||||
|
verifyMfaEnrollment,
|
||||||
|
unenrollMfa,
|
||||||
|
isOAuthUser,
|
||||||
|
refreshMfaState,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ const isLocalDev = import.meta.env['VITE_SUPABASE_URL']?.includes('localhost') ?
|
|||||||
export function Login() {
|
export function Login() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [totpCode, setTotpCode] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
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 navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
@@ -20,11 +22,29 @@ export function Login() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
const { error } = await signInWithEmail(email, password)
|
const { error, requiresMfa } = await signInWithEmail(email, password)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setError(error.message)
|
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 {
|
} else {
|
||||||
navigate(from, { replace: true })
|
navigate(from, { replace: true })
|
||||||
}
|
}
|
||||||
@@ -42,6 +62,68 @@ export function Login() {
|
|||||||
if (error) setError(error.message)
|
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 (
|
return (
|
||||||
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
||||||
<div className="w-full max-w-sm space-y-6">
|
<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 { NewRun } from './NewRun'
|
||||||
export { RunList } from './RunList'
|
export { RunList } from './RunList'
|
||||||
export { RunEncounters } from './RunEncounters'
|
export { RunEncounters } from './RunEncounters'
|
||||||
|
export { Settings } from './Settings'
|
||||||
export { Signup } from './Signup'
|
export { Signup } from './Signup'
|
||||||
export { Stats } from './Stats'
|
export { Stats } from './Stats'
|
||||||
|
|||||||
Reference in New Issue
Block a user