cool stuff

This commit is contained in:
nmemmert
2026-06-03 14:14:25 -04:00
parent a2c4924191
commit 68f6212e9b
7 changed files with 665 additions and 43 deletions
+372 -38
View File
@@ -14,6 +14,9 @@ type StudyAuthState = {
subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
totpEnabled?: boolean
twoFaMethod?: 'app' | 'email' | null
totpRecoveryCodesRemaining?: number
}
type StudyAuthStatusResponse = {
@@ -24,6 +27,9 @@ type StudyAuthStatusResponse = {
subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
totpEnabled?: boolean
twoFaMethod?: 'app' | 'email' | null
totpRecoveryCodesRemaining?: number
}
type StudyAccountOverview = {
@@ -891,6 +897,11 @@ export function StudySignupPage() {
const [message, setMessage] = useState('')
const [done, setDone] = useState(false)
const [loggedInAs, setLoggedInAs] = useState('')
// 2FA state
const [totpPendingToken, setTotpPendingToken] = useState('')
const [totpCode, setTotpCode] = useState('')
const [totpMethod, setTotpMethod] = useState<'app' | 'email' | null>(null)
const [resendStatus, setResendStatus] = useState<'idle' | 'sending' | 'sent'>('idle')
useEffect(() => {
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
@@ -911,11 +922,16 @@ export function StudySignupPage() {
setBusy(true)
setMessage('')
try {
const data = await readJson<{ username: string; enrolledStudySlugs?: string[] }>(`/api/study-auth/${mode}`, {
const data = await readJson<{ username?: string; enrolledStudySlugs?: string[]; totpRequired?: boolean; pendingToken?: string }>(`/api/study-auth/${mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: email, password, ...(mode === 'signup' ? { subscribe: subscribeNewsletter } : {}) }),
})
if (data.totpRequired && data.pendingToken) {
setTotpPendingToken(data.pendingToken)
setTotpMethod((data as { method?: 'app' | 'email' }).method ?? 'app')
return
}
setDone(true)
setLoggedInAs(data.username ?? email.trim().toLowerCase())
navigate('/study')
@@ -926,6 +942,26 @@ export function StudySignupPage() {
}
}, [mode, email, password, subscribeNewsletter])
const submitTotp = useCallback(async () => {
if (!totpCode.trim()) { setMessage('Please enter your 6-digit code.'); return }
setBusy(true)
setMessage('')
try {
const data = await readJson<{ ok?: boolean; username?: string; usedRecoveryCode?: boolean }>('/api/study-auth/totp-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pendingToken: totpPendingToken, code: totpCode }),
})
setDone(true)
setLoggedInAs(data.username ?? email.trim().toLowerCase())
navigate('/study')
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Invalid code. Please try again.')
} finally {
setBusy(false)
}
}, [totpPendingToken, totpCode, email])
return (
<main className="thanks-page" aria-label={mode === 'signup' ? 'Create account' : 'Sign in'}>
<div className="thanks-card study-signup-page-card">
@@ -941,6 +977,56 @@ export function StudySignupPage() {
<Link to="/study" className="btn-secondary">Browse Tracks</Link>
</div>
</>
) : totpPendingToken ? (
<div className="study-signup-form">
{totpMethod === 'email' ? (
<p style={{ color: '#a89a6a', marginBottom: '1.25rem', fontSize: '0.95rem' }}>
📧 A 6-digit code was sent to your email address. Enter it below to sign in.
</p>
) : (
<p style={{ color: '#a89a6a', marginBottom: '1.25rem', fontSize: '0.95rem' }}>
🔐 Open your authenticator app and enter the 6-digit code below.
</p>
)}
<label htmlFor="totp-code">{totpMethod === 'email' ? 'Email Code' : 'Authenticator Code'}</label>
<input
id="totp-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
autoFocus
onKeyDown={e => e.key === 'Enter' && submitTotp()}
style={{ letterSpacing: '0.3em', fontSize: '1.4rem', textAlign: 'center' }}
/>
{message && <p className="study-signup-error">{message}</p>}
<div className="study-signup-actions">
<button type="button" className="btn-primary" disabled={busy} onClick={submitTotp}>
{busy ? 'Verifying' : 'Verify'}
</button>
<button type="button" className="btn-secondary" onClick={() => { setTotpPendingToken(''); setMessage(''); setTotpCode('') }}>Back</button>
</div>
{totpMethod === 'email' && (
<p style={{ fontSize: '0.82rem', color: '#5a5440', marginTop: '1rem' }}>
Didn't get it?{' '}
<button type="button" disabled={resendStatus !== 'idle'} style={{ background: 'none', border: 'none', color: resendStatus === 'sent' ? '#7abf7a' : '#c9a84c', cursor: resendStatus === 'idle' ? 'pointer' : 'default', fontSize: 'inherit', textDecoration: 'underline', padding: 0 }} onClick={async () => {
setResendStatus('sending')
try {
await fetch('/api/study-auth/email-otp-resend', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pendingToken: totpPendingToken }) })
setResendStatus('sent')
setTimeout(() => setResendStatus('idle'), 30000)
} catch { setResendStatus('idle') }
}}>
{resendStatus === 'sending' ? 'Sending…' : resendStatus === 'sent' ? 'Code sent ✓' : 'Resend code'}
</button>
</p>
)}
{totpMethod === 'app' && (
<p style={{ fontSize: '0.8rem', color: '#5a5440', marginTop: '1rem' }}>Lost access to your app? Enter one of your recovery codes instead.</p>
)}
</div>
) : (
<>
<div className="study-signup-form">
@@ -1265,7 +1351,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
const [progressSaving, setProgressSaving] = useState(false)
const [progressMessage, setProgressMessage] = useState('')
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
const [checkpointReflection, setCheckpointReflection] = useState('')
const [_checkpointReflection, _setCheckpointReflection] = useState('')
const canSaveNote = auth.authenticated && !noteSaving && !noteLoading
const lessonAudioEmbedUrl = useMemo(() => {
@@ -1330,7 +1416,6 @@ export function ColossiansStudySectionPage({ content }: Props) {
useEffect(() => {
setCheckpointAnswers({})
setCheckpointReflection('')
}, [section?.id])
useEffect(() => {
@@ -1440,15 +1525,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
async function submitCheckpoint() {
if (!study || !section?.id || progressSaving) return
const checkpointQuestions = section.checkpointQuestions ?? []
const reflectionRequired = !(checkpointQuestions.length > 0)
const allAnswered = checkpointQuestions.every((_, index) => (checkpointAnswers[index] ?? '').trim().length > 0)
if (reflectionRequired && !checkpointReflection.trim()) {
setProgressMessage('Please write a short reflection before submitting the checkpoint.')
return
}
if (!reflectionRequired && !allAnswered) {
setProgressMessage('Please answer all checkpoint questions before submitting.')
if (!allAnswered) {
setProgressMessage('Please answer all checkpoint questions to mark this lesson complete.')
return
}
@@ -1459,9 +1539,9 @@ export function ColossiansStudySectionPage({ content }: Props) {
method: 'POST',
})
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
setProgressMessage('Checkpoint submitted and lesson marked complete.')
setProgressMessage('')
} catch (err) {
setProgressMessage(err instanceof Error ? err.message : 'Unable to submit checkpoint.')
setProgressMessage(err instanceof Error ? err.message : 'Unable to save. Please try again.')
} finally {
setProgressSaving(false)
}
@@ -1616,13 +1696,51 @@ export function ColossiansStudySectionPage({ content }: Props) {
</div>
</article>
{(section.checkpointPrompt || (section.checkpointQuestions && section.checkpointQuestions.length > 0)) && (
<article className="study-class-block">
<h2>Checkpoint</h2>
{section.checkpointPrompt && <p className="study-detail-copy">{section.checkpointPrompt}</p>}
{section.checkpointQuestions && section.checkpointQuestions.length > 0 ? (
{isEnrolled && !(section.checkpointQuestions && section.checkpointQuestions.length > 0) && (
<article className="study-class-block" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem' }}>
<div>
<h2 style={{ margin: 0 }}>Lesson Progress</h2>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: '#5a5440' }}>No checkpoint questions for this lesson.</p>
</div>
<button
type="button"
className={lessonCompleted ? 'btn-secondary' : 'btn-primary'}
style={lessonCompleted ? { borderColor: '#2196f3', color: '#2196f3' } : {}}
disabled={progressSaving}
onClick={async () => {
if (!study || !section?.id) return
setProgressSaving(true)
try {
const method = lessonCompleted ? 'DELETE' : 'POST'
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(
`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`,
{ method }
)
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
} catch { /* ignore */ }
finally { setProgressSaving(false) }
}}
>
{progressSaving ? '' : lessonCompleted ? ' Completed' : 'Mark as Completed'}
</button>
</article>
)}
{(section.checkpointQuestions && section.checkpointQuestions.length > 0) && (() => {
const allAnswered = section.checkpointQuestions!.every((_, i) => (checkpointAnswers[i] ?? '').trim().length > 0)
return (
<article className="study-class-block">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem' }}>
<h2 style={{ margin: 0 }}>Checkpoint</h2>
{lessonCompleted && (
<span style={{ background: '#1565c0', color: '#fff', padding: '0.2rem 0.65rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 600 }}>
✓ Completed
</span>
)}
</div>
{section.checkpointPrompt && <p className="study-detail-copy">{section.checkpointPrompt}</p>}
<div style={{ display: 'grid', gap: '1rem' }}>
{section.checkpointQuestions.map((question, index) => (
{section.checkpointQuestions!.map((question, index) => (
<div key={index}>
<p style={{ margin: '0 0 0.5rem', fontWeight: 600 }}>{question}</p>
<textarea
@@ -1630,30 +1748,28 @@ export function ColossiansStudySectionPage({ content }: Props) {
value={checkpointAnswers[index] ?? ''}
onChange={e => setCheckpointAnswers(prev => ({ ...prev, [index]: e.target.value }))}
placeholder="Write your answer here..."
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: `1px solid ${(checkpointAnswers[index] ?? '').trim() ? '#2a4a2a' : '#2a2518'}`, background: '#14130f', color: '#f0ead8' }}
/>
</div>
))}
</div>
) : (
<div>
<textarea
rows={4}
value={checkpointReflection}
onChange={e => setCheckpointReflection(e.target.value)}
placeholder="Reflect on the prompt above and write your observations here..."
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
/>
<div style={{ marginTop: '1.25rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className="btn-primary"
onClick={submitCheckpoint}
disabled={!isEnrolled || progressSaving || !allAnswered}
>
{progressSaving ? 'Saving' : lessonCompleted ? 'Update Answers' : 'Mark as Completed'}
</button>
{!allAnswered && (
<span style={{ fontSize: '0.85rem', color: '#5a5440' }}>Answer all questions to mark this lesson complete.</span>
)}
{progressMessage && <span style={{ fontSize: '0.85rem', color: '#e07a7a' }}>{progressMessage}</span>}
</div>
)}
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn-primary" onClick={submitCheckpoint} disabled={!isEnrolled || progressSaving}>
{progressSaving ? 'Submitting...' : lessonCompleted ? 'Re-submit Checkpoint' : 'Submit Checkpoint'}
</button>
{lessonCompleted && <span style={{ color: '#a39d8d' }}>Checkpoint completed for this lesson.</span>}
</div>
</article>
)}
</article>
)
})()}
</div>
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
@@ -1990,7 +2106,16 @@ export function StudyAccountPage() {
const [studyRemindersEnabled, setStudyRemindersEnabled] = useState(true)
const [prefMessage, setPrefMessage] = useState('')
const [prefBusy, setPrefBusy] = useState(false)
const [accountModal, setAccountModal] = useState<'none' | 'enrollments' | 'changeEmail' | 'changePassword' | 'profile' | 'preferences' | 'export'>('none')
const [accountModal, setAccountModal] = useState<'none' | 'enrollments' | 'changeEmail' | 'changePassword' | 'profile' | 'preferences' | 'export' | 'choose2fa' | 'setup2fa' | 'setup2fa-email' | 'disable2fa' | 'recoveryCodes'>('none')
// 2FA state
const [twoFaQr, setTwoFaQr] = useState('')
const [twoFaSecret, setTwoFaSecret] = useState('')
const [twoFaCode, setTwoFaCode] = useState('')
const [twoFaMessage, setTwoFaMessage] = useState('')
const [twoFaBusy, setTwoFaBusy] = useState(false)
const [twoFaRecoveryCodes, setTwoFaRecoveryCodes] = useState<string[]>([])
const [disablePassword, setDisablePassword] = useState('')
const [emailCurrentPassword, setEmailCurrentPassword] = useState('')
const [emailNew, setEmailNew] = useState('')
@@ -2031,6 +2156,9 @@ export function StudyAccountPage() {
studyRemindersEnabled: data.studyRemindersEnabled === true,
avatarUrl: data.avatarUrl ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
totpEnabled: data.totpEnabled === true,
twoFaMethod: data.twoFaMethod ?? null,
totpRecoveryCodesRemaining: data.totpRecoveryCodesRemaining ?? 0,
})
})
.catch(() => setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true }))
@@ -2349,7 +2477,32 @@ export function StudyAccountPage() {
<button type="button" className="btn-primary" onClick={() => setAccountModal('enrollments')}>Manage Enrollments</button>
<button type="button" className="btn-secondary" onClick={() => setAccountModal('changeEmail')}>Change Email</button>
<button type="button" className="btn-secondary" onClick={() => setAccountModal('changePassword')}>Change Password</button>
<button type="button" className={auth.totpEnabled ? 'btn-secondary' : 'btn-primary'} onClick={() => {
if (auth.totpEnabled) { setAccountModal('disable2fa'); return }
setAccountModal('choose2fa')
}} disabled={twoFaBusy}>
{auth.totpEnabled
? `2FA: ${auth.twoFaMethod === 'email' ? 'Email ' : 'App '}`
: '🔐 Enable Two-Factor Auth'}
</button>
</div>
{!auth.totpEnabled && (
<div style={{ marginTop: '1rem', background: 'rgba(201,168,76,0.07)', border: '1px solid rgba(201,168,76,0.2)', borderRadius: '8px', padding: '0.75rem 1rem', fontSize: '0.85rem', color: '#a89a6a' }}>
<strong style={{ color: '#c9a84c' }}>Recommended:</strong> Enable two-factor authentication to protect your study notes and account.
</div>
)}
{auth.totpEnabled && auth.totpRecoveryCodesRemaining !== undefined && auth.totpRecoveryCodesRemaining <= 2 && (
<div style={{ marginTop: '1rem', background: 'rgba(224,92,92,0.07)', border: '1px solid rgba(224,92,92,0.2)', borderRadius: '8px', padding: '0.75rem 1rem', fontSize: '0.85rem', color: '#e07a7a' }}>
<strong>Warning:</strong> You only have {auth.totpRecoveryCodesRemaining} recovery code{auth.totpRecoveryCodesRemaining === 1 ? '' : 's'} left.{' '}
<button type="button" style={{ background: 'none', border: 'none', color: '#c9a84c', cursor: 'pointer', textDecoration: 'underline', fontSize: 'inherit' }} onClick={async () => {
try {
const data = await readJson<{ ok: boolean; recoveryCodes: string[] }>('/api/study-auth/totp-regen-recovery', { method: 'POST' })
setTwoFaRecoveryCodes(data.recoveryCodes)
setAccountModal('recoveryCodes')
} catch { /* ignore */ }
}}>Generate new codes →</button>
</div>
)}
</section>
{accountModal !== 'none' && (
@@ -2503,6 +2656,187 @@ export function StudyAccountPage() {
{exportMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{exportMessage}</p>}
</>
)}
{accountModal === 'choose2fa' && (
<>
<h2 id="account-modal-heading">Choose Your 2FA Method</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.5rem', color: '#b9b09b' }}>
Pick how you'd like to verify your identity each time you sign in.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<button type="button" style={{ textAlign: 'left', background: '#1a1a12', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '1rem 1.25rem', cursor: 'pointer', color: '#f0e9cc' }} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
const data = await readJson<{ qrDataUrl: string; secret: string }>('/api/study-auth/totp-setup-init', { method: 'POST' })
if (!data.qrDataUrl || !data.secret) throw new Error('Setup response missing QR data.')
setTwoFaQr(data.qrDataUrl)
setTwoFaSecret(data.secret)
setTwoFaCode('')
setTwoFaMessage('')
setAccountModal('setup2fa')
} catch (err) { setTwoFaMessage(err instanceof Error ? err.message : 'Could not start setup. Please try again.') }
finally { setTwoFaBusy(false) }
}}>
<p style={{ fontWeight: 700, margin: '0 0 0.25rem' }}>📱 Authenticator App</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#8a7f5a' }}>Use Google Authenticator, Authy, 1Password, or any TOTP app. Works offline. More secure.</p>
</button>
<button type="button" style={{ textAlign: 'left', background: '#1a1a12', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '1rem 1.25rem', cursor: 'pointer', color: '#f0e9cc' }} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
await readJson('/api/study-auth/2fa-setup-email', { method: 'POST' })
setTwoFaCode(''); setTwoFaMessage('')
setAccountModal('setup2fa-email')
} catch { setTwoFaMessage('Could not send code. Please try again.') }
finally { setTwoFaBusy(false) }
}}>
<p style={{ fontWeight: 700, margin: '0 0 0.25rem' }}>📧 Email Code</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#8a7f5a' }}>A one-time code sent to your email each time you sign in. Easier to set up.</p>
</button>
</div>
{twoFaMessage && <p style={{ color: '#e07a7a', marginTop: '0.75rem' }}>{twoFaMessage}</p>}
</>
)}
{accountModal === 'setup2fa-email' && (
<>
<h2 id="account-modal-heading">Verify Your Email for 2FA</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.25rem', color: '#b9b09b' }}>
We sent a 6-digit code to your email. Enter it below to activate email-based two-factor authentication.
</p>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Verification Code</label>
<input
type="text" inputMode="numeric" autoComplete="one-time-code"
value={twoFaCode}
onChange={e => setTwoFaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
autoFocus
style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem', letterSpacing: '0.3em', fontSize: '1.3rem', textAlign: 'center' }}
/>
{twoFaMessage && <p style={{ color: '#e07a7a', marginBottom: '0.5rem' }}>{twoFaMessage}</p>}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn-primary" disabled={twoFaBusy || twoFaCode.length < 6} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
await readJson('/api/study-auth/2fa-setup-email-confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: twoFaCode }) })
setAuth(prev => ({ ...prev, totpEnabled: true, twoFaMethod: 'email', totpRecoveryCodesRemaining: 0 }))
closeAccountModal()
} catch (err) { setTwoFaMessage(err instanceof Error ? err.message : 'Invalid code.') }
finally { setTwoFaBusy(false) }
}}>{twoFaBusy ? 'Activating…' : 'Activate Email 2FA'}</button>
<button type="button" style={{ background: 'none', border: 'none', color: '#c9a84c', cursor: 'pointer', fontSize: '0.85rem', textDecoration: 'underline' }} onClick={async () => {
try { await readJson('/api/study-auth/2fa-setup-email', { method: 'POST' }) } catch { /* ignore */ }
}}>Resend code</button>
</div>
</>
)}
{accountModal === 'setup2fa' && (
<>
<h2 id="account-modal-heading">Enable Two-Factor Authentication</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.25rem', color: '#b9b09b' }}>
Scan the QR code below with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code to confirm.
</p>
{twoFaQr ? (
<div style={{ textAlign: 'center', marginBottom: '1.25rem' }}>
<img
src={twoFaQr}
alt="2FA QR code"
style={{ display: 'block', margin: '0 auto 0.75rem', borderRadius: '8px', background: '#fff', padding: '0.5rem' }}
width={180} height={180}
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
<p style={{ fontSize: '0.8rem', color: '#5a5440', margin: 0 }}>Can't scan? Enter this key manually:</p>
</div>
) : (
<p style={{ fontSize: '0.85rem', color: '#8a7f5a', marginBottom: '0.5rem' }}>Enter this key manually in your authenticator app:</p>
)}
{twoFaSecret && (
<div style={{ background: '#0a0a08', border: '1px solid rgba(201,168,76,0.2)', borderRadius: '8px', padding: '0.75rem 1rem', marginBottom: '1.25rem', textAlign: 'center', wordBreak: 'break-all' }}>
<code style={{ color: '#c9a84c', fontSize: '0.95rem', letterSpacing: '0.1em' }}>{twoFaSecret}</code>
</div>
)}
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Verification Code</label>
<input
type="text" inputMode="numeric" autoComplete="one-time-code"
value={twoFaCode}
onChange={e => setTwoFaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem', letterSpacing: '0.3em', fontSize: '1.3rem', textAlign: 'center' }}
/>
{twoFaMessage && <p style={{ color: '#e07a7a', marginBottom: '0.5rem' }}>{twoFaMessage}</p>}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-primary" disabled={twoFaBusy || twoFaCode.length < 6} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
const data = await readJson<{ ok: boolean; recoveryCodes: string[] }>('/api/study-auth/totp-setup-confirm', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: twoFaCode }),
})
setTwoFaRecoveryCodes(data.recoveryCodes)
setAuth(prev => ({ ...prev, totpEnabled: true, totpRecoveryCodesRemaining: data.recoveryCodes.length }))
setAccountModal('recoveryCodes')
} catch (err) {
setTwoFaMessage(err instanceof Error ? err.message : 'Code incorrect. Try again.')
} finally { setTwoFaBusy(false) }
}}>{twoFaBusy ? 'Verifying' : 'Activate 2FA'}</button>
</div>
</>
)}
{accountModal === 'recoveryCodes' && (
<>
<h2 id="account-modal-heading">Save Your Recovery Codes</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.25rem', color: '#b9b09b' }}>
Store these codes somewhere safe. Each one can be used once if you ever lose access to your authenticator app. <strong style={{ color: '#f0ead8' }}>You won't be able to see these again.</strong>
</p>
<div style={{ background: '#0a0a08', border: '1px solid rgba(201,168,76,0.2)', borderRadius: '8px', padding: '1rem', marginBottom: '1.25rem', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
{twoFaRecoveryCodes.map(code => (
<code key={code} style={{ color: '#c9a84c', fontSize: '0.9rem', letterSpacing: '0.1em' }}>{code}</code>
))}
</div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-primary" onClick={() => {
navigator.clipboard?.writeText(twoFaRecoveryCodes.join('\n')).catch(() => {})
}}>Copy All</button>
<button type="button" className="btn-secondary" onClick={closeAccountModal}>Done</button>
</div>
</>
)}
{accountModal === 'disable2fa' && (
<>
<h2 id="account-modal-heading">Disable Two-Factor Authentication</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.25rem', color: '#b9b09b' }}>
Enter your password to confirm. This will remove {auth.twoFaMethod === 'email' ? 'email code' : 'authenticator app'} 2FA from your account.
</p>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Current Password</label>
<input
type="password" autoComplete="current-password"
value={disablePassword}
onChange={e => setDisablePassword(e.target.value)}
placeholder="Your password"
style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }}
/>
{twoFaMessage && <p style={{ color: '#e07a7a', marginBottom: '0.5rem' }}>{twoFaMessage}</p>}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-admin-remove" disabled={twoFaBusy || !disablePassword} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
await readJson('/api/study-auth/totp-disable', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: disablePassword }),
})
setAuth(prev => ({ ...prev, totpEnabled: false, totpRecoveryCodesRemaining: 0 }))
setDisablePassword('')
closeAccountModal()
} catch (err) {
setTwoFaMessage(err instanceof Error ? err.message : 'Could not disable 2FA.')
} finally { setTwoFaBusy(false) }
}}>{twoFaBusy ? 'Disabling…' : 'Disable 2FA'}</button>
<button type="button" className="btn-secondary" onClick={closeAccountModal}>Cancel</button>
</div>
</>
)}
{accountModal === 'changePassword' && (
<>
<h2 id="account-modal-heading">Change Password</h2>