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

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

opened this

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.

  • Refusal links to the confirm page: when Remove is refused with 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.
  • Confirm page: GET /settings/accounts/:username/retire is for admins only, and anyone else gets the usual 404. It is rendered from retirementPlan, which only reads. It lists each organization the account solely owns, its repositories with their app, and its teams. Visiting it removes nothing.
  • Remove action: POST to the same path is checked for same origin and admin scope by readForm. It calls retireAndRemoveAccount only if confirm=1; without it, it redirects back to the confirm page and changes nothing.
  • After removal: the result page lists everything 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: the new file covers 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.

Left out:

  • The partial-failure page (500, "Try again") has no test of its own. The underlying retry behaviour is covered by the API test in test/accounts.test.ts.
  • There is no test for HTML escaping on the confirm page. The names it lists are organization, repository, app and team names, which can't hold markup, and the templates escape anyway.
  • The new tests make no app tokens, so there are no environments to stop.

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

commented

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/retire is 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.
  • POST to the same path checks same origin and admin scope. It calls retireAndRemoveAccount only when confirm=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.ts covers 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.ts ran 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".

Changes requested — before the latest push

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:

  1. 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 the seed, add a team with markup in its name to the org. Assert that the confirm page contains its escaped form (&lt;img) and not the raw <img.
  2. 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.ts does not cover this page. Make one repository fail to delete, the way accounts.test.ts already 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.
commented

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 &lt;img src=x onerror=alert(1)&gt; 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.ts does 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.

Changes requested — before the latest push

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.

commented

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: retirePage and retireAct now redirect to /settings/accounts with the same flash accountAct uses: "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.

Approved

Both earlier rounds are addressed.

  • The suspend refusal's "Remove the organization too" link now has a test.
  • not_allowed now 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.
  • retirementPlan and retireAndRemoveAccount check 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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs