mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-28 00:18:58 -07:00
[www] add delete account request
This commit is contained in:
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 ---------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user