Files
study-bible-project/src/pages/SettingsPage.jsx
T
nmemmertandClaude Sonnet 5.5 31ac6b9179 Make fewer IQ Bible calls
- Load the narrator list only when the audio dropdown is opened
- Keep today's verse and devotional in memory for an hour (not saved)
- Back off for a minute after 'allowance used up' or unreachable; keep 'nothing here' answers
- Server shares one upstream call between identical simultaneous requests (nothing stored)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:25:51 -04:00

645 lines
34 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2 border-t border-[var(--r-line)] py-3 first:border-t-0 first:pt-0">
<div className="min-w-0 flex-1 basis-56">
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-[var(--r-sub)]">{hint} <span className="whitespace-nowrap">· {synced ? 'Synced to your account' : 'This device only'}</span></p>
</div>
{children}
</div>
);
}
function Segmented({ label, options, value, onChange }) {
return (
<div role="group" aria-label={label} className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{options.map(([id, text]) => (
<button key={String(id)} type="button" aria-pressed={value === id} onClick={() => 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}</button>
))}
</div>
);
}
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 (
<PageShell title="Account Settings" onBack={goHome} maxWidth="max-w-2xl">
<main className="mx-auto max-w-2xl space-y-5 px-4 py-6">
<div role="tablist" aria-label="Settings sections" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
{SETTINGS_TABS.map(([id, label]) => (
<button key={id} type="button" role="tab" id={`settings-tab-${id}`} aria-selected={tab === id} aria-controls="settings-panel" onClick={() => 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}
</button>
))}
</div>
<div role="tabpanel" id="settings-panel" aria-labelledby={`settings-tab-${tab}`} className="space-y-5">
{tab === 'account' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-2">
<h2 className="text-base font-medium text-[var(--r-text)]">Account</h2>
<p className="text-sm text-[var(--r-sub)]">
Signed in as <span className="font-medium text-[var(--r-text)]">{authUser.email}</span>
</p>
</section>
)}
{tab === 'prefs' && (
<section aria-label="Preferences" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<h2 className="text-base font-medium">Preferences</h2>
<p className="mb-4 mt-1 text-sm text-[var(--r-sub)]">How the app looks and reads. Study projects always use the Berean Standard Bible.</p>
<PrefRow label="Home page" hint="Where the app opens, and where the Home buttons take you." synced>
<Segmented label="Home page" options={[['studies', 'My Studies'], ['reader', 'Bible Reader'], ['plans', 'Reading Plans']]} value={startPagePref} onChange={setStartPagePref} />
</PrefRow>
<PrefRow label="Theme" hint="Paper, sepia or dark." synced>
<Segmented label="Theme" options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={readerTheme} onChange={setReaderTheme} />
</PrefRow>
<PrefRow label="Default Bible" hint="What the Reader opens in." synced>
<select aria-label="Default Bible" value={readerTranslation} onChange={(e) => setReaderTranslation(e.target.value)} className={selectCls}>
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
</select>
</PrefRow>
<PrefRow label="New reading plans" hint="The Bible a plan starts in. You can change it per plan." synced>
<select aria-label="New reading plans" value={planTranslationPref} onChange={(e) => setPlanTranslationPref(e.target.value)} className={selectCls}>
<option value="same">Same as my default Bible</option>
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
</select>
</PrefRow>
<PrefRow label="Verse layout" hint="Verses run together like a page, or one to a line." synced>
<Segmented label="Verse layout" options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={readerLayout} onChange={setReaderLayout} />
</PrefRow>
<PrefRow label="Commentary" hint="The one shown first in the Reader and the Study stage." synced>
<select aria-label="Commentary" value={readerCommentary} onChange={(e) => setReaderCommentary(e.target.value)} className={selectCls}>
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</PrefRow>
<PrefRow label="Text size" hint="Reader text.">
<Segmented label="Text size" options={FONT_SIZES.map(([l, n]) => [n, l])} value={readerFontSize} onChange={setReaderFontSize} />
</PrefRow>
<PrefRow label="Reading width" hint="One column, or two on a wide screen.">
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} />
</PrefRow>
<PrefRow label="Audio narrator" hint="For the Berean Standard Bible audio. IQ Bible narrations count against its monthly audio allowance.">
<select aria-label="Audio narrator" value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} className={selectCls} {...narrators.selectProps}>
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
<IqNarratorOptions enabled={narrators.enabled} />
</select>
</PrefRow>
</section>
)}
{tab === 'security' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">Change password</h2>
<p className="text-sm text-[var(--r-sub)]">Changing your password signs you out of every other device — this one stays signed in.</p>
</div>
<form onSubmit={submitChangePassword} className="space-y-3">
<label className="block text-sm">Current password
<input
type="password"
required
autoComplete="current-password"
value={changePasswordForm.current}
onChange={(e) => setChangePasswordForm((f) => ({ ...f, current: e.target.value }))}
className="mt-1 block w-full max-w-xs 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)]"
/>
</label>
<label className="block text-sm font-medium text-[var(--r-text)]">
New password
<input
type="password"
required
minLength={8}
autoComplete="new-password"
value={changePasswordForm.next}
onChange={(e) => setChangePasswordForm((f) => ({ ...f, next: e.target.value }))}
className="mt-1 block w-full max-w-xs 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)]"
/>
</label>
<label className="block text-sm font-medium text-[var(--r-text)]">
Confirm new password
<input
type="password"
required
minLength={8}
autoComplete="new-password"
value={changePasswordForm.confirm}
onChange={(e) => setChangePasswordForm((f) => ({ ...f, confirm: e.target.value }))}
className="mt-1 block w-full max-w-xs 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)]"
/>
</label>
{changePasswordError && <p className="text-sm text-[var(--r-danger,#b42318)]">{changePasswordError}</p>}
{changePasswordSaved && <p className="text-sm text-[var(--r-accent)]">Password changed.</p>}
<button
type="submit"
disabled={changePasswordBusy}
className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{changePasswordBusy ? 'Saving…' : 'Change password'}
</button>
</form>
</section>
)}
{tab === 'account' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">Podcast / show name</h2>
<p className="text-sm text-[var(--r-sub)]">
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.
</p>
</div>
<form onSubmit={submitPodcastName} className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={podcastNameInput}
onChange={(e) => 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)]"
/>
<button
type="submit"
disabled={podcastNameSaving}
className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{podcastNameSaving ? 'Saving…' : 'Save'}
</button>
</form>
{podcastNameSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
</section>
)}
{tab === 'data' && (
<DataSection />
)}
{tab === 'prefs' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2>
<p className="text-sm text-[var(--r-sub)]">
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.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={downloadJournalDocx}
disabled={journalBusy !== ''}
className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{journalBusy === 'docx' ? 'Building…' : 'Download as Word (.docx)'}
</button>
<button
type="button"
onClick={printJournal}
disabled={journalBusy !== ''}
className="rounded-full border border-[var(--r-line)] px-5 py-2 text-sm font-medium transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50"
>
{journalBusy === 'pdf' ? 'Building…' : 'Print / Save as PDF'}
</button>
</div>
{journalError && <p className="text-sm text-[var(--r-danger,#b42318)]">{journalError}</p>}
</section>
)}
{tab === 'notify' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">Reading reminders</h2>
<p className="text-sm text-[var(--r-sub)]">
Get a daily nudge via{' '}
<a href="https://ntfy.sh" target="_blank" rel="noreferrer" className="text-[var(--r-accent)] underline">ntfy.sh</a>
{' '}— a free push-notification service. Install the ntfy app (or open{' '}
<span className="font-mono text-xs">{(normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic).value || 'https://ntfy.sh')}/{reminderForm.ntfyTopic || '<topic>'}</span>
{' '}in a browser) and subscribe to the topic below to receive it.
</p>
</div>
<form onSubmit={submitReminderSettings} className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
<input
type="checkbox"
checked={reminderForm.reminderEnabled}
onChange={(e) => setReminderForm((f) => ({ ...f, reminderEnabled: e.target.checked }))}
className="h-4 w-4 accent-[var(--r-accent)]"
/>
Enable daily reading reminder
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
Topic
<input
type="text"
placeholder="e.g. nate-bible-reading-8f2"
value={reminderForm.ntfyTopic}
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyTopic: e.target.value }))}
className="mt-1 block w-full 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)]"
/>
<span className="mt-1 block text-xs text-[var(--r-sub)]">Pick something unique — anyone who knows it can subscribe.</span>
</label>
<label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
Reminder time
<input
type="time"
value={reminderForm.reminderTime}
onChange={(e) => setReminderForm((f) => ({ ...f, reminderTime: e.target.value }))}
className="mt-1 block w-full 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)]"
/>
</label>
</div>
<div className="rounded-xl border border-[var(--r-line)] p-3">
<label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
<input type="checkbox" checked={reminderForm.prayerEnabled} onChange={(e) => setReminderForm((f) => ({ ...f, prayerEnabled: e.target.checked }))} className="h-4 w-4 accent-[var(--r-accent)]" />
Daily prayer reminder
</label>
<label className="mt-2 block max-w-xs text-sm font-medium text-[var(--r-text)]">
Remind me at
<input type="time" value={reminderForm.prayerTime} onChange={(e) => setReminderForm((f) => ({ ...f, prayerTime: e.target.value }))}
className="mt-1 block w-full 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)]" />
</label>
<span className="mt-1 block text-xs text-[var(--r-sub)]">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.</span>
</div>
{authUser?.votdAvailable && (
<div className="rounded-xl border border-[var(--r-line)] p-3">
<label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
<input type="checkbox" checked={reminderForm.votdEnabled} onChange={(e) => setReminderForm((f) => ({ ...f, votdEnabled: e.target.checked }))} className="h-4 w-4 accent-[var(--r-accent)]" />
Also send the verse of the day
</label>
<label className="mt-2 block max-w-xs text-sm font-medium text-[var(--r-text)]">
Send it at
<input type="time" value={reminderForm.votdTime} onChange={(e) => setReminderForm((f) => ({ ...f, votdTime: e.target.value }))}
className="mt-1 block w-full 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)]" />
</label>
<span className="mt-1 block text-xs text-[var(--r-sub)]">A short notification with today’s verse (KJV) to the same topic, even if the reading reminder is off.</span>
</div>
)}
<label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
ntfy server <span className="text-[var(--r-sub)]">(optional — defaults to ntfy.sh)</span>
<input
type="text"
placeholder="https://ntfy.sh"
value={reminderForm.ntfyUrl}
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyUrl: e.target.value }))}
className="mt-1 block w-full 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)]"
/>
{normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic).error && (
<span role="alert" className="mt-1 block text-xs text-[var(--r-danger,#b42318)]">
{normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic).error}{' '}
<button type="button" onClick={() => setReminderForm((f) => ({ ...f, ntfyUrl: '' }))} className="underline">Clear it</button>
</span>
)}
</label>
{reminderError && <p className="text-sm text-[var(--r-danger,#b42318)]">{reminderError}</p>}
{reminderSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
{reminderTest.message && (
<p role="status" className={`text-sm ${reminderTest.ok ? 'text-[var(--r-accent)]' : 'text-[var(--r-danger,#b42318)]'}`}>{reminderTest.message}</p>
)}
<div className="flex flex-wrap items-center gap-3">
<button
type="submit"
disabled={reminderBusy}
className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{reminderBusy ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={testReminder}
disabled={reminderTest.busy || !reminderForm.ntfyTopic.trim()}
className="rounded-full border border-[var(--r-line)] px-5 py-2 text-sm font-medium transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50"
>
{reminderTest.busy ? 'Sending…' : 'Send test notification'}
</button>
</div>
</form>
</section>
)}
{tab === 'security' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">Two-factor authentication</h2>
<p className="text-sm text-[var(--r-sub)]">
{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.'}
</p>
</div>
{mfaBackupCodes ? (
<div className="rounded-2xl border border-[var(--r-accent)] bg-[var(--r-bg)] p-4 space-y-3">
<p className="text-sm font-medium text-[var(--r-accent)]">
Save these backup codes now — each works once if you ever lose your device. They won't be shown again.
</p>
<div className="grid grid-cols-2 gap-2 font-mono text-sm text-[var(--r-text)]">
{mfaBackupCodes.map((code) => (
<div key={code} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-3 py-1.5">{code}</div>
))}
</div>
<button
type="button"
onClick={() => 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
</button>
</div>
) : authUser.totpEnabled ? (
<form onSubmit={submitDisable} className="space-y-3">
<label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
Enter your password to disable 2FA
<input
type="password"
required
value={mfaDisablePassword}
onChange={(e) => setMfaDisablePassword(e.target.value)}
className="mt-1 block w-full 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)]"
/>
</label>
{mfaDisableError && <p className="text-sm text-[var(--r-danger,#b42318)]">{mfaDisableError}</p>}
<button
type="submit"
className="rounded-full bg-red-700 px-5 py-2 text-sm font-medium text-white transition hover:opacity-90"
>
Disable 2FA
</button>
</form>
) : mfaSetup ? (
<form onSubmit={confirmSetup} className="space-y-4">
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
<img src={mfaSetup.qrCodeDataUrl} alt="2FA QR code" className="h-40 w-40 rounded-xl border border-[var(--r-line)]" />
<div className="space-y-1 text-sm text-[var(--r-sub)]">
<p>Scan this with your authenticator app, or enter the code manually:</p>
<p className="break-all rounded-lg bg-[var(--r-bg)] px-2 py-1 font-mono text-xs">{mfaSetup.secret}</p>
</div>
</div>
<label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
Enter the 6-digit code it shows
<input
type="text"
required
inputMode="numeric"
placeholder="123456"
value={mfaSetupCode}
onChange={(e) => setMfaSetupCode(e.target.value)}
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-center text-lg tracking-widest focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/>
</label>
{mfaSetupError && <p className="text-sm text-[var(--r-danger,#b42318)]">{mfaSetupError}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={mfaSetupBusy}
className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{mfaSetupBusy ? 'Verifying…' : 'Confirm & enable'}
</button>
<button
type="button"
onClick={cancelSetup}
className="rounded-full border border-[var(--r-line)] px-5 py-2 text-sm font-medium transition hover:bg-black/5"
>
Cancel
</button>
</div>
</form>
) : (
<div className="space-y-2">
<button
type="button"
onClick={startSetup}
disabled={mfaSetupBusy}
className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{mfaSetupBusy ? 'Starting…' : 'Enable 2FA'}
</button>
{mfaSetupError && <p className="text-sm text-[var(--r-danger,#b42318)]">{mfaSetupError}</p>}
</div>
)}
</section>
)}
{tab === 'security' && <PasskeysSection />}
{tab === 'security' && <DevicesSection />}
</div>
</main>
</PageShell>
);
}