Skip to content

Commit

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.

syntaqxcommitted Parent35b6acdBrowse files
49 files+2553−580/49 viewed
+1−0
5858 | `package.visibility_changed` | A package was made public or private. |
5959 | `package.linked`, `package.unlinked` | A package was linked to a repository (from its settings, or by an image's source label), or unlinked. |
6060 | `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. |
6162 | `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). |
6263
6364 Through the API and the MCP server, the call itself is recorded under its
+63−1
11 ---
22 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.
44 ---
55
66 ## Creating an account
3535 | 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. |
3636 | Two-factor authentication | [`/settings/two-factor`](https://g1t.sh/settings/two-factor) | [An authenticator app and recovery codes](#two-factor-authentication). |
3737 | 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). |
3839
3940 `g1t.sh/settings` opens Profile.
4041
960961 [audit log](/guides/audit-log/) of each workspace you belong to, where its
961962 owners see them.
962963
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+
9631025 ## What g1t stores
9641026
9651027 Passwords are stored as salted PBKDF2-SHA256 hashes. Sessions and tokens are
+1−1
194194
195195 | Resource | |
196196 | --- | --- |
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. |
198198 | [Workspaces](/reference/api/workspaces/create-workspace/) | Creating a workspace. |
199199 | [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/). |
200200 | [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/). |
+36−0
7070 protected workspace. Both go in the workspace's audit log, as g1t, and in
7171 sudo's (`workspace_restored`, `workspace_purged`), naming the staff
7272 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.
73109 - **Aliases** (`/aliases`, under Customers): names that lead to a
74110 workspace, set by staff only; there is no way for a customer to make
75111 one, and nothing user-facing mentions them. `g1t`, the product's name,
+41−0
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+});
+34−0
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+}
+4−0
113113 // From identity: deleted workspaces staff restored or purged.
114114 workspace_restored: "Workspace restored",
115115 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",
116120 // From identity: workspace aliases staff set or removed.
117121 alias_added: "Alias added",
118122 alias_removed: "Alias removed",
+1−0
77 route("workspaces", "routes/workspaces.tsx"),
88 route("workspaces/deleted", "routes/deleted-workspaces.tsx"),
99 route("workspaces/:slug", "routes/workspace.tsx"),
10+ route("users/deleted", "routes/deleted-accounts.tsx"),
1011 route("users/:username", "routes/user.tsx"),
1112 route("enterprises", "routes/enterprises.tsx"),
1213 route("invites", "routes/invites.tsx"),
+156−0
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+}
+170−6
11 import { ArrowLeft } from "lucide-react";
22 import { Form, Link, data, redirect } from "react-router";
33
4−import { securityEventLabel } from "@g1t/contracts";
4+import { ACCOUNT_RESTORE_DAYS, type AdminUser, securityEventLabel } from "@g1t/contracts";
55
66 import type { Route } from "./+types/user";
77 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";
811 import { accountsAdmin } from "~/lib/services.server";
912 import { settle } from "~/lib/settle";
1013 import { requireStaff } from "~/lib/staff";
1821 requireStaff(context);
1922 const result = await settle(accountsAdmin.user(params.username));
2023 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");
2126 return {
2227 user: result.ok ? result.value : null,
2328 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(),
2532 };
2633 }
2734
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+ */
2941 export async function action({ params, request, context }: Route.ActionArgs) {
3042 const staff = requireStaff(context);
3143 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+ }
3271 const email = String(form.get("email") ?? "").trim();
3372 const reason = String(form.get("reason") ?? "").trim();
3473 if (!reason) return data({ error: "Say why. The person sees the reason in their security log.", email }, { status: 422 });
3877 }
3978
4079 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;
4282 if (!user) {
4383 return (
4484 <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
5292 <ArrowLeft size={14} /> Workspaces
5393 </Link>
5494 <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+ }
56102 description={
57103 <>
58104 Account <span className="font-mono">{user.id}</span>, made <When at={user.createdAt} />.{" "}
65111 <Notice tone="ok">Removed {removed}. The person was told, with the reason.</Notice>
66112 </div>
67113 )}
114+ {done && (
115+ <div className="mt-5">
116+ <Notice tone="ok">{done}</Notice>
117+ </div>
118+ )}
68119
69120 <Section
70121 id="emails"
100151 Remove
101152 </Button>
102153 </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>}
104155 </li>
105156 ))}
106157 </ul>
131182 </ul>
132183 )}
133184 </Section>
185+
186+ <AccountSection user={user} error={accountError} now={now} />
134187 </main>
135188 );
136189 }
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+}
+14−6
100100 <h1 className="text-2xl font-semibold tracking-tight">Workspaces</h1>
101101 <p className="mt-1 text-sm text-muted">Every workspace, who owns it, and how it pays this month.</p>
102102 </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>
109117 </div>
110118
111119 <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
+177−0
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+}
+62−0
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+});
+50−0
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+}
+6−1
1313 | "github"
1414 | "applications"
1515 | "two-factor"
16− | "security-log";
16+ | "security-log"
17+ | "account";
1718
1819 /** Each page's name and what it is for, in the sidebar's order. */
1920 export const ACCOUNT_SETTINGS: Record<AccountSettingsPage, { title: string; about: string }> = {
5354 about:
5455 "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.",
5556 },
57+ account: {
58+ title: "Account",
59+ about: "Your username, and deleting your account.",
60+ },
5661 };
5762
5863 /** Where the first page is: `/settings` goes there. */
+1−0
4040 route("applications", "routes/settings/applications.tsx"),
4141 route("two-factor", "routes/settings/two-factor.tsx"),
4242 route("security-log", "routes/settings/security-log.tsx"),
43+ route("account", "routes/settings/account.tsx"),
4344 ]),
4445 // The account menu's header: name, primary email and invites left.
4546 route("settings/menu.json", "routes/settings-menu-json.ts"),
+76−0
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+}
+280−0
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+}
+10−2
361361 /// `two_factor_disabled`, `recovery_codes_regenerated`,
362362 /// `recovery_code_used`, `token_created`, `token_deleted`,
363363 /// `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).
366367 pub kind: String,
367368 /// The address concerned, or what changed.
368369 pub detail: Option<String>,
500501 pub emails: Vec<AccountEmail>,
501502 pub private_email: bool,
502503 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>,
503511 }
504512
505513 /// `admin_remove_email`: staff remove an address from an account, such as
+40−0
810810 pub slug: String,
811811 }
812812
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+
813853 /// `user.updated`: an account was made, or changed what its profile shows
814854 /// (name, bio, avatar). Nothing private: ask identity for the profile.
815855 #[derive(Debug, Serialize, serde::Deserialize)]
+1−0
66
77 pub mod about;
88 pub mod access;
9+pub mod account_deletion;
910 pub mod accounts;
1011 pub mod actions;
1112 pub mod agents;
+8−4
4848 "notifications",
4949 ];
5050
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"];
5557
5658 /// Whether `value`, whatever its case, is a name nobody can register or
5759 /// rename a workspace to: a route, or g1t's own.
128130 }
129131 assert!(!is_valid_namespace("g1t"));
130132 assert!(!is_valid_namespace("g1t-agent"));
133+ assert!(!is_valid_namespace("ghost"));
134+ assert_eq!(claimable_namespace(" Ghost "), None);
131135 }
132136
133137 #[test]
+41−0
489489 }
490490 }
491491
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+
492533 /// Dropping what a service keeps for a workspace alone when the workspace
493534 /// is deleted.
494535 pub mod deleted {
+74−0
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+};
+36−0
22 * A person's email addresses and the security of their account, on the
33 * identity service. Mirrors `crates/contracts/src/accounts.rs`.
44 */
5+import type { AccountDeletion, DeletedAccount } from "./account-deletion";
56 import type { ServiceBinding } from "./clients";
67 import type { User } from "./identity";
78 import type { Result } from "./result";
118119 emails: AccountEmail[];
119120 privateEmail: boolean;
120121 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;
121126 };
122127
123128 /** Where an account's two-factor authentication stands. */
174179 twoFactorDisable(user: User, code: string, reauth: Reauth): Promise<Result<boolean>>;
175180 /** New recovery codes, replacing the old ones. Needs `reauth`. */
176181 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>>;
177191 }
178192
179193 /** Staff only, for sudo.g1t.sh. */
181195 user(username: string): Promise<AdminUser | null>;
182196 /** Removes an address with a reason the person sees; never the last confirmed one. */
183197 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>>;
184210 }
185211
186212 async function call<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
210236 twoFactorEnable: (user, code, reauth) => call(identity, "two_factor_enable", { user, code, reauth }),
211237 twoFactorDisable: (user, code, reauth) => call(identity, "two_factor_disable", { user, code, reauth }),
212238 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 }),
213241 };
214242 }
215243
217245 return {
218246 user: (username) => call(identity, "admin_user", { username }),
219247 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 }),
220252 };
221253 }
222254
266298 return `Revoked ${detail}`;
267299 case "oauth_grant_rescoped":
268300 return `Changed what ${detail} may do`;
301+ case "account_deleted":
302+ return "Deleted the account";
303+ case "account_restored":
304+ return "Restored the account";
269305 default:
270306 return detail ? `${event.kind}: ${detail}` : event.kind;
271307 }
+17−0
142142 "branch.renamed": { repoId: string; from: string; to: string; defaultBranch: boolean };
143143 /** An account was made, or changed what its profile shows. Ask identity for the profile. */
144144 "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 };
145162 /** A workspace was made, or its name, description or icon changed. */
146163 "workspace.updated": { workspaceId: string; slug: string };
147164 /** Someone, or g1t staff, made an invite. Never the code or the address. */
+1−0
11 export * from "./about";
22 export * from "./access";
3+export * from "./account-deletion";
34 export * from "./accounts";
45 export * from "./actions";
56 export * from "./agents";
+3−2
88 "api", "mcp", "login", "logout", "register", "new", "settings", "search",
99 "admin", "auth", "integrations", "pulls", "issues", "verify", "confirm-email", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets", "avatars", "docs", "explore", "about", "pricing",
1010 // 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",
1314 // Trust pages on g1t.sh, and names kept for them.
1415 "policies", "security", "support", "status", "terms", "privacy", "help", "blog",
1516 // Invite links, and the waitlist.
+18−1
15591559 // --- Keeping up ------------------------------------------------------------------
15601560
15611561 /// 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.
15631563 pub async fn follow(db: &D1Database, events: &[Event]) -> Result<()> {
15641564 let mut statements = Vec::new();
15651565 for event in events {
16261626 .bind(&[text("slug").into()])?,
16271627 );
16281628 }
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+ }
16291646 _ => {}
16301647 }
16311648 }
+37−0
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');
+1−1
569569 }
570570 Ok(self
571571 .db
572− .prepare("SELECT id, username FROM users WHERE username = ?")
572+ .prepare("SELECT id, username FROM users WHERE username = ? AND deleted_at IS NULL")
573573 .bind(&[username.trim().trim_start_matches('@').to_lowercase().into()])?
574574 .first::<Person>(None)
575575 .await?
+913−0
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+}
+1−1
9393 }
9494
9595 /// 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<()> {
9797 let Some(key) = key.filter(|key| is_key(key)) else {
9898 return Ok(());
9999 };
+7−2
193193 WHERE w.deleted_at IS NOT NULL";
194194
195195 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.
197199 pub async fn slug_deleted(&self, slug: &str) -> Result<bool> {
198200 Ok(self
199201 .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+ )
201206 .bind(&[slug.to_lowercase().into()])?
202207 .first::<serde_json::Value>(None)
203208 .await?
+1−1
152152 let Some(user) = self
153153 .find_user(
154154 "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",
156156 &user_id,
157157 )
158158 .await?
+1−1
3232 let sql = match a.kind.as_str() {
3333 "user" => {
3434 "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 ?"
3636 }
3737 "workspace" => {
3838 "SELECT id, slug, name, description AS bio, avatar, created_at
+30−0
248248 .await
249249 }
250250
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+
251281 /// An invite email: to make an account, or for an existing one to join a
252282 /// workspace.
253283 pub struct InviteEmail<'a> {
+26−9
3737
3838 use std::collections::HashMap;
3939
40+use g1t_contracts::account_deletion::{GHOST_ID, GHOST_USERNAME};
4041 use g1t_contracts::accounts::*;
4142 use g1t_contracts::events::UserEmailChanged;
4243 use g1t_contracts::identity::{UserArgs, UsernameArgs};
952953 .db
953954 .prepare(
954955 "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",
956957 )
957958 .bind(&[email.as_str().into()])?
958959 .first::<ResetTarget>(None)
964965 .prepare(
965966 "SELECT u.id AS user_id, u.username, e.id AS email_id, e.display FROM user_emails e
966967 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",
968969 )
969970 .bind(&[email.as_str().into()])?
970971 .first::<ResetTarget>(None)
981982 id: String,
982983 username: String,
983984 avatar: Option<String>,
985+ /// 1 for a purged account's username (`deleted_users`).
986+ #[serde(default)]
987+ purged: u8,
984988 }
985989 let mut owners = HashMap::new();
986990 let mut plain: Vec<String> = Vec::new();
10041008 .db
10051009 .prepare(format!(
10061010 "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})"
10081012 ))
10091013 .bind(&bind)?
10101014 .all()
10211025 let rows = self
10221026 .db
10231027 .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})"
10251033 ))
1026− .bind(&bind)?
1034+ .bind(&[bind.clone(), bind].concat())?
10271035 .all()
10281036 .await?
10291037 .results::<Row>()?;
10311039 if let Some(row) = rows.iter().find(|row| row.username == username)
10321040 && suffix.as_deref().is_none_or(|suffix| id_suffix(&row.id) == suffix)
10331041 {
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);
10381049 }
10391050 }
10401051 }
11171128 let rows = self.email_rows(user_id).await?;
11181129 let log = self.security_events(user_id, true).await?;
11191130 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?;
11201135 Ok(Some(AdminUser {
11211136 id: account.id,
11221137 username: account.username,
11241139 emails: emails.emails,
11251140 private_email: emails.private_email,
11261141 log,
1142+ deletion,
1143+ deleted,
11271144 }))
11281145 }
11291146
+5−3
506506
507507 async fn user_by_id(&self, user_id: &str) -> Result<Option<User>> {
508508 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",
510510 user_id,
511511 )
512512 .await
671671 Outcome::Ok(GithubFinished::Linked { login: github.login, next })
672672 }
673673 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).
675676 let Some(user) = self.user_by_id(&user_id).await? else {
676677 return Ok(Outcome::fail(FailureCode::NotFound, TRY_AGAIN));
677678 };
679+ self.link(&user_id, github.id, &github.login, Some(&tokens)).await?;
678680 self.audit_github(&user, "github.sign_in", format!("Signed in with GitHub (@{})", github.login)).await;
679681 self.signed_in(user, false, next).await?
680682 }
917919 .db
918920 .prepare(format!(
919921 "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"
921923 ))
922924 .bind(&bind)?
923925 .all()
+28−8
44 //! the methods and their arguments.
55
66 mod access;
7+mod account_deletion;
78 mod admin;
89 mod aliases;
910 mod avatars;
158159 .prepare(format!(
159160 "SELECT users.id, users.username, email_tokens.email_id FROM email_tokens
160161 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
162163 AND email_tokens.expires_at > {SQL_NOW}"
163164 ))
164165 .bind(&[id.as_str().into(), kind.into()])?
312313 };
313314 self.db
314315 .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"
316318 ))
317319 .bind(&[JsValue::from(value)])?
318320 .first::<UserRow>(None)
467469
468470 /// A new session for `user`, who has proved who they are in full.
469471 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+ }
470477 let session_token = crypto::random_hex(32);
471478 self.db
472479 .prepare(format!(
502509 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified,
503510 users.avatar
504511 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"
506513 ),
507514 &crypto::sha256_hex(&a.session_token),
508515 )
522529 self.find_user(
523530 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM ssh_keys
524531 JOIN users ON users.id = ssh_keys.user_id
525− WHERE fingerprint = ?",
532+ WHERE fingerprint = ? AND users.deleted_at IS NULL",
526533 &a.fingerprint,
527534 )
528535 .await
530537
531538 async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
532539 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",
534542 &a.username.to_lowercase(),
535543 )
536544 .await
546554 }
547555 let user = self
548556 .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",
550558 &a.username.to_lowercase(),
551559 )
552560 .await?;
748756
749757 /// Every 15 minutes: staff hear about waitlist requests that arrived while
750758 /// 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).
753761 #[event(scheduled)]
754762 async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
755763 let Ok(db) = env.d1("DB") else { return };
760768 if let Err(error) = identity.purge_due_workspaces().await {
761769 worker::console_error!("workspace purge: {error}");
762770 }
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+ }
763775 // Once: creators of repositories made before they got Admin (members.rs).
764776 if let Err(error) = identity.backfill_creator_grants().await {
765777 worker::console_error!("creator grants: {error}");
10041016 "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?),
10051017 "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?),
10061018 "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?),
10071027 // Workspace aliases, set by staff only; see aliases.rs.
10081028 "admin_aliases" => reply(&identity.admin_aliases().await?),
10091029 "admin_set_alias" => reply(&identity.admin_set_alias(args(body)?).await?),
+2−1
140140 pub async fn profile(&self, a: UsernameArgs) -> Result<Option<Profile>> {
141141 Ok(self
142142 .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"))
144145 .bind(&[a.username.trim().to_lowercase().into()])?
145146 .first::<ProfileRow>(None)
146147 .await?
+1−1
4646 (Some(id), _) => {
4747 self.find_user(
4848 "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",
5050 id,
5151 )
5252 .await?
+1−1
178178 (Some(user_id), _) => {
179179 self.find_user(
180180 "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",
182182 user_id,
183183 )
184184 .await?
+1−1
449449 self.db.prepare("DELETE FROM two_factor_challenges WHERE id = ?").bind(&[id.as_str().into()])?.run().await?;
450450 let user = self
451451 .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",
453453 &challenge.user_id,
454454 )
455455 .await?;
+1−1
356356 let Some(user) = self
357357 .find_public_user(
358358 "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",
360360 &a.username.trim().to_lowercase(),
361361 )
362362 .await?
+7−2
77 // and each would otherwise cross the distance to it.
88 "placement": { "mode": "off" },
99 "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`).
1212 "triggers": { "crons": ["*/15 * * * *"] },
1313 "build": { "command": "node ../../scripts/build-rust-worker.mjs" },
1414 // Reached only through service bindings.
5656 // PROTECTED_WORKSPACES: workspaces nobody can ever delete, by owner,
5757 // token or staff, as comma-separated slugs or workspace ids. flagon-io
5858 // 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).
5963 "vars": {
6064 "PROTECTED_WORKSPACES": "flagon-io",
65+ "PROTECTED_ACCOUNTS": "",
6166 "GITHUB_APP_CLIENT_ID": "Iv23liZS94alfjIUn1eW",
6267 "REGISTRATION_MODE": "invite",
6368 "INVITES_PER_USER": "5",
+3−1
782782 self.index_item(true, &item.repo_id, item.number).await?;
783783 }
784784 }
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" => {
786788 if let Ok(user) = serde_json::from_value::<UserEvent>(data.clone()) {
787789 self.index_user(&user.username).await?;
788790 }
+65−0
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+}
+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.