feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 10m34s

Makes `users.email` unique, de-duplicates the addresses an upgrade will find,
and builds the self-service change-and-verify flow that did not exist.

The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED`
column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan
specified. Every case-insensitive collation this server offers is also
accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are
two different mailboxes. The plan's index would have refused the second address
forever and the de-duplication would have nulled a legitimate account's.

A requested address is STAGED in `email_pending` and only a tokened link
installs it, so a typo cannot silently redirect account-recovery mail.

`isDuplicateUsername()` now distinguishes the two indexes. All five call sites
branch on it; each answers differently on purpose, because a public form, an
IdP callback, a half-completed invite and an admin screen do not owe the same
person the same amount of truth.

SSO reads the IdP's actual `email_verified`/`verified` claim instead of
inferring verification from an address merely being present.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 01:53:50 -05:00
parent c2e4df5b3d
commit fbb4b0bd91
44 changed files with 3024 additions and 59 deletions

View File

@@ -54,6 +54,7 @@ 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 VerifyEmail from './routes/player/VerifyEmail.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
@@ -205,6 +206,9 @@ export default function App() {
<Route path="/account/register" element={<PlayerRegister />} />
<Route path="/account/forgot" element={<ForgotPassword />} />
<Route path="/account/reset/:token" element={<ResetPassword />} />
{/* Opened from a mailbox, so public like the reset page above — the
token is the proof, and confirming issues no session. */}
<Route path="/account/verify-email/:token" element={<VerifyEmail />} />
<Route path="/invite/:token" element={<AcceptInvite />} />
{/* PUBLIC, and grouped with the other tokened landings above rather
than with the portal below: the person following an unsubscribe

View File

@@ -116,6 +116,18 @@ export const api = {
req('/auth/me/account/username', { method: 'PATCH', body: { username } }),
changePassword: (newPassword, currentPassword) =>
req('/auth/me/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
// Email address (engagement Phase 1b). changeEmail STAGES the address — the
// account keeps its current one until the emailed link is opened — so the UI
// must show `email_pending` as pending, never as the address in force.
changeEmail: (email, currentPassword) =>
req('/auth/me/account/email', { method: 'PATCH', body: { email, currentPassword } }),
resendEmailVerification: () => req('/auth/me/account/email/resend', { method: 'POST' }),
cancelEmailChange: () => req('/auth/me/account/email/pending', { method: 'DELETE' }),
// The confirm half is public and token-gated — it is reached from a mailbox,
// often with no session, so it deliberately sits outside /auth/me.
lookupEmailVerification: (token) => req(`/auth/email/verify/${encodeURIComponent(token)}`),
confirmEmailVerification: (token) =>
req(`/auth/email/verify/${encodeURIComponent(token)}`, { method: 'POST' }),
totpSetup: () => req('/auth/me/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/auth/me/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
@@ -312,6 +324,12 @@ export const api = {
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// Accounts whose address was cleared when addresses became unique (Phase 1b).
// They can still sign in but can receive no mail until they set a new one, so
// they are the list an operator has to work through.
emailDedupeReport: () => req('/admin/users/email-dedupe-report'),
acknowledgeEmailDedupeReport: () =>
req('/admin/users/email-dedupe-report/acknowledge', { method: 'POST' }),
// A user's trusted devices + MFA reset (admin only).
userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
revokeUserTrustedDevice: (id, deviceId) =>

View File

@@ -0,0 +1,175 @@
import { useState } from 'react'
import { api } from '../../api/client.js'
// Self-service email address (engagement Phase 1b). Shared by the player portal
// and the admin account screen, the same way TrustedDevicesPanel and
// RecoveryCodesPanel are — /auth/me/account is one surface for every role, so its
// UI is one component too.
//
// The property this component exists to make visible: a requested address is
// STAGED, not applied. The account keeps receiving mail — password resets
// included — at the address it already has until the emailed link is opened. If
// the UI let a pending address look like the address in force, someone who
// mistyped would believe the change took and would only discover otherwise when
// they could not recover their account.
//
// `hasPassword` decides whether the current-password field appears: an address is
// where account recovery lands, so changing it is re-authenticated, with the same
// carve-out the password form makes for an SSO-only account.
export default function EmailAddressPanel({ account, reload, embedded = false }) {
const hasPassword = account.has_password !== false
const [email, setEmail] = useState('')
const [current, setCurrent] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
const pending = account.email_pending
async function save(e) {
e.preventDefault()
setMsg('')
setError('')
setBusy(true)
try {
const res = await api.changeEmail(email.trim(), hasPassword ? current : undefined)
setEmail('')
setCurrent('')
// Report an unsent mail honestly. Saying "check your inbox" about a message
// that was never sent turns a configuration problem into a user who waits.
if (res.emailed === false) {
setMsg(
res.reason === 'NOT_CONFIGURED'
? 'Address saved, but this site cannot send email right now. Ask an administrator, then use Resend.'
: 'Address saved, but the confirmation email could not be sent. Try Resend in a moment.',
)
} else {
setMsg(
`Confirmation sent to ${res.email_pending}. Your current address stays in use until you open that link.`,
)
}
await reload()
} catch (err) {
if (err.status === 429) setError('Too many confirmation emails. Try again later.')
else setError(err.message || 'Could not change your email address.')
} finally {
setBusy(false)
}
}
async function resend() {
setMsg('')
setError('')
setBusy(true)
try {
const res = await api.resendEmailVerification()
setMsg(
res.emailed === false
? 'Could not send the confirmation email.'
: `Confirmation re-sent to ${res.email_pending}.`,
)
} catch (err) {
setError(err.message || 'Could not resend the confirmation email.')
} finally {
setBusy(false)
}
}
async function discard() {
setMsg('')
setError('')
setBusy(true)
try {
await api.cancelEmailChange()
setMsg('Pending address discarded.')
await reload()
} catch (err) {
setError(err.message || 'Could not discard the pending address.')
} finally {
setBusy(false)
}
}
const wrap = embedded
? {}
: { marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }
return (
<div style={wrap}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Email address
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
{account.email ? (
<>
Currently <strong style={{ color: 'var(--head)' }}>{account.email}</strong>
{account.email_verified ? ' (confirmed)' : ' (not yet confirmed)'}. This is where password-reset
email is sent.
</>
) : (
'You have no email address on file, so you cannot reset your password by email.'
)}
</p>
{pending && (
<div
className="sans"
style={{
border: '1px solid var(--line-soft)',
borderRadius: 6,
padding: '10px 12px',
marginBottom: 16,
fontSize: '0.85rem',
color: 'var(--muted)',
}}
>
<strong style={{ color: 'var(--head)' }}>{pending}</strong> is waiting to be confirmed. It is not in
use until you open the link in that email.
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button type="button" onClick={resend} disabled={busy} className="btn btn-sq">
Resend
</button>
<button type="button" onClick={discard} disabled={busy} className="btn btn-sq">
Discard
</button>
</div>
</div>
)}
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
<label>
<span className="field-label">{pending ? 'Use a different address' : 'New email address'}</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
autoComplete="email"
/>
</label>
{hasPassword && (
<label>
<span className="field-label">Current password</span>
<input
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
className="input"
autoComplete="current-password"
/>
</label>
)}
<div>
<button type="submit" disabled={busy || !email.trim()} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Send confirmation'}
</button>
</div>
{(msg || error) && (
<p className="sans" style={{ margin: 0, fontSize: '0.85rem', color: error ? '#e08a8a' : 'var(--muted)' }}>
{error || msg}
</p>
)}
</form>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import ProviderIcon from '../../../components/ProviderIcon.jsx'
import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisplay.jsx'
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
import { api } from '../../../api/client.js'
// Link/unlink external SSO identities to this account. Linking redirects through
@@ -322,6 +323,10 @@ export default function AccountAdmin() {
</>
)}
{/* The self-service address, from the same component the player portal
renders — /auth/me/account is one surface for every role. */}
{account && <EmailAddressPanel account={account} reload={load} />}
<LinkedAccounts />
</section>
)

View File

@@ -4,6 +4,7 @@ import { Loading, ErrorState } from '../../components/PageState.jsx'
import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay.jsx'
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
@@ -423,6 +424,7 @@ export default function PlayerAccount() {
{account.email ? ` · ${account.email}` : ''}
</p>
<ChangeUsername account={account} onChanged={onUsernameChanged} />
<EmailAddressPanel account={account} reload={load} />
<ChangePassword account={account} />
<TwoFactor account={account} reload={load} />
{account.totp_enabled && (

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api } from '../../api/client.js'
import PlayerShell from './PlayerShell.jsx'
// Public, token-gated confirmation page (/account/verify-email/:token).
//
// Unauthenticated on purpose: the link arrives in a mailbox and is routinely
// opened on a device with no session. That is safe because the token IS the
// proof — opening it installs an address on the account it was minted for and
// does nothing else. No session is issued here, deliberately: proving control of
// a mailbox is not proving control of an account.
//
// Every failure the server can have — expired, already used, superseded by a
// later request, or an address another account confirmed first — comes back as
// the same 404. That is not laziness on the server's part; distinguishing them
// would let anyone test which addresses have accounts. So this page says the same
// thing for all of them, and must keep doing so.
export default function VerifyEmail() {
const { token } = useParams()
const [link, setLink] = useState(null) // { username, email } once validated
const [loadErr, setLoadErr] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
useEffect(() => {
let active = true
api
.lookupEmailVerification(token)
.then((r) => active && setLink(r || {}))
.catch(
(err) =>
active &&
setLoadErr(
err.status === 404
? 'This confirmation link is invalid or has expired.'
: 'Could not load this confirmation link.',
),
)
return () => {
active = false
}
}, [token])
async function onConfirm() {
setError('')
setBusy(true)
try {
await api.confirmEmailVerification(token)
setDone(true)
} catch (err) {
if (err.status === 404) setError('This confirmation link is no longer usable. Request a new one from your account page.')
else if (err.status === 429) setError('Too many attempts. Please try again in a little while.')
else setError('Could not confirm your address right now. Please try again later.')
setBusy(false)
}
}
// ── Invalid link ───────────────────────────────────────────────────────────
if (loadErr) {
return (
<PlayerShell subtitle="Confirm your email">
<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" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Go to your account
</Link>
</p>
</PlayerShell>
)
}
if (link === null) {
return (
<PlayerShell subtitle="Confirm your email">
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
<span className="spin" />
</div>
</PlayerShell>
)
}
// ── Done ───────────────────────────────────────────────────────────────────
if (done) {
return (
<PlayerShell subtitle="Email confirmed">
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
{link.email ? (
<>
<strong style={{ color: 'var(--head)' }}>{link.email}</strong> is now the address for
{link.username ? (
<>
{' '}
<strong style={{ color: 'var(--head)' }}>{link.username}</strong>
</>
) : (
' your account'
)}
.
</>
) : (
'Your email address has been confirmed.'
)}
</p>
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', fontSize: '0.85rem', color: 'var(--dim)' }}>
You have not been signed in confirming an address does not sign you in.
</p>
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Sign in
</Link>
</p>
</PlayerShell>
)
}
// ── Confirm ────────────────────────────────────────────────────────────────
//
// A button rather than confirming on load. A mail client or scanner that
// pre-fetches links would otherwise spend the token before the person ever saw
// it, and this token is single-use.
return (
<PlayerShell subtitle="Confirm your email">
<p
className="sans"
style={{ marginTop: 0, marginBottom: 20, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}
>
Confirm that{' '}
{link.email ? <strong style={{ color: 'var(--head)' }}>{link.email}</strong> : 'this address'} should be
the contact and account-recovery address for
{link.username ? (
<>
{' '}
<strong style={{ color: 'var(--head)' }}>{link.username}</strong>
</>
) : (
' this account'
)}
.
</p>
{error && (
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
{error}
</p>
)}
<button
type="button"
onClick={onConfirm}
disabled={busy}
className="btn btn-primary"
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
>
{busy ? 'Confirming…' : 'Confirm this address'}
</button>
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', fontSize: '0.82rem', color: 'var(--dim)' }}>
If you did not ask for this, close this page. Nothing changes and no account of yours is affected.
</p>
</PlayerShell>
)
}