feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
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:
175
client/src/components/security/EmailAddressPanel.jsx
Normal file
175
client/src/components/security/EmailAddressPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user