Public: anyone can read this; the crew can change it.
The terminal's People screen says when mail is not configured, so newcomers cannot verify themselves, as Settings › People does #7479
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7474-the-terminal-s-people-screen-says-when-m → main 2 commits fork
2 files
changed +103
−2
src/tui/screens/people.ts +19 −2
| @@ -5,10 +5,12 @@ | ||
| 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 | 7 | * `v` vouches for the email of the person under the cursor, after a y/n, |
| 8 | * with the action MCP `account_verify` uses. | |
| 8 | * with the action MCP `account_verify` uses. Without mail a line above the | |
| 9 | * list says so, as the accounts page's card does. | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import { suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts"; |
| 13 | import { mailConfigured } from "../../actions/verify.ts"; | |
| 12 | 14 | import { ADMIN_WORDS } from "../../actions/admin_words.ts"; |
| 13 | 15 | import { lastOwnerSays } from "../../core/orgs.ts"; |
| 14 | 16 | import { setRole } from "../../actions/roles.ts"; |
| @@ -17,8 +19,16 @@ import { listPeople, type Person } from "../../core/roles.ts"; | ||
| 17 | 19 | import type { Ctx, Screen } from "../app/app.ts"; |
| 18 | 20 | import { binding } from "../app/keys.ts"; |
| 19 | 21 | import type { Session } from "../session.ts"; |
| 22 | import { style } from "../term/style.ts"; | |
| 23 | import { truncate } from "../term/width.ts"; | |
| 20 | 24 | import { confirmScreen, drawList, later, ListState, promptScreen, runAsync, type ListRow } from "./common.ts"; |
| 21 | 25 | |
| 26 | /** | |
| 27 | * The accounts page's no-mail card in one line that fits 80 columns: without | |
| 28 | * mail a newcomer cannot prove an address, so the admin's word is the way in. | |
| 29 | */ | |
| 30 | export const NO_MAIL_LINE = "Mail is not configured: verify newcomers here with v, or set MAIL_SMTP_URL."; | |
| 31 | ||
| 22 | 32 | /** One person's row: name, role, allowance, and since when they are suspended. */ |
| 23 | 33 | function rowOf(p: Person): string { |
| 24 | 34 | const allowance = p.usdPerMonth === null ? "" : ` $${p.usdPerMonth.toFixed(2)} a month`; |
| @@ -28,6 +38,8 @@ function rowOf(p: Person): string { | ||
| 28 | 38 | |
| 29 | 39 | export function peopleScreen(f: Session, ctx: Ctx): Screen { |
| 30 | 40 | const list = new ListState(); |
| 41 | // Read once per open: the answer lists the forge's secrets. | |
| 42 | const noMail = !mailConfigured(f.app); | |
| 31 | 43 | const verify = (name: string): void => { |
| 32 | 44 | const done = verifyAccount(f.app, f.user, name); |
| 33 | 45 | if (done.ok) ctx.toast(`${done.value.username}'s email is verified.`); |
| @@ -75,7 +87,12 @@ export function peopleScreen(f: Session, ctx: Ctx): Screen { | ||
| 75 | 87 | title: rowOf(p), |
| 76 | 88 | ...(p.verified ? {} : { meta: "unverified" }), |
| 77 | 89 | })); |
| 78 | drawList(s, area, ctx.theme.c, list, rows); | |
| 90 | const c = ctx.theme.c; | |
| 91 | if (noMail && area.h > 1) { | |
| 92 | s.text(area.x, area.y, truncate(NO_MAIL_LINE, area.w), style(c.attn, c.bg), area.x + area.w); | |
| 93 | area = { ...area, y: area.y + 2, h: Math.max(0, area.h - 2) }; | |
| 94 | } | |
| 95 | drawList(s, area, c, list, rows); | |
| 79 | 96 | }, |
| 80 | 97 | key(e) { |
| 81 | 98 | const people = listPeople(f.app.db); |
test/tui-people-mail.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** The People screen says, above the list, when mail is not configured — as the accounts page 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 { type User } from "../src/core/users.ts"; | |
| 9 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 10 | import { NO_MAIL_LINE } from "../src/tui/screens/people.ts"; | |
| 11 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 14 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | async function openPeople(forge: Forge, user: User) { | |
| 19 | const terminal = headlessTerminal(80, 30); | |
| 20 | const { app } = createForgeApp(forge, { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, { terminal, exitHooks: false }); | |
| 21 | app.start(); | |
| 22 | const press = async (...keys: string[]): Promise<void> => { | |
| 23 | for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 24 | await settleApp(app); | |
| 25 | }; | |
| 26 | await settleApp(app); | |
| 27 | await press("ctrl+k"); | |
| 28 | for (const ch of "People") await press(ch); | |
| 29 | await press("enter"); | |
| 30 | await settleUntil(app, "alice"); | |
| 31 | return { app, text: () => app.screen.toText() }; | |
| 32 | } | |
| 33 | ||
| 34 | describe("the People screen and mail", () => { | |
| 35 | const dirs: string[] = []; | |
| 36 | afterAll(async () => { | |
| 37 | for (const d of dirs) await rm(d, { recursive: true, force: true }); | |
| 38 | }); | |
| 39 | ||
| 40 | async function forgeWith(mail: boolean): Promise<{ forge: Forge; alice: User }> { | |
| 41 | const dataDir = await mkdtemp(join(tmpdir(), "forge-tui-people-mail-")); | |
| 42 | dirs.push(dataDir); | |
| 43 | const forge = createApp({ dataDir, port: 0, ...(mail ? { mailer: async () => true } : {}) }); | |
| 44 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 45 | if (!a.ok) throw new Error("signup failed"); | |
| 46 | return { forge, alice: a.value }; | |
| 47 | } | |
| 48 | ||
| 49 | let without: { forge: Forge; alice: User }; | |
| 50 | let withMail: { forge: Forge; alice: User }; | |
| 51 | beforeAll(async () => { | |
| 52 | without = await forgeWith(false); | |
| 53 | withMail = await forgeWith(true); | |
| 54 | }); | |
| 55 | ||
| 56 | test("the line fits an 80-column terminal and names the key and the secret", () => { | |
| 57 | expect(NO_MAIL_LINE.length).toBeLessThanOrEqual(80); | |
| 58 | expect(NO_MAIL_LINE).toContain("with v"); | |
| 59 | expect(NO_MAIL_LINE).toContain("MAIL_SMTP_URL"); | |
| 60 | }); | |
| 61 | ||
| 62 | test("without mail the line is drawn once, above the list", async () => { | |
| 63 | const h = await openPeople(without.forge, without.alice); | |
| 64 | try { | |
| 65 | const lines = h.text().split("\n"); | |
| 66 | const at = lines.findIndex((l) => l.includes(NO_MAIL_LINE)); | |
| 67 | expect(at).toBeGreaterThanOrEqual(0); | |
| 68 | expect(lines.filter((l) => l.includes("Mail is not configured")).length).toBe(1); | |
| 69 | expect(lines.findLastIndex((l) => l.includes("alice"))).toBeGreaterThan(at); | |
| 70 | } finally { | |
| 71 | h.app.stop(); | |
| 72 | } | |
| 73 | }); | |
| 74 | ||
| 75 | test("with mail configured nothing is drawn", async () => { | |
| 76 | const h = await openPeople(withMail.forge, withMail.alice); | |
| 77 | try { | |
| 78 | expect(h.text()).toContain("alice"); | |
| 79 | expect(h.text()).not.toContain("Mail is not configured"); | |
| 80 | } finally { | |
| 81 | h.app.stop(); | |
| 82 | } | |
| 83 | }); | |
| 84 | }); |
new above
offline