Public: anyone can read this; the crew can change it.
The terminal's "What I can do" offers `u` and `p` only when there is a link to undo, and asks before it unlinks #7955
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7950-the-terminal-s-what-i-can-do-offers-u-an → main 1 commit fork
2 files
changed +79
−15
src/tui/screens/can.ts +51 −11
| @@ -2,8 +2,9 @@ | ||
| 2 | 2 | * "What I can do" in the terminal, as the account page and the `whoami_can` |
| 3 | 3 | * tool have it: the signed-in account's role and its row of the roles table, |
| 4 | 4 | * and, where the platform edge signs people in, the open-platform.sh login |
| 5 | * linked to the account; `u` unlinks it (SSH carries no edge header, so | |
| 6 | * linking is the web page's alone). An unverified email says so in one line | |
| 5 | * linked to the account; `u` unlinks it, and `p` the parent forge, each | |
| 6 | * offered only while there is a link and only after a y/n (SSH carries no | |
| 7 | * edge header, so linking again is the web page's alone). An unverified email says so in one line | |
| 7 | 8 | * and what it holds back; `v` asks `resendVerification` for another link, as |
| 8 | 9 | * the account page and MCP `email_verify` do, and says the outcome in the |
| 9 | 10 | * API's words — never the link. |
| @@ -18,12 +19,23 @@ import { verifyWords } from "../../actions/verify_words.ts"; | ||
| 18 | 19 | import { emailVerified } from "../../core/verify.ts"; |
| 19 | 20 | import { isStandInEmail } from "../../core/users.ts"; |
| 20 | 21 | import type { Ctx, Screen } from "../app/app.ts"; |
| 22 | import { confirmScreen } from "./common.ts"; | |
| 21 | 23 | import { binding } from "../app/keys.ts"; |
| 22 | 24 | import type { Session } from "../session.ts"; |
| 23 | 25 | import { style } from "../term/style.ts"; |
| 24 | 26 | import { truncate } from "../term/width.ts"; |
| 25 | 27 | |
| 26 | 28 | export function canScreen(f: Session, ctx: Ctx): Screen { |
| 29 | // The login `u` would unlink, and the issuer whose link `p` would: each | |
| 30 | // undefined when there is nothing to unlink, so the key is not offered. | |
| 31 | const unlinkableLogin = (): string | undefined => | |
| 32 | f.user === undefined || activeEdgeToken(f.app) === undefined ? undefined : linkedLogin(f.app.db, f.user.id); | |
| 33 | const unlinkableIssuer = (): string | undefined => { | |
| 34 | const issuer = parentIssuerOf(f.app.config); | |
| 35 | if (f.user === undefined || issuer === undefined) return undefined; | |
| 36 | return linkedParent(f.app.db, f.user.id, issuer) === undefined ? undefined : issuer; | |
| 37 | }; | |
| 38 | const relink = "It is linked again from Settings on the web. y/n"; | |
| 27 | 39 | const edgeLogin = (): string | undefined => { |
| 28 | 40 | if (f.user === undefined || activeEdgeToken(f.app) === undefined) return undefined; |
| 29 | 41 | const linked = linkedLogin(f.app.db, f.user.id); |
| @@ -52,13 +64,13 @@ export function canScreen(f: Session, ctx: Ctx): Screen { | ||
| 52 | 64 | const sent = await resendVerification(f.app, f.user); |
| 53 | 65 | verifySaid = verifyWords(sent.ok ? sent.value : sent.error); |
| 54 | 66 | }; |
| 55 | return { | |
| 67 | const screen: Screen = { | |
| 56 | 68 | title: "What I can do", |
| 57 | 69 | get keys() { |
| 58 | 70 | return [ |
| 59 | 71 | ...(mayResend() ? [binding(["v"], "send another verification email")] : []), |
| 60 | binding(["u"], "unlink open-platform.sh"), | |
| 61 | binding(["p"], "unlink parent forge"), | |
| 72 | ...(unlinkableLogin() === undefined ? [] : [binding(["u"], "unlink open-platform.sh")]), | |
| 73 | ...(unlinkableIssuer() === undefined ? [] : [binding(["p"], "unlink parent forge")]), | |
| 62 | 74 | binding(["esc"], "back"), |
| 63 | 75 | ]; |
| 64 | 76 | }, |
| @@ -81,15 +93,43 @@ export function canScreen(f: Session, ctx: Ctx): Screen { | ||
| 81 | 93 | return true; |
| 82 | 94 | } |
| 83 | 95 | if (e.key === "p") { |
| 84 | const issuer = parentIssuerOf(f.app.config); | |
| 85 | const done = issuer === undefined ? undefined : unlinkParent(f.app.db, f.user.id, issuer); | |
| 86 | if (done?.ok === false) refused = LAST_WAY_IN; | |
| 87 | else if (done?.value === "unlinked") f.app.bus.append({ kind: "account.parent_unlinked", actorId: f.user.id }); | |
| 96 | const issuer = unlinkableIssuer(); | |
| 97 | if (issuer === undefined) return false; | |
| 98 | ctx.push( | |
| 99 | confirmScreen(ctx, screen, { | |
| 100 | title: "Unlink parent forge", | |
| 101 | body: ["Unlink the parent forge?", relink], | |
| 102 | actions: [{ label: "Unlink", variant: "danger" }, { label: "Cancel", variant: "ghost" }], | |
| 103 | yesNo: true, | |
| 104 | onChoose(i) { | |
| 105 | if (i !== 0 || f.user === undefined) return; | |
| 106 | const done = unlinkParent(f.app.db, f.user.id, issuer); | |
| 107 | if (!done.ok) refused = LAST_WAY_IN; | |
| 108 | else if (done.value === "unlinked") f.app.bus.append({ kind: "account.parent_unlinked", actorId: f.user.id }); | |
| 109 | ctx.invalidate(); | |
| 110 | }, | |
| 111 | }), | |
| 112 | ); | |
| 88 | 113 | return true; |
| 89 | 114 | } |
| 90 | if (activeEdgeToken(f.app) !== undefined && unlinkEdgeLogin(f.app.db, f.user.id)) | |
| 91 | f.app.bus.append({ kind: "account.edge_unlinked", actorId: f.user.id }); | |
| 115 | const login = unlinkableLogin(); | |
| 116 | if (login === undefined) return false; | |
| 117 | ctx.push( | |
| 118 | confirmScreen(ctx, screen, { | |
| 119 | title: "Unlink open-platform.sh", | |
| 120 | body: [`Unlink open-platform.sh login ${login}?`, relink], | |
| 121 | actions: [{ label: "Unlink", variant: "danger" }, { label: "Cancel", variant: "ghost" }], | |
| 122 | yesNo: true, | |
| 123 | onChoose(i) { | |
| 124 | if (i !== 0 || f.user === undefined) return; | |
| 125 | if (activeEdgeToken(f.app) !== undefined && unlinkEdgeLogin(f.app.db, f.user.id)) | |
| 126 | f.app.bus.append({ kind: "account.edge_unlinked", actorId: f.user.id }); | |
| 127 | ctx.invalidate(); | |
| 128 | }, | |
| 129 | }), | |
| 130 | ); | |
| 92 | 131 | return true; |
| 93 | 132 | }, |
| 94 | 133 | }; |
| 134 | return screen; | |
| 95 | 135 | } |
test/tui-can.test.ts +28 −4
| @@ -75,14 +75,35 @@ describe("what I can do in the terminal", () => { | ||
| 75 | 75 | expect(admin).toContain("Administer the forge: yes"); |
| 76 | 76 | }); |
| 77 | 77 | |
| 78 | test("with nothing linked, u and p are not offered and do nothing", async () => { | |
| 79 | const bob = getUserByName(forge.db, "bob")!; | |
| 80 | expect(linkedLogin(forge.db, bob.id)).toBeUndefined(); | |
| 81 | const plain = await shown("bob"); | |
| 82 | expect(plain).not.toContain("unlink open-platform.sh"); | |
| 83 | expect(plain).not.toContain("unlink parent forge"); | |
| 84 | const pressed = await shown("bob", ["u", "p"]); | |
| 85 | expect(pressed).not.toContain("Unlink open-platform.sh login"); | |
| 86 | expect(pressed).not.toContain("Unlink the parent forge?"); | |
| 87 | }); | |
| 88 | ||
| 78 | 89 | test("the screen shows the open-platform.sh link, and u unlinks it and says so on the bus", async () => { |
| 79 | 90 | const bob = getUserByName(forge.db, "bob")!; |
| 80 | 91 | expect(await shown("bob")).toContain("open-platform.sh: not linked"); |
| 81 | 92 | expect(linkEdgeLogin(forge.db, bob, "octo").ok).toBe(true); |
| 82 | expect(await shown("bob")).toContain("open-platform.sh: linked as octo"); | |
| 93 | const offered = await shown("bob"); | |
| 94 | expect(offered).toContain("open-platform.sh: linked as octo"); | |
| 95 | expect(offered).toContain("unlink open-platform.sh"); | |
| 96 | expect(offered).not.toContain("unlink parent forge"); | |
| 83 | 97 | const seen: string[] = []; |
| 84 | 98 | const stop = forge.bus.subscribe((e) => seen.push(e.kind)); |
| 85 | const after = await shown("bob", ["u"]); | |
| 99 | const asked = await shown("bob", ["u"]); | |
| 100 | expect(asked).toContain("Unlink open-platform.sh login octo?"); | |
| 101 | expect(asked).toContain("linked again from Settings on the web"); | |
| 102 | expect(linkedLogin(forge.db, bob.id)).toBe("octo"); | |
| 103 | expect(await shown("bob", ["u", "n"])).toContain("open-platform.sh: linked as octo"); | |
| 104 | expect(await shown("bob", ["u", "esc"])).toContain("open-platform.sh: linked as octo"); | |
| 105 | expect(linkedLogin(forge.db, bob.id)).toBe("octo"); | |
| 106 | const after = await shown("bob", ["u", "y"]); | |
| 86 | 107 | stop(); |
| 87 | 108 | expect(after).toContain("open-platform.sh: not linked"); |
| 88 | 109 | expect(linkedLogin(forge.db, bob.id)).toBeUndefined(); |
| @@ -97,14 +118,17 @@ describe("what I can do in the terminal", () => { | ||
| 97 | 118 | expect(await shown("bob")).toContain("parent.example: linked"); |
| 98 | 119 | // No password and no passkey: the link is the only way in, and p refuses. |
| 99 | 120 | forge.db.query("UPDATE user SET password_hash = '' WHERE id = ?1").run(bob.id); |
| 100 | const kept = await shown("bob", ["p"]); | |
| 121 | expect(await shown("bob", ["p"])).toContain("Unlink the parent forge?"); | |
| 122 | expect(await shown("bob", ["p", "n"])).toContain("parent.example: linked"); | |
| 123 | expect(linkedParent(forge.db, bob.id, issuer)).toBe("sub1"); | |
| 124 | const kept = await shown("bob", ["p", "y"]); | |
| 101 | 125 | expect(kept).toContain("parent.example: linked"); |
| 102 | 126 | expect(kept).toContain("Add a passkey or a password first"); |
| 103 | 127 | expect(linkedParent(forge.db, bob.id, issuer)).toBe("sub1"); |
| 104 | 128 | forge.db.query("UPDATE user SET password_hash = 'x' WHERE id = ?1").run(bob.id); |
| 105 | 129 | const seen: string[] = []; |
| 106 | 130 | const stop = forge.bus.subscribe((e) => seen.push(e.kind)); |
| 107 | const after = await shown("bob", ["p"]); | |
| 131 | const after = await shown("bob", ["p", "y"]); | |
| 108 | 132 | stop(); |
| 109 | 133 | expect(after).toContain("parent.example: not linked"); |
| 110 | 134 | expect(linkedParent(forge.db, bob.id, issuer)).toBeUndefined(); |
new above
offline