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

@@ -117,6 +117,10 @@ export const api = {
// The change endpoints re-issue the session cookie server-side, so the caller
// stays signed in.
myAccount: () => req('/auth/me/account'),
// Delete the signed-in account (ACCOUNT_DELETION.md §4.1). The password always,
// and `code` or `recoveryCode` when two-factor is on; refusals carry a `code`.
deleteMyAccount: ({ username, password, code, recoveryCode }) =>
req('/auth/me/account', { method: 'DELETE', body: { username, password, code, recoveryCode } }),
changeUsername: (username) =>
req('/auth/me/account/username', { method: 'PATCH', body: { username } }),
changePassword: (newPassword, currentPassword) =>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { onDemoLocked } from '../lib/demoNotice.js'
import { onNotice } from '../lib/notice.js'
// The one toast for a write the public demo refused (DEMO_MODE.md §3.1). Fed
// by api/client.js through lib/demoNotice.js, so every screen — and every
@@ -11,6 +12,9 @@ export default function DemoToast() {
const [message, setMessage] = useState(null)
useEffect(() => onDemoLocked((text) => setMessage(text)), [])
// Also the site's one passing notice (lib/notice.js), on any deployment: the
// account page's "Your account has been deleted." after it has navigated home.
useEffect(() => onNotice((text) => setMessage(text)), [])
useEffect(() => {
if (!message) return undefined

View File

@@ -0,0 +1,178 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { api } from '../../api/client.js'
import { showNotice } from '../../lib/notice.js'
import {
WHAT_GOES, WHAT_STAYS, STAFF_NAME_NOTE, isStaffRole, usernameMatches, isSharedDemoLogin, refusalMessage,
} from '../../lib/accountDeletion.js'
// "Delete my account" (docs/website/ACCOUNT_DELETION.md §4.2). Last on the
// Account page and set apart from the sections above it.
//
// The account goes at once — there is no grace period (AD4) — so the safeguard
// is the confirm step: the typed username, the password, and a two-factor code
// when two-factor is on. Delete stays off until the username matches.
//
// On success the server has already dropped every session. This screen sends
// the browser home, re-reads the session (which is now gone) and says so.
const DANGER = '#d98b84'
export default function DeleteAccountPanel({ account }) {
const { refresh } = useAuth()
const { settings } = useSite()
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const [typed, setTyped] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [useRecovery, setUseRecovery] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const demoLocked = isSharedDemoLogin(settings, account.username)
const noPassword = account.has_password === false
const needsCode = Boolean(account.totp_enabled)
const ready = usernameMatches(typed, account.username) && password.length > 0 && (!needsCode || code.trim().length > 0)
function close() {
setOpen(false)
setTyped('')
setPassword('')
setCode('')
setUseRecovery(false)
setError('')
}
async function confirm(e) {
e.preventDefault()
if (!ready || busy) return
setBusy(true)
setError('')
try {
await api.deleteMyAccount({
username: typed.trim(),
password,
...(needsCode ? (useRecovery ? { recoveryCode: code.trim() } : { code: code.trim() }) : {}),
})
navigate('/', { replace: true })
showNotice('Your account has been deleted.')
await refresh()
} catch (err) {
setError(refusalMessage(err))
setBusy(false)
}
}
return (
<section
aria-labelledby="delete-account-heading"
style={{ borderTop: `1px solid ${DANGER}`, paddingTop: 26, marginTop: 40 }}
>
<h2 id="delete-account-heading" className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: DANGER }}>
Delete my account
</h2>
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 640 }}>
<p style={{ marginTop: 0 }}>
Your account is deleted straight away and cannot be restored. These go with it:
</p>
<ul style={{ margin: '0 0 12px', paddingLeft: 20 }}>
{WHAT_GOES.map((line) => <li key={line}>{line}</li>)}
</ul>
<p style={{ margin: '0 0 6px' }}>What stays:</p>
<ul style={{ margin: '0 0 12px', paddingLeft: 20 }}>
{WHAT_STAYS.map((line) => <li key={line}>{line}</li>)}
</ul>
{isStaffRole(account.role) && <p style={{ margin: '0 0 12px' }}>{STAFF_NAME_NOTE}</p>}
</div>
{noPassword && (
<p className="sans" style={{ color: DANGER, fontSize: '0.86rem' }}>
This account has no password yet. Set one in the Password section above first; the password is how you
confirm a deletion.
</p>
)}
{!open && (
<div>
<button
type="button"
onClick={() => setOpen(true)}
disabled={demoLocked || noPassword}
className="btn btn-sq"
style={{ borderColor: DANGER, color: DANGER }}
>
Delete my account…
</button>
{demoLocked && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '8px 0 0' }}>
This is turned off on the demo site.
</p>
)}
</div>
)}
{open && (
<form
onSubmit={confirm}
style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 360, padding: 16, border: `1px solid ${DANGER}`, borderRadius: 10 }}
>
<label>
<span className="field-label">
Type <strong style={{ color: 'var(--head)' }}>{account.username}</strong> to confirm
</span>
<input
type="text"
value={typed}
onChange={(e) => setTyped(e.target.value)}
className="input"
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
/>
</label>
<label>
<span className="field-label">Password</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" autoComplete="current-password" />
</label>
{needsCode && (
<label>
<span className="field-label">{useRecovery ? 'Recovery code' : 'Authenticator code'}</span>
<input
type="text"
inputMode={useRecovery ? 'text' : 'numeric'}
autoComplete="one-time-code"
value={code}
onChange={(e) => setCode(e.target.value)}
className="input"
/>
<button
type="button"
className="pill"
style={{ marginTop: 8 }}
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
>
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
</button>
</label>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button
type="submit"
disabled={!ready || busy}
className="btn btn-sq"
style={{ background: ready ? DANGER : undefined, borderColor: DANGER, color: ready ? '#1a1a1a' : DANGER }}
>
{busy ? 'Deleting…' : 'Delete my account'}
</button>
<button type="button" onClick={close} disabled={busy} className="pill">Cancel</button>
</div>
{error && <p role="alert" className="sans" style={{ margin: 0, color: DANGER, fontSize: '0.85rem' }}>{error}</p>}
</form>
)}
</section>
)
}

View File

@@ -0,0 +1,69 @@
// 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.'
}

View File

@@ -0,0 +1,25 @@
// How a deleted account's name is shown (ACCOUNT_DELETION.md AD5, AD6).
//
// The server decides WHAT name a viewer gets (server/src/utils/displayAuthor.js):
// a member is sent "[deleted player]", staff are sent the stored name and a
// `…Deleted` flag. All this file does is mark the staff case, so a moderator
// reading "Ravenna" knows that account no longer exists.
export const DELETED_PLAYER = '[deleted player]'
/** "Ravenna (deleted account)" for staff; the placeholder is left as it is. */
export function markDeleted(name, deleted) {
if (!name) return name
return deleted && name !== DELETED_PLAYER ? `${name} (deleted account)` : name
}
/**
* The same for an admin view that is handed raw ledger rows (`actor_user_id`,
* `actor_username`): an id that is NULL beside a snapshot that is not is a
* deleted account, and nothing else clears those ids. Only an explicit null
* counts — a row that does not carry the id column at all says nothing.
*/
export function rowName(userId, username, fallback = '—') {
if (!username) return fallback
return markDeleted(username, userId === null)
}

27
client/src/lib/notice.js Normal file
View File

@@ -0,0 +1,27 @@
// A one-line notice for the site-wide toast (components/DemoToast.jsx), for the
// rare moment a screen has to say something after it has navigated away — the
// account page cannot show "Your account has been deleted." on itself, because
// the account it would be shown on no longer exists.
//
// The same shape as lib/demoNotice.js and drawn by the same toast, so the site
// has one place a passing message appears.
const listeners = new Set()
/** Subscribe to notices. Returns the unsubscribe function. */
export function onNotice(fn) {
listeners.add(fn)
return () => listeners.delete(fn)
}
/** Show one notice. */
export function showNotice(message) {
if (typeof message !== 'string' || !message) return
for (const fn of listeners) {
try {
fn(message)
} catch {
// A broken listener must not stop the caller.
}
}
}

View File

@@ -7,6 +7,8 @@
// for two hours", and those two produce almost the same page. Getting that
// wording right is logic, not markup.
import { markDeleted, rowName } from './deletedAccount.js'
/** Tones the screen uses. Names, not colours — the view maps them. */
export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' }
@@ -91,7 +93,7 @@ export const GATED_NOTE =
/** A one-line description of a queued request, for the approval queue. */
export function describeRequest(request = {}) {
const payload = parsePayload(request.payload)
const who = request.requested_username || 'a deleted user'
const who = rowName(request.requested_by, request.requested_username, 'a deleted user')
switch (request.action) {
case 'unhide':
return `${who} asks to publish “${request.team_name}”`
@@ -133,7 +135,7 @@ export function leadershipOf(member = {}) {
return {
isLeader: granted,
overridden: true,
note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
note: `${granted ? 'Granted' : 'Denied'} by ${markDeleted(member.leaderOverride.by, member.leaderOverride.byDeleted) || 'a deleted user'}`
+ `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}`
+ ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
}

View File

@@ -12,6 +12,8 @@
// `created_at` when the write arrives. A client that granted one would be
// deciding a time-bounded permission against the clock of the party it bounds.
import { markDeleted } from './deletedAccount.js'
export const REPORT_REASONS = [
['abuse', 'Abusive or harassing'],
['spam', 'Spam'],
@@ -75,7 +77,7 @@ export function stripToText(html) {
export function threadSummary(thread) {
const parts = []
if (thread.type === 'announcement') parts.push('Announcement')
parts.push(thread.author)
parts.push(markDeleted(thread.author, thread.authorDeleted))
if (thread.type === 'discussion' && thread.postCount > 1) {
const replies = thread.postCount - 1
parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)

View File

@@ -5,6 +5,8 @@ import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js'
// A member is sent "[deleted player]"; staff the stored name, which this marks.
import { markDeleted } from '../lib/deletedAccount.js'
// Core's Team forum, rendered into a second slot a MODULE declares
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
@@ -332,8 +334,7 @@ function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, o
</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
{thread.type === 'announcement' ? 'Announcement · ' : ''}
{thread.author}
{thread.authorDeleted && ' (account removed)'}
{markDeleted(thread.author, thread.authorDeleted)}
{thread.locked && ' · locked'}
</p>
@@ -448,8 +449,7 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
return (
<article style={{ marginBottom: 16 }}>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
{post.author}
{post.authorDeleted && ' (account removed)'}
{markDeleted(post.author, post.authorDeleted)}
{post.editedAt && ' · edited'}
{post.status === 'hidden' && ' · hidden'}
</p>

View File

@@ -11,6 +11,9 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
// 1.20.0 — `api.onUserDeleting` (ACCOUNT_DELETION.md §4.1, AD10): a module
// unlinks a person's game links before their account is deleted. Server-side
// only. This file bumps for the reason at the top.
// 1.19.0 — an event action may declare `demoRefuses(params)` (DEMO_MODE.md §3.4,
// DM18): the demo refuses a step at save. Server-side only; the refusal reaches
// the browser as an ordinary DEMO_LOCKED answer. This file bumps for the reason at the top.
@@ -105,4 +108,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.19.0'
export const MODULE_API_VERSION = '1.20.0'

View File

@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisp
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
import DeleteAccountPanel from '../../../components/security/DeleteAccountPanel.jsx'
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx'
import { api } from '../../../api/client.js'
@@ -315,6 +316,10 @@ export default function AccountAdmin() {
{account && <EmailAddressPanel account={account} reload={load} />}
<LinkedAccounts />
{/* The same section the player portal shows: every member can delete
themself, staff included (ACCOUNT_DELETION.md §1). */}
{account && <DeleteAccountPanel account={account} />}
</section>
)
}

View File

@@ -4,6 +4,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import { markDeleted } from '../../../lib/deletedAccount.js'
// The member-raised content-report queue (TEAMS.md §5.6).
//
@@ -84,7 +85,7 @@ function TargetCell({ report }) {
<span>
<a href={t.url} target="_blank" rel="noopener noreferrer" className="link-accent">{t.filename}</a>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
{markDeleted(t.uploader, t.uploaderDeleted) || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
{t.deleted && ' · removed'}
</span>
</span>
@@ -95,7 +96,7 @@ function TargetCell({ report }) {
<span>
<strong>{t.title}</strong>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.type} by {t.author || 'unknown'}
{t.type} by {markDeleted(t.author, t.authorDeleted) || 'unknown'}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
@@ -105,7 +106,7 @@ function TargetCell({ report }) {
<span>
{t.excerpt || <em className="dim">(no text)</em>}
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.author || 'unknown'} in “{t.threadTitle}”
{markDeleted(t.author, t.authorDeleted) || 'unknown'} in “{t.threadTitle}”
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
@@ -192,13 +193,13 @@ export default function ContentReports() {
<span className="badge">{REASON_LABEL[r.reason] || r.reason}</span>
</td>
<td className="adm-td dim" style={{ maxWidth: 260 }}>{r.detail || '—'}</td>
<td className="adm-td dim">{r.reporter}</td>
<td className="adm-td dim">{markDeleted(r.reporter, r.reporterDeleted)}</td>
<td className="adm-td dim" title={dateTime(r.createdAt)}>{ago(r.createdAt)}</td>
<td className="adm-td">
<span className="badge" style={STATUS_STYLE[r.status]}>{STATUS_LABEL[r.status] || r.status}</span>
{r.handledBy && (
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
{r.handledBy}
{markDeleted(r.handledBy, r.handledByDeleted)}
{r.handledNote ? ` — ${r.handledNote}` : ''}
</span>
)}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { dateTime } from '../../../lib/format.js'
import { rowName } from '../../../lib/deletedAccount.js'
import {
freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
} from '../../../lib/teamAdmin.js'
@@ -331,7 +332,7 @@ function ForumLedger({ team, onClose }) {
<td className="adm-td dim">{dateTime(r.created_at)}</td>
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.action}</td>
<td className="adm-td dim">{r.target_type} #{r.target_id}</td>
<td className="adm-td">{r.actor_username || '—'}</td>
<td className="adm-td">{rowName(r.actor_user_id, r.actor_username)}</td>
<td className="adm-td">
{/* The distinction the whole ledger exists to preserve. */}
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>

View File

@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
import DeleteAccountPanel from '../../components/security/DeleteAccountPanel.jsx'
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
@@ -422,6 +423,7 @@ export default function PlayerAccount() {
)}
<LinkedAccounts />
<ActiveDevices />
<DeleteAccountPanel account={account} />
</>
)}
</div>