Public: anyone can read this; the crew can change it.
The terminal's People screen suspends or unsuspends an account with `s` and a confirm, as MCP `account_suspend` does #7274
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7267-the-terminal-s-people-screen-suspends-or → main 2 commits fork
4 files
changed +196
−10
docs/CREW.md +1 −1
| @@ -301,7 +301,7 @@ the work repositories and nothing else. Two on one grant take turns, and | ||
| 301 | 301 | one the provider rests hands its runs to the other until the reset. |
| 302 | 302 | **Model budget.** A new account has none: its repositories' crew does not run, spends nothing, and says "This repository has no model account". A person adds their own subscription at Settings → Model (or the terminal's **Model budget** command, `a`); it is sealed, granted to their own repositories only, and one per account. A forge admin who is a person gives an account a capped grant, in dollars a month spent on the forge's own credential, from the account's page, the terminal's **Model grant** command, `POST /api/v1/admin/accounts/:name/model-grant` (`{"usdPerMonth": 20}`, `null` takes it back) or the MCP `model_grant` tool; each change is in the audit. The cap is the UTC month's spend in the usage ledger, checked when a run starts and on every spend tick: a run past it stops, and its page says the grant is spent. Two runs that start together each begin with the whole remainder; the tick check ends both once the sum crosses the cap, so the overshoot is at most what they spend between ticks. |
| 303 | 303 | |
| 304 | **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); nothing switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` (`{ items, next_cursor }`, as `/admin/accounts` and `/admin/invites` answer, with `limit` 1-200, 50 by default, and `cursor`) and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token (with `{"kind": "passkeys"}` it lists your own passkeys instead, `{ items }` of id, name, created, lastUsed, people only; the page is Settings → Passkeys); in the terminal it is the **What I can do** command (read only, agents included). | |
| 304 | **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); nothing switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`; `s` suspends an account by name, or lifts the suspension, after a confirm saying their sessions and tokens stop at once, with the rules of `account_suspend`: not yourself, another admin or an agent, not the only owner of an organization; a suspended row says since when); over the API, `GET /api/v1/admin/people` (`{ items, next_cursor }`, as `/admin/accounts` and `/admin/invites` answer, with `limit` 1-200, 50 by default, and `cursor`) and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token (with `{"kind": "passkeys"}` it lists your own passkeys instead, `{ items }` of id, name, created, lastUsed, people only; the page is Settings → Passkeys); in the terminal it is the **What I can do** command (read only, agents included). | |
| 305 | 305 | |
| 306 | 306 | **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting, revoking and using a link are events (`invite.minted`, `invite.revoked`, `invite.used`; the last names the invite and the account it let in, never the token). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link, and the `invites` tool (`{action: "list"|"revoke", id?}`, the same person admin and admin token, for both actions) lists them without links or revokes one. While signup is `invite`, the link opens the door once: `/signup?invite=…` (the form and the passkey door) or `invite` in the `POST /api/v1/users` body. In the terminal, with signup = invite the create-account screen has a fourth, masked **Invite** field that takes the token (not the whole link); a blank one is asked for, and a bad one is refused with the same sentence as `/signup`. The account and the invite's use are one write, so two sign-ups with one link make one account; a used, revoked or expired link, or any link while signup is `closed`, makes none. While signup is `open`, a link is not spent. |
| 307 | 307 |
src/core/roles.ts +5 −2
| @@ -99,13 +99,15 @@ export interface Person { | ||
| 99 | 99 | readonly username: string; |
| 100 | 100 | readonly role: Role; |
| 101 | 101 | readonly usdPerMonth: number | null; |
| 102 | /** When the owner suspended the account; null while it is in good standing. */ | |
| 103 | readonly suspendedAt: string | null; | |
| 102 | 104 | } |
| 103 | 105 | |
| 104 | 106 | /** Every person (not an agent, not an organization) with role and model grant, newest first, in one query. */ |
| 105 | 107 | export function listPeople(db: Database): Person[] { |
| 106 | 108 | return db |
| 107 | .query<{ username: string; is_admin: number; member: number | null; usd: number | null }, []>( | |
| 108 | `SELECT u.username, u.is_admin, m.user_id AS member, g.usd_per_month AS usd | |
| 109 | .query<{ username: string; is_admin: number; member: number | null; usd: number | null; suspended_at: string | null }, []>( | |
| 110 | `SELECT u.username, u.is_admin, m.user_id AS member, g.usd_per_month AS usd, u.suspended_at | |
| 109 | 111 | FROM user u |
| 110 | 112 | LEFT JOIN member m ON m.user_id = u.id |
| 111 | 113 | LEFT JOIN model_grant g ON g.user_id = u.id |
| @@ -117,5 +119,6 @@ export function listPeople(db: Database): Person[] { | ||
| 117 | 119 | username: r.username, |
| 118 | 120 | role: r.is_admin === 1 ? "admin" : r.member !== null ? "member" : "guest", |
| 119 | 121 | usdPerMonth: r.usd, |
| 122 | suspendedAt: r.suspended_at, | |
| 120 | 123 | })); |
| 121 | 124 | } |
src/tui/screens/people.ts +64 −7
| @@ -2,34 +2,91 @@ | ||
| 2 | 2 | * People in the terminal, as `/settings/people` has them: every person with |
| 3 | 3 | * a role and allowance, and `r` to make one a member (`member 5` gives a |
| 4 | 4 | * starter $5 a month) or a guest. A forge admin who is a person; every rule |
| 5 | * is the action's (`actions/roles.ts`). | |
| 5 | * is the action's (`actions/roles.ts`). `s` suspends or lifts a suspension | |
| 6 | * after a confirm, with the actions and words MCP `account_suspend` uses. | |
| 6 | 7 | */ |
| 7 | 8 | |
| 9 | import { suspendAccount, unsuspendAccount } from "../../actions/accounts.ts"; | |
| 10 | import { ADMIN_WORDS } from "../../actions/admin_words.ts"; | |
| 11 | import { lastOwnerSays } from "../../core/orgs.ts"; | |
| 8 | 12 | import { setRole } from "../../actions/roles.ts"; |
| 9 | 13 | import { AMOUNT_REFUSED } from "../../actions/model_words.ts"; |
| 10 | import { listPeople } from "../../core/roles.ts"; | |
| 14 | import { listPeople, type Person } from "../../core/roles.ts"; | |
| 11 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 16 | import { binding } from "../app/keys.ts"; |
| 13 | 17 | import type { Session } from "../session.ts"; |
| 14 | 18 | import { style } from "../term/style.ts"; |
| 15 | 19 | import { truncate } from "../term/width.ts"; |
| 16 | import { later, promptScreen } from "./common.ts"; | |
| 20 | import { confirmScreen, later, promptScreen, runAsync } from "./common.ts"; | |
| 21 | ||
| 22 | /** One person's row: name, role, allowance, and since when they are suspended. */ | |
| 23 | function rowOf(p: Person): string { | |
| 24 | const allowance = p.usdPerMonth === null ? "" : ` $${p.usdPerMonth.toFixed(2)} a month`; | |
| 25 | const suspended = p.suspendedAt === null ? "" : ` suspended ${p.suspendedAt.slice(0, 10)}`; | |
| 26 | return `${p.username} ${p.role}${allowance}${suspended}`; | |
| 27 | } | |
| 17 | 28 | |
| 18 | 29 | export function peopleScreen(f: Session, ctx: Ctx): Screen { |
| 30 | const askSuspend = (username: string, suspend: boolean): void => { | |
| 31 | later(ctx, () => | |
| 32 | confirmScreen(ctx, screen, { | |
| 33 | title: suspend ? `Suspend ${username}` : `Lift ${username}'s suspension`, | |
| 34 | body: [ | |
| 35 | suspend | |
| 36 | ? `${username}'s sessions and tokens stop at once. Suspend this account?` | |
| 37 | : `${username} can sign in again, with a new session. Lift the suspension?`, | |
| 38 | ], | |
| 39 | actions: [suspend ? { label: "Suspend", variant: "danger" } : { label: "Lift" }, { label: "Cancel" }], | |
| 40 | selected: 1, | |
| 41 | onChoose: (choice) => { | |
| 42 | if (choice !== 0) return; | |
| 43 | runAsync(ctx, async () => { | |
| 44 | const done = suspend ? await suspendAccount(f.app, f.user, username) : unsuspendAccount(f.app, f.user, username); | |
| 45 | if (done.ok) return ctx.toast(suspend ? `${username} is suspended.` : `${username} is lifted.`); | |
| 46 | const words = | |
| 47 | done.error === "not_found" | |
| 48 | ? "No such person." | |
| 49 | : done.error === "last_owner" | |
| 50 | ? lastOwnerSays(f.app.db, username) | |
| 51 | : done.error === "not_allowed" | |
| 52 | ? ADMIN_WORDS.selfSuspend | |
| 53 | : "That did not work."; | |
| 54 | ctx.toast(words, "danger"); | |
| 55 | }); | |
| 56 | }, | |
| 57 | }), | |
| 58 | ); | |
| 59 | }; | |
| 19 | 60 | const screen: Screen = { |
| 20 | 61 | title: "People", |
| 21 | keys: [binding(["r"], "change a role"), binding(["esc"], "back")], | |
| 62 | keys: [binding(["r"], "change a role"), binding(["s"], "suspend or lift"), binding(["esc"], "back")], | |
| 22 | 63 | render(area, s) { |
| 23 | 64 | const c = ctx.theme.c; |
| 24 | 65 | listPeople(f.app.db) |
| 25 | 66 | .slice(0, Math.max(0, area.h)) |
| 26 | 67 | .forEach((p, i) => { |
| 27 | const allowance = p.usdPerMonth === null ? "" : ` $${p.usdPerMonth.toFixed(2)} a month`; | |
| 28 | s.text(area.x, area.y + i, truncate(`${p.username} ${p.role}${allowance}`, area.w), style(c.mutedFg, c.bg), area.x + area.w); | |
| 68 | s.text(area.x, area.y + i, truncate(rowOf(p), area.w), style(c.mutedFg, c.bg), area.x + area.w); | |
| 29 | 69 | }); |
| 30 | 70 | }, |
| 31 | 71 | key(e) { |
| 32 | if (e.ctrl || e.alt || e.key !== "r") return false; | |
| 72 | if (e.ctrl || e.alt) return false; | |
| 73 | if (e.key === "s") { | |
| 74 | ctx.push( | |
| 75 | promptScreen(ctx, screen, { | |
| 76 | title: "Suspend or lift", | |
| 77 | label: "account name", | |
| 78 | onSubmit: (name) => { | |
| 79 | const username = name.trim(); | |
| 80 | const person = listPeople(f.app.db).find((p) => p.username === username); | |
| 81 | if (person === undefined) return "No such account, or not one a suspension is for."; | |
| 82 | askSuspend(username, person.suspendedAt === null); | |
| 83 | return undefined; | |
| 84 | }, | |
| 85 | }), | |
| 86 | ); | |
| 87 | return true; | |
| 88 | } | |
| 89 | if (e.key !== "r") return false; | |
| 33 | 90 | ctx.push( |
| 34 | 91 | promptScreen(ctx, screen, { |
| 35 | 92 | title: "Change a role", |
test/tui-people-suspend.test.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | /** `s` on the terminal's People screen suspends or lifts, as MCP `account_suspend` 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 } from "../src/app.ts"; | |
| 8 | import { ADMIN_WORDS } from "../src/actions/admin_words.ts"; | |
| 9 | import { createOrg, lastOwnerSays } from "../src/core/orgs.ts"; | |
| 10 | import { createSession, sessionUser } from "../src/core/sessions.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.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 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | describe("suspending from the terminal's People screen", () => { | |
| 20 | let dataDir = ""; | |
| 21 | let forge: App; | |
| 22 | ||
| 23 | const suspendedAt = (name: string) => getUserByName(forge.db, name)!.suspendedAt; | |
| 24 | ||
| 25 | function boot(username = "alice") { | |
| 26 | const terminal = headlessTerminal(140, 32); | |
| 27 | const user = getUserByName(forge.db, username)!; | |
| 28 | const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false }); | |
| 29 | app.start(); | |
| 30 | const press = async (...keys: string[]) => { | |
| 31 | for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 32 | await settleApp(app); | |
| 33 | }; | |
| 34 | const type = async (text: string) => press(...text.split("")); | |
| 35 | return { app, press, type, text: () => app.screen.toText() }; | |
| 36 | } | |
| 37 | ||
| 38 | async function people(h: ReturnType<typeof boot>): Promise<void> { | |
| 39 | h.app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 40 | await settleApp(h.app); | |
| 41 | await h.type("People"); | |
| 42 | await h.press("enter"); | |
| 43 | await settleUntil(h.app, "bob"); | |
| 44 | } | |
| 45 | ||
| 46 | async function ask(h: ReturnType<typeof boot>, name: string): Promise<void> { | |
| 47 | await h.press("s"); | |
| 48 | await h.type(name); | |
| 49 | await h.press("enter"); | |
| 50 | } | |
| 51 | ||
| 52 | beforeAll(async () => { | |
| 53 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-suspend-")); | |
| 54 | forge = createApp({ dataDir, port: 0 }); | |
| 55 | for (const [username, isAdmin] of [["alice", true], ["dave", true], ["bob", false], ["carol", false]] as const) { | |
| 56 | const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-long-suspend-passphrase", isAdmin }); | |
| 57 | if (!made.ok) throw new Error(username); | |
| 58 | } | |
| 59 | if (!createOrg(forge.db, "acme", getUserByName(forge.db, "carol")!.id).ok) throw new Error("org"); | |
| 60 | }, 30_000); | |
| 61 | ||
| 62 | afterAll(async () => { | |
| 63 | forge.close(); | |
| 64 | await rm(dataDir, { recursive: true, force: true }); | |
| 65 | }); | |
| 66 | ||
| 67 | test("a guest or a member is offered no People screen, and nobody changes", async () => { | |
| 68 | for (const name of ["bob", "carol"]) { | |
| 69 | const h = boot(name); | |
| 70 | h.app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 71 | await settleApp(h.app); | |
| 72 | await h.type("People"); | |
| 73 | expect(h.text()).not.toContain("who is a guest, a member or an admin"); | |
| 74 | await h.press("enter", "s"); | |
| 75 | expect(h.text()).not.toContain("Suspend or lift"); | |
| 76 | expect(suspendedAt("bob")).toBeNull(); | |
| 77 | h.app.stop(); | |
| 78 | } | |
| 79 | }); | |
| 80 | ||
| 81 | test("esc at the confirm changes nothing", async () => { | |
| 82 | const h = boot(); | |
| 83 | await people(h); | |
| 84 | await ask(h, "bob"); | |
| 85 | expect(h.text()).toContain("sessions and tokens stop at once"); | |
| 86 | await h.press("esc"); | |
| 87 | expect(suspendedAt("bob")).toBeNull(); | |
| 88 | h.app.stop(); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a confirm suspends, signs the person out and marks the row; a second lifts it", async () => { | |
| 92 | const token = createSession(forge.db, getUserByName(forge.db, "bob")!.id).token; | |
| 93 | expect(sessionUser(forge.db, token).ok).toBe(true); | |
| 94 | const h = boot(); | |
| 95 | await people(h); | |
| 96 | await ask(h, "bob"); | |
| 97 | await h.press("left", "enter"); | |
| 98 | await settleUntil(h.app, "bob is suspended."); | |
| 99 | expect(suspendedAt("bob")).not.toBeNull(); | |
| 100 | expect(sessionUser(forge.db, token).ok).toBe(false); | |
| 101 | expect(h.text()).toMatch(/bob +guest +suspended 20\d\d-/); | |
| 102 | ||
| 103 | await ask(h, "bob"); | |
| 104 | expect(h.text()).toContain("can sign in again"); | |
| 105 | await h.press("left", "enter"); | |
| 106 | await settleUntil(h.app, "bob is lifted."); | |
| 107 | expect(suspendedAt("bob")).toBeNull(); | |
| 108 | h.app.stop(); | |
| 109 | }); | |
| 110 | ||
| 111 | test("yourself, another admin and the only owner of an organization are refused in MCP's words", async () => { | |
| 112 | const h = boot(); | |
| 113 | await people(h); | |
| 114 | for (const [name, words] of [ | |
| 115 | ["alice", ADMIN_WORDS.selfSuspend], | |
| 116 | ["dave", ADMIN_WORDS.selfSuspend], | |
| 117 | ["carol", lastOwnerSays(forge.db, "carol")], | |
| 118 | ] as const) { | |
| 119 | await ask(h, name); | |
| 120 | await h.press("left", "enter"); | |
| 121 | await settleUntil(h.app, words.slice(0, 40)); | |
| 122 | expect(suspendedAt(name)).toBeNull(); | |
| 123 | } | |
| 124 | h.app.stop(); | |
| 125 | }); | |
| 126 | }); |
new above
offline