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:
@@ -499,6 +499,19 @@ const doc = {
|
||||
username: { type: 'string', example: 'newplayer' },
|
||||
role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'player' },
|
||||
email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' },
|
||||
email_verified: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the address above has been proved by opening a confirmation link.',
|
||||
example: true,
|
||||
},
|
||||
email_pending: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
nullable: true,
|
||||
description:
|
||||
'An address the user has requested but not yet confirmed. It does NOT replace `email` until the confirmation link is used.',
|
||||
example: null,
|
||||
},
|
||||
status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
|
||||
totp_enabled: { type: 'boolean', example: false },
|
||||
has_password: {
|
||||
@@ -508,6 +521,53 @@ const doc = {
|
||||
},
|
||||
},
|
||||
},
|
||||
// Engagement Phase 1b — the change-and-verify flow.
|
||||
ChangeEmailRequest: {
|
||||
type: 'object',
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email', maxLength: 255, example: 'new@example.com' },
|
||||
currentPassword: {
|
||||
type: 'string',
|
||||
format: 'password',
|
||||
description:
|
||||
'Required when the account has a password. An address is where account recovery lands, so changing it is re-authenticated; an SSO-provisioned account with no password is exempt.',
|
||||
},
|
||||
},
|
||||
},
|
||||
PendingEmail: {
|
||||
type: 'object',
|
||||
description:
|
||||
'The address now awaiting confirmation. The account keeps its existing address until the emailed link is used.',
|
||||
properties: {
|
||||
email_pending: { type: 'string', format: 'email', example: 'new@example.com' },
|
||||
emailed: {
|
||||
type: 'boolean',
|
||||
description: 'False when outbound email is not configured or the send failed; the address stays staged so a resend can succeed later.',
|
||||
example: true,
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
enum: ['NOT_CONFIGURED', 'SEND_FAILED', null],
|
||||
description: 'Why nothing was sent, when `emailed` is false.',
|
||||
example: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
EmailDedupeEntry: {
|
||||
type: 'object',
|
||||
description:
|
||||
'One account whose email address was cleared when addresses became unique, because an older account already held it.',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 3 },
|
||||
user_id: { type: 'integer', example: 42 },
|
||||
username: { type: 'string', example: 'someplayer' },
|
||||
lost_address: { type: 'string', format: 'email', example: 'shared@example.com' },
|
||||
cleared_at: { type: 'string', format: 'date-time' },
|
||||
acknowledged_at: { type: 'string', format: 'date-time', nullable: true },
|
||||
},
|
||||
},
|
||||
OkFlag: {
|
||||
type: 'object',
|
||||
properties: { ok: { type: 'boolean', example: true } },
|
||||
|
||||
Reference in New Issue
Block a user