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
179 lines
6.6 KiB
JavaScript
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>
|
|
)
|
|
}
|