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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8126-an-org-admin-makes-a-team-with-n-name-an → main 2 commits fork 2 threads · 2 unresolved
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 (` | ||
| 77 | 77 | |
| 78 | 78 | 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. |
| 79 | 79 | |
| 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. | |
| 81 | 81 | |
| 82 | 82 | 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`. |
| 83 | 83 |
src/actions/team_words.ts +29 −2
| @@ -1,6 +1,7 @@ | ||
| 1 | 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 | |
| 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 | |
| 4 | 5 | * terminal's Organization teams screen say them from here, so they read the |
| 5 | 6 | * same on both. |
| 6 | 7 | */ |
| @@ -32,3 +33,29 @@ export function removeRefusal(error: string, username: string, org: string, team | ||
| 32 | 33 | export function removedSays(removed: boolean, username: string, team: string): string { |
| 33 | 34 | return removed ? `Removed ${username} from ${team}.` : `${username} was not on ${team}.`; |
| 34 | 35 | } |
| 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 @@ | ||
| 8 | 8 | * `requireOrgAdmin`) adds a person with `a` and removes the member under the |
| 9 | 9 | * cursor with `d` after a y/n (#8125), through the same actions |
| 10 | 10 | * `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. | |
| 11 | 15 | * The lines are read when the screen opens and again after a write, never on |
| 12 | 16 | * every frame. |
| 13 | 17 | */ |
| 14 | 18 | |
| 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"; | |
| 18 | 38 | import type { User } from "../../core/users.ts"; |
| 19 | 39 | import type { Ctx, Screen } from "../app/app.ts"; |
| 20 | 40 | import { binding } from "../app/keys.ts"; |
| 21 | 41 | 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"; | |
| 23 | 43 | import type { PaletteEntry } from "./entries.ts"; |
| 24 | 44 | |
| 25 | 45 | /** 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: | ||
| 115 | 135 | return screen; |
| 116 | 136 | } |
| 117 | 137 | |
| 138 | /** The permissions a team may grant, read first: the one `teamCreate` falls back to. */ | |
| 139 | const PERMISSIONS: readonly Permission[] = ["read", "write", "admin"]; | |
| 140 | ||
| 118 | 141 | export function orgTeamsScreen(f: Session, ctx: Ctx): Screen { |
| 119 | 142 | const state = new ListState(); |
| 120 | 143 | let lines = orgTeamLines(f); |
| @@ -130,22 +153,94 @@ export function orgTeamsScreen(f: Session, ctx: Ctx): Screen { | ||
| 130 | 153 | const recount = () => { |
| 131 | 154 | lines = orgTeamLines(f); |
| 132 | 155 | rows = rowsOf(); |
| 156 | state.clamp(lines.length); | |
| 133 | 157 | }; |
| 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 = { | |
| 135 | 212 | 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 | }, | |
| 137 | 223 | render(area, s) { |
| 138 | 224 | drawList(s, area, ctx.theme.c, state, rows); |
| 139 | 225 | }, |
| 140 | 226 | key(e) { |
| 141 | 227 | 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; | |
| 143 | 237 | const line = lines[state.index]; |
| 144 | 238 | if (line === undefined) return false; |
| 145 | 239 | ctx.push(teamMembersScreen(ctx, f, line.org, line.team, recount)); |
| 146 | 240 | return true; |
| 147 | 241 | }, |
| 148 | 242 | }; |
| 243 | return screen; | |
| 149 | 244 | } |
| 150 | 245 | |
| 151 | 246 | /** 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 { | ||
| 17 | 17 | deleteTeam, |
| 18 | 18 | removeTeamMember, |
| 19 | 19 | } 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"; | |
| 21 | 30 | import { |
| 22 | 31 | OWNERS_TEAM, |
| 23 | 32 | accessAllows, |
| @@ -501,18 +510,13 @@ export async function teamCreate(ctx: Ctx): Promise<Response> { | ||
| 501 | 510 | chosen === "write" || chosen === "admin" ? chosen : "read"; |
| 502 | 511 | const name = field(form, "name"); |
| 503 | 512 | 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) | |
| 509 | 514 | return flashTo(ctx, `/${org.username}`, { |
| 510 | 515 | tone: "danger", |
| 511 | message: messages[created.error] ?? "Could not create that team.", | |
| 516 | message: createRefusal(created.error, org.username, name), | |
| 512 | 517 | }); |
| 513 | } | |
| 514 | 518 | 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), | |
| 516 | 520 | }); |
| 517 | 521 | } |
| 518 | 522 | |
| @@ -669,9 +673,9 @@ export async function teamDelete(ctx: Ctx): Promise<Response> { | ||
| 669 | 673 | if (!deleted.ok) |
| 670 | 674 | return flashTo(ctx, `/${r.org.username}/-/teams/${r.team.id}`, { |
| 671 | 675 | tone: "danger", |
| 672 | message: `${r.team.name} is protected — an organization must keep its owners.`, | |
| 676 | message: deleteRefusal(deleted.error, r.team.name), | |
| 673 | 677 | }); |
| 674 | 678 | return flashTo(ctx, `/${r.org.username}`, { |
| 675 | message: `Deleted the ${r.team.name} team.`, | |
| 679 | message: deletedSays(r.team.name), | |
| 676 | 680 | }); |
| 677 | 681 | } |
test/tui-org-teams.test.ts +112 −2
| @@ -4,14 +4,16 @@ | ||
| 4 | 4 | * the Teams page's rule); Enter lists the team's members; a person in no |
| 5 | 5 | * organization is not offered the entry. An org admin adds a person with `a` |
| 6 | 6 | * 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. | |
| 8 | 10 | */ |
| 9 | 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 10 | 12 | import { mkdtemp, rm } from "node:fs/promises"; |
| 11 | 13 | import { tmpdir } from "node:os"; |
| 12 | 14 | import { join } from "node:path"; |
| 13 | 15 | 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"; | |
| 15 | 17 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 16 | 18 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 17 | 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -206,4 +208,112 @@ describe("organization teams in the terminal", () => { | ||
| 206 | 208 | expect(members("Writers")).toEqual(before); |
| 207 | 209 | h.stop(); |
| 208 | 210 | }); |
| 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 | }); | |
| 209 | 319 | }); |
new above
offline
The header comment cites #8126 while the pull is #8194. Confirm the reference is the issue's number.