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

An org admin adds a person to a team with `a` and removes the member under the cursor with `d` after a confirm, from the terminal's Teams screen #8133

Merged opened this

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

Merged

scout/forge:scout/8125-an-org-admin-adds-a-person-to-a-team-wit → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/8125-an-org-admin-adds-a-person-to-a-team-wit was merged into main as 1ad325e.

Review

Sign in to review this change.
6 files changed +412 −18
README.md +2
@@ -75,6 +75,8 @@ In the terminal, `T` on a shipped pull's screen takes it back, as the pull page'
7575
7676 In the terminal, `m` on a local repository's issue list opens the plan screen (`src/tui/screens/plan.ts`): Working, Ready now, Waiting and Parked twice, each headed with its count. It reads what MCP `map` reads (`repoGraph` under the reader's sealed scope, `pillarView`, `mapHeldFor`, the runs in flight), so a sealed issue the reader may not see is left out. Enter opens the issue, `r` reads again, and the headings are skipped over. It is not offered on the pulls list or on a live browse of another forge.
7777
78 In the terminal, the palette's **Organization teams** (offered to anyone on a team of an organization) lists each organization's teams you may see, as `/<org>/-/teams` does (an org admin sees every team, a member only their own), each with its member count and permission; Enter lists the team's members. On that list an admin of the organization adds a person with `a` (type a username) and removes the member under the cursor with `d` after a y/n, in the Teams page's words; a member who is not an org admin is offered neither key.
79
7880 In the terminal, the palette's **Connected origins** lists the hosts your mirrors come from (a signed-in person's, not an agent's): `s` syncs the selected one now, `d` disconnects it after a confirm; see `docs/ORIGINS.md`.
7981
8082 A writer puts a ready issue **First**: the dispatcher starts it before the rest, and the map's Ready now list (and the plan screen's) says `first, by <who>`. On the map page it is the First button on each ready row (a same-origin POST to `/:owner/:name/issues/:number/first`, no JavaScript; `Accept: application/json` gets JSON); in the terminal it is `f` on a ready line of the plan screen. Both call `putIssueFirst` (`src/actions/first.ts`). Refused, changing nothing: a reader or an agent ("Only someone with write access can put an issue first."), a signed-out post (sent to sign in), a read-only token, a cross-origin post, and an issue that is closed or still waits for another ("Only an open issue that nothing waits in front of can go first."). The rank ends when the issue closes, and it is an `issue.first` event, so open maps and plan screens refresh. No MCP tool yet.
src/actions/team_words.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * The sentences a person reads when a team member is added or removed. The
3 * Teams page (`POST /:owner/-/teams/:team/add` and `/remove`) and the
4 * terminal's Organization teams screen say them from here, so they read the
5 * same on both.
6 */
7
8 /** Why an add was refused, from `addTeamMember`'s error. */
9 export function addRefusal(error: string, username: string): string {
10 return error === "no_such_user"
11 ? `No account called ${username || "that"}.`
12 : error === "agent_cannot_own"
13 ? `${username} is an agent: an agent never administers an organization.`
14 : "Could not add that member.";
15 }
16
17 /** What an add did: `added` false means they were already on the team. */
18 export function addedSays(added: boolean, username: string, team: string): string {
19 return added ? `Added ${username} to ${team}.` : `${username} was already on ${team}.`;
20 }
21
22 /** Why a remove was refused, from `removeTeamMember`'s error. */
23 export function removeRefusal(error: string, username: string, org: string, team: string): string {
24 return error === "no_such_user"
25 ? `No account called ${username || "that"}.`
26 : error === "last_owner"
27 ? `${team} must keep at least one person who owns ${org}. Make someone else an owner first.`
28 : "Could not remove that member.";
29 }
30
31 /** What a remove did: `removed` false means they were not on the team. */
32 export function removedSays(removed: boolean, username: string, team: string): string {
33 return removed ? `Removed ${username} from ${team}.` : `${username} was not on ${team}.`;
34 }
src/tui/screens/entries.ts +2
@@ -24,6 +24,7 @@ import { entry as forges } from "./forges.ts";
2424 import { entry as invites } from "./invites.ts";
2525 import { entry as limits } from "./limits.ts";
2626 import { modelCeilingEntry, modelGrantEntry } from "./model.ts";
27 import { entry as orgTeams } from "./orgteams.ts";
2728 import { entry as owner } from "./owner.ts";
2829 import { entry as people } from "./people.ts";
2930 import { entry as quotas } from "./quotas.ts";
@@ -70,6 +71,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
7071 quotas,
7172 limits,
7273 teams,
74 orgTeams,
7375 agents,
7476 archived,
7577 invites,
src/tui/screens/orgteams.ts added +160
@@ -0,0 +1,160 @@
1 /**
2 * Organization teams in the terminal (#7997): for each organization the
3 * viewer belongs to, the teams they may see with a member count, as
4 * /<org>/-/teams shows them. Enter on a team lists its members, `@username`
5 * and display name; esc goes back. Which teams show is `listTeamsVisibleTo`'s
6 * rule, the page's: an org admin sees every team, a member only their own.
7 * An admin of the organization (`orgAccess` allowing `admin`, the page's
8 * `requireOrgAdmin`) adds a person with `a` and removes the member under the
9 * cursor with `d` after a y/n (#8125), through the same actions
10 * `POST /:owner/-/teams/:team/add` and `/remove` call, in the page's words.
11 * The lines are read when the screen opens and again after a write, never on
12 * every frame.
13 */
14
15 import { addTeamMember, removeTeamMember } from "../../actions/orgs.ts";
16 import { addRefusal, addedSays, removeRefusal, removedSays } from "../../actions/team_words.ts";
17 import { listOrgsForUser, listTeamMembers, listTeamsVisibleTo, orgAccess, type Team } from "../../core/orgs.ts";
18 import type { User } from "../../core/users.ts";
19 import type { Ctx, Screen } from "../app/app.ts";
20 import { binding } from "../app/keys.ts";
21 import type { Session } from "../session.ts";
22 import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "./common.ts";
23 import type { PaletteEntry } from "./entries.ts";
24
25 /** One line of the list: a team of an organization, with its member count. */
26 type Line = { readonly org: User; readonly team: Team; readonly members: number };
27
28 /**
29 * Per organization the viewer is in, the teams they may see, in name order.
30 * `listOrgsForUser` only names orgs where the viewer is on a team, so every
31 * org here has at least that team to show.
32 */
33 export function orgTeamLines(f: Session): Line[] {
34 const user = f.user;
35 if (user === undefined) return [];
36 const lines: Line[] = [];
37 const orgs = listOrgsForUser(f.app.db, user.id).sort((a, b) => a.username.localeCompare(b.username));
38 for (const org of orgs) {
39 for (const team of listTeamsVisibleTo(f.app.db, org.id, user))
40 lines.push({ org, team, members: listTeamMembers(f.app.db, team.id).length });
41 }
42 return lines;
43 }
44
45 /** `changed` runs after a write, so the teams list can recount. */
outdated

Non-blocking: addRefusal and removeRefusal copy the wording from src/web/pages/org.ts:629 and :665. If the page's wording changes, the terminal's will drift. Consider sharing the strings.

46 function teamMembersScreen(ctx: Ctx, f: Session, org: User, team: Team, changed: () => void): Screen {
47 const state = new ListState();
48 const admin = orgAccess(f.app.db, org.id, f.user) === "admin";
49 const read = () => listTeamMembers(f.app.db, team.id).sort((a, b) => a.username.localeCompare(b.username));
50 let members = read();
51 const rowsOf = (): ListRow[] =>
52 members.map((u) => ({ title: `@${u.username}`, ...(u.displayName === "" ? {} : { secondary: u.displayName }) }));
53 let rows = rowsOf();
54 const reload = () => {
55 members = read();
56 rows = rowsOf();
57 state.clamp(members.length);
58 changed();
59 ctx.invalidate();
60 };
61 const screen: Screen = {
62 title: `${org.username} / ${team.name}`,
63 keys: admin
64 ? [binding(["j", "k"], "move cursor"), binding(["a"], "add"), binding(["d"], "remove")]
65 : [binding(["j", "k"], "move cursor")],
66 render(area, s) {
67 drawList(s, area, ctx.theme.c, state, rows);
68 },
69 key(e) {
70 if (state.key(e, rows.length)) return true;
71 if (!admin || e.ctrl || e.alt) return false;
72 if (e.key === "a") {
73 ctx.push(
74 promptScreen(ctx, screen, {
75 title: `Add to ${team.name}`,
76 label: "Username",
77 placeholder: "username",
78 onSubmit: (value) => {
79 const username = value.replace(/^@/, "");
80 const added = addTeamMember(f.app, f.user, org, team, username);
81 if (!added.ok) return addRefusal(added.error, username);
82 ctx.toast(addedSays(added.value.added, username, team.name));
83 reload();
84 return undefined;
85 },
86 }),
87 );
88 return true;
89 }
90 if (e.key === "d") {
91 const member = members[state.index];
92 if (member === undefined) return false;
93 ctx.push(
94 confirmScreen(ctx, screen, {
95 title: "Remove member",
96 body: [`remove @${member.username} from ${team.name}? y/n`],
97 actions: [{ label: "Remove", variant: "danger" }, { label: "Cancel", variant: "ghost" }],
98 selected: 1,
99 yesNo: true,
100 onChoose: (i) => {
101 if (i !== 0) return;
102 const removed = removeTeamMember(f.app, f.user, org, team, member.username);
103 if (!removed.ok)
104 ctx.toast(removeRefusal(removed.error, member.username, org.username, team.name), "danger");
105 else ctx.toast(removedSays(removed.value.removed, member.username, team.name));
106 reload();
107 },
108 }),
109 );
110 return true;
111 }
112 return false;
113 },
114 };
115 return screen;
116 }
117
118 export function orgTeamsScreen(f: Session, ctx: Ctx): Screen {
119 const state = new ListState();
120 let lines = orgTeamLines(f);
121 const rowsOf = () =>
122 lines.map(
123 (l): ListRow => ({
124 title: l.team.name,
125 secondary: `@${l.org.username}`,
126 meta: `${l.members} ${l.members === 1 ? "member" : "members"} · ${l.team.permission}`,
127 }),
128 );
129 let rows = rowsOf();
130 const recount = () => {
131 lines = orgTeamLines(f);
132 rows = rowsOf();
133 };
134 return {
135 title: "Organization teams",
136 keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "members")],
137 render(area, s) {
138 drawList(s, area, ctx.theme.c, state, rows);
139 },
140 key(e) {
141 if (state.key(e, lines.length)) return true;
142 if (e.ctrl || e.alt || e.key !== "enter") return false;
143 const line = lines[state.index];
144 if (line === undefined) return false;
145 ctx.push(teamMembersScreen(ctx, f, line.org, line.team, recount));
146 return true;
147 },
148 };
149 }
150
151 /** Organization teams, as /<org>/-/teams has them: anyone signed in who belongs to an organization. */
152 export const entry: PaletteEntry = {
153 id: "org-teams",
154 title: "Organization teams",
155 hint: "who is in each team of your organizations",
156 after: ["teams", "limits", "quotas", "people"],
157 offered: (session) =>
158 session.user !== undefined && listOrgsForUser(session.app.db, session.user.id).length > 0,
159 run: (session, ctx) => ctx.push(orgTeamsScreen(session, ctx)),
160 };
src/web/pages/org.ts +5 −18
@@ -17,6 +17,7 @@ import {
1717 deleteTeam,
1818 removeTeamMember,
1919 } from "../../actions/orgs.ts";
20 import { addRefusal, addedSays, removeRefusal, removedSays } from "../../actions/team_words.ts";
2021 import {
2122 OWNERS_TEAM,
2223 accessAllows,
@@ -624,17 +625,10 @@ export async function teamMemberAdd(ctx: Ctx): Promise<Response> {
624625 if (!added.ok)
625626 return flashTo(ctx, dest, {
626627 tone: "danger",
627 message:
628 added.error === "no_such_user"
629 ? `No account called ${username || "that"}.`
630 : added.error === "agent_cannot_own"
631 ? `${username} is an agent: an agent never administers an organization.`
632 : "Could not add that member.",
628 message: addRefusal(added.error, username),
633629 });
634630 return flashTo(ctx, dest, {
635 message: added.value.added
636 ? `Added ${username} to ${r.team.name}.`
637 : `${username} was already on ${r.team.name}.`,
631 message: addedSays(added.value.added, username, r.team.name),
638632 });
639633 }
640634
@@ -660,17 +654,10 @@ export async function teamMemberRemove(ctx: Ctx): Promise<Response> {
660654 if (!removed.ok)
661655 return flashTo(ctx, dest, {
662656 tone: "danger",
663 message:
664 removed.error === "no_such_user"
665 ? `No account called ${username || "that"}.`
666 : removed.error === "last_owner"
667 ? `${r.team.name} must keep at least one person who owns ${r.org.username}. Make someone else an owner first.`
668 : "Could not remove that member.",
657 message: removeRefusal(removed.error, username, r.org.username, r.team.name),
669658 });
670659 return flashTo(ctx, dest, {
671 message: removed.value.removed
672 ? `Removed ${username} from ${r.team.name}.`
673 : `${username} was not on ${r.team.name}.`,
660 message: removedSays(removed.value.removed, username, r.team.name),
674661 });
675662 }
676663
test/tui-org-teams.test.ts added +209
@@ -0,0 +1,209 @@
1 /**
2 * Organization teams in the terminal (#7997): an org admin sees every team of
3 * the organization, a member only the teams they are in (`listTeamsVisibleTo`,
4 * the Teams page's rule); Enter lists the team's members; a person in no
5 * organization is not offered the entry. An org admin adds a person with `a`
6 * and removes the member under the cursor with `d` after a y/n (#8125); a
7 * member is offered neither.
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App as Forge } from "../src/app.ts";
14 import { addTeamMember, createOrg, createTeam, listTeamMembers, listTeams } from "../src/core/orgs.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 describe("organization teams in the terminal", () => {
24 let dataDir: string;
25 let forge: Forge;
26
27 function boot(username: string) {
28 const terminal = headlessTerminal(140, 32);
29 const user = getUserByName(forge.db, username)!;
30 const { app } = createForgeApp(
31 forge,
32 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
33 { terminal, exitHooks: false },
34 );
35 app.start();
36 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
37 return {
38 text: () => app.screen.toText(),
39 press: async (...keys: string[]) => {
40 keys.forEach((k) => key(k));
41 await settleApp(app);
42 },
43 ctrl: async (k: string) => {
44 key(k, true);
45 await settleApp(app);
46 },
47 type: async (t: string) => {
48 for (const ch of t) key(ch);
49 await settleApp(app);
50 },
51 stop: () => app.stop(),
52 };
53 }
54
55 const open = async (h: ReturnType<typeof boot>) => {
56 await h.ctrl("k");
57 await h.type("Organization teams");
58 await h.press("enter");
59 };
60
61 beforeAll(async () => {
62 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-org-teams-"));
63 forge = createApp({ dataDir, port: 0 });
64 for (const username of ["rooty", "olive", "mark", "dana", "finn", "erin"]) {
65 const made = await createUser(forge.db, {
66 origin: "seed",
67 trusted: true,
68 username,
69 email: `${username}@forge.local`,
70 password: "a-good-passphrase",
71 adminIfFirst: username === "rooty",
72 ...(username === "mark" ? { displayName: "Mark Member" } : {}),
73 });
74 if (!made.ok) throw new Error(made.error);
75 }
76 const id = (n: string) => getUserByName(forge.db, n)!.id;
77 const org = createOrg(forge.db, "acme", id("olive"));
78 if (!org.ok) throw new Error(org.error);
79 const writers = createTeam(forge.db, org.value.id, "Writers", "write");
80 const readers = createTeam(forge.db, org.value.id, "Readers", "read");
81 if (!writers.ok || !readers.ok) throw new Error("team");
82 addTeamMember(forge.db, writers.value, id("mark"));
83 addTeamMember(forge.db, readers.value, id("dana"));
84 addTeamMember(forge.db, readers.value, id("finn"));
85 });
86 afterAll(async () => {
87 forge.close();
88 await rm(dataDir, { recursive: true, force: true });
89 });
90
91 test("an org admin sees every team with its member count", async () => {
92 const h = boot("olive");
93 await open(h);
94 const text = h.text();
95 expect(text).toContain("Organization teams");
96 expect(text).toContain("Owners");
97 expect(text).toContain("Writers");
98 expect(text).toContain("Readers");
99 expect(text).toContain("@acme");
100 const row = (team: string) => text.split("\n").find((l) => l.includes(team)) ?? "";
101 expect(row("Writers")).toContain("1 member · write");
102 expect(row("Readers")).toContain("2 members · read");
103 h.stop();
104 });
105
106 test("a member sees only the teams they are in", async () => {
107 const h = boot("mark");
108 await open(h);
109 const text = h.text();
110 expect(text.split("\n").find((l) => l.includes("Writers")) ?? "").toContain("1 member · write");
111 expect(text).not.toContain("Readers");
112 expect(text).not.toContain("Owners");
113 h.stop();
114 });
115
116 test("Enter lists the team's members, and esc goes back", async () => {
117 const h = boot("mark");
118 await open(h);
119 await h.press("enter");
120 let text = h.text();
121 expect(text).toContain("acme / Writers");
122 expect(text).toContain("@mark");
123 expect(text).toContain("Mark Member");
124 expect(text).not.toContain("@dana");
125 expect(text).not.toContain("@olive");
126 await h.press("esc");
127 text = h.text();
128 expect(text).not.toContain("acme / Writers");
129 expect(text).toContain("Organization teams");
130 h.stop();
131 });
132
133 test("a person in no organization is not offered the entry", async () => {
134 const h = boot("erin");
135 await h.ctrl("k");
136 await h.type("Organization teams");
137 expect(h.text()).not.toContain("who is in each team of your organizations");
138 h.stop();
139 const m = boot("mark");
140 await m.ctrl("k");
141 await m.type("Organization teams");
142 expect(m.text()).toContain("who is in each team of your organizations");
143 m.stop();
144 });
145 /** Open the teams screen as `who` and step into the team named `team`. */
146 const openTeam = async (who: string, team: string) => {
147 const h = boot(who);
148 await open(h);
149 const rows = h.text().split("\n");
150 const teams = rows.filter((l) => /(Owners|Writers|Readers)/.test(l) && l.includes("@acme"));
151 const at = teams.findIndex((l) => l.includes(team));
152 for (let i = 0; i < at; i++) await h.press("j");
153 await h.press("enter");
154 expect(h.text()).toContain(`acme / ${team}`);
155 return h;
156 };
157 const members = (team: string) => {
158 const org = getUserByName(forge.db, "acme")!;
159 const t = listTeams(forge.db, org.id).find((x) => x.name === team)!;
160 return listTeamMembers(forge.db, t.id).map((u) => u.username).sort();
161 };
162
163 test("an org admin adds a person with a, and the list and count redraw", async () => {
164 const h = await openTeam("olive", "Writers");
165 expect(h.text()).toContain("a add");
166 await h.press("a");
167 await h.type("nobody");
168 await h.press("enter");
169 expect(h.text()).toContain("No account called nobody.");
170 expect(members("Writers")).toEqual(["mark"]);
171 for (let i = 0; i < 6; i++) await h.press("backspace");
172 await h.type("erin");
173 await h.press("enter");
174 expect(members("Writers")).toEqual(["erin", "mark"]);
175 expect(h.text()).toContain("@erin");
176 await h.press("esc");
177 expect(h.text().split("\n").find((l) => l.includes("Writers")) ?? "").toContain("2 members · write");
178 h.stop();
179 });
180
181 test("d asks before removing; anything but y leaves the team as it was", async () => {
182 const h = await openTeam("olive", "Readers");
183 await h.press("d");
184 expect(h.text()).toContain("remove @dana from Readers? y/n");
185 await h.press("n");
186 expect(members("Readers")).toEqual(["dana", "finn"]);
187 await h.press("d");
188 await h.press("y");
189 expect(members("Readers")).toEqual(["finn"]);
190 expect(h.text()).not.toContain("@dana");
191 await h.press("esc");
192 expect(h.text().split("\n").find((l) => l.includes("Readers")) ?? "").toContain("1 member · read");
193 h.stop();
194 });
195
196 test("a member who is not an org admin is offered neither a nor d", async () => {
197 const h = await openTeam("mark", "Writers");
198 const before = members("Writers");
199 expect(h.text()).not.toContain("a add");
200 expect(h.text()).not.toContain("d remove");
201 await h.press("d");
202 expect(h.text()).not.toContain("y/n");
203 await h.press("y");
204 await h.press("a");
205 expect(h.text()).not.toContain("Username");
206 expect(members("Writers")).toEqual(before);
207 h.stop();
208 });
209 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs