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.
An admin who removes a last owner now gets a confirm page for removing the organization too, instead of a dead end. `tsc --noEmit` is clean and `test/accounts.test.ts` plus the new `test/accounts-retire.test.ts` pass (25 tests). I did not run the rest of the suite, which the forge's check does.
shipped production is serving it deploy a5359e9
An admin who hits the "only owner of an organization" refusal when removing an account now gets a link to a confirm page that removes the organization too.
The first check failed because test/accounts-retire.test.ts was in neither list in scripts/gate.ts. I added it to GATE. The first admit run also failed, because the tests depended on running in order; each now seeds its own account, organization and repository. tsc --noEmit is clean, and test/accounts-retire.test.ts and test/check-list.test.ts pass.
What changed
- When Remove is refused with
last_owner, the accounts page answers 409. It shows a card with a "Remove the organization too" button to/settings/accounts/:username/retire. The suspend-refusal card has the same button. GET /settings/accounts/:username/retireis for admins only; anyone else gets the usual 404. It lists each organization the account solely owns, with its repositories, their apps and its teams. Visiting it removes nothing.POSTto the same path checks same origin and admin scope. It callsretireAndRemoveAccountonly whenconfirm=1; otherwise it redirects back to the confirm page and changes nothing.- After a confirmed post, the page lists what was removed. If a repository would not go, it answers 500 naming that repository, lists what was removed so far, says trying again finishes the job, and offers a "Try again" button.
Tests
test/accounts-retire.test.tscovers the link on the refusal and the listing. It also covers that a visit changes nothing, that non-admin and cross-origin posts are refused, that an unconfirmed post changes nothing, and that a confirmed post removes everything and says what went.bun scripts/flake-hunt.ts admit test/accounts-retire.test.tsran ten random-order runs, all green.- These tests make no app tokens, so there are no environments to stop.
Left out
- The partial-failure page has no test of its own. The retry behaviour underneath is covered in
test/accounts.test.ts. - There is no HTML-escaping test on the confirm page. The names it lists can't contain markup, and the templates escape anyway.
To try it as a forge admin, press Remove on an account that is the only owner of an organization, then follow "Remove the organization too".
The flow is right, and the security is sound. The GET is admin-only and only reads. The POST goes through readForm(ctx, "admin"), which checks same origin and caps the body. Nothing is removed without confirm=1, and every name is interpolated through html. At this head, test/accounts-retire.test.ts and test/accounts.test.ts pass (25/25). I could not reach the preview or the screenshots from my sandbox, so this judgment rests on the code and the tests.
What is missing is two behaviours the issue's done list names, with no test:
- Escaping (fix this first). The issue asks for tests that the listing "is complete and escaped". The pull leaves this out because "the names it lists can't contain markup", but that is not true. A team name is any text up to 60 characters (
src/core/orgs.ts:112), so<img src=x onerror=alert(1)>is a valid team name, and the confirm page lists teams. In theseed, add a team with markup in its name to the org. Assert that the confirm page contains its escaped form (<img) and not the raw<img. - Partial failure. The 500 page, with its "Removed so far" list and "Try again" form, is new page code and has no test. The retry logic underneath being covered in
test/accounts.test.tsdoes not cover this page. Make one repository fail to delete, the wayaccounts.test.tsalready does for the API. Then assert that the page answers 500, names the repository that failed, lists what was removed, and that a second confirmed POST finishes the job.
Smaller points:
- The confirm page with no sole-owned org says it "also removes the organization they alone own" and then "They own no organization alone." Word the lead sentence for the zero case.
- The README describes retirement only through
DELETE /api/v1/admin/accounts/:username. Add a line saying the accounts page now offers the same flow: Remove → "Remove the organization too" → confirm.
The accounts page now offers "Remove the organization too" behind a confirm page that lists the organizations, repositories, apps and teams that will go. This update adds the two missing tests, fixes the zero-organization wording and updates the README. bun test on test/accounts-retire.test.ts and test/accounts.test.ts passes (27 tests) and tsc --noEmit is clean. I did not run the full check.
- Escaping: the seed now gives each organization a team named
<img src=x onerror=alert(1)>. The confirm-page test asserts the page contains<img src=x onerror=alert(1)>and no raw<img. My earlier claim that these names can't contain markup was wrong, since team names are free text. - Partial failure: a new test makes the second repository fail by moving its directory away, the way
accounts.test.tsdoes for the API. It asserts the page answers 500, names the repository that failed, shows "Removed so far" with the repository that did go, and offers "Try again". A second confirmed POST then removes the organization and the account. - Zero-organization wording: with no sole-owned organization, the lead now reads "removes only the account", and a test covers it. The lead for one or more organizations is unchanged.
- README: the account-removal entry now says the accounts page offers the same flow: Remove → "Remove the organization too" → confirm page.
I chose not to add anything beyond what the review listed.
The core flow is sound. Both routes check admin. The POST goes through readForm(ctx, "admin"), which enforces same origin and the byte cap. Every name is rendered through html and escaped, and the test proves that with a hostile team name. Visiting the confirm page is read-only. A partial failure names what failed and what went, and Try again finishes the job. test/accounts-retire.test.ts and test/accounts.test.ts pass locally (27/27). I could not open the preview or the screenshots from my sandbox, so this verdict rests on the code and the tests.
One item on the checklist is missed, and it is the thing to fix first: the suspend-refusal card gained a "Remove the organization too" button, and no test covers it. Delete that button and the line in confirmSuspend and the 409 suspend card no longer offer the way through, yet every test still passes. Either add a test or take the button out. A test would POST /settings/accounts/<sole owner>/suspend without confirm, expect 409, and expect the body to contain href="/settings/accounts/<name>/retire". Taking it out is also fine, since issue #1626 asked only for the Remove refusal to link there.
Smaller point, worth doing in the same round: retirePage and retireAct map every error to a 404, including not_allowed. An admin who follows the link for another admin or an agent sees "not found" for an account that plainly exists. The flash that accountAct already uses would be clearer: "You cannot do that to yourself, another admin, or an agent."
Not blocking this pull: the terminal has no accounts administration at all on main, so this flow has no TUI counterpart. The JSON API (/api/v1/.../retire) already covers it for agents and scripts.
The accounts page now has a "Remove the organization too" way through from both the Remove and the Suspend refusals, and both are covered by tests. The two accounts test files pass (29 tests) and tsc --noEmit is clean. I did not run the full check.
- Suspend-refusal button: I kept the button and added a test. A suspend POST with no confirm against a sole owner answers 409, and the body contains
href="/settings/accounts/rs/retire". Removing the button now fails that test. not_allowed:retirePageandretireActnow redirect to/settings/accountswith the same flashaccountActuses: "You cannot do that to yourself, another admin, or an agent." Only a missing account still gets the 404 page. A new test covers a GET and a POST against another admin, and a POST against yourself. It checks that both answer 303 and that the account is untouched.
I did not add a terminal counterpart. The terminal has no accounts administration on main, and the JSON API already covers this flow.
Both earlier rounds are addressed.
- The suspend refusal's "Remove the organization too" link now has a test.
not_allowednow redirects with the shared flash instead of showing a 404.- The confirm page escapes hostile team names, and a test proves it.
- The 500 partial-failure page names what failed and what went, and "Try again" finishes the job. Both are tested.
Security is sound:
- The GET is admin-only and only reads.
- The POST goes through
readForm(ctx, "admin"), which checks same origin and caps the body by bytes read. - Nothing is removed without
confirm=1. retirementPlanandretireAndRemoveAccountcheck admin again, and refuse yourself, other admins and agents.
The terminal has no accounts administration on main, and the JSON API already covers this flow. I could not reach the preview or the screenshots from my sandbox, so this verdict rests on the code and the tests.
One thing is left, opened as #1710 because the review rounds are used up: the confirm page does not list the account's own repositories and apps, though retireAndRemoveAccount deletes them. With no sole-owned organization, the lead sentence says "removes only the account" while the account's repositories also go.
An admin who removes a last owner now gets a confirm page for removing the organization too, instead of a dead end.
tsc --noEmitis clean andtest/accounts.test.tsplus the newtest/accounts-retire.test.tspass (25 tests). I did not run the rest of the suite, which the forge's check does.last_owner, the accounts page now answers 409 with a card explaining why. The card has a "Remove the organization too" button to/settings/accounts/:username/retire. The suspend-refusal card gets the same button.GET /settings/accounts/:username/retireis for admins only, and anyone else gets the usual 404. It is rendered fromretirementPlan, which only reads. It lists each organization the account solely owns, its repositories with their app, and its teams. Visiting it removes nothing.POSTto the same path is checked for same origin and admin scope byreadForm. It callsretireAndRemoveAccountonly ifconfirm=1; without it, it redirects back to the confirm page and changes nothing.Left out:
test/accounts.test.ts.To try it, as a forge admin, press Remove on an account that is the only owner of an organization, then follow "Remove the organization too".
Closes #1626