diff --git a/.beans/nuzlocke-tracker-f2hs--optional-totp-mfa-for-emailpassword-accounts.md b/.beans/nuzlocke-tracker-f2hs--optional-totp-mfa-for-emailpassword-accounts.md new file mode 100644 index 0000000..e9fd780 --- /dev/null +++ b/.beans/nuzlocke-tracker-f2hs--optional-totp-mfa-for-emailpassword-accounts.md @@ -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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d96dbd4..e1037ba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { NewRun, RunList, RunEncounters, + Settings, Signup, Stats, } from './pages' @@ -42,6 +43,7 @@ function App() { } /> } /> } /> + } /> } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 0671323..4dbb000 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,5 +1,5 @@ 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 { useAuth } from '../contexts/AuthContext' @@ -67,6 +67,7 @@ function ThemeToggle() { function UserMenu({ onAction }: { onAction?: () => void }) { const { user, loading, signOut } = useAuth() const [open, setOpen] = useState(false) + const navigate = useNavigate() if (loading) { return
@@ -106,6 +107,17 @@ function UserMenu({ onAction }: { onAction?: () => void }) {

{email}

+ + + + +
+ + ) + } + return (
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..665ccd1 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -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('idle') + const [enrollmentData, setEnrollmentData] = useState(null) + const [code, setCode] = useState('') + const [error, setError] = useState(null) + const [actionLoading, setActionLoading] = useState(false) + const [disableFactorId, setDisableFactorId] = useState(null) + + if (loading) { + return ( +
+
+
+ ) + } + + if (!user) { + return + } + + 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 ( +
+

Settings

+ +
+

Two-Factor Authentication

+ + {isOAuthUser ? ( +

+ You signed in with an OAuth provider (Google/Discord). MFA is managed by your provider. +

+ ) : ( + <> + {step === 'idle' && ( + <> + {hasMfa ? ( +
+
+ + + + MFA is enabled +
+

+ Your account is protected with two-factor authentication. +

+ +
+ ) : ( +
+

+ Add an extra layer of security to your account by enabling two-factor + authentication with an authenticator app. +

+ + {error &&

{error}

} +
+ )} + + )} + + {step === 'enrolling' && enrollmentData && ( +
+

+ Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.): +

+
+ MFA QR Code +
+
+

+ Manual entry code (save this as a backup): +

+ + {enrollmentData.secret} + +

+ Save this code securely. You can use it to restore your authenticator if you + lose access to your device. +

+
+
+
+ + 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" + /> +
+ {error &&

{error}

} +
+ + +
+
+
+ )} + + {step === 'success' && ( +
+
+ + + + MFA enabled successfully! +
+

+ 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. +

+ +
+ )} + + {step === 'disabling' && ( +
+

+ To disable MFA, enter a code from your authenticator app to confirm. +

+
+ + 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" + /> +
+ {error &&

{error}

} +
+ + +
+
+ )} + + )} +
+
+ ) +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 3af9b0b..4d46878 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -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'