feat(account): self-service account deletion, MODULE_API 1.20.0
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

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:
2026-10-29 18:32:19 -05:00
parent 222bcb02fc
commit d1ae8f943f
50 changed files with 2103 additions and 69 deletions

View File

@@ -21,6 +21,7 @@
const forumDb = require('./teamForum.db')
const forumSettings = require('./teamForumSettings.model')
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
const { displayAuthor } = require('../../utils/displayAuthor')
// Announcements are leader-authored and take no replies; discussion threads are
// member-authored and do. Both have been in the enum since 5a — what phase 5
@@ -42,8 +43,6 @@ const CREATABLE_TYPES_5A = ['announcement']
// look like it would open replies on an announcement.
const REPLYABLE_TYPES = ['discussion']
const DELETED_AUTHOR = '[deleted account]'
/**
* Moderation actions, and what each one does to the row.
*
@@ -74,13 +73,21 @@ const POST_ACTIONS = {
restore: { status: 'visible' },
}
function publicThread(row) {
/**
* One thread's list shape, for one viewer.
*
* `author` follows displayAuthor's rule: a deleted account's snapshot reads
* "[deleted player]" to a member and its stored name to staff, and
* `authorDeleted` says which case it is so the staff view can mark it.
*/
function publicThread(row, viewer = null) {
const author = displayAuthor({ userId: row.created_by, username: row.created_username }, viewer)
return {
id: row.id,
type: row.type,
title: row.title,
author: row.created_username || DELETED_AUTHOR,
authorDeleted: row.created_by == null,
author: author.name,
authorDeleted: author.deleted,
createdAt: row.created_at,
lastPostAt: row.last_post_at,
postCount: row.post_count,
@@ -129,10 +136,11 @@ function editability(row, { userId = null, isStaff = false, windowMinutes = 0, n
* said who is reading.
*/
function renderPost(row, mode, viewer) {
const author = displayAuthor({ userId: row.author_user_id, username: row.author_username }, viewer)
return {
id: row.id,
author: row.author_username || DELETED_AUTHOR,
authorDeleted: row.author_user_id == null,
author: author.name,
authorDeleted: author.deleted,
body: renderForumBody(row.body_html, mode),
createdAt: row.created_at,
editedAt: row.edited_at,
@@ -150,9 +158,9 @@ function renderPost(row, mode, viewer) {
* same call answers both audiences without a second endpoint that could disagree
* with this one.
*/
async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0 } = {}) {
async function listThreads(teamId, { canModerate = false, viewer = null, limit = 50, offset = 0 } = {}) {
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
return rows.map(publicThread)
return rows.map((row) => publicThread(row, viewer))
}
/**
@@ -176,7 +184,7 @@ async function getThread(teamId, threadId, { canModerate = false, viewer } = {})
const mode = await forumSettings.imageMode()
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
return {
...publicThread(thread),
...publicThread(thread, viewer),
// A reply control is offered when the TYPE takes replies and the thread is
// open. Both halves are reported separately (`type`, `locked`) so the UI can
// say which one is why, but the decision itself is made here — a client that