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.
| 47 | 47 | ( | |
| 48 | 48 | "Workspaces", | |
| 49 | 49 | "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], | |
| 51 | 51 | ), | |
| 52 | 52 | ( | |
| 53 | 53 | "Invites", | |
| 360 | 360 | fn title(op: Op) -> &'static str { | |
| 361 | 361 | match op { | |
| 362 | 362 | Op::Whoami => "Get the current user", | |
| 363 | + | Op::GetWorkspace => "Get a workspace", | |
| 363 | 364 | Op::CreateWorkspace => "Create a workspace", | |
| 364 | 365 | Op::DeleteWorkspace => "Delete a workspace", | |
| 365 | 366 | Op::UpdateWorkspace => "Update a workspace", |
| 16 | 16 | use g1t_contracts::repos::{CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath}; | |
| 17 | 17 | use g1t_contracts::teams::{ | |
| 18 | 18 | 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, | |
| 20 | 21 | UserTeamsArgs, | |
| 21 | 22 | }; | |
| 22 | 23 | use g1t_contracts::security::{ | |
| 87 | 88 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 88 | 89 | pub enum Op { | |
| 89 | 90 | Whoami, | |
| 91 | + | GetWorkspace, | |
| 90 | 92 | CreateWorkspace, | |
| 91 | 93 | DeleteWorkspace, | |
| 92 | 94 | UpdateWorkspace, | |
| 623 | 625 | } | |
| 624 | 626 | ||
| 625 | 627 | impl Op { | |
| 626 | − | pub const ALL: [Op; 204] = [ | |
| 628 | + | pub const ALL: [Op; 205] = [ | |
| 627 | 629 | Op::Whoami, | |
| 630 | + | Op::GetWorkspace, | |
| 628 | 631 | Op::CreateWorkspace, | |
| 629 | 632 | Op::DeleteWorkspace, | |
| 630 | 633 | Op::UpdateWorkspace, | |
| 838 | 841 | pub fn name(self) -> &'static str { | |
| 839 | 842 | match self { | |
| 840 | 843 | Op::Whoami => "whoami", | |
| 844 | + | Op::GetWorkspace => "get_workspace", | |
| 841 | 845 | Op::CreateWorkspace => "create_workspace", | |
| 842 | 846 | Op::DeleteWorkspace => "delete_workspace", | |
| 843 | 847 | Op::UpdateWorkspace => "update_workspace", | |
| 1053 | 1057 | Op::DeleteWorkspace => { | |
| 1054 | 1058 | "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." | |
| 1055 | 1059 | } | |
| 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 | + | } | |
| 1056 | 1063 | 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." | |
| 1058 | 1065 | } | |
| 1059 | 1066 | Op::ListRepos => "Repositories you can see, optionally filtered by a search query.", | |
| 1060 | 1067 | Op::GetRepo => "One repository's details.", | |
| 1436 | 1443 | "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." | |
| 1437 | 1444 | } | |
| 1438 | 1445 | 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." | |
| 1440 | 1447 | } | |
| 1441 | 1448 | Op::UpdateTeam => { | |
| 1442 | 1449 | "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." | |
| 1512 | 1519 | let states = json!({ "type": "string", "enum": ["open", "closed"] }); | |
| 1513 | 1520 | match self { | |
| 1514 | 1521 | Op::Whoami => object(json!({}), &[]), | |
| 1522 | + | Op::GetWorkspace => object(json!({ "workspace": workspace_schema() }), &["workspace"]), | |
| 1515 | 1523 | Op::CreateWorkspace => object( | |
| 1516 | 1524 | json!({ | |
| 1517 | 1525 | "slug": { | |
| 1621 | 1629 | "enum": g1t_contracts::access::BasePermission::ALL.map(|base| base.as_str()), | |
| 1622 | 1630 | "description": "What every member gets on each repository: none, read, write or admin. Needs the access:admin scope as well.", | |
| 1623 | 1631 | }, | |
| 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 | + | }, | |
| 1624 | 1637 | }), | |
| 1625 | 1638 | &["workspace"], | |
| 1626 | 1639 | ), | |
| 2834 | 2847 | !matches!( | |
| 2835 | 2848 | self, | |
| 2836 | 2849 | Op::Whoami | |
| 2850 | + | | Op::GetWorkspace | |
| 2837 | 2851 | | Op::CreateWorkspace | |
| 2838 | 2852 | | Op::DeleteWorkspace | |
| 2839 | 2853 | | Op::UpdateWorkspace | |
| 3080 | 3094 | ||
| 3081 | 3095 | match self { | |
| 3082 | 3096 | 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 | + | } | |
| 3083 | 3107 | Op::CreateWorkspace => { | |
| 3084 | 3108 | pass( | |
| 3085 | 3109 | identity, | |
| 3189 | 3213 | None => return failed(FailureCode::Invalid, "base_permission is none, read, write or admin."), | |
| 3190 | 3214 | }, | |
| 3191 | 3215 | }; | |
| 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 | + | }; | |
| 3192 | 3223 | 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."); | |
| 3195 | 3226 | } | |
| 3196 | 3227 | let found = || async { | |
| 3197 | 3228 | g1t_kit::call::<_, Option<Workspace>>(identity, "get_workspace", &json!({ "slug": workspace() })).await | |
| 3232 | 3263 | return Ok(Outcome::Fail(failure)); | |
| 3233 | 3264 | } | |
| 3234 | 3265 | } | |
| 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 | + | } | |
| 3235 | 3282 | match found().await? { | |
| 3236 | 3283 | Some(workspace) => ok(&workspace), | |
| 3237 | 3284 | None => failed(FailureCode::NotFound, "Workspace not found."), |
| 66 | 66 | "description": null, | |
| 67 | 67 | "created_at": "2026-10-04T16:02:51.337Z", | |
| 68 | 68 | "member_count": 1, | |
| 69 | − | "base_permission": "write" | |
| 69 | + | "base_permission": "write", | |
| 70 | + | "team_creation": "members" | |
| 70 | 71 | }, | |
| 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}`." | |
| 72 | 73 | }, | |
| 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 | + | }, | |
| 73 | 90 | "update_workspace": { | |
| 74 | 91 | "params": { | |
| 75 | 92 | "workspace": "acme-labs" | |
| 76 | 93 | }, | |
| 77 | 94 | "request": { | |
| 78 | 95 | "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" | |
| 80 | 98 | }, | |
| 81 | 99 | "response": { | |
| 82 | 100 | "id": "wsp_01m43teqa9em6bje0bhvdj4jkb", | |
| 85 | 103 | "description": "Rockets, and the software that flies them.", | |
| 86 | 104 | "created_at": "2026-10-04T16:02:51.337Z", | |
| 87 | 105 | "member_count": 3, | |
| 88 | − | "base_permission": "write" | |
| 106 | + | "base_permission": "write", | |
| 107 | + | "team_creation": "owners" | |
| 89 | 108 | }, | |
| 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/)." | |
| 91 | 110 | }, | |
| 92 | 111 | "delete_workspace": { | |
| 93 | 112 | "request": { | |
| 4441 | 4460 | "created_at": "2026-10-06T15:02:11.480Z", | |
| 4442 | 4461 | "updated_at": "2026-10-06T15:02:11.480Z" | |
| 4443 | 4462 | }, | |
| 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/)." | |
| 4445 | 4464 | }, | |
| 4446 | 4465 | "get_team": { | |
| 4447 | 4466 | "params": { |
| 105 | 105 | sent["codeOwners"] = code_owners.clone(); | |
| 106 | 106 | } | |
| 107 | 107 | 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), | |
| 109 | 109 | Op::ListRepos => through::<Vec<repos::Repo>>(op, sent), | |
| 110 | 110 | Op::Search => through::<search::SearchResults>(op, sent), | |
| 111 | 111 | Op::GetRepo |
| 31 | 31 | pub const ROUTES: &[Route] = &[ | |
| 32 | 32 | route("GET", "/user", Op::Whoami, &[]), | |
| 33 | 33 | route("POST", "/workspaces", Op::CreateWorkspace, &[]), | |
| 34 | + | route("GET", "/workspaces/:workspace", Op::GetWorkspace, &[]), | |
| 34 | 35 | route("DELETE", "/workspaces/:workspace", Op::DeleteWorkspace, &[]), | |
| 35 | 36 | route("GET", "/user/emails", Op::ListEmails, &[]), | |
| 36 | 37 | route("POST", "/user/emails", Op::AddEmail, &[]), |
| 270 | 270 | 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.", | |
| 271 | 271 | default_action: None, | |
| 272 | 272 | actions: &[ | |
| 273 | + | a("get", Op::GetWorkspace, "A workspace's details and settings"), | |
| 273 | 274 | a("create", Op::CreateWorkspace, "Create a workspace"), | |
| 274 | 275 | 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"), | |
| 276 | 277 | a("list_invites", Op::ListWorkspaceInvites, "Its invites"), | |
| 277 | 278 | a("invite_member", Op::InviteMember, "Invite an email address"), | |
| 278 | 279 | a("revoke_invite", Op::RevokeWorkspaceInvite, "Revoke a pending invite"), |
| 29 | 29 | | `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/). | | |
| 30 | 30 | | `repo.invitation_created`, `repo.invitation_revoked` | Someone was invited to it, or an invitation was withdrawn. | | |
| 31 | 31 | | `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). | | |
| 32 | 33 | | `team.created`, `team.edited`, `team.deleted` | A [team](/guides/teams/) was created, changed or deleted. | | |
| 33 | 34 | | `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. | | |
| 34 | 35 | | `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. | |
| 359 | 359 | | `account:write` | Change your email addresses, make invites, answer invitations and pin projects | | |
| 360 | 360 | | `notifications:read` | See your [inbox](/guides/inbox/), its threads, and what you subscribe to and watch | | |
| 361 | 361 | | `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/) | | |
| 363 | 363 | | `workspace:admin` | Create and delete workspaces, invite members, manage teams, connect integrations | | |
| 364 | 364 | | `billing:read` | See a workspace's [usage, budget, AI credit and invoices](/guides/usage-and-billing/) | | |
| 365 | 365 | | `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. | |
| 15 | 15 | ## Create a team | |
| 16 | 16 | ||
| 17 | 17 | 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. | |
| 19 | 20 | ||
| 20 | 21 | 1. Open **Teams** in the sidebar: `g1t.sh/<workspace>/-/teams`. | |
| 21 | 22 | 2. Choose **New team**, or go to `g1t.sh/<workspace>/-/teams/new`. The | |
| 42 | 43 | name, with a search box that matches names and slugs. A person's teams | |
| 43 | 44 | also show beside them on the workspace's **People** page. | |
| 44 | 45 | ||
| 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 | + | ||
| 45 | 66 | ## Visibility | |
| 46 | 67 | ||
| 47 | 68 | | Visibility | Who can see it, mention it and ask it to review | |
| 380 | 380 | ||
| 381 | 381 | | Settings | Who | | | |
| 382 | 382 | | --- | --- | --- | | |
| 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). | | |
| 384 | 384 | | **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. | | |
| 385 | 385 | | **Access tokens** | Members | The workspace's own tokens. Owners create and delete them. | | |
| 386 | 386 | | **Guardrails** | Members | What agents may do and spend across the workspace. Owners change them. | |
| 510 | 510 | | --- | --- | --- | --- | | |
| 511 | 511 | | [`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` | | |
| 512 | 512 | | [`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` | | |
| 514 | 514 | | [`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` | | |
| 515 | 515 | | [`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` | | |
| 516 | 516 | | [`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` | | |
| 532 | 532 | ||
| 533 | 533 | | Action | What it does | Required | Scope | | |
| 534 | 534 | | --- | --- | --- | --- | | |
| 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` | | |
| 535 | 536 | | [`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` | | |
| 537 | 538 | | [`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` | | |
| 538 | 539 | | [`list_invites`](/reference/api/invites/list-workspace-invites/) | A workspace's invites. Owners only. | `workspace` | `workspace:read` | | |
| 539 | 540 | | [`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` | |
Binary or large file; its contents are not shown.
| 2 | 2 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| 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"; | |
| 6 | 6 | ||
| 7 | 7 | import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette"; | |
| 8 | 8 | import { AgentButton, InboxBell } from "./inbox"; | |
| 1837 | 1837 | New workspace | |
| 1838 | 1838 | </Link> | |
| 1839 | 1839 | </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."> | |
| 1844 | 1851 | <UsersRound /> | |
| 1845 | 1852 | New team | |
| 1846 | − | </Link> | |
| 1847 | − | </DropdownMenuItem> | |
| 1848 | − | )} | |
| 1853 | + | <span className="ml-auto pl-3 text-xs text-faint">Owners only</span> | |
| 1854 | + | </DropdownMenuItem> | |
| 1855 | + | ))} | |
| 1849 | 1856 | </DropdownMenuContent> | |
| 1850 | 1857 | </DropdownMenu> | |
| 1851 | 1858 | </> |
| 121 | 121 | assert.equal(teamCounts({ members_count: 1, repos_count: 0, child_teams_count: 2 }), "1 member · 2 child teams"); | |
| 122 | 122 | assert.equal(teamCounts({ members_count: 0, repos_count: 0, child_teams_count: 0 }), "No members yet"); | |
| 123 | 123 | }); | |
| 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 | + | }); |
| 44 | 44 | ||
| 45 | 45 | /** A workspace's own pages, each with its title and what it is for. */ | |
| 46 | 46 | 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." }, | |
| 48 | 48 | people: { | |
| 49 | 49 | title: "People", | |
| 50 | 50 | 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.", |
| 5 | 5 | type DataResidency, | |
| 6 | 6 | RENAME_COOLDOWN_HOURS, | |
| 7 | 7 | SLUG_HOLD_DAYS, | |
| 8 | + | type TeamCreation, | |
| 8 | 9 | WORKSPACE_RESTORE_DAYS, | |
| 9 | 10 | type Workspace, | |
| 10 | 11 | type WorkspaceDeletion, | |
| 117 | 118 | if (!result.ok) return { residencyError: result.error.message }; | |
| 118 | 119 | return { saved: "residency" as const }; | |
| 119 | 120 | } | |
| 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 | + | } | |
| 120 | 128 | if (intent === "rename") { | |
| 121 | 129 | const newSlug = String(form.get("newSlug") ?? "").trim().toLowerCase(); | |
| 122 | 130 | const result = await identity.renameWorkspace(user, params.owner, newSlug); | |
| 175 | 183 | error={actionData && "renameError" in actionData ? actionData.renameError : undefined} | |
| 176 | 184 | /> | |
| 177 | 185 | ||
| 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 | + | ||
| 178 | 194 | {(loaderData.euAvailable || loaderData.residency === "eu") && ( | |
| 179 | 195 | <ResidencySection | |
| 180 | 196 | // Starts from the saved choice whenever it changes. | |
| 199 | 215 | } | |
| 200 | 216 | ||
| 201 | 217 | /** | |
| 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 | + | /** | |
| 202 | 258 | * Where the workspace's new repositories keep their git data. Shown only | |
| 203 | 259 | * once g1t has EU storage (or to a workspace that already chose it), so | |
| 204 | 260 | * nobody is offered a choice that does nothing. |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.