import { normalizeNtfyServer } from '../utils/ntfyServer.js'; import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx'; import { useEffect, useState } from 'react'; import { useApp } from '../context/AppContext.js'; import PageShell from '../components/study/PageShell.jsx'; import { READER_TRANSLATIONS } from '../utils/bibleText.js'; import DataSection from '../components/settings/DataSection.jsx'; import PasskeysSection from '../components/settings/PasskeysSection.jsx'; import DevicesSection from '../components/settings/DevicesSection.jsx'; import { COMMENTARIES } from '../hooks/useVerseStudy.js'; import { READER_THEMES } from '../components/reader/readerThemes.js'; import { startMfaSetup, confirmMfaSetup, disableMfa, changePassword, updateProfile, updateReminderSettings, sendReminderTest, } from '../syncService.js'; import { exportJournalDocx, printJournalPdf } from '../utils/journalExport.js'; const SETTINGS_TABS = [['account', 'Account'], ['prefs', 'Preferences'], ['notify', 'Notifications'], ['security', 'Security'], ['data', 'Your data']]; const FONT_SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]]; const selectCls = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; // One labelled preference: what it is, whether it follows you between devices, and the control. function PrefRow({ label, hint, synced, children }) { return (

{label}

{hint} · {synced ? 'Synced to your account' : 'This device only'}

{children}
); } function Segmented({ label, options, value, onChange }) { return (
{options.map(([id, text]) => ( ))}
); } export default function SettingsPage() { const { authUser, setAuthUser, goHome, readerTheme, setReaderTheme, readerTranslation, setReaderTranslation, planTranslationPref, setPlanTranslationPref, startPagePref, setStartPagePref, readerLayout, setReaderLayout, readerFontSize, setReaderFontSize, readerWide, setReaderWide, readerCommentary, setReaderCommentary, audioNarrator, setAudioNarrator, settingsFocus, clearSettingsFocus, } = useApp(); // "Back up my data" from the ⌘K palette lands on that section const narrators = useLazyNarrators(audioNarrator); const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account'); useEffect(() => { if (!settingsFocus) return; if (settingsFocus === 'backup') setTab('data'); window.setTimeout(() => document.getElementById(settingsFocus)?.scrollIntoView({ block: 'start' }), 0); clearSettingsFocus(); }, [settingsFocus]); // eslint-disable-line react-hooks/exhaustive-deps const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress const [mfaSetupCode, setMfaSetupCode] = useState(''); const [mfaSetupError, setMfaSetupError] = useState(''); const [mfaSetupBusy, setMfaSetupBusy] = useState(false); const [mfaBackupCodes, setMfaBackupCodes] = useState(null); // shown once, right after enabling const [mfaDisablePassword, setMfaDisablePassword] = useState(''); const [mfaDisableError, setMfaDisableError] = useState(''); const [podcastNameInput, setPodcastNameInput] = useState(authUser?.podcastName ?? ''); const [podcastNameSaving, setPodcastNameSaving] = useState(false); const [podcastNameSaved, setPodcastNameSaved] = useState(false); const [changePasswordForm, setChangePasswordForm] = useState({ current: '', next: '', confirm: '' }); const [changePasswordBusy, setChangePasswordBusy] = useState(false); const [changePasswordError, setChangePasswordError] = useState(''); const [changePasswordSaved, setChangePasswordSaved] = useState(false); const [reminderForm, setReminderForm] = useState({ reminderEnabled: authUser?.reminderEnabled ?? false, ntfyUrl: authUser?.ntfyUrl ?? '', ntfyTopic: authUser?.ntfyTopic ?? '', reminderTime: authUser?.reminderTime ?? '07:00', votdEnabled: authUser?.votdEnabled ?? false, votdTime: authUser?.votdTime ?? '06:30', prayerEnabled: authUser?.prayerEnabled ?? false, prayerTime: authUser?.prayerTime ?? '20:00', }); const [reminderBusy, setReminderBusy] = useState(false); const [reminderError, setReminderError] = useState(''); const [reminderSaved, setReminderSaved] = useState(false); const [journalBusy, setJournalBusy] = useState(''); // '' | 'docx' | 'pdf' const [journalError, setJournalError] = useState(''); const startSetup = async () => { setMfaSetupError(''); setMfaSetupBusy(true); const result = await startMfaSetup(); setMfaSetupBusy(false); if (!result.ok) { setMfaSetupError(result.error ?? 'Could not start 2FA setup.'); return; } setMfaSetup(result.data); setMfaSetupCode(''); }; const confirmSetup = async (e) => { e.preventDefault(); setMfaSetupError(''); setMfaSetupBusy(true); const result = await confirmMfaSetup(mfaSetupCode.trim()); setMfaSetupBusy(false); if (!result.ok) { setMfaSetupError(result.error ?? 'Invalid code.'); return; } setMfaSetup(null); setMfaSetupCode(''); setMfaBackupCodes(result.data.backupCodes); setAuthUser((u) => ({ ...u, totpEnabled: true })); }; const cancelSetup = () => { setMfaSetup(null); setMfaSetupCode(''); setMfaSetupError(''); }; const submitDisable = async (e) => { e.preventDefault(); setMfaDisableError(''); const result = await disableMfa(mfaDisablePassword); if (!result.ok) { setMfaDisableError(result.error ?? 'Incorrect password.'); return; } setMfaDisablePassword(''); setAuthUser((u) => ({ ...u, totpEnabled: false })); }; const submitChangePassword = async (e) => { e.preventDefault(); setChangePasswordError(''); if (changePasswordForm.next !== changePasswordForm.confirm) { setChangePasswordError("New passwords don't match."); return; } setChangePasswordBusy(true); const result = await changePassword(changePasswordForm.current, changePasswordForm.next); setChangePasswordBusy(false); if (!result.ok) { setChangePasswordError(result.error ?? 'Failed to change password.'); return; } setChangePasswordForm({ current: '', next: '', confirm: '' }); setChangePasswordSaved(true); window.setTimeout(() => setChangePasswordSaved(false), 3000); }; const submitPodcastName = async (e) => { e.preventDefault(); setPodcastNameSaving(true); setPodcastNameSaved(false); const result = await updateProfile({ podcastName: podcastNameInput.trim() }); setPodcastNameSaving(false); if (!result.ok) return; setAuthUser((u) => ({ ...u, podcastName: result.data.podcastName })); setPodcastNameSaved(true); window.setTimeout(() => setPodcastNameSaved(false), 2500); }; const [reminderTest, setReminderTest] = useState({ busy: false, ok: false, message: '' }); const testReminder = async () => { setReminderTest({ busy: true, ok: false, message: '' }); const server = normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic); if (server.error) { setReminderTest({ busy: false, ok: false, message: server.error }); return; } const result = await sendReminderTest({ ntfyTopic: reminderForm.ntfyTopic, ntfyUrl: server.value }); setReminderTest(result.ok ? { busy: false, ok: true, message: 'Sent. Check your ntfy app or the topic page in a browser.' } : { busy: false, ok: false, message: result.error ?? 'Could not send the test.' }); }; const submitReminderSettings = async (e) => { e.preventDefault(); setReminderError(''); setReminderBusy(true); const server = normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic); if (server.error) { setReminderBusy(false); setReminderError(server.error); return; } const result = await updateReminderSettings({ ...reminderForm, ntfyUrl: server.value, reminderTz: Intl.DateTimeFormat().resolvedOptions().timeZone }); setReminderBusy(false); if (!result.ok) { setReminderError(result.error ?? 'Failed to save reminder settings.'); return; } setAuthUser((u) => ({ ...u, ...result.data })); setReminderForm((f) => ({ ...f, ntfyUrl: server.value })); // shows the address as saved (with https:// added if it was missing) setReminderSaved(true); window.setTimeout(() => setReminderSaved(false), 2500); }; const downloadJournalDocx = async () => { setJournalError(''); setJournalBusy('docx'); try { await exportJournalDocx(); } catch { setJournalError("Couldn't build the journal. Please try again."); } finally { setJournalBusy(''); } }; const printJournal = async () => { setJournalError(''); setJournalBusy('pdf'); try { await printJournalPdf(); } catch { setJournalError("Couldn't build the journal. Please try again."); } finally { setJournalBusy(''); } }; return (
{SETTINGS_TABS.map(([id, label]) => ( ))}
{tab === 'account' && (

Account

Signed in as {authUser.email}

)} {tab === 'prefs' && (

Preferences

How the app looks and reads. Study projects always use the Berean Standard Bible.

[id, t.label])} value={readerTheme} onChange={setReaderTheme} /> [n, l])} value={readerFontSize} onChange={setReaderFontSize} />
)} {tab === 'security' && (

Change password

Changing your password signs you out of every other device — this one stays signed in.

{changePasswordError &&

{changePasswordError}

} {changePasswordSaved &&

Password changed.

}
)} {tab === 'account' && (

Podcast / show name

Only needed if you use "Podcast → Claude" on a study's Script stage — it fills in your show's name when asking Claude to write an episode script. Leave blank if you're just doing personal study; that button still works, it just won't name a specific show.

setPodcastNameInput(e.target.value)} placeholder="e.g. Verse by Verse with Nate: A Journey Through Scripture" className="w-full flex-1 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
{podcastNameSaved &&

Saved.

}
)} {tab === 'data' && ( )} {tab === 'prefs' && (

My reading journal

A document of your bookmarked verses, highlighted passages, and completed reading plans. Bookmarks and highlights are saved to your account and follow you between devices.

{journalError &&

{journalError}

}
)} {tab === 'notify' && (

Reading reminders

Get a daily nudge via{' '} ntfy.sh {' '}— a free push-notification service. Install the ntfy app (or open{' '} {(normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic).value || 'https://ntfy.sh')}/{reminderForm.ntfyTopic || ''} {' '}in a browser) and subscribe to the topic below to receive it.

A notification to the same topic listing a few of the requests in your prayer journal. Nothing is sent when there are no open requests.
{authUser?.votdAvailable && (
A short notification with today’s verse (KJV) to the same topic, even if the reading reminder is off.
)} {reminderError &&

{reminderError}

} {reminderSaved &&

Saved.

} {reminderTest.message && (

{reminderTest.message}

)}
)} {tab === 'security' && (

Two-factor authentication

{authUser.totpEnabled ? "Enabled — you'll need a code from your authenticator app to sign in." : 'Add a 6-digit code from an authenticator app (Google Authenticator, Authy, 1Password, etc.) as a second step at sign-in.'}

{mfaBackupCodes ? (

Save these backup codes now — each works once if you ever lose your device. They won't be shown again.

{mfaBackupCodes.map((code) => (
{code}
))}
) : authUser.totpEnabled ? (
{mfaDisableError &&

{mfaDisableError}

}
) : mfaSetup ? (
2FA QR code

Scan this with your authenticator app, or enter the code manually:

{mfaSetup.secret}

{mfaSetupError &&

{mfaSetupError}

}
) : (
{mfaSetupError &&

{mfaSetupError}

}
)}
)} {tab === 'security' && } {tab === 'security' && }
); }