Files
website/client/src/routes/player/ForgotPassword.jsx
wtclaude 10aed49bb6
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m45s
PR Checks / server-tests (pull_request) Successful in 10m42s
PR Checks / bot-install (pull_request) Successful in 9m21s
feat(auth): self-service password reset (backend + web)
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
2026-07-19 03:57:13 -05:00

75 lines
3.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>, weve 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 well 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>
)
}