- 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>
645 lines
34 KiB
React
645 lines
34 KiB
React
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>
|
||
);
|
||
}
|