Public: anyone can read this; the crew can change it.
Unlink on Settings › Account (open-platform.sh login, parent forge) goes through the confirm page, which says how to link again #7988
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7964-unlink-on-settings-account-open-platform → main 1 commit fork
4 files
changed +103
−7
src/web/pages/account.ts +2 −6
| @@ -143,9 +143,7 @@ function edgeLinkView(ctx: Ctx): Html { | ||
| 143 | 143 | return card( |
| 144 | 144 | linked !== undefined |
| 145 | 145 | ? html`<p>open-platform.sh: linked as <strong>${linked}</strong></p> |
| 146 | <form method="post" action="/settings/edge/unlink"> | |
| 147 | ${btn("Unlink", { type: "submit" })} | |
| 148 | </form>` | |
| 146 | ${confirmAction({ action: "/settings/edge/unlink", verb: "Unlink" })}` | |
| 149 | 147 | : html`<p>open-platform.sh: not linked</p> |
| 150 | 148 | ${carried === undefined |
| 151 | 149 | ? html`<p class="hint">Sign in through open-platform.sh, then come back here to link that login.</p>` |
| @@ -165,9 +163,7 @@ function parentLinkView(ctx: Ctx): Html { | ||
| 165 | 163 | return card( |
| 166 | 164 | linked |
| 167 | 165 | ? html`<p>${host}: linked</p> |
| 168 | <form method="post" action="/settings/parent/unlink"> | |
| 169 | ${btn("Unlink", { type: "submit" })} | |
| 170 | </form>` | |
| 166 | ${confirmAction({ action: "/settings/parent/unlink", verb: "Unlink" })}` | |
| 171 | 167 | : html`<p>${host}: not linked</p> |
| 172 | 168 | <form method="post" action="/settings/parent/link"> |
| 173 | 169 | ${btn(`Link ${host}`, { type: "submit" })} |
src/web/pages/search.ts +20
| @@ -568,6 +568,26 @@ const DESTRUCTIVE: { | ||
| 568 | 568 | phrase: "", |
| 569 | 569 | }), |
| 570 | 570 | }, |
| 571 | { | |
| 572 | pattern: /^\/settings\/edge\/unlink$/, | |
| 573 | describe: () => ({ | |
| 574 | verb: "Unlink", | |
| 575 | title: "Unlink your open-platform.sh login?", | |
| 576 | detail: | |
| 577 | "You can no longer sign in here through open-platform.sh until you sign in that way and link it again on this page.", | |
| 578 | phrase: "", | |
| 579 | }), | |
| 580 | }, | |
| 581 | { | |
| 582 | pattern: /^\/settings\/parent\/unlink$/, | |
| 583 | describe: () => ({ | |
| 584 | verb: "Unlink", | |
| 585 | title: "Unlink the parent forge?", | |
| 586 | detail: | |
| 587 | "You can no longer sign in here through the parent forge until you link it again on this page. The last way in is never removed.", | |
| 588 | phrase: "", | |
| 589 | }), | |
| 590 | }, | |
| 571 | 591 | { |
| 572 | 592 | pattern: /^\/([^/]+)\/([^/]+)\/settings\/proposal-tokens\/([^/]+)\/delete$/, |
| 573 | 593 | describe: (m) => ({ |
test/account-unlink-confirm.test.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** Settings › Account's two Unlink buttons ask first, on the confirm page, which says how to link again (#7964). */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { linkEdgeLogin, linkedLogin } from "../src/core/edge_links.ts"; | |
| 9 | import { linkParent, linkedParent } from "../src/core/federation.ts"; | |
| 10 | import { createSession } from "../src/core/sessions.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | const TOKEN = "edge-secret-0123456789"; | |
| 18 | const ISSUER = "https://parent.test"; | |
| 19 | ||
| 20 | describe("Unlink on Settings › Account goes through the confirm page", () => { | |
| 21 | let dataDir = ""; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let id = ""; | |
| 25 | let cookie = ""; | |
| 26 | const get = (path: string, who = cookie) => fetch(`${servers.url}${path}`, { redirect: "manual", headers: { cookie: who } }); | |
| 27 | const confirmHref = (to: string) => `/-/confirm?to=${encodeURIComponent(to)}`; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dataDir = await mkdtemp(join(tmpdir(), "forge-unlink-confirm-")); | |
| 31 | app = createApp({ dataDir, port: 0, edgeToken: TOKEN, parentUrl: ISSUER }); | |
| 32 | servers = await serve(app); | |
| 33 | await createUser(app.db, { origin: "seed", username: "olive", email: "[email protected]", password: "a-long-family-passphrase", adminIfFirst: true }); | |
| 34 | const made = await createUser(app.db, { origin: "seed", username: "ann", email: "[email protected]", password: "a-long-family-passphrase" }); | |
| 35 | if (!made.ok) throw new Error(made.error); | |
| 36 | id = made.value.id; | |
| 37 | cookie = `forge_session=${createSession(app.db, id).token}`; | |
| 38 | expect(linkEdgeLogin(app.db, made.value, "octo").ok).toBe(true); | |
| 39 | expect(linkParent(app.db, id, ISSUER, "ann-sub")).toEqual({ ok: true, value: "linked" }); | |
| 40 | }, 60_000); | |
| 41 | afterAll(async () => { | |
| 42 | servers.stop(); | |
| 43 | app.close(); | |
| 44 | await rm(dataDir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | test("each Unlink is a link to the confirm page, not a form that posts at once", async () => { | |
| 48 | const page = await (await get("/settings")).text(); | |
| 49 | expect(page).toContain("open-platform.sh: linked as <strong>octo</strong>"); | |
| 50 | expect(page).toContain("parent.test: linked"); | |
| 51 | for (const to of ["/settings/edge/unlink", "/settings/parent/unlink"]) { | |
| 52 | expect(page).toContain(`href="${confirmHref(to)}"`); | |
| 53 | expect(page).not.toContain(`action="${to}"`); | |
| 54 | } | |
| 55 | }); | |
| 56 | ||
| 57 | test("the confirm page words the open-platform.sh unlink and posts to it", async () => { | |
| 58 | const res = await get(confirmHref("/settings/edge/unlink")); | |
| 59 | expect(res.status).toBe(200); | |
| 60 | const body = await res.text(); | |
| 61 | expect(body).toContain("Unlink your open-platform.sh login?"); | |
| 62 | expect(body).toContain("until you sign in that way and link it again on this page."); | |
| 63 | expect(body).toContain('action="/settings/edge/unlink"'); | |
| 64 | expect(body).toContain("Unlink"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("the confirm page words the parent forge unlink and posts to it", async () => { | |
| 68 | const res = await get(confirmHref("/settings/parent/unlink")); | |
| 69 | expect(res.status).toBe(200); | |
| 70 | const body = await res.text(); | |
| 71 | expect(body).toContain("Unlink the parent forge?"); | |
| 72 | expect(body).toContain("The last way in is never removed."); | |
| 73 | expect(body).toContain('action="/settings/parent/unlink"'); | |
| 74 | }); | |
| 75 | ||
| 76 | test("opening the confirm page changes nothing; the links stay until the POST", () => { | |
| 77 | expect(linkedLogin(app.db, id)).toBe("octo"); | |
| 78 | expect(linkedParent(app.db, id, ISSUER)).toBeDefined(); | |
| 79 | }); | |
| 80 | }); |
test/parent-link.test.ts +1 −1
| @@ -99,7 +99,7 @@ describe("link the parent forge from settings", () => { | ||
| 99 | 99 | expect(users(child)).toBe(users0); |
| 100 | 100 | const after = await (await fetch(`${childServers.url}/settings`, { headers: { cookie: carolCookie } })).text(); |
| 101 | 101 | expect(after).not.toContain("not linked"); |
| 102 | expect(after).toContain("/settings/parent/unlink"); | |
| 102 | expect(after).toContain(`/-/confirm?to=${encodeURIComponent("/settings/parent/unlink")}`); | |
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | 105 | test("a linked person then signs in through the parent as the same account", async () => { |
new above
offline