Files
website/client/src/components/security/DeleteAccountPanel.jsx
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

179 lines
6.6 KiB
JavaScript

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>
)
}