Files
website/client/src/routes/player/ResetPassword.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

116 lines
5.5 KiB
JavaScript

import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../../api/client.js'
import PlayerShell from './PlayerShell.jsx'
// Public, token-gated reset page (/account/reset/:token). Validates the link, lets
// the user choose a new password, then sends them to sign in fresh. Setting the
// password revokes every existing session (web + mobile) server-side and does NOT
// log them in here — so a 2FA account still passes TOTP on the next sign-in.
export default function ResetPassword() {
const { token } = useParams()
const navigate = useNavigate()
const [username, setUsername] = useState(null) // whose account this link is for
const [loadErr, setLoadErr] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
useEffect(() => {
let active = true
api.getPasswordReset(token)
.then((r) => active && setUsername(r?.username || ''))
.catch((err) => active && setLoadErr(
err.status === 404 ? 'This reset link is invalid or has expired.' : 'Could not load this reset link.',
))
return () => { active = false }
}, [token])
async function onSubmit(e) {
e.preventDefault()
setError('')
if (password.length < 8) return setError('Password must be at least 8 characters.')
if (password !== confirm) return setError('The passwords do not match.')
setBusy(true)
try {
await api.resetPassword(token, password)
setDone(true)
} catch (err) {
if (err.status === 404) setError('This reset link is invalid or has already been used.')
else if (err.status === 429) setError('Too many attempts. Please try again in a little while.')
else if (err.status === 400) setError(err.message || 'Please check your password and try again.')
else setError('Could not reset your password right now. Please try again later.')
setBusy(false)
}
}
// ── Invalid link ───────────────────────────────────────────────────────────
if (loadErr) {
return (
<PlayerShell subtitle="Reset your password">
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>{loadErr}</p>
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
<Link to="/account/forgot" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Request a new link</Link>
</p>
</PlayerShell>
)
}
if (username === null) {
return (
<PlayerShell subtitle="Reset your password">
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}><span className="spin" /></div>
</PlayerShell>
)
}
// ── Done ───────────────────────────────────────────────────────────────────
if (done) {
return (
<PlayerShell subtitle="Password updated">
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
Your password has been reset. For your security, every existing session has been signed out.
</p>
<button
type="button"
onClick={() => navigate('/account/login', { replace: true })}
className="btn btn-primary"
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center', marginTop: 20 }}
>
Sign in
</button>
</PlayerShell>
)
}
// ── Reset form ─────────────────────────────────────────────────────────────
return (
<PlayerShell subtitle="Reset your password">
<p className="sans" style={{ marginTop: 0, marginBottom: 18, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Choose a new password{username ? <> for <strong style={{ color: 'var(--head)' }}>{username}</strong></> : null}.
</p>
<form onSubmit={onSubmit}>
{/* A hidden username field helps password managers associate the credential. */}
{username ? <input type="text" name="username" autoComplete="username" value={username} readOnly hidden /> : null}
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">New password</span>
<input type="password" autoComplete="new-password" autoFocus value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
</label>
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Confirm new password</span>
<input type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(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 ? 'Saving…' : 'Set new password'}
</button>
</form>
</PlayerShell>
)
}