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
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
const { query } = require('../../utils/db')
|
||||
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'
|
||||
@@ -92,6 +92,31 @@ 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)
|
||||
@@ -148,6 +173,8 @@ module.exports = {
|
||||
listUsers,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
deleteUserReserving,
|
||||
isUsernameReserved,
|
||||
countUsers,
|
||||
countAdmins,
|
||||
touchLastLogin,
|
||||
|
||||
Reference in New Issue
Block a user