Public: anyone can read this; the crew can change it.

The accounts page offers "remove the organization too" behind a confirm page that lists everything that will go #1681

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1626-the-accounts-page-offers-remove-the-orga → main 4 commits fork 7 threads · 7 unresolved

  • Merged. scout/1626-the-accounts-page-offers-remove-the-orga was merged into main as 868f049.

Review

Sign in to review this change.
5 files changed +328 −1
README.md +2 −1
@@ -680,7 +680,8 @@ work here.
680680 do it: apps are stopped, repositories deleted, organizations retired (the
681681 name stays taken), then the account. If a repository will not go, the answer
682682 is 500 with `failed` and `removed`; what is left is intact and the same call
683 again finishes the job.
683 again finishes the job. The accounts page offers the same flow: Remove →
684 "Remove the organization too" → a confirm page listing everything that goes.
684685 - **Apps** — a repository that runs: production on the default branch, a
685686 preview per open pull with its own copy of the data, served by hostname
686687 from the forge's own port, every deploy a run and every run a check.
scripts/gate.ts +1
@@ -25,6 +25,7 @@ export type Exclusion = {
2525 };
2626
2727 export const GATE: readonly string[] = [
28 "test/accounts-retire.test.ts",
2829 "test/accounts.test.ts",
2930 "test/agents.test.ts",
3031 "test/app-domains.test.ts",
src/http/server.ts +4
@@ -17,6 +17,8 @@ import {
1717 } from "./caller.ts";
1818 import {
1919 accountAct,
20 retireAct,
21 retirePage,
2022 accountsPage,
2123 personSignInsPage,
2224 signInAct,
@@ -697,6 +699,8 @@ export function buildRouter(): Router<Handler> {
697699 accountAct("unsuspend"),
698700 );
699701 router.add("POST", "/settings/accounts/:username/remove", accountAct("remove"));
702 router.add("GET", "/settings/accounts/:username/retire", retirePage);
703 router.add("POST", "/settings/accounts/:username/retire", retireAct);
700704 router.add("POST", "/settings/accounts/:username/verify", accountAct("verify"));
701705 router.add("GET", "/settings/crew", crewPage);
702706 router.add("POST", "/settings/crew", agentCreate);
src/web/pages/accounts.ts +141
@@ -8,6 +8,8 @@
88 import {
99 listAccounts,
1010 removeAccount,
11 retirementPlan,
12 retireAndRemoveAccount,
1113 suspendAccount,
1214 unsuspendAccount,
1315 verifyAccount,
@@ -216,6 +218,11 @@ function confirmSuspend(username: string, orgs: readonly string[]): Html {
216218 <form method="post" action="${at}/suspend" class="form-actions">
217219 <input type="hidden" name="confirm" value="1" />
218220 ${btn("Suspend anyway", { type: "submit", size: "sm", variant: "danger" })}
221 ${btn("Remove the organization too", {

No test covers this behaviour. Removing this button leaves every test green. Add a suspend-refusal test that asserts the 409 body links to /settings/accounts/<name>/retire, or drop the button, since the issue asked only for the Remove refusal to link here.

222 href: `${at}/retire`,
223 size: "sm",
224 variant: "danger",
225 })}
219226 ${orgs[0] === undefined
220227 ? null
221228 : btn("Add an owner first", { href: ownersTeam(orgs[0]), size: "sm" })}
@@ -225,6 +232,26 @@ function confirmSuspend(username: string, orgs: readonly string[]): Html {
225232 );
226233 }
227234
235 /** A removal refused for a sole-owned organization: the way through, once. */
236 function refuseRemove(username: string): Html {
237 return card(
238 html`<p>
239 <strong>${username}</strong> is the only owner of an organization
240 (named in their row). Make someone else an owner first, or remove the
241 organization too after seeing everything that would go. Nothing was
242 changed.
243 </p>
244 <div class="form-actions">
245 ${btn("Remove the organization too", {
246 href: `/settings/accounts/${encodeURIComponent(username)}/retire`,
247 size: "sm",
248 variant: "danger",
249 })}
250 </div>`,
251 { tone: "attn" },
252 );
253 }
254
228255 // GET /settings/accounts/:username — where one person is signed in
229256 export function personSignInsPage(ctx: Ctx): Response {
230257 if (!ctx.auth.user) return signInFirst(ctx);
@@ -295,6 +322,12 @@ export function signInAct(what: "token" | "session") {
295322 };
296323 }
297324
325 const cannotDo = (ctx: Ctx): Response =>
326 flashTo(ctx, "/settings/accounts", {
327 message: "You cannot do that to yourself, another admin, or an agent.",
328 tone: "danger",
329 });
330
298331 type Act = "suspend" | "unsuspend" | "remove" | "verify";
299332
300333 const DONE: Readonly<Record<Act, string>> = {
@@ -324,6 +357,8 @@ export function accountAct(act: Act) {
324357 : await removeAccount(ctx.app, ctx.auth.user, username);
325358 if (!done.ok) {
326359 if (done.error === "not_found") return notFoundPage(ctx);
360 if (act === "remove" && done.error === "last_owner")
361 return accountsView(ctx, refuseRemove(username), 409);
327362 if (act === "suspend" && done.error === "last_owner") {
328363 const user = getUserByName(ctx.app.db, username);
329364 const orgs =
@@ -347,3 +382,109 @@ export function accountAct(act: Act) {
347382 });
348383 };
349384 }
385
386 // GET /settings/accounts/:username/retire — everything removal would take; removes nothing
outdated

not_allowed (another admin, an agent, yourself) also becomes a 404 here and in retireAct. Flash the same "You cannot do that…" message accountAct uses, so an admin is not told that an existing account was not found.

387 export function retirePage(ctx: Ctx): Response {
388 if (!ctx.auth.user) return signInFirst(ctx);
389 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
390 const username = ctx.params.username ?? "";
391 const plan = retirementPlan(ctx.app, ctx.auth.user, username);
392 if (!plan.ok)
393 return plan.error === "not_allowed" ? cannotDo(ctx) : notFoundPage(ctx);
394 const at = `/settings/accounts/${encodeURIComponent(username)}`;
395 return shell(
396 ctx,
397 card(
outdated

When there are zero orgs, the sentence above still says it "also removes the organization they alone own". Word the lead for the zero case.

398 html`<p>
399 ${plan.value.length === 0
400 ? html`Removing <strong>${username}</strong> removes only the account.`

This says "removes only the account", but retireAndRemoveAccount also deletes the account's own repositories and tears down their apps (removeOwnedRepos(app, admin, user, …)). retirementPlan should list those too. Follow-up: #1710.

401 : html`Removing <strong>${username}</strong> also removes the
402 ${plan.value.length === 1 ? "organization" : "organizations"} they
403 alone own, with everything below.`}
404 This cannot be undone.
405 </p>
406 ${plan.value.length === 0
407 ? html`<p class="muted">They own no organization alone.</p>`
408 : html`<ul class="rows">
409 ${plan.value.map(
410 (o) => html`<li class="row">
411 <div class="row-main">
412 <strong>${o.org}</strong>
413 <div class="row-meta">
414 ${o.repos.length === 0
outdated

Team names are free text, so this is where an unescaped name would land. html escapes the joined string, but no test pins that down. See the review body.

415 ? "no repositories"
416 : o.repos.map(
417 (r, i) =>
418 html`${i > 0 ? ", " : ""}${r.name}${r.app === null
419 ? null
420 : html` (app ${r.app})`}`,
421 )}
422 </div>
423 <div class="row-meta">
424 teams: ${o.teams.length === 0 ? "none" : o.teams.join(", ")}
425 </div>
426 </div>
427 </li>`,
428 )}
429 </ul>`}
430 <form method="post" action="${at}/retire" class="form-actions">
431 <input type="hidden" name="confirm" value="1" />
432 ${btn("Remove everything", { type: "submit", size: "sm", variant: "danger" })}
433 ${btn("Keep them", { href: "/settings/accounts", size: "sm", variant: "ghost" })}
434 </form>`,
435 { tone: "attn" },
436 ),
437 );
438 }
439
440 // POST /settings/accounts/:username/retire
441 export async function retireAct(ctx: Ctx): Promise<Response> {
442 if (!ctx.auth.user) return signInFirst(ctx);
443 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
444 const form = await readForm(ctx, "admin");
445 if (form instanceof Response) return form;
446 const username = ctx.params.username ?? "";
447 const done = await retireAndRemoveAccount(ctx.app, ctx.auth.user, username, {
448 confirmed: field(form, "confirm") === "1",
449 });
450 if (!done.ok) {
451 if (done.error === "confirm_required")
452 return flashTo(ctx, `/settings/accounts/${encodeURIComponent(username)}/retire`, {
453 message: "Confirm to remove; nothing was changed.",
outdated

This partial-failure branch (500, "Removed so far", Try again) is new behaviour with no test. Cover it by making one repository fail to delete, then retrying.

454 tone: "danger",
455 });
456 return done.error === "not_allowed" ? cannotDo(ctx) : notFoundPage(ctx);
457 }
458 const { removed, failed } = done.value;
459 const list = html`<ul>
460 ${removed.map((n) => html`<li>${n}</li>`)}
461 </ul>`;
462 const back = btn("Back to accounts", { href: "/settings/accounts", size: "sm" });
463 if (failed !== null)
464 return shell(
465 ctx,
466 card(
467 html`<p>
468 <strong>${failed}</strong> would not go, so the removal stopped
469 there. ${removed.length === 0 ? "Nothing was removed yet." : "Removed so far:"}
470 Trying again finishes the job.
471 </p>
472 ${removed.length === 0 ? null : list}
473 <form method="post" action="/settings/accounts/${encodeURIComponent(username)}/retire" class="form-actions">
474 <input type="hidden" name="confirm" value="1" />
475 ${btn("Try again", { type: "submit", size: "sm", variant: "danger" })} ${back}
476 </form>`,
477 { tone: "danger" },
478 ),
479 500,
480 );
481 return shell(
482 ctx,
483 card(
484 html`<p>
485 ${removed.length === 0 ? `${username} was already removed.` : "Removed:"}
486 </p>
487 ${removed.length === 0 ? null : list} ${back}`,
488 ),
489 );
490 }
test/accounts-retire.test.ts added +180
@@ -0,0 +1,180 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { existsSync, renameSync } from "node:fs";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { signUp } from "../src/actions/users.ts";
9 import { createOrg, createTeam } from "../src/core/orgs.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { getUserByName } from "../src/core/users.ts";
12 import { listReposOwnedBy } from "../src/core/repos.ts";
13 import { repoDir } from "../src/git/repos.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { proveAll } from "./helpers/verified.ts";
16
17 describe("accounts: the confirm page for removing an organization too", () => {
18 let dataDir: string;
19 let app: App;
20 let servers: Servers;
21 let base: string;
22 let admin: { authorization: string };
23 let other: { authorization: string };
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-retire-"));
27 app = createApp({ dataDir, port: 0, url: "https://forge.example", edgeToken: "e" });
28 servers = await serve(app);
29 base = servers.url;
30 const mk = async (username: string) => {
31 const u = await signUp(app, {
32 username,
33 email: `${username}@f.dev`,
34 password: "a-long-passphrase",
35 });
36 if (!u.ok) throw new Error(u.error);
37 return u.value;
38 };
39 const owner = await mk("olive");
40 await mk("nosy");
41 mkUser = mk;
42 proveAll(app);
43 admin = { authorization: `Bearer ${createToken(app.db, owner.id, "t", "admin").token}` };
44 const nosy = getUserByName(app.db, "nosy")!;
45 other = { authorization: `Bearer ${createToken(app.db, nosy.id, "t", "admin").token}` };
46 });
47
48 let mkUser: (name: string) => Promise<{ id: string }>;
49 /** A person who alone owns `<name>co`, which holds one repository. */
50 const seed = async (name: string) => {
51 const u = await mkUser(name);
52 proveAll(app);
53 const org = createOrg(app.db, `${name}co`, u.id);
54 if (!org.ok) throw new Error(org.error);
55 const team = createTeam(app.db, org.value.id, "<img src=x onerror=alert(1)>", "read");
56 if (!team.ok) throw new Error(team.error);
57 const made = await createRepo(app, org.value, { name: "ledger" });
58 if (!made.ok) throw new Error(made.error);
59 };
60 afterAll(async () => {
61 servers.stop();
62 app.close();
63 await rm(dataDir, { recursive: true, force: true });
64 });
65
66 const post = (name: string, headers: Record<string, string>) =>
67 fetch(`${base}/settings/accounts/${name}/retire`, {
68 method: "POST",
69 redirect: "manual",
70 headers: { ...headers, "content-type": "application/x-www-form-urlencoded" },
71 body: new URLSearchParams({ confirm: "1" }).toString(),
72 });
73
74 test("the last_owner refusal links to the confirm page", async () => {
75 await seed("ra");
76 const res = await fetch(`${base}/settings/accounts/ra/remove`, {
77 method: "POST",
78 headers: { ...admin, "content-type": "application/x-www-form-urlencoded" },
79 });
80 expect(res.status).toBe(409);
81 expect(await res.text()).toContain('href="/settings/accounts/ra/retire"');
82 });
83
84 test("the suspend refusal links to the confirm page", async () => {
85 await seed("rs");
86 const res = await fetch(`${base}/settings/accounts/rs/suspend`, {
87 method: "POST",
88 headers: { ...admin, "content-type": "application/x-www-form-urlencoded" },
outdated

Add a team whose name contains markup (team names are free text, up to 60 characters). Assert the page shows &lt; and not the raw tag. The issue asks for a test that the listing is escaped.

89 });
90 expect(res.status).toBe(409);
91 expect(await res.text()).toContain('href="/settings/accounts/rs/retire"');
92 });
93
94 test("another admin or yourself is told so, not that nothing was found", async () => {
95 await mkUser("rg");
96 app.db.query("UPDATE user SET is_admin = 1 WHERE username = 'rg'").run();
97 const get = await fetch(`${base}/settings/accounts/rg/retire`, {
98 headers: admin,
99 redirect: "manual",
100 });
101 expect(get.status).toBe(303);
102 expect((await post("olive", admin)).status).toBe(303);
103 expect(getUserByName(app.db, "olive")?.removedAt).toBeNull();
104 });
105
106 test("a visit lists the organization, repositories and teams, and removes nothing", async () => {
107 await seed("rb");
108 const res = await fetch(`${base}/settings/accounts/rb/retire`, { headers: admin });
109 expect(res.status).toBe(200);
110 const body = await res.text();
111 expect(body).toContain("rbco");
112 expect(body).toContain("rbco/ledger");
113 expect(body).toContain("Owners");
114 expect(body).toContain("&lt;img src=x onerror=alert(1)&gt;");
115 expect(body).not.toContain("<img");
116 expect(existsSync(repoDir(app.reposRoot, "rbco", "ledger"))).toBe(true);
117 expect(getUserByName(app.db, "rb")?.removedAt).toBeNull();
118 expect(getUserByName(app.db, "rbco")?.removedAt).toBeNull();
119 });
120
121 test("a non-admin and a cross-origin post are refused, changing nothing", async () => {
122 await seed("rc");
123 expect((await fetch(`${base}/settings/accounts/rc/retire`, { headers: other })).status).toBe(404);
124 expect((await post("rc", other)).status).toBe(404);
125 expect((await post("rc", { ...admin, origin: "https://evil.example" })).status).toBe(403);
126 expect(getUserByName(app.db, "rc")?.removedAt).toBeNull();
127 expect(existsSync(repoDir(app.reposRoot, "rcco", "ledger"))).toBe(true);
128 });
129
130 test("an unconfirmed post removes nothing; a confirmed one says what went", async () => {
131 await seed("rd");
132 const bare = await fetch(`${base}/settings/accounts/rd/retire`, {
133 method: "POST",
134 redirect: "manual",
135 headers: { ...admin, "content-type": "application/x-www-form-urlencoded" },
136 body: "",
137 });
138 expect(bare.status).toBe(303);
139 expect(getUserByName(app.db, "rd")?.removedAt).toBeNull();
140 const done = await post("rd", admin);
141 expect(done.status).toBe(200);
142 const body = await done.text();
143 expect(body).toContain("rdco/ledger");
144 expect(body).toContain("Removed");
145 expect(getUserByName(app.db, "rd")?.removedAt).not.toBeNull();
146 expect(getUserByName(app.db, "rdco")?.removedAt).not.toBeNull();
147 });
148
149 test("a removal that stops partway names what failed and what went; again finishes", async () => {
150 await seed("re");
151 const org = getUserByName(app.db, "reco")!;
152 const made = await createRepo(app, org as never, { name: "second" });
153 if (!made.ok) throw new Error(made.error);
154 const [first, second] = listReposOwnedBy(app.db, org.id).map((r) => r.name) as [string, string];
155 const dir = repoDir(app.reposRoot, "reco", second);
156 renameSync(dir, `${dir}.parked`);
157
158 const failed = await post("re", admin);
159 expect(failed.status).toBe(500);
160 const body = await failed.text();
161 expect(body).toContain(`reco/${second}`);
162 expect(body).toContain("Removed so far");
163 expect(body).toContain(`reco/${first}`);
164 expect(body).toContain("Try again");
165 expect(getUserByName(app.db, "re")?.removedAt).toBeNull();
166
167 const again = await post("re", admin);
168 expect(again.status).toBe(200);
169 expect(getUserByName(app.db, "reco")?.removedAt).not.toBeNull();
170 expect(getUserByName(app.db, "re")?.removedAt).not.toBeNull();
171 });
172
173 test("with no organization of their own, the lead sentence says only the account goes", async () => {
174 await mkUser("rf");
175 const res = await fetch(`${base}/settings/accounts/rf/retire`, { headers: admin });
176 const body = await res.text();
177 expect(body).toContain("removes only the account");
178 expect(body).not.toContain("alone own, with");
179 });
180 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs