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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8045-the-terminal-s-passkeys-screen-removes-t → main 1 commit fork
4 files
changed +163
−12
src/tui/screens/passkeys.ts +42 −11
| @@ -1,10 +1,14 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 6 | 9 | */ |
| 7 | 10 | |
| 11 | import { LAST_WAY_IN, removeOwnPasskey } from "../../actions/passkeys.ts"; | |
| 8 | 12 | import { listPasskeys } from "../../core/passkeys.ts"; |
| 9 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| 10 | 14 | import { binding } from "../app/keys.ts"; |
| @@ -12,10 +16,11 @@ import { style } from "../term/style.ts"; | ||
| 12 | 16 | import { truncate } from "../term/width.ts"; |
| 13 | 17 | import type { Session } from "../session.ts"; |
| 14 | 18 | 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"; | |
| 16 | 20 | |
| 17 | 21 | export function passkeysScreen(f: Session, ctx: Ctx): Screen { |
| 18 | 22 | const state = new ListState(); |
| 23 | let note = ""; | |
| 19 | 24 | const items = () => (f.user === undefined ? [] : listPasskeys(f.app.db, f.user.id)); |
| 20 | 25 | |
| 21 | 26 | const rows = (): ListRow[] => |
| @@ -24,21 +29,47 @@ export function passkeysScreen(f: Session, ctx: Ctx): Screen { | ||
| 24 | 29 | meta: `added ${p.createdAt.slice(0, 10)}, used ${p.lastUsedAt?.slice(0, 10) ?? "never"}`, |
| 25 | 30 | })); |
| 26 | 31 | |
| 27 | return { | |
| 32 | const screen: Screen = { | |
| 28 | 33 | title: "Passkeys", |
| 29 | keys: [binding(["j", "k"], "move cursor")], | |
| 34 | keys: [binding(["j", "k"], "move cursor"), binding(["d"], "remove")], | |
| 30 | 35 | render(area, s) { |
| 31 | 36 | const c = ctx.theme.c; |
| 32 | 37 | const [head, body] = split(area, "v", [len(1), fill(1)]); |
| 33 | 38 | 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."; | |
| 37 | 44 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 38 | 45 | drawList(s, body!, c, state, rows()); |
| 39 | 46 | }, |
| 40 | 47 | 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; | |
| 42 | 72 | }, |
| 43 | 73 | }; |
| 74 | return screen; | |
| 44 | 75 | } |
test/tui-asks-first.test.ts +1
| @@ -30,6 +30,7 @@ const destructive: readonly string[] = [ | ||
| 30 | 30 | "removeAccount", |
| 31 | 31 | "removeAppDomain", |
| 32 | 32 | "removeFromCrew", |
| 33 | "removeOwnPasskey", | |
| 33 | 34 | "removePromptSource", |
| 34 | 35 | "removeSecret", |
| 35 | 36 | "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", () => { | ||
| 87 | 87 | const h = boot("alice"); |
| 88 | 88 | await h.open(); |
| 89 | 89 | 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."); | |
| 91 | 91 | expect(text).toContain("work laptop"); |
| 92 | 92 | expect(text).toContain("phone"); |
| 93 | 93 | const today = new Date().toISOString().slice(0, 10); |
new above
offline