cool stuff
This commit is contained in:
@@ -461,6 +461,12 @@ function EmailTemplatesPanel({
|
||||
fieldKeys={['emailChangeSubject','emailChangeBody','emailChangeCtaLabel']}
|
||||
openKey="email-change" />
|
||||
|
||||
<EmailTemplateCard type="transactional" icon="📟" title="Two-Factor Auth — Email Sign-In Code"
|
||||
trigger="A student with email-based 2FA signs in, or requests a resend during login"
|
||||
autoVars="The 6-digit code is auto-generated and inserted between the body text and expiry note — not editable"
|
||||
fieldKeys={['twoFaOtpEmailSubject','twoFaOtpEmailBody','twoFaOtpEmailExpiry']}
|
||||
openKey="2fa-otp" />
|
||||
|
||||
{renderSaveStatus()}
|
||||
</section>
|
||||
)
|
||||
@@ -939,6 +945,9 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
|
||||
{ key: 'emailChangeSubject', label: 'Subject Line', section: 'email-templates' },
|
||||
{ key: 'emailChangeBody', label: 'Body Text', multiline: true, section: 'email-templates' },
|
||||
{ key: 'emailChangeCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||||
{ key: 'twoFaOtpEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||||
{ key: 'twoFaOtpEmailBody', label: 'Body Text (shown above the code)', multiline: true, section: 'email-templates' },
|
||||
{ key: 'twoFaOtpEmailExpiry', label: 'Expiry / Security Note (shown below the code)', multiline: true, section: 'email-templates' },
|
||||
]
|
||||
|
||||
function normalizeStudies(siteContent: SiteContent): StudyProgram[] {
|
||||
|
||||
+372
-38
@@ -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>
|
||||
|
||||
@@ -248,6 +248,10 @@ export interface SiteContent {
|
||||
emailChangeSubject: string
|
||||
emailChangeBody: string
|
||||
emailChangeCtaLabel: string
|
||||
// ── 2FA Email OTP ──
|
||||
twoFaOtpEmailSubject: string
|
||||
twoFaOtpEmailBody: string
|
||||
twoFaOtpEmailExpiry: string
|
||||
// ── Header ──
|
||||
headerFollowLabel: string
|
||||
// ── Cookie banner ──
|
||||
@@ -432,6 +436,9 @@ export const DEFAULTS: SiteContent = {
|
||||
emailChangeSubject: 'Confirm your new email address',
|
||||
emailChangeBody: 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.',
|
||||
emailChangeCtaLabel: 'Confirm Email Change',
|
||||
twoFaOtpEmailSubject: 'Your sign-in code — Verse by Verse with Nate',
|
||||
twoFaOtpEmailBody: 'Your two-factor sign-in code is below. Enter it to complete sign-in.',
|
||||
twoFaOtpEmailExpiry: 'This code expires in 10 minutes. If you did not request this, you can ignore this message.',
|
||||
headerFollowLabel: 'Follow on Spotify',
|
||||
cookieBannerText: 'We use optional analytics cookies to measure visits and location trends for site improvement.',
|
||||
customLinks: [],
|
||||
|
||||
Reference in New Issue
Block a user