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:
@@ -7,6 +7,8 @@ const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const accountDeletion = require('../../../auth/accountDeletion')
|
||||
const { refuse: demoRefuse } = require('../../../demo/locks')
|
||||
const registries = require('../../../modules/registries')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
|
||||
@@ -1028,19 +1030,21 @@ async function updateUser(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// The same procedure a member's own deletion runs (auth/accountDeletion.js):
|
||||
// modules unlink their game links first, a staff name is reserved, and the
|
||||
// account's owner is mailed. The refusals keep this route's 400s.
|
||||
async function deleteUser(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
if (id === req.user.id) {
|
||||
return res.status(400).json({ message: 'You cannot delete your own account' })
|
||||
}
|
||||
const target = await users.getById(id)
|
||||
if (!target) return res.status(404).json({ message: 'Not found' })
|
||||
if (target.role === 'admin' && (await users.countAdmins()) <= 1) {
|
||||
return res.status(400).json({ message: 'Cannot delete the last admin' })
|
||||
const result = await accountDeletion.deleteAccount(id, { by: 'staff', req })
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'not_found') return res.status(404).json({ message: 'Not found' })
|
||||
if (result.reason === 'last_admin') return res.status(400).json({ message: 'Cannot delete the last admin' })
|
||||
if (result.reason === 'demo') return demoRefuse(res)
|
||||
}
|
||||
await users.remove(id)
|
||||
await activity.log({ req, action: 'user.delete', detail: { id } })
|
||||
return res.json({ id })
|
||||
} catch (err) {
|
||||
log.error('deleteUser', err)
|
||||
|
||||
Reference in New Issue
Block a user