Files
website/client/src/lib/accountDeletion.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

70 lines
3.3 KiB
JavaScript

// What the Account page's "Delete my account" section says and decides
// (docs/website/ACCOUNT_DELETION.md §4.1, §4.2). Plain JS with tests, so the
// words and the confirm rule can be checked without a browser.
//
// The server is the guard: it checks the username, the password and the code
// again whatever this file decides. What is here only keeps a mis-tap from
// reaching it and turns its refusal codes into sentences.
/** What goes with the account. The same list as BACKEND_DESIGN.md's. */
export const WHAT_GOES = [
'your sign-in, password, email address, two-factor settings and recovery codes',
'every signed-in device, trusted device and push registration',
'your linked sign-ins, such as Google, Discord or Steam',
'your game-account links, and anything this site granted in the game through them',
'your notification settings and inbox',
]
/** What stays, and who sees what of it. */
export const WHAT_STAYS = [
'What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”.',
'Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses.',
'Event results stay with the score and the in-game name, with no site account behind them.',
'A game account stays on the game server. Only its link to this site goes.',
]
/** Said only to staff: their name is never free again (AD9). */
export const STAFF_NAME_NOTE =
'You are staff, so your username can never be used again, by anyone, once this account is deleted.'
/** True for every role but player. */
export const isStaffRole = (role) => Boolean(role) && role !== 'player'
/** The confirm button is live once the typed name matches, ignoring case. */
export function usernameMatches(typed, username) {
return typeof typed === 'string'
&& typeof username === 'string'
&& typed.trim().length > 0
&& typed.trim().toLowerCase() === username.toLowerCase()
}
/**
* True when the signed-in account is one of the demo's shared logins, which the
* server refuses to delete. The section still shows (Play's reviewer must find
* it); only its button is off.
*/
export function isSharedDemoLogin(settings, username) {
const logins = settings?.demo?.logins
if (!Array.isArray(logins) || typeof username !== 'string') return false
const name = username.toLowerCase()
return logins.some((l) => typeof l?.username === 'string' && l.username.toLowerCase() === name)
}
const BY_CODE = {
username_mismatch: 'Type your username exactly as it is shown.',
wrong_password: 'Your password is incorrect.',
code_required: 'Enter a code from your authenticator app, or a recovery code.',
wrong_code: 'That code is not valid. Try again.',
password_required: 'This account has no password yet. Set one in the Password section above, then delete the account.',
last_admin: 'You are the only admin. Make someone else an admin before you delete your account.',
DEMO_LOCKED: 'This is turned off on the demo site.',
}
/** A refusal from DELETE /auth/me/account, as one sentence. */
export function refusalMessage(err) {
const code = err?.body?.code
if (code && BY_CODE[code]) return BY_CODE[code]
if (err?.status === 429) return 'Too many attempts. Wait a few minutes and try again.'
return err?.message || 'Could not delete your account. Try again.'
}