Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
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:
1 parent
6bb392933a
commit
f274bc9c8a
67 files changed
+4734
-1569
No files matched your search
+12
-1
@@ -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.”
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]">
|
||||
|
||||
Reference in new issue
Block a user