[www] add delete account request

This commit is contained in:
Devin Zuczek
2026-10-28 00:12:41 -04:00
parent cc14c208b2
commit 633695b657
7 changed files with 189 additions and 0 deletions
+92
View File
@@ -82,6 +82,14 @@ interface SelfAccount {
* stays usable and lets the server be the one to refuse.
*/
availableUsernameChanges?: number
/**
* Whether the player asked for the account to be deleted (`/api/account/delete`). A
* mark, not a deletion: the account still works, and signing in again is meant to
* reverse it. Absent on an older self DTO, which reads as "not marked".
*/
isDeleted?: boolean
/** When they asked, present only while `isDeleted`. */
deletedAt?: string
}
/**
@@ -687,6 +695,14 @@ const claimBenefits = (code: string): Promise<ClaimResult> =>
const unlinkDiscord = (): Promise<BenefitsStatus> =>
call<BenefitsStatus>('/api/benefits/unlink', { json: {}, authed: true })
/**
* Mark the signed-in account for deletion — www's own route, which only sets a flag on
* the account. Nothing is removed and the session stays valid; the account page just
* shows the mark from here on. Answers the mark as it now stands.
*/
const deleteAccount = (): Promise<{ isDeleted: true; deletedAt: string }> =>
call<{ isDeleted: true; deletedAt: string }>('/api/account/delete', { json: {}, authed: true })
/**
* The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage.
*
@@ -4820,6 +4836,11 @@ function Dashboard({
render: () => <EmailForm account={account} onChange={onChange} />,
},
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
{
id: 'delete',
label: 'Delete account',
render: () => <DeleteAccountForm account={account} onChange={onChange} />,
},
// Only when the operator has Discord configured — otherwise the panel has nothing to
// offer and the tab is a promise the server can't keep. The claim also still lives at
// /claim, because that URL is Discord's registered redirect and has to keep working.
@@ -5878,3 +5899,74 @@ function PasswordForm() {
</section>
)
}
/**
* The way out. The button MARKS the account (`/api/account/delete`) rather than deleting
* it, and the panel says so plainly: the account keeps working, and signing in again is
* what reverses the request. The player types their username to confirm — a password
* would lock out the accounts that sign in through Discord and have none.
*/
function DeleteAccountForm({
account,
onChange,
}: {
account: SelfAccount
onChange: (a: SelfAccount) => void
}) {
const [confirmation, setConfirmation] = useState('')
const { pending, error, run } = useAction()
const confirmed = confirmation.trim().toLowerCase() === account.username.toLowerCase()
if (account.isDeleted) {
return (
<section className="card">
<h2>Delete account</h2>
<p className="error">
This account is marked for deletion
{account.deletedAt ? ` as of ${new Date(account.deletedAt).toLocaleString()}` : ''}.
</p>
<p className="muted">
Nothing has been removed yet, and the account still works. Signing in again before it is
deleted keeps it.
</p>
</section>
)
}
return (
<section className="card">
<h2>Delete account</h2>
<p className="muted">
This marks your account for deletion. Your rooms, photos and inventory are not removed right
away, and you can change your mind by signing in again before they are.
</p>
<form
onSubmit={(e) => {
e.preventDefault()
if (!confirmed) return
void run(async () => {
const marked = await deleteAccount()
onChange({ ...account, isDeleted: true, deletedAt: marked.deletedAt })
setConfirmation('')
return 'Your account is marked for deletion.'
})
}}
>
<label>
Type your username (@{account.username}) to confirm
<input
type="text"
value={confirmation}
autoComplete="off"
onChange={(e) => setConfirmation(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" className="danger" disabled={pending || !confirmed}>
{pending ? 'Marking…' : 'Delete account'}
</button>
</form>
</section>
)
}
+11
View File
@@ -1208,6 +1208,17 @@ button[type='submit']:disabled {
cursor: default;
}
/* The one submit that takes something away (delete account): the error colour, so it
can't be mistaken for a save. */
button[type='submit'].danger {
background: var(--error);
color: #fff;
}
button[type='submit'].danger:not(:disabled):hover {
background: var(--text);
}
/* The cross-link under an auth form ("Don't have an account? Create one"). Sits under
the submit button, which hugs its label, so it needs its own separation from it. */
.swap {