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:
22
server/src/model/emailDedupe/emailDedupe.db.js
Normal file
22
server/src/model/emailDedupe/emailDedupe.db.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS = 'id, user_id, username, lost_address, cleared_at, acknowledged_at'
|
||||
|
||||
// Accounts cleared by the Phase 1b de-duplication, newest first.
|
||||
async function list() {
|
||||
return query(`SELECT ${COLS} FROM email_dedupe_report ORDER BY cleared_at DESC, id DESC`)
|
||||
}
|
||||
|
||||
async function countUnacknowledged() {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM email_dedupe_report WHERE acknowledged_at IS NULL')
|
||||
return Number(rows[0] ? rows[0].n : 0)
|
||||
}
|
||||
|
||||
// Dismiss the whole report. Idempotent — an already-acknowledged row is skipped
|
||||
// so a second dismissal cannot rewrite when it happened.
|
||||
async function acknowledgeAll() {
|
||||
const res = await query('UPDATE email_dedupe_report SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL')
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
module.exports = { list, countUnacknowledged, acknowledgeAll }
|
||||
Reference in New Issue
Block a user