[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 {
+41
View File
@@ -741,6 +741,47 @@ const unlink = (token: string) =>
body: '{}',
})
const requestDeletion = (token: string) =>
SELF.fetch('https://example.com/api/account/delete', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: '{}',
})
it('requires a valid session to mark an account for deletion', async () => {
expect((await requestDeletion('')).status).toBe(401)
const forged = await generateToken('4030', '', 4, 'not-the-real-secret')
expect((await requestDeletion(forged)).status).toBe(401)
// A token for an account that doesn't exist marks nothing.
expect((await requestDeletion(await tokenFor(4031))).status).toBe(404)
})
it('marks the account for deletion without deleting it, once', async () => {
const token = await tokenFor(4032)
await updateAccount(env.DB, 4032, { username: 'Leaving', email: 'p4032@example.com' })
const res = await requestDeletion(token)
expect(res.status).toBe(200)
const body = (await res.json()) as { isDeleted: boolean; deletedAt: string }
expect(body.isDeleted).toBe(true)
expect(Date.parse(body.deletedAt)).not.toBeNaN()
// A mark, not a deletion: the row is still there, untouched but for the flag.
const marked = await getAccount(env.DB, 4032)
expect(marked).toMatchObject({
username: 'Leaving',
email: 'p4032@example.com',
isDeleted: true,
deletedAt: body.deletedAt,
})
// Idempotent, keeping the ORIGINAL request time: a second click can't push the mark
// past a sign-in that already reversed it.
const again = (await (await requestDeletion(token)).json()) as { deletedAt: string }
expect(again.deletedAt).toBe(body.deletedAt)
expect((await getAccount(env.DB, 4032))?.deletedAt).toBe(body.deletedAt)
})
it('requires a valid session to unlink a discord', async () => {
expect((await unlink('')).status).toBe(401)
const forged = await generateToken('4020', '', 4, 'not-the-real-secret')
+25
View File
@@ -781,6 +781,31 @@ const app = new Hono<App>()
// Whether the signed-in account has studio upload access (`hasStudio`). The page
// Studio opens (`/settings/recroomstudio`) asks this; it does not list anyone
// else. Staff manage the flag on the routes below.
/**
* Mark the caller's account for deletion. A MARK only: `isDeleted` and `deletedAt` go on
* the account blob and nothing else changes — no row is removed, the session stays
* valid, and the game keeps serving the account. Reversal is meant to be the player
* signing in again (a `lastLoginTime` later than `deletedAt`), which is why there is no
* "undo" here and why this never touches `auth`. Idempotent: re-posting keeps the
* ORIGINAL `deletedAt`, so a second click can't push the mark past a login that already
* reversed it.
*/
.post('/api/account/delete', async (c) => {
const accountId = await claimant(c)
if (accountId === null) return c.body(null, 401)
const account = await getAccount(c.env.DB, accountId)
if (!account) return c.json({ error: 'No such account.' }, 404)
const deletedAt =
account.isDeleted === true && account.deletedAt ? account.deletedAt : new Date().toISOString()
if (!(account.isDeleted === true && account.deletedAt)) {
await updateAccount(c.env.DB, accountId, { isDeleted: true, deletedAt })
logger.info('a player marked their account for deletion', { accountId })
}
return c.json({ isDeleted: true, deletedAt })
})
.get('/api/studio-access', studioAccessStatusHandler)
// ---- Staff moderation panel ---------------------------------------------