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>
167 lines
6.3 KiB
JavaScript
167 lines
6.3 KiB
JavaScript
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>
|
|
)
|
|
}
|