From 633695b6571693a9389c60e2cdfb320b509e960e Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 28 Oct 2026 00:12:41 -0400 Subject: [PATCH] [www] add delete account request --- apps/accounts/src/accounts.app.ts | 4 + apps/accounts/src/openapi.ts | 6 ++ apps/www/src/client/App.tsx | 92 +++++++++++++++++++++++ apps/www/src/client/styles.css | 11 +++ apps/www/src/test/integration/api.test.ts | 41 ++++++++++ apps/www/src/www.app.ts | 25 ++++++ packages/domain/src/accounts-db.ts | 10 +++ 7 files changed, 189 insertions(+) diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index e5922d4..aa2f21a 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -219,6 +219,10 @@ function toSelfAccountDto(account: Account) { // everyone can be old. birthday: '1904-01-01T00:00:00.000Z', availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES, + // The website's "delete account" mark, so its account page can show it. Always a + // boolean (the game client drops unknown members and reads nothing here). + isDeleted: account.isDeleted === true, + ...(account.isDeleted === true && account.deletedAt ? { deletedAt: account.deletedAt } : {}), } } diff --git a/apps/accounts/src/openapi.ts b/apps/accounts/src/openapi.ts index 20466e0..0e091ae 100644 --- a/apps/accounts/src/openapi.ts +++ b/apps/accounts/src/openapi.ts @@ -98,6 +98,12 @@ export const SelfAccountDto = AccountDto.extend({ ), birthday: z.iso.datetime().describe('A fixed placeholder — birthdays are not stored'), availableUsernameChanges: z.int().describe('Remaining username changes'), + isDeleted: z + .boolean() + .describe( + 'Whether the player asked the website to delete this account (a mark, not a deletion)' + ), + deletedAt: z.iso.datetime().optional().describe('When they asked; present only when isDeleted'), }) /** Player bio, from `GET /account/:id/bio`. */ diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 89ba6fa..0003fc2 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -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 => const unlinkDiscord = (): Promise => call('/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: () => , }, { id: 'password', label: 'Password', render: () => }, + { + id: 'delete', + label: 'Delete account', + render: () => , + }, // 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() { ) } + +/** + * 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 ( +
+

Delete account

+

+ This account is marked for deletion + {account.deletedAt ? ` as of ${new Date(account.deletedAt).toLocaleString()}` : ''}. +

+

+ Nothing has been removed yet, and the account still works. Signing in again before it is + deleted keeps it. +

+
+ ) + } + + return ( +
+

Delete account

+

+ 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. +

+
{ + 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.' + }) + }} + > + + {error &&

{error}

} + +
+
+ ) +} diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index 3d5811e..1ea7176 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -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 { diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 84b851d..4f35708 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -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') diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index c21aaab..afc447a 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -781,6 +781,31 @@ const app = new Hono() // 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 --------------------------------------------- diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 4bd8e49..08334c3 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -225,6 +225,16 @@ export interface Account { hasStudio?: boolean /** Staff override for screen sharing. Absent means screen sharing is disabled. */ hasScreenshare?: boolean + /** + * Whether the player has asked for this account to be deleted (`www` + * `POST /api/account/delete`). A MARK, not a deletion: nothing is removed, nothing reads + * it yet, and the account logs in and plays exactly as before. The intent is that a later + * sweep compares `deletedAt` with `lastLoginTime` — a login AFTER the request is the + * player changing their mind, and reverses it. Absent/false means not marked. + */ + isDeleted?: boolean + /** ISO-8601 time of the deletion request; set with `isDeleted`, absent otherwise. */ + deletedAt?: string } interface AccountRow {