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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1626-the-accounts-page-offers-remove-the-orga → main 4 commits fork 7 threads · 7 unresolved
README.md +2 −1
| @@ -680,7 +680,8 @@ work here. | ||
| 680 | 680 | do it: apps are stopped, repositories deleted, organizations retired (the |
| 681 | 681 | name stays taken), then the account. If a repository will not go, the answer |
| 682 | 682 | 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. | |
| 684 | 685 | - **Apps** — a repository that runs: production on the default branch, a |
| 685 | 686 | preview per open pull with its own copy of the data, served by hostname |
| 686 | 687 | 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 = { | ||
| 25 | 25 | }; |
| 26 | 26 | |
| 27 | 27 | export const GATE: readonly string[] = [ |
| 28 | "test/accounts-retire.test.ts", | |
| 28 | 29 | "test/accounts.test.ts", |
| 29 | 30 | "test/agents.test.ts", |
| 30 | 31 | "test/app-domains.test.ts", |
src/http/server.ts +4
| @@ -17,6 +17,8 @@ import { | ||
| 17 | 17 | } from "./caller.ts"; |
| 18 | 18 | import { |
| 19 | 19 | accountAct, |
| 20 | retireAct, | |
| 21 | retirePage, | |
| 20 | 22 | accountsPage, |
| 21 | 23 | personSignInsPage, |
| 22 | 24 | signInAct, |
| @@ -697,6 +699,8 @@ export function buildRouter(): Router<Handler> { | ||
| 697 | 699 | accountAct("unsuspend"), |
| 698 | 700 | ); |
| 699 | 701 | 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); | |
| 700 | 704 | router.add("POST", "/settings/accounts/:username/verify", accountAct("verify")); |
| 701 | 705 | router.add("GET", "/settings/crew", crewPage); |
| 702 | 706 | router.add("POST", "/settings/crew", agentCreate); |
src/web/pages/accounts.ts +141
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | import { |
| 9 | 9 | listAccounts, |
| 10 | 10 | removeAccount, |
| 11 | retirementPlan, | |
| 12 | retireAndRemoveAccount, | |
| 11 | 13 | suspendAccount, |
| 12 | 14 | unsuspendAccount, |
| 13 | 15 | verifyAccount, |
| @@ -216,6 +218,11 @@ function confirmSuspend(username: string, orgs: readonly string[]): Html { | ||
| 216 | 218 | <form method="post" action="${at}/suspend" class="form-actions"> |
| 217 | 219 | <input type="hidden" name="confirm" value="1" /> |
| 218 | 220 | ${btn("Suspend anyway", { type: "submit", size: "sm", variant: "danger" })} |
| 221 | ${btn("Remove the organization too", { | |
| 222 | href: `${at}/retire`, | |
| 223 | size: "sm", | |
| 224 | variant: "danger", | |
| 225 | })} | |
| 219 | 226 | ${orgs[0] === undefined |
| 220 | 227 | ? null |
| 221 | 228 | : btn("Add an owner first", { href: ownersTeam(orgs[0]), size: "sm" })} |
| @@ -225,6 +232,26 @@ function confirmSuspend(username: string, orgs: readonly string[]): Html { | ||
| 225 | 232 | ); |
| 226 | 233 | } |
| 227 | 234 | |
| 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 | ||
| 228 | 255 | // GET /settings/accounts/:username — where one person is signed in |
| 229 | 256 | export function personSignInsPage(ctx: Ctx): Response { |
| 230 | 257 | if (!ctx.auth.user) return signInFirst(ctx); |
| @@ -295,6 +322,12 @@ export function signInAct(what: "token" | "session") { | ||
| 295 | 322 | }; |
| 296 | 323 | } |
| 297 | 324 | |
| 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 | ||
| 298 | 331 | type Act = "suspend" | "unsuspend" | "remove" | "verify"; |
| 299 | 332 | |
| 300 | 333 | const DONE: Readonly<Record<Act, string>> = { |
| @@ -324,6 +357,8 @@ export function accountAct(act: Act) { | ||
| 324 | 357 | : await removeAccount(ctx.app, ctx.auth.user, username); |
| 325 | 358 | if (!done.ok) { |
| 326 | 359 | if (done.error === "not_found") return notFoundPage(ctx); |
| 360 | if (act === "remove" && done.error === "last_owner") | |
| 361 | return accountsView(ctx, refuseRemove(username), 409); | |
| 327 | 362 | if (act === "suspend" && done.error === "last_owner") { |
| 328 | 363 | const user = getUserByName(ctx.app.db, username); |
| 329 | 364 | const orgs = |
| @@ -347,3 +382,109 @@ export function accountAct(act: Act) { | ||
| 347 | 382 | }); |
| 348 | 383 | }; |
| 349 | 384 | } |
| 385 | ||
| 386 | // GET /settings/accounts/:username/retire — everything removal would take; removes nothing | |
outdated
|
||
| 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 |
||
| 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. |
||
| 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 |
||
| 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("<img src=x onerror=alert(1)>"); | |
| 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 | }); |
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.