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

The terminal's People screen marks a person's email verified with `v` and a confirm, as MCP `account_verify` does #7328

Merged opened this

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

Merged

scout/forge:scout/7317-the-terminal-s-people-screen-marks-a-per → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/7317-the-terminal-s-people-screen-marks-a-per was merged into main as dfe7610.

Review

Sign in to review this change.
5 files changed +178 −15
docs/CREW.md +1 −1

Diff not shown — a line 2,344 characters wide (limit 2,000). +1 −1

View file
src/core/roles.ts +6 −2
@@ -122,16 +122,19 @@ export interface Person {
122122 readonly usdPerMonth: number | null;
123123 /** When the owner suspended the account; null while it is in good standing. */
124124 readonly suspendedAt: string | null;
125 /** Whether the email is proven: a link followed or an owner's word (`core/verify.ts`). */
126 readonly verified: boolean;
125127 }
126128
127129 /** Every person (not an agent, not an organization) with role and model grant, newest first, in one query. */
128130 export function listPeople(db: Database): Person[] {
129131 return db
130 .query<{ username: string; is_admin: number; member: number | null; usd: number | null; suspended_at: string | null }, []>(
131 `SELECT u.username, u.is_admin, m.user_id AS member, g.usd_per_month AS usd, u.suspended_at
132 .query<{ username: string; is_admin: number; member: number | null; usd: number | null; suspended_at: string | null; verified_at: string | null }, []>(
133 `SELECT u.username, u.is_admin, m.user_id AS member, g.usd_per_month AS usd, u.suspended_at, c.verified_at
132134 FROM user u
133135 LEFT JOIN member m ON m.user_id = u.id
134136 LEFT JOIN model_grant g ON g.user_id = u.id
137 LEFT JOIN email_check c ON c.user_id = u.id
135138 WHERE u.kind = 'user' AND u.is_agent = 0 AND u.removed_at IS NULL
136139 ORDER BY u.created_at DESC, u.username`,
137140 )
@@ -141,5 +144,6 @@ export function listPeople(db: Database): Person[] {
141144 role: r.is_admin === 1 ? "admin" : r.member !== null ? "member" : "guest",
142145 usdPerMonth: r.usd,
143146 suspendedAt: r.suspended_at,
147 verified: r.verified_at !== null,
144148 }));
145149 }
src/tui/screens/common.ts +6 −1
@@ -396,6 +396,8 @@ export function confirmScreen(
396396 /** Index of the action chosen; -1 when dismissed. */
397397 onChoose: (index: number) => void;
398398 selected?: number;
399 /** `y` chooses the first action and `n` dismisses, for a plain yes/no question. */
400 yesNo?: boolean;
399401 },
400402 ): Screen {
401403 let selected = o.selected ?? 0;
@@ -424,7 +426,10 @@ export function confirmScreen(
424426 );
425427 },
426428 key(e) {
427 if (e.key === "left" || e.key === "h")
429 if (o.yesNo === true && (e.key === "y" || e.key === "n")) {
430 ctx.pop();
431 o.onChoose(e.key === "y" ? 0 : -1);
432 } else if (e.key === "left" || e.key === "h")
428433 selected = Math.max(0, selected - 1);
429434 else if (e.key === "right" || e.key === "l" || e.key === "tab")
430435 selected = Math.min(o.actions.length - 1, selected + 1);
src/tui/screens/people.ts +42 −11
@@ -4,9 +4,11 @@
44 * starter $5 a month) or a guest. A forge admin who is a person; every rule
55 * is the action's (`actions/roles.ts`). `s` suspends or lifts a suspension
66 * after a confirm, with the actions and words MCP `account_suspend` uses.
7 * `v` vouches for the email of the person under the cursor, after a y/n,
8 * with the action MCP `account_verify` uses.
79 */
810
9 import { suspendAccount, unsuspendAccount } from "../../actions/accounts.ts";
11 import { suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts";
1012 import { ADMIN_WORDS } from "../../actions/admin_words.ts";
1113 import { lastOwnerSays } from "../../core/orgs.ts";
1214 import { setRole } from "../../actions/roles.ts";
@@ -15,9 +17,7 @@ import { listPeople, type Person } from "../../core/roles.ts";
1517 import type { Ctx, Screen } from "../app/app.ts";
1618 import { binding } from "../app/keys.ts";
1719 import type { Session } from "../session.ts";
outdated

N lookups per call, called on each render and key. Fetch verified in one query in listPeople.

18 import { style } from "../term/style.ts";
19 import { truncate } from "../term/width.ts";
20 import { confirmScreen, later, promptScreen, runAsync } from "./common.ts";
20 import { confirmScreen, drawList, later, ListState, promptScreen, runAsync, type ListRow } from "./common.ts";
outdated

Missing user counted as verified; wrong default.

2121
2222 /** One person's row: name, role, allowance, and since when they are suspended. */
2323 function rowOf(p: Person): string {
@@ -27,6 +27,16 @@ function rowOf(p: Person): string {
2727 }
2828
2929 export function peopleScreen(f: Session, ctx: Ctx): Screen {
outdated

A non-admin is told 'No such account.'; gate on admin here and say so plainly.

30 const list = new ListState();
31 const verify = (name: string): void => {
32 const done = verifyAccount(f.app, f.user, name);
33 if (done.ok) ctx.toast(`${done.value.username}'s email is verified.`);
34 else
35 ctx.toast(
36 done.error === "not_found" ? "No such account." : "An agent or a removed account has no email to verify.",
37 "danger",
38 );
39 };
3040 const askSuspend = (username: string, suspend: boolean): void => {
3141 later(ctx, () =>
3242 confirmScreen(ctx, screen, {
@@ -59,17 +69,38 @@ export function peopleScreen(f: Session, ctx: Ctx): Screen {
5969 };
6070 const screen: Screen = {
6171 title: "People",
outdated

Monkey-patching ask.key; put y/n into confirmScreen or a dedicated screen, and drop the duplicate verify call.

62 keys: [binding(["r"], "change a role"), binding(["s"], "suspend or lift"), binding(["esc"], "back")],
72 keys: [binding(["r"], "change a role"), binding(["s"], "suspend or lift"), binding(["v"], "verify"), binding(["esc"], "back")],
6373 render(area, s) {
64 const c = ctx.theme.c;
65 listPeople(f.app.db)
66 .slice(0, Math.max(0, area.h))
67 .forEach((p, i) => {
68 s.text(area.x, area.y + i, truncate(rowOf(p), area.w), style(c.mutedFg, c.bg), area.x + area.w);
69 });
74 const rows: ListRow[] = listPeople(f.app.db).map((p) => ({
75 title: rowOf(p),
76 ...(p.verified ? {} : { meta: "unverified" }),
77 }));
78 drawList(s, area, ctx.theme.c, list, rows);
7079 },
7180 key(e) {
81 const people = listPeople(f.app.db);
82 if (list.key(e, people.length)) return true;
7283 if (e.ctrl || e.alt) return false;
84 if (e.key === "v") {
85 const p = people[list.index];
86 if (f.user?.isAdmin !== true || f.user.kind !== "user") ctx.toast("Only a forge admin can verify an email.", "danger");
87 else if (p === undefined) return true;
88 else if (p.verified) ctx.toast(`${p.username}'s email is already verified.`, "danger");
89 else
90 ctx.push(
91 confirmScreen(ctx, screen, {
92 title: "Verify email",
93 body: [`verify ${p.username}'s email? y/n`],
94 actions: [{ label: "Verify" }, { label: "Cancel" }],
95 selected: 1,
96 yesNo: true,
97 onChoose: (choice) => {
98 if (choice === 0) verify(p.username);
99 },
100 }),
101 );
102 return true;
103 }
73104 if (e.key === "s") {
74105 ctx.push(
75106 promptScreen(ctx, screen, {
test/tui-people-verify.test.ts added +123
@@ -0,0 +1,123 @@
1 /** `v` on the People screen vouches for an email, as MCP `account_verify` does. */
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 as Forge } from "../src/app.ts";
8 import { listEvents } from "../src/core/events.ts";
9 import { listPeople } from "../src/core/roles.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { emailVerified, markUnproven } from "../src/core/verify.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { headlessTerminal } from "../src/tui/term/terminal.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15 import { settleApp, settleUntil } from "./helpers/tui.ts";
16 import { seedAdmin } from "./helpers/admin.ts";
17
18 quietLogs();
19
20 describe("verify an email from the People screen", () => {
21 let dataDir: string;
22 let forge: Forge;
23 let alice: User;
24 let bob: User;
25
26 beforeAll(async () => {
27 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-people-verify-"));
28 forge = createApp({ dataDir, port: 0 });
29 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
30 const b = await createUser(forge.db, { username: "bobby", email: "[email protected]", password: "a-long-passphrase", origin: "seed" });
31 if (!a.ok || !b.ok) throw new Error("signup failed");
32 alice = a.value;
33 bob = b.value;
34 markUnproven(forge.db, bob.id);
35 });
36
37 afterAll(async () => {
38 await rm(dataDir, { recursive: true, force: true });
39 });
40
41 async function open(user: User) {
42 const terminal = headlessTerminal(120, 40);
43 const { app } = createForgeApp(forge, { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, { terminal, exitHooks: false });
44 app.start();
45 const press = async (...keys: string[]): Promise<void> => {
46 for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
47 await settleApp(app);
48 };
49 await settleApp(app);
50 await press("ctrl+k");
51 for (const ch of "People") await press(ch);
52 await press("enter");
53 await settleUntil(app, "alice");
54 return { app, press, text: () => app.screen.toText() };
55 }
56
57 test("an admin verifies the person under the cursor and the row changes", async () => {
58 const h = await open({ ...alice });
59 try {
60 expect(h.text()).toMatch(/v +verify/);
61 expect(h.text()).toMatch(/bobby.*unverified/);
62 // newest first: bobby is the first row
63 await h.press("v");
64 expect(h.text()).toContain("verify bobby's email? y/n");
65 await h.press("n");
66 expect(emailVerified(forge.db, bob.id)).toBe(false);
67 await h.press("v", "esc");
68 expect(emailVerified(forge.db, bob.id)).toBe(false);
69 expect(h.text()).not.toContain("y/n");
70 await h.press("v", "y");
71 expect(emailVerified(forge.db, bob.id)).toBe(true);
72 expect(h.text()).not.toContain("unverified");
73 expect(h.text()).toContain("bobby's email is verified.");
74 expect(
75 listEvents(forge.db, { kind: "account.email_verified" }).some(
76 (e) => e.payload["username"] === "bobby" && e.payload["by"] === "owner",
77 ),
78 ).toBe(true);
79 // already verified: refused, nothing changes
80 await h.press("v", "y");
81 expect(h.text()).toContain("already verified");
82 } finally {
83 h.app.stop();
84 }
85 });
86
87 test("`r` still changes a role on the list", async () => {
88 const h = await open({ ...alice });
89 try {
90 await h.press("j", "k", "r");
91 for (const ch of "bobby") await h.press(ch);
92 await h.press("enter");
93 for (const ch of "member") await h.press(ch);
94 await h.press("enter");
95 expect(h.text()).toContain("bobby is a member.");
96 } finally {
97 h.app.stop();
98 }
99 });
100
101 test("someone who is not a forge admin is refused and nothing changes", async () => {
102 const carol = await createUser(forge.db, { username: "carol", email: "[email protected]", password: "a-long-passphrase", origin: "seed" });
103 if (!carol.ok) throw new Error("signup failed");
104 markUnproven(forge.db, carol.value.id);
105 const me = { ...alice };
106 const h = await open(me);
107 try {
108 me.isAdmin = false;
109 await h.press("v", "y");
110 expect(emailVerified(forge.db, carol.value.id)).toBe(false);
111 expect(h.text()).toContain("Only a forge admin can verify an email.");
112 } finally {
113 h.app.stop();
114 }
115 });
116
117 test("listPeople says who is verified", () => {
118 const byName = new Map(listPeople(forge.db).map((p) => [p.username, p.verified]));
119 expect(byName.get("bobby")).toBe(true);
120 expect(byName.get("alice")).toBe(true);
121 expect(byName.get("carol")).toBe(false);
122 });
123 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs