Account deletion: from Settings and sudo, soft for 30 days, then purged to ghost (identity 0037)
A person deletes their own account from Settings > Account: a danger action whose dialog lists what goes, the typed username, and proof it is them (password, or a sign-in within 10 minutes). Staff delete one from a person's page in sudo with a reason and the typed username, and restore or purge deleted accounts there and on Deleted accounts (/users/deleted). Refused for protected accounts (g1t, g1t-agent, ghost, PROTECTED_ACCOUNTS via protected_names) and while the account is the only owner of a live workspace; the page lists each with what billing needs to delete it. Soft first: users gets deleted_at/deleted_by/purge_after/deleted_went (ACCOUNT_RESTORE_DAYS = 30). Sessions, tokens, OAuth grants and codes, device sign-ins, SSH keys, deploy keys it added, two-factor challenges, emailed links and GitHub tokens end at once; it leaves every workspace, team and repository (kept for a restore); pending invitations and unused invites are revoked. Every person lookup skips it: sign-in fails as a wrong password does, the profile 404s, nobody can add or email it, and the username stays held. user.deleting tells services; search drops it. Purge (sweep every 15 minutes, or staff) removes the row and personal data, keeps the username in deleted_users forever (slug_deleted checks it), names a workspace's creator usr_ghost, and publishes user.deleted: work rewrites authorship to ghost and unassigns, events drops the inbox, commits with the noreply address resolve to ghost. ghost is reserved. No API route: site and sudo only, as documented. Docs: the accounts guide's Deleting your account, audit log actions, the sudo README.
| 58 | 58 | | `package.visibility_changed` | A package was made public or private. | | |
| 59 | 59 | | `package.linked`, `package.unlinked` | A package was linked to a repository (from its settings, or by an image's source label), or unlinked. | | |
| 60 | 60 | | `workspace.residency_changed` | An owner changed where the workspace's new repositories are stored. See [data residency](/guides/workspaces/#data-residency). | | |
| 61 | + | | `account.deleted`, `account.deleted_by_staff` | A member [deleted their account](/guides/authentication/#deleting-your-account), or g1t's staff deleted it, and so left the workspace. Recorded in each of their workspaces. | | |
| 61 | 62 | | `workspace.deleted`, `workspace.restored`, `workspace.purged` | An owner deleted the workspace, g1t's support restored it, or it was removed for good. See [deleting a workspace](/guides/workspaces/#delete-a-workspace). | | |
| 62 | 63 | ||
| 63 | 64 | Through the API and the MCP server, the call itself is recorded under its |
| 1 | 1 | --- | |
| 2 | 2 | title: Accounts and authentication | |
| 3 | − | description: Accounts, invites, email addresses, confirming them, two-factor authentication and recovery codes, fine-grained and classic personal access tokens, scopes and permissions, a workspace's rules for tokens, OAuth, signing in from a tool, password reset and your security log. | |
| 3 | + | description: Accounts, invites, email addresses, confirming them, two-factor authentication and recovery codes, fine-grained and classic personal access tokens, scopes and permissions, a workspace's rules for tokens, OAuth, signing in from a tool, password reset, your security log and deleting your account. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | ## Creating an account | |
| ⋯ | |||
| 35 | 35 | | Connected applications | [`/settings/applications`](https://g1t.sh/settings/applications) | Tools you [signed in to with OAuth](#signing-in-with-oauth), such as an agent using the MCP server. | | |
| 36 | 36 | | Two-factor authentication | [`/settings/two-factor`](https://g1t.sh/settings/two-factor) | [An authenticator app and recovery codes](#two-factor-authentication). | | |
| 37 | 37 | | Security log | [`/settings/security-log`](https://g1t.sh/settings/security-log) | [What happened to your account](#security-log). | | |
| 38 | + | | Account | [`/settings/account`](https://g1t.sh/settings/account) | Your username, and [deleting your account](#deleting-your-account). | | |
| 38 | 39 | ||
| 39 | 40 | `g1t.sh/settings` opens Profile. | |
| 40 | 41 | ||
| ⋯ | |||
| 960 | 961 | [audit log](/guides/audit-log/) of each workspace you belong to, where its | |
| 961 | 962 | owners see them. | |
| 962 | 963 | ||
| 964 | + | ## Deleting your account | |
| 965 | + | ||
| 966 | + | You can delete your account from | |
| 967 | + | [Settings → Account](https://g1t.sh/settings/account), signed in as | |
| 968 | + | yourself. It is not gone at once: for **30 days** g1t keeps it, so that a | |
| 969 | + | deletion you did not mean, or did not make, can be undone through support. | |
| 970 | + | After 30 days it is removed for good. | |
| 971 | + | ||
| 972 | + | 1. Open **Settings → Account** and go to **Danger zone**. If anything is | |
| 973 | + | in the way, it says what, instead of offering the button. | |
| 974 | + | 2. Choose **Delete account**. The dialog lists what goes with it: your | |
| 975 | + | workspaces, the repositories you were added to, your access tokens, SSH | |
| 976 | + | keys and connected applications. | |
| 977 | + | 3. Type your username, and your password unless you signed in within the | |
| 978 | + | last 10 minutes. An account that signs in with GitHub only signs out, | |
| 979 | + | signs in with GitHub again, and deletes it within 10 minutes. | |
| 980 | + | 4. Choose **Delete account** again. You are signed out, and g1t emails your | |
| 981 | + | primary and backup addresses to say it was deleted. | |
| 982 | + | ||
| 983 | + | There is no API route or MCP tool for deleting an account, by design: like | |
| 984 | + | [creating one](#creating-an-account), it happens only in a browser, signed | |
| 985 | + | in as yourself, never with a token or as an agent. | |
| 986 | + | ||
| 987 | + | ### What stands in the way | |
| 988 | + | ||
| 989 | + | | | | | |
| 990 | + | | --- | --- | | |
| 991 | + | | A workspace you own alone | Each live workspace where you are the only owner is listed. [Make someone else an owner](/guides/workspaces/#change-someones-role) of it, or [delete it](/guides/workspaces/#delete-a-workspace), first. Deleting a workspace settles its billing, which can ask for something first: the list says what. A workspace you own with someone else is not in the way. | | |
| 992 | + | | A protected account | `g1t` and the other names g1t uses for itself can never be deleted, by anyone. | | |
| 993 | + | ||
| 994 | + | Billing belongs to workspaces, not to accounts, so once no workspace | |
| 995 | + | depends on you alone there is nothing for billing to settle. | |
| 996 | + | ||
| 997 | + | ### What happens | |
| 998 | + | ||
| 999 | + | At once, when you delete it: | |
| 1000 | + | ||
| 1001 | + | | | | | |
| 1002 | + | | --- | --- | | |
| 1003 | + | | Signing in | You are signed out everywhere. Signing in with your password, GitHub, a recovery code or from a tool fails, with the same answer a wrong password gets. | | |
| 1004 | + | | Access tokens, SSH keys and applications | Your personal access tokens (classic and fine-grained), SSH keys, connected applications and sign-ins from a tool stop working and are removed, and so do the deploy keys you added to repositories. A workspace's own tokens are not affected, even ones you made. | | |
| 1005 | + | | Workspaces, teams and repositories | You leave every workspace and team, and lose the roles you were given on single repositories. Repository invitations waiting for you are withdrawn, and invites you made that nobody used are revoked. | | |
| 1006 | + | | Your profile | `g1t.sh/<username>` answers 404, and you drop out of search. Nobody can add you to a workspace, team or repository, and nothing more is emailed to you. | | |
| 1007 | + | | What you wrote | Stays where it is, under your username for now. | | |
| 1008 | + | | Your username | Held for your account. Nobody else can take it. | | |
| 1009 | + | ||
| 1010 | + | Within 30 days, support can restore it: write to support@g1t.sh from one | |
| 1011 | + | of its addresses. You come back to the workspaces, teams and repositories | |
| 1012 | + | you were in, where they are still there, and sign in again with your | |
| 1013 | + | password. Your old sessions, tokens and keys stay ended: make new ones. | |
| 1014 | + | ||
| 1015 | + | After 30 days it is removed for good: | |
| 1016 | + | ||
| 1017 | + | | | | | |
| 1018 | + | | --- | --- | | |
| 1019 | + | | Your addresses, keys and profile | Removed: your email addresses, two-factor secret and recovery codes, GitHub link, picture, profile and security log, and your inbox and its settings. | | |
| 1020 | + | | What you wrote | Issues, pull requests, comments and reviews keep their place and their words, and show as written by `ghost`. You are taken off issues and pull requests you were assigned to or asked to review. Commits keep the name and address git recorded in them; those made with your [noreply address](#keeping-your-address-private) show as `ghost`. | | |
| 1021 | + | | Workspaces you made | Name `ghost` as their creator. | | |
| 1022 | + | | Statements, invoices and audit logs | Kept with your username, for the workspaces they belong to. | | |
| 1023 | + | | Your username | Never given to another account or workspace, so links, mentions and remotes that use it keep meaning what they meant. `ghost` is reserved for this, and nobody can register it. | | |
| 1024 | + | ||
| 963 | 1025 | ## What g1t stores | |
| 964 | 1026 | ||
| 965 | 1027 | Passwords are stored as salted PBKDF2-SHA256 hashes. Sessions and tokens are | |
| 194 | 194 | ||
| 195 | 195 | | Resource | | | |
| 196 | 196 | | --- | --- | | |
| 197 | − | | [Accounts](/reference/api/accounts/whoami/) | Signing in from a tool, and who a token acts as. | | |
| 197 | + | | [Accounts](/reference/api/accounts/whoami/) | Signing in from a tool, and who a token acts as. Accounts are made and [deleted](/guides/authentication/#deleting-your-account) only in a browser: there is no route for either. | | |
| 198 | 198 | | [Workspaces](/reference/api/workspaces/create-workspace/) | Creating a workspace. | | |
| 199 | 199 | | [Notifications](/reference/api/notifications/list-notifications/) | Your inbox: its threads, why you were told of each, marking them read, done, saved or snoozed, and what you subscribe to and watch. See [your inbox](/guides/inbox/). | | |
| 200 | 200 | | [Billing](/reference/api/billing/get-usage/) | A workspace's usage by product, project and day, its budget, its AI credit, its invoices and its [AI Gateway](/guides/ai-gateway/) requests. See [usage and billing](/guides/usage-and-billing/). | |
| 70 | 70 | protected workspace. Both go in the workspace's audit log, as g1t, and in | |
| 71 | 71 | sudo's (`workspace_restored`, `workspace_purged`), naming the staff | |
| 72 | 72 | member. | |
| 73 | + | - **A person's page** (`/users/<username>`, linked from a workspace's | |
| 74 | + | members): their addresses (remove one, with a reason they see), their | |
| 75 | + | security log, and **Delete account**. Delete only when the person asks | |
| 76 | + | (from one of the account's confirmed addresses) or for abuse: give the | |
| 77 | + | reason, which goes in sudo's audit log (`account_deleted`), and type the | |
| 78 | + | username (`admin_delete_account`). It does what deleting their own | |
| 79 | + | account from Settings does: signs them out everywhere, ends their tokens, | |
| 80 | + | SSH keys, deploy keys they added and applications, takes them out of | |
| 81 | + | every workspace, team and repository, and emails their addresses that | |
| 82 | + | staff deleted it. It is refused while the account is the **only owner of | |
| 83 | + | a live workspace**: the page lists those workspaces instead of the form, | |
| 84 | + | each linking to its page. Each needs another owner first (an owner makes | |
| 85 | + | one under People), or to be deleted by its owner, which settles its | |
| 86 | + | billing; staff never delete a customer's workspace to get an account | |
| 87 | + | out. Accounts that can never be deleted (`g1t`, `g1t-agent`, `ghost`, | |
| 88 | + | and whatever identity's `PROTECTED_ACCOUNTS` names, by username or id) | |
| 89 | + | are marked **Protected** and offer no form. A deleted account's page | |
| 90 | + | says so, with who deleted it, why, when it is purged, and **Restore** and | |
| 91 | + | **Purge now**, as on Deleted accounts. | |
| 92 | + | - **Deleted accounts** (`/users/deleted`, linked from Workspaces): | |
| 93 | + | accounts deleted by the person or by staff, newest first | |
| 94 | + | (`admin_deleted_accounts`), each with who deleted it (the person, or the | |
| 95 | + | staff member and why), when it is purged, the days left and what it | |
| 96 | + | left (workspaces, teams, repositories, tokens, SSH keys). Identity keeps | |
| 97 | + | each 30 days (`ACCOUNT_RESTORE_DAYS`). **Restore** | |
| 98 | + | (`admin_restore_account`) clears the deletion and puts back the | |
| 99 | + | memberships, teams and repository roles it left where they still exist; | |
| 100 | + | its sessions, tokens and keys stay ended, and the person signs in with | |
| 101 | + | their password. Check that whoever asks owns one of its addresses first. | |
| 102 | + | **Purge now** (`admin_purge_account`, the username typed) removes it at | |
| 103 | + | once, as the sweep does every 15 minutes once its 30 days are up: its | |
| 104 | + | row, addresses, keys, two-factor secret, GitHub link, profile and | |
| 105 | + | security log go; its username is kept in `deleted_users` and never given | |
| 106 | + | out again; what it wrote shows as `ghost`. Both go in sudo's audit log | |
| 107 | + | (`account_restored`, `account_purged`), naming the staff member. There | |
| 108 | + | is no API route for deleting an account; only the site and sudo can. | |
| 73 | 109 | - **Aliases** (`/aliases`, under Customers): names that lead to a | |
| 74 | 110 | workspace, set by staff only; there is no way for a customer to make | |
| 75 | 111 | one, and nothing user-facing mentions them. `g1t`, the product's name, |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { accountWentSummary, confirmsUsername, staffDeletionRefusal } from "./deleted-accounts.ts"; | |
| 5 | + | ||
| 6 | + | const deletion = { | |
| 7 | + | username: "ada", | |
| 8 | + | workspaces: 2, | |
| 9 | + | tokens: 1, | |
| 10 | + | ssh_keys: 0, | |
| 11 | + | applications: 0, | |
| 12 | + | repositories: 0, | |
| 13 | + | sole_owner_of: [], | |
| 14 | + | protected: false, | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | test("what went reads as one line", () => { | |
| 18 | + | assert.equal( | |
| 19 | + | accountWentSummary({ workspaces: 2, teams: 1, repositories: 3, tokens: 1, sshKeys: 2, staff: null, reason: null }), | |
| 20 | + | "2 workspaces, 1 team, 3 repositories, 1 token, 2 SSH keys", | |
| 21 | + | ); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | test("an account that owns workspaces alone, or is protected, is refused", () => { | |
| 25 | + | assert.equal(staffDeletionRefusal(deletion), null); | |
| 26 | + | const owner = { ...deletion, sole_owner_of: [{ slug: "acme", name: "Acme", members: 3, billing: null }] }; | |
| 27 | + | assert.equal( | |
| 28 | + | staffDeletionRefusal(owner), | |
| 29 | + | "ada is the only owner of 1 workspace. Each needs another owner, or to be deleted by its owner, first.", | |
| 30 | + | ); | |
| 31 | + | assert.equal( | |
| 32 | + | staffDeletionRefusal({ ...owner, username: "g1t", protected: true }), | |
| 33 | + | "g1t is protected and can never be deleted.", | |
| 34 | + | ); | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | test("staff confirm with the username", () => { | |
| 38 | + | assert.ok(confirmsUsername("ada", " ADA ")); | |
| 39 | + | assert.ok(!confirmsUsername("ada", "")); | |
| 40 | + | assert.ok(!confirmsUsername("ada", "grace")); | |
| 41 | + | }); |
| 1 | + | /** | |
| 2 | + | * Deleting accounts, as a person's page and the Deleted accounts page show | |
| 3 | + | * it: what went with an account, what stands in the way of deleting one, | |
| 4 | + | * and what staff type to confirm. Identity checks all of it again. No | |
| 5 | + | * Workers imports, so it can be tested under Node. | |
| 6 | + | */ | |
| 7 | + | import type { AccountDeletion, AccountWent } from "@g1t/contracts"; | |
| 8 | + | ||
| 9 | + | const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; | |
| 10 | + | ||
| 11 | + | /** What went with an account, in a line: "2 workspaces, 1 team, 3 repositories, 4 tokens, 1 SSH key". */ | |
| 12 | + | export function accountWentSummary(went: AccountWent): string { | |
| 13 | + | return [ | |
| 14 | + | plural(went.workspaces, "workspace", "workspaces"), | |
| 15 | + | plural(went.teams, "team", "teams"), | |
| 16 | + | plural(went.repositories, "repository", "repositories"), | |
| 17 | + | plural(went.tokens, "token", "tokens"), | |
| 18 | + | plural(went.sshKeys, "SSH key", "SSH keys"), | |
| 19 | + | ].join(", "); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /** Why staff cannot delete the account, in a sentence about it, or null. */ | |
| 23 | + | export function staffDeletionRefusal(deletion: AccountDeletion): string | null { | |
| 24 | + | if (deletion.protected) return `${deletion.username} is protected and can never be deleted.`; | |
| 25 | + | const slugs = deletion.sole_owner_of.map((workspace) => workspace.slug); | |
| 26 | + | if (slugs.length === 0) return null; | |
| 27 | + | return `${deletion.username} is the only owner of ${slugs.length === 1 ? "1 workspace" : `${slugs.length} workspaces`}. Each needs another owner, or to be deleted by its owner, first.`; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /** Whether what staff typed is the username. Identity checks it again. */ | |
| 31 | + | export function confirmsUsername(username: string, typed: string): boolean { | |
| 32 | + | const value = typed.trim(); | |
| 33 | + | return value !== "" && value.toLowerCase() === username.toLowerCase(); | |
| 34 | + | } |
| 113 | 113 | // From identity: deleted workspaces staff restored or purged. | |
| 114 | 114 | workspace_restored: "Workspace restored", | |
| 115 | 115 | workspace_purged: "Workspace purged", | |
| 116 | + | // From identity: accounts staff deleted, restored or purged. | |
| 117 | + | account_deleted: "Account deleted", | |
| 118 | + | account_restored: "Account restored", | |
| 119 | + | account_purged: "Account purged", | |
| 116 | 120 | // From identity: workspace aliases staff set or removed. | |
| 117 | 121 | alias_added: "Alias added", | |
| 118 | 122 | alias_removed: "Alias removed", |
| 7 | 7 | route("workspaces", "routes/workspaces.tsx"), | |
| 8 | 8 | route("workspaces/deleted", "routes/deleted-workspaces.tsx"), | |
| 9 | 9 | route("workspaces/:slug", "routes/workspace.tsx"), | |
| 10 | + | route("users/deleted", "routes/deleted-accounts.tsx"), | |
| 10 | 11 | route("users/:username", "routes/user.tsx"), | |
| 11 | 12 | route("enterprises", "routes/enterprises.tsx"), | |
| 12 | 13 | route("invites", "routes/invites.tsx"), |
| 1 | + | import { ArrowLeft } from "lucide-react"; | |
| 2 | + | import { Link, data, redirect } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { ACCOUNT_RESTORE_DAYS, type DeletedAccount } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/deleted-accounts"; | |
| 7 | + | import { Badge, Button, EmptyState, Input, Notice, PageHeader, When } from "~/components/ui"; | |
| 8 | + | import { accountWentSummary, confirmsUsername } from "~/lib/deleted-accounts"; | |
| 9 | + | import { daysLeft } from "~/lib/deleted-workspaces"; | |
| 10 | + | import { text } from "~/lib/forms"; | |
| 11 | + | import { accountsAdmin } from "~/lib/services.server"; | |
| 12 | + | import { settle } from "~/lib/settle"; | |
| 13 | + | import { requireStaff } from "~/lib/staff"; | |
| 14 | + | ||
| 15 | + | export const meta: Route.MetaFunction = () => [ | |
| 16 | + | { title: "Deleted accounts · sudo" }, | |
| 17 | + | { name: "robots", content: "noindex, nofollow" }, | |
| 18 | + | ]; | |
| 19 | + | ||
| 20 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 21 | + | requireStaff(context); | |
| 22 | + | const url = new URL(request.url); | |
| 23 | + | const deleted = await settle(accountsAdmin.deletedAccounts()); | |
| 24 | + | const done = url.searchParams.get("done"); | |
| 25 | + | const username = url.searchParams.get("username") ?? ""; | |
| 26 | + | return { | |
| 27 | + | accounts: deleted.ok ? deleted.value : [], | |
| 28 | + | error: deleted.ok ? null : deleted.error, | |
| 29 | + | done: done === "restored" ? `Restored ${username}.` : done === "purged" ? `Purged ${username}.` : null, | |
| 30 | + | now: Date.now(), | |
| 31 | + | }; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /** | |
| 35 | + | * Restoring and purging. Identity checks the window, the typed username | |
| 36 | + | * and protection again, and records each in sudo's audit log naming the | |
| 37 | + | * staff member. | |
| 38 | + | */ | |
| 39 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 40 | + | const staff = requireStaff(context); | |
| 41 | + | const form = await request.formData(); | |
| 42 | + | const id = text(form, "id"); | |
| 43 | + | const username = text(form, "username"); | |
| 44 | + | const back = (done: string) => redirect(`/users/deleted?done=${done}&username=${encodeURIComponent(username)}`); | |
| 45 | + | switch (text(form, "intent")) { | |
| 46 | + | case "restore": { | |
| 47 | + | const result = await accountsAdmin.restoreAccount(id, staff.email); | |
| 48 | + | if (!result.ok) return data({ error: result.error.message, id }, { status: 422 }); | |
| 49 | + | throw back("restored"); | |
| 50 | + | } | |
| 51 | + | case "purge": { | |
| 52 | + | const confirm = text(form, "confirm"); | |
| 53 | + | if (!confirmsUsername(username, confirm)) return data({ error: `Type ${username} to confirm.`, id }, { status: 422 }); | |
| 54 | + | const result = await accountsAdmin.purgeAccount(id, staff.email, confirm); | |
| 55 | + | if (!result.ok) return data({ error: result.error.message, id }, { status: 422 }); | |
| 56 | + | throw back("purged"); | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | return data({ error: "Unknown action.", id }, { status: 400 }); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | export default function DeletedAccounts({ loaderData, actionData }: Route.ComponentProps) { | |
| 63 | + | const { accounts, error, done, now } = loaderData; | |
| 64 | + | const errorFor = (id: string) => (actionData && "id" in actionData && actionData.id === id ? actionData.error : null); | |
| 65 | + | return ( | |
| 66 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 67 | + | <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 68 | + | <ArrowLeft size={14} /> | |
| 69 | + | Workspaces | |
| 70 | + | </Link> | |
| 71 | + | <div className="mt-4"> | |
| 72 | + | <PageHeader | |
| 73 | + | title="Deleted accounts" | |
| 74 | + | description={`Accounts deleted by the person or by staff. Each is kept for ${ACCOUNT_RESTORE_DAYS} days: restore one the person asks back, after checking they own one of its addresses. Then it is purged for good, and its username is never given out again.`} | |
| 75 | + | /> | |
| 76 | + | </div> | |
| 77 | + | <div className="mt-6 space-y-3"> | |
| 78 | + | {done && <Notice tone="ok">{done}</Notice>} | |
| 79 | + | {error && <Notice tone="error">Identity did not answer: {error}</Notice>} | |
| 80 | + | </div> | |
| 81 | + | {accounts.length === 0 ? ( | |
| 82 | + | <div className="mt-6"> | |
| 83 | + | <EmptyState title="No deleted accounts">Accounts appear here when they are deleted, until they are purged.</EmptyState> | |
| 84 | + | </div> | |
| 85 | + | ) : ( | |
| 86 | + | <ul className="mt-6 space-y-3"> | |
| 87 | + | {accounts.map((account) => ( | |
| 88 | + | <DeletedRow key={account.userId} account={account} now={now} error={errorFor(account.userId)} /> | |
| 89 | + | ))} | |
| 90 | + | </ul> | |
| 91 | + | )} | |
| 92 | + | </main> | |
| 93 | + | ); | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | function DeletedRow({ account, now, error }: { account: DeletedAccount; now: number; error: string | null }) { | |
| 97 | + | const left = daysLeft(account.purgeAfter, now); | |
| 98 | + | return ( | |
| 99 | + | <li className="rounded-lg border border-line bg-surface p-4 sm:p-5"> | |
| 100 | + | <div className="flex flex-wrap items-start justify-between gap-3"> | |
| 101 | + | <div className="min-w-0"> | |
| 102 | + | <p className="font-medium"> | |
| 103 | + | <Link to={`/users/${account.username}`} className="font-mono hover:underline"> | |
| 104 | + | {account.username} | |
| 105 | + | </Link> | |
| 106 | + | </p> | |
| 107 | + | <p className="mt-1 text-sm text-muted"> | |
| 108 | + | Deleted by <span className="text-fg-soft">{account.went.staff ?? "the person"}</span>{" "} | |
| 109 | + | <When at={account.deletedAt} time /> | |
| 110 | + | {" · "}purged <When at={account.purgeAfter} time /> | |
| 111 | + | </p> | |
| 112 | + | {account.went.reason && <p className="mt-1 text-sm text-muted">Reason: {account.went.reason}</p>} | |
| 113 | + | <p className="mt-1 text-sm text-muted">Left: {accountWentSummary(account.went)}</p> | |
| 114 | + | </div> | |
| 115 | + | <div className="flex flex-wrap gap-1.5"> | |
| 116 | + | {account.restorable ? ( | |
| 117 | + | <Badge tone="warn"> | |
| 118 | + | {left} day{left === 1 ? "" : "s"} left | |
| 119 | + | </Badge> | |
| 120 | + | ) : ( | |
| 121 | + | <Badge tone="danger">Being purged</Badge> | |
| 122 | + | )} | |
| 123 | + | </div> | |
| 124 | + | </div> | |
| 125 | + | {error && ( | |
| 126 | + | <div className="mt-3"> | |
| 127 | + | <Notice tone="error">{error}</Notice> | |
| 128 | + | </div> | |
| 129 | + | )} | |
| 130 | + | <div className="mt-4 flex flex-col gap-3 border-t border-line pt-4 sm:flex-row sm:items-end sm:justify-between"> | |
| 131 | + | <form method="post"> | |
| 132 | + | <input type="hidden" name="intent" value="restore" /> | |
| 133 | + | <input type="hidden" name="id" value={account.userId} /> | |
| 134 | + | <input type="hidden" name="username" value={account.username} /> | |
| 135 | + | <Button type="submit" variant="lavender" disabled={!account.restorable}> | |
| 136 | + | Restore | |
| 137 | + | </Button> | |
| 138 | + | </form> | |
| 139 | + | <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end"> | |
| 140 | + | <input type="hidden" name="intent" value="purge" /> | |
| 141 | + | <input type="hidden" name="id" value={account.userId} /> | |
| 142 | + | <input type="hidden" name="username" value={account.username} /> | |
| 143 | + | <label className="grid gap-1 text-xs text-muted"> | |
| 144 | + | <span> | |
| 145 | + | Type <span className="font-mono text-fg">{account.username}</span> to purge it now | |
| 146 | + | </span> | |
| 147 | + | <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" aria-label={`Type ${account.username} to purge it now`} /> | |
| 148 | + | </label> | |
| 149 | + | <Button type="submit" variant="danger"> | |
| 150 | + | Purge now | |
| 151 | + | </Button> | |
| 152 | + | </form> | |
| 153 | + | </div> | |
| 154 | + | </li> | |
| 155 | + | ); | |
| 156 | + | } |
| 1 | 1 | import { ArrowLeft } from "lucide-react"; | |
| 2 | 2 | import { Form, Link, data, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import { securityEventLabel } from "@g1t/contracts"; | |
| 4 | + | import { ACCOUNT_RESTORE_DAYS, type AdminUser, securityEventLabel } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/user"; | |
| 7 | 7 | import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, When } from "~/components/ui"; | |
| 8 | + | import { accountWentSummary, confirmsUsername, staffDeletionRefusal } from "~/lib/deleted-accounts"; | |
| 9 | + | import { daysLeft } from "~/lib/deleted-workspaces"; | |
| 10 | + | import { text } from "~/lib/forms"; | |
| 8 | 11 | import { accountsAdmin } from "~/lib/services.server"; | |
| 9 | 12 | import { settle } from "~/lib/settle"; | |
| 10 | 13 | import { requireStaff } from "~/lib/staff"; | |
| ⋯ | |||
| 18 | 21 | requireStaff(context); | |
| 19 | 22 | const result = await settle(accountsAdmin.user(params.username)); | |
| 20 | 23 | if (result.ok && !result.value) throw data("No such account.", { status: 404 }); | |
| 24 | + | const url = new URL(request.url); | |
| 25 | + | const done = url.searchParams.get("done"); | |
| 21 | 26 | return { | |
| 22 | 27 | user: result.ok ? result.value : null, | |
| 23 | 28 | error: result.ok ? null : result.error, | |
| 24 | − | removed: new URL(request.url).searchParams.get("removed"), | |
| 29 | + | removed: url.searchParams.get("removed"), | |
| 30 | + | done: done === "deleted" ? "Deleted the account." : done === "restored" ? "Restored the account." : null, | |
| 31 | + | now: Date.now(), | |
| 25 | 32 | }; | |
| 26 | 33 | } | |
| 27 | 34 | ||
| 28 | − | /** Removes an address: the reason is required, recorded and shown to the person. */ | |
| 35 | + | /** | |
| 36 | + | * Removes an address (the reason is required, recorded and shown to the | |
| 37 | + | * person), or deletes, restores or purges the account. Identity checks | |
| 38 | + | * each again: protection, the workspaces it owns alone, the typed | |
| 39 | + | * username, the restore window. | |
| 40 | + | */ | |
| 29 | 41 | export async function action({ params, request, context }: Route.ActionArgs) { | |
| 30 | 42 | const staff = requireStaff(context); | |
| 31 | 43 | const form = await request.formData(); | |
| 44 | + | const intent = text(form, "intent"); | |
| 45 | + | const back = (done: string) => redirect(`/users/${encodeURIComponent(params.username)}?done=${done}`); | |
| 46 | + | if (intent === "delete-account") { | |
| 47 | + | const reason = text(form, "reason"); | |
| 48 | + | const confirm = text(form, "confirm"); | |
| 49 | + | if (!reason) return data({ error: "Say why the account is being deleted.", account: true }, { status: 422 }); | |
| 50 | + | if (!confirmsUsername(params.username, confirm)) { | |
| 51 | + | return data({ error: `Type ${params.username} to confirm.`, account: true }, { status: 422 }); | |
| 52 | + | } | |
| 53 | + | const result = await accountsAdmin.deleteAccount(params.username, reason, confirm, staff.email); | |
| 54 | + | if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 }); | |
| 55 | + | throw back("deleted"); | |
| 56 | + | } | |
| 57 | + | if (intent === "restore-account") { | |
| 58 | + | const result = await accountsAdmin.restoreAccount(text(form, "id"), staff.email); | |
| 59 | + | if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 }); | |
| 60 | + | throw back("restored"); | |
| 61 | + | } | |
| 62 | + | if (intent === "purge-account") { | |
| 63 | + | const confirm = text(form, "confirm"); | |
| 64 | + | if (!confirmsUsername(params.username, confirm)) { | |
| 65 | + | return data({ error: `Type ${params.username} to confirm.`, account: true }, { status: 422 }); | |
| 66 | + | } | |
| 67 | + | const result = await accountsAdmin.purgeAccount(text(form, "id"), staff.email, confirm); | |
| 68 | + | if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 }); | |
| 69 | + | throw redirect(`/users/deleted?done=purged&username=${encodeURIComponent(params.username)}`); | |
| 70 | + | } | |
| 32 | 71 | const email = String(form.get("email") ?? "").trim(); | |
| 33 | 72 | const reason = String(form.get("reason") ?? "").trim(); | |
| 34 | 73 | if (!reason) return data({ error: "Say why. The person sees the reason in their security log.", email }, { status: 422 }); | |
| ⋯ | |||
| 38 | 77 | } | |
| 39 | 78 | ||
| 40 | 79 | export default function User({ loaderData, actionData }: Route.ComponentProps) { | |
| 41 | − | const { user, error, removed } = loaderData; | |
| 80 | + | const { user, error, removed, done, now } = loaderData; | |
| 81 | + | const accountError = actionData && "account" in actionData ? actionData.error : null; | |
| 42 | 82 | if (!user) { | |
| 43 | 83 | return ( | |
| 44 | 84 | <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10"> | |
| ⋯ | |||
| 52 | 92 | <ArrowLeft size={14} /> Workspaces | |
| 53 | 93 | </Link> | |
| 54 | 94 | <PageHeader | |
| 55 | − | title={user.username} | |
| 95 | + | title={ | |
| 96 | + | <span className="flex flex-wrap items-center gap-2"> | |
| 97 | + | {user.username} | |
| 98 | + | {user.deleted && <Badge tone="danger">Deleted</Badge>} | |
| 99 | + | {user.deletion.protected && <Badge tone="info">Protected</Badge>} | |
| 100 | + | </span> | |
| 101 | + | } | |
| 56 | 102 | description={ | |
| 57 | 103 | <> | |
| 58 | 104 | Account <span className="font-mono">{user.id}</span>, made <When at={user.createdAt} />.{" "} | |
| ⋯ | |||
| 65 | 111 | <Notice tone="ok">Removed {removed}. The person was told, with the reason.</Notice> | |
| 66 | 112 | </div> | |
| 67 | 113 | )} | |
| 114 | + | {done && ( | |
| 115 | + | <div className="mt-5"> | |
| 116 | + | <Notice tone="ok">{done}</Notice> | |
| 117 | + | </div> | |
| 118 | + | )} | |
| 68 | 119 | ||
| 69 | 120 | <Section | |
| 70 | 121 | id="emails" | |
| ⋯ | |||
| 100 | 151 | Remove | |
| 101 | 152 | </Button> | |
| 102 | 153 | </Form> | |
| 103 | − | {actionData?.email === email.email && actionData.error && <Notice tone="error">{actionData.error}</Notice>} | |
| 154 | + | {actionData && "email" in actionData && actionData.email === email.email && actionData.error && <Notice tone="error">{actionData.error}</Notice>} | |
| 104 | 155 | </li> | |
| 105 | 156 | ))} | |
| 106 | 157 | </ul> | |
| ⋯ | |||
| 131 | 182 | </ul> | |
| 132 | 183 | )} | |
| 133 | 184 | </Section> | |
| 185 | + | ||
| 186 | + | <AccountSection user={user} error={accountError} now={now} /> | |
| 134 | 187 | </main> | |
| 135 | 188 | ); | |
| 136 | 189 | } | |
| 190 | + | ||
| 191 | + | /** | |
| 192 | + | * Deleting the account, or, once it is deleted, restoring or purging it. | |
| 193 | + | * Every form is plain HTML: sudo ships no JavaScript. | |
| 194 | + | */ | |
| 195 | + | function AccountSection({ user, error, now }: { user: AdminUser; error: string | null; now: number }) { | |
| 196 | + | const deleted = user.deleted; | |
| 197 | + | if (deleted) { | |
| 198 | + | const left = daysLeft(deleted.purgeAfter, now); | |
| 199 | + | return ( | |
| 200 | + | <Section | |
| 201 | + | id="account" | |
| 202 | + | title="Deleted account" | |
| 203 | + | description={`Deleted ${deleted.went.staff ? `by ${deleted.went.staff}` : "by the person"}. Kept ${ACCOUNT_RESTORE_DAYS} days for a restore, then purged.`} | |
| 204 | + | className="mt-6" | |
| 205 | + | > | |
| 206 | + | <div className="space-y-3 text-sm"> | |
| 207 | + | <p className="flex flex-wrap items-center gap-2 text-muted"> | |
| 208 | + | <span> | |
| 209 | + | Deleted <When at={deleted.deletedAt} time /> · purged <When at={deleted.purgeAfter} time /> | |
| 210 | + | </span> | |
| 211 | + | {deleted.restorable ? ( | |
| 212 | + | <Badge tone="warn"> | |
| 213 | + | {left} day{left === 1 ? "" : "s"} left | |
| 214 | + | </Badge> | |
| 215 | + | ) : ( | |
| 216 | + | <Badge tone="danger">Being purged</Badge> | |
| 217 | + | )} | |
| 218 | + | </p> | |
| 219 | + | {deleted.went.reason && <p className="text-muted">Reason: {deleted.went.reason}</p>} | |
| 220 | + | <p className="text-muted">Left: {accountWentSummary(deleted.went)}</p> | |
| 221 | + | {error && <Notice tone="error">{error}</Notice>} | |
| 222 | + | <div className="flex flex-col gap-3 border-t border-line pt-4 sm:flex-row sm:items-end sm:justify-between"> | |
| 223 | + | <form method="post"> | |
| 224 | + | <input type="hidden" name="intent" value="restore-account" /> | |
| 225 | + | <input type="hidden" name="id" value={deleted.userId} /> | |
| 226 | + | <Button type="submit" variant="lavender" disabled={!deleted.restorable}> | |
| 227 | + | Restore | |
| 228 | + | </Button> | |
| 229 | + | </form> | |
| 230 | + | {user.deletion.protected ? ( | |
| 231 | + | <p className="text-muted">Protected: it can never be purged.</p> | |
| 232 | + | ) : ( | |
| 233 | + | <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end"> | |
| 234 | + | <input type="hidden" name="intent" value="purge-account" /> | |
| 235 | + | <input type="hidden" name="id" value={deleted.userId} /> | |
| 236 | + | <label className="grid gap-1 text-xs text-muted"> | |
| 237 | + | <span> | |
| 238 | + | Type <span className="font-mono text-fg">{user.username}</span> to purge it now | |
| 239 | + | </span> | |
| 240 | + | <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" /> | |
| 241 | + | </label> | |
| 242 | + | <Button type="submit" variant="danger"> | |
| 243 | + | Purge now | |
| 244 | + | </Button> | |
| 245 | + | </form> | |
| 246 | + | )} | |
| 247 | + | </div> | |
| 248 | + | </div> | |
| 249 | + | </Section> | |
| 250 | + | ); | |
| 251 | + | } | |
| 252 | + | const refusal = staffDeletionRefusal(user.deletion); | |
| 253 | + | return ( | |
| 254 | + | <Section | |
| 255 | + | id="account" | |
| 256 | + | title="Delete account" | |
| 257 | + | description={`Signs it out everywhere, ends its tokens and keys, and takes it out of every workspace. Kept ${ACCOUNT_RESTORE_DAYS} days for a restore, then purged; its username is never given out again. Only when the person asks, or for abuse, with a reason.`} | |
| 258 | + | className="mt-6" | |
| 259 | + | > | |
| 260 | + | {refusal ? ( | |
| 261 | + | <div className="space-y-3 text-sm"> | |
| 262 | + | <Notice tone="warn">{refusal}</Notice> | |
| 263 | + | {user.deletion.sole_owner_of.length > 0 && ( | |
| 264 | + | <ul className="divide-y divide-line rounded-md border border-line"> | |
| 265 | + | {user.deletion.sole_owner_of.map((workspace) => ( | |
| 266 | + | <li key={workspace.slug} className="flex flex-wrap items-center justify-between gap-2 px-4 py-2"> | |
| 267 | + | <Link to={`/workspaces/${workspace.slug}`} className="text-fg hover:underline"> | |
| 268 | + | {workspace.name} <span className="font-mono text-xs text-muted">{workspace.slug}</span> | |
| 269 | + | </Link> | |
| 270 | + | <span className="text-xs text-faint"> | |
| 271 | + | {workspace.members} member{workspace.members === 1 ? "" : "s"} | |
| 272 | + | </span> | |
| 273 | + | </li> | |
| 274 | + | ))} | |
| 275 | + | </ul> | |
| 276 | + | )} | |
| 277 | + | </div> | |
| 278 | + | ) : ( | |
| 279 | + | <details className="rounded-md border border-danger/30 px-3 py-2" open={Boolean(error)}> | |
| 280 | + | <summary className="cursor-pointer text-sm text-danger">Delete this account</summary> | |
| 281 | + | <form method="post" className="mt-3 grid gap-3 sm:max-w-md"> | |
| 282 | + | <input type="hidden" name="intent" value="delete-account" /> | |
| 283 | + | <Field label="Reason (kept in sudo's audit log)"> | |
| 284 | + | <Input name="reason" required maxLength={200} placeholder="The person asked from their primary address" /> | |
| 285 | + | </Field> | |
| 286 | + | <Field label={`Type ${user.username} to confirm`}> | |
| 287 | + | <Input name="confirm" required autoComplete="off" spellCheck={false} className="font-mono" /> | |
| 288 | + | </Field> | |
| 289 | + | {error && <Notice tone="error">{error}</Notice>} | |
| 290 | + | <div> | |
| 291 | + | <Button type="submit" variant="danger"> | |
| 292 | + | Delete account | |
| 293 | + | </Button> | |
| 294 | + | </div> | |
| 295 | + | </form> | |
| 296 | + | </details> | |
| 297 | + | )} | |
| 298 | + | </Section> | |
| 299 | + | ); | |
| 300 | + | } | |
| 100 | 100 | <h1 className="text-2xl font-semibold tracking-tight">Workspaces</h1> | |
| 101 | 101 | <p className="mt-1 text-sm text-muted">Every workspace, who owns it, and how it pays this month.</p> | |
| 102 | 102 | </div> | |
| 103 | − | <Link | |
| 104 | − | to="/workspaces/deleted" | |
| 105 | − | className="rounded-md border border-line px-3 py-1.5 text-sm text-muted hover:border-line-strong hover:text-fg" | |
| 106 | − | > | |
| 107 | − | Deleted workspaces | |
| 108 | − | </Link> | |
| 103 | + | <div className="flex flex-wrap gap-2"> | |
| 104 | + | <Link | |
| 105 | + | to="/workspaces/deleted" | |
| 106 | + | className="rounded-md border border-line px-3 py-1.5 text-sm text-muted hover:border-line-strong hover:text-fg" | |
| 107 | + | > | |
| 108 | + | Deleted workspaces | |
| 109 | + | </Link> | |
| 110 | + | <Link | |
| 111 | + | to="/users/deleted" | |
| 112 | + | className="rounded-md border border-line px-3 py-1.5 text-sm text-muted hover:border-line-strong hover:text-fg" | |
| 113 | + | > | |
| 114 | + | Deleted accounts | |
| 115 | + | </Link> | |
| 116 | + | </div> | |
| 109 | 117 | </div> | |
| 110 | 118 | ||
| 111 | 119 | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4"> |
| 1 | + | import { useState } from "react"; | |
| 2 | + | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { ACCOUNT_RESTORE_DAYS, type AccountDeletion } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { DangerAction } from "./danger-zone"; | |
| 7 | + | import { Button, ErrorText } from "./ui"; | |
| 8 | + | import { | |
| 9 | + | AlertDialog, | |
| 10 | + | AlertDialogCancel, | |
| 11 | + | AlertDialogContent, | |
| 12 | + | AlertDialogDescription, | |
| 13 | + | AlertDialogFooter, | |
| 14 | + | AlertDialogHeader, | |
| 15 | + | AlertDialogTitle, | |
| 16 | + | } from "./ui/alert-dialog"; | |
| 17 | + | import { FieldDescription, FieldLabel, Field as FormField } from "./ui/field"; | |
| 18 | + | import { Input as TextInput } from "./ui/input"; | |
| 19 | + | import { accountDeletionRefusal, confirmsUsername, whatAccountDeletionTakes } from "../lib/account-deletion"; | |
| 20 | + | ||
| 21 | + | /** | |
| 22 | + | * Deleting your account, in one step, typed out to confirm, with your | |
| 23 | + | * password unless you signed in within the last few minutes. Kept for | |
| 24 | + | * `ACCOUNT_RESTORE_DAYS`, when support can restore it. Workspaces you own | |
| 25 | + | * alone are listed instead of the button until they have another owner or | |
| 26 | + | * are deleted. | |
| 27 | + | */ | |
| 28 | + | export function DeleteAccountAction({ | |
| 29 | + | username, | |
| 30 | + | deletion, | |
| 31 | + | hasPassword, | |
| 32 | + | error, | |
| 33 | + | defaultOpen = false, | |
| 34 | + | }: { | |
| 35 | + | username: string; | |
| 36 | + | deletion: AccountDeletion | null; | |
| 37 | + | hasPassword: boolean; | |
| 38 | + | error?: string; | |
| 39 | + | /** Open on first render: for previews of the dialog. */ | |
| 40 | + | defaultOpen?: boolean; | |
| 41 | + | }) { | |
| 42 | + | const [open, setOpen] = useState(Boolean(error) || defaultOpen); | |
| 43 | + | const [confirm, setConfirm] = useState(""); | |
| 44 | + | const navigation = useNavigation(); | |
| 45 | + | const deleting = navigation.state !== "idle" && navigation.formData?.get("intent") === "delete-account"; | |
| 46 | + | const refusal = accountDeletionRefusal(deletion); | |
| 47 | + | const goes = deletion ? whatAccountDeletionTakes(deletion) : []; | |
| 48 | + | if (deletion?.protected) { | |
| 49 | + | return ( | |
| 50 | + | <DangerAction title="Delete your account" action={null}> | |
| 51 | + | <span role="status">{refusal}</span> | |
| 52 | + | </DangerAction> | |
| 53 | + | ); | |
| 54 | + | } | |
| 55 | + | return ( | |
| 56 | + | <DangerAction | |
| 57 | + | title="Delete your account" | |
| 58 | + | action={ | |
| 59 | + | <Button type="button" variant="danger" disabled={Boolean(refusal)} onClick={() => setOpen(true)}> | |
| 60 | + | Delete account | |
| 61 | + | </Button> | |
| 62 | + | } | |
| 63 | + | > | |
| 64 | + | {refusal ? ( | |
| 65 | + | <> | |
| 66 | + | <span role="status">{refusal}</span> | |
| 67 | + | <ul className="mt-3 space-y-2"> | |
| 68 | + | {deletion?.sole_owner_of.map((workspace) => ( | |
| 69 | + | <li key={workspace.slug} className="rounded-lg border border-line bg-surface px-3 py-2"> | |
| 70 | + | <p className="text-fg"> | |
| 71 | + | <span className="font-medium">{workspace.name}</span>{" "} | |
| 72 | + | <span className="font-mono text-xs text-faint">{workspace.slug}</span> | |
| 73 | + | </p> | |
| 74 | + | <p className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs"> | |
| 75 | + | {workspace.members > 1 ? ( | |
| 76 | + | <Link to={`/${workspace.slug}/-/people`} className="text-accent underline-offset-4 hover:underline"> | |
| 77 | + | Make someone else an owner | |
| 78 | + | </Link> | |
| 79 | + | ) : null} | |
| 80 | + | <Link to={`/${workspace.slug}/-/settings`} className="text-accent underline-offset-4 hover:underline"> | |
| 81 | + | Delete the workspace | |
| 82 | + | </Link> | |
| 83 | + | </p> | |
| 84 | + | {workspace.billing ? <p className="mt-1 text-xs text-warn">{workspace.billing}</p> : null} | |
| 85 | + | </li> | |
| 86 | + | ))} | |
| 87 | + | </ul> | |
| 88 | + | </> | |
| 89 | + | ) : ( | |
| 90 | + | <> | |
| 91 | + | You sign out everywhere and leave every workspace at once. Your account is kept for {ACCOUNT_RESTORE_DAYS}{" "} | |
| 92 | + | days, and support can restore it until then. | |
| 93 | + | </> | |
| 94 | + | )} | |
| 95 | + | <AlertDialog | |
| 96 | + | open={open} | |
| 97 | + | onOpenChange={(next) => { | |
| 98 | + | setOpen(next); | |
| 99 | + | setConfirm(""); | |
| 100 | + | }} | |
| 101 | + | > | |
| 102 | + | <AlertDialogContent> | |
| 103 | + | <Form method="post" className="grid gap-4"> | |
| 104 | + | <input type="hidden" name="intent" value="delete-account" /> | |
| 105 | + | <AlertDialogHeader> | |
| 106 | + | <AlertDialogTitle>Delete your account?</AlertDialogTitle> | |
| 107 | + | <AlertDialogDescription> | |
| 108 | + | Everything you sign in with stops working now. For {ACCOUNT_RESTORE_DAYS} days, support can restore | |
| 109 | + | your account; after that it is gone for good. | |
| 110 | + | </AlertDialogDescription> | |
| 111 | + | </AlertDialogHeader> | |
| 112 | + | {goes.length > 0 ? ( | |
| 113 | + | <div className="grid gap-1.5"> | |
| 114 | + | <p className="text-sm font-medium">What goes with it</p> | |
| 115 | + | <ul className="list-disc space-y-1 pl-5 text-sm text-muted"> | |
| 116 | + | {goes.map((line) => ( | |
| 117 | + | <li key={line}>{line}</li> | |
| 118 | + | ))} | |
| 119 | + | </ul> | |
| 120 | + | </div> | |
| 121 | + | ) : null} | |
| 122 | + | <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted"> | |
| 123 | + | <li>You are signed out everywhere. Your access tokens, SSH keys, deploy keys you added and applications stop working.</li> | |
| 124 | + | <li>You leave every workspace, team and repository. Workspaces you share keep everything in them.</li> | |
| 125 | + | <li>Your profile is no longer found, and nobody can add you to anything.</li> | |
| 126 | + | <li> | |
| 127 | + | To get it back within {ACCOUNT_RESTORE_DAYS} days, write to support from one of its addresses. After | |
| 128 | + | that your addresses, keys, profile and settings are removed for good. | |
| 129 | + | </li> | |
| 130 | + | <li> | |
| 131 | + | What you wrote stays where it is: issues, pull requests, comments and reviews show as{" "} | |
| 132 | + | <span className="font-mono text-fg">ghost</span> once it is removed. | |
| 133 | + | </li> | |
| 134 | + | <li> | |
| 135 | + | The username <span className="font-mono text-fg">{username}</span> is never given to anyone else. | |
| 136 | + | </li> | |
| 137 | + | </ul> | |
| 138 | + | <FormField> | |
| 139 | + | <FieldLabel htmlFor="confirm-account"> | |
| 140 | + | Type <span className="font-mono text-fg">{username}</span> to confirm | |
| 141 | + | </FieldLabel> | |
| 142 | + | <TextInput | |
| 143 | + | id="confirm-account" | |
| 144 | + | name="confirm" | |
| 145 | + | value={confirm} | |
| 146 | + | spellCheck={false} | |
| 147 | + | autoCapitalize="off" | |
| 148 | + | autoComplete="off" | |
| 149 | + | onChange={(event) => setConfirm(event.target.value)} | |
| 150 | + | className="font-mono" | |
| 151 | + | /> | |
| 152 | + | </FormField> | |
| 153 | + | {hasPassword ? ( | |
| 154 | + | <FormField> | |
| 155 | + | <FieldLabel htmlFor="confirm-password">Your password</FieldLabel> | |
| 156 | + | <TextInput id="confirm-password" name="password" type="password" autoComplete="current-password" /> | |
| 157 | + | <FieldDescription>Not needed if you signed in within the last 10 minutes.</FieldDescription> | |
| 158 | + | </FormField> | |
| 159 | + | ) : ( | |
| 160 | + | <p className="text-sm text-muted"> | |
| 161 | + | Your account signs in with GitHub only: sign out, sign in with GitHub again, and delete it within 10 | |
| 162 | + | minutes. | |
| 163 | + | </p> | |
| 164 | + | )} | |
| 165 | + | <ErrorText>{error}</ErrorText> | |
| 166 | + | <AlertDialogFooter> | |
| 167 | + | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 168 | + | <Button type="submit" variant="danger" disabled={!confirmsUsername(username, confirm) || deleting}> | |
| 169 | + | {deleting ? "Deleting…" : "Delete account"} | |
| 170 | + | </Button> | |
| 171 | + | </AlertDialogFooter> | |
| 172 | + | </Form> | |
| 173 | + | </AlertDialogContent> | |
| 174 | + | </AlertDialog> | |
| 175 | + | </DangerAction> | |
| 176 | + | ); | |
| 177 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { accountDeletionRefusal, confirmsUsername, whatAccountDeletionTakes } from "./account-deletion.ts"; | |
| 5 | + | ||
| 6 | + | const nothing = { | |
| 7 | + | username: "ada", | |
| 8 | + | workspaces: 0, | |
| 9 | + | tokens: 0, | |
| 10 | + | ssh_keys: 0, | |
| 11 | + | applications: 0, | |
| 12 | + | repositories: 0, | |
| 13 | + | sole_owner_of: [], | |
| 14 | + | protected: false, | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | const sole = (slug: string) => ({ slug, name: slug, members: 3, billing: null }); | |
| 18 | + | ||
| 19 | + | test("the dialog lists only what the account has", () => { | |
| 20 | + | assert.deepEqual(whatAccountDeletionTakes(nothing), []); | |
| 21 | + | assert.deepEqual( | |
| 22 | + | whatAccountDeletionTakes({ ...nothing, workspaces: 2, repositories: 1, tokens: 3, ssh_keys: 1, applications: 2 }), | |
| 23 | + | [ | |
| 24 | + | "Your membership of 2 workspaces", | |
| 25 | + | "Your role on 1 repository you were added to", | |
| 26 | + | "3 access tokens", | |
| 27 | + | "1 SSH key", | |
| 28 | + | "2 connected applications", | |
| 29 | + | ], | |
| 30 | + | ); | |
| 31 | + | assert.deepEqual(whatAccountDeletionTakes({ ...nothing, workspaces: 1, tokens: 1 }), [ | |
| 32 | + | "Your membership of 1 workspace", | |
| 33 | + | "1 access token", | |
| 34 | + | ]); | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | test("owning a workspace alone stands in the way, and names it", () => { | |
| 38 | + | assert.equal(accountDeletionRefusal(null), null); | |
| 39 | + | assert.equal(accountDeletionRefusal({ ...nothing, workspaces: 4 }), null); | |
| 40 | + | assert.equal( | |
| 41 | + | accountDeletionRefusal({ ...nothing, sole_owner_of: [sole("acme")] }), | |
| 42 | + | "You are the only owner of acme. Make someone else an owner of it, or delete it, first.", | |
| 43 | + | ); | |
| 44 | + | assert.equal( | |
| 45 | + | accountDeletionRefusal({ ...nothing, sole_owner_of: [sole("acme"), sole("globex"), sole("initech")] }), | |
| 46 | + | "You are the only owner of acme, globex and initech. Make someone else an owner of each, or delete them, first.", | |
| 47 | + | ); | |
| 48 | + | }); | |
| 49 | + | ||
| 50 | + | test("a protected account says so first", () => { | |
| 51 | + | assert.equal( | |
| 52 | + | accountDeletionRefusal({ ...nothing, username: "g1t", protected: true, sole_owner_of: [sole("acme")] }), | |
| 53 | + | "g1t is protected and can never be deleted.", | |
| 54 | + | ); | |
| 55 | + | }); | |
| 56 | + | ||
| 57 | + | test("only the username itself confirms", () => { | |
| 58 | + | assert.ok(confirmsUsername("ada", " Ada ")); | |
| 59 | + | assert.ok(!confirmsUsername("ada", "")); | |
| 60 | + | assert.ok(!confirmsUsername("ada", " ")); | |
| 61 | + | assert.ok(!confirmsUsername("ada", "ada-l")); | |
| 62 | + | }); |
| 1 | + | /** | |
| 2 | + | * What Settings → Account says about deleting your account: what goes with | |
| 3 | + | * it, why it cannot go yet, and what you type to confirm. Identity decides | |
| 4 | + | * all of it again (`account_deletion.rs`); these say the same words first. | |
| 5 | + | * No Workers or React imports, so it can be tested under Node. | |
| 6 | + | */ | |
| 7 | + | import type { AccountDeletion } from "@g1t/contracts"; | |
| 8 | + | ||
| 9 | + | const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; | |
| 10 | + | ||
| 11 | + | /** `a`, `a and b`, `a, b and c`. */ | |
| 12 | + | function list(items: string[]): string { | |
| 13 | + | if (items.length <= 1) return items.join(""); | |
| 14 | + | return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /** Why the account cannot be deleted, as one sentence, or null. */ | |
| 18 | + | export function accountDeletionRefusal(deletion: AccountDeletion | null): string | null { | |
| 19 | + | if (!deletion) return null; | |
| 20 | + | if (deletion.protected) return `${deletion.username} is protected and can never be deleted.`; | |
| 21 | + | const slugs = deletion.sole_owner_of.map((workspace) => workspace.slug); | |
| 22 | + | if (slugs.length === 1) { | |
| 23 | + | return `You are the only owner of ${slugs[0]}. Make someone else an owner of it, or delete it, first.`; | |
| 24 | + | } | |
| 25 | + | if (slugs.length > 1) { | |
| 26 | + | return `You are the only owner of ${list(slugs)}. Make someone else an owner of each, or delete them, first.`; | |
| 27 | + | } | |
| 28 | + | return null; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /** What deleting the account takes with it, one line each. Nothing it does not have is listed. */ | |
| 32 | + | export function whatAccountDeletionTakes(deletion: AccountDeletion): string[] { | |
| 33 | + | const lines: string[] = []; | |
| 34 | + | if (deletion.workspaces > 0) lines.push(`Your membership of ${plural(deletion.workspaces, "workspace", "workspaces")}`); | |
| 35 | + | if (deletion.repositories > 0) { | |
| 36 | + | lines.push(`Your role on ${plural(deletion.repositories, "repository", "repositories")} you were added to`); | |
| 37 | + | } | |
| 38 | + | if (deletion.tokens > 0) lines.push(plural(deletion.tokens, "access token", "access tokens")); | |
| 39 | + | if (deletion.ssh_keys > 0) lines.push(plural(deletion.ssh_keys, "SSH key", "SSH keys")); | |
| 40 | + | if (deletion.applications > 0) { | |
| 41 | + | lines.push(plural(deletion.applications, "connected application", "connected applications")); | |
| 42 | + | } | |
| 43 | + | return lines; | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /** Whether what was typed confirms the username: the username itself, in any case. */ | |
| 47 | + | export function confirmsUsername(username: string, typed: string): boolean { | |
| 48 | + | const value = typed.trim(); | |
| 49 | + | return value !== "" && value.toLowerCase() === username.trim().toLowerCase(); | |
| 50 | + | } |
| 13 | 13 | | "github" | |
| 14 | 14 | | "applications" | |
| 15 | 15 | | "two-factor" | |
| 16 | − | | "security-log"; | |
| 16 | + | | "security-log" | |
| 17 | + | | "account"; | |
| 17 | 18 | ||
| 18 | 19 | /** Each page's name and what it is for, in the sidebar's order. */ | |
| 19 | 20 | export const ACCOUNT_SETTINGS: Record<AccountSettingsPage, { title: string; about: string }> = { | |
| ⋯ | |||
| 53 | 54 | about: | |
| 54 | 55 | "Changes to your addresses, password, two-factor authentication, keys, tokens and applications, by you or by g1t staff. If you do not recognise one, reset your password.", | |
| 55 | 56 | }, | |
| 57 | + | account: { | |
| 58 | + | title: "Account", | |
| 59 | + | about: "Your username, and deleting your account.", | |
| 60 | + | }, | |
| 56 | 61 | }; | |
| 57 | 62 | ||
| 58 | 63 | /** Where the first page is: `/settings` goes there. */ | |
| 40 | 40 | route("applications", "routes/settings/applications.tsx"), | |
| 41 | 41 | route("two-factor", "routes/settings/two-factor.tsx"), | |
| 42 | 42 | route("security-log", "routes/settings/security-log.tsx"), | |
| 43 | + | route("account", "routes/settings/account.tsx"), | |
| 43 | 44 | ]), | |
| 44 | 45 | // The account menu's header: name, primary email and invites left. | |
| 45 | 46 | route("settings/menu.json", "routes/settings-menu-json.ts"), |
| 1 | + | import { Link, redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/account"; | |
| 4 | + | import { DeleteAccountAction } from "../../components/delete-account"; | |
| 5 | + | import { DangerZone } from "../../components/danger-zone"; | |
| 6 | + | import { githubSignIn } from "../../lib/github.server"; | |
| 7 | + | import { page } from "../../lib/meta"; | |
| 8 | + | import { accounts } from "../../lib/services.server"; | |
| 9 | + | import { assertSameOrigin, clientOf, endSession, requireUser, sessionTokenOf } from "../../lib/session.server"; | |
| 10 | + | ||
| 11 | + | export function meta(args: Route.MetaArgs) { | |
| 12 | + | return page(args, { title: "Account · Settings · g1t" }); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 16 | + | const user = requireUser(context, request); | |
| 17 | + | // What deleting it would take, and anything in the way, shown before | |
| 18 | + | // anyone types: workspaces you own alone, each with what billing needs. | |
| 19 | + | const [deletion, github] = await Promise.all([ | |
| 20 | + | accounts.checkAccountDeletion(user).catch(() => null), | |
| 21 | + | githubSignIn.account(user).catch(() => null), | |
| 22 | + | ]); | |
| 23 | + | return { | |
| 24 | + | username: user.username, | |
| 25 | + | deletion: deletion?.ok ? deletion.value : null, | |
| 26 | + | hasPassword: github?.hasPassword ?? true, | |
| 27 | + | }; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /** | |
| 31 | + | * Deleting the account: identity checks the person, the typed username, | |
| 32 | + | * the proof that it is them, protection and the workspaces they own alone. | |
| 33 | + | * Once it is deleted, this browser is signed out and sent home. | |
| 34 | + | */ | |
| 35 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 36 | + | assertSameOrigin(request); | |
| 37 | + | const user = requireUser(context, request); | |
| 38 | + | const form = await request.formData(); | |
| 39 | + | if (form.get("intent") !== "delete-account") return null; | |
| 40 | + | const password = String(form.get("password") ?? ""); | |
| 41 | + | const result = await accounts.deleteAccount(user, String(form.get("confirm") ?? ""), { | |
| 42 | + | sessionToken: sessionTokenOf(request), | |
| 43 | + | password: password || null, | |
| 44 | + | client: clientOf(request), | |
| 45 | + | }); | |
| 46 | + | if (!result.ok) return { deleteError: result.error.message, reauth: result.error.code === "reauth_required" }; | |
| 47 | + | throw redirect("/", { headers: { "set-cookie": await endSession(request) } }); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | export default function AccountSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 51 | + | const { username, deletion, hasPassword } = loaderData; | |
| 52 | + | return ( | |
| 53 | + | <div className="space-y-8"> | |
| 54 | + | <section aria-labelledby="username-heading"> | |
| 55 | + | <h2 id="username-heading" className="font-medium"> | |
| 56 | + | Username | |
| 57 | + | </h2> | |
| 58 | + | <p className="mt-1 text-sm text-muted"> | |
| 59 | + | You are <span className="font-mono text-fg">{username}</span>. Your profile is at{" "} | |
| 60 | + | <Link to={`/${username}`} className="text-accent underline-offset-4 hover:underline"> | |
| 61 | + | g1t.sh/{username} | |
| 62 | + | </Link> | |
| 63 | + | . | |
| 64 | + | </p> | |
| 65 | + | </section> | |
| 66 | + | <DangerZone> | |
| 67 | + | <DeleteAccountAction | |
| 68 | + | username={username} | |
| 69 | + | deletion={deletion} | |
| 70 | + | hasPassword={hasPassword} | |
| 71 | + | error={actionData?.deleteError} | |
| 72 | + | /> | |
| 73 | + | </DangerZone> | |
| 74 | + | </div> | |
| 75 | + | ); | |
| 76 | + | } |
| 1 | + | //! Deleting an account: what it takes with it, what stands in its way, and | |
| 2 | + | //! how long g1t keeps it. Identity's `account_deletion.rs` does it; these | |
| 3 | + | //! are its arguments and the rules every caller applies the same way. | |
| 4 | + | //! | |
| 5 | + | //! A person deletes their own account from their settings, signed in as | |
| 6 | + | //! themselves, typing their username and proving it is them | |
| 7 | + | //! ([`crate::accounts::Reauth`]); g1t's staff can delete one from sudo with | |
| 8 | + | //! a reason. Neither is possible while the account is the only owner of a | |
| 9 | + | //! live workspace: its owner transfers it or deletes it first. Accounts | |
| 10 | + | //! that run g1t, and the names g1t shows for itself, can never be deleted | |
| 11 | + | //! ([`is_protected_account`]). | |
| 12 | + | //! | |
| 13 | + | //! Deleting is soft first. The account's sessions, tokens, applications, | |
| 14 | + | //! SSH keys, the deploy keys it added and its pending sign-ins end at once; | |
| 15 | + | //! it leaves every workspace, team and repository; its profile is not | |
| 16 | + | //! found and it cannot sign in. Its row is kept, holding its username, for | |
| 17 | + | //! [`ACCOUNT_RESTORE_DAYS`], when staff can restore it. Then it is purged: | |
| 18 | + | //! the row and its personal data go, the username is never given to anyone | |
| 19 | + | //! again, and what it wrote shows as [`GHOST_USERNAME`]. | |
| 20 | + | //! | |
| 21 | + | //! There is no API route for it: only the site and sudo delete accounts. | |
| 22 | + | ||
| 23 | + | use serde::{Deserialize, Serialize}; | |
| 24 | + | ||
| 25 | + | use crate::User; | |
| 26 | + | use crate::accounts::Reauth; | |
| 27 | + | ||
| 28 | + | /// How long a deleted account is kept, for g1t's staff to restore, before | |
| 29 | + | /// it is purged. | |
| 30 | + | pub const ACCOUNT_RESTORE_DAYS: u64 = 30; | |
| 31 | + | ||
| 32 | + | /// Who wrote what a purged account wrote: issues, pull requests, comments, | |
| 33 | + | /// reviews, and commits made with its noreply address. Reserved: nobody may | |
| 34 | + | /// register it. | |
| 35 | + | pub const GHOST_USERNAME: &str = "ghost"; | |
| 36 | + | ||
| 37 | + | /// The account row `ghost` has, so that what pointed at a purged account | |
| 38 | + | /// (a workspace's creator) points somewhere. It can never sign in. | |
| 39 | + | pub const GHOST_ID: &str = "usr_ghost"; | |
| 40 | + | ||
| 41 | + | /// Whether the account `id`, called `username`, can never be deleted: one | |
| 42 | + | /// of the names g1t shows for itself (`g1t`, `g1t-agent`, `ghost`; see | |
| 43 | + | /// [`crate::is_reserved_name`]), or named by id or username in `names` | |
| 44 | + | /// (from [`crate::identity::protected_names`] over identity's | |
| 45 | + | /// `PROTECTED_ACCOUNTS`). | |
| 46 | + | pub fn is_protected_account(names: &[String], id: &str, username: &str) -> bool { | |
| 47 | + | let named = |name: &str| names.iter().any(|protected| protected.eq_ignore_ascii_case(name.trim())); | |
| 48 | + | id == GHOST_ID || crate::is_reserved_name(username) || named(id) || named(username) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Why an account that is protected is not deleted or purged. | |
| 52 | + | pub fn protected_account_refusal(username: &str) -> String { | |
| 53 | + | format!("{username} is protected and can never be deleted.") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// A live workspace the account is the only owner of. Each one stands in | |
| 57 | + | /// the way of deleting the account until it has another owner or is | |
| 58 | + | /// deleted. | |
| 59 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 60 | + | pub struct SoleOwnedWorkspace { | |
| 61 | + | pub slug: String, | |
| 62 | + | pub name: String, | |
| 63 | + | /// Everyone in it, the account included. | |
| 64 | + | pub members: u32, | |
| 65 | + | /// Why billing could not close it yet if it were deleted now, in words | |
| 66 | + | /// for its owner: what deleting it first would need. Null when nothing | |
| 67 | + | /// is owed, and always for staff. | |
| 68 | + | #[serde(default)] | |
| 69 | + | pub billing: Option<String>, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// `check_account_deletion` (takes `UserArgs`, people only) returns | |
| 73 | + | /// `Outcome<AccountDeletion>`: what deleting the account would take with | |
| 74 | + | /// it, and what stands in the way, changing nothing. Nothing does when | |
| 75 | + | /// `sole_owner_of` is empty and it is not `protected`. | |
| 76 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 77 | + | pub struct AccountDeletion { | |
| 78 | + | pub username: String, | |
| 79 | + | /// Live workspaces it is a member or owner of, which it leaves. | |
| 80 | + | pub workspaces: u32, | |
| 81 | + | /// Its personal access tokens, classic and fine-grained. | |
| 82 | + | pub tokens: u32, | |
| 83 | + | pub ssh_keys: u32, | |
| 84 | + | /// Applications signed in as it (OAuth). | |
| 85 | + | pub applications: u32, | |
| 86 | + | /// Repositories it has a role on directly, as an outside collaborator | |
| 87 | + | /// or a member given more. | |
| 88 | + | pub repositories: u32, | |
| 89 | + | /// Workspaces it is the only owner of. | |
| 90 | + | #[serde(default)] | |
| 91 | + | pub sole_owner_of: Vec<SoleOwnedWorkspace>, | |
| 92 | + | /// It can never be deleted, by anyone. | |
| 93 | + | #[serde(default)] | |
| 94 | + | pub protected: bool, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | impl AccountDeletion { | |
| 98 | + | pub fn blocked(&self) -> bool { | |
| 99 | + | self.reason().is_some() | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// Why the account cannot be deleted, as one sentence, or `None`. | |
| 103 | + | pub fn reason(&self) -> Option<String> { | |
| 104 | + | if self.protected { | |
| 105 | + | return Some(protected_account_refusal(&self.username)); | |
| 106 | + | } | |
| 107 | + | sole_owner_refusal(&self.sole_owner_of) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// Why an account that is the only owner of `workspaces` cannot be | |
| 112 | + | /// deleted, naming them, or `None` when it owns none alone. | |
| 113 | + | pub fn sole_owner_refusal(workspaces: &[SoleOwnedWorkspace]) -> Option<String> { | |
| 114 | + | let slugs: Vec<&str> = workspaces.iter().map(|workspace| workspace.slug.as_str()).collect(); | |
| 115 | + | match slugs.as_slice() { | |
| 116 | + | [] => None, | |
| 117 | + | [one] => Some(format!( | |
| 118 | + | "You are the only owner of {one}. Make someone else an owner of it, or delete it, first." | |
| 119 | + | )), | |
| 120 | + | many => Some(format!( | |
| 121 | + | "You are the only owner of {}. Make someone else an owner of each, or delete them, first.", | |
| 122 | + | list(many) | |
| 123 | + | )), | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// `a`, `a and b`, `a, b and c`. | |
| 128 | + | fn list(items: &[&str]) -> String { | |
| 129 | + | match items { | |
| 130 | + | [] => String::new(), | |
| 131 | + | [one] => (*one).to_owned(), | |
| 132 | + | [rest @ .., last] => format!("{} and {last}", rest.join(", ")), | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// Whether what was typed confirms `username`: the username itself, in | |
| 137 | + | /// any case, without the spaces around it. | |
| 138 | + | pub fn confirms_username(username: &str, typed: &str) -> bool { | |
| 139 | + | let typed = typed.trim(); | |
| 140 | + | !typed.is_empty() && typed.eq_ignore_ascii_case(username.trim()) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// `delete_account`: the person deletes their own account. `confirm` is | |
| 144 | + | /// their username typed out; `reauth` is proof it is them. Refused for | |
| 145 | + | /// anyone but the person themselves (never a token's or an agent's), for a | |
| 146 | + | /// protected account, and while they are the only owner of a live | |
| 147 | + | /// workspace. Publishes `user.deleting`. Returns `Outcome<bool>`. | |
| 148 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 149 | + | pub struct DeleteAccountArgs { | |
| 150 | + | pub user: User, | |
| 151 | + | #[serde(default)] | |
| 152 | + | pub confirm: String, | |
| 153 | + | #[serde(default)] | |
| 154 | + | pub reauth: Reauth, | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// What went with a deleted account, counted when it was deleted, and who | |
| 158 | + | /// deleted it when it was staff. | |
| 159 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 160 | + | #[serde(rename_all = "camelCase")] | |
| 161 | + | pub struct AccountWent { | |
| 162 | + | pub workspaces: u32, | |
| 163 | + | pub teams: u32, | |
| 164 | + | pub repositories: u32, | |
| 165 | + | pub tokens: u32, | |
| 166 | + | pub ssh_keys: u32, | |
| 167 | + | /// The staff member who deleted it, by email; null when the person did. | |
| 168 | + | #[serde(default)] | |
| 169 | + | pub staff: Option<String>, | |
| 170 | + | /// Why staff deleted it. | |
| 171 | + | #[serde(default)] | |
| 172 | + | pub reason: Option<String>, | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// An account deleted and kept until `purge_after` for staff to restore. | |
| 176 | + | /// `admin_deleted_accounts` takes no arguments (`{}`) and returns | |
| 177 | + | /// `Vec<DeletedAccount>`, newest first. Staff only. | |
| 178 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 179 | + | #[serde(rename_all = "camelCase")] | |
| 180 | + | pub struct DeletedAccount { | |
| 181 | + | pub user_id: String, | |
| 182 | + | pub username: String, | |
| 183 | + | /// RFC 3339. | |
| 184 | + | pub deleted_at: String, | |
| 185 | + | /// RFC 3339: when it is purged unless restored first. | |
| 186 | + | pub purge_after: String, | |
| 187 | + | pub went: AccountWent, | |
| 188 | + | /// Whether staff can still restore it. | |
| 189 | + | pub restorable: bool, | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// `admin_delete_account`: staff delete an account, with a reason (kept in | |
| 193 | + | /// sudo's audit log and the account's record). `confirm` is the username | |
| 194 | + | /// typed out. Refused for a protected account and while it is the only | |
| 195 | + | /// owner of a live workspace, as for the person. Returns `Outcome<bool>`. | |
| 196 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 197 | + | #[serde(rename_all = "camelCase")] | |
| 198 | + | pub struct AdminDeleteAccountArgs { | |
| 199 | + | pub username: String, | |
| 200 | + | pub reason: String, | |
| 201 | + | #[serde(default)] | |
| 202 | + | pub confirm: String, | |
| 203 | + | /// The staff member, by email. | |
| 204 | + | pub staff: String, | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// `admin_restore_account` and `admin_purge_account`: staff restore a | |
| 208 | + | /// deleted account within [`ACCOUNT_RESTORE_DAYS`], or purge it now. | |
| 209 | + | /// Purging needs `confirm`, the username typed out. Restoring publishes | |
| 210 | + | /// `user.restored`; purging, `user.deleted`. Both return `Outcome<bool>`. | |
| 211 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 212 | + | #[serde(rename_all = "camelCase")] | |
| 213 | + | pub struct AdminDeletedAccountArgs { | |
| 214 | + | pub user_id: String, | |
| 215 | + | pub staff: String, | |
| 216 | + | #[serde(default)] | |
| 217 | + | pub confirm: String, | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | #[cfg(test)] | |
| 221 | + | mod tests { | |
| 222 | + | use super::*; | |
| 223 | + | use crate::identity::protected_names; | |
| 224 | + | ||
| 225 | + | fn sole(slug: &str) -> SoleOwnedWorkspace { | |
| 226 | + | SoleOwnedWorkspace { slug: slug.into(), name: slug.into(), members: 1, billing: None } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | #[test] | |
| 230 | + | fn g1t_ghost_and_named_accounts_are_protected() { | |
| 231 | + | let names = protected_names(Some("usr_keep, Ada")); | |
| 232 | + | for (id, username) in [ | |
| 233 | + | ("usr_1", "g1t"), | |
| 234 | + | ("usr_1", "G1T-Agent"), | |
| 235 | + | ("usr_1", "ghost"), | |
| 236 | + | (GHOST_ID, "anything"), | |
| 237 | + | ("usr_keep", "someone"), | |
| 238 | + | ("usr_2", "ada"), | |
| 239 | + | ] { | |
| 240 | + | assert!(is_protected_account(&names, id, username), "{id} {username}"); | |
| 241 | + | } | |
| 242 | + | assert!(!is_protected_account(&names, "usr_3", "grace")); | |
| 243 | + | assert!(!is_protected_account(&protected_names(None), "usr_3", "ghosts")); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | #[test] | |
| 247 | + | fn the_only_owner_of_a_workspace_is_told_which() { | |
| 248 | + | assert_eq!(sole_owner_refusal(&[]), None); | |
| 249 | + | assert_eq!( | |
| 250 | + | sole_owner_refusal(&[sole("acme")]).unwrap(), | |
| 251 | + | "You are the only owner of acme. Make someone else an owner of it, or delete it, first." | |
| 252 | + | ); | |
| 253 | + | assert_eq!( | |
| 254 | + | sole_owner_refusal(&[sole("acme"), sole("globex"), sole("initech")]).unwrap(), | |
| 255 | + | "You are the only owner of acme, globex and initech. Make someone else an owner of each, or delete them, first." | |
| 256 | + | ); | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | #[test] | |
| 260 | + | fn protection_comes_before_ownership() { | |
| 261 | + | let deletion = AccountDeletion { | |
| 262 | + | username: "g1t".into(), | |
| 263 | + | sole_owner_of: vec![sole("acme")], | |
| 264 | + | protected: true, | |
| 265 | + | ..AccountDeletion::default() | |
| 266 | + | }; | |
| 267 | + | assert_eq!(deletion.reason().unwrap(), "g1t is protected and can never be deleted."); | |
| 268 | + | assert!(deletion.blocked()); | |
| 269 | + | let free = AccountDeletion { username: "ada".into(), ..AccountDeletion::default() }; | |
| 270 | + | assert!(!free.blocked()); | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | #[test] | |
| 274 | + | fn only_the_username_itself_confirms() { | |
| 275 | + | assert!(confirms_username("ada", " Ada ")); | |
| 276 | + | assert!(!confirms_username("ada", "")); | |
| 277 | + | assert!(!confirms_username("ada", "ada-l")); | |
| 278 | + | assert!(!confirms_username("ada", " ")); | |
| 279 | + | } | |
| 280 | + | } |
| 361 | 361 | /// `two_factor_disabled`, `recovery_codes_regenerated`, | |
| 362 | 362 | /// `recovery_code_used`, `token_created`, `token_deleted`, | |
| 363 | 363 | /// `token_rescoped`, `ssh_key_added`, `ssh_key_removed`, | |
| 364 | − | /// `oauth_grant_created`, `oauth_grant_revoked` or | |
| 365 | − | /// `oauth_grant_rescoped`. | |
| 364 | + | /// `oauth_grant_created`, `oauth_grant_revoked`, | |
| 365 | + | /// `oauth_grant_rescoped`, `account_deleted` or `account_restored` | |
| 366 | + | /// (seen by staff while the account waits to be purged). | |
| 366 | 367 | pub kind: String, | |
| 367 | 368 | /// The address concerned, or what changed. | |
| 368 | 369 | pub detail: Option<String>, | |
| ⋯ | |||
| 500 | 501 | pub emails: Vec<AccountEmail>, | |
| 501 | 502 | pub private_email: bool, | |
| 502 | 503 | pub log: Vec<SecurityEvent>, | |
| 504 | + | /// What deleting it would take, and what stands in the way (its | |
| 505 | + | /// workspaces' billing is not asked for staff). | |
| 506 | + | #[serde(default)] | |
| 507 | + | pub deletion: crate::account_deletion::AccountDeletion, | |
| 508 | + | /// Set while it is deleted and not yet purged. | |
| 509 | + | #[serde(default)] | |
| 510 | + | pub deleted: Option<crate::account_deletion::DeletedAccount>, | |
| 503 | 511 | } | |
| 504 | 512 | ||
| 505 | 513 | /// `admin_remove_email`: staff remove an address from an account, such as | |
| 810 | 810 | pub slug: String, | |
| 811 | 811 | } | |
| 812 | 812 | ||
| 813 | + | /// `user.deleting`: an account was deleted, by the person or by g1t's | |
| 814 | + | /// staff, and can be restored by staff until `purge_after`. Its sessions, | |
| 815 | + | /// tokens and keys have ended and it has left every workspace; services | |
| 816 | + | /// stop what they do for it (search drops its profile, nothing more is | |
| 817 | + | /// sent to it) and keep their rows. `user.restored` undoes that; once | |
| 818 | + | /// `purge_after` passes, `user.deleted` follows. | |
| 819 | + | #[derive(Clone, Debug, Serialize, serde::Deserialize)] | |
| 820 | + | #[serde(rename_all = "camelCase")] | |
| 821 | + | pub struct UserDeleting { | |
| 822 | + | pub user_id: String, | |
| 823 | + | pub username: String, | |
| 824 | + | /// Whether g1t's staff deleted it rather than the person. | |
| 825 | + | #[serde(default)] | |
| 826 | + | pub by_staff: bool, | |
| 827 | + | /// RFC 3339: when it is purged unless restored first. | |
| 828 | + | pub purge_after: String, | |
| 829 | + | } | |
| 830 | + | ||
| 831 | + | /// `user.restored`: staff brought a deleted account back. It signs in | |
| 832 | + | /// again with its password; its old sessions and tokens stay ended. | |
| 833 | + | /// Services undo what they did on `user.deleting`. | |
| 834 | + | #[derive(Clone, Debug, Serialize, serde::Deserialize)] | |
| 835 | + | #[serde(rename_all = "camelCase")] | |
| 836 | + | pub struct UserRestored { | |
| 837 | + | pub user_id: String, | |
| 838 | + | pub username: String, | |
| 839 | + | } | |
| 840 | + | ||
| 841 | + | /// `user.deleted`: an account is gone for good. Services drop what they | |
| 842 | + | /// keep for it alone (its inbox, subscriptions, settings) and show what it | |
| 843 | + | /// wrote as `ghost` (`account_deletion::GHOST_USERNAME`, id | |
| 844 | + | /// `account_deletion::GHOST_ID`). Ledgers, invoices and audit logs keep | |
| 845 | + | /// its username. The username is never given to anyone again. | |
| 846 | + | #[derive(Clone, Debug, Serialize, serde::Deserialize)] | |
| 847 | + | #[serde(rename_all = "camelCase")] | |
| 848 | + | pub struct UserDeleted { | |
| 849 | + | pub user_id: String, | |
| 850 | + | pub username: String, | |
| 851 | + | } | |
| 852 | + | ||
| 813 | 853 | /// `user.updated`: an account was made, or changed what its profile shows | |
| 814 | 854 | /// (name, bio, avatar). Nothing private: ask identity for the profile. | |
| 815 | 855 | #[derive(Debug, Serialize, serde::Deserialize)] |
| 6 | 6 | ||
| 7 | 7 | pub mod about; | |
| 8 | 8 | pub mod access; | |
| 9 | + | pub mod account_deletion; | |
| 9 | 10 | pub mod accounts; | |
| 10 | 11 | pub mod actions; | |
| 11 | 12 | pub mod agents; |
| 48 | 48 | "notifications", | |
| 49 | 49 | ]; | |
| 50 | 50 | ||
| 51 | − | /// g1t itself, and the name its agent once went by: everything g1t does is | |
| 52 | − | /// shown as `g1t`, so nobody else may be called either. Not routes: staff | |
| 53 | − | /// may point one at a workspace as an alias (identity's `aliases.rs`). | |
| 54 | − | const OWN: &[&str] = &["g1t", "g1t-agent"]; | |
| 51 | + | /// g1t itself, the name its agent once went by, and `ghost`, who wrote what | |
| 52 | + | /// a deleted account wrote (`account_deletion::GHOST_USERNAME`): everything | |
| 53 | + | /// g1t does is shown as `g1t`, so nobody else may be called any of these. | |
| 54 | + | /// Not routes: staff may point one at a workspace as an alias (identity's | |
| 55 | + | /// `aliases.rs`). | |
| 56 | + | const OWN: &[&str] = &["g1t", "g1t-agent", "ghost"]; | |
| 55 | 57 | ||
| 56 | 58 | /// Whether `value`, whatever its case, is a name nobody can register or | |
| 57 | 59 | /// rename a workspace to: a route, or g1t's own. | |
| ⋯ | |||
| 128 | 130 | } | |
| 129 | 131 | assert!(!is_valid_namespace("g1t")); | |
| 130 | 132 | assert!(!is_valid_namespace("g1t-agent")); | |
| 133 | + | assert!(!is_valid_namespace("ghost")); | |
| 134 | + | assert_eq!(claimable_namespace(" Ghost "), None); | |
| 131 | 135 | } | |
| 132 | 136 | ||
| 133 | 137 | #[test] | |
| 489 | 489 | } | |
| 490 | 490 | } | |
| 491 | 491 | ||
| 492 | + | /// What a service does when an account is purged (`user.deleted`): drop | |
| 493 | + | /// what it keeps for the account alone, and show what it wrote as `ghost`. | |
| 494 | + | pub mod user_deleted { | |
| 495 | + | use g1t_contracts::events::{Event, UserDeleted}; | |
| 496 | + | use worker::wasm_bindgen::JsValue; | |
| 497 | + | use worker::{D1Database, Result}; | |
| 498 | + | ||
| 499 | + | /// What each statement is given: the account's id as `?1`, and its | |
| 500 | + | /// username (lowercase) as `?2` when the statement names `?2`. | |
| 501 | + | pub fn binds<'a>(sql: &str, user_id: &'a str, username: &'a str) -> Vec<&'a str> { | |
| 502 | + | let mut binds = vec![user_id]; | |
| 503 | + | if sql.contains("?2") { | |
| 504 | + | binds.push(username); | |
| 505 | + | } | |
| 506 | + | binds | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | /// Handles `user.deleted` with `statements` in one batch; says whether | |
| 510 | + | /// `event` was one. Running them again changes nothing. | |
| 511 | + | pub async fn on_event(db: &D1Database, event: &Event, statements: &[&str]) -> Result<bool> { | |
| 512 | + | if event.kind != "user.deleted" { | |
| 513 | + | return Ok(false); | |
| 514 | + | } | |
| 515 | + | let Ok(deleted) = serde_json::from_value::<UserDeleted>(event.data.clone()) else { | |
| 516 | + | worker::console_error!("user.deleted {} could not be read", event.id); | |
| 517 | + | return Ok(true); | |
| 518 | + | }; | |
| 519 | + | let username = deleted.username.to_lowercase(); | |
| 520 | + | if deleted.user_id.is_empty() || username.is_empty() || statements.is_empty() { | |
| 521 | + | return Ok(true); | |
| 522 | + | } | |
| 523 | + | let mut batch = Vec::with_capacity(statements.len()); | |
| 524 | + | for sql in statements { | |
| 525 | + | let values: Vec<JsValue> = binds(sql, &deleted.user_id, &username).into_iter().map(JsValue::from).collect(); | |
| 526 | + | batch.push(db.prepare(*sql).bind(&values)?); | |
| 527 | + | } | |
| 528 | + | db.batch(batch).await?; | |
| 529 | + | Ok(true) | |
| 530 | + | } | |
| 531 | + | } | |
| 532 | + | ||
| 492 | 533 | /// Dropping what a service keeps for a workspace alone when the workspace | |
| 493 | 534 | /// is deleted. | |
| 494 | 535 | pub mod deleted { |
| 1 | + | /** | |
| 2 | + | * Deleting an account. Mirrors `crates/contracts/src/account_deletion.rs`; | |
| 3 | + | * identity's `account_deletion.rs` does it. | |
| 4 | + | * | |
| 5 | + | * A person deletes their own account from Settings, typing their username | |
| 6 | + | * and proving it is them; g1t's staff can delete one from sudo with a | |
| 7 | + | * reason. Neither works while the account is the only owner of a live | |
| 8 | + | * workspace, or for a protected account. It is soft first: everything it | |
| 9 | + | * could sign in with ends at once and it leaves every workspace, and it is | |
| 10 | + | * kept for `ACCOUNT_RESTORE_DAYS` for staff to restore. Then it is purged, | |
| 11 | + | * its username is never given to anyone again, and what it wrote shows as | |
| 12 | + | * `GHOST_USERNAME`. There is no API route for it: the site and sudo only. | |
| 13 | + | */ | |
| 14 | + | ||
| 15 | + | /** How long a deleted account is kept, for g1t's staff to restore, before it is purged. */ | |
| 16 | + | export const ACCOUNT_RESTORE_DAYS = 30; | |
| 17 | + | ||
| 18 | + | /** Who wrote what a purged account wrote. Reserved: nobody may register it. */ | |
| 19 | + | export const GHOST_USERNAME = "ghost"; | |
| 20 | + | ||
| 21 | + | /** `ghost`'s account id. */ | |
| 22 | + | export const GHOST_ID = "usr_ghost"; | |
| 23 | + | ||
| 24 | + | /** A live workspace the account is the only owner of: in the way until it has another owner or is deleted. */ | |
| 25 | + | export type SoleOwnedWorkspace = { | |
| 26 | + | slug: string; | |
| 27 | + | name: string; | |
| 28 | + | /** Everyone in it, the account included. */ | |
| 29 | + | members: number; | |
| 30 | + | /** What billing needs before the workspace itself can be deleted; null when nothing. Never asked for staff. */ | |
| 31 | + | billing: string | null; | |
| 32 | + | }; | |
| 33 | + | ||
| 34 | + | /** What deleting an account takes with it, and what stands in the way. */ | |
| 35 | + | export type AccountDeletion = { | |
| 36 | + | username: string; | |
| 37 | + | /** Live workspaces it is in, which it leaves. */ | |
| 38 | + | workspaces: number; | |
| 39 | + | /** Personal access tokens, classic and fine-grained. */ | |
| 40 | + | tokens: number; | |
| 41 | + | ssh_keys: number; | |
| 42 | + | /** Applications signed in as it. */ | |
| 43 | + | applications: number; | |
| 44 | + | /** Repositories it has a role on directly. */ | |
| 45 | + | repositories: number; | |
| 46 | + | sole_owner_of: SoleOwnedWorkspace[]; | |
| 47 | + | /** It can never be deleted, by anyone. */ | |
| 48 | + | protected: boolean; | |
| 49 | + | }; | |
| 50 | + | ||
| 51 | + | /** What went with a deleted account, counted when it was deleted, and who deleted it when it was staff. */ | |
| 52 | + | export type AccountWent = { | |
| 53 | + | workspaces: number; | |
| 54 | + | teams: number; | |
| 55 | + | repositories: number; | |
| 56 | + | tokens: number; | |
| 57 | + | sshKeys: number; | |
| 58 | + | /** The staff member who deleted it; null when the person did. */ | |
| 59 | + | staff: string | null; | |
| 60 | + | reason: string | null; | |
| 61 | + | }; | |
| 62 | + | ||
| 63 | + | /** An account deleted and kept until `purgeAfter` for staff to restore. */ | |
| 64 | + | export type DeletedAccount = { | |
| 65 | + | userId: string; | |
| 66 | + | username: string; | |
| 67 | + | /** RFC 3339. */ | |
| 68 | + | deletedAt: string; | |
| 69 | + | /** RFC 3339: when it is purged unless restored first. */ | |
| 70 | + | purgeAfter: string; | |
| 71 | + | went: AccountWent; | |
| 72 | + | /** Whether staff can still restore it. */ | |
| 73 | + | restorable: boolean; | |
| 74 | + | }; |
| 2 | 2 | * A person's email addresses and the security of their account, on the | |
| 3 | 3 | * identity service. Mirrors `crates/contracts/src/accounts.rs`. | |
| 4 | 4 | */ | |
| 5 | + | import type { AccountDeletion, DeletedAccount } from "./account-deletion"; | |
| 5 | 6 | import type { ServiceBinding } from "./clients"; | |
| 6 | 7 | import type { User } from "./identity"; | |
| 7 | 8 | import type { Result } from "./result"; | |
| ⋯ | |||
| 118 | 119 | emails: AccountEmail[]; | |
| 119 | 120 | privateEmail: boolean; | |
| 120 | 121 | log: SecurityEvent[]; | |
| 122 | + | /** What deleting it would take, and what stands in the way (billing is not asked for staff). */ | |
| 123 | + | deletion: AccountDeletion; | |
| 124 | + | /** Set while it is deleted and not yet purged. */ | |
| 125 | + | deleted: DeletedAccount | null; | |
| 121 | 126 | }; | |
| 122 | 127 | ||
| 123 | 128 | /** Where an account's two-factor authentication stands. */ | |
| ⋯ | |||
| 174 | 179 | twoFactorDisable(user: User, code: string, reauth: Reauth): Promise<Result<boolean>>; | |
| 175 | 180 | /** New recovery codes, replacing the old ones. Needs `reauth`. */ | |
| 176 | 181 | twoFactorRecoveryCodes(user: User, reauth: Reauth): Promise<Result<{ codes: string[] }>>; | |
| 182 | + | /** What deleting the person's own account would take, and what stands in the way, changing nothing. People only. */ | |
| 183 | + | checkAccountDeletion(user: User): Promise<Result<AccountDeletion>>; | |
| 184 | + | /** | |
| 185 | + | * Deletes the person's own account. `confirm` is their username, typed | |
| 186 | + | * out; needs `reauth`. Refused for a protected account and while they are | |
| 187 | + | * the only owner of a live workspace. Kept `ACCOUNT_RESTORE_DAYS` for | |
| 188 | + | * staff to restore. Publishes `user.deleting`. Not offered by the API. | |
| 189 | + | */ | |
| 190 | + | deleteAccount(user: User, confirm: string, reauth: Reauth): Promise<Result<boolean>>; | |
| 177 | 191 | } | |
| 178 | 192 | ||
| 179 | 193 | /** Staff only, for sudo.g1t.sh. */ | |
| ⋯ | |||
| 181 | 195 | user(username: string): Promise<AdminUser | null>; | |
| 182 | 196 | /** Removes an address with a reason the person sees; never the last confirmed one. */ | |
| 183 | 197 | removeEmail(username: string, email: string, reason: string, staff: string): Promise<Result<AdminUser>>; | |
| 198 | + | /** | |
| 199 | + | * Deletes an account, with the reason and the username typed out. Refused | |
| 200 | + | * for a protected account and while it is the only owner of a live | |
| 201 | + | * workspace. Recorded in sudo's audit log (`account_deleted`). | |
| 202 | + | */ | |
| 203 | + | deleteAccount(username: string, reason: string, confirm: string, staff: string): Promise<Result<boolean>>; | |
| 204 | + | /** Deleted accounts not purged yet, newest first. */ | |
| 205 | + | deletedAccounts(): Promise<DeletedAccount[]>; | |
| 206 | + | /** Brings a deleted account back within its window, with the memberships it left. Publishes `user.restored`. */ | |
| 207 | + | restoreAccount(userId: string, staff: string): Promise<Result<boolean>>; | |
| 208 | + | /** Purges a deleted account now; `confirm` is its username. Publishes `user.deleted`. */ | |
| 209 | + | purgeAccount(userId: string, staff: string, confirm: string): Promise<Result<boolean>>; | |
| 184 | 210 | } | |
| 185 | 211 | ||
| 186 | 212 | async function call<T>(service: ServiceBinding, method: string, args: object): Promise<T> { | |
| ⋯ | |||
| 210 | 236 | twoFactorEnable: (user, code, reauth) => call(identity, "two_factor_enable", { user, code, reauth }), | |
| 211 | 237 | twoFactorDisable: (user, code, reauth) => call(identity, "two_factor_disable", { user, code, reauth }), | |
| 212 | 238 | twoFactorRecoveryCodes: (user, reauth) => call(identity, "two_factor_recovery_codes", { user, reauth }), | |
| 239 | + | checkAccountDeletion: (user) => call(identity, "check_account_deletion", { user }), | |
| 240 | + | deleteAccount: (user, confirm, reauth) => call(identity, "delete_account", { user, confirm, reauth }), | |
| 213 | 241 | }; | |
| 214 | 242 | } | |
| 215 | 243 | ||
| ⋯ | |||
| 217 | 245 | return { | |
| 218 | 246 | user: (username) => call(identity, "admin_user", { username }), | |
| 219 | 247 | removeEmail: (username, email, reason, staff) => call(identity, "admin_remove_email", { username, email, reason, staff }), | |
| 248 | + | deleteAccount: (username, reason, confirm, staff) => call(identity, "admin_delete_account", { username, reason, confirm, staff }), | |
| 249 | + | deletedAccounts: () => call(identity, "admin_deleted_accounts", {}), | |
| 250 | + | restoreAccount: (userId, staff) => call(identity, "admin_restore_account", { userId, staff }), | |
| 251 | + | purgeAccount: (userId, staff, confirm) => call(identity, "admin_purge_account", { userId, staff, confirm }), | |
| 220 | 252 | }; | |
| 221 | 253 | } | |
| 222 | 254 | ||
| ⋯ | |||
| 266 | 298 | return `Revoked ${detail}`; | |
| 267 | 299 | case "oauth_grant_rescoped": | |
| 268 | 300 | return `Changed what ${detail} may do`; | |
| 301 | + | case "account_deleted": | |
| 302 | + | return "Deleted the account"; | |
| 303 | + | case "account_restored": | |
| 304 | + | return "Restored the account"; | |
| 269 | 305 | default: | |
| 270 | 306 | return detail ? `${event.kind}: ${detail}` : event.kind; | |
| 271 | 307 | } | |
| 142 | 142 | "branch.renamed": { repoId: string; from: string; to: string; defaultBranch: boolean }; | |
| 143 | 143 | /** An account was made, or changed what its profile shows. Ask identity for the profile. */ | |
| 144 | 144 | "user.updated": { username: string }; | |
| 145 | + | /** | |
| 146 | + | * An account was deleted, by the person or by g1t's staff; staff can | |
| 147 | + | * restore it until `purgeAfter`. Its sessions, tokens and keys have ended | |
| 148 | + | * and it has left every workspace. Services stop what they do for it and | |
| 149 | + | * keep their rows; `user.restored` undoes that, and `user.deleted` follows | |
| 150 | + | * once `purgeAfter` passes. | |
| 151 | + | */ | |
| 152 | + | "user.deleting": { userId: string; username: string; byStaff: boolean; purgeAfter: string }; | |
| 153 | + | /** Staff brought a deleted account back. Services undo what they did on `user.deleting`. */ | |
| 154 | + | "user.restored": { userId: string; username: string }; | |
| 155 | + | /** | |
| 156 | + | * An account is gone for good. Services drop what they keep for it alone | |
| 157 | + | * and show what it wrote as `ghost` (`GHOST_USERNAME`, `GHOST_ID`). | |
| 158 | + | * Ledgers, invoices and audit logs keep its username, which is never given | |
| 159 | + | * to anyone again. | |
| 160 | + | */ | |
| 161 | + | "user.deleted": { userId: string; username: string }; | |
| 145 | 162 | /** A workspace was made, or its name, description or icon changed. */ | |
| 146 | 163 | "workspace.updated": { workspaceId: string; slug: string }; | |
| 147 | 164 | /** Someone, or g1t staff, made an invite. Never the code or the address. */ |
| 1 | 1 | export * from "./about"; | |
| 2 | 2 | export * from "./access"; | |
| 3 | + | export * from "./account-deletion"; | |
| 3 | 4 | export * from "./accounts"; | |
| 4 | 5 | export * from "./actions"; | |
| 5 | 6 | export * from "./agents"; |
| 8 | 8 | "api", "mcp", "login", "logout", "register", "new", "settings", "search", | |
| 9 | 9 | "admin", "auth", "integrations", "pulls", "issues", "verify", "confirm-email", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets", "avatars", "docs", "explore", "about", "pricing", | |
| 10 | 10 | // g1t itself, and the name its agent once went by: everything g1t does is | |
| 11 | − | // shown as `g1t`, so nobody else may be called either. | |
| 12 | − | "g1t", "g1t-agent", | |
| 11 | + | // shown as `g1t`, so nobody else may be called either. `ghost` wrote what | |
| 12 | + | // a deleted account wrote (`GHOST_USERNAME`). | |
| 13 | + | "g1t", "g1t-agent", "ghost", | |
| 13 | 14 | // Trust pages on g1t.sh, and names kept for them. | |
| 14 | 15 | "policies", "security", "support", "status", "terms", "privacy", "help", "blog", | |
| 15 | 16 | // Invite links, and the waitlist. |
| 1559 | 1559 | // --- Keeping up ------------------------------------------------------------------ | |
| 1560 | 1560 | ||
| 1561 | 1561 | /// Moves rows with renamed workspaces and repositories, and drops those | |
| 1562 | − | /// of purged repositories and deleted workspaces. | |
| 1562 | + | /// of purged repositories, deleted workspaces and purged accounts. | |
| 1563 | 1563 | pub async fn follow(db: &D1Database, events: &[Event]) -> Result<()> { | |
| 1564 | 1564 | let mut statements = Vec::new(); | |
| 1565 | 1565 | for event in events { | |
| ⋯ | |||
| 1626 | 1626 | .bind(&[text("slug").into()])?, | |
| 1627 | 1627 | ); | |
| 1628 | 1628 | } | |
| 1629 | + | // An account purged: its inbox, what it watched and its | |
| 1630 | + | // settings go with it (identity's account_deletion.rs). | |
| 1631 | + | "user.deleted" => { | |
| 1632 | + | let username = text("username"); | |
| 1633 | + | if username.is_empty() { | |
| 1634 | + | continue; | |
| 1635 | + | } | |
| 1636 | + | for sql in [ | |
| 1637 | + | "DELETE FROM inbox_activity WHERE username = ?", | |
| 1638 | + | "DELETE FROM inbox_items WHERE username = ?", | |
| 1639 | + | "DELETE FROM inbox_subscriptions WHERE username = ?", | |
| 1640 | + | "DELETE FROM inbox_watching WHERE username = ?", | |
| 1641 | + | "DELETE FROM inbox_settings WHERE username = ?", | |
| 1642 | + | ] { | |
| 1643 | + | statements.push(db.prepare(sql).bind(&[username.as_str().into()])?); | |
| 1644 | + | } | |
| 1645 | + | } | |
| 1629 | 1646 | _ => {} | |
| 1630 | 1647 | } | |
| 1631 | 1648 | } | |
| 1 | + | -- Deleting an account is soft first. The row stays, with when, by whom and | |
| 2 | + | -- until when g1t's staff can restore it, and every read that resolves a | |
| 3 | + | -- person leaves it out: it cannot sign in, its profile is not found, and | |
| 4 | + | -- nobody can add it to anything. Its sessions, tokens, keys and | |
| 5 | + | -- memberships are removed at once (src/account_deletion.rs). The row keeps | |
| 6 | + | -- the username from anyone else meanwhile. Once purge_after passes, the | |
| 7 | + | -- scheduled purge removes it with its personal data. | |
| 8 | + | -- | |
| 9 | + | -- `deleted_by`: the account itself when the person deleted it; null when | |
| 10 | + | -- staff did (who, and why, are in `deleted_went`). | |
| 11 | + | -- `deleted_went`: JSON, what went with it, counted when it was deleted, | |
| 12 | + | -- and the memberships, teams and repository roles it left, so a restore | |
| 13 | + | -- can put them back. | |
| 14 | + | ALTER TABLE users ADD COLUMN deleted_at TEXT; | |
| 15 | + | ALTER TABLE users ADD COLUMN deleted_by TEXT; | |
| 16 | + | ALTER TABLE users ADD COLUMN purge_after TEXT; | |
| 17 | + | ALTER TABLE users ADD COLUMN deleted_went TEXT; | |
| 18 | + | ||
| 19 | + | CREATE INDEX IF NOT EXISTS users_purge_after ON users (purge_after) WHERE deleted_at IS NOT NULL; | |
| 20 | + | ||
| 21 | + | -- A purged account's username, kept so it is never given to another | |
| 22 | + | -- account or workspace: links, mentions and commits that name it keep | |
| 23 | + | -- meaning what they meant. Nothing personal: the id is random. | |
| 24 | + | CREATE TABLE IF NOT EXISTS deleted_users ( | |
| 25 | + | username TEXT PRIMARY KEY, | |
| 26 | + | user_id TEXT NOT NULL, | |
| 27 | + | deleted_at TEXT NOT NULL, | |
| 28 | + | purged_at TEXT NOT NULL | |
| 29 | + | ); | |
| 30 | + | ||
| 31 | + | -- `ghost`: who wrote what a purged account wrote. A row of its own, so a | |
| 32 | + | -- workspace whose creator is purged still names an account. It has no | |
| 33 | + | -- password and no address, and is marked deleted with no purge time, so it | |
| 34 | + | -- can never sign in, is never listed, and is never purged. `ghost` is a | |
| 35 | + | -- reserved name, so nobody can register it. | |
| 36 | + | INSERT OR IGNORE INTO users (id, username, password_hash, created_at, deleted_at) | |
| 37 | + | VALUES ('usr_ghost', 'ghost', '', '2026-10-08T00:00:00.000Z', '2026-10-08T00:00:00.000Z'); |
| 569 | 569 | } | |
| 570 | 570 | Ok(self | |
| 571 | 571 | .db | |
| 572 | − | .prepare("SELECT id, username FROM users WHERE username = ?") | |
| 572 | + | .prepare("SELECT id, username FROM users WHERE username = ? AND deleted_at IS NULL") | |
| 573 | 573 | .bind(&[username.trim().trim_start_matches('@').to_lowercase().into()])? | |
| 574 | 574 | .first::<Person>(None) | |
| 575 | 575 | .await? |
| 1 | + | //! Deleting an account. | |
| 2 | + | //! | |
| 3 | + | //! A person deletes their own account from their settings: signed in as | |
| 4 | + | //! themselves (never with a token or as an agent), typing their username, | |
| 5 | + | //! and proving it is them ([`Identity::proof`], security.rs). g1t's staff | |
| 6 | + | //! can delete one from sudo, with a reason. Both are refused for a | |
| 7 | + | //! protected account (`g1t`, `g1t-agent`, `ghost`, and whatever | |
| 8 | + | //! `PROTECTED_ACCOUNTS` names, through | |
| 9 | + | //! `g1t_contracts::identity::protected_names`), and while the account is | |
| 10 | + | //! the only owner of any live workspace: its owner makes someone else an | |
| 11 | + | //! owner, or deletes the workspace (deletion.rs, which settles its billing | |
| 12 | + | //! with `close_workspace`), first. Billing is per workspace, so a workspace | |
| 13 | + | //! the account co-owns is someone else's to pay for, and one it owns alone | |
| 14 | + | //! is in the way already. | |
| 15 | + | //! | |
| 16 | + | //! Deleting is soft first, as for a workspace. At once, in one batch: the | |
| 17 | + | //! row gets `deleted_at`, `deleted_by` and `purge_after` | |
| 18 | + | //! ([`ACCOUNT_RESTORE_DAYS`] on); its sessions, access tokens (classic, | |
| 19 | + | //! fine-grained and agents'), OAuth grants and codes, device sign-ins, SSH | |
| 20 | + | //! keys, the deploy keys it added, two-factor sign-ins in progress, emailed | |
| 21 | + | //! links and GitHub sign-ins in progress go; it leaves every workspace, | |
| 22 | + | //! team and repository, its pending repository invitations are revoked and | |
| 23 | + | //! the invites it made and nobody used are revoked. Every read that | |
| 24 | + | //! resolves a person leaves it out from then on: it cannot sign in (the | |
| 25 | + | //! answer is the one any wrong password gets), its profile is not found, | |
| 26 | + | //! nobody can add it to anything, and nothing is emailed to it. Its | |
| 27 | + | //! username stays held by its row. `user.deleting` tells services to stop | |
| 28 | + | //! what they do for it. The memberships, teams and repository roles it | |
| 29 | + | //! left are kept in `deleted_went`, so a restore puts them back. | |
| 30 | + | //! | |
| 31 | + | //! Until `purge_after`, staff can restore it from sudo: the columns are | |
| 32 | + | //! cleared, its memberships come back where their workspace is still | |
| 33 | + | //! there, and `user.restored` tells services. Its old sessions, tokens and | |
| 34 | + | //! keys stay ended; the person signs in again with their password. | |
| 35 | + | //! | |
| 36 | + | //! The purge, by the scheduled sweep or by staff, removes the row, and with | |
| 37 | + | //! it (by cascade and here) its addresses, keys, two-factor secret, GitHub | |
| 38 | + | //! link, security log and profile. Its username goes into `deleted_users`, | |
| 39 | + | //! so it is never given to another account or workspace. A workspace it | |
| 40 | + | //! made names `ghost` as its creator instead. `user.deleted` tells services | |
| 41 | + | //! to drop what they keep for it and show what it wrote as `ghost`. | |
| 42 | + | //! Billing's ledgers and invoices and the audit logs keep its username. | |
| 43 | + | //! | |
| 44 | + | //! There is no API route for any of this: only the site and sudo call it. | |
| 45 | + | ||
| 46 | + | use g1t_contracts::FailureCode; | |
| 47 | + | use g1t_contracts::Outcome; | |
| 48 | + | use g1t_contracts::User; | |
| 49 | + | use g1t_contracts::account_deletion::*; | |
| 50 | + | use g1t_contracts::billing::CloseWorkspaceArgs; | |
| 51 | + | use g1t_contracts::events::{UserDeleted, UserDeleting, UserRestored}; | |
| 52 | + | use g1t_contracts::identity::{UserArgs, protected_names}; | |
| 53 | + | use g1t_contracts::time::rfc3339; | |
| 54 | + | use g1t_kit::now_ms; | |
| 55 | + | use serde::{Deserialize, Serialize}; | |
| 56 | + | use worker::Result; | |
| 57 | + | use worker::wasm_bindgen::JsValue; | |
| 58 | + | ||
| 59 | + | use crate::Identity; | |
| 60 | + | use crate::security::is_person; | |
| 61 | + | ||
| 62 | + | type Refusal = (FailureCode, String); | |
| 63 | + | ||
| 64 | + | /// How many accounts one sweep purges. | |
| 65 | + | const PURGES_PER_SWEEP: u32 = 25; | |
| 66 | + | ||
| 67 | + | pub const PEOPLE_ONLY: &str = "Only you can delete your account, signed in as yourself; never with a token or as an agent."; | |
| 68 | + | ||
| 69 | + | /// When an account deleted at `now_ms` is purged. | |
| 70 | + | pub fn purge_after(now_ms: u64) -> String { | |
| 71 | + | rfc3339(now_ms + ACCOUNT_RESTORE_DAYS * 86_400_000) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// Whether an account to be purged at `purge_after` can still be restored | |
| 75 | + | /// at `now` (both RFC 3339, which compare as text). | |
| 76 | + | pub fn restorable(purge_after: &str, now: &str) -> bool { | |
| 77 | + | now < purge_after | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Whether the person may delete their account, from what is in the way | |
| 81 | + | /// and what they typed. Protection first, then the workspaces they own | |
| 82 | + | /// alone, then the typed username. | |
| 83 | + | pub fn may_delete_own(person: bool, deletion: &AccountDeletion, confirm: &str) -> std::result::Result<(), Refusal> { | |
| 84 | + | if !person { | |
| 85 | + | return Err((FailureCode::Forbidden, PEOPLE_ONLY.to_owned())); | |
| 86 | + | } | |
| 87 | + | may_delete(deletion, confirm) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// Whether an account may be deleted, for the person or staff alike. | |
| 91 | + | pub fn may_delete(deletion: &AccountDeletion, confirm: &str) -> std::result::Result<(), Refusal> { | |
| 92 | + | if deletion.protected { | |
| 93 | + | return Err((FailureCode::Forbidden, protected_account_refusal(&deletion.username))); | |
| 94 | + | } | |
| 95 | + | if let Some(reason) = sole_owner_refusal(&deletion.sole_owner_of) { | |
| 96 | + | return Err((FailureCode::Conflict, reason)); | |
| 97 | + | } | |
| 98 | + | if !confirms_username(&deletion.username, confirm) { | |
| 99 | + | return Err((FailureCode::Invalid, format!("Type {} to confirm.", deletion.username))); | |
| 100 | + | } | |
| 101 | + | Ok(()) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Whether staff may restore a deleted account, now `now`. | |
| 105 | + | pub fn may_restore(username: &str, purge_after: &str, now: &str) -> std::result::Result<(), Refusal> { | |
| 106 | + | if !restorable(purge_after, now) { | |
| 107 | + | return Err(( | |
| 108 | + | FailureCode::Conflict, | |
| 109 | + | format!("{username} is being purged and can no longer be restored."), | |
| 110 | + | )); | |
| 111 | + | } | |
| 112 | + | Ok(()) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Whether a deleted account may be purged, by staff (`confirm` is what | |
| 116 | + | /// they typed) or by the sweep (`None`). Never a protected one. | |
| 117 | + | pub fn may_purge(protected: bool, username: &str, confirm: Option<&str>) -> std::result::Result<(), Refusal> { | |
| 118 | + | if protected { | |
| 119 | + | return Err((FailureCode::Forbidden, protected_account_refusal(username))); | |
| 120 | + | } | |
| 121 | + | if let Some(typed) = confirm | |
| 122 | + | && !confirms_username(username, typed) | |
| 123 | + | { | |
| 124 | + | return Err((FailureCode::Invalid, format!("Type {username} to confirm."))); | |
| 125 | + | } | |
| 126 | + | Ok(()) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// What a deletion ends at once, in the batch that marks the row, each | |
| 130 | + | /// with how many of the account's id (`?1`) and now (`?2`) it takes. | |
| 131 | + | pub fn revoke_statements() -> Vec<(String, usize)> { | |
| 132 | + | let mut sql: Vec<(String, usize)> = vec![ | |
| 133 | + | // A fine-grained token's repositories go with it, before it. | |
| 134 | + | ("DELETE FROM token_repositories WHERE token_id IN (SELECT id FROM access_tokens WHERE user_id = ?1)".to_owned(), 1), | |
| 135 | + | ]; | |
| 136 | + | // Everything it signs in or acts with: sessions, tokens (classic, | |
| 137 | + | // fine-grained, agents'), applications, device sign-ins, SSH keys, | |
| 138 | + | // two-factor sign-ins in progress, emailed links, GitHub sign-ins in | |
| 139 | + | // progress. Then its place in workspaces and teams. | |
| 140 | + | for table in [ | |
| 141 | + | "sessions", | |
| 142 | + | "access_tokens", | |
| 143 | + | "oauth_grants", | |
| 144 | + | "oauth_codes", | |
| 145 | + | "device_codes", | |
| 146 | + | "ssh_keys", | |
| 147 | + | "two_factor_challenges", | |
| 148 | + | "email_tokens", | |
| 149 | + | "github_states", | |
| 150 | + | "workspace_members", | |
| 151 | + | "team_members", | |
| 152 | + | ] { | |
| 153 | + | sql.push((format!("DELETE FROM {table} WHERE user_id = ?1"), 1)); | |
| 154 | + | } | |
| 155 | + | sql.extend([ | |
| 156 | + | // Deploy keys it added to repositories. | |
| 157 | + | ("DELETE FROM deploy_keys WHERE created_by = ?1".to_owned(), 1), | |
| 158 | + | // g1t keeps no GitHub token for it any more. | |
| 159 | + | ("UPDATE github_accounts SET tokens = NULL WHERE user_id = ?1".to_owned(), 1), | |
| 160 | + | ("DELETE FROM repo_grants WHERE principal_kind = 'user' AND principal_id = ?1".to_owned(), 1), | |
| 161 | + | ( | |
| 162 | + | "UPDATE repo_invitations SET revoked_at = ?2 | |
| 163 | + | WHERE invitee_id = ?1 AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL" | |
| 164 | + | .to_owned(), | |
| 165 | + | 2, | |
| 166 | + | ), | |
| 167 | + | // Invites it made that nobody used. | |
| 168 | + | ( | |
| 169 | + | "UPDATE invites SET revoked_at = ?2, sealed_code = NULL | |
| 170 | + | WHERE inviter_id = ?1 AND redeemed_at IS NULL AND revoked_at IS NULL" | |
| 171 | + | .to_owned(), | |
| 172 | + | 2, | |
| 173 | + | ), | |
| 174 | + | ]); | |
| 175 | + | sql | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// What a purge runs, in one batch, each with how many of the account's | |
| 179 | + | /// id (`?1`), username (`?2`), when it was deleted (`?3`) and now (`?4`) it | |
| 180 | + | /// takes. Every statement acts only while the account is still deleted and | |
| 181 | + | /// awaiting its purge, so a restore a moment before wins whole. | |
| 182 | + | pub fn purge_statements() -> Vec<(String, usize)> { | |
| 183 | + | const STILL: &str = "EXISTS (SELECT 1 FROM users WHERE id = ?1 AND deleted_at IS NOT NULL AND purge_after IS NOT NULL)"; | |
| 184 | + | let mut sql = vec![( | |
| 185 | + | format!( | |
| 186 | + | "INSERT OR REPLACE INTO deleted_users (username, user_id, deleted_at, purged_at) | |
| 187 | + | SELECT ?2, ?1, ?3, ?4 WHERE {STILL}" | |
| 188 | + | ), | |
| 189 | + | 4, | |
| 190 | + | )]; | |
| 191 | + | // What names it as its maker or deleter names ghost: a workspace's | |
| 192 | + | // creator must be an account. | |
| 193 | + | for (table, column) in [("workspaces", "created_by"), ("workspaces", "deleted_by"), ("teams", "created_by")] { | |
| 194 | + | sql.push((format!("UPDATE {table} SET {column} = '{GHOST_ID}' WHERE {column} = ?1 AND {STILL}"), 1)); | |
| 195 | + | } | |
| 196 | + | // Its personal data. Most goes by cascade with the row; each is said | |
| 197 | + | // outright so nothing depends on that. | |
| 198 | + | for table in [ | |
| 199 | + | "user_emails", | |
| 200 | + | "github_accounts", | |
| 201 | + | "two_factor", | |
| 202 | + | "two_factor_recovery", | |
| 203 | + | "two_factor_challenges", | |
| 204 | + | "security_events", | |
| 205 | + | "sessions", | |
| 206 | + | "access_tokens", | |
| 207 | + | "oauth_grants", | |
| 208 | + | "oauth_codes", | |
| 209 | + | "device_codes", | |
| 210 | + | "email_tokens", | |
| 211 | + | "ssh_keys", | |
| 212 | + | "workspace_members", | |
| 213 | + | "team_members", | |
| 214 | + | ] { | |
| 215 | + | sql.push((format!("DELETE FROM {table} WHERE user_id = ?1 AND {STILL}"), 1)); | |
| 216 | + | } | |
| 217 | + | sql.push(("DELETE FROM users WHERE id = ?1 AND deleted_at IS NOT NULL AND purge_after IS NOT NULL".to_owned(), 1)); | |
| 218 | + | sql | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | /// A membership the account left, as it was. | |
| 222 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 223 | + | struct Member { | |
| 224 | + | workspace_id: String, | |
| 225 | + | role: String, | |
| 226 | + | #[serde(default)] | |
| 227 | + | billing_manager: u8, | |
| 228 | + | #[serde(default)] | |
| 229 | + | security_manager: u8, | |
| 230 | + | created_at: String, | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | /// A team the account left. | |
| 234 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 235 | + | struct TeamMember { | |
| 236 | + | team_id: String, | |
| 237 | + | role: String, | |
| 238 | + | created_at: String, | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | /// A role on a repository the account had directly. | |
| 242 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 243 | + | struct Grant { | |
| 244 | + | repo_id: String, | |
| 245 | + | workspace_id: String, | |
| 246 | + | repo_name: String, | |
| 247 | + | role: String, | |
| 248 | + | #[serde(default)] | |
| 249 | + | granted_by: Option<String>, | |
| 250 | + | created_at: String, | |
| 251 | + | updated_at: String, | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | /// What `deleted_went` holds. | |
| 255 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 256 | + | struct Snapshot { | |
| 257 | + | #[serde(default)] | |
| 258 | + | went: AccountWent, | |
| 259 | + | #[serde(default)] | |
| 260 | + | memberships: Vec<Member>, | |
| 261 | + | #[serde(default)] | |
| 262 | + | teams: Vec<TeamMember>, | |
| 263 | + | #[serde(default)] | |
| 264 | + | grants: Vec<Grant>, | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | fn snapshot_of(stored: Option<&str>) -> Snapshot { | |
| 268 | + | stored.and_then(|text| serde_json::from_str(text).ok()).unwrap_or_default() | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | /// A deleted account's row. | |
| 272 | + | #[derive(Deserialize)] | |
| 273 | + | struct DeletedRow { | |
| 274 | + | id: String, | |
| 275 | + | username: String, | |
| 276 | + | deleted_at: String, | |
| 277 | + | purge_after: String, | |
| 278 | + | #[serde(default)] | |
| 279 | + | deleted_went: Option<String>, | |
| 280 | + | #[serde(default)] | |
| 281 | + | avatar: Option<String>, | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | impl DeletedRow { | |
| 285 | + | fn listed(&self, now: &str) -> DeletedAccount { | |
| 286 | + | DeletedAccount { | |
| 287 | + | user_id: self.id.clone(), | |
| 288 | + | username: self.username.clone(), | |
| 289 | + | deleted_at: self.deleted_at.clone(), | |
| 290 | + | purge_after: self.purge_after.clone(), | |
| 291 | + | went: snapshot_of(self.deleted_went.as_deref()).went, | |
| 292 | + | restorable: restorable(&self.purge_after, now), | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | /// Deleted accounts awaiting their purge: never `ghost`, whose row has no | |
| 298 | + | /// purge time. | |
| 299 | + | const DELETED_COLUMNS: &str = "id, username, deleted_at, purge_after, deleted_went, avatar | |
| 300 | + | FROM users WHERE deleted_at IS NOT NULL AND purge_after IS NOT NULL"; | |
| 301 | + | ||
| 302 | + | /// A live account, as found by username. | |
| 303 | + | #[derive(Deserialize)] | |
| 304 | + | struct Live { | |
| 305 | + | id: String, | |
| 306 | + | username: String, | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | impl Identity { | |
| 310 | + | /// `PROTECTED_ACCOUNTS`, with what is always protected. | |
| 311 | + | fn protected_account_names(&self) -> Vec<String> { | |
| 312 | + | let configured = self.env.var("PROTECTED_ACCOUNTS").ok().map(|v| v.to_string()); | |
| 313 | + | protected_names(configured.as_deref()) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | /// Whether `user_id` is an account that has not been deleted. | |
| 317 | + | pub(crate) async fn account_live(&self, user_id: &str) -> Result<bool> { | |
| 318 | + | Ok(self | |
| 319 | + | .db | |
| 320 | + | .prepare("SELECT 1 AS live FROM users WHERE id = ? AND deleted_at IS NULL") | |
| 321 | + | .bind(&[user_id.into()])? | |
| 322 | + | .first::<serde_json::Value>(None) | |
| 323 | + | .await? | |
| 324 | + | .is_some()) | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | async fn live_account(&self, column: &str, value: &str) -> Result<Option<Live>> { | |
| 328 | + | self.db | |
| 329 | + | .prepare(format!("SELECT id, username FROM users WHERE {column} = ? AND deleted_at IS NULL")) | |
| 330 | + | .bind(&[value.into()])? | |
| 331 | + | .first::<Live>(None) | |
| 332 | + | .await | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | /// The live workspaces `user_id` is the only owner of. | |
| 336 | + | async fn sole_owned(&self, user_id: &str) -> Result<Vec<SoleOwnedWorkspace>> { | |
| 337 | + | #[derive(Deserialize)] | |
| 338 | + | struct Row { | |
| 339 | + | slug: String, | |
| 340 | + | name: String, | |
| 341 | + | members: u32, | |
| 342 | + | } | |
| 343 | + | let rows = self | |
| 344 | + | .db | |
| 345 | + | .prepare( | |
| 346 | + | "SELECT w.slug, w.name, | |
| 347 | + | (SELECT count(*) FROM workspace_members a WHERE a.workspace_id = w.id) AS members | |
| 348 | + | FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id | |
| 349 | + | WHERE m.user_id = ?1 AND m.role = 'owner' AND w.deleted_at IS NULL | |
| 350 | + | AND NOT EXISTS (SELECT 1 FROM workspace_members o | |
| 351 | + | WHERE o.workspace_id = w.id AND o.role = 'owner' AND o.user_id <> ?1) | |
| 352 | + | ORDER BY w.slug", | |
| 353 | + | ) | |
| 354 | + | .bind(&[user_id.into()])? | |
| 355 | + | .all() | |
| 356 | + | .await? | |
| 357 | + | .results::<Row>()?; | |
| 358 | + | Ok(rows | |
| 359 | + | .into_iter() | |
| 360 | + | .map(|row| SoleOwnedWorkspace { slug: row.slug, name: row.name, members: row.members, billing: None }) | |
| 361 | + | .collect()) | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | /// What deleting the account would take with it, and what stands in | |
| 365 | + | /// the way. With `actor` (the person), billing says for each workspace | |
| 366 | + | /// they own alone what deleting it first would need. | |
| 367 | + | pub(crate) async fn account_deletion_facts(&self, user_id: &str, username: &str, actor: Option<&User>) -> Result<AccountDeletion> { | |
| 368 | + | #[derive(Deserialize)] | |
| 369 | + | struct Counts { | |
| 370 | + | workspaces: u32, | |
| 371 | + | tokens: u32, | |
| 372 | + | ssh_keys: u32, | |
| 373 | + | applications: u32, | |
| 374 | + | repositories: u32, | |
| 375 | + | } | |
| 376 | + | let counts = self | |
| 377 | + | .db | |
| 378 | + | .prepare( | |
| 379 | + | "SELECT | |
| 380 | + | (SELECT count(*) FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id | |
| 381 | + | WHERE m.user_id = ?1 AND w.deleted_at IS NULL) AS workspaces, | |
| 382 | + | (SELECT count(*) FROM access_tokens WHERE user_id = ?1 AND agent_scope IS NULL | |
| 383 | + | AND (expires_at IS NULL OR listed = 1)) AS tokens, | |
| 384 | + | (SELECT count(*) FROM ssh_keys WHERE user_id = ?1) AS ssh_keys, | |
| 385 | + | (SELECT count(*) FROM oauth_grants WHERE user_id = ?1) AS applications, | |
| 386 | + | (SELECT count(*) FROM repo_grants WHERE principal_kind = 'user' AND principal_id = ?1) AS repositories", | |
| 387 | + | ) | |
| 388 | + | .bind(&[user_id.into()])? | |
| 389 | + | .first::<Counts>(None) | |
| 390 | + | .await? | |
| 391 | + | .unwrap_or(Counts { workspaces: 0, tokens: 0, ssh_keys: 0, applications: 0, repositories: 0 }); | |
| 392 | + | let mut sole_owner_of = self.sole_owned(user_id).await?; | |
| 393 | + | if let Some(actor) = actor | |
| 394 | + | && !sole_owner_of.is_empty() | |
| 395 | + | { | |
| 396 | + | let billing = self.env.service("BILLING")?; | |
| 397 | + | for workspace in &mut sole_owner_of { | |
| 398 | + | let closing: Result<Outcome<bool>> = g1t_kit::call( | |
| 399 | + | &billing, | |
| 400 | + | "close_workspace", | |
| 401 | + | &CloseWorkspaceArgs { actor: actor.clone(), workspace: workspace.slug.clone(), dry_run: true }, | |
| 402 | + | ) | |
| 403 | + | .await; | |
| 404 | + | workspace.billing = match closing { | |
| 405 | + | Ok(Outcome::Fail(failure)) => Some(failure.message), | |
| 406 | + | _ => None, | |
| 407 | + | }; | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | Ok(AccountDeletion { | |
| 411 | + | username: username.to_owned(), | |
| 412 | + | workspaces: counts.workspaces, | |
| 413 | + | tokens: counts.tokens, | |
| 414 | + | ssh_keys: counts.ssh_keys, | |
| 415 | + | applications: counts.applications, | |
| 416 | + | repositories: counts.repositories, | |
| 417 | + | sole_owner_of, | |
| 418 | + | protected: is_protected_account(&self.protected_account_names(), user_id, username), | |
| 419 | + | }) | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | /// `check_account_deletion`: what deleting the person's own account | |
| 423 | + | /// would take, and what is in the way, changing nothing. | |
| 424 | + | pub async fn check_account_deletion(&self, a: UserArgs) -> Result<Outcome<AccountDeletion>> { | |
| 425 | + | if !is_person(&a.user) { | |
| 426 | + | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 427 | + | } | |
| 428 | + | let Some(live) = self.live_account("id", &a.user.id).await? else { | |
| 429 | + | return Ok(Outcome::fail(FailureCode::NotFound, "Account not found.")); | |
| 430 | + | }; | |
| 431 | + | Ok(Outcome::Ok(self.account_deletion_facts(&live.id, &live.username, Some(&a.user)).await?)) | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | /// `delete_account`: the person deletes their own account. | |
| 435 | + | pub async fn delete_account(&self, a: DeleteAccountArgs) -> Result<Outcome<bool>> { | |
| 436 | + | if !is_person(&a.user) { | |
| 437 | + | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 438 | + | } | |
| 439 | + | let Some(live) = self.live_account("id", &a.user.id).await? else { | |
| 440 | + | return Ok(Outcome::fail(FailureCode::NotFound, "Account not found.")); | |
| 441 | + | }; | |
| 442 | + | let deletion = self.account_deletion_facts(&live.id, &live.username, None).await?; | |
| 443 | + | if let Err((code, message)) = may_delete_own(true, &deletion, &a.confirm) { | |
| 444 | + | return Ok(Outcome::fail(code, message)); | |
| 445 | + | } | |
| 446 | + | // Proof last: nothing else in the way, so a password typed now is | |
| 447 | + | // the last thing asked. | |
| 448 | + | if let Some(refusal) = self.proof(&live.id, &a.reauth).await?.refusal() { | |
| 449 | + | return Ok(refusal); | |
| 450 | + | } | |
| 451 | + | self.soft_delete(&live, &deletion, Some(&live.id), None).await?; | |
| 452 | + | Ok(Outcome::Ok(true)) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | /// `admin_delete_account`: staff delete an account, with a reason. | |
| 456 | + | pub async fn admin_delete_account(&self, a: AdminDeleteAccountArgs) -> Result<Outcome<bool>> { | |
| 457 | + | let staff = a.staff.trim(); | |
| 458 | + | let reason = a.reason.trim(); | |
| 459 | + | if staff.is_empty() { | |
| 460 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is deleting it.")); | |
| 461 | + | } | |
| 462 | + | if reason.is_empty() { | |
| 463 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Say why the account is being deleted.")); | |
| 464 | + | } | |
| 465 | + | let Some(live) = self.live_account("username", &a.username.trim().to_lowercase()).await? else { | |
| 466 | + | return Ok(Outcome::fail(FailureCode::NotFound, "No such account, or it is already deleted.")); | |
| 467 | + | }; | |
| 468 | + | let deletion = self.account_deletion_facts(&live.id, &live.username, None).await?; | |
| 469 | + | if let Err((code, message)) = may_delete(&deletion, &a.confirm) { | |
| 470 | + | // Staff read "you" as the account's. | |
| 471 | + | let message = message.replacen("You are the only owner", &format!("{} is the only owner", live.username), 1); | |
| 472 | + | return Ok(Outcome::fail(code, message)); | |
| 473 | + | } | |
| 474 | + | self.soft_delete(&live, &deletion, None, Some((staff, reason))).await?; | |
| 475 | + | self.record_for_staff( | |
| 476 | + | &live.username, | |
| 477 | + | "account_deleted", | |
| 478 | + | &format!("Deleted the account {}: {reason}", live.username), | |
| 479 | + | staff, | |
| 480 | + | ) | |
| 481 | + | .await; | |
| 482 | + | Ok(Outcome::Ok(true)) | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | /// Deletes an account softly: see the module docs. | |
| 486 | + | async fn soft_delete( | |
| 487 | + | &self, | |
| 488 | + | live: &Live, | |
| 489 | + | deletion: &AccountDeletion, | |
| 490 | + | deleted_by: Option<&str>, | |
| 491 | + | staff: Option<(&str, &str)>, | |
| 492 | + | ) -> Result<()> { | |
| 493 | + | let id = live.id.as_str(); | |
| 494 | + | let snapshot = self.snapshot(id, deletion, staff).await?; | |
| 495 | + | let now = now_ms(); | |
| 496 | + | let at = rfc3339(now); | |
| 497 | + | let purge = purge_after(now); | |
| 498 | + | let by_staff = staff.is_some(); | |
| 499 | + | // Recorded in each workspace it was in, while it still is. | |
| 500 | + | let person = User { id: live.id.clone(), username: live.username.clone(), ..User::default() }; | |
| 501 | + | let action = if by_staff { "account.deleted_by_staff" } else { "account.deleted" }; | |
| 502 | + | self.audit_account(&person, action, &format!("Deleted the account {}", live.username)).await; | |
| 503 | + | // The person hears of it while their addresses are still there. | |
| 504 | + | for address in self.notice_recipients(id, false).await.unwrap_or_default() { | |
| 505 | + | if let Err(error) = crate::email::send_account_deleted(&self.env, &address, &live.username, by_staff, ACCOUNT_RESTORE_DAYS).await { | |
| 506 | + | worker::console_error!("account deleted notice failed: {error}"); | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | let went = serde_json::to_string(&snapshot).unwrap_or_default(); | |
| 510 | + | let by: JsValue = deleted_by.map_or(JsValue::NULL, Into::into); | |
| 511 | + | let mut statements = vec![ | |
| 512 | + | // Only while it is still live: two deletions at once delete once. | |
| 513 | + | self.db | |
| 514 | + | .prepare( | |
| 515 | + | "UPDATE users SET deleted_at = ?, deleted_by = ?, purge_after = ?, deleted_went = ? | |
| 516 | + | WHERE id = ? AND deleted_at IS NULL", | |
| 517 | + | ) | |
| 518 | + | .bind(&[at.as_str().into(), by, purge.as_str().into(), went.into(), id.into()])?, | |
| 519 | + | ]; | |
| 520 | + | let values = [id, at.as_str()]; | |
| 521 | + | for (sql, binds) in revoke_statements() { | |
| 522 | + | let binds: Vec<JsValue> = values[..binds].iter().map(|value| JsValue::from(*value)).collect(); | |
| 523 | + | statements.push(self.db.prepare(sql).bind(&binds)?); | |
| 524 | + | } | |
| 525 | + | self.db.batch(statements).await?; | |
| 526 | + | self.log_security(id, "account_deleted", None, staff).await; | |
| 527 | + | self.announce( | |
| 528 | + | "user.deleting", | |
| 529 | + | deleted_by, | |
| 530 | + | UserDeleting { user_id: live.id.clone(), username: live.username.clone(), by_staff, purge_after: purge }, | |
| 531 | + | ) | |
| 532 | + | .await; | |
| 533 | + | Ok(()) | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | /// What the account has now, kept so a restore can put it back. | |
| 537 | + | async fn snapshot(&self, id: &str, deletion: &AccountDeletion, staff: Option<(&str, &str)>) -> Result<Snapshot> { | |
| 538 | + | let memberships = self | |
| 539 | + | .db | |
| 540 | + | .prepare( | |
| 541 | + | "SELECT workspace_id, role, billing_manager, security_manager, created_at | |
| 542 | + | FROM workspace_members WHERE user_id = ?", | |
| 543 | + | ) | |
| 544 | + | .bind(&[id.into()])? | |
| 545 | + | .all() | |
| 546 | + | .await? | |
| 547 | + | .results::<Member>()?; | |
| 548 | + | let teams = self | |
| 549 | + | .db | |
| 550 | + | .prepare("SELECT team_id, role, created_at FROM team_members WHERE user_id = ?") | |
| 551 | + | .bind(&[id.into()])? | |
| 552 | + | .all() | |
| 553 | + | .await? | |
| 554 | + | .results::<TeamMember>()?; | |
| 555 | + | let grants = self | |
| 556 | + | .db | |
| 557 | + | .prepare( | |
| 558 | + | "SELECT repo_id, workspace_id, repo_name, role, granted_by, created_at, updated_at | |
| 559 | + | FROM repo_grants WHERE principal_kind = 'user' AND principal_id = ?", | |
| 560 | + | ) | |
| 561 | + | .bind(&[id.into()])? | |
| 562 | + | .all() | |
| 563 | + | .await? | |
| 564 | + | .results::<Grant>()?; | |
| 565 | + | Ok(Snapshot { | |
| 566 | + | went: AccountWent { | |
| 567 | + | workspaces: deletion.workspaces, | |
| 568 | + | teams: teams.len() as u32, | |
| 569 | + | repositories: grants.len() as u32, | |
| 570 | + | tokens: deletion.tokens, | |
| 571 | + | ssh_keys: deletion.ssh_keys, | |
| 572 | + | staff: staff.map(|(who, _)| who.to_owned()), | |
| 573 | + | reason: staff.map(|(_, why)| why.to_owned()), | |
| 574 | + | }, | |
| 575 | + | memberships, | |
| 576 | + | teams, | |
| 577 | + | grants, | |
| 578 | + | }) | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | /// Deleted accounts not purged yet, newest first. Staff only. | |
| 582 | + | pub async fn admin_deleted_accounts(&self) -> Result<Vec<DeletedAccount>> { | |
| 583 | + | let rows = self | |
| 584 | + | .db | |
| 585 | + | .prepare(format!("SELECT {DELETED_COLUMNS} ORDER BY deleted_at DESC LIMIT 500")) | |
| 586 | + | .all() | |
| 587 | + | .await? | |
| 588 | + | .results::<DeletedRow>()?; | |
| 589 | + | let now = rfc3339(now_ms()); | |
| 590 | + | Ok(rows.iter().map(|row| row.listed(&now)).collect()) | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | async fn deleted_account_row(&self, column: &str, value: &str) -> Result<Option<DeletedRow>> { | |
| 594 | + | self.db | |
| 595 | + | .prepare(format!("SELECT {DELETED_COLUMNS} AND {column} = ?")) | |
| 596 | + | .bind(&[value.into()])? | |
| 597 | + | .first::<DeletedRow>(None) | |
| 598 | + | .await | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | /// The deletion of `user_id`, when it is deleted and not purged. | |
| 602 | + | pub(crate) async fn deleted_account(&self, user_id: &str) -> Result<Option<DeletedAccount>> { | |
| 603 | + | let now = rfc3339(now_ms()); | |
| 604 | + | Ok(self.deleted_account_row("id", user_id).await?.map(|row| row.listed(&now))) | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | /// Staff bring a deleted account back within its window, with the | |
| 608 | + | /// memberships, teams and repository roles it left. Staff only. | |
| 609 | + | pub async fn admin_restore_account(&self, a: AdminDeletedAccountArgs) -> Result<Outcome<bool>> { | |
| 610 | + | let staff = a.staff.trim(); | |
| 611 | + | if staff.is_empty() { | |
| 612 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is restoring it.")); | |
| 613 | + | } | |
| 614 | + | let Some(row) = self.deleted_account_row("id", &a.user_id).await? else { | |
| 615 | + | return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted account with that id.")); | |
| 616 | + | }; | |
| 617 | + | if let Err((code, message)) = may_restore(&row.username, &row.purge_after, &rfc3339(now_ms())) { | |
| 618 | + | return Ok(Outcome::fail(code, message)); | |
| 619 | + | } | |
| 620 | + | let snapshot = snapshot_of(row.deleted_went.as_deref()); | |
| 621 | + | let id = row.id.as_str(); | |
| 622 | + | let mut statements = vec![ | |
| 623 | + | self.db | |
| 624 | + | .prepare( | |
| 625 | + | "UPDATE users SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL, deleted_went = NULL | |
| 626 | + | WHERE id = ? AND deleted_at IS NOT NULL", | |
| 627 | + | ) | |
| 628 | + | .bind(&[id.into()])?, | |
| 629 | + | ]; | |
| 630 | + | // Back where the workspace, team or repository's workspace is | |
| 631 | + | // still there; never over what was given since. | |
| 632 | + | for member in &snapshot.memberships { | |
| 633 | + | statements.push( | |
| 634 | + | self.db | |
| 635 | + | .prepare( | |
| 636 | + | "INSERT OR IGNORE INTO workspace_members | |
| 637 | + | (workspace_id, user_id, role, created_at, billing_manager, security_manager) | |
| 638 | + | SELECT ?1, ?2, ?3, ?4, ?5, ?6 WHERE EXISTS (SELECT 1 FROM workspaces WHERE id = ?1)", | |
| 639 | + | ) | |
| 640 | + | .bind(&[ | |
| 641 | + | member.workspace_id.as_str().into(), | |
| 642 | + | id.into(), | |
| 643 | + | member.role.as_str().into(), | |
| 644 | + | member.created_at.as_str().into(), | |
| 645 | + | member.billing_manager.into(), | |
| 646 | + | member.security_manager.into(), | |
| 647 | + | ])?, | |
| 648 | + | ); | |
| 649 | + | } | |
| 650 | + | for team in &snapshot.teams { | |
| 651 | + | statements.push( | |
| 652 | + | self.db | |
| 653 | + | .prepare( | |
| 654 | + | "INSERT OR IGNORE INTO team_members (team_id, user_id, role, created_at) | |
| 655 | + | SELECT ?1, ?2, ?3, ?4 WHERE EXISTS (SELECT 1 FROM teams WHERE id = ?1)", | |
| 656 | + | ) | |
| 657 | + | .bind(&[team.team_id.as_str().into(), id.into(), team.role.as_str().into(), team.created_at.as_str().into()])?, | |
| 658 | + | ); | |
| 659 | + | } | |
| 660 | + | for grant in &snapshot.grants { | |
| 661 | + | statements.push( | |
| 662 | + | self.db | |
| 663 | + | .prepare( | |
| 664 | + | "INSERT OR IGNORE INTO repo_grants | |
| 665 | + | (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at) | |
| 666 | + | SELECT ?1, 'user', ?2, ?3, ?4, ?5, ?6, ?7, ?8 | |
| 667 | + | WHERE EXISTS (SELECT 1 FROM workspaces WHERE id = ?3)", | |
| 668 | + | ) | |
| 669 | + | .bind(&[ | |
| 670 | + | grant.repo_id.as_str().into(), | |
| 671 | + | id.into(), | |
| 672 | + | grant.workspace_id.as_str().into(), | |
| 673 | + | grant.repo_name.as_str().into(), | |
| 674 | + | grant.role.as_str().into(), | |
| 675 | + | grant.granted_by.as_deref().map_or(JsValue::NULL, Into::into), | |
| 676 | + | grant.created_at.as_str().into(), | |
| 677 | + | grant.updated_at.as_str().into(), | |
| 678 | + | ])?, | |
| 679 | + | ); | |
| 680 | + | } | |
| 681 | + | self.db.batch(statements).await?; | |
| 682 | + | self.log_security(id, "account_restored", None, Some((staff, "Restored by g1t's staff"))).await; | |
| 683 | + | self.record_for_staff(&row.username, "account_restored", &format!("Restored the account {}", row.username), staff) | |
| 684 | + | .await; | |
| 685 | + | self.announce("user.restored", None, UserRestored { user_id: row.id.clone(), username: row.username.clone() }) | |
| 686 | + | .await; | |
| 687 | + | Ok(Outcome::Ok(true)) | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | /// Staff purge a deleted account now rather than at `purge_after`. | |
| 691 | + | pub async fn admin_purge_account(&self, a: AdminDeletedAccountArgs) -> Result<Outcome<bool>> { | |
| 692 | + | let staff = a.staff.trim(); | |
| 693 | + | if staff.is_empty() { | |
| 694 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is purging it.")); | |
| 695 | + | } | |
| 696 | + | let Some(row) = self.deleted_account_row("id", &a.user_id).await? else { | |
| 697 | + | return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted account with that id.")); | |
| 698 | + | }; | |
| 699 | + | let protected = is_protected_account(&self.protected_account_names(), &row.id, &row.username); | |
| 700 | + | if let Err((code, message)) = may_purge(protected, &row.username, Some(&a.confirm)) { | |
| 701 | + | return Ok(Outcome::fail(code, message)); | |
| 702 | + | } | |
| 703 | + | self.purge_account(&row).await?; | |
| 704 | + | self.record_for_staff(&row.username, "account_purged", &format!("Purged the account {} now", row.username), staff) | |
| 705 | + | .await; | |
| 706 | + | Ok(Outcome::Ok(true)) | |
| 707 | + | } | |
| 708 | + | ||
| 709 | + | /// The sweep: purges deleted accounts whose restore window has passed. | |
| 710 | + | pub async fn purge_due_accounts(&self) -> Result<u32> { | |
| 711 | + | let due = self | |
| 712 | + | .db | |
| 713 | + | .prepare(format!( | |
| 714 | + | "SELECT {DELETED_COLUMNS} AND purge_after <= ? ORDER BY purge_after LIMIT {PURGES_PER_SWEEP}" | |
| 715 | + | )) | |
| 716 | + | .bind(&[rfc3339(now_ms()).into()])? | |
| 717 | + | .all() | |
| 718 | + | .await? | |
| 719 | + | .results::<DeletedRow>()?; | |
| 720 | + | let names = self.protected_account_names(); | |
| 721 | + | let mut purged = 0; | |
| 722 | + | for row in due { | |
| 723 | + | if let Err((_, why)) = may_purge(is_protected_account(&names, &row.id, &row.username), &row.username, None) { | |
| 724 | + | worker::console_error!("{} not purged: {why}", row.username); | |
| 725 | + | continue; | |
| 726 | + | } | |
| 727 | + | match self.purge_account(&row).await { | |
| 728 | + | Ok(()) => purged += 1, | |
| 729 | + | Err(error) => worker::console_error!("{} not purged: {error}", row.username), | |
| 730 | + | } | |
| 731 | + | } | |
| 732 | + | Ok(purged) | |
| 733 | + | } | |
| 734 | + | ||
| 735 | + | /// Removes a deleted account for good: see the module docs. | |
| 736 | + | async fn purge_account(&self, row: &DeletedRow) -> Result<()> { | |
| 737 | + | let now = rfc3339(now_ms()); | |
| 738 | + | let values = [row.id.as_str(), row.username.as_str(), row.deleted_at.as_str(), now.as_str()]; | |
| 739 | + | let mut batch = Vec::new(); | |
| 740 | + | for (sql, binds) in purge_statements() { | |
| 741 | + | let binds: Vec<JsValue> = values[..binds].iter().map(|value| JsValue::from(*value)).collect(); | |
| 742 | + | batch.push(self.db.prepare(sql).bind(&binds)?); | |
| 743 | + | } | |
| 744 | + | self.db.batch(batch).await?; | |
| 745 | + | if let Err(error) = self.forget_avatar(row.avatar.clone()).await { | |
| 746 | + | worker::console_error!("avatar of {} not removed: {error}", row.username); | |
| 747 | + | } | |
| 748 | + | self.announce("user.deleted", None, UserDeleted { user_id: row.id.clone(), username: row.username.clone() }) | |
| 749 | + | .await; | |
| 750 | + | Ok(()) | |
| 751 | + | } | |
| 752 | + | } | |
| 753 | + | ||
| 754 | + | #[cfg(test)] | |
| 755 | + | mod tests { | |
| 756 | + | use super::*; | |
| 757 | + | ||
| 758 | + | fn deletion(username: &str) -> AccountDeletion { | |
| 759 | + | AccountDeletion { username: username.into(), ..AccountDeletion::default() } | |
| 760 | + | } | |
| 761 | + | ||
| 762 | + | fn sole(slug: &str) -> SoleOwnedWorkspace { | |
| 763 | + | SoleOwnedWorkspace { slug: slug.into(), name: slug.into(), members: 2, billing: None } | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | #[test] | |
| 767 | + | fn only_the_person_typing_their_username() { | |
| 768 | + | assert!(may_delete_own(true, &deletion("ada"), " ADA ").is_ok()); | |
| 769 | + | assert_eq!(may_delete_own(false, &deletion("ada"), "ada").unwrap_err().0, FailureCode::Forbidden); | |
| 770 | + | assert_eq!(may_delete_own(true, &deletion("ada"), "").unwrap_err(), (FailureCode::Invalid, "Type ada to confirm.".into())); | |
| 771 | + | assert_eq!(may_delete_own(true, &deletion("ada"), "ada-l").unwrap_err().0, FailureCode::Invalid); | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | #[test] | |
| 775 | + | fn the_only_owner_of_a_live_workspace_is_refused_with_its_name() { | |
| 776 | + | let owner = AccountDeletion { sole_owner_of: vec![sole("acme"), sole("globex")], ..deletion("ada") }; | |
| 777 | + | let (code, message) = may_delete_own(true, &owner, "ada").unwrap_err(); | |
| 778 | + | assert_eq!(code, FailureCode::Conflict); | |
| 779 | + | assert!(message.contains("acme and globex"), "{message}"); | |
| 780 | + | // Staff are refused the same way. | |
| 781 | + | assert_eq!(may_delete(&owner, "ada").unwrap_err().0, FailureCode::Conflict); | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | #[test] | |
| 785 | + | fn a_protected_account_is_refused_to_everyone_and_never_purged() { | |
| 786 | + | let names = protected_names(None); | |
| 787 | + | for username in ["g1t", "g1t-agent", "ghost"] { | |
| 788 | + | assert!(is_protected_account(&names, "usr_1", username)); | |
| 789 | + | let protected = AccountDeletion { protected: true, ..deletion(username) }; | |
| 790 | + | let (code, message) = may_delete_own(true, &protected, username).unwrap_err(); | |
| 791 | + | assert_eq!(code, FailureCode::Forbidden); | |
| 792 | + | assert_eq!(message, format!("{username} is protected and can never be deleted.")); | |
| 793 | + | assert_eq!(may_purge(true, username, None).unwrap_err().0, FailureCode::Forbidden); | |
| 794 | + | assert_eq!(may_purge(true, username, Some(username)).unwrap_err().0, FailureCode::Forbidden); | |
| 795 | + | } | |
| 796 | + | // Named in PROTECTED_ACCOUNTS, by username or id. | |
| 797 | + | let named = protected_names(Some("ada,usr_9")); | |
| 798 | + | assert!(is_protected_account(&named, "usr_2", "Ada")); | |
| 799 | + | assert!(is_protected_account(&named, "usr_9", "grace")); | |
| 800 | + | assert!(!is_protected_account(&named, "usr_3", "grace")); | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | #[test] | |
| 804 | + | fn a_deleted_account_is_restorable_for_thirty_days_then_due() { | |
| 805 | + | let deleted = 1_790_000_000_000; | |
| 806 | + | let purge = purge_after(deleted); | |
| 807 | + | assert_eq!(purge, rfc3339(deleted + 30 * 86_400_000)); | |
| 808 | + | assert!(restorable(&purge, &rfc3339(deleted + 29 * 86_400_000))); | |
| 809 | + | assert!(!restorable(&purge, &purge)); | |
| 810 | + | assert!(may_restore("ada", &purge, &rfc3339(deleted + 86_400_000)).is_ok()); | |
| 811 | + | assert_eq!( | |
| 812 | + | may_restore("ada", &purge, &rfc3339(deleted + 31 * 86_400_000)).unwrap_err(), | |
| 813 | + | (FailureCode::Conflict, "ada is being purged and can no longer be restored.".to_owned()) | |
| 814 | + | ); | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | #[test] | |
| 818 | + | fn staff_purge_only_with_the_username_typed() { | |
| 819 | + | assert!(may_purge(false, "ada", None).is_ok()); | |
| 820 | + | assert!(may_purge(false, "ada", Some(" Ada ")).is_ok()); | |
| 821 | + | assert_eq!(may_purge(false, "ada", Some("")).unwrap_err().0, FailureCode::Invalid); | |
| 822 | + | assert_eq!(may_purge(false, "ada", Some("grace")).unwrap_err().0, FailureCode::Invalid); | |
| 823 | + | } | |
| 824 | + | ||
| 825 | + | #[test] | |
| 826 | + | fn what_it_left_is_kept_for_a_restore_and_read_back() { | |
| 827 | + | let snapshot = Snapshot { | |
| 828 | + | went: AccountWent { workspaces: 2, teams: 1, repositories: 1, tokens: 3, ssh_keys: 1, staff: Some("s@g1t.sh".into()), reason: Some("asked".into()) }, | |
| 829 | + | memberships: vec![Member { | |
| 830 | + | workspace_id: "wsp_1".into(), | |
| 831 | + | role: "owner".into(), | |
| 832 | + | billing_manager: 0, | |
| 833 | + | security_manager: 1, | |
| 834 | + | created_at: "2026-01-01T00:00:00.000Z".into(), | |
| 835 | + | }], | |
| 836 | + | teams: vec![TeamMember { team_id: "tem_1".into(), role: "maintainer".into(), created_at: "x".into() }], | |
| 837 | + | grants: vec![Grant { | |
| 838 | + | repo_id: "rep_1".into(), | |
| 839 | + | workspace_id: "wsp_2".into(), | |
| 840 | + | repo_name: "api".into(), | |
| 841 | + | role: "write".into(), | |
| 842 | + | granted_by: None, | |
| 843 | + | created_at: "x".into(), | |
| 844 | + | updated_at: "y".into(), | |
| 845 | + | }], | |
| 846 | + | }; | |
| 847 | + | let stored = serde_json::to_string(&snapshot).unwrap(); | |
| 848 | + | assert_eq!(snapshot_of(Some(&stored)), snapshot); | |
| 849 | + | assert_eq!(snapshot_of(None), Snapshot::default()); | |
| 850 | + | assert_eq!(snapshot_of(Some("not json")), Snapshot::default()); | |
| 851 | + | } | |
| 852 | + | ||
| 853 | + | /// The highest `?N` a statement names: D1 refuses a statement given | |
| 854 | + | /// more or fewer values than that. | |
| 855 | + | fn highest_bind(sql: &str) -> usize { | |
| 856 | + | (1..=9).filter(|n| sql.contains(&format!("?{n}"))).max().unwrap_or(0) | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | #[test] | |
| 860 | + | fn every_statement_is_given_exactly_the_values_it_names() { | |
| 861 | + | for (sql, binds) in revoke_statements().into_iter().chain(purge_statements()) { | |
| 862 | + | assert_eq!(highest_bind(&sql), binds, "{sql}"); | |
| 863 | + | } | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | #[test] | |
| 867 | + | fn deleting_ends_everything_it_signs_in_with_and_every_membership() { | |
| 868 | + | let sql: Vec<String> = revoke_statements().into_iter().map(|(sql, _)| sql).collect(); | |
| 869 | + | for table in [ | |
| 870 | + | "sessions", | |
| 871 | + | "access_tokens", | |
| 872 | + | "oauth_grants", | |
| 873 | + | "device_codes", | |
| 874 | + | "ssh_keys", | |
| 875 | + | "two_factor_challenges", | |
| 876 | + | "email_tokens", | |
| 877 | + | "workspace_members", | |
| 878 | + | "team_members", | |
| 879 | + | ] { | |
| 880 | + | assert!(sql.iter().any(|s| s == &format!("DELETE FROM {table} WHERE user_id = ?1")), "{table}"); | |
| 881 | + | } | |
| 882 | + | assert!(sql.iter().any(|s| s.starts_with("DELETE FROM deploy_keys WHERE created_by = ?1"))); | |
| 883 | + | assert!(sql.iter().any(|s| s.starts_with("DELETE FROM repo_grants"))); | |
| 884 | + | assert!(sql.iter().any(|s| s.starts_with("UPDATE github_accounts SET tokens = NULL"))); | |
| 885 | + | // A token's repositories go before the token, which the subquery needs. | |
| 886 | + | let repositories = sql.iter().position(|s| s.contains("token_repositories")).unwrap(); | |
| 887 | + | let tokens = sql.iter().position(|s| s == "DELETE FROM access_tokens WHERE user_id = ?1").unwrap(); | |
| 888 | + | assert!(repositories < tokens); | |
| 889 | + | } | |
| 890 | + | ||
| 891 | + | #[test] | |
| 892 | + | fn a_purge_keeps_the_username_hands_authorship_to_ghost_and_loses_to_a_restore() { | |
| 893 | + | let sql: Vec<String> = purge_statements().into_iter().map(|(sql, _)| sql).collect(); | |
| 894 | + | assert!(sql[0].starts_with("INSERT OR REPLACE INTO deleted_users")); | |
| 895 | + | assert!(sql.iter().any(|s| s.starts_with("UPDATE workspaces SET created_by = 'usr_ghost' WHERE created_by = ?1"))); | |
| 896 | + | for table in ["user_emails", "github_accounts", "two_factor", "two_factor_recovery", "security_events", "ssh_keys"] { | |
| 897 | + | assert!(sql.iter().any(|s| s.starts_with(&format!("DELETE FROM {table} WHERE user_id = ?1"))), "{table}"); | |
| 898 | + | } | |
| 899 | + | // The row goes last, and everything before it only while the | |
| 900 | + | // account is still deleted, so a restore a moment before wins. | |
| 901 | + | assert!(sql.last().unwrap().starts_with("DELETE FROM users WHERE id = ?1 AND deleted_at IS NOT NULL")); | |
| 902 | + | for s in &sql[..sql.len() - 1] { | |
| 903 | + | assert!(s.contains("deleted_at IS NOT NULL AND purge_after IS NOT NULL"), "{s}"); | |
| 904 | + | } | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | #[test] | |
| 908 | + | fn the_deleted_list_never_shows_ghost() { | |
| 909 | + | // ghost's row is deleted with no purge time; the list and the | |
| 910 | + | // sweep both need one. | |
| 911 | + | assert!(DELETED_COLUMNS.contains("purge_after IS NOT NULL")); | |
| 912 | + | } | |
| 913 | + | } |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | 95 | /// Deletes an avatar no workspace or person uses any more. | |
| 96 | − | async fn forget_avatar(&self, key: Option<String>) -> Result<()> { | |
| 96 | + | pub(crate) async fn forget_avatar(&self, key: Option<String>) -> Result<()> { | |
| 97 | 97 | let Some(key) = key.filter(|key| is_key(key)) else { | |
| 98 | 98 | return Ok(()); | |
| 99 | 99 | }; |
| 193 | 193 | WHERE w.deleted_at IS NOT NULL"; | |
| 194 | 194 | ||
| 195 | 195 | impl Identity { | |
| 196 | − | /// Whether `slug` belonged to a workspace that was deleted and purged. | |
| 196 | + | /// Whether `slug` belonged to a workspace that was deleted and purged, | |
| 197 | + | /// or is the username of an account that was (account_deletion.rs): | |
| 198 | + | /// neither is ever given to anyone again. | |
| 197 | 199 | pub async fn slug_deleted(&self, slug: &str) -> Result<bool> { | |
| 198 | 200 | Ok(self | |
| 199 | 201 | .db | |
| 200 | − | .prepare("SELECT 1 AS held FROM deleted_workspaces WHERE slug = ?") | |
| 202 | + | .prepare( | |
| 203 | + | "SELECT 1 AS held FROM deleted_workspaces WHERE slug = ?1 | |
| 204 | + | UNION ALL SELECT 1 FROM deleted_users WHERE username = ?1", | |
| 205 | + | ) | |
| 201 | 206 | .bind(&[slug.to_lowercase().into()])? | |
| 202 | 207 | .first::<serde_json::Value>(None) | |
| 203 | 208 | .await? |
| 152 | 152 | let Some(user) = self | |
| 153 | 153 | .find_user( | |
| 154 | 154 | "SELECT id, username, email_verified_at IS NOT NULL AS verified | |
| 155 | − | FROM users WHERE id = ?", | |
| 155 | + | FROM users WHERE id = ? AND deleted_at IS NULL", | |
| 156 | 156 | &user_id, | |
| 157 | 157 | ) | |
| 158 | 158 | .await? |
| 32 | 32 | let sql = match a.kind.as_str() { | |
| 33 | 33 | "user" => { | |
| 34 | 34 | "SELECT id, username AS slug, display_name AS name, bio, avatar, created_at | |
| 35 | − | FROM users WHERE username > ? ORDER BY username LIMIT ?" | |
| 35 | + | FROM users WHERE username > ? AND deleted_at IS NULL ORDER BY username LIMIT ?" | |
| 36 | 36 | } | |
| 37 | 37 | "workspace" => { | |
| 38 | 38 | "SELECT id, slug, name, description AS bio, avatar, created_at |
| 248 | 248 | .await | |
| 249 | 249 | } | |
| 250 | 250 | ||
| 251 | + | /// What the email telling an account it was deleted says: its subject and | |
| 252 | + | /// first paragraph. `by_staff` when g1t's staff deleted it. | |
| 253 | + | pub fn deleted_wording(username: &str, by_staff: bool, days: u64) -> (String, String) { | |
| 254 | + | let who = if by_staff { "g1t's staff deleted" } else { "You deleted" }; | |
| 255 | + | ( | |
| 256 | + | format!("Your g1t account {username} was deleted"), | |
| 257 | + | format!( | |
| 258 | + | "{who} your g1t account {username}. It has signed out everywhere, its access tokens and keys no longer work, and it has left every workspace. g1t keeps it for {days} days; after that it is removed for good." | |
| 259 | + | ), | |
| 260 | + | ) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | /// Tells an account's primary and backup addresses it was deleted, with how | |
| 264 | + | /// to ask for it back. | |
| 265 | + | pub async fn send_account_deleted(env: &Env, to: &str, username: &str, by_staff: bool, days: u64) -> Result<()> { | |
| 266 | + | let (subject, intro) = deleted_wording(username, by_staff, days); | |
| 267 | + | send_link( | |
| 268 | + | env, | |
| 269 | + | to, | |
| 270 | + | &subject, | |
| 271 | + | &intro, | |
| 272 | + | "Contact support", | |
| 273 | + | &format!("{}/support", site(env)), | |
| 274 | + | &format!( | |
| 275 | + | "If you did not mean to delete it, or did not delete it, write to support within {days} days and g1t can restore it." | |
| 276 | + | ), | |
| 277 | + | ) | |
| 278 | + | .await | |
| 279 | + | } | |
| 280 | + | ||
| 251 | 281 | /// An invite email: to make an account, or for an existing one to join a | |
| 252 | 282 | /// workspace. | |
| 253 | 283 | pub struct InviteEmail<'a> { |
| 37 | 37 | ||
| 38 | 38 | use std::collections::HashMap; | |
| 39 | 39 | ||
| 40 | + | use g1t_contracts::account_deletion::{GHOST_ID, GHOST_USERNAME}; | |
| 40 | 41 | use g1t_contracts::accounts::*; | |
| 41 | 42 | use g1t_contracts::events::UserEmailChanged; | |
| 42 | 43 | use g1t_contracts::identity::{UserArgs, UsernameArgs}; | |
| ⋯ | |||
| 952 | 953 | .db | |
| 953 | 954 | .prepare( | |
| 954 | 955 | "SELECT u.id AS user_id, u.username, e.id AS email_id, e.display FROM user_emails e | |
| 955 | − | JOIN users u ON u.id = e.user_id WHERE e.email = ? AND e.verified_at IS NOT NULL", | |
| 956 | + | JOIN users u ON u.id = e.user_id WHERE e.email = ? AND e.verified_at IS NOT NULL AND u.deleted_at IS NULL", | |
| 956 | 957 | ) | |
| 957 | 958 | .bind(&[email.as_str().into()])? | |
| 958 | 959 | .first::<ResetTarget>(None) | |
| ⋯ | |||
| 964 | 965 | .prepare( | |
| 965 | 966 | "SELECT u.id AS user_id, u.username, e.id AS email_id, e.display FROM user_emails e | |
| 966 | 967 | JOIN users u ON u.primary_email_id = e.id | |
| 967 | − | WHERE e.email = ? AND e.verified_at IS NULL ORDER BY u.created_at, u.id LIMIT 1", | |
| 968 | + | WHERE e.email = ? AND e.verified_at IS NULL AND u.deleted_at IS NULL ORDER BY u.created_at, u.id LIMIT 1", | |
| 968 | 969 | ) | |
| 969 | 970 | .bind(&[email.as_str().into()])? | |
| 970 | 971 | .first::<ResetTarget>(None) | |
| ⋯ | |||
| 981 | 982 | id: String, | |
| 982 | 983 | username: String, | |
| 983 | 984 | avatar: Option<String>, | |
| 985 | + | /// 1 for a purged account's username (`deleted_users`). | |
| 986 | + | #[serde(default)] | |
| 987 | + | purged: u8, | |
| 984 | 988 | } | |
| 985 | 989 | let mut owners = HashMap::new(); | |
| 986 | 990 | let mut plain: Vec<String> = Vec::new(); | |
| ⋯ | |||
| 1004 | 1008 | .db | |
| 1005 | 1009 | .prepare(format!( | |
| 1006 | 1010 | "SELECT e.email, u.id, u.username, u.avatar FROM user_emails e JOIN users u ON u.id = e.user_id | |
| 1007 | − | WHERE e.verified_at IS NOT NULL AND e.email IN ({marks})" | |
| 1011 | + | WHERE e.verified_at IS NOT NULL AND u.deleted_at IS NULL AND e.email IN ({marks})" | |
| 1008 | 1012 | )) | |
| 1009 | 1013 | .bind(&bind)? | |
| 1010 | 1014 | .all() | |
| ⋯ | |||
| 1021 | 1025 | let rows = self | |
| 1022 | 1026 | .db | |
| 1023 | 1027 | .prepare(format!( | |
| 1024 | − | "SELECT username AS email, id, username, avatar FROM users WHERE username IN ({marks})" | |
| 1028 | + | "SELECT username AS email, id, username, avatar, 0 AS purged FROM users | |
| 1029 | + | WHERE username IN ({marks}) AND deleted_at IS NULL | |
| 1030 | + | UNION ALL | |
| 1031 | + | SELECT username AS email, user_id AS id, username, NULL AS avatar, 1 AS purged FROM deleted_users | |
| 1032 | + | WHERE username IN ({marks})" | |
| 1025 | 1033 | )) | |
| 1026 | − | .bind(&bind)? | |
| 1034 | + | .bind(&[bind.clone(), bind].concat())? | |
| 1027 | 1035 | .all() | |
| 1028 | 1036 | .await? | |
| 1029 | 1037 | .results::<Row>()?; | |
| ⋯ | |||
| 1031 | 1039 | if let Some(row) = rows.iter().find(|row| row.username == username) | |
| 1032 | 1040 | && suffix.as_deref().is_none_or(|suffix| id_suffix(&row.id) == suffix) | |
| 1033 | 1041 | { | |
| 1034 | − | owners.insert( | |
| 1035 | − | email, | |
| 1036 | − | EmailOwner { id: row.id.clone(), username: row.username.clone(), avatar: row.avatar.clone() }, | |
| 1037 | − | ); | |
| 1042 | + | // A purged account's commits are ghost's (account_deletion.rs). | |
| 1043 | + | let owner = if row.purged != 0 { | |
| 1044 | + | EmailOwner { id: GHOST_ID.to_owned(), username: GHOST_USERNAME.to_owned(), avatar: None } | |
| 1045 | + | } else { | |
| 1046 | + | EmailOwner { id: row.id.clone(), username: row.username.clone(), avatar: row.avatar.clone() } | |
| 1047 | + | }; | |
| 1048 | + | owners.insert(email, owner); | |
| 1038 | 1049 | } | |
| 1039 | 1050 | } | |
| 1040 | 1051 | } | |
| ⋯ | |||
| 1117 | 1128 | let rows = self.email_rows(user_id).await?; | |
| 1118 | 1129 | let log = self.security_events(user_id, true).await?; | |
| 1119 | 1130 | let emails = view(&account, rows); | |
| 1131 | + | // Whether it can be deleted, and its deletion while it waits to be | |
| 1132 | + | // purged (account_deletion.rs). | |
| 1133 | + | let deleted = self.deleted_account(&account.id).await?; | |
| 1134 | + | let deletion = self.account_deletion_facts(&account.id, &account.username, None).await?; | |
| 1120 | 1135 | Ok(Some(AdminUser { | |
| 1121 | 1136 | id: account.id, | |
| 1122 | 1137 | username: account.username, | |
| ⋯ | |||
| 1124 | 1139 | emails: emails.emails, | |
| 1125 | 1140 | private_email: emails.private_email, | |
| 1126 | 1141 | log, | |
| 1142 | + | deletion, | |
| 1143 | + | deleted, | |
| 1127 | 1144 | })) | |
| 1128 | 1145 | } | |
| 1129 | 1146 | ||
| 506 | 506 | ||
| 507 | 507 | async fn user_by_id(&self, user_id: &str) -> Result<Option<User>> { | |
| 508 | 508 | self.find_user( | |
| 509 | − | "SELECT id, username, email_verified_at IS NOT NULL AS verified, avatar FROM users WHERE id = ?", | |
| 509 | + | "SELECT id, username, email_verified_at IS NOT NULL AS verified, avatar FROM users WHERE id = ? AND deleted_at IS NULL", | |
| 510 | 510 | user_id, | |
| 511 | 511 | ) | |
| 512 | 512 | .await | |
| ⋯ | |||
| 671 | 671 | Outcome::Ok(GithubFinished::Linked { login: github.login, next }) | |
| 672 | 672 | } | |
| 673 | 673 | Decision::SignIn(user_id) => { | |
| 674 | − | self.link(&user_id, github.id, &github.login, Some(&tokens)).await?; | |
| 674 | + | // A deleted account signs in to nothing, and g1t keeps no | |
| 675 | + | // new GitHub token for it (account_deletion.rs). | |
| 675 | 676 | let Some(user) = self.user_by_id(&user_id).await? else { | |
| 676 | 677 | return Ok(Outcome::fail(FailureCode::NotFound, TRY_AGAIN)); | |
| 677 | 678 | }; | |
| 679 | + | self.link(&user_id, github.id, &github.login, Some(&tokens)).await?; | |
| 678 | 680 | self.audit_github(&user, "github.sign_in", format!("Signed in with GitHub (@{})", github.login)).await; | |
| 679 | 681 | self.signed_in(user, false, next).await? | |
| 680 | 682 | } | |
| ⋯ | |||
| 917 | 919 | .db | |
| 918 | 920 | .prepare(format!( | |
| 919 | 921 | "SELECT github_accounts.github_id, users.username FROM github_accounts | |
| 920 | − | JOIN users ON users.id = github_accounts.user_id WHERE github_id IN ({marks})" | |
| 922 | + | JOIN users ON users.id = github_accounts.user_id WHERE github_id IN ({marks}) AND users.deleted_at IS NULL" | |
| 921 | 923 | )) | |
| 922 | 924 | .bind(&bind)? | |
| 923 | 925 | .all() | |
| 4 | 4 | //! the methods and their arguments. | |
| 5 | 5 | ||
| 6 | 6 | mod access; | |
| 7 | + | mod account_deletion; | |
| 7 | 8 | mod admin; | |
| 8 | 9 | mod aliases; | |
| 9 | 10 | mod avatars; | |
| ⋯ | |||
| 158 | 159 | .prepare(format!( | |
| 159 | 160 | "SELECT users.id, users.username, email_tokens.email_id FROM email_tokens | |
| 160 | 161 | JOIN users ON users.id = email_tokens.user_id | |
| 161 | − | WHERE email_tokens.id = ? AND email_tokens.kind = ? | |
| 162 | + | WHERE email_tokens.id = ? AND email_tokens.kind = ? AND users.deleted_at IS NULL | |
| 162 | 163 | AND email_tokens.expires_at > {SQL_NOW}" | |
| 163 | 164 | )) | |
| 164 | 165 | .bind(&[id.as_str().into(), kind.into()])? | |
| ⋯ | |||
| 312 | 313 | }; | |
| 313 | 314 | self.db | |
| 314 | 315 | .prepare(format!( | |
| 315 | − | "SELECT id, username, password_hash, email_verified_at IS NOT NULL AS verified FROM users WHERE {column} = ?" | |
| 316 | + | "SELECT id, username, password_hash, email_verified_at IS NOT NULL AS verified FROM users | |
| 317 | + | WHERE {column} = ? AND deleted_at IS NULL" | |
| 316 | 318 | )) | |
| 317 | 319 | .bind(&[JsValue::from(value)])? | |
| 318 | 320 | .first::<UserRow>(None) | |
| ⋯ | |||
| 467 | 469 | ||
| 468 | 470 | /// A new session for `user`, who has proved who they are in full. | |
| 469 | 471 | async fn session_for(&self, user: User) -> Result<Outcome<SignedIn>> { | |
| 472 | + | // Whichever way it was proved, a deleted account starts none | |
| 473 | + | // (account_deletion.rs). | |
| 474 | + | if !self.account_live(&user.id).await? { | |
| 475 | + | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Incorrect username or password.")); | |
| 476 | + | } | |
| 470 | 477 | let session_token = crypto::random_hex(32); | |
| 471 | 478 | self.db | |
| 472 | 479 | .prepare(format!( | |
| ⋯ | |||
| 502 | 509 | "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified, | |
| 503 | 510 | users.avatar | |
| 504 | 511 | FROM sessions JOIN users ON users.id = sessions.user_id | |
| 505 | − | WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW}" | |
| 512 | + | WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW} AND users.deleted_at IS NULL" | |
| 506 | 513 | ), | |
| 507 | 514 | &crypto::sha256_hex(&a.session_token), | |
| 508 | 515 | ) | |
| ⋯ | |||
| 522 | 529 | self.find_user( | |
| 523 | 530 | "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM ssh_keys | |
| 524 | 531 | JOIN users ON users.id = ssh_keys.user_id | |
| 525 | − | WHERE fingerprint = ?", | |
| 532 | + | WHERE fingerprint = ? AND users.deleted_at IS NULL", | |
| 526 | 533 | &a.fingerprint, | |
| 527 | 534 | ) | |
| 528 | 535 | .await | |
| ⋯ | |||
| 530 | 537 | ||
| 531 | 538 | async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> { | |
| 532 | 539 | self.find_public_user( | |
| 533 | − | "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ?", | |
| 540 | + | // A deleted account is nobody's to find, mention or add. | |
| 541 | + | "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ? AND deleted_at IS NULL", | |
| 534 | 542 | &a.username.to_lowercase(), | |
| 535 | 543 | ) | |
| 536 | 544 | .await | |
| ⋯ | |||
| 546 | 554 | } | |
| 547 | 555 | let user = self | |
| 548 | 556 | .find_user( | |
| 549 | − | "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ?", | |
| 557 | + | "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ? AND deleted_at IS NULL", | |
| 550 | 558 | &a.username.to_lowercase(), | |
| 551 | 559 | ) | |
| 552 | 560 | .await?; | |
| ⋯ | |||
| 748 | 756 | ||
| 749 | 757 | /// Every 15 minutes: staff hear about waitlist requests that arrived while | |
| 750 | 758 | /// the last summary's window was still open, so none waits on a later one; | |
| 751 | − | /// and deleted workspaces past their restore window are purged | |
| 752 | − | /// (deletion.rs). | |
| 759 | + | /// and deleted workspaces and accounts past their restore window are purged | |
| 760 | + | /// (deletion.rs, account_deletion.rs). | |
| 753 | 761 | #[event(scheduled)] | |
| 754 | 762 | async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { | |
| 755 | 763 | let Ok(db) = env.d1("DB") else { return }; | |
| ⋯ | |||
| 760 | 768 | if let Err(error) = identity.purge_due_workspaces().await { | |
| 761 | 769 | worker::console_error!("workspace purge: {error}"); | |
| 762 | 770 | } | |
| 771 | + | // And deleted accounts past theirs (account_deletion.rs). | |
| 772 | + | if let Err(error) = identity.purge_due_accounts().await { | |
| 773 | + | worker::console_error!("account purge: {error}"); | |
| 774 | + | } | |
| 763 | 775 | // Once: creators of repositories made before they got Admin (members.rs). | |
| 764 | 776 | if let Err(error) = identity.backfill_creator_grants().await { | |
| 765 | 777 | worker::console_error!("creator grants: {error}"); | |
| ⋯ | |||
| 1004 | 1016 | "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?), | |
| 1005 | 1017 | "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?), | |
| 1006 | 1018 | "admin_purge_workspace" => reply(&identity.admin_purge_workspace(args(body)?).await?), | |
| 1019 | + | // Deleting accounts (account_deletion.rs): the person from the | |
| 1020 | + | // site, staff from sudo. There is no API route for it. | |
| 1021 | + | "check_account_deletion" => reply(&identity.check_account_deletion(args(body)?).await?), | |
| 1022 | + | "delete_account" => reply(&identity.delete_account(args(body)?).await?), | |
| 1023 | + | "admin_delete_account" => reply(&identity.admin_delete_account(args(body)?).await?), | |
| 1024 | + | "admin_deleted_accounts" => reply(&identity.admin_deleted_accounts().await?), | |
| 1025 | + | "admin_restore_account" => reply(&identity.admin_restore_account(args(body)?).await?), | |
| 1026 | + | "admin_purge_account" => reply(&identity.admin_purge_account(args(body)?).await?), | |
| 1007 | 1027 | // Workspace aliases, set by staff only; see aliases.rs. | |
| 1008 | 1028 | "admin_aliases" => reply(&identity.admin_aliases().await?), | |
| 1009 | 1029 | "admin_set_alias" => reply(&identity.admin_set_alias(args(body)?).await?), | |
| 140 | 140 | pub async fn profile(&self, a: UsernameArgs) -> Result<Option<Profile>> { | |
| 141 | 141 | Ok(self | |
| 142 | 142 | .db | |
| 143 | − | .prepare(format!("SELECT {PROFILE_COLUMNS} FROM users WHERE username = ?")) | |
| 143 | + | // A deleted account's profile is not found (account_deletion.rs). | |
| 144 | + | .prepare(format!("SELECT {PROFILE_COLUMNS} FROM users WHERE username = ? AND deleted_at IS NULL")) | |
| 144 | 145 | .bind(&[a.username.trim().to_lowercase().into()])? | |
| 145 | 146 | .first::<ProfileRow>(None) | |
| 146 | 147 | .await? |
| 46 | 46 | (Some(id), _) => { | |
| 47 | 47 | self.find_user( | |
| 48 | 48 | "SELECT id, username, email_verified_at IS NOT NULL AS verified | |
| 49 | − | FROM users WHERE id = ?", | |
| 49 | + | FROM users WHERE id = ? AND deleted_at IS NULL", | |
| 50 | 50 | id, | |
| 51 | 51 | ) | |
| 52 | 52 | .await? |
| 178 | 178 | (Some(user_id), _) => { | |
| 179 | 179 | self.find_user( | |
| 180 | 180 | "SELECT id, username, email_verified_at IS NOT NULL AS verified | |
| 181 | − | FROM users WHERE id = ?", | |
| 181 | + | FROM users WHERE id = ? AND deleted_at IS NULL", | |
| 182 | 182 | user_id, | |
| 183 | 183 | ) | |
| 184 | 184 | .await? |
| 449 | 449 | self.db.prepare("DELETE FROM two_factor_challenges WHERE id = ?").bind(&[id.as_str().into()])?.run().await?; | |
| 450 | 450 | let user = self | |
| 451 | 451 | .find_user( | |
| 452 | − | "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?", | |
| 452 | + | "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ? AND deleted_at IS NULL", | |
| 453 | 453 | &challenge.user_id, | |
| 454 | 454 | ) | |
| 455 | 455 | .await?; |
| 356 | 356 | let Some(user) = self | |
| 357 | 357 | .find_public_user( | |
| 358 | 358 | "SELECT id, username, email_verified_at IS NOT NULL AS verified | |
| 359 | − | FROM users WHERE username = ?", | |
| 359 | + | FROM users WHERE username = ? AND deleted_at IS NULL", | |
| 360 | 360 | &a.username.trim().to_lowercase(), | |
| 361 | 361 | ) | |
| 362 | 362 | .await? |
| 7 | 7 | // and each would otherwise cross the distance to it. | |
| 8 | 8 | "placement": { "mode": "off" }, | |
| 9 | 9 | "main": "build/index.js", | |
| 10 | − | // Staff waitlist summaries, and purging deleted workspaces once their | |
| 11 | − | // restore window passes (src/lib.rs `scheduled`). | |
| 10 | + | // Staff waitlist summaries, and purging deleted workspaces and accounts | |
| 11 | + | // once their restore window passes (src/lib.rs `scheduled`). | |
| 12 | 12 | "triggers": { "crons": ["*/15 * * * *"] }, | |
| 13 | 13 | "build": { "command": "node ../../scripts/build-rust-worker.mjs" }, | |
| 14 | 14 | // Reached only through service bindings. | |
| ⋯ | |||
| 56 | 56 | // PROTECTED_WORKSPACES: workspaces nobody can ever delete, by owner, | |
| 57 | 57 | // token or staff, as comma-separated slugs or workspace ids. flagon-io | |
| 58 | 58 | // is protected whatever this says (src/deletion.rs). | |
| 59 | + | // | |
| 60 | + | // PROTECTED_ACCOUNTS: accounts nobody can ever delete, by the person or | |
| 61 | + | // staff, as comma-separated usernames or user ids. g1t, g1t-agent and | |
| 62 | + | // ghost are protected whatever this says (src/account_deletion.rs). | |
| 59 | 63 | "vars": { | |
| 60 | 64 | "PROTECTED_WORKSPACES": "flagon-io", | |
| 65 | + | "PROTECTED_ACCOUNTS": "", | |
| 61 | 66 | "GITHUB_APP_CLIENT_ID": "Iv23liZS94alfjIUn1eW", | |
| 62 | 67 | "REGISTRATION_MODE": "invite", | |
| 63 | 68 | "INVITES_PER_USER": "5", | |
| 782 | 782 | self.index_item(true, &item.repo_id, item.number).await?; | |
| 783 | 783 | } | |
| 784 | 784 | } | |
| 785 | − | "user.updated" => { | |
| 785 | + | // A deleted account's profile is not found, so indexing it | |
| 786 | + | // again drops it from results; a restored one comes back. | |
| 787 | + | "user.updated" | "user.deleting" | "user.restored" | "user.deleted" => { | |
| 786 | 788 | if let Ok(user) = serde_json::from_value::<UserEvent>(data.clone()) { | |
| 787 | 789 | self.index_user(&user.username).await?; | |
| 788 | 790 | } |
| 1 | + | //! An account purged (`user.deleted`, identity's `account_deletion.rs`): | |
| 2 | + | //! what it wrote stays where it is and shows as `ghost`, and it is taken | |
| 3 | + | //! off what it was asked to do. | |
| 4 | + | //! | |
| 5 | + | //! Its issues, pull requests, comments and reviews, plans and messages to | |
| 6 | + | //! agents keep their place and their words; their author becomes `ghost` | |
| 7 | + | //! (`usr_ghost`), and so does whoever asked g1t for an issue or pull | |
| 8 | + | //! request. It is no longer assigned to anything or asked to review, and | |
| 9 | + | //! review requests that reached it through a team are dropped. Stored | |
| 10 | + | //! rather than mapped when read, so every reader agrees. | |
| 11 | + | ||
| 12 | + | use g1t_contracts::account_deletion::{GHOST_ID, GHOST_USERNAME}; | |
| 13 | + | ||
| 14 | + | /// Each statement takes the account's id as `?1` and its username as `?2` | |
| 15 | + | /// (`g1t_kit::user_deleted`). `ghost`'s id and name are written in. | |
| 16 | + | pub(crate) fn statements() -> Vec<String> { | |
| 17 | + | let mut sql = Vec::new(); | |
| 18 | + | for table in ["issues", "pulls", "comments", "plans", "agent_messages"] { | |
| 19 | + | sql.push(format!( | |
| 20 | + | "UPDATE {table} SET author_id = '{GHOST_ID}', author_name = '{GHOST_USERNAME}' WHERE author_id = ?1" | |
| 21 | + | )); | |
| 22 | + | } | |
| 23 | + | for table in ["issues", "pulls"] { | |
| 24 | + | sql.push(format!( | |
| 25 | + | "UPDATE {table} SET requested_by_id = '{GHOST_ID}', requested_by_name = '{GHOST_USERNAME}' WHERE requested_by_id = ?1" | |
| 26 | + | )); | |
| 27 | + | } | |
| 28 | + | for (table, column) in [("issues", "assignees"), ("pulls", "assignees"), ("pulls", "reviewers")] { | |
| 29 | + | sql.push(format!( | |
| 30 | + | "UPDATE {table} SET {column} = (SELECT json_group_array(value) FROM json_each({table}.{column}) WHERE value <> ?2) | |
| 31 | + | WHERE ?1 IS NOT NULL AND json_valid({column}) AND EXISTS (SELECT 1 FROM json_each({table}.{column}) WHERE value = ?2)" | |
| 32 | + | )); | |
| 33 | + | } | |
| 34 | + | sql.push("DELETE FROM team_review_requests WHERE ?1 IS NOT NULL AND username = ?2".to_owned()); | |
| 35 | + | sql | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | #[cfg(test)] | |
| 39 | + | mod tests { | |
| 40 | + | use super::*; | |
| 41 | + | ||
| 42 | + | #[test] | |
| 43 | + | fn everything_it_wrote_becomes_ghosts() { | |
| 44 | + | let sql = statements(); | |
| 45 | + | for table in ["issues", "pulls", "comments", "plans", "agent_messages"] { | |
| 46 | + | assert!( | |
| 47 | + | sql.iter().any(|s| s.starts_with(&format!("UPDATE {table} SET author_id = 'usr_ghost', author_name = 'ghost'"))), | |
| 48 | + | "{table}" | |
| 49 | + | ); | |
| 50 | + | } | |
| 51 | + | assert!(sql.iter().any(|s| s.contains("UPDATE pulls SET requested_by_id = 'usr_ghost'"))); | |
| 52 | + | assert!(sql.iter().any(|s| s.contains("UPDATE pulls SET reviewers"))); | |
| 53 | + | assert!(sql.iter().any(|s| s.contains("team_review_requests"))); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | #[test] | |
| 57 | + | fn every_statement_takes_both_binds() { | |
| 58 | + | // D1 refuses a bind a statement does not name: each names ?1, and | |
| 59 | + | // the ones that need the username name ?2 as well. | |
| 60 | + | for s in statements() { | |
| 61 | + | assert!(s.contains("?1"), "{s}"); | |
| 62 | + | assert_eq!(g1t_kit::user_deleted::binds(&s, "usr_1", "ada").len(), if s.contains("?2") { 2 } else { 1 }); | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } |
Binary or large file; its contents are not shown.
This change is too large to show in full.