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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user