Public: anyone can read this; the crew can change it.

The terminal's Passkeys screen removes the passkey under the cursor with `d` after a confirm #8117

Merged opened this

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

Merged

mason/forge:mason/8045-the-terminal-s-passkeys-screen-removes-t → main 1 commit fork

  • Merged. mason/8045-the-terminal-s-passkeys-screen-removes-t was merged into main as 1d8e67a.

Review

Sign in to review this change.
4 files changed +163 −12
src/tui/screens/passkeys.ts +42 −11
@@ -1,10 +1,14 @@
11 /**
2 * Your passkeys in the terminal (#8044): the list, read only. A passkey is
3 * made by a browser ceremony, so this screen never offers to add one; it
4 * names where to, Settings › Passkeys. The list is your own passkeys, never
5 * another account's, as MCP `whoami_can kind: passkeys` shows them.
2 * Your passkeys in the terminal (#8044): the list, and `d` to remove the one
3 * under the cursor after a y/n (#8045). A passkey is made by a browser
4 * ceremony, so this screen never offers to add one; it names where to,
5 * Settings › Passkeys. Removing goes through `removeOwnPasskey`, the rule
6 * Settings and MCP `whoami_can` share, and says their words. The list is your
7 * own passkeys, never another account's, as MCP `whoami_can kind: passkeys`
8 * shows them.
69 */
710
11 import { LAST_WAY_IN, removeOwnPasskey } from "../../actions/passkeys.ts";
812 import { listPasskeys } from "../../core/passkeys.ts";
913 import type { Ctx, Screen } from "../app/app.ts";
1014 import { binding } from "../app/keys.ts";
@@ -12,10 +16,11 @@ import { style } from "../term/style.ts";
1216 import { truncate } from "../term/width.ts";
1317 import type { Session } from "../session.ts";
1418 import { fill, len, split } from "../ui/layout.ts";
15 import { ListState, drawList, type ListRow } from "./common.ts";
19 import { ListState, confirmScreen, drawList, type ListRow } from "./common.ts";
1620
1721 export function passkeysScreen(f: Session, ctx: Ctx): Screen {
1822 const state = new ListState();
23 let note = "";
1924 const items = () => (f.user === undefined ? [] : listPasskeys(f.app.db, f.user.id));
2025
2126 const rows = (): ListRow[] =>
@@ -24,21 +29,47 @@ export function passkeysScreen(f: Session, ctx: Ctx): Screen {
2429 meta: `added ${p.createdAt.slice(0, 10)}, used ${p.lastUsedAt?.slice(0, 10) ?? "never"}`,
2530 }));
2631
27 return {
32 const screen: Screen = {
2833 title: "Passkeys",
29 keys: [binding(["j", "k"], "move cursor")],
34 keys: [binding(["j", "k"], "move cursor"), binding(["d"], "remove")],
3035 render(area, s) {
3136 const c = ctx.theme.c;
3237 const [head, body] = split(area, "v", [len(1), fill(1)]);
3338 const text =
34 items().length === 0
35 ? "None yet. Add one on the web: Settings › Passkeys."
36 : "Passkeys that sign in as you. Read only: add or remove one on the web, Settings › Passkeys.";
39 note !== ""
40 ? note
41 : items().length === 0
42 ? "None yet. Add one on the web: Settings › Passkeys."
43 : "Passkeys that sign in as you. d removes one; add one on the web, Settings › Passkeys.";
3744 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
3845 drawList(s, body!, c, state, rows());
3946 },
4047 key(e) {
41 return state.key(e, items().length);
48 if (state.key(e, items().length)) return true;
49 if (e.ctrl || e.alt) return false;
50 if (e.key === "d") {
51 const p = items()[state.index];
52 if (p === undefined || f.user === undefined) return false;
53 const user = f.user;
54 ctx.push(
55 confirmScreen(ctx, screen, {
56 title: "Remove passkey",
57 body: [`remove passkey ${p.name}? y/n`],
58 actions: [{ label: "Remove", variant: "danger" }, { label: "Cancel", variant: "ghost" }],
59 selected: 1,
60 yesNo: true,
61 onChoose: (i) => {
62 if (i !== 0) return;
63 const gone = removeOwnPasskey(f.app, user, p.id);
64 note = gone.ok ? "Passkey removed." : gone.error === "last_way_in" ? LAST_WAY_IN : "No such passkey.";
65 ctx.invalidate();
66 },
67 }),
68 );
69 return true;
70 }
71 return false;
4272 },
4373 };
74 return screen;
4475 }
test/tui-asks-first.test.ts +1
@@ -30,6 +30,7 @@ const destructive: readonly string[] = [
3030 "removeAccount",
3131 "removeAppDomain",
3232 "removeFromCrew",
33 "removeOwnPasskey",
3334 "removePromptSource",
3435 "removeSecret",
3536 "removeTeamMember",
test/tui-passkeys-remove.test.ts added +119
@@ -0,0 +1,119 @@
1 /**
2 * Removing a passkey in the terminal (#8045): `d` asks "remove passkey <name>? y/n",
3 * `y` removes it through `removeOwnPasskey` and says "Passkey removed.", `n`
4 * keeps it, and a passkey-only account's last passkey stays with the action's
5 * last-way-in refusal, word for word.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { LAST_WAY_IN } from "../src/actions/passkeys.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import type { Credential } from "../src/auth/webauthn.ts";
14 import { addPasskey, listPasskeys } from "../src/core/passkeys.ts";
15 import { createUser, getUserByName } from "../src/core/users.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp } from "./helpers/tui.ts";
20
21 quietLogs();
22
23 const cred = (id: string): Credential => ({
24 id,
25 alg: "ES256",
26 key: { kty: "EC", crv: "P-256", x: "x", y: "y" },
27 signCount: 0,
28 });
29
30 describe("removing a passkey in the terminal", () => {
31 let dataDir: string;
32 let app: App;
33
34 const seed = (owner: string, name: string) => {
35 const added = addPasskey(app.db, getUserByName(app.db, owner)!.id, name, cred(`${owner}-${name}`));
36 if (!added.ok) throw new Error(added.error);
37 };
38 const names = (owner: string) => listPasskeys(app.db, getUserByName(app.db, owner)!.id).map((p) => p.name);
39
40 async function boot(username: string) {
41 const terminal = headlessTerminal(140, 32);
42 const user = getUserByName(app.db, username)!;
43 const { app: tui } = createForgeApp(
44 app,
45 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
46 { terminal, exitHooks: false },
47 );
48 tui.start();
49 const key = async (k: string, ctrl = false) => {
50 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
51 await settleApp(tui);
52 };
53 await key("k", true);
54 for (const ch of "Passkeys") await key(ch);
55 await key("enter");
56 return { key, text: () => tui.screen.toText(), stop: () => tui.stop() };
57 }
58
59 beforeAll(async () => {
60 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-passkeys-remove-"));
61 app = createApp({ dataDir, port: 0, host: "forge.test" });
62 for (const [username, password] of [["alice", "a-good-passphrase"], ["dana", undefined]] as const) {
63 const made = await createUser(app.db, {
64 origin: "seed",
65 trusted: true,
66 username,
67 email: `${username}@forge.local`,
68 ...(password !== undefined ? { password } : {}),
69 adminIfFirst: username === "alice",
70 });
71 if (!made.ok) throw new Error(made.error);
72 }
73 seed("alice", "lost phone");
74 seed("alice", "laptop");
75 seed("dana", "only key");
76 });
77
78 afterAll(async () => {
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 test("the d binding shows in the screen's keys", async () => {
83 const h = await boot("alice");
84 expect(h.text()).toMatch(/d\s+remove/);
85 h.stop();
86 });
87
88 test("d then n keeps it", async () => {
89 const h = await boot("alice");
90 const first = names("alice")[0]!;
91 await h.key("d");
92 expect(h.text()).toContain(`remove passkey ${first}? y/n`);
93 await h.key("n");
94 expect(names("alice")).toContain(first);
95 expect(h.text()).not.toContain("Passkey removed.");
96 h.stop();
97 });
98
99 test("d then y removes it and says Passkey removed.", async () => {
100 const h = await boot("alice");
101 const first = names("alice")[0]!;
102 await h.key("d");
103 await h.key("y");
104 expect(names("alice")).not.toContain(first);
105 expect(names("alice")).toHaveLength(1);
106 expect(h.text()).toContain("Passkey removed.");
107 h.stop();
108 });
109
110 test("a passkey-only account's last passkey stays, with the last-way-in refusal", async () => {
111 const h = await boot("dana");
112 await h.key("d");
113 expect(h.text()).toContain("remove passkey only key? y/n");
114 await h.key("y");
115 expect(names("dana")).toEqual(["only key"]);
116 expect(h.text()).toContain(LAST_WAY_IN);
117 h.stop();
118 });
119 });
test/tui-passkeys.test.ts +1 −1
@@ -87,7 +87,7 @@ describe("passkeys in the terminal", () => {
8787 const h = boot("alice");
8888 await h.open();
8989 const text = h.text();
90 expect(text).toContain("Passkeys that sign in as you. Read only: add or remove one on the web, Settings › Passkeys.");
90 expect(text).toContain("Passkeys that sign in as you. d removes one; add one on the web, Settings › Passkeys.");
9191 expect(text).toContain("work laptop");
9292 expect(text).toContain("phone");
9393 const today = new Date().toISOString().slice(0, 10);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs