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>
176 lines
6.1 KiB
JavaScript
176 lines
6.1 KiB
JavaScript
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>
|
|
)
|
|
}
|