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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7317-the-terminal-s-people-screen-marks-a-per → main 4 commits fork 4 threads · 4 unresolved
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 filesrc/core/roles.ts +6 −2
| @@ -122,16 +122,19 @@ export interface Person { | ||
| 122 | 122 | readonly usdPerMonth: number | null; |
| 123 | 123 | /** When the owner suspended the account; null while it is in good standing. */ |
| 124 | 124 | 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; | |
| 125 | 127 | } |
| 126 | 128 | |
| 127 | 129 | /** Every person (not an agent, not an organization) with role and model grant, newest first, in one query. */ |
| 128 | 130 | export function listPeople(db: Database): Person[] { |
| 129 | 131 | 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 | |
| 132 | 134 | FROM user u |
| 133 | 135 | LEFT JOIN member m ON m.user_id = u.id |
| 134 | 136 | LEFT JOIN model_grant g ON g.user_id = u.id |
| 137 | LEFT JOIN email_check c ON c.user_id = u.id | |
| 135 | 138 | WHERE u.kind = 'user' AND u.is_agent = 0 AND u.removed_at IS NULL |
| 136 | 139 | ORDER BY u.created_at DESC, u.username`, |
| 137 | 140 | ) |
| @@ -141,5 +144,6 @@ export function listPeople(db: Database): Person[] { | ||
| 141 | 144 | role: r.is_admin === 1 ? "admin" : r.member !== null ? "member" : "guest", |
| 142 | 145 | usdPerMonth: r.usd, |
| 143 | 146 | suspendedAt: r.suspended_at, |
| 147 | verified: r.verified_at !== null, | |
| 144 | 148 | })); |
| 145 | 149 | } |
src/tui/screens/common.ts +6 −1
| @@ -396,6 +396,8 @@ export function confirmScreen( | ||
| 396 | 396 | /** Index of the action chosen; -1 when dismissed. */ |
| 397 | 397 | onChoose: (index: number) => void; |
| 398 | 398 | selected?: number; |
| 399 | /** `y` chooses the first action and `n` dismisses, for a plain yes/no question. */ | |
| 400 | yesNo?: boolean; | |
| 399 | 401 | }, |
| 400 | 402 | ): Screen { |
| 401 | 403 | let selected = o.selected ?? 0; |
| @@ -424,7 +426,10 @@ export function confirmScreen( | ||
| 424 | 426 | ); |
| 425 | 427 | }, |
| 426 | 428 | 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") | |
| 428 | 433 | selected = Math.max(0, selected - 1); |
| 429 | 434 | else if (e.key === "right" || e.key === "l" || e.key === "tab") |
| 430 | 435 | selected = Math.min(o.actions.length - 1, selected + 1); |
src/tui/screens/people.ts +42 −11
| @@ -4,9 +4,11 @@ | ||
| 4 | 4 | * starter $5 a month) or a guest. A forge admin who is a person; every rule |
| 5 | 5 | * is the action's (`actions/roles.ts`). `s` suspends or lifts a suspension |
| 6 | 6 | * 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. | |
| 7 | 9 | */ |
| 8 | 10 | |
| 9 | import { suspendAccount, unsuspendAccount } from "../../actions/accounts.ts"; | |
| 11 | import { suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts"; | |
| 10 | 12 | import { ADMIN_WORDS } from "../../actions/admin_words.ts"; |
| 11 | 13 | import { lastOwnerSays } from "../../core/orgs.ts"; |
| 12 | 14 | import { setRole } from "../../actions/roles.ts"; |
| @@ -15,9 +17,7 @@ import { listPeople, type Person } from "../../core/roles.ts"; | ||
| 15 | 17 | import type { Ctx, Screen } from "../app/app.ts"; |
| 16 | 18 | import { binding } from "../app/keys.ts"; |
| 17 | 19 | import type { Session } from "../session.ts"; |
| 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. |
||
| 21 | 21 | |
| 22 | 22 | /** One person's row: name, role, allowance, and since when they are suspended. */ |
| 23 | 23 | function rowOf(p: Person): string { |
| @@ -27,6 +27,16 @@ function rowOf(p: Person): string { | ||
| 27 | 27 | } |
| 28 | 28 | |
| 29 | 29 | 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 | }; | |
| 30 | 40 | const askSuspend = (username: string, suspend: boolean): void => { |
| 31 | 41 | later(ctx, () => |
| 32 | 42 | confirmScreen(ctx, screen, { |
| @@ -59,17 +69,38 @@ export function peopleScreen(f: Session, ctx: Ctx): Screen { | ||
| 59 | 69 | }; |
| 60 | 70 | const screen: Screen = { |
| 61 | 71 | 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")], | |
| 63 | 73 | 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); | |
| 70 | 79 | }, |
| 71 | 80 | key(e) { |
| 81 | const people = listPeople(f.app.db); | |
| 82 | if (list.key(e, people.length)) return true; | |
| 72 | 83 | 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 | } | |
| 73 | 104 | if (e.key === "s") { |
| 74 | 105 | ctx.push( |
| 75 | 106 | 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 | }); |
new above
offline
N lookups per call, called on each render and key. Fetch verified in one query in listPeople.