([])
+ 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() {
+
+ {!auth.totpEnabled && (
+
+ Recommended: Enable two-factor authentication to protect your study notes and account.
+
+ )}
+ {auth.totpEnabled && auth.totpRecoveryCodesRemaining !== undefined && auth.totpRecoveryCodesRemaining <= 2 && (
+
+ Warning: You only have {auth.totpRecoveryCodesRemaining} recovery code{auth.totpRecoveryCodesRemaining === 1 ? '' : 's'} left.{' '}
+
+
+ )}
{accountModal !== 'none' && (
@@ -2503,6 +2656,187 @@ export function StudyAccountPage() {
{exportMessage && {exportMessage}
}
>
)}
+ {accountModal === 'choose2fa' && (
+ <>
+ Choose Your 2FA Method
+
+ Pick how you'd like to verify your identity each time you sign in.
+
+
+
+
+
+ {twoFaMessage && {twoFaMessage}
}
+ >
+ )}
+
+ {accountModal === 'setup2fa-email' && (
+ <>
+ Verify Your Email for 2FA
+
+ We sent a 6-digit code to your email. Enter it below to activate email-based two-factor authentication.
+
+
+ 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 && {twoFaMessage}
}
+
+
+
+
+ >
+ )}
+
+ {accountModal === 'setup2fa' && (
+ <>
+ Enable Two-Factor Authentication
+
+ Scan the QR code below with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code to confirm.
+
+ {twoFaQr ? (
+
+

{ (e.target as HTMLImageElement).style.display = 'none' }}
+ />
+
Can't scan? Enter this key manually:
+
+ ) : (
+ Enter this key manually in your authenticator app:
+ )}
+ {twoFaSecret && (
+
+ {twoFaSecret}
+
+ )}
+
+ 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 && {twoFaMessage}
}
+
+
+
+ >
+ )}
+
+ {accountModal === 'recoveryCodes' && (
+ <>
+ Save Your Recovery Codes
+
+ Store these codes somewhere safe. Each one can be used once if you ever lose access to your authenticator app. You won't be able to see these again.
+
+
+ {twoFaRecoveryCodes.map(code => (
+ {code}
+ ))}
+
+
+
+
+
+ >
+ )}
+
+ {accountModal === 'disable2fa' && (
+ <>
+ Disable Two-Factor Authentication
+
+ Enter your password to confirm. This will remove {auth.twoFaMethod === 'email' ? 'email code' : 'authenticator app'} 2FA from your account.
+
+
+ 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 && {twoFaMessage}
}
+
+
+
+
+ >
+ )}
+
{accountModal === 'changePassword' && (
<>
Change Password
diff --git a/src/content.ts b/src/content.ts
index d7915b3..dcf34bc 100644
--- a/src/content.ts
+++ b/src/content.ts
@@ -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: [],