Skip to content

Commit

Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now.

syntaqxcommitted Parentb92ee58Browse files
30 files+2121−7410/30 viewed
+1−0
179179 { label: 'Mirroring', slug: 'guides/mirroring' },
180180 { label: 'Workspaces and tokens', slug: 'guides/workspaces' },
181181 { label: 'Access and roles', slug: 'guides/access-and-roles' },
182+ { label: 'Permissions', slug: 'guides/permissions' },
182183 { label: 'Teams', slug: 'guides/teams' },
183184 { label: 'People and teams', slug: 'guides/people-and-teams' },
184185 { label: 'Managing a repository', slug: 'guides/managing-repositories' },
+6−3
147147
148148 Owners always have Admin, whatever it says. Only owners can change it:
149149
150−1. Open **People → Members and invites**, `g1t.sh/<workspace>/-/members#base-permission`. Every member can see it; owners manage it.
151−2. Under **Base permission**, choose one.
150+1. Open **Workspace → Access → Permissions**, `g1t.sh/<workspace>/-/permissions`. Owners only.
151+2. In the **Code** card, under **Base permission**, choose one and select **Save**.
152152
153+Every other who-may-do-what setting is on the same page: see
154+[permissions](/guides/permissions/).
155+
153156 It takes effect on everyone's next request. To give one member more on
154157 one repository, give them a role there; to give them less, lower the base
155158 permission and give roles to the people who need them. Whoever creates a
253256
254257 Owners see every outside collaborator, and the repositories and roles each
255258 has, on the **Outside collaborators** tab of the workspace's
256−**Members and invites** page, `g1t.sh/<workspace>/-/members`. **Invite as a member** sends one an invitation to join the
259+**Members** page, `g1t.sh/<workspace>/-/members`. **Invite as a member** sends one an invitation to join the
257260 workspace as a member (see [add people](/guides/workspaces/#add-people));
258261 once they accept, the roles they have stay, and the base permission adds
259262 to them.
+9−9
128128
129129 | | Invite to g1t | Invite to a workspace |
130130 | --- | --- | --- |
131−| Made from | [Settings → Invites](https://g1t.sh/settings/invites), your own | The workspace's **Members and invites** page, by its owners |
131+| Made from | [Settings → Invites](https://g1t.sh/settings/invites), your own | **Invite** on the workspace's **Members** or **Invitations** page, by its owners |
132132 | What it gives | One new account. It adds them to no workspace: the account gets a workspace of its own | An invitation to join that workspace, which they accept or decline |
133133 | Someone without an account | Makes their account with it | Makes their account with it too, while g1t is invite-only, then answers the invitation |
134134 | Someone already on g1t | Nothing: they have an account | The invitation, in their notifications and by email |
229229 - **It makes your own account.** Each person who uses it gets a new
230230 account, and then makes their own workspace. It does not add you to
231231 anyone else's workspace; once you are in, a workspace's owners can add
232− you from its **Members and invites** page.
232+ you from its **Members** page.
233233 - **It may be for some email domains only.** When it is, the email field
234234 says which, such as `example.com`, and sign-up takes only an address
235235 there. Use your address at that organization; you confirm it like any
270270 are in.
271271
272272 To bring someone into a workspace, you do not need an invite to g1t:
273−invite them from the workspace's **Members and invites** page,
273+invite them from the workspace's **Members** page,
274274 `g1t.sh/<workspace>/-/members`, instead (see
275275 [inviting someone into a workspace](#inviting-someone-into-a-workspace)).
276−Settings → Invites links to the Members and invites pages of the
277−workspaces you own.
276+Settings → Invites links to the Members pages of the workspaces you own.
278277
279278 Once anyone can sign up for g1t, there are no invites to g1t to make:
280279 Settings → Invites keeps only the list of invites you already made, and
281280 says that anyone can sign up now and that workspace invitations live on
282−each workspace's Members and invites page. With no invites made, the page is not listed
281+each workspace's Invitations page. With no invites made, the page is not listed
283282 in your settings or account menu.
284283
285284 Each person can have **5** invites out at a time. Pending and used invites
317316 - **Decline** joins nothing; whoever invited you is told in their notifications.
318317
319318 An invitation works for 30 days, the same as an invite. Until it is
320−answered, the workspace's owners see it under **Pending invitations** on
321−its Members and invites page and can revoke it. A workspace on the free plan cannot add
319+answered, the workspace's owners see it as **Pending** on its
320+[Invitations](/guides/workspaces/#add-people) page, and can send it again
321+or revoke it. A workspace on the free plan cannot add
322322 people, so an invitation to one cannot be accepted until it starts the
323323 plan. Only you can answer your invitations: an agent's token and a
324324 workspace's token cannot.
759759 | SSH keys | Settings → SSH keys |
760760 | Applications you signed in to, and signing in with GitHub | Settings → Connected applications, Settings → GitHub |
761761 | Letting a device or an application sign in | `g1t.sh/device`, `g1t.sh/oauth/authorize` |
762−| Deleting a workspace, and giving it to another owner | A workspace's Settings, and Members and invites |
762+| Deleting a workspace, and giving it to another owner | A workspace's General settings, and its Members page |
763763 | Payment methods: the billing portal, adding a card, subscribing and buying AI credit | A workspace's Billing |
764764
765765 ### Permissions
+2−3
551551 ## Workspace chat settings
552552
553553 A workspace's owners decide what members can do in its chat, under
554−**Workspace → Settings → Chat** (`g1t.sh/<workspace>/-/settings/chat`).
554+**Workspace → Chat → Chat settings** (`g1t.sh/<workspace>/-/settings/chat`).
555555 Every member can open the page to see what is allowed; only owners can
556556 change it. g1t enforces each setting, whichever app or API the request
557557 comes from.
558558
559559 | Setting | Choices |
560560 | --- | --- |
561−| **Public channels** | Who can create them: **Any member** (the default) or **Owners only**. |
562−| **Private channels** | Who can create them: **Any member** (the default) or **Owners only**. |
561+| **Creating channels** | Who can create public and private channels (**Any member**, the default, or **Owners only**) is set with the workspace's other permissions, under [Permissions → Chat](/guides/permissions/#chat); this page says what is allowed today and leads there. |
563562 | **Renaming and archiving** | **Channel owners and workspace owners** (the default) or **Workspace owners only**. Any member of a channel can still change its topic. |
564563 | **Custom emoji** | Who can add them: **Any member** (the default) or **Owners only**. |
565564 | **Default channels** | The public channels someone new is put in the first time they open Chat. `#general` unless owners choose others. People already in the workspace are not moved, and anyone can leave a default channel. |
+15−10
1414 | **Everyone** | `g1t.sh/<workspace>/-/people` | The [directory](#the-directory) of the workspace's people. |
1515 | **Teams** | `g1t.sh/<workspace>/-/teams` | The workspace's [teams](/guides/teams/). |
1616 | **Org chart** | `g1t.sh/<workspace>/-/org-chart` | Who reports to whom. See [the org chart](#the-org-chart). |
17−| **Members and invites** | `g1t.sh/<workspace>/-/members` | Under **Membership**: roles, invitations and leaving. See [members and invites](#members-and-invites). |
1817
19−Only members of the workspace can open these pages.
18+Only members of the workspace can open these pages. Who belongs, and
19+inviting people, is the workspace's own business: **Members**,
20+**Invitations** and **Permissions** are under **Workspace → Access**; see
21+[members, invitations and permissions](#members-invitations-and-permissions).
2022
2123 ## The directory
2224
4547 its people (see [agents on a team](/guides/teams/#agents-on-a-team)).
4648
4749 **Org chart** opens [the org chart](#the-org-chart). Owners also see
48−**Invite people**, which opens [Members and invites](#members-and-invites).
50+**Invite people**, which opens the workspace's
51+[Members](#members-invitations-and-permissions) page, where **Invite** is.
4952
5053 ## A person's profile
5154
207210 When a person is needed: Priya Shah (@priya), who is online now.
208211 ```
209212
210−## Members and invites
213+## Members, invitations and permissions
211214
212−Inviting people, their roles, pending invitations, outside collaborators,
213−the base permission, transferring ownership and leaving a workspace are
214−on **Members and invites**, `g1t.sh/<workspace>/-/members` (People →
215−Members and invites). See [add people](/guides/workspaces/#add-people),
216−[change someone's role](/guides/workspaces/#change-someones-role) and
217−[the base permission](/guides/access-and-roles/#the-base-permission).
215+Who is in the workspace is a setting of the workspace, so it lives in
216+[Workspace mode](/guides/workspaces/#workspace-mode), under **Access**:
217+
218+| Page | Address | What it is |
219+| --- | --- | --- |
220+| **Members** | `g1t.sh/<workspace>/-/members` | Everyone in the workspace and their roles, with search and filters; **Invite** (a dialog), changing roles, transferring ownership, outside collaborators and leaving. See [add people](/guides/workspaces/#add-people) and [change someone's role](/guides/workspaces/#change-someones-role). |
221+| **Invitations** | `g1t.sh/<workspace>/-/members/invitations` | Every invitation to join, by where it stands: pending, accepted, declined, expired, revoked. Owners only. |
222+| **Permissions** | `g1t.sh/<workspace>/-/permissions` | Who may do what, part by part, [the base permission](/guides/access-and-roles/#the-base-permission) included. Owners only. See [permissions](/guides/permissions/). |
218223
219224 ## Next
220225
+98−0
1+---
2+title: Permissions
3+description: Who may do what in a workspace, in one place, part by part — repositories, teams, chat, artifacts, agents, deployments and packages — what each setting does, and which are still to come.
4+---
5+
6+import Soon from '../../../components/Soon.astro';
7+
8+**Permissions** is where a workspace's owners decide who may do what, part
9+by part: `g1t.sh/<workspace>/-/permissions`, under **Workspace → Access**
10+in the sidebar. Only owners can open it. Each part of g1t has a card, each
11+setting says what it decides, and each card saves on its own with its
12+**Save** button; the change holds on everyone's next request and is
13+recorded in the [audit log](/guides/audit-log/).
14+
15+A setting marked **Soon** is not configurable yet. It is shown the way it
16+will be, locked, with the rule that holds today under it, so nobody has to
17+look elsewhere to learn what members can do.
18+
19+## Code
20+
21+| Setting | What it decides | Today |
22+| --- | --- | --- |
23+| **Base permission** | The role every member has on each of the workspace's repositories: **No permission**, **Read**, **Write** or **Admin**. Owners are Admins on all of them, and a role given to someone on one repository adds to this; it never takes away. See [the base permission](/guides/access-and-roles/#the-base-permission). | A new workspace starts at **Read**. |
24+| **Members can create public repositories** | Whether any member can create a public repository. | On |
25+| **Members can create private repositories** | Whether any member can create a private repository. Whoever creates one is its Admin. | On |
26+| **Repository admins can change visibility** | Whether a member with Admin on a repository can make it public or private. | On |
27+| **Repository admins can delete and transfer repositories** | Whether a member with Admin on a repository can delete it, or transfer it to a workspace where they can create one. | Off |
28+| **Repository admins can add outside collaborators** | Whether a member with Admin on a repository can give a role on it to someone outside the workspace. | On |
29+| **Members can fork private repositories** <Soon /> | Whether a member may fork a private repository under their own name. | g1t has no personal forks: work happens on branches of the repository itself. |
30+
31+The five switches are the workspace's
32+[member privileges](/guides/workspaces/#member-privileges), the same ones
33+`GET /workspaces/{workspace}` returns by name (`members_can_…`). When one
34+is off, only owners can do it; the refusal says so.
35+
36+## People and teams
37+
38+| Setting | What it decides | Today |
39+| --- | --- | --- |
40+| **Who can create teams** | **Any member** with a confirmed email address, or **Owners only**. Whoever creates a team becomes its first maintainer; teams that already exist stay as they are. See [who can create teams](/guides/teams/#who-can-create-teams). | Any member |
41+
42+## Chat
43+
44+| Setting | What it decides | Today |
45+| --- | --- | --- |
46+| **Public channels** | Who can create them: **Any member** or **Owners only**. | Any member |
47+| **Private channels** | Who can create them: **Any member** or **Owners only**. | Any member |
48+| **Who can add agents to a conversation** <Soon /> | Who may invite a workspace agent into a channel or a group message. | Any member of a channel can invite an agent to it, the way they invite a person; a personal agent is never invited anywhere. |
49+| **Who can message agents directly** <Soon /> | Who may open a direct message with a workspace agent. | Every member. |
50+
51+Renaming and archiving channels, custom emoji and default channels are on
52+[Chat settings](/guides/chat/#workspace-chat-settings), under **Workspace →
53+Chat**: they are about what a channel's own members may do, not who may do
54+what in the workspace.
55+
56+## Artifacts
57+
58+| Setting | What it decides | Today |
59+| --- | --- | --- |
60+| **Who can create spaces** <Soon /> | Who may add a space beside the ones the workspace has. | Any member, from **+** beside **Spaces** in the Artifacts sidebar. |
61+| **Default sharing** <Soon /> | Who a new artifact is shared with before its author chooses. | A new artifact is its author's, shared with the people, agents and teams they add, or opened to the workspace from its **Share** menu. See [sharing](/guides/artifacts/#sharing). |
62+
63+## Agents
64+
65+| Setting | What it decides | Today |
66+| --- | --- | --- |
67+| **Members can create personal agents** | Whether members who are not owners may create [personal agents](/guides/agents/#personal-agents). Off, only owners create agents; personal agents members already have keep working. | On |
68+| **Who can create workspace agents** <Soon /> | Who may create an agent everyone in the workspace talks to and pays for. | Owners. An owner can also promote a member's personal agent to a workspace agent. |
69+| **Who can raise budgets** <Soon /> | Who may raise the workspace's agent budget, a person's or a team's. | Owners raise the agent budget and each person's; a team's maintainers raise their team's. See [agent budgets](/guides/agent-budgets/). |
70+
71+Budgets themselves, and the models agents may use, are on the
72+[Agents overview](/guides/agents/) and under [Spend](/guides/spend/).
73+
74+## Deployments and packages
75+
76+| Setting | What it decides | Today |
77+| --- | --- | --- |
78+| **Who can deploy to production** <Soon /> | Who may deploy a project's production environment, and redeploy or take it down. | Anyone with Write on the project's repository redeploys or takes an app down; turning deployments on for a project takes Admin. See [deployments](/guides/deployments/). |
79+| **Who can publish packages** <Soon /> | Who may publish a new version of one of the workspace's packages. | Anyone with Write on the package's repository, or a token given write on packages. See [packages](/guides/packages/). |
80+
81+## Through the API
82+
83+Every setting that exists is set the way it was before it had this page:
84+
85+| Setting | Route | MCP |
86+| --- | --- | --- |
87+| Base permission | [`PUT /workspaces/{workspace}/base_permission`](/reference/api/access/set-base-permission/) | `access` `set_base_permission` |
88+| Member privileges, who can create teams | [`PATCH /workspaces/{workspace}`](/reference/api/workspaces/update-workspace/) with `members_can_…` or `team_creation` | `workspace` `update` |
89+| Public and private channels | The chat service's workspace settings, as [Chat settings](/guides/chat/#workspace-chat-settings) saves them | |
90+| Members can create personal agents | The agents service's workspace policy, as the [Agents overview](/guides/agents/#personal-agents) saves it | |
91+
92+A setting marked Soon has no route yet.
93+
94+## Next
95+
96+- [Access and roles](/guides/access-and-roles/): what each role can do on a repository.
97+- [Workspaces](/guides/workspaces/#workspace-mode): every page of Workspace mode.
98+- [Audit log](/guides/audit-log/): where each change is recorded.
+3−2
5454
5555 ### Who can create teams
5656
57−An owner chooses who can create the workspace's teams, under **Settings**,
58−**General**, **Teams**:
57+An owner chooses who can create the workspace's teams, under **Workspace →
58+Access → [Permissions](/guides/permissions/#people-and-teams)**, in the
59+**People and teams** card:
5960
6061 | Who can create teams | |
6162 | --- | --- |
+1−1
7676 waits, and works once the plan is on).
7777
7878 Its members can still be given a role on its repositories, and put on its
79−[teams](/guides/teams/). On the **Members and invites** page and a repository's
79+[teams](/guides/teams/). On the **Members** and **Invitations** pages and a repository's
8080 **Settings → Access**, an owner sees **Start the plan to invite people**
8181 with a button to the plan. Through the API and MCP, adding a member,
8282 inviting, adding an outside collaborator and accepting are refused with
+108−60
11 ---
22 title: Workspaces
3−description: Workspaces, their names and icons, renaming and deleting one, members, owners and the roles that add to a member, member privileges, requiring two-factor authentication, and access tokens that belong to a workspace.
3+description: Workspaces, their names and icons, renaming and deleting one, members, owners and the roles that add to a member, inviting people, Workspace mode's settings pages, requiring two-factor authentication, and access tokens that belong to a workspace.
44 ---
55
66 A workspace owns repositories and is the first part of their address:
303303
304304 ### Change someone's role
305305
306−On **Members and invites**, `g1t.sh/<workspace>/-/members` (People →
307−Members and invites), an owner opens the **⋯** menu beside a member:
306+On **Members**, `g1t.sh/<workspace>/-/members` (**Workspace → Access →
307+Members**), an owner opens the **⋯** menu beside a member:
308308
309309 1. **Make owner** or **Make member** changes their role.
310310 2. **Billing manager** and **Security manager** turn each role on or off.
315315 repositories and their place in its teams go too.
316316
317317 Owners see a shield beside each member: green when two-factor
318−authentication is on, amber when it is off.
318+authentication is on, amber when it is off. Above the list, a search box
319+finds a member by username or name, and the filters narrow the list by
320+role, by two-factor authentication (owners only) and by team; each is kept
321+in the address (`?q=`, `?role=`, `?2fa=`, `?team=`), so a filtered list can
322+be shared.
319323
320324 The last owner cannot be made a member, removed, or leave: make someone
321325 else an owner first, or [delete the workspace](#delete-a-workspace).
322326
323327 ### Leave a workspace
324328
325−Anyone can leave a workspace they belong to: at the bottom of **Members and
326−invites**, choose **Leave {workspace}** and confirm. Your roles on its repositories
329+Anyone can leave a workspace they belong to: at the bottom of **Members**,
330+choose **Leave {workspace}** and confirm. Your roles on its repositories
327331 and your place in its teams go with you at once. The only owner cannot
328332 leave.
329333
338342
339343 | | Invite to a workspace | Invite to g1t |
340344 | --- | --- | --- |
341−| Where | The workspace's **Members and invites** page, **Invite to** *workspace* | [Settings → Invites](https://g1t.sh/settings/invites) |
345+| Where | **Invite** on the workspace's **Members** or **Invitations** page | [Settings → Invites](https://g1t.sh/settings/invites) |
342346 | What they get | An invitation to join the workspace, to accept or decline | One new account, in no workspace but its own |
343347 | Without an account | The invitation lets them sign up first, while g1t is invite-only | It lets them sign up |
344348 | When | Always | Only while g1t is invite-only |
345349
346350 To invite someone to g1t without adding them to your workspace, use
347−[Settings → Invites](/guides/authentication/#making-invites); the Members
348−and invites page links there while g1t is invite-only.
351+[Settings → Invites](/guides/authentication/#making-invites).
349352
350−On the workspace's **Members and invites**, `g1t.sh/<workspace>/-/members`
351−(People → Members and invites in the sidebar):
353+On the workspace's **Members** page, `g1t.sh/<workspace>/-/members`
354+(**Workspace → Access → Members** in the sidebar), or on **Invitations**
355+beside it:
352356
353−1. Under **Invite to** *workspace name*, type a username, a name or an email address.
354− As you type, people on g1t are offered by username and name, with their
357+1. Select **Invite**. A dialog opens.
358+2. Under **Who**, type a username, a name or an email address. As you
359+ type, people on g1t are offered by username and name, with their
355360 pictures; hover over one for their card. Only usernames, names and
356− pictures are shown, never anyone's email address.
357−2. Choose the **Role** they join with: **Member** or **Owner**.
358−3. Select **Invite**.
361+ pictures are shown, never anyone's email address. An email address is
362+ taken as written.
363+3. Choose the **Role** they join with: **Member** or **Owner**.
364+4. Write a **Message** if you like: a line quoted in the invitation email,
365+ under your name. Up to 500 characters.
366+5. Select **Invite**. The dialog closes and the page says who was invited.
359367
360368 - **By username**: they get a
361369 [workspace invitation](/guides/authentication/#workspace-invitations) in
380388 the workspace, with a one-time welcome. See
381389 [using an invite](/guides/authentication/#using-an-invite).
382390
383−Pending invitations are listed under the members, with the person or
384−address, the role, until when it works (30 days), a link to copy and
385−**Revoke**: one waiting to be used, one whose new account is **confirming
386−their email**, and one **waiting for them to accept**. Converting an
387−outside collaborator to a member sends them an invitation the same way.
391+Every invitation sent is on **Invitations**,
392+`g1t.sh/<workspace>/-/members/invitations` (**Workspace → Access →
393+Invitations**, owners only): a table of who it is for, who sent it, the
394+role, when it was sent and where it stands, newest first. The chips above
395+it narrow the table, each with its count:
396+
397+| Chip | Shows |
398+| --- | --- |
399+| **All** | Every invitation, however it ended. |
400+| **Pending** | The ones still waiting: to be used, for the new account to confirm its email, or for the person to accept. Each says the day it stops working (30 days after it was sent). |
401+| **Accepted** | The people who joined. |
402+| **Declined** | The people who said no. |
403+| **Expired** | The ones nobody used in time. g1t does not yet know whether an invitation's email was delivered, so one that bounced ends up here too. |
404+| **Revoked** | The ones an owner took back. |
405+
406+The **⋯** menu on a pending invitation has **Copy link** (the invitation's
407+page, while it waits to be used), **Send again** (the same email, and the
408+inbox notice again for someone on g1t; the invitation itself does not
409+change) and **Revoke**. Through the API, the invitation is sent again with
410+nothing yet; revoke it and invite again instead. Converting an outside
411+collaborator to a member sends them an invitation the same way. The
412+Members page says how many invitations are waiting, and leads here.
388413 Through the API, use
389414 [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/)
390415 with a `username` or an `email` and a `role` (the `workspace` tool's
398423 **A free workspace cannot add people.** Until it starts the g1t plan, it
399424 cannot add members, send invites, or invite outside collaborators, and an
400425 invite sent before waits until the plan is on. Its members stay. Members and
401−invites shows **Start the plan to invite people** with the button in place of the
402−form, and the API and MCP answer `402` (`payment_required`). See
426+Invitations show **Start the plan to invite people** with the button in place of
427+**Invite**, and the API and MCP answer `402` (`payment_required`). See
403428 [who a free workspace can add](/guides/usage-and-billing/#who-a-free-workspace-can-add).
404429
405430 ### Members through the API
424449 ## Member privileges
425450
426451 What members can do beyond their role on each repository. Owners set them
427−in the workspace's **Settings → Member privileges**,
428−`g1t.sh/<workspace>/-/settings#member-privileges`, and can always do all of
429−it themselves.
452+under **Workspace → Access → [Permissions](/guides/permissions/#code)**,
453+`g1t.sh/<workspace>/-/permissions`, in the **Code** card, and can always do
454+all of it themselves.
430455
431456 | Setting | Default | When on |
432457 | --- | --- | --- |
454479 on.
455480
456481 1. Turn it on for your own account first.
457−2. Open **Settings**, `g1t.sh/<workspace>/-/settings#two-factor`. Under
458− **Authentication security**, it says how many members do not have it on,
459− and who.
482+2. Open **Workspace → General**, `g1t.sh/<workspace>/-/settings#two-factor`.
483+ Under **Authentication security**, it says how many members do not have
484+ it on, and who.
460485 3. Turn on **Require two-factor authentication** and choose **Save**.
461486
462487 From then on, someone without it keeps their place but cannot use the
554579 | **Agents** | The workspace's agents and their sessions. See [agents](/guides/agents/). |
555580 | **Code** | Projects, pull requests and checks. Not shown to a member without [Code access](/guides/agent-access/). |
556581 | **Artifacts** | Documents, decks and pages. See [artifacts](/guides/artifacts/). |
557−| **People** | Everyone in the workspace (its people; agents are under Agents); its [teams](/guides/teams/), which agents can be on; the org chart; and, under **Membership**, **Members and invites**. See [people and teams](/guides/people-and-teams/). |
558−| **Workspace** | Usage, billing, integrations, policies and settings. |
582+| **People** | Everyone in the workspace (its people; agents are under Agents); its [teams](/guides/teams/), which agents can be on; and the org chart. See [people and teams](/guides/people-and-teams/). |
583+| **Workspace** | The workspace's settings: who has access, money, compute, and how each part of g1t is set up. See [Workspace mode](#workspace-mode). |
559584
560585 The app you are in sits on a filled square. Your account is the
561586 avatar at the foot: your status, your profile and settings,
604629 extensions are published. An app you can't use shows **Request access**.
605630 The built-in apps' own pages are in their sidebars, not in Apps: Code's has
606631 **Projects**, **Security** and **Packages**; Agents' has **Templates**,
607−**Context** and **Memory**; People's has **Everyone**, **Teams**, **Org chart** and
608−**Members and invites**; Workspace's has **Usage**,
609−**AI Gateway**, **Integrations** and the **Audit log**.
632+**Context** and **Memory**; People's has **Everyone**, **Teams** and **Org
633+chart**; Workspace's has every settings page, from **Members** to the
634+**Audit log** (see [Workspace mode](#workspace-mode)).
610635
611636 A pinned app shows as its mark under the built-in ones, in the order you
612637 pinned them. To pin one:
647672 for the roles that can use it. A link straight to any of these pages opens
648673 the sidebar already there.
649674
650−Workspace's sidebar lists the workspace's **Overview**; under **Money**,
651−[**Spend**](/guides/spend/), **Usage**, **AI Gateway**,
652−**Billing and plans**; for owners, **Runners** under **Compute**;
653−**Integrations**, its security policies, the
654−**Audit log** and **Settings**, which slides over to how the workspace is
655−set up:
675+Workspace's sidebar is the [workspace's settings area](#workspace-mode):
676+its **Overview**, then every settings page in named groups, each one click
677+away.
678+
679+Below 1024px wide, the sidebar opens over the page from the left, from the
680+header's button, and closes when you open a page.
681+
682+### Workspace mode
656683
657−| Settings | Who | |
658−| --- | --- | --- |
659−| **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). |
660−| **Chat** | Members | What members may do in chat. Owners change it. |
661−| **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. |
662−| **Access tokens** | Members | The workspace's own tokens. Owners create and delete them. |
663−| **Personal access tokens** | Owners | The workspace's rules for members' tokens, and approving them. |
664−| [**Webhooks**](/guides/webhooks/) | Members | Where the workspace's events are sent. Owners add and change them. |
665−| **Emoji** | Members | The workspace's own emoji. |
666−| [**Secrets and variables**](/guides/secrets-and-variables/) | Members | What runs and deployments are given. Owners change them. |
667−| **Actions** | Members | How workflows run in the workspace. |
668−| **Runners** | Owners | The workspace's self-hosted machines, their groups and registration tokens. |
684+**Workspace**, at the foot of the rail, is everything about the workspace
685+itself: who has access, what it spends, and how each part of g1t is set up
686+for it. There is no second Settings inside it: every page is in the
687+sidebar, under the group it belongs to, and the group you are in stays
688+open while the others fold when you select their name. A page only owners
689+open is left out for members; a page that is coming is a greyed **Soon**
690+row, with a hint saying what it will hold.
669691
670−**Members and invites** is for every member to see; owners invite, add and
671−remove people there, set the
672−[base permission](/guides/access-and-roles/#the-base-permission), and see
673−the **Outside collaborators** tab. Each member's row also shows the
674−[teams](/guides/teams/) they are in that you can see. To find someone, and
675−what they work on, use the [People directory](/guides/people-and-teams/#the-directory).
692+| Group | Page | Who | |
693+| --- | --- | --- | --- |
694+| | **Overview** | Members | The workspace at a glance: members, plan and spend. `g1t.sh/<workspace>/-/workspace` |
695+| **General** | **General** | Owners | The icon, the display name, a one-line description, the address (the slug), [requiring two-factor authentication](#require-two-factor-authentication), [data residency](#data-residency), and deleting the workspace. `-/settings` |
696+| **Access** | **Members** | Members | Who is in the workspace and their roles, with search and filters; owners [invite people](#add-people), change roles, and see **Outside collaborators**. `-/members` |
697+| | **Invitations** | Owners | Every invitation to join, filtered by where it stands; send one again, revoke it, copy its link. `-/members/invitations` |
698+| | [**Permissions**](/guides/permissions/) | Owners | Who may do what, part by part: the base permission, repositories, teams, chat, artifacts, agents, deployments and packages. `-/permissions` |
699+| | [**Access tokens**](#workspace-access-tokens) | Members | The workspace's own tokens. Owners create and delete them. `-/tokens` |
700+| | **Personal access tokens** | Owners | The workspace's rules for members' tokens, and approving them. `-/personal-access-tokens` |
701+| **Money** | [**Spend**](/guides/spend/) | Members | Where the money went, budgets and receipts. `-/spend` |
702+| | [**Usage**](/guides/usage-and-billing/) | Members | What the agents cost, run by run. `-/usage` |
703+| | [**AI Gateway**](/guides/ai-gateway/) | Members | Your own code's model requests. `-/gateway` |
704+| | **Billing and plans** | Members | The plan, the spend limit, prepaying and every charge. `-/billing` |
705+| **Compute** | [**Runners**](/guides/self-hosted-runners/) | Owners | The workspace's self-hosted machines, their groups and registration tokens. `-/runners` |
706+| **Code** | **Repositories** | Members | Every repository, and for owners **Recently deleted**, where a [deleted repository](/guides/managing-repositories/#restore-a-repository) can be restored, or purged, for 30 days. `-/repositories` |
707+| | **Actions** | Members | How workflows run in the workspace. `-/actions` |
708+| | [**Secrets and variables**](/guides/secrets-and-variables/) | Members | What runs and deployments are given. Owners change them. `-/secrets` |
709+| | [**Webhooks**](/guides/webhooks/) | Members | Where the workspace's events are sent. Owners add and change them. `-/webhooks` |
710+| | **Deployments**, **Project defaults** | | Soon: defaults for every project's deployments, and what a new project starts with. |
711+| **Agents** | **Agent settings** | | Soon. Budgets and models are on the [Agents overview](/guides/agents/) today; who may create agents is under Permissions. |
712+| **Chat** | [**Chat settings**](/guides/chat/#workspace-chat-settings) | Members | Renaming and archiving channels, emoji, default channels. Owners change it. `-/settings/chat` |
713+| | **Emoji** | Members | The workspace's own emoji. `-/emoji` |
714+| **Artifacts** | **Spaces and sharing** | | Soon: which spaces a workspace starts with, and how a new artifact is shared by default. |
715+| **Security** | **Security settings** | Members | The workspace's security policies. `-/security/settings` |
716+| | [**Guardrails**](/guides/guardrails/) | Members | What agents may reach, run, spend and take. `-/guardrails` |
717+| | [**Rules**](/guides/rules/) | Members | Rulesets across projects. `-/rules` |
718+| | [**Audit log**](/guides/audit-log/) | Members | Every change, by whom, to what. `-/audit` |
719+| **Integrations** | [**Integrations**](/guides/integrations/) | Members | The tools connected for everyone. `-/integrations` |
720+| | **Applications** | | Soon: OAuth applications the workspace registers. |
676721
677−Below 1024px wide, the sidebar opens over the page from the left, from the
678−header's button, and closes when you open a page.
722+Each page's address is under `g1t.sh/<workspace>/`. The page's header
723+shows where you are: **General**, **Members / Invitations**, **Permissions**.
724+Every address that was there before this layout still works. To find
725+someone, and what they work on, use the
726+[People directory](/guides/people-and-teams/#the-directory).
679727
680728 ### Pinned and recent projects
681729
+222−0
1+import { AtSign, Mail, UserPlus } from "lucide-react";
2+import { type ReactNode, useEffect, useId, useRef, useState } from "react";
3+import { useFetcher } from "react-router";
4+
5+import { MAX_INVITE_MESSAGE, type PersonMatch } from "@g1t/contracts";
6+
7+import { ErrorText, SubmitButton } from "./ui";
8+import { Avatar } from "./ui/avatar";
9+import { Button } from "./ui/button";
10+import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
11+import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
12+import { Field, FieldDescription, FieldLabel } from "./ui/field";
13+import { SelectField } from "./ui/select";
14+import { Textarea } from "./ui/textarea";
15+import { UserCard } from "./user-card";
16+import { type InviteResult, moreInvitesMailto, workspaceInviteCopy } from "../lib/invites";
17+import { PEOPLE_SEARCH_PATH, looksLikeEmail, peopleQuery } from "../lib/people-search";
18+
19+/**
20+ * Inviting someone to the workspace, in a dialog opened from Members and
21+ * from Invitations: who (a username, a name to search by, or an email
22+ * address), the role they join with, and a note for the email. Posted to
23+ * `action` as `action=add` (lib/member-invitations.server.ts); on success the
24+ * dialog closes and `onInvited` is told who, so the page can say so.
25+ */
26+export function InviteDialog({
27+ slug,
28+ name,
29+ inviteOnly,
30+ action,
31+ onInvited,
32+ trigger,
33+}: {
34+ slug: string;
35+ /** The workspace's display name. */
36+ name: string;
37+ /** Whether sign-up takes an invite: then an invitation to a new address costs one. */
38+ inviteOnly: boolean;
39+ /** The page's address, whose action handles `add`. */
40+ action: string;
41+ onInvited?: (who: string) => void;
42+ /** The button that opens it; the Invite button when absent. */
43+ trigger?: ReactNode;
44+}) {
45+ const [open, setOpen] = useState(false);
46+ const fetcher = useFetcher<InviteResult>();
47+ const copy = workspaceInviteCopy(name, inviteOnly);
48+ const result = fetcher.state === "idle" ? fetcher.data : undefined;
49+ // Sent: the dialog closes and the page says so. Each opening starts clean.
50+ const told = useRef<InviteResult | null>(null);
51+ useEffect(() => {
52+ if (!result || told.current === result) return;
53+ told.current = result;
54+ if ("invited" in result) {
55+ setOpen(false);
56+ onInvited?.(result.invited);
57+ }
58+ }, [result, onInvited]);
59+ const error = result && "error" in result ? result : null;
60+ const messageId = useId();
61+ return (
62+ <Dialog open={open} onOpenChange={setOpen}>
63+ <DialogTrigger asChild>
64+ {trigger ?? (
65+ <Button type="button">
66+ <UserPlus size={15} />
67+ Invite
68+ </Button>
69+ )}
70+ </DialogTrigger>
71+ <DialogContent>
72+ {open && (
73+ <fetcher.Form method="post" action={action} className="grid gap-5">
74+ <input type="hidden" name="action" value="add" />
75+ <DialogHeader>
76+ <DialogTitle>{copy.heading}</DialogTitle>
77+ <DialogDescription>{copy.hint}</DialogDescription>
78+ </DialogHeader>
79+ <WhoField />
80+ <Field>
81+ <FieldLabel htmlFor={`${messageId}-role`}>Role</FieldLabel>
82+ <SelectField
83+ id={`${messageId}-role`}
84+ name="role"
85+ defaultValue="member"
86+ options={[
87+ { value: "member", label: "Member", description: `Works in ${name} with the base permission on its repositories.` },
88+ { value: "owner", label: "Owner", description: "Everything a member can, and manages the workspace itself." },
89+ ]}
90+ />
91+ </Field>
92+ <Field>
93+ <FieldLabel htmlFor={messageId}>
94+ Message <span className="font-normal text-faint">(optional)</span>
95+ </FieldLabel>
96+ <Textarea id={messageId} name="message" maxLength={MAX_INVITE_MESSAGE} rows={3} placeholder={`A line for the email: why you'd like them in ${name}.`} />
97+ <FieldDescription>Quoted in the invitation email, under your name.</FieldDescription>
98+ </Field>
99+ {error && (
100+ <div>
101+ <ErrorText>{error.error}</ErrorText>
102+ {error.outOfInvites && (
103+ <p className="mt-1 text-sm text-muted">
104+ <a href={moreInvitesMailto(slug)} className="text-accent underline underline-offset-4">
105+ Ask for more invites
106+ </a>{" "}
107+ for {slug}.
108+ </p>
109+ )}
110+ </div>
111+ )}
112+ <DialogFooter>
113+ <DialogClose asChild>
114+ <Button type="button" variant="outline">
115+ Cancel
116+ </Button>
117+ </DialogClose>
118+ <SubmitButton fetcher={fetcher} match={{ action: "add" }} pending="Inviting…">
119+ <UserPlus size={15} />
120+ Invite
121+ </SubmitButton>
122+ </DialogFooter>
123+ </fetcher.Form>
124+ )}
125+ </DialogContent>
126+ </Dialog>
127+ );
128+}
129+
130+/**
131+ * Who to invite: a search box that offers people on g1t by username and
132+ * name as you type, each with their picture and card, and takes an email
133+ * address as written. Only what a profile shows is offered, never anyone's
134+ * address. Posts what is in the box as `member`.
135+ */
136+function WhoField() {
137+ const [text, setText] = useState("");
138+ const [people, setPeople] = useState<PersonMatch[]>([]);
139+ const [chosen, setChosen] = useState<string | null>(null);
140+ const asked = useRef(0);
141+ const id = useId();
142+
143+ useEffect(() => {
144+ const query = chosen === text.trim().replace(/^@+/, "") ? null : peopleQuery(text);
145+ if (!query) {
146+ setPeople([]);
147+ return;
148+ }
149+ const ask = ++asked.current;
150+ const timer = setTimeout(() => {
151+ fetch(`${PEOPLE_SEARCH_PATH}?q=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } })
152+ .then((response) => (response.ok ? (response.json() as Promise<{ people?: PersonMatch[] }>) : { people: [] }))
153+ .then((found) => {
154+ if (ask === asked.current) setPeople(found.people ?? []);
155+ })
156+ .catch(() => {
157+ if (ask === asked.current) setPeople([]);
158+ });
159+ }, 150);
160+ return () => clearTimeout(timer);
161+ }, [text, chosen]);
162+
163+ const email = looksLikeEmail(text);
164+ const listed = people.length > 0 && !email;
165+ return (
166+ <Field>
167+ <FieldLabel htmlFor={id}>Who</FieldLabel>
168+ <Command shouldFilter={false} className="rounded-md border border-line bg-bg has-[input:focus-visible]:border-accent-dim has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-accent/25">
169+ <CommandInput
170+ id={id}
171+ name="member"
172+ required
173+ maxLength={254}
174+ autoComplete="off"
175+ placeholder="username, name or name@example.com"
176+ value={text}
177+ onValueChange={(value) => {
178+ setText(value);
179+ setChosen(null);
180+ }}
181+ className="h-9"
182+ />
183+ {listed && (
184+ <CommandList aria-label="People on g1t" className="max-h-56 border-t border-line">
185+ {people.map((person) => (
186+ <CommandItem
187+ key={person.username}
188+ value={person.username}
189+ onSelect={() => {
190+ setText(person.username);
191+ setChosen(person.username);
192+ setPeople([]);
193+ }}
194+ >
195+ <Avatar name={person.username} image={person.avatar} size={20} />
196+ <UserCard username={person.username}>
197+ <span className="font-mono">{person.username}</span>
198+ </UserCard>
199+ {person.name && <span className="min-w-0 truncate text-muted">{person.name}</span>}
200+ </CommandItem>
201+ ))}
202+ </CommandList>
203+ )}
204+ </Command>
205+ <FieldDescription className="flex items-center gap-1.5">
206+ {email ? (
207+ <>
208+ <Mail size={12} className="shrink-0" />
209+ Invited by email: {text.trim()} gets the invitation, and signs up first if it has no account.
210+ </>
211+ ) : chosen ? (
212+ <>
213+ <AtSign size={12} className="shrink-0" />
214+ @{chosen} gets the invitation in their notifications and by email.
215+ </>
216+ ) : (
217+ "People on g1t are offered as you type; an email address is sent as written."
218+ )}
219+ </FieldDescription>
220+ </Field>
221+ );
222+}
+126−140
1−import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Store, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react";
1+import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Store, House, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react";
22 import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
1010 import { SpendPill } from "./spend";
1111 import { PinButton } from "./pin-button";
1212 import { Hint } from "./ui/hint";
13+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
1314 import {
1415 Sidebar,
1516 SidebarContent,
4647 DropdownMenuTrigger,
4748 } from "./ui/dropdown-menu";
4849 import { type RoadmapItem, roadmapIn, roadmapItem } from "../lib/roadmap";
49−import { type ModeKey, SETTINGS_PAGES, homePagePath, modeOf } from "../lib/workspace-nav";
50+import { type ModeKey, WORKSPACE_SETTINGS_GROUPS, type WorkspaceSettingsPage, homePagePath, modeOf, workspaceSettingsPage } from "../lib/workspace-nav";
5051 import { AgentsSidebar } from "./agents-mode";
5152 import { ChatSidebar } from "./chat/sidebar";
5253 import { FoliosSidebar } from "./folios/sidebar";
518519 );
519520 }
520521
521−/**
522− * A workspace's settings pages, which the sidebar drills into: how it is
523− * set up and connected (guardrails, secrets, integrations,
524− * webhooks), what it pays, its repositories, tokens and record. The main
525− * list keeps the places work happens and who belongs; every member can
526− * still open these.
527− */
528−const SETTINGS_PAGE =
529− new RegExp(`^/([^/]+)/-/(${SETTINGS_PAGES.join("|")})(/|$)`);
530522 /** A project's settings pages, which the project's menu drills into. */
531523 const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/;
532524
12131205 );
12141206 }
12151207
1216−/** Workspace pages that sit in its Settings list, drilled into from the Workspace sidebar. */
1217−const WORKSPACE_SETTINGS = ["settings", "repositories", "tokens", "personal-access-tokens", "secrets", "actions", "webhooks", "emoji"];
1208+/** The icon of each of Workspace mode's pages, by its key (lib/workspace-nav.ts). */
1209+const WORKSPACE_PAGE_ICONS: Record<string, ReactNode> = {
1210+ settings: <Settings size={15} />,
1211+ members: <Users size={15} />,
1212+ invitations: <Mail size={15} />,
1213+ permissions: <ShieldCheck size={15} />,
1214+ tokens: <KeyRound size={15} />,
1215+ "personal-access-tokens": <UserRoundKey size={15} />,
1216+ spend: <Coins size={15} />,
1217+ usage: <BarChart3 size={15} />,
1218+ gateway: <Network size={15} />,
1219+ billing: <CreditCard size={15} />,
1220+ runners: <ServerCog size={15} />,
1221+ repositories: <BookMarked size={15} />,
1222+ actions: <PlayCircle size={15} />,
1223+ secrets: <Lock size={15} />,
1224+ webhooks: <Webhook size={15} />,
1225+ deployments: <Rocket size={15} />,
1226+ projects: <Layers size={15} />,
1227+ "agent-settings": <Bot size={15} />,
1228+ "chat-settings": <MessagesSquare size={15} />,
1229+ emoji: <Smile size={15} />,
1230+ "artifact-settings": <Shapes size={15} />,
1231+ "security-settings": <Shield size={15} />,
1232+ guardrails: <Gauge size={15} />,
1233+ rules: <Scale size={15} />,
1234+ audit: <History size={15} />,
1235+ integrations: <Plug size={15} />,
1236+ applications: <Blocks size={15} />,
1237+};
12181238
12191239 /**
1220− * The Workspace mode's sidebar: the workspace itself, for every member.
1221− * Its overview, money, machines, connections, policies and record, then its
1222− * settings as a list of their own. Owner-only pages stay owner-only.
1240+ * A group of Workspace mode's rows that folds: its name is the button, with
1241+ * a chevron that turns when it is open. The group holding the current page
1242+ * is always open, and its button does nothing. Every group starts open, so
1243+ * the server and the browser draw the same list.
12231244 */
1245+function FoldingGroup({ title, current, children }: { title: string; current: boolean; children: ReactNode }) {
1246+ const [open, setOpen] = useState(true);
1247+ return (
1248+ <Collapsible open={open || current} onOpenChange={setOpen}>
1249+ <SidebarGroup>
1250+ <CollapsibleTrigger
1251+ disabled={current}
1252+ aria-label={`${title}: ${open || current ? "fold" : "unfold"}`}
1253+ className="flex h-7 w-full shrink-0 items-center justify-between rounded-md px-2 text-[0.6875rem] font-medium tracking-wide text-faint uppercase hover:text-muted focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-default disabled:text-faint data-[state=open]:[&_svg]:rotate-90"
1254+ >
1255+ <span>{title}</span>
1256+ {!current && <ChevronRight size={12} aria-hidden="true" className="text-faint/70" />}
1257+ </CollapsibleTrigger>
1258+ <CollapsibleContent>
1259+ <SidebarGroupContent>
1260+ <SidebarMenu>{children}</SidebarMenu>
1261+ </SidebarGroupContent>
1262+ </CollapsibleContent>
1263+ </SidebarGroup>
1264+ </Collapsible>
1265+ );
1266+}
1267+
1268+/**
1269+ * The Workspace mode's sidebar: the workspace's settings area, every page
1270+ * one click away. Its overview, then the groups in lib/workspace-nav.ts
1271+ * (General, Access, Money, Compute, Code, Agents, Chat, Artifacts,
1272+ * Security, Integrations), each folding, the one you are in held open.
1273+ * Pages only owners open are left out for members, and a group with none
1274+ * left goes with them; a page that is coming is a Soon row, not a link.
1275+ */
12241276 export function WorkspaceSidebar({ slug, owner }: { slug: string; owner: boolean }) {
12251277 const { pathname } = useLocation();
12261278 const going = useNavigation().location?.pathname;
12271279 const path = going ?? pathname;
1228− const page = path.startsWith(`/${slug}/-/`) ? path.slice(`/${slug}/-/`.length) : "";
1229− const top = page.split("/")[0] ?? "";
1230− const at = (...pages: string[]) => pages.some((p) => page === p || page.startsWith(`${p}/`));
1231− const inSettings = WORKSPACE_SETTINGS.includes(top);
1232− const main = (
1233− <nav aria-label="Workspace" className={PANEL}>
1234− <NavRows>
1235− <SidebarLink to={`/${slug}/-/workspace`} icon={<LayoutGrid size={15} />} current={at("workspace")}>
1236− Overview
1237− </SidebarLink>
1238− </NavRows>
1239− <Rule />
1240− <NavGroup title="Money">
1241− <SidebarLink to={`/${slug}/-/spend`} icon={<Coins size={15} />} current={at("spend")}>
1242− Spend
1243− </SidebarLink>
1244− <SidebarLink to={`/${slug}/-/usage`} icon={<BarChart3 size={15} />} current={at("usage")}>
1245− Usage
1246− </SidebarLink>
1247− <SidebarLink to={`/${slug}/-/gateway`} icon={<Network size={15} />} current={at("gateway")}>
1248− AI Gateway
1249− </SidebarLink>
1250− <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />} current={at("billing")}>
1251− Billing and plans
1252− </SidebarLink>
1253− </NavGroup>
1254− {owner && (
1255− <>
1256− <Rule />
1257− <NavGroup title="Compute">
1258− <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />} current={at("runners")}>
1259− Runners
1260− </SidebarLink>
1261− </NavGroup>
1262− </>
1263− )}
1264− <Rule />
1265− <NavGroup title="Connections">
1266− <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />} current={at("integrations")}>
1267− Integrations
1268− </SidebarLink>
1269− </NavGroup>
1270− <Rule />
1271− <NavGroup title="Security policies">
1272− <SidebarLink to={`/${slug}/-/security/settings`} icon={<ShieldCheck size={15} />} current={at("security/settings")}>
1273− Security settings
1274− </SidebarLink>
1275− <SidebarLink to={`/${slug}/-/guardrails`} icon={<Gauge size={15} />} current={at("guardrails")}>
1276− Guardrails
1277− </SidebarLink>
1278− <SidebarLink to={`/${slug}/-/rules`} icon={<Scale size={15} />} current={at("rules")}>
1279− Rules
1280− </SidebarLink>
1281− </NavGroup>
1282− <Rule />
1283− <NavRows>
1284− <SidebarLink to={`/${slug}/-/audit`} icon={<History size={15} />} current={at("audit")}>
1285− Audit log
1286− </SidebarLink>
1287− <SidebarLink to={owner ? `/${slug}/-/settings` : `/${slug}/-/repositories`} icon={<Settings size={15} />} drill current={inSettings}>
1288− Settings
1289− </SidebarLink>
1290− </NavRows>
1291− </nav>
1292− );
1293− const settings = (
1294− <nav aria-label="Workspace settings" className={PANEL}>
1295− <BackRow to={`/${slug}/-/workspace`} label="Settings" context={slug} />
1296− <NavRows className="mt-2">
1297− {owner && (
1298− <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />} end>
1299− General
1300− </SidebarLink>
1301− )}
1302− <SidebarLink to={`/${slug}/-/settings/chat`} icon={<MessagesSquare size={15} />}>
1303− Chat
1304− </SidebarLink>
1305− <SidebarLink to={`/${slug}/-/repositories`} icon={<BookMarked size={15} />}>
1306− Repositories
1307− </SidebarLink>
1308− <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
1309− Access tokens
1310− </SidebarLink>
1311− {owner && (
1312− <SidebarLink to={`/${slug}/-/personal-access-tokens`} icon={<UserRoundKey size={15} />}>
1313− Personal access tokens
1314− </SidebarLink>
1315− )}
1316− <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
1317− Webhooks
1318− </SidebarLink>
1319− <SidebarLink to={`/${slug}/-/emoji`} icon={<Smile size={15} />}>
1320− Emoji
1321− </SidebarLink>
1322− </NavRows>
1323− <Rule />
1324− <NavGroup title="Runs">
1325− <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
1326− Secrets and variables
1327− </SidebarLink>
1328− <SidebarLink to={`/${slug}/-/actions`} icon={<PlayCircle size={15} />}>
1329− Actions
1330− </SidebarLink>
1331− </NavGroup>
1332− </nav>
1333− );
1334− const trail: Level[] = [{ key: "workspace", node: main }];
1335− if (inSettings) trail.push({ key: "workspace:settings", node: settings });
1280+ const here = workspaceSettingsPage(path, slug);
1281+ const overview = path === `/${slug}/-/workspace` || path.startsWith(`/${slug}/-/workspace/`);
1282+ const shown = (page: WorkspaceSettingsPage) => owner || !page.owner;
13361283 return (
13371284 <div className="flex h-full flex-col">
13381285 <ModeHeader title="Workspace" />
1339− <Drill trail={trail} />
1286+ <nav aria-label="Workspace" className={PANEL}>
1287+ <NavRows>
1288+ <SidebarLink to={`/${slug}/-/workspace`} icon={<LayoutGrid size={15} />} current={overview}>
1289+ Overview
1290+ </SidebarLink>
1291+ </NavRows>
1292+ {WORKSPACE_SETTINGS_GROUPS.filter((group) => group.pages.some(shown)).map((group) => (
1293+ <Fragment key={group.key}>
1294+ <Rule />
1295+ <FoldingGroup title={group.title} current={here?.group === group.key}>
1296+ {group.pages.filter(shown).map((page) =>
1297+ page.soon ? (
1298+ <SidebarSoon key={page.key} icon={WORKSPACE_PAGE_ICONS[page.key] ?? <Sparkles size={15} />} about={page.soon}>
1299+ {page.label}
1300+ </SidebarSoon>
1301+ ) : (
1302+ <SidebarLink
1303+ key={page.key}
1304+ to={`/${slug}/-/${page.path}`}
1305+ icon={WORKSPACE_PAGE_ICONS[page.key] ?? <Settings size={15} />}
1306+ current={here?.page === page.key}
1307+ >
1308+ {page.label}
1309+ </SidebarLink>
1310+ ),
1311+ )}
1312+ </FoldingGroup>
1313+ </Fragment>
1314+ ))}
1315+ </nav>
13401316 </div>
13411317 );
13421318 }
13431319
13441320 /**
13451321 * People's sidebar: everyone in the workspace (its people; agents are
1346− * under Agents); its teams, which agents can be on; the org chart; and, to
1347− * manage who belongs, members and invites.
1322+ * under Agents); its teams, which agents can be on; and the org chart. Who
1323+ * belongs, and inviting people, is the workspace's: Workspace mode's
1324+ * Access group.
13481325 */
13491326 function PeopleSidebar({ slug }: { slug: string }) {
13501327 const { pathname } = useLocation();
13661343 Org chart
13671344 </SidebarLink>
13681345 </NavRows>
1369− <Rule />
1370− <NavGroup title="Membership">
1371− <SidebarLink to={`/${slug}/-/members`} icon={<UserPlus size={15} />} current={at("members")}>
1372− Members and invites
1373− </SidebarLink>
1374− </NavGroup>
13751346 </nav>
13761347 </div>
13771348 );
14701441 settings: "Settings",
14711442 people: "People",
14721443 "org-chart": "Org chart",
1473− members: "Members and invites",
1444+ members: "Members",
1445+ permissions: "Permissions",
14741446 projects: "Projects",
14751447 teams: "Teams",
14761448 tokens: "Access tokens",
15631535 const trail: Crumb[] = ours ? [] : [{ label: owner!, to: `/${owner}`, mono: true }];
15641536 if (second === "-") {
15651537 const page = `/${owner}/-/${third}`;
1566− if (third && SETTINGS_PAGE.test(page)) {
1567− trail.push({ label: "Settings", to: `/${owner}/-/settings` });
1538+ if (third === "settings" && fourth === "chat") {
1539+ // Chat settings is a page of its own, not one of General's.
1540+ trail.push({ label: "Chat settings", to: `${page}/chat` });
1541+ } else if (third) {
1542+ // The workspace's settings pages each stand alone: General, Members, Permissions.
15681543 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
1569− } else if (third) {
1570− trail.push({ label: SECTIONS[third] ?? third, to: page });
1571− // The Marketplace names its tab (Marketplace / Extensions), and Agents its templates.
1544+ // The Marketplace names its tab (Marketplace / Extensions), Agents its templates, and Members its invitations.
15721545 const tabs: Record<string, string> =
1573− third === "marketplace" ? { integrations: "Integrations", extensions: "Extensions", requests: "Requests" } : third === "agents" ? { templates: "Templates" } : {};
1546+ third === "marketplace"
1547+ ? { integrations: "Integrations", extensions: "Extensions", requests: "Requests" }
1548+ : third === "agents"
1549+ ? { templates: "Templates" }
1550+ : third === "members"
1551+ ? { invitations: "Invitations" }
1552+ : {};
15741553 const tab = fourth ? tabs[fourth] : undefined;
15751554 if (tab) trail.push({ label: tab, to: `${page}/${fourth}` });
15761555 }
17531732 { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
17541733 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
17551734 { label: "Integrations", hint: membership.slug, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> },
1735+ { label: "Members", hint: `${membership.slug} · Access`, to: `/${membership.slug}/-/members`, icon: <Users size={15} /> },
1736+ ...(membership.role === "owner"
1737+ ? [
1738+ { label: "Invitations", hint: `${membership.slug} · Access`, to: `/${membership.slug}/-/members/invitations`, icon: <Mail size={15} /> },
1739+ { label: "Permissions", hint: `${membership.slug} · Access`, to: `/${membership.slug}/-/permissions`, icon: <ShieldCheck size={15} /> },
1740+ ]
1741+ : []),
17561742 );
17571743 }
17581744 for (const listed of shell.repos) {
+52−0
88 G1T_INVITES,
99 HAVE_AN_INVITE,
1010 bringIntoChoices,
11+ filterInvitations,
12+ filterMembers,
13+ invitationCounts,
14+ invitationFilter,
15+ invitationFilterOf,
16+ invitationTone,
1117 inviteDraft,
1218 inviteKind,
19+ memberFilters,
1320 invitePageCopy,
1421 invitesPage,
1522 peoplePages,
3845
3946 const CODE = "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk";
4047
48+test("the Invitations page sorts every state under one filter, and counts them", () => {
49+ const statuses = ["pending", "awaiting_confirmation", "awaiting_answer", "redeemed", "declined", "expired", "revoked"] as const;
50+ const invites = statuses.map((status) => ({ status }));
51+ assert.equal(invitationFilterOf("pending"), "pending");
52+ assert.equal(invitationFilterOf("awaiting_confirmation"), "pending");
53+ assert.equal(invitationFilterOf("awaiting_answer"), "pending");
54+ assert.equal(invitationFilterOf("redeemed"), "accepted");
55+ assert.equal(invitationFilterOf("declined"), "declined");
56+ assert.equal(invitationFilterOf("expired"), "expired");
57+ assert.equal(invitationFilterOf("revoked"), "revoked");
58+ assert.deepEqual(invitationCounts(invites), { all: 7, pending: 3, accepted: 1, declined: 1, expired: 1, revoked: 1 });
59+ assert.equal(filterInvitations(invites, "all").length, 7);
60+ assert.deepEqual(filterInvitations(invites, "pending").map((invite) => invite.status), ["pending", "awaiting_confirmation", "awaiting_answer"]);
61+ assert.deepEqual(filterInvitations(invites, "accepted").map((invite) => invite.status), ["redeemed"]);
62+ // The query names a filter, or means All.
63+ assert.equal(invitationFilter("pending"), "pending");
64+ assert.equal(invitationFilter("failed"), "all");
65+ assert.equal(invitationFilter(null), "all");
66+ // Waiting is amber, joined green, a no red, and over is quiet.
67+ assert.equal(invitationTone("awaiting_answer"), "warn");
68+ assert.equal(invitationTone("redeemed"), "success");
69+ assert.equal(invitationTone("revoked"), "danger");
70+ assert.equal(invitationTone("expired"), "neutral");
71+});
72+
73+test("the Members page filters by role, two-factor, team and words", () => {
74+ const members = [
75+ { username: "ada", name: "Ada Lovelace", role: "owner" as const, two_factor: true },
76+ { username: "grace", name: "Grace Hopper", role: "member" as const, two_factor: false },
77+ { username: "linus", name: null, role: "member" as const, two_factor: true },
78+ ];
79+ const teams = { ada: [{ slug: "core" }], grace: [{ slug: "web" }, { slug: "core" }] };
80+ assert.deepEqual(memberFilters(""), { role: "all", twoFactor: "all", team: null, query: "" });
81+ assert.deepEqual(memberFilters("?role=owner&2fa=off&team=Core&q=%20ada%20"), { role: "owner", twoFactor: "off", team: "core", query: "ada" });
82+ assert.deepEqual(memberFilters("?role=admin&2fa=maybe"), { role: "all", twoFactor: "all", team: null, query: "" });
83+ const names = (filters: string) => filterMembers(members, memberFilters(filters), teams).map((member) => member.username);
84+ assert.deepEqual(names(""), ["ada", "grace", "linus"]);
85+ assert.deepEqual(names("?role=member"), ["grace", "linus"]);
86+ assert.deepEqual(names("?2fa=off"), ["grace"]);
87+ assert.deepEqual(names("?team=core"), ["ada", "grace"]);
88+ assert.deepEqual(names("?q=hopper"), ["grace"]);
89+ assert.deepEqual(names("?q=@LIN"), ["linus"]);
90+ assert.deepEqual(names("?role=owner&team=web"), []);
91+});
92+
4193 test("while invite-only, nobody is offered a plain sign-up", () => {
4294 // Sign up everywhere; only the sign-up page says registration takes an invite.
4395 assert.deepEqual(signUpCopy(), { primary: "Sign up", secondary: null });
+116−0
215215 };
216216 }
217217
218+/** What inviting someone came to (lib/member-invitations.server.ts): who was invited, or why not. */
219+export type InviteResult = { invited: string; outOfInvites: false } | { error: string; outOfInvites: boolean };
220+
221+/** What revoking or resending an invitation came to. */
222+export type InvitationResult = { done: "revoked" | "resent"; id: string } | { error: string; id: string };
223+
224+/**
225+ * The Invitations page's filters: every invitation, or the ones in one
226+ * state. Pending gathers the three states an invitation waits in (to be
227+ * used, for the new account to confirm its address, for the person's
228+ * answer); Accepted is the ones that joined. g1t does not record an email
229+ * that could not be delivered, so an invitation nobody used shows as
230+ * Expired once its 30 days are up; there is no Failed filter until it does.
231+ */
232+export type InvitationFilter = "all" | "pending" | "accepted" | "declined" | "expired" | "revoked";
233+
234+export const INVITATION_FILTERS: readonly { key: InvitationFilter; label: string }[] = [
235+ { key: "all", label: "All" },
236+ { key: "pending", label: "Pending" },
237+ { key: "accepted", label: "Accepted" },
238+ { key: "declined", label: "Declined" },
239+ { key: "expired", label: "Expired" },
240+ { key: "revoked", label: "Revoked" },
241+];
242+
243+/** The filter an invitation's state falls under. */
244+export function invitationFilterOf(status: Listed["status"]): Exclude<InvitationFilter, "all"> {
245+ switch (status) {
246+ case "pending":
247+ case "awaiting_confirmation":
248+ case "awaiting_answer":
249+ return "pending";
250+ case "redeemed":
251+ return "accepted";
252+ case "declined":
253+ return "declined";
254+ case "expired":
255+ return "expired";
256+ case "revoked":
257+ return "revoked";
258+ }
259+}
260+
261+/** The filter `?state=` names; All for anything else. */
262+export function invitationFilter(raw: string | null | undefined): InvitationFilter {
263+ return INVITATION_FILTERS.some((filter) => filter.key === raw) ? (raw as InvitationFilter) : "all";
264+}
265+
266+/** The invitations under `filter`. */
267+export function filterInvitations<T extends Pick<Listed, "status">>(invites: readonly T[], filter: InvitationFilter): T[] {
268+ if (filter === "all") return [...invites];
269+ return invites.filter((invite) => invitationFilterOf(invite.status) === filter);
270+}
271+
272+/** How many invitations each filter holds, for the counts on the chips. */
273+export function invitationCounts(invites: readonly Pick<Listed, "status">[]): Record<InvitationFilter, number> {
274+ const counts: Record<InvitationFilter, number> = { all: invites.length, pending: 0, accepted: 0, declined: 0, expired: 0, revoked: 0 };
275+ for (const invite of invites) counts[invitationFilterOf(invite.status)] += 1;
276+ return counts;
277+}
278+
279+/** The tone of an invitation's status badge, from how its state reads. */
280+export function invitationTone(status: Listed["status"]): "neutral" | "success" | "warn" | "danger" {
281+ switch (invitationFilterOf(status)) {
282+ case "pending":
283+ return "warn";
284+ case "accepted":
285+ return "success";
286+ case "declined":
287+ case "revoked":
288+ return "danger";
289+ default:
290+ return "neutral";
291+ }
292+}
293+
218294 /** Who an invite is for, in a list. */
219295 export function inviteFor(invite: Listed): string {
220296 return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link");
338414 }
339415
340416 /**
417+ * The Members page's filters, from its query: the role to show (`role=`:
418+ * owners or members), whether two-factor authentication is on (`2fa=`: on
419+ * or off; owners only see it), a team by slug (`team=`) and words to
420+ * search by (`q=`, matched against the username and the name). Anything
421+ * else is every member.
422+ */
423+export type MemberFilters = { role: "all" | "owner" | "member"; twoFactor: "all" | "on" | "off"; team: string | null; query: string };
424+
425+export function memberFilters(search: URLSearchParams | string): MemberFilters {
426+ const params = typeof search === "string" ? new URLSearchParams(search) : search;
427+ const role = params.get("role");
428+ const twoFactor = params.get("2fa");
429+ return {
430+ role: role === "owner" || role === "member" ? role : "all",
431+ twoFactor: twoFactor === "on" || twoFactor === "off" ? twoFactor : "all",
432+ team: params.get("team")?.trim().toLowerCase() || null,
433+ query: params.get("q")?.trim() ?? "",
434+ };
435+}
436+
437+type Listable = {
438+ username: string;
439+ name?: string | null;
440+ role: "owner" | "member";
441+ two_factor?: boolean | null;
442+};
443+
444+/** The members `filters` keep, in the order given. `teams` is each member's teams, by slug. */
445+export function filterMembers<T extends Listable>(members: readonly T[], filters: MemberFilters, teams: Record<string, { slug: string }[]> = {}): T[] {
446+ const words = filters.query.toLowerCase().replace(/^@+/, "");
447+ return members.filter((member) => {
448+ if (filters.role !== "all" && member.role !== filters.role) return false;
449+ if (filters.twoFactor !== "all" && member.two_factor !== (filters.twoFactor === "on")) return false;
450+ if (filters.team && !(teams[member.username] ?? []).some((team) => team.slug.toLowerCase() === filters.team)) return false;
451+ if (words && !member.username.toLowerCase().includes(words) && !(member.name ?? "").toLowerCase().includes(words)) return false;
452+ return true;
453+ });
454+}
455+
456+/**
341457 * The People pages Settings → Invites points to for a workspace
342458 * invitation: the workspaces the viewer owns (only owners invite), the
343459 * current one first.
+40−0
1+/**
2+ * What the Invite dialog and the Invitations page post, answered the same
3+ * way from whichever page they are on (Members, Invitations): inviting
4+ * someone, sending an invitation again, and revoking one. Identity checks
5+ * the owner, the plan and the invite allowance again. Answering an
6+ * invitation of your own is lib/invitations.server.ts.
7+ */
8+import type { User } from "@g1t/contracts";
9+
10+import type { InvitationResult, InviteResult } from "./invites";
11+import { inviteTarget } from "./people-search";
12+import { identity } from "./services.server";
13+
14+/**
15+ * Invites whoever the form names (`member`: a username or an address) with
16+ * `role` and an optional `message`. Nobody joins without saying yes: a
17+ * username or an address gets an invitation to accept or decline.
18+ */
19+export async function inviteFromForm(user: User, slug: string, form: FormData): Promise<InviteResult> {
20+ const member = String(form.get("member") ?? "").trim();
21+ const who = inviteTarget(member);
22+ if (!who) return { error: "Enter a g1t username or an email address.", outOfInvites: false };
23+ const role = form.get("role") === "owner" ? "owner" : "member";
24+ const message = String(form.get("message") ?? "").trim() || null;
25+ const result = await identity.inviteMember(user, slug, { ...who, role, message });
26+ if (!result.ok) return { error: result.error.message, outOfInvites: result.error.code === "limit" };
27+ return { invited: "email" in who ? who.email : `@${who.username}`, outOfInvites: false };
28+}
29+
30+/** `action=revoke-invite` or `action=resend-invite`, with the invitation's `id`; null for anything else. */
31+export async function invitationFromForm(user: User, slug: string, form: FormData): Promise<InvitationResult | null> {
32+ const action = form.get("action");
33+ const id = String(form.get("id") ?? "").trim();
34+ if (action !== "revoke-invite" && action !== "resend-invite") return null;
35+ if (!id) return { error: "Which invitation?", id };
36+ const result =
37+ action === "revoke-invite" ? await identity.revokeWorkspaceInvite(user, slug, id) : await identity.resendWorkspaceInvite(user, slug, id);
38+ if (!result.ok) return { error: result.error.message, id };
39+ return { done: action === "revoke-invite" ? "revoked" : "resent", id };
40+}
+29−1
105105 // An agent's old People address (it redirects to Agents) is still read as People's until it does.
106106 assert.equal(modeOf("/acme/-/people/agents/margo", "acme"), "people");
107107 assert.equal(modeOf("/acme/-/org-chart", "acme"), "people");
108− assert.equal(modeOf("/acme/-/members", "acme"), "people");
108+ // Members, invitations and permissions are the workspace's settings.
109+ assert.equal(modeOf("/acme/-/members", "acme"), "workspace");
110+ assert.equal(modeOf("/acme/-/members/invitations", "acme"), "workspace");
111+ assert.equal(modeOf("/acme/-/permissions", "acme"), "workspace");
109112 assert.equal(modeOf("/acme/-/apps", "acme"), "apps");
110113 assert.equal(modeOf("/acme/-/billing", "acme"), "workspace");
111114 assert.equal(modeOf("/acme/-/tokens", "acme"), "workspace");
125128 assert.equal(homePath("acme", "?tab=landed&_routes=x"), "/acme/-/overview?tab=landed");
126129 });
127130
131+test("Workspace mode's sidebar knows which group and page an address is", async () => {
132+ const { WORKSPACE_SETTINGS_GROUPS, modeOf, workspaceSettingsPage } = await import("./workspace-nav.ts");
133+ assert.deepEqual(workspaceSettingsPage("/acme/-/settings", "acme"), { group: "general", page: "settings" });
134+ assert.deepEqual(workspaceSettingsPage("/acme/-/settings/chat", "acme"), { group: "chat", page: "chat-settings" });
135+ assert.deepEqual(workspaceSettingsPage("/acme/-/members", "acme"), { group: "access", page: "members" });
136+ assert.deepEqual(workspaceSettingsPage("/acme/-/members/invitations.data", "acme"), { group: "access", page: "invitations" });
137+ assert.deepEqual(workspaceSettingsPage("/acme/-/permissions", "acme"), { group: "access", page: "permissions" });
138+ assert.deepEqual(workspaceSettingsPage("/acme/-/rules/rs_1", "acme"), { group: "security", page: "rules" });
139+ assert.deepEqual(workspaceSettingsPage("/acme/-/security/settings", "acme"), { group: "security", page: "security-settings" });
140+ assert.deepEqual(workspaceSettingsPage("/acme/-/spend/receipts/r1", "acme"), { group: "money", page: "spend" });
141+ // The overview, Code's security page and another workspace are no settings page.
142+ assert.equal(workspaceSettingsPage("/acme/-/workspace", "acme"), null);
143+ assert.equal(workspaceSettingsPage("/acme/-/security", "acme"), null);
144+ assert.equal(workspaceSettingsPage("/other/-/settings", "acme"), null);
145+ // Every real page is in Workspace mode, and every key is unique.
146+ const keys = new Set<string>();
147+ for (const group of WORKSPACE_SETTINGS_GROUPS) {
148+ for (const page of group.pages) {
149+ assert.ok(!keys.has(page.key), `${page.key} twice`);
150+ keys.add(page.key);
151+ if (!page.soon) assert.equal(modeOf(`/acme/-/${page.path}`, "acme"), "workspace", page.path);
152+ }
153+ }
154+});
155+
128156 test("a member without Code access is sent around Code's pages", async () => {
129157 const { codeGate } = await import("./workspace-nav.ts");
130158 const none = ["acme"];
+148−19
2626 }
2727
2828 /**
29− * The workspace's pages that its Settings row drills into. Runners is not
30− * one: it is a page of Workspace mode's own, at `-/runners`.
29+ * Workspace mode's pages, in the groups its sidebar lists them under: the
30+ * workspace's settings area, every page one click away. `owner` marks the
31+ * pages only owners open; the sidebar shows members the rest. A page that
32+ * is not here yet is a `soon` row: what the group will hold, not a link.
33+ * `path` is under `-/`.
3134 */
32−export const SETTINGS_PAGES = [
33− "settings",
34− "repositories",
35− "tokens",
36− "rules",
37− "guardrails",
38− "secrets",
39− "actions",
40− "integrations",
41− "webhooks",
42− "emoji",
43− "billing",
44− "audit",
45−] as const;
35+export type WorkspaceSettingsPage = { key: string; label: string; path: string; owner?: boolean; soon?: string };
36+
37+export type WorkspaceSettingsGroup = { key: string; title: string; pages: WorkspaceSettingsPage[] };
4638
39+export const WORKSPACE_SETTINGS_GROUPS: readonly WorkspaceSettingsGroup[] = [
40+ {
41+ key: "general",
42+ title: "General",
43+ pages: [{ key: "settings", label: "General", path: "settings", owner: true }],
44+ },
45+ {
46+ key: "access",
47+ title: "Access",
48+ pages: [
49+ { key: "members", label: "Members", path: "members" },
50+ { key: "invitations", label: "Invitations", path: "members/invitations", owner: true },
51+ { key: "permissions", label: "Permissions", path: "permissions", owner: true },
52+ { key: "tokens", label: "Access tokens", path: "tokens" },
53+ { key: "personal-access-tokens", label: "Personal access tokens", path: "personal-access-tokens", owner: true },
54+ ],
55+ },
56+ {
57+ key: "money",
58+ title: "Money",
59+ pages: [
60+ { key: "spend", label: "Spend", path: "spend" },
61+ { key: "usage", label: "Usage", path: "usage" },
62+ { key: "gateway", label: "AI Gateway", path: "gateway" },
63+ { key: "billing", label: "Billing and plans", path: "billing" },
64+ ],
65+ },
66+ {
67+ key: "compute",
68+ title: "Compute",
69+ pages: [{ key: "runners", label: "Runners", path: "runners", owner: true }],
70+ },
71+ {
72+ key: "code",
73+ title: "Code",
74+ pages: [
75+ { key: "repositories", label: "Repositories", path: "repositories" },
76+ { key: "actions", label: "Actions", path: "actions" },
77+ { key: "secrets", label: "Secrets and variables", path: "secrets" },
78+ { key: "webhooks", label: "Webhooks", path: "webhooks" },
79+ {
80+ key: "deployments",
81+ label: "Deployments",
82+ path: "soon/deployments",
83+ soon: "Defaults for every project's deployments: environments, who approves production, and the domains the workspace owns.",
84+ },
85+ {
86+ key: "projects",
87+ label: "Project defaults",
88+ path: "soon/projects",
89+ soon: "What a new project starts with: its visibility, branch rules, merge method and the agents on it.",
90+ },
91+ ],
92+ },
93+ {
94+ key: "agents",
95+ title: "Agents",
96+ pages: [
97+ {
98+ key: "agent-settings",
99+ label: "Agent settings",
100+ path: "soon/agent-settings",
101+ soon: "Budgets and the models agents may use are on the Agents overview today; who may create agents is under Permissions.",
102+ },
103+ ],
104+ },
105+ {
106+ key: "chat",
107+ title: "Chat",
108+ pages: [
109+ { key: "chat-settings", label: "Chat settings", path: "settings/chat" },
110+ { key: "emoji", label: "Emoji", path: "emoji" },
111+ ],
112+ },
113+ {
114+ key: "artifacts",
115+ title: "Artifacts",
116+ pages: [
117+ {
118+ key: "artifact-settings",
119+ label: "Spaces and sharing",
120+ path: "soon/artifact-settings",
121+ soon: "Which spaces a workspace starts with, and how a new artifact is shared by default.",
122+ },
123+ ],
124+ },
125+ {
126+ key: "security",
127+ title: "Security",
128+ pages: [
129+ { key: "security-settings", label: "Security settings", path: "security/settings" },
130+ { key: "guardrails", label: "Guardrails", path: "guardrails" },
131+ { key: "rules", label: "Rules", path: "rules" },
132+ { key: "audit", label: "Audit log", path: "audit" },
133+ ],
134+ },
135+ {
136+ key: "integrations",
137+ title: "Integrations",
138+ pages: [
139+ { key: "integrations", label: "Integrations", path: "integrations" },
140+ {
141+ key: "applications",
142+ label: "Applications",
143+ path: "soon/applications",
144+ soon: "OAuth applications the workspace registers, and the ones it lets its members authorize.",
145+ },
146+ ],
147+ },
148+];
149+
150+/**
151+ * Which of Workspace mode's pages `pathname` is, under the workspace
152+ * `slug`, as a group key and a page key; null for the overview and for
153+ * anything else. A page's own subpages (one ruleset, a receipt) count as
154+ * the page; the deepest match wins, so Invitations is its own page and
155+ * not a subpage of Members.
156+ */
157+export function workspaceSettingsPage(pathname: string, slug: string): { group: string; page: string } | null {
158+ const parts = pagePath(pathname).split("/").filter(Boolean);
159+ if (parts[0]?.toLowerCase() !== slug.toLowerCase() || parts[1] !== "-") return null;
160+ const rest = parts.slice(2).join("/");
161+ let found: { group: string; page: string; depth: number } | null = null;
162+ for (const group of WORKSPACE_SETTINGS_GROUPS) {
163+ for (const page of group.pages) {
164+ if (page.soon) continue;
165+ if (rest === page.path || rest.startsWith(`${page.path}/`)) {
166+ const depth = page.path.split("/").length;
167+ if (!found || depth > found.depth) found = { group: group.key, page: page.key, depth };
168+ }
169+ }
170+ }
171+ return found ? { group: found.group, page: found.page } : null;
172+}
173+
47174 /** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */
48175 const TAB_WORDS: Record<string, string> = {
49176 overview: "",
125252 /**
126253 * The rail's modes. Home is the front page;
127254 * Chat, Agents, Code and Artifacts are where work happens; Notifications
128− * spans them; People is who belongs; Workspace is the workspace itself:
129− * its money, policies and settings; Apps is everything installed that you
255+ * spans them; People is who belongs; Workspace is the workspace itself,
256+ * its settings area: who has access, money, policies and how each part is
257+ * set up; Apps is everything installed that you
130258 * can use. `account` is your own settings, under the avatar. `site` is
131259 * g1t's own public pages (a profile, Explore, Search): no workspace's, so
132260 * no mode is lit and no mode's sidebar sits beside them. Each mode has a
156284 apps: "apps",
157285 marketplace: "apps",
158286 people: "people",
159− members: "people",
287+ members: "workspace",
288+ permissions: "workspace",
160289 teams: "people",
161290 "org-chart": "people",
162291 workspace: "workspace",
+3−1
100100 route("-/people/agents/:handle", "routes/workspace/people/agent.tsx"),
101101 route("-/people/:username", "routes/workspace/people/person.tsx"),
102102 route("-/org-chart", "routes/workspace/org-chart.tsx"),
103− // Who is a member, their roles, invitations and outside collaborators.
103+ // Workspace mode's Access: who is a member and their roles, the invitations sent, and who may do what.
104104 route("-/members", "routes/workspace/members.tsx"),
105+ route("-/members/invitations", "routes/workspace/member-invitations.tsx"),
106+ route("-/permissions", "routes/workspace/permissions.tsx"),
105107 route("-/insights", "routes/workspace/tab-soon.tsx", { id: "routes/workspace/insights" }),
106108 // Pinning its projects, for the person signed in.
107109 route("-/pins", "routes/workspace/pins.ts"),
+17−29
22 import { useEffect, useState } from "react";
33 import { Form, Link, data, useNavigation } from "react-router";
44
5−import type { ChannelManagers, ChatAllowed, ChatSettings, EmojiUpload } from "@g1t/contracts";
5+import type { ChannelManagers, ChatSettings, EmojiUpload } from "@g1t/contracts";
66
77 import type { Route } from "./+types/chat-settings";
88 import { ErrorText } from "../../components/ui";
3737 const viewer = requireUser(context, request);
3838 const slug = params.owner.toLowerCase();
3939 const form = await request.formData();
40− const who = (key: string): ChatAllowed => (form.get(key) === "owners" ? "owners" : "members");
40+ // Who creates channels is set under Permissions; this page leaves it as it is.
4141 const change: Partial<ChatSettings> = {
42− public_channels: who("public_channels"),
43− private_channels: who("private_channels"),
4442 manage_channels: (form.get("manage_channels") === "owners" ? "owners" : "channel_owners") as ChannelManagers,
4543 emoji_upload: (form.get("emoji_upload") === "admins" ? "admins" : "members") as EmojiUpload,
4644 default_channels: form.getAll("default_channels").map(String),
9290 <fieldset disabled={!owner} className="space-y-8">
9391 <section className={SECTION}>
9492 <h2 className="font-medium">Creating channels</h2>
95− <p className="mt-1.5 text-xs text-faint">Who can create channels. Whoever creates one becomes its first owner.</p>
96− <div className="mt-5 grid gap-6 sm:grid-cols-2">
97− <Choice
98− name="public_channels"
99− title="Public channels"
100− value={settings.public_channels}
101− onChange={(value) => set("public_channels", value as ChatAllowed)}
102− options={[
103− { value: "members", label: "Any member", description: "The default." },
104− { value: "owners", label: "Owners only", description: "Members join and read public channels, but ask an owner for a new one." },
105− ]}
106− disabled={!owner}
107− />
108− <Choice
109− name="private_channels"
110− title="Private channels"
111− value={settings.private_channels}
112− onChange={(value) => set("private_channels", value as ChatAllowed)}
113− options={[
114− { value: "members", label: "Any member", description: "The default." },
115− { value: "owners", label: "Owners only", description: "Only owners can make a channel that is kept to the people invited." },
116− ]}
117− disabled={!owner}
118− />
119− </div>
93+ <p className="mt-1.5 text-xs text-faint">
94+ Today {settings.public_channels === "owners" ? "only owners" : "any member"} can create a public channel and{" "}
95+ {settings.private_channels === "owners" ? "only owners" : "any member"} a private one.{" "}
96+ {owner ? (
97+ <>
98+ Change it under{" "}
99+ <Link to={`/${slug}/-/permissions`} className="text-muted underline decoration-line-strong underline-offset-2 hover:text-fg">
100+ Permissions
101+ </Link>
102+ , with everything else about who may do what.
103+ </>
104+ ) : (
105+ "Owners change it under Permissions."
106+ )}
107+ </p>
120108 </section>
121109
122110 <section className={SECTION}>
+15−5
5050 title: "Workspace",
5151 about: "Who is in it, what it is on, and what it has spent this month. Everything about the workspace itself is in this sidebar.",
5252 },
53− settings: { title: "General", about: "The workspace's name, icon, address and description, who can create teams, and deleting it." },
53+ settings: { title: "General", about: "The workspace's name, icon, address and description, two-factor authentication, where its data lives, and deleting it." },
5454 people: {
5555 title: "People",
5656 about: "Everyone in the workspace, people and agents, in one directory: who they are, what they own, the teams they're on and who's around.",
5757 },
5858 members: {
59− title: "Members and invites",
60− 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.",
59+ title: "Members",
60+ about: "Who is in the workspace and their roles. Owners invite people, manage members and owners, and see the outside collaborators; what members may do is under Permissions.",
61+ },
62+ "members/invitations": {
63+ title: "Invitations",
64+ about: "Every invitation to join the workspace: who it is for, who sent it, the role, and where it stands. Send one again, revoke it, or copy its link.",
65+ },
66+ permissions: {
67+ title: "Permissions",
68+ about: "Who may do what in the workspace, part by part: repositories, teams, chat, artifacts, agents, deployments and packages. Owners only.",
6169 },
6270 "org-chart": {
6371 title: "Org chart",
180188 );
181189 }
182190 if (shown !== "overview") {
183− const key = shown ?? parts[0] ?? "";
191+ // A page of its own under another's address (Invitations under Members) has its own heading.
192+ const whole = parts.join("/");
193+ const key = shown ?? (PAGES[whole] ? whole : (parts[0] ?? ""));
184194 const heading = PAGES[key];
185195 // A page within one (a single package or team) and a page that is
186196 // coming (Insights) have headings of their own.
187− const titled = heading && (shown != null || parts.length === 1);
197+ const titled = heading && (shown != null || parts.length === 1 || key === whole);
188198 return (
189199 <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
190200 {titled && (
+272−0
1+import { Check, Copy, Ellipsis, Mail, RotateCw, X } from "lucide-react";
2+import { useEffect, useRef, useState } from "react";
3+import { Link, data, useFetcher, useSearchParams } from "react-router";
4+
5+import type { Invite } from "@g1t/contracts";
6+
7+import type { Route } from "./+types/member-invitations";
8+import { page } from "../../lib/meta";
9+import { EmptyState, ErrorText, TimeAgo } from "../../components/ui";
10+import { Badge } from "../../components/ui/badge";
11+import { Button } from "../../components/ui/button";
12+import { Card } from "../../components/ui/card";
13+import {
14+ DropdownMenu,
15+ DropdownMenuContent,
16+ DropdownMenuItem,
17+ DropdownMenuSeparator,
18+ DropdownMenuTrigger,
19+} from "../../components/ui/dropdown-menu";
20+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table";
21+import { ToggleGroup, ToggleGroupItem } from "../../components/ui/toggle-group";
22+import { InviteDialog } from "../../components/invite-dialog";
23+import { StartPlanToInvite } from "../../components/start-plan";
24+import { UserCard } from "../../components/user-card";
25+import { invitationFromForm, inviteFromForm } from "../../lib/member-invitations.server";
26+import {
27+ INVITATION_FILTERS,
28+ type InvitationResult,
29+ filterInvitations,
30+ invitationCounts,
31+ invitationFilter,
32+ invitationFilterOf,
33+ invitationTone,
34+ inviteLink,
35+ inviteState,
36+} from "../../lib/invites";
37+import { registrationMode } from "../../lib/registration.server";
38+import { billing, identity } from "../../lib/services.server";
39+import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
40+
41+export function meta({ params, ...args }: Route.MetaArgs) {
42+ return page(args, { title: `Invitations · ${params.owner} · g1t` });
43+}
44+
45+/**
46+ * Invitations: every invitation to join the workspace, newest first, with
47+ * where each stands, and the way to send one again, revoke it or copy its
48+ * link. Owners only; to anyone else the page does not exist.
49+ */
50+export async function loader({ params, context, request }: Route.LoaderArgs) {
51+ const viewer = getViewer(context);
52+ if (roleIn(viewer, params.owner) !== "owner") throw data(null, { status: 404 });
53+ const [invites, workspace, free, mode] = await Promise.all([
54+ identity.workspaceInvites(params.owner, viewer),
55+ identity.getWorkspace(params.owner),
56+ billing.freeWorkspaces([params.owner]).catch(() => [] as string[]),
57+ registrationMode(),
58+ ]);
59+ return {
60+ name: workspace?.name?.trim() || params.owner,
61+ inviteOnly: mode !== "open",
62+ free: free.includes(params.owner.toLowerCase()),
63+ invites: invites.ok ? invites.value : [],
64+ origin: new URL(request.url).origin,
65+ };
66+}
67+
68+export async function action({ request, params, context }: Route.ActionArgs) {
69+ assertSameOrigin(request);
70+ const user = requireUser(context, request);
71+ const form = await request.formData();
72+ const changed = await invitationFromForm(user, params.owner, form);
73+ if (changed) return changed;
74+ return inviteFromForm(user, params.owner, form);
75+}
76+
77+/** The role an invitation joins with; member when it names none. */
78+function roleOf(invite: Invite): string {
79+ return invite.role === "owner" ? "Owner" : "Member";
80+}
81+
82+/** The person or address an invitation is for. */
83+function Who({ invite }: { invite: Invite }) {
84+ if (invite.invitee && !invite.email) {
85+ return (
86+ <UserCard username={invite.invitee}>
87+ <Link to={`/u/${invite.invitee}`} className="font-mono hover:text-accent">
88+ @{invite.invitee}
89+ </Link>
90+ </UserCard>
91+ );
92+ }
93+ if (invite.email) {
94+ return (
95+ <span className="inline-flex min-w-0 items-center gap-1.5">
96+ <Mail size={13} aria-hidden="true" className="shrink-0 text-faint" />
97+ <span className="truncate">{invite.email}</span>
98+ </span>
99+ );
100+ }
101+ return <span className="text-muted">Anyone with the link</span>;
102+}
103+
104+/** The ⋯ menu on one invitation: copy its link, send it again, revoke it. */
105+function InvitationMenu({ invite, origin, onDone }: { invite: Invite; origin: string; onDone: (result: InvitationResult) => void }) {
106+ const fetcher = useFetcher<InvitationResult>();
107+ const [copied, setCopied] = useState(false);
108+ const pending = invitationFilterOf(invite.status) === "pending";
109+ const resendable = invite.status === "pending";
110+ const link = invite.code ? inviteLink(invite.code, origin) : null;
111+ const working = fetcher.state !== "idle";
112+ // What the service answered, once, to the page.
113+ const answered = fetcher.state === "idle" ? fetcher.data : undefined;
114+ const told = useRef<InvitationResult | undefined>(undefined);
115+ useEffect(() => {
116+ if (!answered || told.current === answered) return;
117+ told.current = answered;
118+ onDone(answered);
119+ }, [answered, onDone]);
120+ if (!pending && !link) return null;
121+ return (
122+ <DropdownMenu>
123+ <DropdownMenuTrigger asChild>
124+ <Button type="button" aria-label={`Manage the invitation for ${invite.email ?? invite.invitee ?? invite.hint}`} variant="ghost" size="inline" className="p-1.5 text-faint max-sm:p-2.5" disabled={working}>
125+ <Ellipsis size={16} />
126+ </Button>
127+ </DropdownMenuTrigger>
128+ <DropdownMenuContent align="end" className="w-56">
129+ <DropdownMenuItem
130+ disabled={!link}
131+ onSelect={(event) => {
132+ event.preventDefault();
133+ if (!link) return;
134+ void navigator.clipboard?.writeText(link).then(() => {
135+ setCopied(true);
136+ setTimeout(() => setCopied(false), 1500);
137+ });
138+ }}
139+ >
140+ {copied ? <Check /> : <Copy />}
141+ {copied ? "Copied" : link ? "Copy link" : "No link to copy"}
142+ </DropdownMenuItem>
143+ <DropdownMenuItem disabled={!resendable} onSelect={() => fetcher.submit({ action: "resend-invite", id: invite.id }, { method: "post" })}>
144+ <RotateCw />
145+ {resendable ? "Send again" : "Already in their hands"}
146+ </DropdownMenuItem>
147+ <DropdownMenuSeparator />
148+ <DropdownMenuItem disabled={!pending} className="text-danger" onSelect={() => fetcher.submit({ action: "revoke-invite", id: invite.id }, { method: "post" })}>
149+ <X />
150+ Revoke
151+ </DropdownMenuItem>
152+ </DropdownMenuContent>
153+ </DropdownMenu>
154+ );
155+}
156+
157+export default function WorkspaceInvitations({ loaderData, params }: Route.ComponentProps) {
158+ const { name, inviteOnly, free, invites, origin } = loaderData;
159+ const [search, setSearch] = useSearchParams();
160+ const filter = invitationFilter(search.get("state"));
161+ const counts = invitationCounts(invites);
162+ const shown = filterInvitations(invites, filter);
163+ const [notice, setNotice] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
164+ const done = (result: InvitationResult) => {
165+ const who = invites.find((invite) => invite.id === result.id);
166+ const whom = who?.email ?? (who?.invitee ? `@${who.invitee}` : "the invitation");
167+ if ("error" in result) setNotice({ tone: "error", text: result.error });
168+ else setNotice({ tone: "ok", text: result.done === "revoked" ? `Revoked the invitation for ${whom}.` : `Sent the invitation for ${whom} again.` });
169+ };
170+ const choose = (value: string) => {
171+ const next = new URLSearchParams(search);
172+ if (value && value !== "all") next.set("state", value);
173+ else next.delete("state");
174+ setSearch(next, { replace: true, preventScrollReset: true });
175+ };
176+ return (
177+ <div className="max-w-4xl">
178+ <div className="mb-4 flex flex-wrap items-center gap-3">
179+ <ToggleGroup type="single" variant="outline" value={filter} onValueChange={(value) => value && choose(value)} aria-label="Which invitations">
180+ {INVITATION_FILTERS.map((entry) => (
181+ <ToggleGroupItem key={entry.key} value={entry.key}>
182+ {entry.label}
183+ <span className="text-faint tabular-nums">{counts[entry.key]}</span>
184+ </ToggleGroupItem>
185+ ))}
186+ </ToggleGroup>
187+ {!free && (
188+ <div className="ml-auto">
189+ <InviteDialog
190+ slug={params.owner}
191+ name={name}
192+ inviteOnly={inviteOnly}
193+ action={`/${params.owner}/-/members/invitations`}
194+ onInvited={(who) => setNotice({ tone: "ok", text: `Invitation to join ${name} sent to ${who}. They join once they accept.` })}
195+ />
196+ </div>
197+ )}
198+ </div>
199+ {free && (
200+ <div className="mb-4">
201+ <StartPlanToInvite workspace={params.owner} owner />
202+ </div>
203+ )}
204+ {shown.length === 0 ? (
205+ <EmptyState title={filter === "all" ? "No invitations yet" : `No ${INVITATION_FILTERS.find((entry) => entry.key === filter)?.label.toLowerCase()} invitations`}>
206+ {filter === "all"
207+ ? `Invite someone and the invitation is listed here until they answer, and after.`
208+ : "Choose All to see every invitation sent to join the workspace."}
209+ </EmptyState>
210+ ) : (
211+ <Card tone="plain" className="overflow-hidden">
212+ <Table>
213+ <TableHeader>
214+ <TableRow>
215+ <TableHead>Who</TableHead>
216+ <TableHead>Invited by</TableHead>
217+ <TableHead>Role</TableHead>
218+ <TableHead>Sent</TableHead>
219+ <TableHead>Status</TableHead>
220+ <TableHead className="w-10">
221+ <span className="sr-only">Actions</span>
222+ </TableHead>
223+ </TableRow>
224+ </TableHeader>
225+ <TableBody>
226+ {shown.map((invite) => {
227+ const state = inviteState(invite);
228+ return (
229+ <TableRow key={invite.id}>
230+ <TableCell className="max-w-64 align-middle text-sm">
231+ <Who invite={invite} />
232+ </TableCell>
233+ <TableCell className="align-middle text-sm whitespace-nowrap text-muted">
234+ {invite.invitedBy ? (
235+ <UserCard username={invite.invitedBy}>
236+ <Link to={`/u/${invite.invitedBy}`} className="font-mono hover:text-accent">
237+ {invite.invitedBy}
238+ </Link>
239+ </UserCard>
240+ ) : (
241+ "g1t"
242+ )}
243+ </TableCell>
244+ <TableCell className="align-middle text-sm whitespace-nowrap text-muted">{roleOf(invite)}</TableCell>
245+ <TableCell className="align-middle text-sm whitespace-nowrap text-muted">
246+ <TimeAgo at={invite.createdAt} />
247+ </TableCell>
248+ <TableCell className="align-middle">
249+ <Badge tone={invitationTone(invite.status)} size="md" className="max-w-56 truncate">
250+ {state.label}
251+ </Badge>
252+ {invitationFilterOf(invite.status) === "pending" && (
253+ <span className="ml-2 text-xs whitespace-nowrap text-faint">until {invite.expiresAt.slice(0, 10)}</span>
254+ )}
255+ </TableCell>
256+ <TableCell className="align-middle text-right">
257+ <InvitationMenu invite={invite} origin={origin} onDone={done} />
258+ </TableCell>
259+ </TableRow>
260+ );
261+ })}
262+ </TableBody>
263+ </Table>
264+ </Card>
265+ )}
266+ <p className="mt-2 text-xs text-faint">
267+ An invitation works for 30 days. One nobody used by then shows as Expired; g1t does not yet know whether its email was delivered.
268+ </p>
269+ {notice && (notice.tone === "ok" ? <p className="mt-3 text-sm text-muted" role="status">{notice.text}</p> : <ErrorText>{notice.text}</ErrorText>)}
270+ </div>
271+ );
272+}
+209−303
1−import { ArrowUpRight, Check, Ellipsis, ShieldAlert, ShieldCheck, Users } from "lucide-react";
2−import { useState } from "react";
1+import { ArrowUpRight, Check, Ellipsis, Mail, Search, ShieldAlert, ShieldCheck, Users } from "lucide-react";
2+import { type ReactNode, useEffect, useState } from "react";
33 import { Form, Link, data, redirect, useSearchParams, useSubmit } from "react-router";
44
5−import {
6− BASE_PERMISSIONS,
7− BASE_PERMISSION_LABELS,
8− type BasePermission,
9− DEFAULT_BASE_PERMISSION,
10− type Member,
11− ORG_ROLES,
12− ORG_ROLE_LABELS,
13− ORG_ROLE_SUMMARIES,
14− type OrgRole,
15− REPO_ROLE_LABELS,
16−} from "@g1t/contracts";
5+import { type Member, ORG_ROLES, ORG_ROLE_LABELS, ORG_ROLE_SUMMARIES, type OrgRole, REPO_ROLE_LABELS } from "@g1t/contracts";
176
187 import type { Route } from "./+types/members";
198 import { page } from "../../lib/meta";
20−import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
9+import { EmptyState, ErrorText, SubmitButton } from "../../components/ui";
2110 import { Badge } from "../../components/ui/badge";
2211 import { PersonStatusEmoji, WithPresence } from "../../components/presence";
2312 import {
4130 DropdownMenuTrigger,
4231 } from "../../components/ui/dropdown-menu";
4332 import { Hint } from "../../components/ui/hint";
44−import { forgetWorkspace } from "../../lib/workspace-choice";
45−import { Select, SelectContent, SelectField, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
33+import { Input } from "../../components/ui/input";
34+import { SelectField } from "../../components/ui/select";
4635 import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
47−import { inviteLink, inviteState, moreInvitesMailto, workspaceInviteCopy } from "../../lib/invites";
36+import { InviteDialog } from "../../components/invite-dialog";
37+import { StartPlanToInvite } from "../../components/start-plan";
38+import { UserCard } from "../../components/user-card";
39+import { inviteFromForm } from "../../lib/member-invitations.server";
40+import { filterMembers, invitationFilterOf, memberFilters } from "../../lib/invites";
4841 import { registrationMode } from "../../lib/registration.server";
4942 import { billing, identity } from "../../lib/services.server";
50−import { StartPlanToInvite } from "../../components/start-plan";
51−import {
52− assertSameOrigin,
53− getViewer,
54− requireUser,
55− roleIn,
56− unwrap,
57−} from "../../lib/session.server";
58−import { UserCard } from "../../components/user-card";
59−import { PeoplePicker } from "../../components/people-picker";
60−import { inviteTarget } from "../../lib/people-search";
43+import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
44+import { forgetWorkspace } from "../../lib/workspace-choice";
6145
6246 export function meta({ params, ...args }: Route.MetaArgs) {
63− return page(args, { title: `Members and invites · ${params.owner} · g1t` });
47+ return page(args, { title: `Members · ${params.owner} · g1t` });
6448 }
6549
66−export async function loader({ params, context, request }: Route.LoaderArgs) {
50+/**
51+ * Members: who is in the workspace, their roles, and for owners the way to
52+ * invite someone (a dialog) and the outside collaborators. Invitations
53+ * have a page of their own, next door; the base permission and the
54+ * member privileges are under Permissions.
55+ */
56+export async function loader({ params, context }: Route.LoaderArgs) {
6757 const viewer = getViewer(context);
6858 const role = roleIn(viewer, params.owner);
6959 // Who is in a workspace is its members' business; anyone else, including
7060 // someone a repository is shared with, gets nothing here.
7161 if (!role) throw data(null, { status: 404 });
7262 const owner = role === "owner";
73− const [members, invites, workspace, outside, teams, free, mode] = await Promise.all([
63+ const [members, workspace, outside, teams, free, mode, invites] = await Promise.all([
7464 identity.listMembers(params.owner, viewer),
75− owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null,
7665 identity.getWorkspace(params.owner),
7766 owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null,
7867 // Each member's teams, as the viewer may see them.
8069 // A free workspace adds no one until it starts the plan; identity
8170 // refuses it either way, so a failure here only hides the note.
8271 billing.freeWorkspaces([params.owner]).catch(() => [] as string[]),
83− // Whether sign-up takes an invite: the invite form says what that means.
72+ // Whether sign-up takes an invite: the invite dialog says what that means.
8473 registrationMode(),
74+ // How many invitations wait, for the line that leads to them.
75+ owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null,
8576 ]);
8677 return {
8778 role,
8980 inviteOnly: mode !== "open",
9081 free: free.includes(params.owner.toLowerCase()),
9182 members: unwrap(members),
92− invites: invites?.ok ? invites.value : [],
93− base: workspace?.basePermission ?? DEFAULT_BASE_PERMISSION,
9483 outside: outside?.ok ? outside.value : [],
9584 teams: Object.fromEntries((teams?.ok ? teams.value : []).map((person) => [person.username, person.teams])),
96− origin: new URL(request.url).origin,
85+ pendingInvitations: invites?.ok ? invites.value.filter((invite) => invitationFilterOf(invite.status) === "pending").length : 0,
9786 me: viewer?.username ?? null,
9887 };
9988 }
10291 assertSameOrigin(request);
10392 const user = requireUser(context, request);
10493 const form = await request.formData();
105− if (form.get("action") === "revoke-invite") {
106− const result = await identity.revokeWorkspaceInvite(user, params.owner, String(form.get("id") ?? ""));
107− return result.ok ? null : { error: result.error.message };
108− }
10994 const target = String(form.get("member") ?? "").trim();
11095 // Owner or member, and the roles that add to a member.
11196 if (form.get("action") === "role") {
129114 if (!result.ok) return { error: result.error.message, leaving: true };
130115 const secure = new URL(request.url).protocol === "https:";
131116 throw redirect("/", { headers: { "Set-Cookie": forgetWorkspace(secure) } });
132− }
133− if (form.get("action") === "base-permission") {
134− const base = String(form.get("base") ?? "");
135− if (!(BASE_PERMISSIONS as readonly string[]).includes(base)) return { error: "Choose a base permission." };
136− const result = await identity.setBasePermission(user, params.owner, base as BasePermission);
137− return result.ok ? { based: result.value } : { error: result.error.message, base: true };
138117 }
139− const member = String(form.get("member") ?? "").trim();
140118 if (form.get("action") === "remove") {
141− const result = await identity.removeMember(user, params.owner, member);
119+ const result = await identity.removeMember(user, params.owner, target);
142120 return result.ok ? null : { error: result.error.message, converting: false };
143121 }
144− // Nobody joins without saying yes: a username or an address gets an
145− // invitation to accept or decline, with the role chosen here.
146− const who = inviteTarget(member);
147− if (!who) return { error: "Enter a g1t username or an email address.", outOfInvites: false };
148− const role = form.get("role") === "owner" ? "owner" : "member";
149− const result = await identity.inviteMember(user, params.owner, { ...who, role });
150− if (!result.ok) {
151− return { error: result.error.message, outOfInvites: result.error.code === "limit", converting: form.get("action") === "convert" };
152− }
153− if (form.get("action") === "convert") return { converted: member };
154− return { invited: "email" in who ? who.email : `@${"username" in who ? who.username : member}`, outOfInvites: false };
122+ // Inviting: from the dialog (`add`), or an outside collaborator as a
123+ // member (`convert`). Nobody joins without saying yes.
124+ const invited = await inviteFromForm(user, params.owner, form);
125+ if ("error" in invited) return { ...invited, converting: form.get("action") === "convert" };
126+ if (form.get("action") === "convert") return { converted: target };
127+ return invited;
155128 }
156−
157−/** What the base permission means for members, in a sentence. */
158−const BASE_MEANS: Record<BasePermission, string> = {
159− none: "Members see only public repositories and the ones they are given a role on.",
160− read: "Members can see and clone every repository, open issues and pull requests, and comment.",
161− write: "Members can see every repository, manage its issues and pull requests, push, merge, and put agents to work.",
162− admin: "Members can do everything on every repository, including its settings, webhooks, secrets and who has access. Only owners transfer or delete one.",
163−};
164129
165130 /** What a member's roles say about them, as badges: owner or member, then the roles on top. */
166131 function RoleBadges({ member }: { member: Member }) {
318283 );
319284 }
320285
321−export default function WorkspacePeople({ loaderData, actionData, params }: Route.ComponentProps) {
322− const { role, members, invites, origin, base, outside, teams, free, me, name, inviteOnly } = loaderData;
286+/**
287+ * The row above the list: words to search by, the role, two-factor (for
288+ * owners) and a team, each kept in the address so a filtered list can be
289+ * shared; and, for owners, Invite.
290+ */
291+function MembersToolbar({
292+ owner,
293+ teams,
294+ invite,
295+}: {
296+ owner: boolean;
297+ /** The teams any listed member is on, to filter by. */
298+ teams: { slug: string; name: string }[];
299+ invite: ReactNode;
300+}) {
301+ const [search, setSearch] = useSearchParams();
302+ const filters = memberFilters(search);
303+ const [words, setWords] = useState(filters.query);
304+ // The box is typed into freely; the address follows a moment later.
305+ useEffect(() => {
306+ if (words === filters.query) return;
307+ const timer = setTimeout(() => {
308+ const next = new URLSearchParams(search);
309+ if (words.trim()) next.set("q", words.trim());
310+ else next.delete("q");
311+ setSearch(next, { replace: true, preventScrollReset: true });
312+ }, 200);
313+ return () => clearTimeout(timer);
314+ }, [words, filters.query, search, setSearch]);
315+ const set = (key: string, value: string) => {
316+ const next = new URLSearchParams(search);
317+ if (value && value !== "all") next.set(key, value);
318+ else next.delete(key);
319+ setSearch(next, { replace: true, preventScrollReset: true });
320+ };
321+ return (
322+ <div className="mb-4 flex flex-wrap items-center gap-2">
323+ <div className="relative min-w-0 grow basis-56">
324+ <Search size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
325+ <Input
326+ type="search"
327+ aria-label="Search members"
328+ placeholder="Search by username or name"
329+ value={words}
330+ onChange={(event) => setWords(event.currentTarget.value)}
331+ className="pl-9"
332+ />
333+ </div>
334+ <SelectField
335+ aria-label="Role"
336+ value={filters.role}
337+ onValueChange={(value) => set("role", value)}
338+ className="w-auto min-w-28"
339+ options={[
340+ { value: "all", label: "Any role" },
341+ { value: "owner", label: "Owners" },
342+ { value: "member", label: "Members" },
343+ ]}
344+ />
345+ {owner && (
346+ <SelectField
347+ aria-label="Two-factor authentication"
348+ value={filters.twoFactor}
349+ onValueChange={(value) => set("2fa", value)}
350+ className="w-auto min-w-28"
351+ options={[
352+ { value: "all", label: "Two-factor: any" },
353+ { value: "on", label: "Two-factor on" },
354+ { value: "off", label: "Two-factor off" },
355+ ]}
356+ />
357+ )}
358+ {teams.length > 0 && (
359+ <SelectField
360+ aria-label="Team"
361+ value={filters.team ?? "all"}
362+ onValueChange={(value) => set("team", value)}
363+ className="w-auto min-w-28"
364+ options={[{ value: "all", label: "Any team" }, ...teams.map((team) => ({ value: team.slug.toLowerCase(), label: team.name }))]}
365+ />
366+ )}
367+ {invite && <div className="ml-auto">{invite}</div>}
368+ </div>
369+ );
370+}
371+
372+export default function WorkspaceMembers({ loaderData, actionData, params }: Route.ComponentProps) {
373+ const { role, members, outside, teams, free, me, name, inviteOnly, pendingInvitations } = loaderData;
323374 const owner = role === "owner";
324− // The invitation to join this workspace, not an invite to g1t (Settings → Invites).
325− const inviteCopy = workspaceInviteCopy(name, inviteOnly);
326375 const owners = members.filter((member) => member.role === "owner").length;
327376 const rowError = (username: string) =>
328377 actionData && "row" in actionData && actionData.row === username ? (actionData.error ?? null) : null;
329− // Waiting to be used, or used by someone still confirming their email:
330− // either can be revoked.
331− // Waiting for an answer to a workspace invitation, too.
332− const pending = invites.filter(
333− (invite) => invite.status === "pending" || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer",
334− );
335378 const [search, setSearch] = useSearchParams();
336379 const tab = search.get("tab") === "outside" && owner ? "outside" : "members";
380+ const filters = memberFilters(search);
381+ const shown = filterMembers(members, filters, teams);
382+ const filtered = filters.role !== "all" || filters.twoFactor !== "all" || filters.team !== null || filters.query !== "";
383+ // Every team a listed member is on, once each, to filter by.
384+ const teamChoices = [...new Map(Object.values(teams).flat().map((team) => [team.slug.toLowerCase(), team])).values()].sort((a, b) =>
385+ a.name.localeCompare(b.name),
386+ );
387+ // Who was just invited from the dialog, said under the list.
388+ const [invited, setInvited] = useState<string | null>(null);
389+ const invite = owner && !free ? <InviteDialog slug={params.owner} name={name} inviteOnly={inviteOnly} action={`/${params.owner}/-/members`} onInvited={setInvited} /> : null;
337390 const membersTab = (
338391 <>
339− <Card asChild tone="plain" divided>
340− <ul>
341− {members.map((member) => (
342− <li key={member.username} className="flex flex-wrap items-center gap-3 px-4 py-3">
343− <WithPresence person={{ username: member.username }} size={28}>
344− <Avatar name={member.username} image={member.avatar} size={28} />
345− </WithPresence>
346− <div className="min-w-0 grow truncate">
347− <UserCard username={member.username}>
348− <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent">
349− {member.username}
350− </Link>
351− </UserCard>
352− <PersonStatusEmoji person={{ username: member.username }} size={13} className="ml-1.5 align-[-2px]" />
353− {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>}
354− {(teams[member.username] ?? []).length > 0 && (
355− <div className="mt-1 flex flex-wrap gap-1">
356− {(teams[member.username] ?? []).slice(0, 3).map((team) => (
357− <Link
358− key={team.slug}
359− to={`/${params.owner}/-/teams/${team.slug}`}
360− className="rounded-full border border-line px-2 py-px text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
361− >
362− {team.name}
363− </Link>
364− ))}
365− {(teams[member.username] ?? []).length > 3 && (
366− <span className="px-1 text-xs text-faint">+{(teams[member.username] ?? []).length - 3} more</span>
367− )}
368− </div>
369− )}
370− </div>
371− {/* Together, so on a narrow screen they move under the name as one. */}
372− <div className="ml-auto flex items-center gap-2">
373− {owner && <TwoFactorMark on={member.two_factor} />}
374− <RoleBadges member={member} />
375− {owner && <MemberMenu member={member} self={member.username === me} owners={owners} slug={params.owner} />}
376− </div>
377− {rowError(member.username) && (
378− <p className="basis-full text-sm text-danger" role="alert">
379− {rowError(member.username)}
380− </p>
381− )}
382− </li>
383− ))}
384− </ul>
385− </Card>
392+ <MembersToolbar owner={owner} teams={teamChoices} invite={invite} />
386393 {owner && free && (
387− <div className="mt-6">
394+ <div className="mb-4">
388395 <StartPlanToInvite workspace={params.owner} owner={owner} />
389396 </div>
390397 )}
391− {owner && !free && (
392− // Empty again once the person is on the list; kept as typed when it failed.
393− <section aria-labelledby="invite-to-workspace" className="mt-6">
394− <h2 id="invite-to-workspace" className="text-sm font-medium">
395− {inviteCopy.heading}
396− </h2>
397− <p className="mt-1 text-xs text-faint">{inviteCopy.hint}</p>
398− <Form
399− method="post"
400− key={`${members.length}:${pending.length}`}
401− className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-start"
402− >
403− <input type="hidden" name="action" value="add" />
404− <div className="grow">
405− <Field label="Who">
406− <PeoplePicker name="member" placeholder="username, name or name@example.com" />
407− </Field>
408− </div>
409− <label className="block">
410− <span className="mb-1.5 block text-sm font-medium text-muted">Role</span>
411− <SelectField
412− name="role"
413− defaultValue="member"
414− className="h-auto w-full py-2 sm:w-auto sm:min-w-32"
415− options={[
416− { value: "member", label: "Member" },
417− { value: "owner", label: "Owner" },
418− ]}
419− />
420− </label>
421− <div className="sm:pt-[1.625rem]">
422− <SubmitButton match={{ action: "add" }} pending="Inviting…">
423− Invite
424− </SubmitButton>
425− </div>
426− </Form>
427− {inviteCopy.elsewhere && (
428− <p className="mt-2 text-xs text-faint">
429− {inviteCopy.elsewhere.replace(/Settings → Invites\.$/, "")}
430− <Link to="/settings/invites" className="text-muted underline underline-offset-4 hover:text-fg">
431− Settings → Invites
398+ {shown.length === 0 ? (
399+ <EmptyState title={filtered ? "No members match" : "No members yet"}>
400+ {filtered ? "Clear a filter, or search for something else." : "Invite someone and they are listed here once they accept."}
401+ </EmptyState>
402+ ) : (
403+ <Card asChild tone="plain" divided>
404+ <ul>
405+ {shown.map((member) => (
406+ <li key={member.username} className="flex flex-wrap items-center gap-3 px-4 py-3">
407+ <WithPresence person={{ username: member.username }} size={28}>
408+ <Avatar name={member.username} image={member.avatar} size={28} />
409+ </WithPresence>
410+ <div className="min-w-0 grow truncate">
411+ <UserCard username={member.username}>
412+ <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent">
413+ {member.username}
414+ </Link>
415+ </UserCard>
416+ <PersonStatusEmoji person={{ username: member.username }} size={13} className="ml-1.5 align-[-2px]" />
417+ {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>}
418+ {(teams[member.username] ?? []).length > 0 && (
419+ <div className="mt-1 flex flex-wrap gap-1">
420+ {(teams[member.username] ?? []).slice(0, 3).map((team) => (
421+ <Link
422+ key={team.slug}
423+ to={`/${params.owner}/-/teams/${team.slug}`}
424+ className="rounded-full border border-line px-2 py-px text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
425+ >
426+ {team.name}
427+ </Link>
428+ ))}
429+ {(teams[member.username] ?? []).length > 3 && (
430+ <span className="px-1 text-xs text-faint">+{(teams[member.username] ?? []).length - 3} more</span>
431+ )}
432+ </div>
433+ )}
434+ </div>
435+ {/* Together, so on a narrow screen they move under the name as one. */}
436+ <div className="ml-auto flex items-center gap-2">
437+ {owner && <TwoFactorMark on={member.two_factor} />}
438+ <RoleBadges member={member} />
439+ {owner && <MemberMenu member={member} self={member.username === me} owners={owners} slug={params.owner} />}
440+ </div>
441+ {rowError(member.username) && (
442+ <p className="basis-full text-sm text-danger" role="alert">
443+ {rowError(member.username)}
444+ </p>
445+ )}
446+ </li>
447+ ))}
448+ </ul>
449+ </Card>
450+ )}
451+ <p className="mt-2 text-xs text-faint">
452+ {filtered ? `${shown.length} of ${members.length}` : members.length} {members.length === 1 ? "member" : "members"}
453+ {owner && pendingInvitations > 0 && (
454+ <>
455+ {" · "}
456+ <Link to={`/${params.owner}/-/members/invitations?state=pending`} className="inline-flex items-center gap-1 text-muted underline-offset-4 hover:text-fg hover:underline">
457+ <Mail size={12} aria-hidden="true" />
458+ {pendingInvitations} {pendingInvitations === 1 ? "invitation" : "invitations"} waiting
432459 </Link>
433− .
434− </p>
460+ </>
435461 )}
436− </section>
437− )}
438− {actionData && "invited" in actionData && actionData.invited && (
439− <p className="text-sm text-muted" role="status">
440− Invitation to join {name} sent to <span className="text-fg">{actionData.invited}</span>. They join once they accept.
462+ </p>
463+ {invited && (
464+ <p className="mt-3 text-sm text-muted" role="status">
465+ Invitation to join {name} sent to <span className="text-fg">{invited}</span>. They join once they accept; until then it is listed under{" "}
466+ <Link to={`/${params.owner}/-/members/invitations`} className="text-accent underline-offset-4 hover:underline">
467+ Invitations
468+ </Link>
469+ .
441470 </p>
442471 )}
443472 {actionData && "transferred" in actionData && (
444− <p className="text-sm text-muted" role="status">
473+ <p className="mt-3 text-sm text-muted" role="status">
445474 <span className="font-mono text-fg">{actionData.transferred}</span> owns {params.owner} now, and you are a member.
446475 </p>
447476 )}
448477 {actionData && "converted" in actionData && (
449− <p className="text-sm text-muted" role="status">
478+ <p className="mt-3 text-sm text-muted" role="status">
450479 <span className="font-mono text-fg">{actionData.converted}</span> is invited to join {params.owner} as a member, and joins once they accept.
451480 </p>
452481 )}
453482 {actionData &&
454− !("base" in actionData) &&
483+ "error" in actionData &&
455484 !("row" in actionData) &&
456485 !("leaving" in actionData) &&
457− !("converting" in actionData && actionData.converting) && (
458− <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
459− )}
460− {actionData && "outOfInvites" in actionData && actionData.outOfInvites && (
461− <p className="mt-1 text-sm text-muted">
462− <a href={moreInvitesMailto(params.owner)} className="text-accent underline underline-offset-4">
463− Ask for more invites
464− </a>{" "}
465− for {params.owner}.
466− </p>
467− )}
468−
469− {owner && pending.length > 0 && (
470− <section className="mt-8">
471− <h2 className="text-sm font-medium">Pending invitations</h2>
472− <Card asChild tone="plain" divided className="mt-3">
473− <ul>
474− {pending.map((invite) => (
475− <li key={invite.id} className="space-y-2 px-4 py-3">
476− <div className="flex flex-wrap items-center gap-3">
477− <span className="min-w-0 grow truncate text-sm">
478− {invite.email ??
479− (invite.invitee ? (
480− <UserCard username={invite.invitee}>
481− <Link to={`/u/${invite.invitee}`} className="font-mono hover:text-accent">
482− @{invite.invitee}
483− </Link>
484− </UserCard>
485− ) : (
486− "Anyone with the link"
487− ))}
488− </span>
489− {invite.role === "owner" && <Badge size="md">Owner</Badge>}
490− <Badge size="md">{inviteState(invite).label}</Badge>
491− <Form method="post">
492− <input type="hidden" name="action" value="revoke-invite" />
493− <input type="hidden" name="id" value={invite.id} />
494− <SubmitButton variant="outline" match={{ action: "revoke-invite", id: invite.id }} pending="Revoking…">
495− Revoke
496− </SubmitButton>
497− </Form>
498− </div>
499− <p className="text-xs text-faint">
500− By {invite.invitedBy ?? "g1t"} · <TimeAgo at={invite.createdAt} /> · works until{" "}
501− {new Date(invite.expiresAt).toISOString().slice(0, 10)}
502− </p>
503− {invite.code && <CopyLine text={inviteLink(invite.code, origin)} />}
504− </li>
505− ))}
506− </ul>
507− </Card>
508− </section>
509− )}
510−
511− <BasePermissionSection
512− // Starts from the saved choice whenever it changes.
513− key={base}
514− base={base}
515− owner={owner}
516− slug={params.owner}
517− error={actionData && "base" in actionData && "error" in actionData ? (actionData.error ?? null) : null}
518− saved={Boolean(actionData && "based" in actionData)}
519− />
486+ !("converting" in actionData && actionData.converting) && <ErrorText>{actionData.error}</ErrorText>}
520487
521488 <LeaveSection
522489 slug={params.owner}
526493 </>
527494 );
528495 return (
529− <div className="max-w-2xl">
496+ <div className="max-w-3xl">
530497 {owner ? (
531498 <Tabs
532499 value={tab}
537504 setSearch(next, { replace: true, preventScrollReset: true });
538505 }}
539506 >
540− <TabsList className="mb-3">
507+ <TabsList className="mb-4">
541508 <TabsTrigger value="members">Members · {members.length}</TabsTrigger>
542509 <TabsTrigger value="outside">Outside collaborators · {outside.length}</TabsTrigger>
543510 </TabsList>
555522 membersTab
556523 )}
557524 </div>
558− );
559−}
560−
561−/** What members get on every repository, which owners choose. */
562−function BasePermissionSection({
563− base,
564− owner,
565− slug,
566− error,
567− saved,
568−}: {
569− base: BasePermission;
570− owner: boolean;
571− slug: string;
572− error: string | null;
573− saved: boolean;
574−}) {
575− const [chosen, setChosen] = useState<BasePermission>(base);
576− return (
577− <section id="base-permission" className="mt-10 scroll-mt-20 border-t border-line pt-8">
578− <h2 className="font-medium">Base permission</h2>
579− <p className="mt-1 text-sm text-muted">
580− The role every member has on each of {slug}'s repositories. Owners are Admins on all of them, and a role given to
581− someone on one repository adds to this; it never takes away.
582− </p>
583− {owner ? (
584− <Card asChild className="mt-4 p-4">
585− <Form method="post">
586− <input type="hidden" name="action" value="base-permission" />
587− <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
588− <Select name="base" value={chosen} onValueChange={(value) => setChosen(value as BasePermission)}>
589− <SelectTrigger aria-label="Base permission" className="sm:max-w-48">
590− <SelectValue />
591− </SelectTrigger>
592− <SelectContent>
593− {BASE_PERMISSIONS.map((value) => (
594− <SelectItem key={value} value={value} description={BASE_MEANS[value]}>
595− {BASE_PERMISSION_LABELS[value]}
596− </SelectItem>
597− ))}
598− </SelectContent>
599− </Select>
600− <SubmitButton variant="outline" match={{ action: "base-permission" }} pending="Saving…" disabled={chosen === base}>
601− Save
602− </SubmitButton>
603− {saved && chosen === base && <span className="text-sm text-muted">Saved.</span>}
604− </div>
605− <p className="mt-3 text-sm text-muted">{BASE_MEANS[chosen]}</p>
606− <ErrorText>{error}</ErrorText>
607− </Form>
608− </Card>
609− ) : (
610− <Card className="mt-4 p-4 text-sm">
611− <p>
612− <span className="font-medium">{BASE_PERMISSION_LABELS[base]}.</span>{" "}
613− <span className="text-muted">{BASE_MEANS[base]}</span>
614− </p>
615− <p className="mt-2 text-xs text-faint">Owners of the workspace choose it.</p>
616− </Card>
617− )}
618− </section>
619525 );
620526 }
621527
+489−0
1+import { Bot, Code2, MessagesSquare, Rocket, Shapes, UsersRound } from "lucide-react";
2+import { type ReactNode, useState } from "react";
3+import { Form, Link, data, useNavigation } from "react-router";
4+
5+import {
6+ BASE_PERMISSIONS,
7+ BASE_PERMISSION_LABELS,
8+ type BasePermission,
9+ type ChatAllowed,
10+ DEFAULT_BASE_PERMISSION,
11+ DEFAULT_MEMBER_PRIVILEGES,
12+ type MemberPrivileges,
13+ type TeamCreation,
14+ type Workspace,
15+} from "@g1t/contracts";
16+
17+import type { Route } from "./+types/permissions";
18+import { page } from "../../lib/meta";
19+import { ErrorText, SubmitButton } from "../../components/ui";
20+import { Badge } from "../../components/ui/badge";
21+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card";
22+import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
23+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
24+import { SwitchCard } from "../../components/ui/switch";
25+import { chat, identity, workspaceAgents } from "../../lib/services.server";
26+import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
27+
28+export function meta({ params, ...args }: Route.MetaArgs) {
29+ return page(args, { title: `Permissions · ${params.owner} · g1t` });
30+}
31+
32+/**
33+ * Permissions: who may do what in the workspace, in one place, a section
34+ * for each part of g1t. What exists today saves through the service that
35+ * owns it (identity for repositories and teams, chat for channels, agents
36+ * for personal agents); what does not yet is shown as it will be, marked
37+ * Soon, with the rule that holds today. Owners only.
38+ */
39+export async function loader({ params, context }: Route.LoaderArgs) {
40+ const viewer = getViewer(context);
41+ if (roleIn(viewer, params.owner) !== "owner" || !viewer) throw data(null, { status: 404 });
42+ const slug = params.owner.toLowerCase();
43+ const [workspace, policy, chatSettings] = await Promise.all([
44+ identity.getWorkspace(params.owner),
45+ workspaceAgents.policy(slug, viewer).catch(() => null),
46+ chat.chatSettings(slug, viewer).catch(() => null),
47+ ]);
48+ if (!workspace) throw data(null, { status: 404 });
49+ return {
50+ slug,
51+ base: workspace.basePermission ?? DEFAULT_BASE_PERMISSION,
52+ privileges: { ...DEFAULT_MEMBER_PRIVILEGES, ...pickPrivileges(workspace) },
53+ teamCreation: workspace.teamCreation ?? "members",
54+ // Null when the service did not answer: the section says so and takes no change.
55+ membersCreateAgents: policy?.ok ? policy.value.members_create_agents : null,
56+ channels: chatSettings?.ok ? { public: chatSettings.value.settings.public_channels, private: chatSettings.value.settings.private_channels } : null,
57+ };
58+}
59+
60+export async function action({ request, params, context }: Route.ActionArgs) {
61+ assertSameOrigin(request);
62+ const user = requireUser(context, request);
63+ const form = await request.formData();
64+ const intent = String(form.get("intent") ?? "");
65+ const slug = params.owner.toLowerCase();
66+ // Each section saves on its own; identity, chat and agents check the owner again.
67+ if (intent === "base-permission") {
68+ const base = String(form.get("base") ?? "");
69+ if (!(BASE_PERMISSIONS as readonly string[]).includes(base)) return { intent, error: "Choose a base permission." };
70+ const result = await identity.setBasePermission(user, params.owner, base as BasePermission);
71+ return result.ok ? { intent, saved: true } : { intent, error: result.error.message };
72+ }
73+ if (intent === "member-privileges") {
74+ // A switch posts only when on.
75+ const change = Object.fromEntries(PRIVILEGES.map(([key]) => [key, form.get(key) === "on"])) as MemberPrivileges;
76+ const result = await identity.setMemberPrivileges(user, params.owner, change);
77+ return result.ok ? { intent, saved: true } : { intent, error: result.error.message };
78+ }
79+ if (intent === "team-creation") {
80+ const wanted: TeamCreation = form.get("team_creation") === "owners" ? "owners" : "members";
81+ const result = await identity.setTeamCreation(user, params.owner, wanted);
82+ return result.ok ? { intent, saved: true } : { intent, error: result.error.message };
83+ }
84+ if (intent === "chat-channels") {
85+ const who = (key: string): ChatAllowed => (form.get(key) === "owners" ? "owners" : "members");
86+ try {
87+ const result = await chat.setChatSettings(slug, user, { public_channels: who("public_channels"), private_channels: who("private_channels") });
88+ return result.ok ? { intent, saved: true } : { intent, error: result.error.message };
89+ } catch {
90+ return { intent, error: "Chat didn't answer. Try again in a moment." };
91+ }
92+ }
93+ if (intent === "personal-agents") {
94+ try {
95+ const result = await workspaceAgents.setPolicy(slug, user, { members_create_agents: form.get("members_create_agents") === "on" });
96+ return result.ok ? { intent, saved: true } : { intent, error: result.error.message };
97+ } catch {
98+ return { intent, error: "Agents didn't answer. Try again in a moment." };
99+ }
100+ }
101+ return { intent, error: "Unknown request." };
102+}
103+
104+/** What the base permission means for members, in a sentence. */
105+const BASE_MEANS: Record<BasePermission, string> = {
106+ none: "Members see only public repositories and the ones they are given a role on.",
107+ read: "Members can see and clone every repository, open issues and pull requests, and comment.",
108+ write: "Members can see every repository, manage its issues and pull requests, push, merge, and put agents to work.",
109+ admin: "Members can do everything on every repository, including its settings, webhooks, secrets and who has access. Only owners transfer or delete one.",
110+};
111+
112+/** The member privileges, in the order the Code section shows them, with what each lets members do. */
113+const PRIVILEGES: [keyof MemberPrivileges, string, string][] = [
114+ ["members_can_create_public_repositories", "Members can create public repositories", "Owners always can. Off: members ask an owner."],
115+ ["members_can_create_private_repositories", "Members can create private repositories", "Owners always can. Whoever creates a repository is its Admin."],
116+ [
117+ "members_can_change_repo_visibility",
118+ "Repository admins can change visibility",
119+ "Members with the Admin role on a repository can make it public or private. Off: owners only.",
120+ ],
121+ [
122+ "members_can_delete_repositories",
123+ "Repository admins can delete and transfer repositories",
124+ "Members with the Admin role on a repository can delete it, or transfer it to a workspace where they can create one. Off: owners only.",
125+ ],
126+ [
127+ "members_can_invite_outside_collaborators",
128+ "Repository admins can add outside collaborators",
129+ "Members with the Admin role on a repository can give a role on it to someone outside the workspace. Off: owners only.",
130+ ],
131+];
132+
133+/** The member privileges a workspace carries, by name. */
134+function pickPrivileges(workspace: Workspace): Partial<MemberPrivileges> {
135+ const picked: Partial<MemberPrivileges> = {};
136+ for (const [key] of PRIVILEGES) {
137+ const value = workspace[key];
138+ if (typeof value === "boolean") picked[key] = value;
139+ }
140+ return picked;
141+}
142+
143+type Answer = Route.ComponentProps["actionData"];
144+
145+/** What the last save of `intent` came to: saved, or why not. */
146+function outcome(answer: Answer, intent: string): { saved: boolean; error: string | null } {
147+ if (!answer || answer.intent !== intent) return { saved: false, error: null };
148+ return { saved: "saved" in answer && answer.saved === true, error: "error" in answer ? (answer.error ?? null) : null };
149+}
150+
151+/** Whether a save of `intent` is on its way. */
152+function useSaving(intent: string): boolean {
153+ const navigation = useNavigation();
154+ return navigation.state !== "idle" && navigation.formData?.get("intent") === intent;
155+}
156+
157+/** One part of g1t: its sections, under its name and what the part covers. */
158+function Part({ icon, title, about, children }: { icon: ReactNode; title: string; about: string; children: ReactNode }) {
159+ return (
160+ <Card asChild>
161+ <section aria-labelledby={`part-${title.toLowerCase().replace(/\W+/g, "-")}`}>
162+ <CardHeader className="border-b border-line">
163+ <CardTitle id={`part-${title.toLowerCase().replace(/\W+/g, "-")}`} className="flex items-center gap-2 text-base">
164+ <span className="text-faint [&>svg]:size-4">{icon}</span>
165+ {title}
166+ </CardTitle>
167+ <CardDescription>{about}</CardDescription>
168+ </CardHeader>
169+ <CardContent className="space-y-8 p-5">{children}</CardContent>
170+ </section>
171+ </Card>
172+ );
173+}
174+
175+/** A setting's heading inside a part, and a line on what it decides. */
176+function Setting({ title, about, soon, children }: { title: string; about: ReactNode; soon?: boolean; children: ReactNode }) {
177+ return (
178+ <div>
179+ <h3 className="flex items-center gap-2 text-sm font-medium">
180+ {title}
181+ {soon && <Badge>Soon</Badge>}
182+ </h3>
183+ <p className="mt-1 text-xs leading-relaxed text-faint">{about}</p>
184+ <div className="mt-3">{children}</div>
185+ </div>
186+ );
187+}
188+
189+/** Saved, or why not, beside a Save button. */
190+function SaveRow({ intent, changed, answer }: { intent: string; changed: boolean; answer: Answer }) {
191+ const saving = useSaving(intent);
192+ const { saved, error } = outcome(answer, intent);
193+ return (
194+ <div className="flex flex-wrap items-center gap-3">
195+ <SubmitButton variant="outline" size="sm" match={{ intent }} pending="Saving…" disabled={saving || !changed}>
196+ Save
197+ </SubmitButton>
198+ {saved && !changed && !error && (
199+ <p role="status" className="text-xs text-muted">
200+ Saved.
201+ </p>
202+ )}
203+ <ErrorText>{error}</ErrorText>
204+ </div>
205+ );
206+}
207+
208+/**
209+ * A setting g1t will have but does not yet: the control as it will be,
210+ * off, with the rule that holds today, so nobody looks for it elsewhere.
211+ */
212+function Stub({ title, about, today, control }: { title: string; about: string; today: string; control: ReactNode }) {
213+ return (
214+ <Setting title={title} about={about} soon>
215+ <div aria-disabled="true" className="opacity-70">
216+ {control}
217+ </div>
218+ <p className="mt-2 text-xs text-muted">
219+ <span className="font-medium text-fg-soft">Not configurable yet; today:</span> {today}
220+ </p>
221+ </Setting>
222+ );
223+}
224+
225+/** A who-can choice as a stub shows it: the rule that holds, chosen and locked. */
226+function LockedChoice({ name, options, value }: { name: string; options: { value: string; label: string; description?: string }[]; value: string }) {
227+ return (
228+ <RadioGroup name={name} value={value} disabled aria-label={name.replace(/_/g, " ")} className="grid gap-2 sm:grid-cols-2">
229+ {options.map((option) => (
230+ <RadioOption key={option.value} value={option.value} label={option.label} description={option.description} disabled />
231+ ))}
232+ </RadioGroup>
233+ );
234+}
235+
236+const MEMBERS_OR_OWNERS = [
237+ { value: "members", label: "Any member", description: "The default." },
238+ { value: "owners", label: "Owners only" },
239+];
240+
241+export default function WorkspacePermissions({ loaderData, actionData, params }: Route.ComponentProps) {
242+ const { slug, base, privileges, teamCreation, membersCreateAgents, channels } = loaderData;
243+ return (
244+ <div className="max-w-3xl space-y-6">
245+ <Part icon={<Code2 />} title="Code" about="What members may do with the workspace's repositories, beyond the role each repository gives them. Owners always can do all of it.">
246+ <BasePermissionSetting key={base} base={base} slug={params.owner} answer={actionData} />
247+ <PrivilegesSetting key={JSON.stringify(privileges)} privileges={privileges} answer={actionData} />
248+ <Stub
249+ title="Members can fork private repositories"
250+ about="Whether a member may make a fork of a private repository under their own name."
251+ today="g1t has no personal forks, so nothing is forked; work happens on branches of the repository itself."
252+ control={<SwitchCard title="Members can fork private repositories" checked={false} disabled />}
253+ />
254+ </Part>
255+
256+ <Part icon={<UsersRound />} title="People and teams" about="Who may shape the workspace's teams.">
257+ <TeamCreationSetting key={teamCreation} setting={teamCreation} answer={actionData} />
258+ </Part>
259+
260+ <Part icon={<MessagesSquare />} title="Chat" about="Who may open channels and bring agents into conversations. What a channel's own members may do, its default channels and emoji are on Chat settings.">
261+ {channels ? (
262+ <ChannelsSetting key={`${channels.public}:${channels.private}`} channels={channels} slug={slug} answer={actionData} />
263+ ) : (
264+ <Setting title="Creating channels" about="Who can create public and private channels.">
265+ <p className="text-sm text-muted">Chat didn't answer. Reload to try again.</p>
266+ </Setting>
267+ )}
268+ <Stub
269+ title="Who can add agents to a conversation"
270+ about="Who may invite a workspace agent into a channel or a group message."
271+ today="any member of a channel can invite an agent to it, the way they invite a person; a personal agent is never invited anywhere."
272+ control={<LockedChoice name="agents_in_channels" options={MEMBERS_OR_OWNERS} value="members" />}
273+ />
274+ <Stub
275+ title="Who can message agents directly"
276+ about="Who may open a direct message with a workspace agent."
277+ today="every member can message any workspace agent directly."
278+ control={<LockedChoice name="agent_dms" options={MEMBERS_OR_OWNERS} value="members" />}
279+ />
280+ </Part>
281+
282+ <Part icon={<Shapes />} title="Artifacts" about="Who may make the places documents live, and how a new one is shared.">
283+ <Stub
284+ title="Who can create spaces"
285+ about="Who may add a space beside the ones the workspace has."
286+ today="any member can create a space, from + beside Spaces in the Artifacts sidebar."
287+ control={<LockedChoice name="create_spaces" options={MEMBERS_OR_OWNERS} value="members" />}
288+ />
289+ <Stub
290+ title="Default sharing"
291+ about="Who a new artifact is shared with before its author chooses."
292+ today="a new artifact is its author's, shared with the people, agents and teams they add, or opened to the workspace from its Share menu."
293+ control={
294+ <LockedChoice
295+ name="default_sharing"
296+ options={[
297+ { value: "author", label: "Its author", description: "Shared only with whom they add." },
298+ { value: "workspace", label: "Everyone in the workspace" },
299+ ]}
300+ value="author"
301+ />
302+ }
303+ />
304+ </Part>
305+
306+ <Part icon={<Bot />} title="Agents" about="Who may bring agents into the workspace and give them money to spend.">
307+ {membersCreateAgents == null ? (
308+ <Setting title="Personal agents" about="Whether members who are not owners may create agents of their own.">
309+ <p className="text-sm text-muted">Agents didn't answer. Reload to try again.</p>
310+ </Setting>
311+ ) : (
312+ <PersonalAgentsSetting key={String(membersCreateAgents)} on={membersCreateAgents} slug={slug} answer={actionData} />
313+ )}
314+ <Stub
315+ title="Who can create workspace agents"
316+ about="Who may create an agent everyone in the workspace talks to and pays for."
317+ today="owners create workspace agents; a member's personal agent can be promoted to one by an owner."
318+ control={<LockedChoice name="workspace_agents" options={[{ value: "owners", label: "Owners only", description: "The rule today." }, { value: "members", label: "Any member" }]} value="owners" />}
319+ />
320+ <Stub
321+ title="Who can raise budgets"
322+ about="Who may raise the workspace's agent budget, a person's or a team's."
323+ today={`owners raise the agent budget and each person's; a team's maintainers raise their team's. Budgets are set on the Agents overview and under Spend.`}
324+ control={<LockedChoice name="raise_budgets" options={[{ value: "owners", label: "Owners and team maintainers", description: "The rule today." }, { value: "members", label: "Any member, their own" }]} value="owners" />}
325+ />
326+ </Part>
327+
328+ <Part icon={<Rocket />} title="Deployments and packages" about="Who may put code in front of people, and publish what others install.">
329+ <Stub
330+ title="Who can deploy to production"
331+ about="Who may deploy a project's production environment, and redeploy or take it down."
332+ today="anyone with Write on the project's repository redeploys or takes an app down; turning deployments on for a project takes Admin."
333+ control={<LockedChoice name="deploy_production" options={[{ value: "write", label: "Write on the repository", description: "The rule today." }, { value: "admin", label: "Admin on the repository" }, { value: "owners", label: "Owners only" }]} value="write" />}
334+ />
335+ <Stub
336+ title="Who can publish packages"
337+ about="Who may publish a new version of one of the workspace's packages."
338+ today="anyone with Write on the package's repository, or a token given write on packages, publishes new versions and tags."
339+ control={<LockedChoice name="publish_packages" options={[{ value: "write", label: "Write on the repository", description: "The rule today." }, { value: "admin", label: "Admin on the repository" }, { value: "owners", label: "Owners only" }]} value="write" />}
340+ />
341+ </Part>
342+
343+ <p className="text-xs text-faint">
344+ Each change is recorded in the{" "}
345+ <Link to={`/${slug}/-/audit`} className="underline underline-offset-4 hover:text-fg">
346+ audit log
347+ </Link>
348+ . Every other setting of the workspace is in this sidebar.
349+ </p>
350+ </div>
351+ );
352+}
353+
354+/** What members get on every repository, which owners choose. */
355+function BasePermissionSetting({ base, slug, answer }: { base: BasePermission; slug: string; answer: Answer }) {
356+ const [chosen, setChosen] = useState<BasePermission>(base);
357+ return (
358+ <Setting
359+ title="Base permission"
360+ about={`The role every member has on each of ${slug}'s repositories. Owners are Admins on all of them, and a role given to someone on one repository adds to this; it never takes away.`}
361+ >
362+ <Form method="post" className="space-y-3">
363+ <input type="hidden" name="intent" value="base-permission" />
364+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
365+ <Select name="base" value={chosen} onValueChange={(value) => setChosen(value as BasePermission)}>
366+ <SelectTrigger aria-label="Base permission" className="sm:max-w-56">
367+ <SelectValue />
368+ </SelectTrigger>
369+ <SelectContent>
370+ {BASE_PERMISSIONS.map((value) => (
371+ <SelectItem key={value} value={value} description={BASE_MEANS[value]}>
372+ {BASE_PERMISSION_LABELS[value]}
373+ </SelectItem>
374+ ))}
375+ </SelectContent>
376+ </Select>
377+ <p className="text-sm text-muted">{BASE_MEANS[chosen]}</p>
378+ </div>
379+ <SaveRow intent="base-permission" changed={chosen !== base} answer={answer} />
380+ </Form>
381+ </Setting>
382+ );
383+}
384+
385+/** What members may do: create repositories, and what repository admins may do. */
386+function PrivilegesSetting({ privileges, answer }: { privileges: MemberPrivileges; answer: Answer }) {
387+ const [chosen, setChosen] = useState<MemberPrivileges>(privileges);
388+ const changed = PRIVILEGES.some(([key]) => chosen[key] !== privileges[key]);
389+ return (
390+ <Setting title="Repositories" about="Creating repositories, and what a member with Admin on one may do with it.">
391+ <Form method="post" className="space-y-3">
392+ <input type="hidden" name="intent" value="member-privileges" />
393+ {PRIVILEGES.map(([key, title, about]) => (
394+ <SwitchCard key={key} name={key} title={title} checked={chosen[key]} onCheckedChange={(on) => setChosen((current) => ({ ...current, [key]: on }))}>
395+ {about}
396+ </SwitchCard>
397+ ))}
398+ <SaveRow intent="member-privileges" changed={changed} answer={answer} />
399+ </Form>
400+ </Setting>
401+ );
402+}
403+
404+/** Who may create the workspace's teams. Teams already made stay as they are, whoever made them. */
405+function TeamCreationSetting({ setting, answer }: { setting: TeamCreation; answer: Answer }) {
406+ const [choice, setChoice] = useState<TeamCreation>(setting);
407+ return (
408+ <Setting title="Who can create teams" about="Whoever creates a team becomes its first maintainer. Teams that already exist stay as they are.">
409+ <Form method="post" className="space-y-3">
410+ <input type="hidden" name="intent" value="team-creation" />
411+ <RadioGroup name="team_creation" value={choice} onValueChange={(value) => setChoice(value as TeamCreation)} aria-label="Who can create teams" className="grid gap-2 sm:grid-cols-2">
412+ <RadioOption value="members" label="Any member" description="Every member with a confirmed email address. The default." />
413+ <RadioOption value="owners" label="Owners only" description="Members ask an owner to create a team; maintainers still manage their own." />
414+ </RadioGroup>
415+ <SaveRow intent="team-creation" changed={choice !== setting} answer={answer} />
416+ </Form>
417+ </Setting>
418+ );
419+}
420+
421+/** Who may create public and private channels, saved by the chat service. */
422+function ChannelsSetting({ channels, slug, answer }: { channels: { public: ChatAllowed; private: ChatAllowed }; slug: string; answer: Answer }) {
423+ const [chosen, setChosen] = useState(channels);
424+ const changed = chosen.public !== channels.public || chosen.private !== channels.private;
425+ return (
426+ <Setting
427+ title="Creating channels"
428+ about={
429+ <>
430+ Whoever creates a channel becomes its first owner. Renaming, archiving, emoji and default channels are on{" "}
431+ <Link to={`/${slug}/-/settings/chat`} className="underline underline-offset-4 hover:text-fg">
432+ Chat settings
433+ </Link>
434+ .
435+ </>
436+ }
437+ >
438+ <Form method="post" className="space-y-3">
439+ <input type="hidden" name="intent" value="chat-channels" />
440+ <div className="grid gap-4 sm:grid-cols-2">
441+ <div>
442+ <h4 className="mb-2 text-xs font-medium text-fg-soft">Public channels</h4>
443+ <RadioGroup name="public_channels" value={chosen.public} onValueChange={(value) => setChosen((now) => ({ ...now, public: value as ChatAllowed }))} aria-label="Who can create public channels">
444+ <RadioOption value="members" label="Any member" description="The default." />
445+ <RadioOption value="owners" label="Owners only" description="Members join and read public channels, but ask an owner for a new one." />
446+ </RadioGroup>
447+ </div>
448+ <div>
449+ <h4 className="mb-2 text-xs font-medium text-fg-soft">Private channels</h4>
450+ <RadioGroup name="private_channels" value={chosen.private} onValueChange={(value) => setChosen((now) => ({ ...now, private: value as ChatAllowed }))} aria-label="Who can create private channels">
451+ <RadioOption value="members" label="Any member" description="The default." />
452+ <RadioOption value="owners" label="Owners only" description="Only owners can make a channel that is kept to the people invited." />
453+ </RadioGroup>
454+ </div>
455+ </div>
456+ <SaveRow intent="chat-channels" changed={changed} answer={answer} />
457+ </Form>
458+ </Setting>
459+ );
460+}
461+
462+/** Whether members create personal agents: on unless an owner turns it off. */
463+function PersonalAgentsSetting({ on, slug, answer }: { on: boolean; slug: string; answer: Answer }) {
464+ const [checked, setChecked] = useState(on);
465+ return (
466+ <Setting
467+ title="Personal agents"
468+ about={
469+ <>
470+ A member's own agent: only they talk to it, in their direct message with it, and it spends from their budget. Owners can promote one to a workspace agent from its profile; budgets are on the{" "}
471+ <Link to={`/${slug}/-/agents`} className="underline underline-offset-4 hover:text-fg">
472+ Agents overview
473+ </Link>
474+ .
475+ </>
476+ }
477+ >
478+ <Form method="post" className="space-y-3">
479+ <input type="hidden" name="intent" value="personal-agents" />
480+ <SwitchCard title="Members can create personal agents" name="members_create_agents" checked={checked} onCheckedChange={setChecked}>
481+ {checked
482+ ? "On: anyone in the workspace can describe an agent and make it theirs. Owners always can."
483+ : "Off: only owners create agents. Personal agents members already have keep working."}
484+ </SwitchCard>
485+ <SaveRow intent="personal-agents" changed={checked !== on} answer={answer} />
486+ </Form>
487+ </Setting>
488+ );
489+}
+11−150
22 import { Form, Link, data, redirect, useFetcher, useNavigation } from "react-router";
33
44 import {
5− DEFAULT_MEMBER_PRIVILEGES,
65 type DataResidency,
7− type MemberPrivileges,
86 RENAME_COOLDOWN_HOURS,
97 SLUG_HOLD_DAYS,
10− type TeamCreation,
118 WORKSPACE_RESTORE_DAYS,
129 type Workspace,
1310 type WorkspaceDeletion,
9592 apps,
9693 euAvailable: storage.euAvailable,
9794 residency: residency ?? "anywhere",
98− privileges: { ...DEFAULT_MEMBER_PRIVILEGES, ...pickPrivileges(workspace) },
9995 withoutTwoFactor: without,
10096 ownTwoFactor: twoFactor?.ok ? twoFactor.value.enabled : false,
10197 };
135131 const result = await identity.setWorkspaceResidency(user, params.owner, wanted);
136132 if (!result.ok) return { residencyError: result.error.message };
137133 return { saved: "residency" as const };
138− }
139− // Member privileges: identity checks the owner. A switch posts only when on.
140− if (intent === "member-privileges") {
141− const change = Object.fromEntries(PRIVILEGES.map(([key]) => [key, form.get(key) === "on"])) as MemberPrivileges;
142− const result = await identity.setMemberPrivileges(user, params.owner, change);
143− if (!result.ok) return { privilegesError: result.error.message };
144− return { saved: "member-privileges" as const };
145134 }
146135 // Requiring two-factor authentication: identity checks the owner has it.
147136 if (intent === "two-factor-requirement") {
149138 if (!result.ok) return { twoFactorError: result.error.message };
150139 return { saved: "two-factor-requirement" as const };
151140 }
152− // Who may create teams: identity checks the owner.
153− if (intent === "team-creation") {
154− const wanted: TeamCreation = form.get("teamCreation") === "owners" ? "owners" : "members";
155− const result = await identity.setTeamCreation(user, params.owner, wanted);
156− if (!result.ok) return { teamCreationError: result.error.message };
157− return { saved: "team-creation" as const };
158− }
159141 if (intent === "rename") {
160142 const newSlug = String(form.get("newSlug") ?? "").trim().toLowerCase();
161143 const result = await identity.renameWorkspace(user, params.owner, newSlug);
214196 error={actionData && "renameError" in actionData ? actionData.renameError : undefined}
215197 />
216198
217− <MemberPrivilegesSection
218− key={JSON.stringify(loaderData.privileges)}
219− privileges={loaderData.privileges}
220− saved={Boolean(actionData && "saved" in actionData && actionData.saved === "member-privileges")}
221− error={actionData && "privilegesError" in actionData ? actionData.privilegesError : undefined}
222− />
223−
224199 <TwoFactorRequirementSection
225200 key={String(workspace.twoFactorRequirementEnabled ?? false)}
226201 slug={workspace.slug}
231206 error={actionData && "twoFactorError" in actionData ? actionData.twoFactorError : undefined}
232207 />
233208
234− <TeamCreationSection
235− // Starts from the saved choice whenever it changes.
236− key={workspace.teamCreation ?? "members"}
237− setting={workspace.teamCreation ?? "members"}
238− saved={Boolean(actionData && "saved" in actionData && actionData.saved === "team-creation")}
239− error={actionData && "teamCreationError" in actionData ? actionData.teamCreationError : undefined}
240− />
209+ <section>
210+ <h2 className="font-medium">Permissions</h2>
211+ <p className="mt-1.5 text-xs text-faint">
212+ Who may do what in {workspace.slug}: the base permission on its repositories, creating repositories and teams, channels, agents and more are
213+ under{" "}
214+ <Link to={`/${workspace.slug}/-/permissions`} className="text-muted underline underline-offset-4 hover:text-fg">
215+ Permissions
216+ </Link>
217+ .
218+ </p>
219+ </section>
241220
242221 {(loaderData.euAvailable || loaderData.residency === "eu") && (
243222 <ResidencySection
261240 </div>
262241 );
263242 }
264−
265−/** The member privileges, in the order Settings shows them, with what each lets members do. */
266−const PRIVILEGES: [keyof MemberPrivileges, string, string][] = [
267− [
268− "members_can_create_public_repositories",
269− "Members can create public repositories",
270− "Owners always can. Off: members ask an owner.",
271− ],
272− [
273− "members_can_create_private_repositories",
274− "Members can create private repositories",
275− "Owners always can. Whoever creates a repository is its Admin.",
276− ],
277− [
278− "members_can_change_repo_visibility",
279− "Repository admins can change visibility",
280− "Members with the Admin role on a repository can make it public or private. Off: owners only.",
281− ],
282− [
283− "members_can_delete_repositories",
284− "Repository admins can delete and transfer repositories",
285− "Members with the Admin role on a repository can delete it, or transfer it to a workspace where they can create one. Off: owners only.",
286− ],
287− [
288− "members_can_invite_outside_collaborators",
289− "Repository admins can add outside collaborators",
290− "Members with the Admin role on a repository can give a role on it to someone outside the workspace. Off: owners only.",
291− ],
292−];
293243
294−/** The member privileges a workspace carries, by name. */
295−function pickPrivileges(workspace: Workspace): Partial<MemberPrivileges> {
296− const picked: Partial<MemberPrivileges> = {};
297− for (const [key] of PRIVILEGES) {
298− const value = workspace[key];
299− if (typeof value === "boolean") picked[key] = value;
300− }
301− return picked;
302−}
303−
304−/** What members may do: create repositories, and what repository admins may do. */
305−function MemberPrivilegesSection({ privileges, saved, error }: { privileges: MemberPrivileges; saved: boolean; error?: string }) {
306− const [chosen, setChosen] = useState<MemberPrivileges>(privileges);
307− const navigation = useNavigation();
308− const saving = navigation.state !== "idle" && navigation.formData?.get("intent") === "member-privileges";
309− const changed = PRIVILEGES.some(([key]) => chosen[key] !== privileges[key]);
310− return (
311− <section id="member-privileges" className="scroll-mt-20">
312− <h2 className="font-medium">Member privileges</h2>
313− <p className="mt-1.5 text-xs text-faint">
314− What members can do beyond their role on each repository. Owners can always do all of it.
315− </p>
316− <Form method="post" className="mt-5 space-y-3">
317− <input type="hidden" name="intent" value="member-privileges" />
318− {PRIVILEGES.map(([key, title, about]) => (
319− <SwitchCard
320− key={key}
321− name={key}
322− title={title}
323− checked={chosen[key]}
324− onCheckedChange={(on) => setChosen((current) => ({ ...current, [key]: on }))}
325− >
326− {about}
327− </SwitchCard>
328− ))}
329− <ErrorText>{error}</ErrorText>
330− {saved && !error && !changed && (
331− <p role="status" className="text-xs text-muted">
332− Saved.
333− </p>
334− )}
335− <Button type="submit" disabled={saving || !changed}>
336− Save
337− </Button>
338− </Form>
339− </section>
340− );
341−}
342−
343244 /** Requiring two-factor authentication of everyone with access. */
344245 function TwoFactorRequirementSection({
345246 slug,
396297 </p>
397298 )}
398299 <Button type="submit" disabled={saving || chosen === required}>
399− Save
400− </Button>
401− </Form>
402− </section>
403− );
404−}
405−
406−/**
407− * Who may create the workspace's teams. Teams already made stay as they
408− * are, whoever made them.
409− */
410−function TeamCreationSection({ setting, saved, error }: { setting: TeamCreation; saved: boolean; error?: string }) {
411− const [choice, setChoice] = useState<TeamCreation>(setting);
412− const navigation = useNavigation();
413− const saving = navigation.state !== "idle" && navigation.formData?.get("intent") === "team-creation";
414− return (
415− <section>
416− <h2 className="font-medium">Teams</h2>
417− <p className="mt-1.5 text-xs text-faint">
418− Who can create teams in the workspace. Whoever creates one becomes its first maintainer. Teams that already
419− exist stay as they are.
420− </p>
421− <Form method="post" className="mt-5 space-y-4">
422− <input type="hidden" name="intent" value="team-creation" />
423− <RadioGroup
424− name="teamCreation"
425− value={choice}
426− onValueChange={(value) => setChoice(value as TeamCreation)}
427− aria-label="Who can create teams"
428− >
429− <RadioOption value="members" label="Any member" description="Every member with a confirmed email address. The default." />
430− <RadioOption value="owners" label="Owners only" description="Members ask an owner to create a team; maintainers still manage their own." />
431− </RadioGroup>
432− <ErrorText>{error}</ErrorText>
433− {saved && !error && (
434− <p role="status" className="text-xs text-muted">
435− Saved.
436− </p>
437− )}
438− <Button type="submit" disabled={saving || choice === setting}>
439300 Save
440301 </Button>
441302 </Form>
+13−0
15391539 /// The role they join with; member when absent.
15401540 #[serde(default)]
15411541 pub role: Option<crate::Role>,
1542+ /// A note from whoever invites, quoted in the invitation email. Up to
1543+ /// [`MAX_INVITE_MESSAGE`] characters; longer is cut, blank is none.
1544+ #[serde(default)]
1545+ pub message: Option<String>,
15421546 /// Where the request came in, for the audit log; g1t.sh when absent.
15431547 #[serde(default)]
15441548 pub surface: Option<crate::audit::Surface>,
15451549 }
15461550
1551+/// The most characters an invitation's note keeps.
1552+pub const MAX_INVITE_MESSAGE: usize = 500;
1553+
15471554 /// A workspace invitation waiting for its person's answer, as they see it.
15481555 /// `list_invitations` (takes `UserArgs`) returns `Vec<WorkspaceInvitation>`,
15491556 /// newest first: pending ones only, never expired, revoked or answered.
15911598 /// newest first. Owners only. Returns `Outcome<Vec<Invite>>`.
15921599 ///
15931600 /// `revoke_workspace_invite`: owners only. Returns `Outcome<Invite>`.
1601+///
1602+/// `resend_workspace_invite`: owners only. Sends a pending invitation
1603+/// again, by email to the address it is bound to or the invited account's
1604+/// confirmed address, and again in that account's inbox. The invitation
1605+/// itself is unchanged: same code, same day it stops working. Returns
1606+/// `Outcome<Invite>`; one that is not pending answers `409`.
15941607 #[derive(Debug, Serialize, Deserialize)]
15951608 pub struct WorkspaceInviteArgs {
15961609 pub actor: User,
+2−1
160160 }),
161161 acceptInvite: (user, code) => call("accept_invite", { user, code }),
162162 inviteMember: (actor, slug, who) =>
163− call("invite_member", { actor, slug, email: who.email ?? "", username: who.username ?? null, role: who.role ?? null }),
163+ call("invite_member", { actor, slug, email: who.email ?? "", username: who.username ?? null, role: who.role ?? null, message: who.message ?? null }),
164164 listInvitations: (user) => call("list_invitations", { user }),
165165 acceptInvitation: (user, id) => call("accept_invitation", { user, id, surface: "web" }),
166166 declineInvitation: (user, id) => call("decline_invitation", { user, id, surface: "web" }),
167167 findPeople: (query, limit) => call("find_people", { query, limit: limit ?? null }),
168168 workspaceInvites: (slug, viewer) => call("workspace_invites", { slug, viewer }),
169169 revokeWorkspaceInvite: (actor, slug, id) => call("revoke_workspace_invite", { actor, slug, id }),
170+ resendWorkspaceInvite: (actor, slug, id) => call("resend_workspace_invite", { actor, slug, id }),
170171 requestAccess: (email, about, client) => call("request_access", { email, about, client: client ?? null }),
171172 userForGitCredentials: (username, secret) =>
172173 call("user_for_git_credentials", { username, secret }),
+5−1
227227 export const INVITES_PER_USER = 5;
228228 /** How long an invite works, unless identity's `INVITE_TTL_DAYS` says otherwise. */
229229 export const INVITE_TTL_DAYS = 30;
230+/** The most characters an invitation's note keeps (`invite_member`'s `message`). */
231+export const MAX_INVITE_MESSAGE = 500;
230232
231233 /** Only a pending invite can be used or revoked. Revoked and expired ones never used give the invite back. */
232234 /**
992994 inviteMember(
993995 actor: User,
994996 slug: string,
995− who: { email?: string | null; username?: string | null; role?: Role | null },
997+ who: { email?: string | null; username?: string | null; role?: Role | null; message?: string | null },
996998 ): Promise<Result<Invite>>;
997999 /** The workspace invitations waiting for the person's answer, newest first. */
9981000 listInvitations(user: User): Promise<WorkspaceInvitation[]>;
10061008 workspaceInvites(slug: string, viewer: Viewer): Promise<Result<Invite[]>>;
10071009 /** Owners only. */
10081010 revokeWorkspaceInvite(actor: User, slug: string, id: string): Promise<Result<Invite>>;
1011+ /** Owners only: sends a pending invitation again, by email and in the invited account's inbox. Unchanged otherwise. */
1012+ resendWorkspaceInvite(actor: User, slug: string, id: string): Promise<Result<Invite>>;
10091013 /** Someone without an invite asks for one. Always the same answer for a valid address. */
10101014 requestAccess(email: string, about: string, client?: string | null): Promise<Result<boolean>>;
10111015
+105−2
179179 }
180180 }
181181
182+/// Whether an invitation can be sent again: only while it waits to be
183+/// used. One whose account is confirming its address or answering already
184+/// has what it needs; the rest are over.
185+pub fn resendable(status: InviteStatus) -> bool {
186+ status == InviteStatus::Pending
187+}
188+
189+/// The note an inviter wrote, as the email quotes it: trimmed and cut to
190+/// [`MAX_INVITE_MESSAGE`] characters; none when blank.
191+pub fn invite_note(message: Option<&str>) -> Option<String> {
192+ let note: String = message?.trim().chars().take(MAX_INVITE_MESSAGE).collect();
193+ (!note.is_empty()).then_some(note)
194+}
195+
182196 /// Where an invite stands once the workspace invitation in it is counted:
183197 /// `base` from [`status_of`]. A declined one is declined; an account
184198 /// invite whose account is confirmed but has not answered the workspace it
17051719 return Ok(refused);
17061720 }
17071721 let surface = a.surface.unwrap_or(Surface::Web);
1722+ // A note from the inviter goes in the email, cut to its limit.
1723+ let note = invite_note(a.message.as_deref());
17081724 // Someone on g1t, by username: an invitation to accept or decline
17091725 // (invites/invitations.rs).
17101726 let Some(email) = email else {
17111727 let username = username.unwrap_or_default();
1712− return self.invite_account(&a.actor, &slug, &workspace_id, &username, role, surface).await;
1728+ return self.invite_account(&a.actor, &slug, &workspace_id, &username, role, note.as_deref(), surface).await;
17131729 };
17141730 if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? {
17151731 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
17871803 if let Some(code) = &invite.code {
17881804 let from = self.display_name(&a.actor).await;
17891805 let workspace = self.workspace_name(&workspace_id, &slug).await;
1790− self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, &invite.id, None).await;
1806+ self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, &invite.id, note.as_deref()).await;
17911807 }
17921808 // Someone on g1t hears of it in their inbox too.
17931809 if invitee.is_some()
18751891 self.revoke_invite(RemoveArgs { user: a.actor, id: a.id }).await
18761892 }
18771893
1894+ /// Sends one of the workspace's pending invitations again: the same
1895+ /// email to the address it is bound to, or to the invited account's
1896+ /// confirmed address, and the inbox notice again for an account. The
1897+ /// invitation itself does not change. Counted against the owner's
1898+ /// hourly allowance of invites made, so a list cannot be used to flood
1899+ /// an inbox.
1900+ pub async fn resend_workspace_invite(&self, a: WorkspaceInviteArgs) -> Result<Outcome<Invite>> {
1901+ let slug = a.slug.trim().to_lowercase();
1902+ if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
1903+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can send a workspace's invitations again."));
1904+ }
1905+ let row = self.invite_by_id(a.id.trim()).await?.filter(|row| row.workspace.as_deref() == Some(slug.as_str()));
1906+ let Some(row) = row else {
1907+ return Ok(Outcome::fail(FailureCode::NotFound, "There is no invitation to this workspace with that id."));
1908+ };
1909+ let now = rfc3339(now_ms());
1910+ if !resendable(row.status(&now)) {
1911+ return Ok(Outcome::fail(FailureCode::Conflict, "Only a pending invitation can be sent again."));
1912+ }
1913+ if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? {
1914+ return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1915+ }
1916+ let Some(workspace_id) = row.workspace_id.as_deref() else {
1917+ return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1918+ };
1919+ let code = row.sealed_code.as_deref().and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id));
1920+ // Where it goes: the address it is bound to, else the invited
1921+ // account's confirmed address. An account invite (one that also
1922+ // makes the account) carries the proof the link came from its email.
1923+ let to = match &row.email {
1924+ Some(email) => Some(email.clone()),
1925+ None => match &row.invitee_id {
1926+ Some(invitee) => self.verified_email_of(invitee).await?,
1927+ None => None,
1928+ },
1929+ };
1930+ if let (Some(to), Some(code)) = (&to, &code) {
1931+ let from = self.display_name(&a.actor).await;
1932+ let workspace = self.workspace_name(workspace_id, &slug).await;
1933+ self.send_invite_email(to, Some(&from), Some(&workspace), row.kind == "workspace", code, &row.id, None).await;
1934+ }
1935+ if let Some(username) = row.invitee.as_deref().filter(|_| row.email.is_none()) {
1936+ self.invitation_sent(&row, username).await;
1937+ }
1938+ self.audit_invites(&a.actor, "invite.resent", vec![slug.clone()], Surface::Web, format!("Sent invite {} again", row.hint)).await;
1939+ Ok(Outcome::Ok(self.shown(row, true, false)))
1940+ }
1941+
1942+ /// The confirmed primary address of the account `user_id`, if it has one.
1943+ async fn verified_email_of(&self, user_id: &str) -> Result<Option<String>> {
1944+ #[derive(Deserialize)]
1945+ struct Address {
1946+ email: Option<String>,
1947+ }
1948+ Ok(self
1949+ .db
1950+ .prepare("SELECT email FROM users WHERE id = ? AND email_verified_at IS NOT NULL AND deleted_at IS NULL")
1951+ .bind(&[user_id.into()])?
1952+ .first::<Address>(None)
1953+ .await?
1954+ .and_then(|row| row.email))
1955+ }
1956+
18781957 // --- The waitlist ---
18791958
18801959 pub async fn request_access(&self, a: RequestAccessArgs) -> Result<Outcome<bool>> {
24492528 }
24502529
24512530 #[test]
2531+ fn only_a_pending_invitation_is_sent_again() {
2532+ assert!(resendable(InviteStatus::Pending));
2533+ for over in [
2534+ InviteStatus::AwaitingConfirmation,
2535+ InviteStatus::AwaitingAnswer,
2536+ InviteStatus::Redeemed,
2537+ InviteStatus::Declined,
2538+ InviteStatus::Expired,
2539+ InviteStatus::Revoked,
2540+ ] {
2541+ assert!(!resendable(over), "{over:?}");
2542+ }
2543+ }
2544+
2545+ #[test]
2546+ fn an_inviters_note_is_trimmed_cut_and_dropped_when_blank() {
2547+ assert_eq!(invite_note(None), None);
2548+ assert_eq!(invite_note(Some(" ")), None);
2549+ assert_eq!(invite_note(Some(" Welcome aboard ")).as_deref(), Some("Welcome aboard"));
2550+ let long = "x".repeat(MAX_INVITE_MESSAGE + 40);
2551+ assert_eq!(invite_note(Some(&long)).map(|note| note.chars().count()), Some(MAX_INVITE_MESSAGE));
2552+ }
2553+
2554+ #[test]
24522555 fn codes_are_not_repeated() {
24532556 let codes: std::collections::HashSet<String> = (0..2000).map(|_| new_code_body()).collect();
24542557 assert_eq!(codes.len(), 2000);
+2−1
183183 workspace_id: &str,
184184 username: &str,
185185 role: Role,
186+ note: Option<&str>,
186187 surface: Surface,
187188 ) -> Result<Outcome<Invite>> {
188189 let person = self
247248 if let (Some(email), true, Some(code)) = (&person.email, person.verified != 0, &invite.code) {
248249 let from = self.display_name(actor).await;
249250 let workspace = self.workspace_name(workspace_id, slug).await;
250− self.send_invite_email(email, Some(&from), Some(&workspace), true, code, &invite.id, None).await;
251+ self.send_invite_email(email, Some(&from), Some(&workspace), true, code, &invite.id, note).await;
251252 }
252253 if let Some(row) = self.invite_by_id(&invite.id).await? {
253254 self.invitation_sent(&row, &person.username).await;
+1−0
10451045 "find_people" => reply(&identity.find_people(args(body)?).await?),
10461046 "workspace_invites" => reply(&identity.workspace_invites(args(body)?).await?),
10471047 "revoke_workspace_invite" => reply(&identity.revoke_workspace_invite(args(body)?).await?),
1048+ "resend_workspace_invite" => reply(&identity.resend_workspace_invite(args(body)?).await?),
10481049 "request_access" => reply(&identity.request_access(args(body)?).await?),
10491050 // Who has access to a repository; see access.rs.
10501051 "repo_access" => reply(&identity.repo_access(args(body)?).await?),
+1−0
421421 email: String::new(),
422422 username: Some(user.username),
423423 role: None,
424+ message: None,
424425 surface: a.surface,
425426 })
426427 .await?;