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:
178
client/src/components/security/DeleteAccountPanel.jsx
Normal file
178
client/src/components/security/DeleteAccountPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user