Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s

Sync and data safety
- Autosave flushes on leaving a study or the tab; storage-full errors are reported
- Unsynced studies, deletes and ink are queued and retried when back online
- Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect
- Server assigns study versions (no client-clock conflicts); "Combine both"
  merges a conflicted study chunk by chunk
- Reading-plan progress made offline is merged, not overwritten; plans, ink and
  marks are cleared from the device on sign-out

Security
- Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and
  account; constant-time login; TOTP replay protection; input validation;
  SESSION_SECRET required in production; admin user delete fixed
- Forgot password: one-time code sent to the account's ntfy topic

Features
- IQ Bible proxy (route allow-list, text-only caching per its terms): more
  commentaries, lexicons, study aids, daily verse and devotional, plan calendar
  export, narrations, podcast background
- Study templates, reading-plan catch-up, account export, daily database snapshots

Tests and tooling
- ESLint config; App tests split into src/test; tests for sync queues, library
  hook, merge, templates, IQ Bible UI and reset form

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 08:13:36 -04:00
1 parent 6bb392933a
commit f274bc9c8a
67 files changed
+4734 -1569

No files matched your search

+12 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
import ResetPasswordForm from '../components/ResetPasswordForm.jsx';
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
// What the app does, in four groups. Everything listed here exists in the app.
@@ -89,7 +90,7 @@ function ThemeSwitch() {
export default function AuthPage() {
const { signIn, readerTheme } = useApp();
const shell = { style: themeVars(readerTheme) };
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' | 'forgot'
const [authForm, setAuthForm] = useState({ email: '', password: '' });
const [authError, setAuthError] = useState('');
const [authBusy, setAuthBusy] = useState(false);
@@ -206,6 +207,10 @@ export default function AuthPage() {
<div className="lg:order-2">
<div className="lg:sticky lg:top-10">
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-7 shadow-sm sm:p-8">
{authMode === 'forgot' ? (
<ResetPasswordForm initialEmail={authForm.email} onDone={() => setAuthMode('login')} onBack={() => setAuthMode('login')} />
) : (
<>
<h2 className="text-xl font-medium">{authMode === 'login' ? 'Sign in' : 'Create an account'}</h2>
<p className="mt-1 text-sm text-[var(--r-sub)]">
{authMode === 'login' ? 'Pick up where you left off.' : 'Free to start. Your studies stay in your account.'}
@@ -244,6 +249,10 @@ export default function AuthPage() {
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
</button>
</form>
{authMode === 'login' && (
<button type="button" onClick={() => { setAuthMode('forgot'); setAuthError(''); }}
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Forgot your password?</button>
)}
<button
type="button"
onClick={() => {
@@ -254,6 +263,8 @@ export default function AuthPage() {
>
{authMode === 'login' ? 'Need an account? Sign up' : 'Already have an account? Sign in'}
</button>
</>
)}
</div>
<blockquote className="mt-6 hidden border-l-2 border-[var(--r-line)] pl-4 text-sm italic leading-relaxed text-[var(--r-sub)] lg:block">
“Make every effort to present yourself approved to God, an unashamed workman who accurately handles the word of truth.”
+4
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
import PageShell from '../components/study/PageShell.jsx';
import AudioPlayer from '../components/AudioPlayer.jsx';
import {
@@ -120,6 +122,7 @@ export default function HomePage() {
>
<main className="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
<section className="min-w-0">
<IqDailyCard />
{autoRestoredCount !== null && (
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
)}
@@ -268,6 +271,7 @@ export default function HomePage() {
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
<IqNarratorOptions />
</select>
</>
)}
+2 -3
View File
@@ -5,11 +5,11 @@ import { vi, test, expect } from 'vitest';
const noop = () => {};
const ctx = {
goHome: noop,
readerBookAbbrev: 'JHN', setReaderBookAbbrev: noop,
readerBookAbbrev: 'JHN', setReaderBookAbbrev: vi.fn(),
readerTranslation: 'ESV', setReaderTranslation: noop,
readerTheme: 'paper', setReaderTheme: noop, readerCommentary: 'matthew-henry', setReaderCommentary: noop,
readerLayout: 'flow', setReaderLayout: noop,
readerChapter: 3, setReaderChapter: noop,
readerChapter: 3, setReaderChapter: vi.fn(),
readerVerses: [{ number: 1, text: 'Now there was a man of the Pharisees.' }, { number: 2, text: 'This man came to Jesus by night.' }],
readerTotalChapters: 21, readerLoading: false, readerError: '',
readerInterlinear: null, readerSelectedVerse: null, setReaderSelectedVerse: vi.fn(),
@@ -28,7 +28,6 @@ const ctx = {
readerInkByPage: {}, updateReaderPageInk: noop, readerTextHighlights: [], addTextHighlight: noop, removeTextHighlight: noop,
planProgress: [], toggleDay: vi.fn(), toggleChapterRead: vi.fn(), readingActivity: [],
readerPlan: null, setReaderPlan: vi.fn(), openPlanReading: vi.fn(), studyReadingPlanDay: vi.fn(), studyBuildBusy: false,
setReaderBookAbbrev: vi.fn(), setReaderChapter: vi.fn(),
};
vi.mock('../context/AppContext.js', () => ({ useApp: () => ctx }));
+8 -2
View File
@@ -1,6 +1,7 @@
// Reading plans home, styled to match the reader (paper / sepia / dark). One big
// "Continue reading" card, your plans in progress, and a browsable catalog. Details,
// the custom-plan builder and everything else open as sheets.
import IqPlanExport from '../components/iq/IqPlanExport.jsx';
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
@@ -141,13 +142,17 @@ export default function ReadingPlansPage() {
<span className={pill}>{p.translation ?? readerTranslation}</span>
</div>
<p className="mb-2 mt-0.5 text-xs text-[var(--r-sub)]">
Day {p.completedDays.length} of {plan.days} done{next ? ` · next: ${formatDayReadings(next.refs)}` : ''}{behind >= 2 ? ` · ${behind} days behind` : ''}
Day {p.completedDays.length} of {plan.days} done{next ? ` · next: ${formatDayReadings(next.refs)}` : ''}{behind >= 1 ? ` · ${behind} ${behind === 1 ? 'day' : 'days'} behind` : ''}
</p>
<ProgressBar pct={pct} />
</button>
{next && (
<div className="mt-3 flex items-center justify-between">
<div className="mt-3 flex items-center justify-between gap-2">
<span className="text-xs text-[var(--r-sub)]">{pct}%</span>
{behind >= 1 && (
<button type="button" onClick={() => setDetail({ planId: p.planId, progressId: p.id })}
className="ml-auto rounded-full px-3 py-1.5 text-xs font-medium text-[var(--r-accent)] hover:underline">Catch up</button>
)}
<button type="button" onClick={() => openPlanReading(p.id, next.dayIndex)}
className="rounded-full border border-[var(--r-line)] px-4 py-1.5 text-xs font-medium hover:bg-black/5">Continue</button>
</div>
@@ -214,6 +219,7 @@ export default function ReadingPlansPage() {
</div>
</>
)}
<div className="mt-8"><IqPlanExport mcheyne /></div>
</main>
{detailPlan && (
+26 -1
View File
@@ -2,6 +2,8 @@
// write the final script beside your study notes. Claude prompts and the
// pronunciation guide live here because this is where they're needed.
import { useState } from 'react';
import { useIqAvailable } from '../hooks/useIq.js';
import { chunkRefs, gatherIqContext } from '../utils/iqbible.js';
import { useApp } from '../context/AppContext.js';
import { useToast } from '../components/Toasts.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
@@ -16,6 +18,8 @@ const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:
export default function ScriptPage() {
const { project, allChunks, updateProject, updateChunk, authUser, setSaveStatus, setCurrentPage } = useApp();
const [notice, setNotice] = useState('');
const iq = useIqAvailable();
const [gathering, setGathering] = useState(false);
const { toast } = useToast();
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
@@ -53,7 +57,22 @@ export default function ScriptPage() {
setSaveStatus('Copied! Opening Claude…');
window.setTimeout(() => setSaveStatus(''), 3000);
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
});
}).catch(() => toast("Couldn't copy to the clipboard. Click the button again.", { tone: 'error' }));
};
// The podcast prompt plus IQ Bible's background on each passage (commentary, cross-references, word glosses).
const copyPodcastWithContext = async () => {
setGathering(true);
try {
const { text, count, skipped } = await gatherIqContext(project);
const base = buildPodcastPrompt(project, authUser?.podcastName);
const prompt = text ? `${base}\n\n---\nBACKGROUND REFERENCE (from IQ Bible; use it to inform the script, don't quote it at length):\n\n${text}\n` : base;
if (!text) toast('IQ Bible had no background to add; copied the standard prompt', { tone: 'error' });
else if (skipped > 0) toast(`Added background for ${count} passages (the first ${count} of ${count + skipped})`);
copyPrompt(prompt);
} finally {
setGathering(false);
}
};
const noteBlock = (label, text) => has(text) && (
@@ -112,6 +131,12 @@ export default function ScriptPage() {
title="Copies your study notes as a Claude prompt, then opens claude.ai">Study guide → Claude ↗</button>
<button type="button" onClick={() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName))} className={btn}
title="Copies a podcast script prompt for Claude — set your show name in Account Settings first">Podcast → Claude ↗</button>
{iq && (
<button type="button" disabled={gathering} onClick={copyPodcastWithContext} className={btn}
title={`Adds IQ Bible commentary, cross-references and word glosses for up to ${chunkRefs(project).refs.length} passages (each uses a few API requests)`}>
{gathering ? 'Gathering…' : 'Podcast + background → Claude ↗'}
</button>
)}
<button type="button" onClick={() => setCurrentPage('pronunciation')} className={btn}>Pronunciation guide</button>
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
</div>
+3 -1
View File
@@ -1,3 +1,4 @@
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
@@ -261,11 +262,12 @@ export default function SettingsPage() {
<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.">
<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}>
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
<IqNarratorOptions />
</select>
</PrefRow>
</section>
+9
View File
@@ -1,6 +1,7 @@
// The Outline stage: choose a chapter, then cut it into chunks by clicking between
// verses (or let the section headings suggest them). Each chunk becomes one unit of
// study in the next stage.
import { STUDY_TEMPLATES, getTemplate } from '../utils/studyTemplates.js';
import { useApp } from '../context/AppContext.js';
import { useChunkBuilder } from '../hooks/useChunkBuilder.js';
import StudioShell from '../components/study/StudioShell.jsx';
@@ -30,6 +31,14 @@ function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, wi
<input type="text" value={setup.title} onChange={(e) => onTitleChange(e.target.value)} className={inputCls} />
</label>
)}
{withTitle && (
<label className="block text-sm sm:col-span-2">Start from
<select value={setup.template ?? 'blank'} onChange={(e) => onField('template', e.target.value)} className={inputCls}>
{STUDY_TEMPLATES.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<span className="mt-1 block text-xs text-[var(--r-sub)]">{getTemplate(setup.template).description}</span>
</label>
)}
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-[var(--r-sub)]">{error ? <span className="text-[var(--r-danger,#b42318)]">{error}</span> : 'Text: Berean Standard Bible (BSB).'}</p>
+1 -1
View File
@@ -132,7 +132,7 @@ export default function StudyPage() {
<section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]">
<h2 className="shrink-0 border-b border-[var(--r-line)] px-4 py-2.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</h2>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} />
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateId} />
</div>
</section>
<section aria-label="Resources" className="flex min-h-[24rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[10]">