Skip to content

Commit

Owners choose who can create teams

A workspace setting, "Who can create teams": any member (the default) or owners only. Identity keeps it in workspaces.team_creation (migration 0028), enforces it in create_team, records changes as workspace.team_creation_changed, and carries it on the workspace and on each member's membership. Owners set it under Settings, General, Teams. Members who may not create one see a hint on the Teams page instead of New team, find New team greyed out in the + menu, and are sent back from /-/teams/new. The API's PATCH /workspaces/{workspace} (MCP workspace update) takes team_creation, and a new GET /workspaces/{workspace} (workspace get, workspace:read, members only) reads the workspace with its settings. Docs: the teams guide, workspace settings, audit log, MCP and scopes.

syntaqxcommitted Parent4fb41dbBrowse files
32 files+194−290/32 viewed
+2−1
4747 (
4848 "Workspaces",
4949 "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.",
50− &[Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace],
50+ &[Op::GetWorkspace, Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace],
5151 ),
5252 (
5353 "Invites",
360360 fn title(op: Op) -> &'static str {
361361 match op {
362362 Op::Whoami => "Get the current user",
363+ Op::GetWorkspace => "Get a workspace",
363364 Op::CreateWorkspace => "Create a workspace",
364365 Op::DeleteWorkspace => "Delete a workspace",
365366 Op::UpdateWorkspace => "Update a workspace",
+53−6
1616 use g1t_contracts::repos::{CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath};
1717 use g1t_contracts::teams::{
1818 CreateTeamArgs, DeleteTeamArgs, ListTeamsArgs, RemoveTeamMemberArgs, RemoveTeamRepoArgs, ReviewAlgorithm,
19− ReviewAssignment, SetTeamMemberArgs, SetTeamRepoArgs, Team, TeamArgs, TeamRole, TeamVisibility, UpdateTeamArgs,
19+ ReviewAssignment, SetTeamCreationArgs, SetTeamMemberArgs, SetTeamRepoArgs, Team, TeamArgs, TeamCreation, TeamRole,
20+ TeamVisibility, UpdateTeamArgs,
2021 UserTeamsArgs,
2122 };
2223 use g1t_contracts::security::{
8788 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
8889 pub enum Op {
8990 Whoami,
91+ GetWorkspace,
9092 CreateWorkspace,
9193 DeleteWorkspace,
9294 UpdateWorkspace,
623625 }
624626
625627 impl Op {
626− pub const ALL: [Op; 204] = [
628+ pub const ALL: [Op; 205] = [
627629 Op::Whoami,
630+ Op::GetWorkspace,
628631 Op::CreateWorkspace,
629632 Op::DeleteWorkspace,
630633 Op::UpdateWorkspace,
838841 pub fn name(self) -> &'static str {
839842 match self {
840843 Op::Whoami => "whoami",
844+ Op::GetWorkspace => "get_workspace",
841845 Op::CreateWorkspace => "create_workspace",
842846 Op::DeleteWorkspace => "delete_workspace",
843847 Op::UpdateWorkspace => "update_workspace",
10531057 Op::DeleteWorkspace => {
10541058 "Delete a workspace and everything in it. Owners only, signed in as a person, and confirm must be the workspace's slug. Billing must be able to settle it: no unpaid invoice, no prepaid credit left, and no usage this month still being metered; what it owes is charged to its card at once and its plan ends. Its repositories, projects and apps go with it at once, nobody can reach it, and its access tokens stop working. It is kept for 30 days, when g1t's support can restore it as it was; then it is purged, with its webhooks, integrations and workspace secrets. Its statements, invoices and audit log are kept. The slug is never given to another workspace; the person whose username it is may create it again once it is purged. Some workspaces, such as Flagon's, can never be deleted."
10551059 }
1060+ Op::GetWorkspace => {
1061+ "One workspace you belong to: its name, description and member count, what every member gets on each of its repositories (base_permission), and who may create its teams (team_creation: members or owners). Members only."
1062+ }
10561063 Op::UpdateWorkspace => {
1057− "Change a workspace's display name and description, and what every member gets on each of its repositories (base_permission: none, read, write or admin). Only the fields given are changed; give at least one. An empty name falls back to the slug, which this never changes (that is a rename, on Settings); an empty description clears it. Owners only, signed in as a person. Returns the workspace as it is now."
1064+ "Change a workspace's display name and description, what every member gets on each of its repositories (base_permission: none, read, write or admin), and who may create its teams (team_creation: members or owners). Only the fields given are changed; give at least one. An empty name falls back to the slug, which this never changes (that is a rename, on Settings); an empty description clears it. Owners only, signed in as a person. Returns the workspace as it is now."
10581065 }
10591066 Op::ListRepos => "Repositories you can see, optionally filtered by a search query.",
10601067 Op::GetRepo => "One repository's details.",
14361443 "One team, by its slug, as list_teams describes it. A secret team is found only by its own people and the workspace's owners; anyone else is told it does not exist. Members of the workspace only."
14371444 }
14381445 Op::CreateTeam => {
1439− "Create a team in a workspace. Any member may create one, and becomes its first maintainer; `members` adds more people by username, each a member of the workspace. `slug` is made from the name unless you give one: lowercase letters, digits and single hyphens. `visibility` is `visible` (the default: every member sees it) or `secret` (only its people and the owners). A team under a `parent` inherits the parent's roles on repositories, and a mention or review request for the parent reaches it too; giving it a parent needs an owner, or a maintainer of the parent. Secret teams cannot be nested. People only, signed in or with a personal access token. Returns the team."
1446+ "Create a team in a workspace. Any member may create one, unless the workspace's `team_creation` is `owners` (then only owners may: see update_workspace), and becomes its first maintainer; `members` adds more people by username, each a member of the workspace. `slug` is made from the name unless you give one: lowercase letters, digits and single hyphens. `visibility` is `visible` (the default: every member sees it) or `secret` (only its people and the owners). A team under a `parent` inherits the parent's roles on repositories, and a mention or review request for the parent reaches it too; giving it a parent needs an owner, or a maintainer of the parent. Secret teams cannot be nested. People only, signed in or with a personal access token. Returns the team."
14401447 }
14411448 Op::UpdateTeam => {
14421449 "Change a team's `name`, `slug`, `description`, `visibility`, `parent` (an empty string takes it out from under its parent), `notify` or `review_assignment`. Only the fields given change; give at least one. A new slug changes how it is mentioned, @workspace/slug. Owners of the workspace and the team's maintainers. People only. Returns the team as it is now."
15121519 let states = json!({ "type": "string", "enum": ["open", "closed"] });
15131520 match self {
15141521 Op::Whoami => object(json!({}), &[]),
1522+ Op::GetWorkspace => object(json!({ "workspace": workspace_schema() }), &["workspace"]),
15151523 Op::CreateWorkspace => object(
15161524 json!({
15171525 "slug": {
16211629 "enum": g1t_contracts::access::BasePermission::ALL.map(|base| base.as_str()),
16221630 "description": "What every member gets on each repository: none, read, write or admin. Needs the access:admin scope as well.",
16231631 },
1632+ "team_creation": {
1633+ "type": "string",
1634+ "enum": g1t_contracts::teams::TeamCreation::ALL.map(|setting| setting.as_str()),
1635+ "description": "Who may create the workspace's teams: members (any member, the default) or owners (owners only).",
1636+ },
16241637 }),
16251638 &["workspace"],
16261639 ),
28342847 !matches!(
28352848 self,
28362849 Op::Whoami
2850+ | Op::GetWorkspace
28372851 | Op::CreateWorkspace
28382852 | Op::DeleteWorkspace
28392853 | Op::UpdateWorkspace
30803094
30813095 match self {
30823096 Op::Whoami => ok(&actor()),
3097+ Op::GetWorkspace => {
3098+ // Its settings are its members' business.
3099+ if actor().role_in(&workspace()).is_none() {
3100+ return failed(FailureCode::NotFound, "Workspace not found.");
3101+ }
3102+ match g1t_kit::call::<_, Option<Workspace>>(identity, "get_workspace", &json!({ "slug": workspace() })).await? {
3103+ Some(found) => ok(&found),
3104+ None => failed(FailureCode::NotFound, "Workspace not found."),
3105+ }
3106+ }
30833107 Op::CreateWorkspace => {
30843108 pass(
30853109 identity,
31893213 None => return failed(FailureCode::Invalid, "base_permission is none, read, write or admin."),
31903214 },
31913215 };
3216+ let creation = match input.get("team_creation").filter(|value| !value.is_null()) {
3217+ None => None,
3218+ Some(value) => match value.as_str().and_then(TeamCreation::parse) {
3219+ Some(setting) => Some(setting),
3220+ None => return failed(FailureCode::Invalid, "team_creation is members or owners."),
3221+ },
3222+ };
31923223 let (name, description) = (optional_text(input, "name"), optional_text(input, "description"));
3193− if base.is_none() && name.is_none() && description.is_none() {
3194− return failed(FailureCode::Invalid, "Give name, description or base_permission to change.");
3224+ if base.is_none() && creation.is_none() && name.is_none() && description.is_none() {
3225+ return failed(FailureCode::Invalid, "Give name, description, base_permission or team_creation to change.");
31953226 }
31963227 let found = || async {
31973228 g1t_kit::call::<_, Option<Workspace>>(identity, "get_workspace", &json!({ "slug": workspace() })).await
32323263 return Ok(Outcome::Fail(failure));
32333264 }
32343265 }
3266+ if let Some(setting) = creation {
3267+ let set: Outcome<TeamCreation> = call(
3268+ identity,
3269+ "set_team_creation",
3270+ &SetTeamCreationArgs {
3271+ actor: actor(),
3272+ slug: workspace(),
3273+ team_creation: setting,
3274+ surface: Some(services.audit.surface),
3275+ },
3276+ )
3277+ .await?;
3278+ if let Outcome::Fail(failure) = set {
3279+ return Ok(Outcome::Fail(failure));
3280+ }
3281+ }
32353282 match found().await? {
32363283 Some(workspace) => ok(&workspace),
32373284 None => failed(FailureCode::NotFound, "Workspace not found."),
+25−6
6666 "description": null,
6767 "created_at": "2026-10-04T16:02:51.337Z",
6868 "member_count": 1,
69− "base_permission": "write"
69+ "base_permission": "write",
70+ "team_creation": "members"
7071 },
71− "notes": "`base_permission` is what every member gets on each of its repositories: `write` until an owner changes it with `PATCH /workspaces/{workspace}` or `PUT /workspaces/{workspace}/base_permission`. See [Access and roles](/guides/access-and-roles/)."
72+ "notes": "`base_permission` is what every member gets on each of its repositories: `write` until an owner changes it with `PATCH /workspaces/{workspace}` or `PUT /workspaces/{workspace}/base_permission`. See [Access and roles](/guides/access-and-roles/). `team_creation` is who may create its teams: `members` (any member) until an owner sets `owners` with `PATCH /workspaces/{workspace}`."
7273 },
74+ "get_workspace": {
75+ "params": {
76+ "workspace": "acme-labs"
77+ },
78+ "response": {
79+ "id": "wsp_01m43teqa9em6bje0bhvdj4jkb",
80+ "slug": "acme-labs",
81+ "name": "Acme Labs",
82+ "description": "Rockets, and the software that flies them.",
83+ "created_at": "2026-10-04T16:02:51.337Z",
84+ "member_count": 3,
85+ "base_permission": "write",
86+ "team_creation": "members"
87+ },
88+ "notes": "`team_creation` is who may create its teams: `members` (any member, the default) or `owners`. Change it, and the rest, with [`PATCH /workspaces/{workspace}`](/reference/api/workspaces/update-workspace/). `404` for anyone who is not a member. See [Workspaces](/guides/workspaces/)."
89+ },
7390 "update_workspace": {
7491 "params": {
7592 "workspace": "acme-labs"
7693 },
7794 "request": {
7895 "name": "Acme Labs",
79− "description": "Rockets, and the software that flies them."
96+ "description": "Rockets, and the software that flies them.",
97+ "team_creation": "owners"
8098 },
8199 "response": {
82100 "id": "wsp_01m43teqa9em6bje0bhvdj4jkb",
85103 "description": "Rockets, and the software that flies them.",
86104 "created_at": "2026-10-04T16:02:51.337Z",
87105 "member_count": 3,
88− "base_permission": "write"
106+ "base_permission": "write",
107+ "team_creation": "owners"
89108 },
90− "notes": "Only the fields given change. `name` is the display name; the slug, the first part of the workspace's addresses, stays as it is. `base_permission` needs the `access:admin` scope as well as `workspace:admin`; [`set_base_permission`](/reference/api/access/set-base-permission/) sets it alone. Refused with `403` for anyone but an owner signed in as a person. Recorded in the [audit log](/guides/audit-log/). See [Workspaces](/guides/workspaces/)."
109+ "notes": "Only the fields given change. `name` is the display name; the slug, the first part of the workspace's addresses, stays as it is. `base_permission` needs the `access:admin` scope as well as `workspace:admin`; [`set_base_permission`](/reference/api/access/set-base-permission/) sets it alone. `team_creation` is `members` (any member may create a team, the default) or `owners`; teams already made stay. Refused with `403` for anyone but an owner signed in as a person. Recorded in the [audit log](/guides/audit-log/). See [Workspaces](/guides/workspaces/)."
91110 },
92111 "delete_workspace": {
93112 "request": {
44414460 "created_at": "2026-10-06T15:02:11.480Z",
44424461 "updated_at": "2026-10-06T15:02:11.480Z"
44434462 },
4444− "notes": "You become the team's maintainer. `slug` is made from `name` when left out (`Web & Mobile` becomes `web-mobile`), and `409` says one is taken. A workspace has at most 500 teams, nested at most 8 deep; `name` is at most 80 characters and `description` 280. A `secret` team cannot have a parent or child teams. Refused with `403` for an agent's or a workspace's token. See [Teams](/guides/teams/)."
4463+ "notes": "You become the team's maintainer. Refused with `403` for a member when the workspace's `team_creation` is `owners`. `slug` is made from `name` when left out (`Web & Mobile` becomes `web-mobile`), and `409` says one is taken. A workspace has at most 500 teams, nested at most 8 deep; `name` is at most 80 characters and `description` 280. A `secret` team cannot have a parent or child teams. Refused with `403` for an agent's or a workspace's token. See [Teams](/guides/teams/)."
44454464 },
44464465 "get_team": {
44474466 "params": {
+1−1
105105 sent["codeOwners"] = code_owners.clone();
106106 }
107107 match op {
108− Op::CreateWorkspace | Op::UpdateWorkspace => through::<g1t_contracts::identity::Workspace>(op, sent),
108+ Op::GetWorkspace | Op::CreateWorkspace | Op::UpdateWorkspace => through::<g1t_contracts::identity::Workspace>(op, sent),
109109 Op::ListRepos => through::<Vec<repos::Repo>>(op, sent),
110110 Op::Search => through::<search::SearchResults>(op, sent),
111111 Op::GetRepo
+1−0
3131 pub const ROUTES: &[Route] = &[
3232 route("GET", "/user", Op::Whoami, &[]),
3333 route("POST", "/workspaces", Op::CreateWorkspace, &[]),
34+ route("GET", "/workspaces/:workspace", Op::GetWorkspace, &[]),
3435 route("DELETE", "/workspaces/:workspace", Op::DeleteWorkspace, &[]),
3536 route("GET", "/user/emails", Op::ListEmails, &[]),
3637 route("POST", "/user/emails", Op::AddEmail, &[]),
+2−1
270270 description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, connect integrations and model providers, and keep your own pinned projects at the top of its sidebar.",
271271 default_action: None,
272272 actions: &[
273+ a("get", Op::GetWorkspace, "A workspace's details and settings"),
273274 a("create", Op::CreateWorkspace, "Create a workspace"),
274275 a("delete", Op::DeleteWorkspace, "Delete a workspace and everything in it (support can restore it for 30 days)"),
275− a("update", Op::UpdateWorkspace, "Change its name, description or base permission"),
276+ a("update", Op::UpdateWorkspace, "Change its name, description, base permission or who may create teams"),
276277 a("list_invites", Op::ListWorkspaceInvites, "Its invites"),
277278 a("invite_member", Op::InviteMember, "Invite an email address"),
278279 a("revoke_invite", Op::RevokeWorkspaceInvite, "Revoke a pending invite"),
+1−0
2929 | `repo.collaborator_added`, `repo.collaborator_role_changed`, `repo.collaborator_removed` | Someone was given a role on it, had it changed, or lost it. See [access and roles](/guides/access-and-roles/). |
3030 | `repo.invitation_created`, `repo.invitation_revoked` | Someone was invited to it, or an invitation was withdrawn. |
3131 | `workspace.base_permission_changed` | An owner changed what members get on every repository. |
32+| `workspace.team_creation_changed` | An owner changed who can create teams. See [who can create teams](/guides/teams/#who-can-create-teams). |
3233 | `team.created`, `team.edited`, `team.deleted` | A [team](/guides/teams/) was created, changed or deleted. |
3334 | `team.member_added`, `team.member_role_changed`, `team.member_removed` | Someone was added to a team, made its maintainer or a member, or taken out of it. |
3435 | `team.repo_added`, `team.repo_role_changed`, `team.repo_removed` | A team was given a role on a repository, had it changed, or lost it. |
+1−1
359359 | `account:write` | Change your email addresses, make invites, answer invitations and pin projects |
360360 | `notifications:read` | See your [inbox](/guides/inbox/), its threads, and what you subscribe to and watch |
361361 | `notifications:write` | Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories |
362−| `workspace:read` | Read workspace invites, integrations, model routes and [teams](/guides/teams/) |
362+| `workspace:read` | Read workspace settings, invites, integrations, model routes and [teams](/guides/teams/) |
363363 | `workspace:admin` | Create and delete workspaces, invite members, manage teams, connect integrations |
364364 | `billing:read` | See a workspace's [usage, budget, AI credit and invoices](/guides/usage-and-billing/) |
365365 | `billing:write` | Change a workspace's budget and buy AI credit. Only owners, as people: a workspace's own token and g1t's agents never change billing, whatever their scopes. Not in any preset but full access. |
+22−1
1515 ## Create a team
1616
1717 Any member of the workspace with a confirmed email address can create a
18−team, and becomes its first maintainer.
18+team, and becomes its first maintainer, unless an owner has set
19+[who can create teams](#who-can-create-teams) to owners only.
1920
2021 1. Open **Teams** in the sidebar: `g1t.sh/<workspace>/-/teams`.
2122 2. Choose **New team**, or go to `g1t.sh/<workspace>/-/teams/new`. The
4243 name, with a search box that matches names and slugs. A person's teams
4344 also show beside them on the workspace's **People** page.
4445
46+### Who can create teams
47+
48+An owner chooses who can create the workspace's teams, under **Settings**,
49+**General**, **Teams**:
50+
51+| Who can create teams | |
52+| --- | --- |
53+| **Any member** | Every member with a confirmed email address. The default. |
54+| **Owners only** | Only the workspace's owners. Members see **Only owners can create teams in this workspace.** on the Teams page instead of **New team**, and **New team** in the **+** menu is greyed out. |
55+
56+Teams that already exist stay as they are, and their maintainers still
57+manage them. The change is recorded in the [audit log](/guides/audit-log/)
58+as `workspace.team_creation_changed`.
59+
60+Through the API, set `team_creation` to `members` or `owners` with
61+[`PATCH /workspaces/{workspace}`](/reference/api/workspaces/update-workspace/)
62+(the `workspace` tool's `update` action over MCP); the workspace you get
63+back, and [`GET /workspaces/{workspace}`](/reference/api/workspaces/get-workspace/),
64+include it. Creating a team when you may not is refused with `403`.
65+
4566 ## Visibility
4667
4768 | Visibility | Who can see it, mention it and ask it to review |
+1−1
380380
381381 | Settings | Who | |
382382 | --- | --- | --- |
383−| **General** | Owners | The icon, the display name, a one-line description and the address (the slug). |
383+| **General** | Owners | The icon, the display name, a one-line description, the address (the slug), [who can create teams](/guides/teams/#who-can-create-teams), and [data residency](#data-residency). |
384384 | **Repositories** | Members | The workspace's repositories. Owners also see **Recently deleted**, where a [deleted repository](/guides/managing-repositories/#restore-a-repository) can be restored, or purged, for 30 days. |
385385 | **Access tokens** | Members | The workspace's own tokens. Owners create and delete them. |
386386 | **Guardrails** | Members | What agents may do and spend across the workspace. Owners change them. |
+3−2
510510 | --- | --- | --- | --- |
511511 | [`list`](/reference/api/teams/list-teams/) | The workspace's teams you can see, yours first; `query` narrows by name or slug. Members only. | `workspace` | `workspace:read` |
512512 | [`get`](/reference/api/teams/get-team/) | One team: its `visibility`, `parent`, `notify`, `review_assignment`, counts, your `viewer_role` and whether you may change it (`can_manage`). | `workspace`, `team` | `workspace:read` |
513−| [`create`](/reference/api/teams/create-team/) | Create a team; you become its maintainer. `slug` is made from `name` unless given; `visibility`, `parent`, `notify`, and `members` to add by username. | `workspace`, `name` | `workspace:admin` |
513+| [`create`](/reference/api/teams/create-team/) | Create a team; you become its maintainer. Members may, unless the workspace's `team_creation` is `owners`. `slug` is made from `name` unless given; `visibility`, `parent`, `notify`, and `members` to add by username. | `workspace`, `name` | `workspace:admin` |
514514 | [`update`](/reference/api/teams/update-team/) | Change its `name`, `slug`, `description`, `visibility`, `parent` (`""` for none), `notify` or `review_assignment`. Owners and its maintainers. | `workspace`, `team` | `workspace:admin` |
515515 | [`delete`](/reference/api/teams/delete-team/) | Delete it; its child teams move up to its parent, and the roles it gave go. Owners and its maintainers. | `workspace`, `team` | `workspace:admin` |
516516 | [`list_members`](/reference/api/teams/list-team-members/) | Its people and their `role` (`member` or `maintainer`); with `include_child_teams`, its child teams' people too, each with `via`. | `workspace`, `team` | `workspace:read` |
532532
533533 | Action | What it does | Required | Scope |
534534 | --- | --- | --- | --- |
535+| [`get`](/reference/api/workspaces/get-workspace/) | One workspace you belong to: its name, description and member count, its `base_permission` and `team_creation`. Members only. | `workspace` | `workspace:read` |
535536 | [`create`](/reference/api/workspaces/create-workspace/) | Create a workspace. | `slug` | `workspace:admin` |
536−| [`update`](/reference/api/workspaces/update-workspace/) | Change its display name and description, and with the `access:admin` scope too, its `base_permission`. Only the fields given change; the slug never does. Owners only. | `workspace` | `workspace:admin` |
537+| [`update`](/reference/api/workspaces/update-workspace/) | Change its display name and description, who may create its teams (`team_creation`: `members` or `owners`), and with the `access:admin` scope too, its `base_permission`. Only the fields given change; the slug never does. Owners only. | `workspace` | `workspace:admin` |
537538 | [`delete`](/reference/api/workspaces/delete-workspace/) | Delete an empty workspace whose billing is settled; `confirm` is its slug. Owners only. See [deleting a workspace](/guides/workspaces/#delete-a-workspace). | `workspace`, `confirm` | `workspace:admin` |
538539 | [`list_invites`](/reference/api/invites/list-workspace-invites/) | A workspace's invites. Owners only. | `workspace` | `workspace:read` |
539540 | [`invite_member`](/reference/api/invites/invite-member/) | Invite an address into a workspace, with an invite bound to it. Owners only. | `workspace`, `email` | `workspace:admin` |
+0−0

Binary or large file; its contents are not shown.

+15−8
22 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
5−import type { Abilities, InboxCounts, Membership, Spike, User } from "@g1t/contracts";
5+import { type Abilities, type InboxCounts, type Membership, type Spike, type User, mayCreateTeams } from "@g1t/contracts";
66
77 import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette";
88 import { AgentButton, InboxBell } from "./inbox";
18371837 New workspace
18381838 </Link>
18391839 </DropdownMenuItem>
1840− {/* A team in the workspace the sidebar is about. */}
1841− {shell.workspace && (
1842− <DropdownMenuItem asChild>
1843− <Link to={`/${shell.workspace.slug}/-/teams/new`}>
1840+ {/* A team in the workspace the sidebar is about, for whoever it lets create one. */}
1841+ {shell.workspace &&
1842+ (mayCreateTeams(shell.workspace.team_creation, shell.workspace.role) ? (
1843+ <DropdownMenuItem asChild>
1844+ <Link to={`/${shell.workspace.slug}/-/teams/new`}>
1845+ <UsersRound />
1846+ New team
1847+ </Link>
1848+ </DropdownMenuItem>
1849+ ) : (
1850+ <DropdownMenuItem disabled title="Only owners can create teams in this workspace.">
18441851 <UsersRound />
18451852 New team
1846− </Link>
1847− </DropdownMenuItem>
1848− )}
1853+ <span className="ml-auto pl-3 text-xs text-faint">Owners only</span>
1854+ </DropdownMenuItem>
1855+ ))}
18491856 </DropdownMenuContent>
18501857 </DropdownMenu>
18511858 </>
+10−0
121121 assert.equal(teamCounts({ members_count: 1, repos_count: 0, child_teams_count: 2 }), "1 member · 2 child teams");
122122 assert.equal(teamCounts({ members_count: 0, repos_count: 0, child_teams_count: 0 }), "No members yet");
123123 });
124+
125+test("who may create teams follows the workspace's setting", async () => {
126+ // From the contracts' source: the Teams page and the + menu decide with it.
127+ const { mayCreateTeams } = await import("../../../../packages/contracts/src/teams.ts");
128+ assert.equal(mayCreateTeams(undefined, "member"), true);
129+ assert.equal(mayCreateTeams("members", "member"), true);
130+ assert.equal(mayCreateTeams("owners", "member"), false);
131+ assert.equal(mayCreateTeams("owners", "owner"), true);
132+ assert.equal(mayCreateTeams("members", null), false);
133+});
+1−1
4444
4545 /** A workspace's own pages, each with its title and what it is for. */
4646 const PAGES: Record<string, { title: string; about: string }> = {
47− settings: { title: "General", about: "The workspace's name, icon, address and description, and deleting it." },
47+ settings: { title: "General", about: "The workspace's name, icon, address and description, who can create teams, and deleting it." },
4848 people: {
4949 title: "People",
5050 about: "Members create repositories and have the base permission on each one. Owners are Admins on every repository, and also manage members, tokens, billing and integrations.",
+56−0
55 type DataResidency,
66 RENAME_COOLDOWN_HOURS,
77 SLUG_HOLD_DAYS,
8+ type TeamCreation,
89 WORKSPACE_RESTORE_DAYS,
910 type Workspace,
1011 type WorkspaceDeletion,
117118 if (!result.ok) return { residencyError: result.error.message };
118119 return { saved: "residency" as const };
119120 }
121+ // Who may create teams: identity checks the owner.
122+ if (intent === "team-creation") {
123+ const wanted: TeamCreation = form.get("teamCreation") === "owners" ? "owners" : "members";
124+ const result = await identity.setTeamCreation(user, params.owner, wanted);
125+ if (!result.ok) return { teamCreationError: result.error.message };
126+ return { saved: "team-creation" as const };
127+ }
120128 if (intent === "rename") {
121129 const newSlug = String(form.get("newSlug") ?? "").trim().toLowerCase();
122130 const result = await identity.renameWorkspace(user, params.owner, newSlug);
175183 error={actionData && "renameError" in actionData ? actionData.renameError : undefined}
176184 />
177185
186+ <TeamCreationSection
187+ // Starts from the saved choice whenever it changes.
188+ key={workspace.teamCreation ?? "members"}
189+ setting={workspace.teamCreation ?? "members"}
190+ saved={Boolean(actionData && "saved" in actionData && actionData.saved === "team-creation")}
191+ error={actionData && "teamCreationError" in actionData ? actionData.teamCreationError : undefined}
192+ />
193+
178194 {(loaderData.euAvailable || loaderData.residency === "eu") && (
179195 <ResidencySection
180196 // Starts from the saved choice whenever it changes.
199215 }
200216
201217 /**
218+ * Who may create the workspace's teams. Teams already made stay as they
219+ * are, whoever made them.
220+ */
221+function TeamCreationSection({ setting, saved, error }: { setting: TeamCreation; saved: boolean; error?: string }) {
222+ const [choice, setChoice] = useState<TeamCreation>(setting);
223+ const navigation = useNavigation();
224+ const saving = navigation.state !== "idle" && navigation.formData?.get("intent") === "team-creation";
225+ return (
226+ <section>
227+ <h2 className="font-medium">Teams</h2>
228+ <p className="mt-1.5 text-xs text-faint">
229+ Who can create teams in the workspace. Whoever creates one becomes its first maintainer. Teams that already
230+ exist stay as they are.
231+ </p>
232+ <Form method="post" className="mt-5 space-y-4">
233+ <input type="hidden" name="intent" value="team-creation" />
234+ <RadioGroup
235+ name="teamCreation"
236+ value={choice}
237+ onValueChange={(value) => setChoice(value as TeamCreation)}
238+ aria-label="Who can create teams"
239+ >
240+ <RadioOption value="members" label="Any member" description="Every member with a confirmed email address. The default." />
241+ <RadioOption value="owners" label="Owners only" description="Members ask an owner to create a team; maintainers still manage their own." />
242+ </RadioGroup>
243+ <ErrorText>{error}</ErrorText>
244+ {saved && !error && (
245+ <p role="status" className="text-xs text-muted">
246+ Saved.
247+ </p>
248+ )}
249+ <Button type="submit" disabled={saving || choice === setting}>
250+ Save
251+ </Button>
252+ </Form>
253+ </section>
254+ );
255+}
256+
257+/**
202258 * Where the workspace's new repositories keep their git data. Shown only
203259 * once g1t has EU storage (or to a workspace that already chose it), so
204260 * nobody is offered a choice that does nothing.
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.