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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/7950-the-terminal-s-what-i-can-do-offers-u-an → main 1 commit fork

  • Merged. mason/7950-the-terminal-s-what-i-can-do-offers-u-an was merged into main as 622df1f.

Review

Sign in to review this change.
2 files changed +79 −15
src/tui/screens/can.ts +51 −11
@@ -2,8 +2,9 @@
22 * "What I can do" in the terminal, as the account page and the `whoami_can`
33 * tool have it: the signed-in account's role and its row of the roles table,
44 * 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
78 * and what it holds back; `v` asks `resendVerification` for another link, as
89 * the account page and MCP `email_verify` do, and says the outcome in the
910 * API's words — never the link.
@@ -18,12 +19,23 @@ import { verifyWords } from "../../actions/verify_words.ts";
1819 import { emailVerified } from "../../core/verify.ts";
1920 import { isStandInEmail } from "../../core/users.ts";
2021 import type { Ctx, Screen } from "../app/app.ts";
22 import { confirmScreen } from "./common.ts";
2123 import { binding } from "../app/keys.ts";
2224 import type { Session } from "../session.ts";
2325 import { style } from "../term/style.ts";
2426 import { truncate } from "../term/width.ts";
2527
2628 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";
2739 const edgeLogin = (): string | undefined => {
2840 if (f.user === undefined || activeEdgeToken(f.app) === undefined) return undefined;
2941 const linked = linkedLogin(f.app.db, f.user.id);
@@ -52,13 +64,13 @@ export function canScreen(f: Session, ctx: Ctx): Screen {
5264 const sent = await resendVerification(f.app, f.user);
5365 verifySaid = verifyWords(sent.ok ? sent.value : sent.error);
5466 };
55 return {
67 const screen: Screen = {
5668 title: "What I can do",
5769 get keys() {
5870 return [
5971 ...(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")]),
6274 binding(["esc"], "back"),
6375 ];
6476 },
@@ -81,15 +93,43 @@ export function canScreen(f: Session, ctx: Ctx): Screen {
8193 return true;
8294 }
8395 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 );
88113 return true;
89114 }
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 );
92131 return true;
93132 },
94133 };
134 return screen;
95135 }
test/tui-can.test.ts +28 −4
@@ -75,14 +75,35 @@ describe("what I can do in the terminal", () => {
7575 expect(admin).toContain("Administer the forge: yes");
7676 });
7777
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
7889 test("the screen shows the open-platform.sh link, and u unlinks it and says so on the bus", async () => {
7990 const bob = getUserByName(forge.db, "bob")!;
8091 expect(await shown("bob")).toContain("open-platform.sh: not linked");
8192 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");
8397 const seen: string[] = [];
8498 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"]);
86107 stop();
87108 expect(after).toContain("open-platform.sh: not linked");
88109 expect(linkedLogin(forge.db, bob.id)).toBeUndefined();
@@ -97,14 +118,17 @@ describe("what I can do in the terminal", () => {
97118 expect(await shown("bob")).toContain("parent.example: linked");
98119 // No password and no passkey: the link is the only way in, and p refuses.
99120 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"]);
101125 expect(kept).toContain("parent.example: linked");
102126 expect(kept).toContain("Add a passkey or a password first");
103127 expect(linkedParent(forge.db, bob.id, issuer)).toBe("sub1");
104128 forge.db.query("UPDATE user SET password_hash = 'x' WHERE id = ?1").run(bob.id);
105129 const seen: string[] = [];
106130 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"]);
108132 stop();
109133 expect(after).toContain("parent.example: not linked");
110134 expect(linkedParent(forge.db, bob.id, issuer)).toBeUndefined();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs