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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8125-an-org-admin-adds-a-person-to-a-team-wit → main 4 commits fork 1 thread · 1 unresolved
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' | ||
| 75 | 75 | |
| 76 | 76 | 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. |
| 77 | 77 | |
| 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 | ||
| 78 | 80 | 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`. |
| 79 | 81 | |
| 80 | 82 | 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"; | ||
| 24 | 24 | import { entry as invites } from "./invites.ts"; |
| 25 | 25 | import { entry as limits } from "./limits.ts"; |
| 26 | 26 | import { modelCeilingEntry, modelGrantEntry } from "./model.ts"; |
| 27 | import { entry as orgTeams } from "./orgteams.ts"; | |
| 27 | 28 | import { entry as owner } from "./owner.ts"; |
| 28 | 29 | import { entry as people } from "./people.ts"; |
| 29 | 30 | import { entry as quotas } from "./quotas.ts"; |
| @@ -70,6 +71,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 70 | 71 | quotas, |
| 71 | 72 | limits, |
| 72 | 73 | teams, |
| 74 | orgTeams, | |
| 73 | 75 | agents, |
| 74 | 76 | archived, |
| 75 | 77 | 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. */ | |
| 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 { | ||
| 17 | 17 | deleteTeam, |
| 18 | 18 | removeTeamMember, |
| 19 | 19 | } from "../../actions/orgs.ts"; |
| 20 | import { addRefusal, addedSays, removeRefusal, removedSays } from "../../actions/team_words.ts"; | |
| 20 | 21 | import { |
| 21 | 22 | OWNERS_TEAM, |
| 22 | 23 | accessAllows, |
| @@ -624,17 +625,10 @@ export async function teamMemberAdd(ctx: Ctx): Promise<Response> { | ||
| 624 | 625 | if (!added.ok) |
| 625 | 626 | return flashTo(ctx, dest, { |
| 626 | 627 | 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), | |
| 633 | 629 | }); |
| 634 | 630 | 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), | |
| 638 | 632 | }); |
| 639 | 633 | } |
| 640 | 634 | |
| @@ -660,17 +654,10 @@ export async function teamMemberRemove(ctx: Ctx): Promise<Response> { | ||
| 660 | 654 | if (!removed.ok) |
| 661 | 655 | return flashTo(ctx, dest, { |
| 662 | 656 | 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), | |
| 669 | 658 | }); |
| 670 | 659 | 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), | |
| 674 | 661 | }); |
| 675 | 662 | } |
| 676 | 663 |
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 | }); |
new above
offline
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.