diff --git a/client/src/App.jsx b/client/src/App.jsx
index ac320d1..d239798 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -55,6 +55,8 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx'
// Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx'
import PlayerRegister from './routes/player/PlayerRegister.jsx'
+import ForgotPassword from './routes/player/ForgotPassword.jsx'
+import ResetPassword from './routes/player/ResetPassword.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
@@ -166,6 +168,8 @@ export default function App() {
{/* Player portal */}
} />
} />
+ } />
+ } />
} />
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
+ // Self-service password reset (public, token-gated). forgot always resolves the
+ // same way whether or not the email exists (no enumeration); getPasswordReset
+ // validates a link (200 → { username }, 404 → invalid/expired); resetPassword
+ // sets the new password and revokes all sessions (the user then signs in fresh).
+ forgotPassword: (email) => req('/auth/password/forgot', { method: 'POST', body: { email } }),
+ getPasswordReset: (token) => req(`/auth/password/reset/${encodeURIComponent(token)}`),
+ resetPassword: (token, password) =>
+ req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
// the callback, so only the code is sent). Returns { user, returnTo }.
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
diff --git a/client/src/routes/player/ForgotPassword.jsx b/client/src/routes/player/ForgotPassword.jsx
new file mode 100644
index 0000000..739a586
--- /dev/null
+++ b/client/src/routes/player/ForgotPassword.jsx
@@ -0,0 +1,74 @@
+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 (
+
+
+ If an account exists for {email.trim()}, we’ve sent a link to
+ reset its password. Check your inbox (and spam) — the link expires in about an hour.
+