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>
83 lines
4.8 KiB
React
83 lines
4.8 KiB
React
// "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>
|
||
);
|
||
}
|