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

An org admin makes a team with `n` (name and permission asked) and deletes the selected team with `D` after typing its name, from the terminal's Teams screen #8194

Merged opened this

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

Merged

scout/forge:scout/8126-an-org-admin-makes-a-team-with-n-name-an → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/8126-an-org-admin-makes-a-team-with-n-name-an was merged into main as 233d9c4.

Review

Sign in to review this change.
5 files changed +259 −23
README.md +1 −1
@@ -77,7 +77,7 @@ In the terminal, `m` on a local repository's issue list opens the plan screen (`
7777
7878 In the terminal, a repository screen's `:` palette offers **Delete this repository** (`<owner>/<name> — Delete this repository`, "cannot be undone") to a person who administers it, never to an agent, a writer who is not an admin or a reader. It asks for the full `<owner>/<name>`; anything else is refused with "Type <owner>/<name> to confirm." and nothing changes. The exact name calls `deleteRepo` (`src/actions/repos.ts`, as Settings' danger zone does), toasts the result and unwinds the stack to the screen beneath the repository's (`leaveRepo`), so no screen reads the deleted row.
7979
80 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.
80 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. On the list itself the admin makes a team with `n` (a name, then read, write or admin) and deletes the selected team with `D` once its name is typed in full; anything else is refused with "Type <team> to confirm." and nothing changes. A member who is not an org admin is offered none of these keys.
8181
8282 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`.
8383
src/actions/team_words.ts +29 −2
@@ -1,6 +1,7 @@
11 /**
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
2 * The sentences a person reads when a team is made or deleted, or a member
3 * added or removed. The Teams page (`POST /:owner/-/teams`, `/:team/delete`,
4 * `/:team/add` and `/:team/remove`) and the
45 * terminal's Organization teams screen say them from here, so they read the
56 * same on both.
67 */
@@ -32,3 +33,29 @@ export function removeRefusal(error: string, username: string, org: string, team
3233 export function removedSays(removed: boolean, username: string, team: string): string {
3334 return removed ? `Removed ${username} from ${team}.` : `${username} was not on ${team}.`;
3435 }
36
37 /** Why making a team was refused, from `createTeam`'s error. */
38 export function createRefusal(error: string, org: string, name: string): string {
39 return error === "name_taken"
40 ? `${org} already has a team called ${name}.`
41 : error === "invalid_name"
42 ? "A team needs a name."
43 : "Could not create that team.";
44 }
45
46 /** What making a team did. */
47 export function createdSays(name: string, permission: string): string {
48 return `Created ${name} with ${permission} access.`;
49 }
50
51 /** Why a delete was refused, from `deleteTeam`'s error: `protected` is the Owners team. */
52 export function deleteRefusal(error: string, team: string): string {
53 return error === "protected"
54 ? `${team} is protected — an organization must keep its owners.`
55 : "Could not delete that team.";
56 }
57
58 /** What a delete did. */
59 export function deletedSays(team: string): string {
60 return `Deleted the ${team} team.`;
61 }
src/tui/screens/orgteams.ts +102 −7
@@ -8,18 +8,38 @@
88 * `requireOrgAdmin`) adds a person with `a` and removes the member under the
99 * cursor with `d` after a y/n (#8125), through the same actions
1010 * `POST /:owner/-/teams/:team/add` and `/remove` call, in the page's words.
11 * On the list, an admin of the selected team's organization makes a team with
12 * `n` (a name, then read, write or admin) and deletes the selected team with
13 * `D` once its name is typed in full (#8126), through `createTeam` and
14 * `deleteTeam` as `POST /:owner/-/teams` and `/:team/delete` do.

The header comment cites #8126 while the pull is #8194. Confirm the reference is the issue's number.

1115 * The lines are read when the screen opens and again after a write, never on
1216 * every frame.
1317 */
1418
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";
19 import { addTeamMember, createTeam, deleteTeam, removeTeamMember } from "../../actions/orgs.ts";
20 import {
21 addRefusal,
22 addedSays,
23 createRefusal,
24 createdSays,
25 deleteRefusal,
26 deletedSays,
27 removeRefusal,
28 removedSays,
29 } from "../../actions/team_words.ts";
30 import {
31 listOrgsForUser,
32 listTeamMembers,
33 listTeamsVisibleTo,
34 orgAccess,
35 type Permission,
36 type Team,
37 } from "../../core/orgs.ts";
1838 import type { User } from "../../core/users.ts";
1939 import type { Ctx, Screen } from "../app/app.ts";
2040 import { binding } from "../app/keys.ts";
2141 import type { Session } from "../session.ts";
22 import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "./common.ts";
42 import { ListState, confirmScreen, drawList, later, pickerScreen, promptScreen, type ListRow } from "./common.ts";
2343 import type { PaletteEntry } from "./entries.ts";
2444
2545 /** One line of the list: a team of an organization, with its member count. */
@@ -115,6 +135,9 @@ function teamMembersScreen(ctx: Ctx, f: Session, org: User, team: Team, changed:
115135 return screen;
116136 }
117137
138 /** The permissions a team may grant, read first: the one `teamCreate` falls back to. */
139 const PERMISSIONS: readonly Permission[] = ["read", "write", "admin"];
140
118141 export function orgTeamsScreen(f: Session, ctx: Ctx): Screen {
119142 const state = new ListState();
120143 let lines = orgTeamLines(f);
@@ -130,22 +153,94 @@ export function orgTeamsScreen(f: Session, ctx: Ctx): Screen {
130153 const recount = () => {
131154 lines = orgTeamLines(f);
132155 rows = rowsOf();
156 state.clamp(lines.length);
133157 };
134 return {
158 /** The line under the cursor when the viewer administers its organization, the page's `requireOrgAdmin`. */
159 const adminLine = (): Line | undefined => {
160 const line = lines[state.index];
161 return line !== undefined && orgAccess(f.app.db, line.org.id, f.user) === "admin" ? line : undefined;
162 };
163 /** `n`: a name, then the permission (read first, as `teamCreate` falls back to it), then `createTeam`. */
164 const make = (org: User): void => {
165 ctx.push(
166 promptScreen(ctx, screen, {
167 title: `New team in ${org.username}`,
168 label: "Team name",
169 placeholder: "name",
170 onSubmit: (name) => {
171 if (name === "") return createRefusal("invalid_name", org.username, name);
172 return later(ctx, () =>
173 pickerScreen(ctx, screen, {
174 title: `${name}: permission`,
175 items: PERMISSIONS.map((p) => ({ id: p, label: p })),
176 onPick: ([chosen]) => {
177 const permission: Permission = chosen === "write" || chosen === "admin" ? chosen : "read";
178 const created = createTeam(f.app, f.user, org, name, permission);
179 if (!created.ok) ctx.toast(createRefusal(created.error, org.username, name), "danger");
180 else ctx.toast(createdSays(created.value.name, permission));
181 recount();
182 ctx.invalidate();
183 },
184 }),
185 );
186 },
187 }),
188 );
189 };
190 /** `D`: the team's name typed in full, then `deleteTeam`; any other text deletes nothing. */
191 const remove = (line: Line): void => {
192 const { org, team } = line;
193 ctx.push(
194 promptScreen(ctx, screen, {
195 title: `Delete ${team.name}`,
196 label: `Type ${team.name} to confirm`,
197 hint: "Everyone on it loses the access it gave.",
198 placeholder: team.name,
199 onSubmit: (typed) => {
200 if (typed !== team.name) return `Type ${team.name} to confirm.`;
201 const deleted = deleteTeam(f.app, f.user, org, team);
202 if (!deleted.ok) ctx.toast(deleteRefusal(deleted.error, team.name), "danger");
203 else ctx.toast(deletedSays(team.name));
204 recount();
205 ctx.invalidate();
206 return undefined;
207 },
208 }),
209 );

Only the 'protected' error means the Owners team. Branch on deleted.error and word every other failure (not_found) as 'Could not delete that team.' via team_words.ts.

210 };
211 const screen: Screen = {
135212 title: "Organization teams",
136 keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "members")],
213 get keys() {
214 return adminLine() === undefined
215 ? [binding(["j", "k"], "move cursor"), binding(["enter"], "members")]
216 : [
217 binding(["j", "k"], "move cursor"),
218 binding(["enter"], "members"),
219 binding(["n"], "new team"),
220 binding(["D"], "delete team"),
221 ];
222 },
137223 render(area, s) {
138224 drawList(s, area, ctx.theme.c, state, rows);
139225 },
140226 key(e) {
141227 if (state.key(e, lines.length)) return true;
142 if (e.ctrl || e.alt || e.key !== "enter") return false;
228 if (e.ctrl || e.alt) return false;
229 if (e.key === "n" || e.key === "D") {
230 const line = adminLine();
231 if (line === undefined) return false;
232 if (e.key === "n") make(line.org);
233 else remove(line);
234 return true;
235 }
236 if (e.key !== "enter") return false;
143237 const line = lines[state.index];
144238 if (line === undefined) return false;
145239 ctx.push(teamMembersScreen(ctx, f, line.org, line.team, recount));
146240 return true;
147241 },
148242 };
243 return screen;
149244 }
150245
151246 /** Organization teams, as /<org>/-/teams has them: anyone signed in who belongs to an organization. */
src/web/pages/org.ts +15 −11
@@ -17,7 +17,16 @@ import {
1717 deleteTeam,
1818 removeTeamMember,
1919 } from "../../actions/orgs.ts";
20 import { addRefusal, addedSays, removeRefusal, removedSays } from "../../actions/team_words.ts";
20 import {
21 addRefusal,
22 addedSays,
23 createRefusal,
24 createdSays,
25 deleteRefusal,
26 deletedSays,
27 removeRefusal,
28 removedSays,
29 } from "../../actions/team_words.ts";
2130 import {
2231 OWNERS_TEAM,
2332 accessAllows,
@@ -501,18 +510,13 @@ export async function teamCreate(ctx: Ctx): Promise<Response> {
501510 chosen === "write" || chosen === "admin" ? chosen : "read";
502511 const name = field(form, "name");
503512 const created = createTeam(ctx.app, ctx.auth.user, org, name, permission);
504 if (!created.ok) {
505 const messages: Record<string, string> = {
506 name_taken: `${org.username} already has a team called ${name}.`,
507 invalid_name: "A team needs a name.",
508 };
513 if (!created.ok)
509514 return flashTo(ctx, `/${org.username}`, {
510515 tone: "danger",
511 message: messages[created.error] ?? "Could not create that team.",
516 message: createRefusal(created.error, org.username, name),
512517 });
513 }
514518 return flashTo(ctx, `/${org.username}/-/teams/${created.value.id}`, {
515 message: `Created ${created.value.name} with ${permission} access.`,
519 message: createdSays(created.value.name, permission),
516520 });
517521 }
518522
@@ -669,9 +673,9 @@ export async function teamDelete(ctx: Ctx): Promise<Response> {
669673 if (!deleted.ok)
670674 return flashTo(ctx, `/${r.org.username}/-/teams/${r.team.id}`, {
671675 tone: "danger",
672 message: `${r.team.name} is protected — an organization must keep its owners.`,
676 message: deleteRefusal(deleted.error, r.team.name),
673677 });
674678 return flashTo(ctx, `/${r.org.username}`, {
675 message: `Deleted the ${r.team.name} team.`,
679 message: deletedSays(r.team.name),
676680 });
677681 }
test/tui-org-teams.test.ts +112 −2
@@ -4,14 +4,16 @@
44 * the Teams page's rule); Enter lists the team's members; a person in no
55 * organization is not offered the entry. An org admin adds a person with `a`
66 * and removes the member under the cursor with `d` after a y/n (#8125); a
7 * member is offered neither.
7 * member is offered neither. On the list an org admin makes a team with `n`
8 * (name, then permission) and deletes the selected team with `D` only after
9 * its name is typed in full (#8126); a member is offered neither key.
810 */
911 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1012 import { mkdtemp, rm } from "node:fs/promises";
1113 import { tmpdir } from "node:os";
1214 import { join } from "node:path";
1315 import { createApp, type App as Forge } from "../src/app.ts";
14 import { addTeamMember, createOrg, createTeam, listTeamMembers, listTeams } from "../src/core/orgs.ts";
16 import { addTeamMember, createOrg, createTeam, listTeamMembers, listTeams, removeTeamMember } from "../src/core/orgs.ts";
1517 import { createUser, getUserByName } from "../src/core/users.ts";
1618 import { createForgeApp } from "../src/tui/forge.ts";
1719 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -206,4 +208,112 @@ describe("organization teams in the terminal", () => {
206208 expect(members("Writers")).toEqual(before);
207209 h.stop();
208210 });
211
212 const team = (name: string) => {
213 const org = getUserByName(forge.db, "acme")!;
214 return listTeams(forge.db, org.id).find((x) => x.name === name);
215 };
216 /** Move the list cursor onto the row naming `name`. */
217 const cursorTo = async (h: ReturnType<typeof boot>, name: string) => {
218 const rows = h.text().split("\n").filter((l) => l.includes("@acme"));
219 const at = rows.findIndex((l) => l.includes(name));
220 expect(at).toBeGreaterThanOrEqual(0);
221 for (let i = 0; i < at; i++) await h.press("j");
222 };
223
224 test("an org admin makes a team with n: a name, then the permission", async () => {
225 const h = boot("olive");
226 await open(h);
227 expect(h.text()).toContain("n new team");
228 expect(h.text()).toContain("D delete team");
229 await h.press("n");
230 expect(h.text()).toContain("Team name");
231 await h.press("enter");
232 expect(h.text()).toContain("A team needs a name.");
233 await h.type("Editors");
234 await h.press("enter");
235 expect(h.text()).toContain("Editors: permission");
236 await h.press("j", "enter");
237 expect(team("Editors")?.permission).toBe("write");
238 expect(h.text()).toContain("Created Editors with write access.");
239 expect(h.text().split("\n").find((l) => l.includes("Editors")) ?? "").toContain("0 members · write");
240 await h.press("n");
241 await h.type("Writers");
242 await h.press("enter");
243 await h.press("enter");
244 expect(h.text()).toContain("acme already has a team called Writers.");
245 expect(team("Writers")?.permission).toBe("write");
246 h.stop();
247 });
248
249 test("D deletes the selected team only after its name is typed in full", async () => {
250 const h = boot("olive");
251 await open(h);
252 await cursorTo(h, "Editors");
253 await h.press("D");
254 expect(h.text()).toContain("Type Editors to confirm");
255 await h.type("Editor");
256 await h.press("enter");
257 expect(h.text()).toContain("Type Editors to confirm.");
258 expect(team("Editors")).toBeDefined();
259 await h.press("esc");
260 expect(team("Editors")).toBeDefined();
261 await h.press("D");
262 await h.type("Editors");
263 await h.press("enter");
264 expect(team("Editors")).toBeUndefined();
265 expect(h.text()).toContain("Deleted the Editors team.");
266 expect(h.text().split("\n").some((l) => l.includes("Editors") && l.includes("@acme"))).toBe(false);
267 h.stop();
268 });
269
270 test("the Owners team is refused in the page's words", async () => {
271 const h = boot("olive");
272 await open(h);
273 await cursorTo(h, "Owners");
274 await h.press("D");
275 await h.type("Owners");
276 await h.press("enter");
277 expect(h.text()).toContain("Owners is protected");
278 expect(team("Owners")).toBeDefined();
279 h.stop();
280 });
281
282 test("a member who is not an org admin is offered neither n nor D", async () => {
283 const h = boot("mark");
284 await open(h);
285 expect(h.text()).not.toContain("new team");
286 expect(h.text()).not.toContain("delete team");
287 await h.press("D");
288 expect(h.text()).not.toContain("to confirm");
289 await h.type("Writers");
290 await h.press("enter");
291 await h.press("n");
292 expect(h.text()).not.toContain("Team name");
293 expect(team("Writers")).toBeDefined();
294 h.stop();
295 });
296
297 test("an admin whose rights go between D and the typed name is refused plainly, not as protected", async () => {
298 const id = (n: string) => getUserByName(forge.db, n)!.id;
299 const beta = createOrg(forge.db, "beta", id("olive"));
300 if (!beta.ok) throw new Error(beta.error);
301 const temp = createTeam(forge.db, beta.value.id, "Temp", "read");
302 if (!temp.ok) throw new Error(temp.error);
303 const owners = listTeams(forge.db, beta.value.id).find((t) => t.name === "Owners")!;
304 addTeamMember(forge.db, owners, id("dana"));
305 const h = boot("dana");
306 await open(h);
307 const at = h.text().split("\n").filter((l) => l.includes("@beta")).findIndex((l) => l.includes("Temp"));
308 for (let i = 0; i < at; i++) await h.press("j");
309 await h.press("D");
310 expect(h.text()).toContain("Type Temp to confirm");
311 removeTeamMember(forge.db, owners, id("dana"));
312 await h.type("Temp");
313 await h.press("enter");
314 expect(h.text()).toContain("Could not delete that team.");
315 expect(h.text()).not.toContain("Temp is protected");
316 expect(listTeams(forge.db, beta.value.id).some((t) => t.name === "Temp")).toBe(true);
317 h.stop();
318 });
209319 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs