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.
30 files+2121−7410/30 viewed
| 179 | 179 | { label: 'Mirroring', slug: 'guides/mirroring' }, | |
| 180 | 180 | { label: 'Workspaces and tokens', slug: 'guides/workspaces' }, | |
| 181 | 181 | { label: 'Access and roles', slug: 'guides/access-and-roles' }, | |
| 182 | + | { label: 'Permissions', slug: 'guides/permissions' }, | |
| 182 | 183 | { label: 'Teams', slug: 'guides/teams' }, | |
| 183 | 184 | { label: 'People and teams', slug: 'guides/people-and-teams' }, | |
| 184 | 185 | { label: 'Managing a repository', slug: 'guides/managing-repositories' }, |
| 147 | 147 | ||
| 148 | 148 | Owners always have Admin, whatever it says. Only owners can change it: | |
| 149 | 149 | ||
| 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**. | |
| 152 | 152 | ||
| 153 | + | Every other who-may-do-what setting is on the same page: see | |
| 154 | + | [permissions](/guides/permissions/). | |
| 155 | + | ||
| 153 | 156 | It takes effect on everyone's next request. To give one member more on | |
| 154 | 157 | one repository, give them a role there; to give them less, lower the base | |
| 155 | 158 | permission and give roles to the people who need them. Whoever creates a | |
| ⋯ | |||
| 253 | 256 | ||
| 254 | 257 | Owners see every outside collaborator, and the repositories and roles each | |
| 255 | 258 | 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 | |
| 257 | 260 | workspace as a member (see [add people](/guides/workspaces/#add-people)); | |
| 258 | 261 | once they accept, the roles they have stay, and the base permission adds | |
| 259 | 262 | to them. | |
| 128 | 128 | ||
| 129 | 129 | | | Invite to g1t | Invite to a workspace | | |
| 130 | 130 | | --- | --- | --- | | |
| 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 | | |
| 132 | 132 | | 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 | | |
| 133 | 133 | | Someone without an account | Makes their account with it | Makes their account with it too, while g1t is invite-only, then answers the invitation | | |
| 134 | 134 | | Someone already on g1t | Nothing: they have an account | The invitation, in their notifications and by email | | |
| ⋯ | |||
| 229 | 229 | - **It makes your own account.** Each person who uses it gets a new | |
| 230 | 230 | account, and then makes their own workspace. It does not add you to | |
| 231 | 231 | 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. | |
| 233 | 233 | - **It may be for some email domains only.** When it is, the email field | |
| 234 | 234 | says which, such as `example.com`, and sign-up takes only an address | |
| 235 | 235 | there. Use your address at that organization; you confirm it like any | |
| ⋯ | |||
| 270 | 270 | are in. | |
| 271 | 271 | ||
| 272 | 272 | 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, | |
| 274 | 274 | `g1t.sh/<workspace>/-/members`, instead (see | |
| 275 | 275 | [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. | |
| 278 | 277 | ||
| 279 | 278 | Once anyone can sign up for g1t, there are no invites to g1t to make: | |
| 280 | 279 | Settings → Invites keeps only the list of invites you already made, and | |
| 281 | 280 | 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 | |
| 283 | 282 | in your settings or account menu. | |
| 284 | 283 | ||
| 285 | 284 | Each person can have **5** invites out at a time. Pending and used invites | |
| ⋯ | |||
| 317 | 316 | - **Decline** joins nothing; whoever invited you is told in their notifications. | |
| 318 | 317 | ||
| 319 | 318 | 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 | |
| 322 | 322 | people, so an invitation to one cannot be accepted until it starts the | |
| 323 | 323 | plan. Only you can answer your invitations: an agent's token and a | |
| 324 | 324 | workspace's token cannot. | |
| ⋯ | |||
| 759 | 759 | | SSH keys | Settings → SSH keys | | |
| 760 | 760 | | Applications you signed in to, and signing in with GitHub | Settings → Connected applications, Settings → GitHub | | |
| 761 | 761 | | 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 | | |
| 763 | 763 | | Payment methods: the billing portal, adding a card, subscribing and buying AI credit | A workspace's Billing | | |
| 764 | 764 | ||
| 765 | 765 | ### Permissions | |
| 551 | 551 | ## Workspace chat settings | |
| 552 | 552 | ||
| 553 | 553 | 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`). | |
| 555 | 555 | Every member can open the page to see what is allowed; only owners can | |
| 556 | 556 | change it. g1t enforces each setting, whichever app or API the request | |
| 557 | 557 | comes from. | |
| 558 | 558 | ||
| 559 | 559 | | Setting | Choices | | |
| 560 | 560 | | --- | --- | | |
| 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. | | |
| 563 | 562 | | **Renaming and archiving** | **Channel owners and workspace owners** (the default) or **Workspace owners only**. Any member of a channel can still change its topic. | | |
| 564 | 563 | | **Custom emoji** | Who can add them: **Any member** (the default) or **Owners only**. | | |
| 565 | 564 | | **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. | |
| 14 | 14 | | **Everyone** | `g1t.sh/<workspace>/-/people` | The [directory](#the-directory) of the workspace's people. | | |
| 15 | 15 | | **Teams** | `g1t.sh/<workspace>/-/teams` | The workspace's [teams](/guides/teams/). | | |
| 16 | 16 | | **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). | | |
| 18 | 17 | ||
| 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). | |
| 20 | 22 | ||
| 21 | 23 | ## The directory | |
| 22 | 24 | ||
| ⋯ | |||
| 45 | 47 | its people (see [agents on a team](/guides/teams/#agents-on-a-team)). | |
| 46 | 48 | ||
| 47 | 49 | **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. | |
| 49 | 52 | ||
| 50 | 53 | ## A person's profile | |
| 51 | 54 | ||
| ⋯ | |||
| 207 | 210 | When a person is needed: Priya Shah (@priya), who is online now. | |
| 208 | 211 | ``` | |
| 209 | 212 | ||
| 210 | − | ## Members and invites | |
| 213 | + | ## Members, invitations and permissions | |
| 211 | 214 | ||
| 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/). | | |
| 218 | 223 | ||
| 219 | 224 | ## Next | |
| 220 | 225 | ||
| 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. |
| 54 | 54 | ||
| 55 | 55 | ### Who can create teams | |
| 56 | 56 | ||
| 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: | |
| 59 | 60 | ||
| 60 | 61 | | Who can create teams | | | |
| 61 | 62 | | --- | --- | |
| 76 | 76 | waits, and works once the plan is on). | |
| 77 | 77 | ||
| 78 | 78 | 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 | |
| 80 | 80 | **Settings → Access**, an owner sees **Start the plan to invite people** | |
| 81 | 81 | with a button to the plan. Through the API and MCP, adding a member, | |
| 82 | 82 | inviting, adding an outside collaborator and accepting are refused with |
| 1 | 1 | --- | |
| 2 | 2 | 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. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | A workspace owns repositories and is the first part of their address: | |
| ⋯ | |||
| 303 | 303 | ||
| 304 | 304 | ### Change someone's role | |
| 305 | 305 | ||
| 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: | |
| 308 | 308 | ||
| 309 | 309 | 1. **Make owner** or **Make member** changes their role. | |
| 310 | 310 | 2. **Billing manager** and **Security manager** turn each role on or off. | |
| ⋯ | |||
| 315 | 315 | repositories and their place in its teams go too. | |
| 316 | 316 | ||
| 317 | 317 | 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. | |
| 319 | 323 | ||
| 320 | 324 | The last owner cannot be made a member, removed, or leave: make someone | |
| 321 | 325 | else an owner first, or [delete the workspace](#delete-a-workspace). | |
| 322 | 326 | ||
| 323 | 327 | ### Leave a workspace | |
| 324 | 328 | ||
| 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 | |
| 327 | 331 | and your place in its teams go with you at once. The only owner cannot | |
| 328 | 332 | leave. | |
| 329 | 333 | ||
| ⋯ | |||
| 338 | 342 | ||
| 339 | 343 | | | Invite to a workspace | Invite to g1t | | |
| 340 | 344 | | --- | --- | --- | | |
| 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) | | |
| 342 | 346 | | What they get | An invitation to join the workspace, to accept or decline | One new account, in no workspace but its own | | |
| 343 | 347 | | Without an account | The invitation lets them sign up first, while g1t is invite-only | It lets them sign up | | |
| 344 | 348 | | When | Always | Only while g1t is invite-only | | |
| 345 | 349 | ||
| 346 | 350 | 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). | |
| 349 | 352 | ||
| 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: | |
| 352 | 356 | ||
| 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 | |
| 355 | 360 | 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. | |
| 359 | 367 | ||
| 360 | 368 | - **By username**: they get a | |
| 361 | 369 | [workspace invitation](/guides/authentication/#workspace-invitations) in | |
| ⋯ | |||
| 380 | 388 | the workspace, with a one-time welcome. See | |
| 381 | 389 | [using an invite](/guides/authentication/#using-an-invite). | |
| 382 | 390 | ||
| 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. | |
| 388 | 413 | Through the API, use | |
| 389 | 414 | [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/) | |
| 390 | 415 | with a `username` or an `email` and a `role` (the `workspace` tool's | |
| ⋯ | |||
| 398 | 423 | **A free workspace cannot add people.** Until it starts the g1t plan, it | |
| 399 | 424 | cannot add members, send invites, or invite outside collaborators, and an | |
| 400 | 425 | 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 | |
| 403 | 428 | [who a free workspace can add](/guides/usage-and-billing/#who-a-free-workspace-can-add). | |
| 404 | 429 | ||
| 405 | 430 | ### Members through the API | |
| ⋯ | |||
| 424 | 449 | ## Member privileges | |
| 425 | 450 | ||
| 426 | 451 | 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. | |
| 430 | 455 | ||
| 431 | 456 | | Setting | Default | When on | | |
| 432 | 457 | | --- | --- | --- | | |
| ⋯ | |||
| 454 | 479 | on. | |
| 455 | 480 | ||
| 456 | 481 | 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. | |
| 460 | 485 | 3. Turn on **Require two-factor authentication** and choose **Save**. | |
| 461 | 486 | ||
| 462 | 487 | From then on, someone without it keeps their place but cannot use the | |
| ⋯ | |||
| 554 | 579 | | **Agents** | The workspace's agents and their sessions. See [agents](/guides/agents/). | | |
| 555 | 580 | | **Code** | Projects, pull requests and checks. Not shown to a member without [Code access](/guides/agent-access/). | | |
| 556 | 581 | | **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). | | |
| 559 | 584 | ||
| 560 | 585 | The app you are in sits on a filled square. Your account is the | |
| 561 | 586 | avatar at the foot: your status, your profile and settings, | |
| ⋯ | |||
| 604 | 629 | extensions are published. An app you can't use shows **Request access**. | |
| 605 | 630 | The built-in apps' own pages are in their sidebars, not in Apps: Code's has | |
| 606 | 631 | **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)). | |
| 610 | 635 | ||
| 611 | 636 | A pinned app shows as its mark under the built-in ones, in the order you | |
| 612 | 637 | pinned them. To pin one: | |
| ⋯ | |||
| 647 | 672 | for the roles that can use it. A link straight to any of these pages opens | |
| 648 | 673 | the sidebar already there. | |
| 649 | 674 | ||
| 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 | |
| 656 | 683 | ||
| 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. | |
| 669 | 691 | ||
| 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. | | |
| 676 | 721 | ||
| 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). | |
| 679 | 727 | ||
| 680 | 728 | ### Pinned and recent projects | |
| 681 | 729 | ||
| 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 | + | } |
| 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"; | |
| 2 | 2 | import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 10 | 10 | import { SpendPill } from "./spend"; | |
| 11 | 11 | import { PinButton } from "./pin-button"; | |
| 12 | 12 | import { Hint } from "./ui/hint"; | |
| 13 | + | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | |
| 13 | 14 | import { | |
| 14 | 15 | Sidebar, | |
| 15 | 16 | SidebarContent, | |
| ⋯ | |||
| 46 | 47 | DropdownMenuTrigger, | |
| 47 | 48 | } from "./ui/dropdown-menu"; | |
| 48 | 49 | 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"; | |
| 50 | 51 | import { AgentsSidebar } from "./agents-mode"; | |
| 51 | 52 | import { ChatSidebar } from "./chat/sidebar"; | |
| 52 | 53 | import { FoliosSidebar } from "./folios/sidebar"; | |
| ⋯ | |||
| 518 | 519 | ); | |
| 519 | 520 | } | |
| 520 | 521 | ||
| 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("|")})(/|$)`); | |
| 530 | 522 | /** A project's settings pages, which the project's menu drills into. */ | |
| 531 | 523 | const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/; | |
| 532 | 524 | ||
| ⋯ | |||
| 1213 | 1205 | ); | |
| 1214 | 1206 | } | |
| 1215 | 1207 | ||
| 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 | + | }; | |
| 1218 | 1238 | ||
| 1219 | 1239 | /** | |
| 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. | |
| 1223 | 1244 | */ | |
| 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 | + | */ | |
| 1224 | 1276 | export function WorkspaceSidebar({ slug, owner }: { slug: string; owner: boolean }) { | |
| 1225 | 1277 | const { pathname } = useLocation(); | |
| 1226 | 1278 | const going = useNavigation().location?.pathname; | |
| 1227 | 1279 | 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; | |
| 1336 | 1283 | return ( | |
| 1337 | 1284 | <div className="flex h-full flex-col"> | |
| 1338 | 1285 | <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> | |
| 1340 | 1316 | </div> | |
| 1341 | 1317 | ); | |
| 1342 | 1318 | } | |
| 1343 | 1319 | ||
| 1344 | 1320 | /** | |
| 1345 | 1321 | * 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. | |
| 1348 | 1325 | */ | |
| 1349 | 1326 | function PeopleSidebar({ slug }: { slug: string }) { | |
| 1350 | 1327 | const { pathname } = useLocation(); | |
| ⋯ | |||
| 1366 | 1343 | Org chart | |
| 1367 | 1344 | </SidebarLink> | |
| 1368 | 1345 | </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> | |
| 1375 | 1346 | </nav> | |
| 1376 | 1347 | </div> | |
| 1377 | 1348 | ); | |
| ⋯ | |||
| 1470 | 1441 | settings: "Settings", | |
| 1471 | 1442 | people: "People", | |
| 1472 | 1443 | "org-chart": "Org chart", | |
| 1473 | − | members: "Members and invites", | |
| 1444 | + | members: "Members", | |
| 1445 | + | permissions: "Permissions", | |
| 1474 | 1446 | projects: "Projects", | |
| 1475 | 1447 | teams: "Teams", | |
| 1476 | 1448 | tokens: "Access tokens", | |
| ⋯ | |||
| 1563 | 1535 | const trail: Crumb[] = ours ? [] : [{ label: owner!, to: `/${owner}`, mono: true }]; | |
| 1564 | 1536 | if (second === "-") { | |
| 1565 | 1537 | 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. | |
| 1568 | 1543 | 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. | |
| 1572 | 1545 | 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 | + | : {}; | |
| 1574 | 1553 | const tab = fourth ? tabs[fourth] : undefined; | |
| 1575 | 1554 | if (tab) trail.push({ label: tab, to: `${page}/${fourth}` }); | |
| 1576 | 1555 | } | |
| ⋯ | |||
| 1753 | 1732 | { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> }, | |
| 1754 | 1733 | { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> }, | |
| 1755 | 1734 | { 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 | + | : []), | |
| 1756 | 1742 | ); | |
| 1757 | 1743 | } | |
| 1758 | 1744 | for (const listed of shell.repos) { | |
| 8 | 8 | G1T_INVITES, | |
| 9 | 9 | HAVE_AN_INVITE, | |
| 10 | 10 | bringIntoChoices, | |
| 11 | + | filterInvitations, | |
| 12 | + | filterMembers, | |
| 13 | + | invitationCounts, | |
| 14 | + | invitationFilter, | |
| 15 | + | invitationFilterOf, | |
| 16 | + | invitationTone, | |
| 11 | 17 | inviteDraft, | |
| 12 | 18 | inviteKind, | |
| 19 | + | memberFilters, | |
| 13 | 20 | invitePageCopy, | |
| 14 | 21 | invitesPage, | |
| 15 | 22 | peoplePages, | |
| ⋯ | |||
| 38 | 45 | ||
| 39 | 46 | const CODE = "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk"; | |
| 40 | 47 | ||
| 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 | + | ||
| 41 | 93 | test("while invite-only, nobody is offered a plain sign-up", () => { | |
| 42 | 94 | // Sign up everywhere; only the sign-up page says registration takes an invite. | |
| 43 | 95 | assert.deepEqual(signUpCopy(), { primary: "Sign up", secondary: null }); | |
| 215 | 215 | }; | |
| 216 | 216 | } | |
| 217 | 217 | ||
| 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 | + | ||
| 218 | 294 | /** Who an invite is for, in a list. */ | |
| 219 | 295 | export function inviteFor(invite: Listed): string { | |
| 220 | 296 | return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link"); | |
| ⋯ | |||
| 338 | 414 | } | |
| 339 | 415 | ||
| 340 | 416 | /** | |
| 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 | + | /** | |
| 341 | 457 | * The People pages Settings → Invites points to for a workspace | |
| 342 | 458 | * invitation: the workspaces the viewer owns (only owners invite), the | |
| 343 | 459 | * current one first. | |
| 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 | + | } |
| 105 | 105 | // An agent's old People address (it redirects to Agents) is still read as People's until it does. | |
| 106 | 106 | assert.equal(modeOf("/acme/-/people/agents/margo", "acme"), "people"); | |
| 107 | 107 | 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"); | |
| 109 | 112 | assert.equal(modeOf("/acme/-/apps", "acme"), "apps"); | |
| 110 | 113 | assert.equal(modeOf("/acme/-/billing", "acme"), "workspace"); | |
| 111 | 114 | assert.equal(modeOf("/acme/-/tokens", "acme"), "workspace"); | |
| ⋯ | |||
| 125 | 128 | assert.equal(homePath("acme", "?tab=landed&_routes=x"), "/acme/-/overview?tab=landed"); | |
| 126 | 129 | }); | |
| 127 | 130 | ||
| 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 | + | ||
| 128 | 156 | test("a member without Code access is sent around Code's pages", async () => { | |
| 129 | 157 | const { codeGate } = await import("./workspace-nav.ts"); | |
| 130 | 158 | const none = ["acme"]; | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | /** | |
| 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 `-/`. | |
| 31 | 34 | */ | |
| 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[] }; | |
| 46 | 38 | ||
| 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 | + | ||
| 47 | 174 | /** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */ | |
| 48 | 175 | const TAB_WORDS: Record<string, string> = { | |
| 49 | 176 | overview: "", | |
| ⋯ | |||
| 125 | 252 | /** | |
| 126 | 253 | * The rail's modes. Home is the front page; | |
| 127 | 254 | * 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 | |
| 130 | 258 | * can use. `account` is your own settings, under the avatar. `site` is | |
| 131 | 259 | * g1t's own public pages (a profile, Explore, Search): no workspace's, so | |
| 132 | 260 | * no mode is lit and no mode's sidebar sits beside them. Each mode has a | |
| ⋯ | |||
| 156 | 284 | apps: "apps", | |
| 157 | 285 | marketplace: "apps", | |
| 158 | 286 | people: "people", | |
| 159 | − | members: "people", | |
| 287 | + | members: "workspace", | |
| 288 | + | permissions: "workspace", | |
| 160 | 289 | teams: "people", | |
| 161 | 290 | "org-chart": "people", | |
| 162 | 291 | workspace: "workspace", | |
| 100 | 100 | route("-/people/agents/:handle", "routes/workspace/people/agent.tsx"), | |
| 101 | 101 | route("-/people/:username", "routes/workspace/people/person.tsx"), | |
| 102 | 102 | 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. | |
| 104 | 104 | route("-/members", "routes/workspace/members.tsx"), | |
| 105 | + | route("-/members/invitations", "routes/workspace/member-invitations.tsx"), | |
| 106 | + | route("-/permissions", "routes/workspace/permissions.tsx"), | |
| 105 | 107 | route("-/insights", "routes/workspace/tab-soon.tsx", { id: "routes/workspace/insights" }), | |
| 106 | 108 | // Pinning its projects, for the person signed in. | |
| 107 | 109 | route("-/pins", "routes/workspace/pins.ts"), |
| 2 | 2 | import { useEffect, useState } from "react"; | |
| 3 | 3 | import { Form, Link, data, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import type { ChannelManagers, ChatAllowed, ChatSettings, EmojiUpload } from "@g1t/contracts"; | |
| 5 | + | import type { ChannelManagers, ChatSettings, EmojiUpload } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/chat-settings"; | |
| 8 | 8 | import { ErrorText } from "../../components/ui"; | |
| ⋯ | |||
| 37 | 37 | const viewer = requireUser(context, request); | |
| 38 | 38 | const slug = params.owner.toLowerCase(); | |
| 39 | 39 | 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. | |
| 41 | 41 | const change: Partial<ChatSettings> = { | |
| 42 | − | public_channels: who("public_channels"), | |
| 43 | − | private_channels: who("private_channels"), | |
| 44 | 42 | manage_channels: (form.get("manage_channels") === "owners" ? "owners" : "channel_owners") as ChannelManagers, | |
| 45 | 43 | emoji_upload: (form.get("emoji_upload") === "admins" ? "admins" : "members") as EmojiUpload, | |
| 46 | 44 | default_channels: form.getAll("default_channels").map(String), | |
| ⋯ | |||
| 92 | 90 | <fieldset disabled={!owner} className="space-y-8"> | |
| 93 | 91 | <section className={SECTION}> | |
| 94 | 92 | <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> | |
| 120 | 108 | </section> | |
| 121 | 109 | ||
| 122 | 110 | <section className={SECTION}> | |
| 50 | 50 | title: "Workspace", | |
| 51 | 51 | 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.", | |
| 52 | 52 | }, | |
| 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." }, | |
| 54 | 54 | people: { | |
| 55 | 55 | title: "People", | |
| 56 | 56 | 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.", | |
| 57 | 57 | }, | |
| 58 | 58 | 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.", | |
| 61 | 69 | }, | |
| 62 | 70 | "org-chart": { | |
| 63 | 71 | title: "Org chart", | |
| ⋯ | |||
| 180 | 188 | ); | |
| 181 | 189 | } | |
| 182 | 190 | 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] ?? "")); | |
| 184 | 194 | const heading = PAGES[key]; | |
| 185 | 195 | // A page within one (a single package or team) and a page that is | |
| 186 | 196 | // 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); | |
| 188 | 198 | return ( | |
| 189 | 199 | <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8"> | |
| 190 | 200 | {titled && ( | |
| 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 | + | } |
| 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"; | |
| 3 | 3 | import { Form, Link, data, redirect, useSearchParams, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| 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"; | |
| 17 | 6 | ||
| 18 | 7 | import type { Route } from "./+types/members"; | |
| 19 | 8 | import { page } from "../../lib/meta"; | |
| 20 | − | import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 9 | + | import { EmptyState, ErrorText, SubmitButton } from "../../components/ui"; | |
| 21 | 10 | import { Badge } from "../../components/ui/badge"; | |
| 22 | 11 | import { PersonStatusEmoji, WithPresence } from "../../components/presence"; | |
| 23 | 12 | import { | |
| ⋯ | |||
| 41 | 30 | DropdownMenuTrigger, | |
| 42 | 31 | } from "../../components/ui/dropdown-menu"; | |
| 43 | 32 | 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"; | |
| 46 | 35 | 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"; | |
| 48 | 41 | import { registrationMode } from "../../lib/registration.server"; | |
| 49 | 42 | 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"; | |
| 61 | 45 | ||
| 62 | 46 | 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` }); | |
| 64 | 48 | } | |
| 65 | 49 | ||
| 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) { | |
| 67 | 57 | const viewer = getViewer(context); | |
| 68 | 58 | const role = roleIn(viewer, params.owner); | |
| 69 | 59 | // Who is in a workspace is its members' business; anyone else, including | |
| 70 | 60 | // someone a repository is shared with, gets nothing here. | |
| 71 | 61 | if (!role) throw data(null, { status: 404 }); | |
| 72 | 62 | 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([ | |
| 74 | 64 | identity.listMembers(params.owner, viewer), | |
| 75 | − | owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null, | |
| 76 | 65 | identity.getWorkspace(params.owner), | |
| 77 | 66 | owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null, | |
| 78 | 67 | // Each member's teams, as the viewer may see them. | |
| ⋯ | |||
| 80 | 69 | // A free workspace adds no one until it starts the plan; identity | |
| 81 | 70 | // refuses it either way, so a failure here only hides the note. | |
| 82 | 71 | 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. | |
| 84 | 73 | registrationMode(), | |
| 74 | + | // How many invitations wait, for the line that leads to them. | |
| 75 | + | owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null, | |
| 85 | 76 | ]); | |
| 86 | 77 | return { | |
| 87 | 78 | role, | |
| ⋯ | |||
| 89 | 80 | inviteOnly: mode !== "open", | |
| 90 | 81 | free: free.includes(params.owner.toLowerCase()), | |
| 91 | 82 | members: unwrap(members), | |
| 92 | − | invites: invites?.ok ? invites.value : [], | |
| 93 | − | base: workspace?.basePermission ?? DEFAULT_BASE_PERMISSION, | |
| 94 | 83 | outside: outside?.ok ? outside.value : [], | |
| 95 | 84 | 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, | |
| 97 | 86 | me: viewer?.username ?? null, | |
| 98 | 87 | }; | |
| 99 | 88 | } | |
| ⋯ | |||
| 102 | 91 | assertSameOrigin(request); | |
| 103 | 92 | const user = requireUser(context, request); | |
| 104 | 93 | 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 | − | } | |
| 109 | 94 | const target = String(form.get("member") ?? "").trim(); | |
| 110 | 95 | // Owner or member, and the roles that add to a member. | |
| 111 | 96 | if (form.get("action") === "role") { | |
| ⋯ | |||
| 129 | 114 | if (!result.ok) return { error: result.error.message, leaving: true }; | |
| 130 | 115 | const secure = new URL(request.url).protocol === "https:"; | |
| 131 | 116 | 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 }; | |
| 138 | 117 | } | |
| 139 | − | const member = String(form.get("member") ?? "").trim(); | |
| 140 | 118 | 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); | |
| 142 | 120 | return result.ok ? null : { error: result.error.message, converting: false }; | |
| 143 | 121 | } | |
| 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; | |
| 155 | 128 | } | |
| 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 | − | }; | |
| 164 | 129 | ||
| 165 | 130 | /** What a member's roles say about them, as badges: owner or member, then the roles on top. */ | |
| 166 | 131 | function RoleBadges({ member }: { member: Member }) { | |
| ⋯ | |||
| 318 | 283 | ); | |
| 319 | 284 | } | |
| 320 | 285 | ||
| 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; | |
| 323 | 374 | const owner = role === "owner"; | |
| 324 | − | // The invitation to join this workspace, not an invite to g1t (Settings → Invites). | |
| 325 | − | const inviteCopy = workspaceInviteCopy(name, inviteOnly); | |
| 326 | 375 | const owners = members.filter((member) => member.role === "owner").length; | |
| 327 | 376 | const rowError = (username: string) => | |
| 328 | 377 | 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 | − | ); | |
| 335 | 378 | const [search, setSearch] = useSearchParams(); | |
| 336 | 379 | 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; | |
| 337 | 390 | const membersTab = ( | |
| 338 | 391 | <> | |
| 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} /> | |
| 386 | 393 | {owner && free && ( | |
| 387 | − | <div className="mt-6"> | |
| 394 | + | <div className="mb-4"> | |
| 388 | 395 | <StartPlanToInvite workspace={params.owner} owner={owner} /> | |
| 389 | 396 | </div> | |
| 390 | 397 | )} | |
| 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 | |
| 432 | 459 | </Link> | |
| 433 | − | . | |
| 434 | − | </p> | |
| 460 | + | </> | |
| 435 | 461 | )} | |
| 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 | + | . | |
| 441 | 470 | </p> | |
| 442 | 471 | )} | |
| 443 | 472 | {actionData && "transferred" in actionData && ( | |
| 444 | − | <p className="text-sm text-muted" role="status"> | |
| 473 | + | <p className="mt-3 text-sm text-muted" role="status"> | |
| 445 | 474 | <span className="font-mono text-fg">{actionData.transferred}</span> owns {params.owner} now, and you are a member. | |
| 446 | 475 | </p> | |
| 447 | 476 | )} | |
| 448 | 477 | {actionData && "converted" in actionData && ( | |
| 449 | − | <p className="text-sm text-muted" role="status"> | |
| 478 | + | <p className="mt-3 text-sm text-muted" role="status"> | |
| 450 | 479 | <span className="font-mono text-fg">{actionData.converted}</span> is invited to join {params.owner} as a member, and joins once they accept. | |
| 451 | 480 | </p> | |
| 452 | 481 | )} | |
| 453 | 482 | {actionData && | |
| 454 | − | !("base" in actionData) && | |
| 483 | + | "error" in actionData && | |
| 455 | 484 | !("row" in actionData) && | |
| 456 | 485 | !("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>} | |
| 520 | 487 | ||
| 521 | 488 | <LeaveSection | |
| 522 | 489 | slug={params.owner} | |
| ⋯ | |||
| 526 | 493 | </> | |
| 527 | 494 | ); | |
| 528 | 495 | return ( | |
| 529 | − | <div className="max-w-2xl"> | |
| 496 | + | <div className="max-w-3xl"> | |
| 530 | 497 | {owner ? ( | |
| 531 | 498 | <Tabs | |
| 532 | 499 | value={tab} | |
| ⋯ | |||
| 537 | 504 | setSearch(next, { replace: true, preventScrollReset: true }); | |
| 538 | 505 | }} | |
| 539 | 506 | > | |
| 540 | − | <TabsList className="mb-3"> | |
| 507 | + | <TabsList className="mb-4"> | |
| 541 | 508 | <TabsTrigger value="members">Members · {members.length}</TabsTrigger> | |
| 542 | 509 | <TabsTrigger value="outside">Outside collaborators · {outside.length}</TabsTrigger> | |
| 543 | 510 | </TabsList> | |
| ⋯ | |||
| 555 | 522 | membersTab | |
| 556 | 523 | )} | |
| 557 | 524 | </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> | |
| 619 | 525 | ); | |
| 620 | 526 | } | |
| 621 | 527 | ||
| 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 | + | } |
| 2 | 2 | import { Form, Link, data, redirect, useFetcher, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { | |
| 5 | − | DEFAULT_MEMBER_PRIVILEGES, | |
| 6 | 5 | type DataResidency, | |
| 7 | − | type MemberPrivileges, | |
| 8 | 6 | RENAME_COOLDOWN_HOURS, | |
| 9 | 7 | SLUG_HOLD_DAYS, | |
| 10 | − | type TeamCreation, | |
| 11 | 8 | WORKSPACE_RESTORE_DAYS, | |
| 12 | 9 | type Workspace, | |
| 13 | 10 | type WorkspaceDeletion, | |
| ⋯ | |||
| 95 | 92 | apps, | |
| 96 | 93 | euAvailable: storage.euAvailable, | |
| 97 | 94 | residency: residency ?? "anywhere", | |
| 98 | − | privileges: { ...DEFAULT_MEMBER_PRIVILEGES, ...pickPrivileges(workspace) }, | |
| 99 | 95 | withoutTwoFactor: without, | |
| 100 | 96 | ownTwoFactor: twoFactor?.ok ? twoFactor.value.enabled : false, | |
| 101 | 97 | }; | |
| ⋯ | |||
| 135 | 131 | const result = await identity.setWorkspaceResidency(user, params.owner, wanted); | |
| 136 | 132 | if (!result.ok) return { residencyError: result.error.message }; | |
| 137 | 133 | 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 }; | |
| 145 | 134 | } | |
| 146 | 135 | // Requiring two-factor authentication: identity checks the owner has it. | |
| 147 | 136 | if (intent === "two-factor-requirement") { | |
| ⋯ | |||
| 149 | 138 | if (!result.ok) return { twoFactorError: result.error.message }; | |
| 150 | 139 | return { saved: "two-factor-requirement" as const }; | |
| 151 | 140 | } | |
| 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 | − | } | |
| 159 | 141 | if (intent === "rename") { | |
| 160 | 142 | const newSlug = String(form.get("newSlug") ?? "").trim().toLowerCase(); | |
| 161 | 143 | const result = await identity.renameWorkspace(user, params.owner, newSlug); | |
| ⋯ | |||
| 214 | 196 | error={actionData && "renameError" in actionData ? actionData.renameError : undefined} | |
| 215 | 197 | /> | |
| 216 | 198 | ||
| 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 | − | ||
| 224 | 199 | <TwoFactorRequirementSection | |
| 225 | 200 | key={String(workspace.twoFactorRequirementEnabled ?? false)} | |
| 226 | 201 | slug={workspace.slug} | |
| ⋯ | |||
| 231 | 206 | error={actionData && "twoFactorError" in actionData ? actionData.twoFactorError : undefined} | |
| 232 | 207 | /> | |
| 233 | 208 | ||
| 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> | |
| 241 | 220 | ||
| 242 | 221 | {(loaderData.euAvailable || loaderData.residency === "eu") && ( | |
| 243 | 222 | <ResidencySection | |
| ⋯ | |||
| 261 | 240 | </div> | |
| 262 | 241 | ); | |
| 263 | 242 | } | |
| 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 | − | ]; | |
| 293 | 243 | ||
| 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 | − | ||
| 343 | 244 | /** Requiring two-factor authentication of everyone with access. */ | |
| 344 | 245 | function TwoFactorRequirementSection({ | |
| 345 | 246 | slug, | |
| ⋯ | |||
| 396 | 297 | </p> | |
| 397 | 298 | )} | |
| 398 | 299 | <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}> | |
| 439 | 300 | Save | |
| 440 | 301 | </Button> | |
| 441 | 302 | </Form> | |
| 1539 | 1539 | /// The role they join with; member when absent. | |
| 1540 | 1540 | #[serde(default)] | |
| 1541 | 1541 | 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>, | |
| 1542 | 1546 | /// Where the request came in, for the audit log; g1t.sh when absent. | |
| 1543 | 1547 | #[serde(default)] | |
| 1544 | 1548 | pub surface: Option<crate::audit::Surface>, | |
| 1545 | 1549 | } | |
| 1546 | 1550 | ||
| 1551 | + | /// The most characters an invitation's note keeps. | |
| 1552 | + | pub const MAX_INVITE_MESSAGE: usize = 500; | |
| 1553 | + | ||
| 1547 | 1554 | /// A workspace invitation waiting for its person's answer, as they see it. | |
| 1548 | 1555 | /// `list_invitations` (takes `UserArgs`) returns `Vec<WorkspaceInvitation>`, | |
| 1549 | 1556 | /// newest first: pending ones only, never expired, revoked or answered. | |
| ⋯ | |||
| 1591 | 1598 | /// newest first. Owners only. Returns `Outcome<Vec<Invite>>`. | |
| 1592 | 1599 | /// | |
| 1593 | 1600 | /// `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`. | |
| 1594 | 1607 | #[derive(Debug, Serialize, Deserialize)] | |
| 1595 | 1608 | pub struct WorkspaceInviteArgs { | |
| 1596 | 1609 | pub actor: User, | |
| 160 | 160 | }), | |
| 161 | 161 | acceptInvite: (user, code) => call("accept_invite", { user, code }), | |
| 162 | 162 | 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 }), | |
| 164 | 164 | listInvitations: (user) => call("list_invitations", { user }), | |
| 165 | 165 | acceptInvitation: (user, id) => call("accept_invitation", { user, id, surface: "web" }), | |
| 166 | 166 | declineInvitation: (user, id) => call("decline_invitation", { user, id, surface: "web" }), | |
| 167 | 167 | findPeople: (query, limit) => call("find_people", { query, limit: limit ?? null }), | |
| 168 | 168 | workspaceInvites: (slug, viewer) => call("workspace_invites", { slug, viewer }), | |
| 169 | 169 | revokeWorkspaceInvite: (actor, slug, id) => call("revoke_workspace_invite", { actor, slug, id }), | |
| 170 | + | resendWorkspaceInvite: (actor, slug, id) => call("resend_workspace_invite", { actor, slug, id }), | |
| 170 | 171 | requestAccess: (email, about, client) => call("request_access", { email, about, client: client ?? null }), | |
| 171 | 172 | userForGitCredentials: (username, secret) => | |
| 172 | 173 | call("user_for_git_credentials", { username, secret }), |
| 227 | 227 | export const INVITES_PER_USER = 5; | |
| 228 | 228 | /** How long an invite works, unless identity's `INVITE_TTL_DAYS` says otherwise. */ | |
| 229 | 229 | 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; | |
| 230 | 232 | ||
| 231 | 233 | /** Only a pending invite can be used or revoked. Revoked and expired ones never used give the invite back. */ | |
| 232 | 234 | /** | |
| ⋯ | |||
| 992 | 994 | inviteMember( | |
| 993 | 995 | actor: User, | |
| 994 | 996 | 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 }, | |
| 996 | 998 | ): Promise<Result<Invite>>; | |
| 997 | 999 | /** The workspace invitations waiting for the person's answer, newest first. */ | |
| 998 | 1000 | listInvitations(user: User): Promise<WorkspaceInvitation[]>; | |
| ⋯ | |||
| 1006 | 1008 | workspaceInvites(slug: string, viewer: Viewer): Promise<Result<Invite[]>>; | |
| 1007 | 1009 | /** Owners only. */ | |
| 1008 | 1010 | 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>>; | |
| 1009 | 1013 | /** Someone without an invite asks for one. Always the same answer for a valid address. */ | |
| 1010 | 1014 | requestAccess(email: string, about: string, client?: string | null): Promise<Result<boolean>>; | |
| 1011 | 1015 | ||
| 179 | 179 | } | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 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 | + | ||
| 182 | 196 | /// Where an invite stands once the workspace invitation in it is counted: | |
| 183 | 197 | /// `base` from [`status_of`]. A declined one is declined; an account | |
| 184 | 198 | /// invite whose account is confirmed but has not answered the workspace it | |
| ⋯ | |||
| 1705 | 1719 | return Ok(refused); | |
| 1706 | 1720 | } | |
| 1707 | 1721 | 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()); | |
| 1708 | 1724 | // Someone on g1t, by username: an invitation to accept or decline | |
| 1709 | 1725 | // (invites/invitations.rs). | |
| 1710 | 1726 | let Some(email) = email else { | |
| 1711 | 1727 | 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; | |
| 1713 | 1729 | }; | |
| 1714 | 1730 | if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? { | |
| 1715 | 1731 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); | |
| ⋯ | |||
| 1787 | 1803 | if let Some(code) = &invite.code { | |
| 1788 | 1804 | let from = self.display_name(&a.actor).await; | |
| 1789 | 1805 | 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; | |
| 1791 | 1807 | } | |
| 1792 | 1808 | // Someone on g1t hears of it in their inbox too. | |
| 1793 | 1809 | if invitee.is_some() | |
| ⋯ | |||
| 1875 | 1891 | self.revoke_invite(RemoveArgs { user: a.actor, id: a.id }).await | |
| 1876 | 1892 | } | |
| 1877 | 1893 | ||
| 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 | + | ||
| 1878 | 1957 | // --- The waitlist --- | |
| 1879 | 1958 | ||
| 1880 | 1959 | pub async fn request_access(&self, a: RequestAccessArgs) -> Result<Outcome<bool>> { | |
| ⋯ | |||
| 2449 | 2528 | } | |
| 2450 | 2529 | ||
| 2451 | 2530 | #[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] | |
| 2452 | 2555 | fn codes_are_not_repeated() { | |
| 2453 | 2556 | let codes: std::collections::HashSet<String> = (0..2000).map(|_| new_code_body()).collect(); | |
| 2454 | 2557 | assert_eq!(codes.len(), 2000); | |
| 183 | 183 | workspace_id: &str, | |
| 184 | 184 | username: &str, | |
| 185 | 185 | role: Role, | |
| 186 | + | note: Option<&str>, | |
| 186 | 187 | surface: Surface, | |
| 187 | 188 | ) -> Result<Outcome<Invite>> { | |
| 188 | 189 | let person = self | |
| ⋯ | |||
| 247 | 248 | if let (Some(email), true, Some(code)) = (&person.email, person.verified != 0, &invite.code) { | |
| 248 | 249 | let from = self.display_name(actor).await; | |
| 249 | 250 | 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; | |
| 251 | 252 | } | |
| 252 | 253 | if let Some(row) = self.invite_by_id(&invite.id).await? { | |
| 253 | 254 | self.invitation_sent(&row, &person.username).await; | |
| 1045 | 1045 | "find_people" => reply(&identity.find_people(args(body)?).await?), | |
| 1046 | 1046 | "workspace_invites" => reply(&identity.workspace_invites(args(body)?).await?), | |
| 1047 | 1047 | "revoke_workspace_invite" => reply(&identity.revoke_workspace_invite(args(body)?).await?), | |
| 1048 | + | "resend_workspace_invite" => reply(&identity.resend_workspace_invite(args(body)?).await?), | |
| 1048 | 1049 | "request_access" => reply(&identity.request_access(args(body)?).await?), | |
| 1049 | 1050 | // Who has access to a repository; see access.rs. | |
| 1050 | 1051 | "repo_access" => reply(&identity.repo_access(args(body)?).await?), |
| 421 | 421 | email: String::new(), | |
| 422 | 422 | username: Some(user.username), | |
| 423 | 423 | role: None, | |
| 424 | + | message: None, | |
| 424 | 425 | surface: a.surface, | |
| 425 | 426 | }) | |
| 426 | 427 | .await?; |