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.
|
// everyone can be old.
|
||||||
birthday: '1904-01-01T00:00:00.000Z',
|
birthday: '1904-01-01T00:00:00.000Z',
|
||||||
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
|
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'),
|
birthday: z.iso.datetime().describe('A fixed placeholder — birthdays are not stored'),
|
||||||
availableUsernameChanges: z.int().describe('Remaining username changes'),
|
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`. */
|
/** Player bio, from `GET /account/:id/bio`. */
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ interface SelfAccount {
|
|||||||
* stays usable and lets the server be the one to refuse.
|
* stays usable and lets the server be the one to refuse.
|
||||||
*/
|
*/
|
||||||
availableUsernameChanges?: number
|
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> =>
|
const unlinkDiscord = (): Promise<BenefitsStatus> =>
|
||||||
call<BenefitsStatus>('/api/benefits/unlink', { json: {}, authed: true })
|
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.
|
* The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage.
|
||||||
*
|
*
|
||||||
@@ -4820,6 +4836,11 @@ function Dashboard({
|
|||||||
render: () => <EmailForm account={account} onChange={onChange} />,
|
render: () => <EmailForm account={account} onChange={onChange} />,
|
||||||
},
|
},
|
||||||
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
|
{ 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
|
// 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
|
// 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.
|
// /claim, because that URL is Discord's registered redirect and has to keep working.
|
||||||
@@ -5878,3 +5899,74 @@ function PasswordForm() {
|
|||||||
</section>
|
</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;
|
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 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. */
|
the submit button, which hugs its label, so it needs its own separation from it. */
|
||||||
.swap {
|
.swap {
|
||||||
|
|||||||
@@ -741,6 +741,47 @@ const unlink = (token: string) =>
|
|||||||
body: '{}',
|
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 () => {
|
it('requires a valid session to unlink a discord', async () => {
|
||||||
expect((await unlink('')).status).toBe(401)
|
expect((await unlink('')).status).toBe(401)
|
||||||
const forged = await generateToken('4020', '', 4, 'not-the-real-secret')
|
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
|
// Whether the signed-in account has studio upload access (`hasStudio`). The page
|
||||||
// Studio opens (`/settings/recroomstudio`) asks this; it does not list anyone
|
// Studio opens (`/settings/recroomstudio`) asks this; it does not list anyone
|
||||||
// else. Staff manage the flag on the routes below.
|
// 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)
|
.get('/api/studio-access', studioAccessStatusHandler)
|
||||||
|
|
||||||
// ---- Staff moderation panel ---------------------------------------------
|
// ---- Staff moderation panel ---------------------------------------------
|
||||||
|
|||||||
@@ -225,6 +225,16 @@ export interface Account {
|
|||||||
hasStudio?: boolean
|
hasStudio?: boolean
|
||||||
/** Staff override for screen sharing. Absent means screen sharing is disabled. */
|
/** Staff override for screen sharing. Absent means screen sharing is disabled. */
|
||||||
hasScreenshare?: boolean
|
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 {
|
interface AccountRow {
|
||||||
|
|||||||
Reference in New Issue
Block a user