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

+82
View File
@@ -0,0 +1,82 @@
// "Forgot password": ask for a code (it is sent to the account's notification topic), then choose a new
// password with it. Shown on the sign-in page in place of the sign-in form.
import { useState } from 'react';
import { requestPasswordReset, resetPassword } from '../syncService.js';
const input = 'mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]';
const primary = 'w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50';
export default function ResetPasswordForm({ initialEmail = '', onDone, onBack }) {
const [step, setStep] = useState('ask'); // 'ask' | 'code' | 'done'
const [email, setEmail] = useState(initialEmail);
const [form, setForm] = useState({ code: '', password: '', token: '' });
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const ask = async (e) => {
e.preventDefault();
setError(''); setBusy(true);
const result = await requestPasswordReset(email.trim());
setBusy(false);
if (!result.ok) { setError(result.error ?? 'Something went wrong.'); return; }
setMessage(result.data?.message ?? 'If that account has a notification topic, a code was sent.');
setStep('code');
};
const reset = async (e) => {
e.preventDefault();
setError(''); setBusy(true);
const result = await resetPassword({ email: email.trim(), code: form.code, newPassword: form.password, token: form.token.trim() || undefined });
setBusy(false);
if (!result.ok) { setError(result.error ?? 'Something went wrong.'); return; }
setStep('done');
};
if (step === 'done') {
return (
<div>
<h2 className="text-xl font-medium">Password changed</h2>
<p className="mt-2 text-sm text-[var(--r-sub)]">You’ve been signed out everywhere. Sign in with your new password.</p>
<button type="button" onClick={onDone} className={`mt-6 ${primary}`}>Back to sign in</button>
</div>
);
}
return (
<div>
<h2 className="text-xl font-medium">Reset your password</h2>
{step === 'ask' ? (
<form onSubmit={ask} className="mt-4 space-y-4">
<p className="text-sm text-[var(--r-sub)]">We’ll send a one-time code to the notification topic you set up for reading reminders (ntfy).</p>
<label className="block text-sm">Email
<input type="email" required autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} className={input} />
</label>
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
<button type="submit" disabled={busy} className={primary}>{busy ? 'Please wait…' : 'Send me a code'}</button>
<button type="button" onClick={() => { setStep('code'); setMessage(''); }} className="block w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">I already have a code</button>
</form>
) : (
<form onSubmit={reset} className="mt-4 space-y-4">
{message && <p role="status" className="text-sm text-[var(--r-sub)]">{message}</p>}
<label className="block text-sm">Email
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} className={input} />
</label>
<label className="block text-sm">Code
<input type="text" required autoFocus autoComplete="one-time-code" value={form.code} onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))} className={`${input} text-center font-mono tracking-widest`} placeholder="ABCD2345" />
</label>
<label className="block text-sm">New password
<input type="password" required minLength={8} autoComplete="new-password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} className={input} />
<span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>
</label>
<label className="block text-sm">Authenticator code <span className="text-xs text-[var(--r-sub)]">(only if you use two-factor sign-in)</span>
<input type="text" inputMode="numeric" value={form.token} onChange={(e) => setForm((f) => ({ ...f, token: e.target.value }))} className={input} placeholder="123456" />
</label>
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
<button type="submit" disabled={busy} className={primary}>{busy ? 'Please wait…' : 'Change password'}</button>
</form>
)}
<button type="button" onClick={onBack} className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Back to sign in</button>
</div>
);
}