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]) => (
onChange(id)}
className={`px-3 py-1.5 text-sm ${value === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{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]) => (
setTab(id)}
className={`shrink-0 rounded-full px-4 py-1.5 text-sm transition ${tab === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{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} />
setReaderTranslation(e.target.value)} className={selectCls}>
{READER_TRANSLATIONS.map((t) => {t.id} · {t.name} )}
setPlanTranslationPref(e.target.value)} className={selectCls}>
Same as my default Bible
{READER_TRANSLATIONS.map((t) => {t.id} · {t.name} )}
setReaderCommentary(e.target.value)} className={selectCls}>
{COMMENTARIES.map((c) => {c.name} )}
[n, l])} value={readerFontSize} onChange={setReaderFontSize} />
setAudioNarrator(e.target.value)} className={selectCls} {...narrators.selectProps}>
David
Hays
Souer
)}
{tab === 'security' && (
Change password
Changing your password signs you out of every other device — this one stays signed in.
)}
{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.
{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.
{journalBusy === 'docx' ? 'Building…' : 'Download as Word (.docx)'}
{journalBusy === 'pdf' ? 'Building…' : 'Print / Save as PDF'}
{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.
)}
{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}
))}
setMfaBackupCodes(null)}
className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90"
>
I've saved these codes
) : authUser.totpEnabled ? (
) : mfaSetup ? (
) : (
{mfaSetupBusy ? 'Starting…' : 'Enable 2FA'}
{mfaSetupError &&
{mfaSetupError}
}
)}
)}
{tab === 'security' &&
}
{tab === 'security' &&
}
);
}