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:
@@ -192,7 +192,7 @@ test('an upload report carries uploader, size and the SNIFFED type', async () =>
|
||||
patch(reportsDb, 'threadsByIds', async () => [])
|
||||
patch(reportsDb, 'postsByIds', async () => [])
|
||||
patch(reportsDb, 'uploadsByIds', async () => [{
|
||||
id: 3, team_id: 1, post_id: 80, uploader_username: 'someone', filename: 'a1b2.png',
|
||||
id: 3, team_id: 1, post_id: 80, uploader_user_id: 12, uploader_username: 'someone', filename: 'a1b2.png',
|
||||
mimetype: 'image/png', byte_size: 184320, created_at: new Date(), deleted_at: null,
|
||||
}])
|
||||
|
||||
@@ -201,6 +201,7 @@ test('an upload report carries uploader, size and the SNIFFED type', async () =>
|
||||
// load-bearing rather than bookkeeping.
|
||||
assert.equal(item.target.kind, 'upload')
|
||||
assert.equal(item.target.uploader, 'someone')
|
||||
assert.equal(item.target.uploaderDeleted, false)
|
||||
assert.equal(item.target.byteSize, 184320)
|
||||
assert.equal(item.target.mimetype, 'image/png')
|
||||
assert.equal(item.target.url, '/uploads/a1b2.png')
|
||||
@@ -234,17 +235,64 @@ test('a report whose target is already gone still lists, with a null target', as
|
||||
assert.equal(item.target, null)
|
||||
})
|
||||
|
||||
test('a deleted reporter still shows as somebody, and is marked deleted', async () => {
|
||||
test('a deleted reporter with no snapshot still shows as somebody, and is marked deleted', async () => {
|
||||
patch(reportsDb, 'list', async () => [row({ reporter_user_id: null, reporter_username: null })])
|
||||
patch(reportsDb, 'threadsByIds', async () => [])
|
||||
patch(reportsDb, 'postsByIds', async () => [])
|
||||
patch(reportsDb, 'uploadsByIds', async () => [])
|
||||
|
||||
const [item] = await reports.queue({})
|
||||
assert.equal(item.reporter, '[deleted account]')
|
||||
const [item] = await reports.queue({ viewer: { role: 'moderator' } })
|
||||
assert.equal(item.reporter, '[deleted player]')
|
||||
assert.equal(item.reporterDeleted, true)
|
||||
})
|
||||
|
||||
// ACCOUNT_DELETION.md AD6: staff keep the stored name of a deleted account, for
|
||||
// moderation, and the flag says which it is. Every snapshot in a report row goes
|
||||
// through the one rule — reporter, handler, and the target's author.
|
||||
test('staff see a deleted account’s stored names in the queue, each marked deleted', async () => {
|
||||
patch(reportsDb, 'list', async () => [row({
|
||||
reporter_user_id: null, reporter_username: 'Ravenna',
|
||||
status: 'actioned', handled_by: null, handled_username: 'OldMod', handled_at: new Date(),
|
||||
})])
|
||||
patch(reportsDb, 'threadsByIds', async () => [])
|
||||
patch(reportsDb, 'uploadsByIds', async () => [])
|
||||
patch(reportsDb, 'postsByIds', async () => [{
|
||||
id: 80, thread_id: 5, author_user_id: null, author_username: 'Thorne', status: 'hidden',
|
||||
body_html: '<p>x</p>', created_at: new Date(), team_id: 1, thread_title: 'Raid night',
|
||||
}])
|
||||
|
||||
for (const role of ['admin', 'editor', 'moderator']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const [item] = await reports.queue({ viewer: { role } })
|
||||
assert.equal(item.reporter, 'Ravenna', role)
|
||||
assert.equal(item.reporterDeleted, true)
|
||||
assert.equal(item.handledBy, 'OldMod')
|
||||
assert.equal(item.handledByDeleted, true)
|
||||
assert.equal(item.target.author, 'Thorne')
|
||||
assert.equal(item.target.authorDeleted, true)
|
||||
}
|
||||
|
||||
// Nothing but a staff viewer sees those names, so a caller that forgot to say
|
||||
// who is reading cannot leak them.
|
||||
const [blind] = await reports.queue({})
|
||||
assert.equal(blind.reporter, '[deleted player]')
|
||||
assert.equal(blind.handledBy, '[deleted player]')
|
||||
assert.equal(blind.target.author, '[deleted player]')
|
||||
})
|
||||
|
||||
test('an open report has no handler, which is not a deleted handler', async () => {
|
||||
patch(reportsDb, 'list', async () => [row()])
|
||||
patch(reportsDb, 'threadsByIds', async () => [])
|
||||
patch(reportsDb, 'postsByIds', async () => [])
|
||||
patch(reportsDb, 'uploadsByIds', async () => [])
|
||||
|
||||
const [item] = await reports.queue({ viewer: { role: 'admin' } })
|
||||
assert.equal(item.handledBy, null)
|
||||
assert.equal(item.handledByDeleted, false)
|
||||
assert.equal(item.reporter, 'wanderer')
|
||||
assert.equal(item.reporterDeleted, false)
|
||||
})
|
||||
|
||||
// ── handling ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('handling records who decided, when, and why', async () => {
|
||||
|
||||
Reference in New Issue
Block a user