Files
website/server/src/model/users/users.db.js
wtclaude d1ae8f943f
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 6m14s
feat(account): self-service account deletion, MODULE_API 1.20.0
ACCOUNT_DELETION.md phase 2 (core): a member deletes their own account on
the website, and the admin route runs the same procedure.

- DELETE /api/v1/auth/me/account: typed username, password always (AD7),
  and a 2FA or recovery code when 2FA is on. A wrong password or code is
  400, not 401 (AD12), so the app's refresh-on-401 never replays it.
  409 password_required and last_admin; the demo's shared logins are
  locked.
- auth/accountDeletion.js: module hooks first, then one transaction that
  reserves a staff username (reserved_usernames, AD9) and deletes the row,
  then a Teams reconcile, the audit entry (no username) and a notice to
  the old address (new protected template auth.account-deleted).
- MODULE_API 1.20.0: api.onUserDeleting({ userId, deletedBy }), before the
  delete, 10 s limit, cannot veto (AD10, AD11).
- utils/displayAuthor.js: a deleted account's username snapshots read
  "[deleted player]" to members and the stored name, marked, to staff
  (AD5, AD6). Used by the forum, the Reports queue, the guest list and
  the Team digest. A deleted account drops off Forum guests (AD13).
- Reserved names refuse at every username write, answered as "taken".
- Web: Delete my account on the player and staff Account pages; staff
  views mark "(deleted account)".
- OpenAPI regenerated; route manifest and engagement manifest regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-29 18:32:19 -05:00

187 lines
6.7 KiB
JavaScript

const { query, transaction } = require('../../utils/db')
const PUBLIC_COLS =
'id, username, role, status, email, email_verified, email_pending, totp_enabled, created_at, last_login_at'
// passwordHash may be null (SSO-provisioned players who have not set one yet).
// email/status/emailVerified are optional so existing admin-create callers are
// unaffected.
async function insertUser({
username,
passwordHash = null,
role = 'admin',
email = null,
status = 'active',
emailVerified = false,
}) {
const res = await query(
'INSERT INTO users (username, password_hash, role, email, status, email_verified) VALUES (?, ?, ?, ?, ?, ?)',
[username, passwordHash, role, email, status, emailVerified ? 1 : 0],
)
return res.insertId
}
async function findByUsername(username) {
const rows = await query('SELECT * FROM users WHERE username = ? LIMIT 1', [username])
return rows[0] || null
}
async function findById(id) {
const rows = await query('SELECT * FROM users WHERE id = ? LIMIT 1', [id])
return rows[0] || null
}
// The ACTIVE account on an email address, as a list. Unique since engagement
// Phase 1b, so this returns at most one row — the array shape is kept because the
// password-reset caller iterates and there is nothing to gain from making it care.
// accounts; the caller issues one reset link per row. Case-insensitive to match
// however the address was stored. Excludes disabled/banned accounts.
// Active accounts on an address. Matches on email_norm, the same generated column
// the UNIQUE index uses, so a lookup folds case exactly the way uniqueness does —
// LOWER() here and LOWER() there can never drift apart. Since Phase 1b this
// returns at most one row; it still returns an array because the password-reset
// caller iterates and there is no value in making that caller care.
async function findActiveByEmail(email) {
return query(
"SELECT * FROM users WHERE email_norm = LOWER(?) AND status = 'active'",
[email],
)
}
// Stage an address the user has asked for but not yet proved. Does not touch
// `email`, so their current address keeps receiving mail until the link is used.
async function setPendingEmail(id, email) {
const res = await query('UPDATE users SET email_pending = ? WHERE id = ?', [email, id])
return res.affectedRows || 0
}
// Promote a proved address into place. Guarded on email_pending still matching, so
// a stale link (the user asked again for a different address) cannot install the
// address it was minted for. Returns rows changed — 0 means the guard rejected it.
async function promotePendingEmail(id, email) {
const res = await query(
`UPDATE users SET email = ?, email_verified = 1, email_pending = NULL
WHERE id = ? AND email_pending = ?`,
[email, id, email],
)
return res.affectedRows || 0
}
async function clearPendingEmail(id) {
const res = await query('UPDATE users SET email_pending = NULL WHERE id = ?', [id])
return res.affectedRows || 0
}
async function listUsers() {
return query(`SELECT ${PUBLIC_COLS} FROM users ORDER BY id ASC`)
}
async function updateUser(id, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(id)
await query(`UPDATE users SET ${cols.join(', ')} WHERE id = ?`, params)
}
async function deleteUser(id) {
return query('DELETE FROM users WHERE id = ?', [id])
}
// Delete the account and, when `reserveName` is given, reserve it, as ONE
// transaction (ACCOUNT_DELETION.md §4.1 step 2): a staff name is never free for
// a moment between the two. `INSERT IGNORE` because a name already reserved
// (a second staff account renamed onto it cannot exist, but a hand-inserted row
// can) is the state wanted, not an error. Returns the rows deleted, 0 or 1.
async function deleteUserReserving(id, reserveName = null) {
return transaction(async (conn) => {
if (reserveName) {
await conn.query(
"INSERT IGNORE INTO reserved_usernames (username_norm, reason) VALUES (LOWER(?), 'deleted_staff')",
[reserveName],
)
}
const result = await conn.query('DELETE FROM users WHERE id = ?', [id])
return Number(result.affectedRows || 0)
})
}
// True when the name can never be taken again (AD9). The column's collation is
// users.username's, so this folds case exactly as the UNIQUE index does.
async function isUsernameReserved(username) {
const rows = await query('SELECT 1 FROM reserved_usernames WHERE username_norm = LOWER(?) LIMIT 1', [username])
return rows.length > 0
}
async function countUsers() {
const rows = await query('SELECT COUNT(*) AS c FROM users')
return Number(rows[0].c)
}
async function countAdmins() {
const rows = await query("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'")
return Number(rows[0].c)
}
async function touchLastLogin(id, ip = null) {
return query('UPDATE users SET last_login_at = NOW(), last_login_ip = ? WHERE id = ?', [ip, id])
}
// Move the "tokens valid after" cutoff to now, invalidating every session token
// issued before this instant (password change / log out everywhere). requireAuth
// compares each session's issued-at against this column.
async function bumpTokensValidAfter(id) {
return query('UPDATE users SET tokens_valid_after = NOW() WHERE id = ?', [id])
}
// Set the cutoff to an explicit instant. Used when re-issuing the caller's own
// session right after a password change: the bump above revokes everything at
// NOW(), and requireAuth's cutoff test is inclusive (createdAt <= cutoff), so a
// freshly-minted token sharing that same wall-clock second would be revoked too.
// Rewinding the cutoff a hair below the new token's issued-at lets it survive
// while still revoking every older session.
async function setTokensValidAfter(id, when) {
return query('UPDATE users SET tokens_valid_after = ? WHERE id = ?', [when, id])
}
// Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step
// so a secret is never trusted until the user has confirmed one code.
async function setTotpSecret(id, secret) {
return query('UPDATE users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?', [secret, id])
}
async function enableTotp(id) {
return query('UPDATE users SET totp_enabled = 1 WHERE id = ?', [id])
}
async function disableTotp(id) {
return query('UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?', [id])
}
module.exports = {
insertUser,
findByUsername,
findById,
findActiveByEmail,
setPendingEmail,
promotePendingEmail,
clearPendingEmail,
listUsers,
updateUser,
deleteUser,
deleteUserReserving,
isUsernameReserved,
countUsers,
countAdmins,
touchLastLogin,
bumpTokensValidAfter,
setTokensValidAfter,
setTotpSecret,
enableTotp,
disableTotp,
}