Add a full password-reset flow — the prerequisite for the Android app (docs/android/PLAN.md §8.2), which hands off to the website for reset rather than shipping a native screen. Backend: - password_resets table: stores only the sha256 hash of an opaque 32-byte token (mirrors user_invites / mobile_refresh_tokens), single-use, ~1h TTL. - model/passwordResets + users.getActiveByEmail (email is non-unique, so a request can match several accounts, each emailed its own link). - mailer.sendPasswordReset (fails soft when email is unconfigured). - Endpoints: POST /auth/password/forgot (always a generic 200 — no account enumeration), GET|POST /auth/password/reset/:token. Confirming rotates the hash and revokes every session (web cutoff + mobile refresh tokens); it does not auto-login, so a 2FA account still passes TOTP next sign-in. Also serves SSO-only accounts (null hash) as their set-initial-password path. - Dedicated request/confirm rate limiters. Swagger regenerated. Web: - ForgotPassword + ResetPassword pages, routes /account/forgot and /account/reset/:token, and a "Forgot your password?" link on the login page. Tests: test/passwordResets.test.js (5). All server tests pass; client builds; end-to-end smoketest against MariaDB passes (no-enumeration, single-use, hash rotation, session revoke, login with the new password). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
75 lines
3.3 KiB
JavaScript
75 lines
3.3 KiB
JavaScript
import { useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import { api } from '../../api/client.js'
|
||
import PlayerShell from './PlayerShell.jsx'
|
||
|
||
// Public "forgot password" request page. Submitting emails a tokened reset link to
|
||
// every active account on the address (see ResetPassword for the other half). The
|
||
// server never reveals whether the email exists — it always answers the same way —
|
||
// so this page shows an identical confirmation regardless, to avoid enumeration.
|
||
export default function ForgotPassword() {
|
||
const [email, setEmail] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [sent, setSent] = useState(false)
|
||
|
||
async function onSubmit(e) {
|
||
e.preventDefault()
|
||
setError('')
|
||
if (!/.+@.+\..+/.test(email.trim())) return setError('Enter a valid email address.')
|
||
setBusy(true)
|
||
try {
|
||
await api.forgotPassword(email.trim())
|
||
setSent(true)
|
||
} catch (err) {
|
||
// Only a rate-limit (429) or a real outage surfaces here — a non-match still
|
||
// returns 200. Keep the message generic either way.
|
||
if (err.status === 429) setError('Too many requests. Please try again in a little while.')
|
||
else setError('Could not send the reset email right now. Please try again later.')
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
if (sent) {
|
||
return (
|
||
<PlayerShell subtitle="Reset your password">
|
||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', lineHeight: 1.6, textAlign: 'center' }}>
|
||
If an account exists for <strong style={{ color: 'var(--head)' }}>{email.trim()}</strong>, we’ve sent a link to
|
||
reset its password. Check your inbox (and spam) — the link expires in about an hour.
|
||
</p>
|
||
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
|
||
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Back to sign in</Link>
|
||
</p>
|
||
</PlayerShell>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<PlayerShell
|
||
subtitle="Reset your password"
|
||
footer={
|
||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||
Remembered it?{' '}
|
||
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Sign in</Link>
|
||
</p>
|
||
}
|
||
>
|
||
<p className="sans" style={{ marginTop: 0, marginBottom: 18, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||
Enter the email on your account and we’ll send you a link to choose a new password.
|
||
</p>
|
||
<form onSubmit={onSubmit}>
|
||
<label style={{ display: 'block', marginBottom: 22 }}>
|
||
<span className="field-label">Email</span>
|
||
<input type="email" autoComplete="email" autoFocus value={email} onChange={(e) => setEmail(e.target.value)} className="input" />
|
||
</label>
|
||
|
||
{error && <p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
|
||
|
||
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||
{busy ? 'Sending…' : 'Send reset link'}
|
||
</button>
|
||
</form>
|
||
</PlayerShell>
|
||
)
|
||
}
|