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:
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user