Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)
| 82 | 82 | ), | |
| 83 | 83 | ( | |
| 84 | 84 | "Invites", | |
| 85 | − | "While g1t is invite-only, every new account needs an invite. Your invites, and inviting people into a workspace by email.", | |
| 85 | + | "While g1t is invite-only, every new account needs an invite. Your invites, inviting people into a workspace by username or email, and answering the invitations to workspaces sent to you.", | |
| 86 | 86 | &[ | |
| 87 | 87 | Op::ListInvites, | |
| 88 | 88 | Op::CreateInvite, | |
| ⋯ | |||
| 90 | 90 | Op::ListWorkspaceInvites, | |
| 91 | 91 | Op::InviteMember, | |
| 92 | 92 | Op::RevokeWorkspaceInvite, | |
| 93 | + | Op::ListInvitations, | |
| 94 | + | Op::AcceptInvitation, | |
| 95 | + | Op::DeclineInvitation, | |
| 93 | 96 | ], | |
| 94 | 97 | ), | |
| 95 | 98 | ( | |
| ⋯ | |||
| 549 | 552 | Op::RevokeInvite => "Revoke an invite", | |
| 550 | 553 | Op::ListWorkspaceInvites => "List a workspace's invites", | |
| 551 | 554 | Op::InviteMember => "Invite someone to a workspace", | |
| 555 | + | Op::ListInvitations => "List your workspace invitations", | |
| 556 | + | Op::AcceptInvitation => "Accept a workspace invitation", | |
| 557 | + | Op::DeclineInvitation => "Decline a workspace invitation", | |
| 552 | 558 | Op::RevokeWorkspaceInvite => "Revoke a workspace's invite", | |
| 553 | 559 | Op::TransferRepo => "Transfer a repository", | |
| 554 | 560 | Op::RenameRepo => "Rename a repository", | |
| 123 | 123 | ListWorkspaceInvites, | |
| 124 | 124 | InviteMember, | |
| 125 | 125 | RevokeWorkspaceInvite, | |
| 126 | + | ListInvitations, | |
| 127 | + | AcceptInvitation, | |
| 128 | + | DeclineInvitation, | |
| 126 | 129 | ListRepos, | |
| 127 | 130 | GetRepo, | |
| 128 | 131 | CreateRepo, | |
| ⋯ | |||
| 683 | 686 | } | |
| 684 | 687 | ||
| 685 | 688 | impl Op { | |
| 686 | − | pub const ALL: [Op; 315] = [ | |
| 689 | + | pub const ALL: [Op; 318] = [ | |
| 687 | 690 | Op::Whoami, | |
| 688 | 691 | Op::GetWorkspace, | |
| 689 | 692 | Op::CreateWorkspace, | |
| ⋯ | |||
| 705 | 708 | Op::ListWorkspaceInvites, | |
| 706 | 709 | Op::InviteMember, | |
| 707 | 710 | Op::RevokeWorkspaceInvite, | |
| 711 | + | Op::ListInvitations, | |
| 712 | + | Op::AcceptInvitation, | |
| 713 | + | Op::DeclineInvitation, | |
| 708 | 714 | Op::ListRepos, | |
| 709 | 715 | Op::GetRepo, | |
| 710 | 716 | Op::CreateRepo, | |
| ⋯ | |||
| 1029 | 1035 | Op::ListWorkspaceInvites => "list_workspace_invites", | |
| 1030 | 1036 | Op::InviteMember => "invite_member", | |
| 1031 | 1037 | Op::RevokeWorkspaceInvite => "revoke_workspace_invite", | |
| 1038 | + | Op::ListInvitations => "list_invitations", | |
| 1039 | + | Op::AcceptInvitation => "accept_invitation", | |
| 1040 | + | Op::DeclineInvitation => "decline_invitation", | |
| 1032 | 1041 | Op::ListRepos => "list_repos", | |
| 1033 | 1042 | Op::GetRepo => "get_repo", | |
| 1034 | 1043 | Op::CreateRepo => "create_repo", | |
| ⋯ | |||
| 1225 | 1234 | "Add an email address to your account. g1t emails it a link to confirm it; until then it cannot be primary and does not sign you in. Adding an address you added before and have not confirmed sends the link again. An address another account has confirmed cannot be added. An account has at most 10. Needs your account `password`; your confirmed addresses are told. People only." | |
| 1226 | 1235 | } | |
| 1227 | 1236 | Op::ConfirmEmail => { | |
| 1228 | − | "Confirm an email address with the six-digit `code` from the confirmation email g1t sent it. The same email has a link that does the same; either one works, once, for 60 minutes, and asking for a new email ends both. A new account must confirm its address before it can do anything else: until then this, `GET /user` and `GET /user/emails` are the only calls its token can make, and everything else, MCP included, is refused with `403`. Confirming a new account's address also joins the workspace its invite named, when the invite still applies: the answer's `joined` names it, or `invite_lapsed` says why not. Ten wrong codes in an hour pause checking for the account. People only." | |
| 1237 | + | "Confirm an email address with the six-digit `code` from the confirmation email g1t sent it. The same email has a link that does the same; either one works, once, for 60 minutes, and asking for a new email ends both. A new account must confirm its address before it can do anything else: until then this, `GET /user` and `GET /user/emails` are the only calls its token can make, and everything else, MCP included, is refused with `403`. Confirming a new account's address also invites it to the workspace its invite named, when the invite still applies: the answer's `invited_to` names it, and the invitation waits for you to accept or decline it (accept_invitation), or `invite_lapsed` says why not. Ten wrong codes in an hour pause checking for the account. People only." | |
| 1229 | 1238 | } | |
| 1230 | 1239 | Op::RemoveEmail => { | |
| 1231 | 1240 | "Remove an email address from your account. Never your primary address (make another primary first) and never your last confirmed one. Needs your account `password`; every confirmed address, the removed one included, is told. People only." | |
| ⋯ | |||
| 1234 | 1243 | "Change what your addresses do; only the fields given change. `primary` is a confirmed address to make primary: account mail and password resets go there. `backup` is a confirmed address that also gets security notices, or an empty string for the primary only. Changing either needs your account `password`, and every confirmed address is told. `private_email` keeps your address off commits g1t makes for you (merges and changes made on the web, and agents' commits for you), which use your noreply address instead; `block_private_pushes` refuses pushes whose commits carry one of your addresses while it is private. People only." | |
| 1235 | 1244 | } | |
| 1236 | 1245 | Op::ListInvites => { | |
| 1237 | − | "Your invites, newest first, and how many you have left. While g1t is invite-only, every new account needs an invite code. You may have 5 invites out at once: pending and used ones count, and one revoked or expired before it was used comes back. `allowance.limit` is null when you have no limit. `workspaces` lists the workspaces you own that were granted invites to share. A pending invite's `code` is shown to you; `status` is pending, redeemed, expired or revoked." | |
| 1246 | + | "Your invites, newest first, and how many you have left. While g1t is invite-only, every new account needs an invite code. You may have 5 invites out at once: pending and used ones count, and one revoked or expired before it was used comes back. `allowance.limit` is null when you have no limit. `workspaces` lists the workspaces you own that were granted invites to share. A pending invite's `code` is shown to you. `status` is `pending`; `awaiting_confirmation` (used to make an account that has not confirmed its address yet); `awaiting_answer` (used to make an account that has yet to accept or decline the workspace it was invited to); `redeemed`; `declined` (its person declined the workspace); `expired`; or `revoked`. An invite that brings someone into a workspace names it in `workspace`, with the `role` it joins with and, once known, the account it is for in `invitee`." | |
| 1238 | 1247 | } | |
| 1239 | 1248 | Op::CreateInvite => { | |
| 1240 | − | "Make an invite. With `email`, it is sent there and only that address can use it; without, anyone with the code can, once. It works for 30 days. It uses one of your invites, or with `workspace`, one of the invites g1t granted that workspace (its owners only). Returns the invite with its `code`; the link is https://g1t.sh/invite/<code>. People only: an agent's token or a workspace's token cannot make invites." | |
| 1249 | + | "Make an invite. With `email`, it is sent there and only that address can use it; without, anyone with the code can, once. It works for 30 days. With `workspace`, the new account is brought into that workspace: once it confirms its address it gets an invitation to join as a member, which it accepts or declines, and no workspace of its own is made for it. That must be a workspace you own on the g1t plan; a free workspace is refused with `payment_required` (402). Without `workspace`, the new account gets a free workspace of its own. It uses one of your invites, or with `charge_workspace`, one of the invites g1t granted that workspace (its owners only). Returns the invite with its `code`; the link is https://g1t.sh/invite/<code>. People only: an agent's token or a workspace's token cannot make invites." | |
| 1241 | 1250 | } | |
| 1242 | 1251 | Op::RevokeInvite => { | |
| 1243 | 1252 | "Revoke a pending invite you made, or one made for a workspace you own. It stops working at once, and the invite comes back to whoever it was charged to." | |
| ⋯ | |||
| 1246 | 1255 | "The invites made for a workspace, newest first, with each pending one's `code`. Owners only." | |
| 1247 | 1256 | } | |
| 1248 | 1257 | Op::InviteMember => { | |
| 1249 | − | "Invite an email address into a workspace. It always makes an invite bound to that address and emails it the link, so the answer never says whether the address has a g1t account. Without one, accepting makes the account and joins the workspace in one step, and uses one of the workspace's granted invites, or else one of yours. With one, it costs nothing, and they join when they accept. To add someone by username at once, use the workspace's People page. Owners only. A free workspace cannot invite anyone: this is refused with `payment_required` (402) until it starts the g1t plan, and an invite sent before cannot be accepted until then." | |
| 1258 | + | "Invite someone into a workspace, by `username` or by `email`. Nobody joins without saying yes: they get an invitation to accept or decline, and join with `role` (`member` unless you give `owner`) when they accept. By `username`, the account gets the invitation in its inbox and by email, and it costs nothing. By `email`, it always makes an invite bound to that address and emails it the link, so the answer never says whether the address has a g1t account. Without one, the link makes the account, which is invited once it confirms its address; that uses one of the workspace's granted invites, or else one of yours. With one, it costs nothing. Refused with `409` when the person is already a member or already has a pending invitation to the workspace. Owners only. A free workspace cannot invite anyone: this is refused with `payment_required` (402) until it starts the g1t plan, and an invite sent before cannot be accepted until then." | |
| 1250 | 1259 | } | |
| 1251 | 1260 | Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.", | |
| 1261 | + | Op::ListInvitations => { | |
| 1262 | + | "The invitations to workspaces waiting for your answer, newest first: each one's `id`, the `workspace` (`slug`, `name`, `avatar`), the `role` accepting gives (`member` or `owner`), who sent it (`invited_by`, null when g1t staff did), and when it was made and when it expires. Expired, revoked and answered ones are left out. Accept or decline each by its `id`. People only; an agent's or a workspace's token gets an empty list." | |
| 1263 | + | } | |
| 1264 | + | Op::AcceptInvitation => { | |
| 1265 | + | "Accept an invitation to a workspace sent to you. You join it at once with the role it names. Returns the workspace's slug in `workspace`. Refused with `404` when you have no open invitation with that id (it may have been answered, revoked or expired), with `403` until you confirm your email address or when your account does not meet what the workspace asks of its members, such as two-factor authentication, and with `payment_required` (402) while the workspace is free: it can add no one until it starts the g1t plan, and the invitation stays open until then. People only." | |
| 1266 | + | } | |
| 1267 | + | Op::DeclineInvitation => { | |
| 1268 | + | "Decline an invitation to a workspace sent to you. Whoever sent it is told in their inbox, and the workspace's owners can invite you again. People only." | |
| 1269 | + | } | |
| 1252 | 1270 | Op::DeleteWorkspace => { | |
| 1253 | 1271 | "Delete a workspace and everything in it. Owners only, signed in as a person, and confirm must be the workspace's slug. Billing must be able to settle it: no unpaid invoice, no prepaid credit left, and no usage this month still being metered; what it owes is charged to its card at once and its plan ends. Its repositories, projects and apps go with it at once, nobody can reach it, and its access tokens stop working. It is kept for 30 days, when g1t's support can restore it as it was; then it is purged, with its webhooks, integrations and workspace secrets. Its statements, invoices and audit log are kept. The slug is never given to another workspace; the person whose username it is may create it again once it is purged. Some workspaces, such as Flagon's, can never be deleted." | |
| 1254 | 1272 | } | |
| ⋯ | |||
| 1832 | 1850 | }, | |
| 1833 | 1851 | "workspace": { | |
| 1834 | 1852 | "type": "string", | |
| 1853 | + | "description": "The workspace the new account is invited to, by slug. Once it confirms its address it gets an invitation to join as a member, and no workspace of its own. One you own, on the g1t plan.", | |
| 1854 | + | }, | |
| 1855 | + | "charge_workspace": { | |
| 1856 | + | "type": "string", | |
| 1835 | 1857 | "description": "Use one of the invites g1t granted this workspace instead of yours, by slug. Owners only.", | |
| 1836 | 1858 | }, | |
| 1837 | 1859 | }), | |
| ⋯ | |||
| 1845 | 1867 | Op::InviteMember => object( | |
| 1846 | 1868 | json!({ | |
| 1847 | 1869 | "workspace": workspace_schema(), | |
| 1848 | − | "email": { "type": "string", "description": "The address to invite." }, | |
| 1870 | + | "username": { | |
| 1871 | + | "type": "string", | |
| 1872 | + | "description": "A g1t username to invite. Give this or email.", | |
| 1873 | + | }, | |
| 1874 | + | "email": { "type": "string", "description": "An address to invite. Give this or username." }, | |
| 1875 | + | "role": { | |
| 1876 | + | "type": "string", | |
| 1877 | + | "enum": ["member", "owner"], | |
| 1878 | + | "description": "The role they join with when they accept. member when left out.", | |
| 1879 | + | }, | |
| 1849 | 1880 | }), | |
| 1850 | − | &["workspace", "email"], | |
| 1881 | + | &["workspace"], | |
| 1851 | 1882 | ), | |
| 1883 | + | Op::ListInvitations => object(json!({}), &[]), | |
| 1884 | + | Op::AcceptInvitation | Op::DeclineInvitation => object( | |
| 1885 | + | json!({ | |
| 1886 | + | "id": { "type": "string", "description": "The invitation's id, from list_invitations." }, | |
| 1887 | + | }), | |
| 1888 | + | &["id"], | |
| 1889 | + | ), | |
| 1852 | 1890 | Op::RevokeWorkspaceInvite => object( | |
| 1853 | 1891 | json!({ | |
| 1854 | 1892 | "workspace": workspace_schema(), | |
| ⋯ | |||
| 3327 | 3365 | | Op::ListWorkspaceInvites | |
| 3328 | 3366 | | Op::InviteMember | |
| 3329 | 3367 | | Op::RevokeWorkspaceInvite | |
| 3368 | + | | Op::ListInvitations | |
| 3369 | + | | Op::AcceptInvitation | |
| 3370 | + | | Op::DeclineInvitation | |
| 3330 | 3371 | | Op::ListDeletedRepos | |
| 3331 | 3372 | | Op::SearchContext | |
| 3332 | 3373 | | Op::GetEntity | |
| ⋯ | |||
| 3642 | 3683 | &json!({ | |
| 3643 | 3684 | "user": actor(), | |
| 3644 | 3685 | "email": optional_text(input, "email"), | |
| 3645 | − | "workspace": optional_text(input, "workspace"), | |
| 3686 | + | "workspace": optional_text(input, "charge_workspace"), | |
| 3687 | + | "join": optional_text(input, "workspace"), | |
| 3646 | 3688 | "surface": services.audit.surface, | |
| 3647 | 3689 | }), | |
| 3648 | 3690 | ) | |
| ⋯ | |||
| 3655 | 3697 | pass(identity, "workspace_invites", &json!({ "slug": workspace(), "viewer": viewer })).await | |
| 3656 | 3698 | } | |
| 3657 | 3699 | Op::InviteMember => { | |
| 3700 | + | let role = optional_text(input, "role"); | |
| 3701 | + | if role.as_deref().is_some_and(|role| role != "member" && role != "owner") { | |
| 3702 | + | return failed(FailureCode::Invalid, "role is member or owner."); | |
| 3703 | + | } | |
| 3658 | 3704 | pass( | |
| 3659 | 3705 | identity, | |
| 3660 | 3706 | "invite_member", | |
| 3661 | 3707 | &json!({ | |
| 3662 | 3708 | "actor": actor(), | |
| 3663 | 3709 | "slug": workspace(), | |
| 3664 | − | "email": text(input, "email"), | |
| 3710 | + | "email": optional_text(input, "email").unwrap_or_default(), | |
| 3711 | + | "username": optional_text(input, "username"), | |
| 3712 | + | "role": role, | |
| 3665 | 3713 | "surface": services.audit.surface, | |
| 3666 | 3714 | }), | |
| 3667 | 3715 | ) | |
| 3668 | 3716 | .await | |
| 3669 | 3717 | } | |
| 3718 | + | Op::ListInvitations => { | |
| 3719 | + | let waiting: Vec<g1t_contracts::identity::WorkspaceInvitation> = | |
| 3720 | + | g1t_kit::call(identity, "list_invitations", &json!({ "user": actor() })).await?; | |
| 3721 | + | ok(&waiting) | |
| 3722 | + | } | |
| 3723 | + | Op::AcceptInvitation => { | |
| 3724 | + | let joined: Outcome<String> = call( | |
| 3725 | + | identity, | |
| 3726 | + | "accept_invitation", | |
| 3727 | + | &json!({ "user": actor(), "id": text(input, "id"), "surface": services.audit.surface }), | |
| 3728 | + | ) | |
| 3729 | + | .await?; | |
| 3730 | + | match joined { | |
| 3731 | + | Outcome::Ok(slug) => ok(&json!({ "workspace": slug })), | |
| 3732 | + | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 3733 | + | } | |
| 3734 | + | } | |
| 3735 | + | Op::DeclineInvitation => { | |
| 3736 | + | pass( | |
| 3737 | + | identity, | |
| 3738 | + | "decline_invitation", | |
| 3739 | + | &json!({ "user": actor(), "id": text(input, "id"), "surface": services.audit.surface }), | |
| 3740 | + | ) | |
| 3741 | + | .await | |
| 3742 | + | } | |
| 3670 | 3743 | Op::RevokeWorkspaceInvite => { | |
| 3671 | 3744 | pass( | |
| 3672 | 3745 | identity, | |
| 292 | 292 | "username": "ada", | |
| 293 | 293 | "email": "ada@example.com", | |
| 294 | 294 | "verified": true, | |
| 295 | − | "joined": "acme", | |
| 295 | + | "joined": null, | |
| 296 | + | "invited_to": "acme", | |
| 296 | 297 | "invite_lapsed": null | |
| 297 | 298 | }, | |
| 298 | − | "notes": "The code confirms the address it was sent to, and ends the link sent with it. A wrong, used or expired code answers `422` with one message for all three; after ten wrong codes in an hour the account answers `409` for a while, and the link in the email still works. `verified` says whether the account is confirmed: whether its primary address is. `joined` is set when confirming a new account's address joined the workspace its invite named; when the invite was revoked or expired, or the workspace deleted, while the account waited, the address is confirmed all the same and `invite_lapsed` says so. See [Confirming your email address](/guides/authentication/#confirming-your-email-address)." | |
| 299 | + | "notes": "The code confirms the address it was sent to, and ends the link sent with it. A wrong, used or expired code answers `422` with one message for all three; after ten wrong codes in an hour the account answers `409` for a while, and the link in the email still works. `verified` says whether the account is confirmed: whether its primary address is. `invited_to` is set when the invite the account signed up with brings it into a workspace: confirming the address sends the account an invitation to that workspace, which it accepts with `POST /user/invitations/{id}/accept` or declines; nobody joins a workspace without saying yes. `joined` is kept for older clients and is null. When the invite was revoked or expired, or the workspace deleted, while the account waited, the address is confirmed all the same and `invite_lapsed` says so. See [Confirming your email address](/guides/authentication/#confirming-your-email-address)." | |
| 299 | 300 | }, | |
| 300 | 301 | "remove_email": { | |
| 301 | 302 | "request": { | |
| ⋯ | |||
| 409 | 410 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 410 | 411 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 411 | 412 | "redeemed_at": null, | |
| 412 | − | "revoked_at": null | |
| 413 | + | "revoked_at": null, | |
| 414 | + | "invitee": null, | |
| 415 | + | "role": null | |
| 413 | 416 | }, | |
| 414 | 417 | { | |
| 415 | 418 | "id": "inv_01kp1v3c4d5e6f7g8h9j0k1m2n", | |
| ⋯ | |||
| 425 | 428 | "created_at": "2026-10-02T09:12:40.000Z", | |
| 426 | 429 | "expires_at": "2026-11-01T09:12:40.000Z", | |
| 427 | 430 | "redeemed_at": "2026-10-02T11:30:05.000Z", | |
| 428 | − | "revoked_at": null | |
| 431 | + | "revoked_at": null, | |
| 432 | + | "invitee": "grace", | |
| 433 | + | "role": null | |
| 429 | 434 | } | |
| 430 | 435 | ] | |
| 431 | 436 | }, | |
| 432 | − | "notes": "`allowance.limit` and `allowance.remaining` are null when you have no limit. A revoked or expired invite that was never used is not counted. Ask for more at hey@flagon.io with the subject `[g1t Invites]`. See [Invites](/guides/authentication/#invites)." | |
| 437 | + | "notes": "`allowance.limit` and `allowance.remaining` are null when you have no limit. A revoked or expired invite that was never used is not counted. `status` is `pending`, `awaiting_confirmation`, `awaiting_answer` (the account it made has yet to accept or decline the workspace in `workspace`), `redeemed`, `declined`, `expired` or `revoked`. Ask for more at hey@flagon.io with the subject `[g1t Invites]`. See [Invites](/guides/authentication/#invites)." | |
| 433 | 438 | }, | |
| 434 | 439 | "create_invite": { | |
| 435 | 440 | "request": { | |
| 436 | − | "email": "ada@example.com" | |
| 441 | + | "email": "ada@example.com", | |
| 442 | + | "workspace": "acme-labs" | |
| 437 | 443 | }, | |
| 438 | 444 | "response": { | |
| 439 | 445 | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y", | |
| ⋯ | |||
| 441 | 447 | "hint": "g1t-k7m2", | |
| 442 | 448 | "email": "ada@example.com", | |
| 443 | 449 | "kind": "account", | |
| 444 | − | "workspace": null, | |
| 450 | + | "workspace": "acme-labs", | |
| 445 | 451 | "status": "pending", | |
| 446 | 452 | "charged_to": "user", | |
| 447 | 453 | "invited_by": "syntaqx", | |
| ⋯ | |||
| 449 | 455 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 450 | 456 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 451 | 457 | "redeemed_at": null, | |
| 452 | − | "revoked_at": null | |
| 458 | + | "revoked_at": null, | |
| 459 | + | "invitee": null, | |
| 460 | + | "role": "member" | |
| 453 | 461 | }, | |
| 454 | − | "notes": "Refused with `402` (`limit`) when you have no invites left, with `409` when you already have a pending invite for that address or it already has a g1t account, and with `403` for an agent's or a workspace's token. The code is returned in full; send people the link `https://g1t.sh/invite/<code>`." | |
| 462 | + | "notes": "`workspace` brings the new account into a workspace you own: once it confirms its address it gets an invitation to join as a member, which it accepts or declines, and no workspace of its own is made for it. Leave `workspace` out and the new account gets a free workspace of its own. A workspace on the free plan cannot bring anyone in: `402` with `payment_required`, until it starts the g1t plan. `charge_workspace` is separate: it uses one of the invites g1t granted that workspace instead of one of yours, and works with or without `workspace`. Refused with `402` (`limit`) when you have no invites left, with `409` when you already have a pending invite for that address or it already has a g1t account, and with `403` for an agent's or a workspace's token. The code is returned in full; send people the link `https://g1t.sh/invite/<code>`." | |
| 455 | 463 | }, | |
| 456 | 464 | "revoke_invite": { | |
| 457 | 465 | "response": { | |
| ⋯ | |||
| 468 | 476 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 469 | 477 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 470 | 478 | "redeemed_at": null, | |
| 471 | − | "revoked_at": "2026-10-06T08:00:00.000Z" | |
| 479 | + | "revoked_at": "2026-10-06T08:00:00.000Z", | |
| 480 | + | "invitee": null, | |
| 481 | + | "role": null | |
| 472 | 482 | } | |
| 473 | 483 | }, | |
| 474 | 484 | "list_workspace_invites": { | |
| ⋯ | |||
| 487 | 497 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 488 | 498 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 489 | 499 | "redeemed_at": null, | |
| 490 | − | "revoked_at": null | |
| 500 | + | "revoked_at": null, | |
| 501 | + | "invitee": null, | |
| 502 | + | "role": "member" | |
| 491 | 503 | }, | |
| 492 | 504 | { | |
| 493 | 505 | "id": "inv_01kp1z9y8x7w6v5t4s3r2q1p0n", | |
| 494 | 506 | "code": "g1t-k7m2-q9xd-4hpw-…", | |
| 495 | 507 | "hint": "g1t-w2vb", | |
| 496 | − | "email": "linus@example.com", | |
| 508 | + | "email": null, | |
| 497 | 509 | "kind": "workspace", | |
| 498 | 510 | "workspace": "acme-labs", | |
| 499 | 511 | "status": "pending", | |
| ⋯ | |||
| 503 | 515 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 504 | 516 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 505 | 517 | "redeemed_at": null, | |
| 506 | − | "revoked_at": null | |
| 518 | + | "revoked_at": null, | |
| 519 | + | "invitee": "linus", | |
| 520 | + | "role": "owner" | |
| 507 | 521 | } | |
| 508 | 522 | ] | |
| 509 | 523 | }, | |
| 510 | 524 | "invite_member": { | |
| 511 | 525 | "request": { | |
| 512 | − | "email": "ada@example.com" | |
| 526 | + | "username": "ada", | |
| 527 | + | "role": "member" | |
| 513 | 528 | }, | |
| 514 | 529 | "response": { | |
| 515 | 530 | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y", | |
| 516 | 531 | "code": "g1t-k7m2-q9xd-4hpw-…", | |
| 517 | 532 | "hint": "g1t-k7m2", | |
| 518 | − | "email": "ada@example.com", | |
| 519 | − | "kind": "account", | |
| 533 | + | "email": null, | |
| 534 | + | "kind": "workspace", | |
| 520 | 535 | "workspace": "acme-labs", | |
| 521 | 536 | "status": "pending", | |
| 522 | − | "charged_to": "workspace", | |
| 537 | + | "charged_to": "none", | |
| 523 | 538 | "invited_by": "syntaqx", | |
| 524 | 539 | "redeemed_by": null, | |
| 525 | 540 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 526 | 541 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 527 | 542 | "redeemed_at": null, | |
| 528 | − | "revoked_at": null | |
| 543 | + | "revoked_at": null, | |
| 544 | + | "invitee": "ada", | |
| 545 | + | "role": "member" | |
| 529 | 546 | }, | |
| 530 | − | "notes": "`kind` is `workspace` when the address already has a g1t account (`charged_to` is then `none`), and `account` when accepting makes one. Both answers look alike to the caller on purpose: the invite is emailed either way. A free workspace cannot invite anyone: `402` with `payment_required`, until it starts the g1t plan." | |
| 547 | + | "notes": "Give `username` or `email`. Nobody is added without saying yes: the person gets an invitation to accept or decline, and joins with `role` when they accept. By username, `invitee` names the account and it costs nothing; `404` when there is no account with that username. By email, `kind` is `workspace` when the address already has a g1t account (`charged_to` is then `none`), and `account` when the link makes one; both answers look alike to the caller on purpose, and `invitee` stays null until an account is known. `409` when the person is already a member or already has a pending invitation to the workspace. A free workspace cannot invite anyone: `402` with `payment_required`, until it starts the g1t plan. See [Workspace invitations](/reference/api/invites/list-invitations/) for the other side." | |
| 531 | 548 | }, | |
| 532 | 549 | "revoke_workspace_invite": { | |
| 533 | 550 | "response": { | |
| ⋯ | |||
| 544 | 561 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 545 | 562 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 546 | 563 | "redeemed_at": null, | |
| 547 | − | "revoked_at": "2026-10-06T08:00:00.000Z" | |
| 564 | + | "revoked_at": "2026-10-06T08:00:00.000Z", | |
| 565 | + | "invitee": null, | |
| 566 | + | "role": "member" | |
| 548 | 567 | } | |
| 549 | 568 | }, | |
| 569 | + | "list_invitations": { | |
| 570 | + | "response": [ | |
| 571 | + | { | |
| 572 | + | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y", | |
| 573 | + | "workspace": { | |
| 574 | + | "slug": "acme-labs", | |
| 575 | + | "name": "Acme Labs", | |
| 576 | + | "avatar": null | |
| 577 | + | }, | |
| 578 | + | "role": "member", | |
| 579 | + | "invited_by": { | |
| 580 | + | "username": "syntaqx", | |
| 581 | + | "name": "Chase", | |
| 582 | + | "avatar": null | |
| 583 | + | }, | |
| 584 | + | "created_at": "2026-10-05T17:00:00.000Z", | |
| 585 | + | "expires_at": "2026-11-04T17:00:00.000Z" | |
| 586 | + | } | |
| 587 | + | ], | |
| 588 | + | "notes": "Accept one with `POST /user/invitations/{id}/accept`, or decline it with `POST /user/invitations/{id}/decline`. An agent's or a workspace's token gets an empty list." | |
| 589 | + | }, | |
| 590 | + | "accept_invitation": { | |
| 591 | + | "params": { | |
| 592 | + | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y" | |
| 593 | + | }, | |
| 594 | + | "response": { | |
| 595 | + | "workspace": "acme-labs" | |
| 596 | + | }, | |
| 597 | + | "notes": "You join at once with the invitation's `role`. `404` when you have no open invitation with that id (it may have been answered, revoked or expired). `403` until you confirm your email address, and when your account does not meet what the workspace asks of its members, such as two-factor authentication, with a message that says what to turn on. `402` with `payment_required` while the workspace is on the free plan; the invitation stays open until it starts the g1t plan. Recorded as `member.added`." | |
| 598 | + | }, | |
| 599 | + | "decline_invitation": { | |
| 600 | + | "params": { | |
| 601 | + | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y" | |
| 602 | + | }, | |
| 603 | + | "response": true, | |
| 604 | + | "notes": "Whoever sent it is told in their inbox. The workspace's owners can invite you again. `404` when you have no open invitation with that id." | |
| 605 | + | }, | |
| 550 | 606 | "list_repos": { | |
| 551 | 607 | "query": { | |
| 552 | 608 | "q": "hello" | |
| 210 | 210 | through::<g1t_contracts::identity::Invite>(op, sent) | |
| 211 | 211 | } | |
| 212 | 212 | Op::ListWorkspaceInvites => through::<Vec<g1t_contracts::identity::Invite>>(op, sent), | |
| 213 | + | Op::ListInvitations => through::<Vec<g1t_contracts::identity::WorkspaceInvitation>>(op, sent), | |
| 213 | 214 | Op::ListNotifications => through::<g1t_contracts::inbox::InboxPage>(op, sent), | |
| 214 | 215 | Op::GetNotificationThread | Op::MarkThreadRead | Op::MarkThreadDone | Op::SaveThread | Op::SnoozeThread => { | |
| 215 | 216 | through::<g1t_contracts::inbox::InboxThread>(op, sent) |
| 50 | 50 | route("GET", "/user/invites", Op::ListInvites, &[]), | |
| 51 | 51 | route("POST", "/user/invites", Op::CreateInvite, &[]), | |
| 52 | 52 | route("DELETE", "/user/invites/:id", Op::RevokeInvite, &[]), | |
| 53 | + | // Invitations to workspaces waiting for your answer. | |
| 54 | + | route("GET", "/user/invitations", Op::ListInvitations, &[]), | |
| 55 | + | route("POST", "/user/invitations/:id/accept", Op::AcceptInvitation, &[]), | |
| 56 | + | route("POST", "/user/invitations/:id/decline", Op::DeclineInvitation, &[]), | |
| 53 | 57 | route("GET", "/workspaces/:workspace/invitations", Op::ListWorkspaceInvites, &[]), | |
| 54 | 58 | // A workspace's rules for personal access tokens, and its members' tokens. | |
| 55 | 59 | route("GET", "/workspaces/:workspace/personal-access-token-policy", Op::Tokens(TokenOp::GetTokenPolicy), &[]), |
| 384 | 384 | a("transfer_ownership", Op::TransferOwnership, "Hand it to another member: they become an owner, you a member"), | |
| 385 | 385 | a("leave", Op::LeaveWorkspace, "Leave it yourself"), | |
| 386 | 386 | a("list_invites", Op::ListWorkspaceInvites, "Its invites"), | |
| 387 | − | a("invite_member", Op::InviteMember, "Invite an email address"), | |
| 387 | + | a("invite_member", Op::InviteMember, "Invite someone by username or email address"), | |
| 388 | 388 | a("revoke_invite", Op::RevokeWorkspaceInvite, "Revoke a pending invite"), | |
| 389 | 389 | a("list_integrations", Op::ListIntegrations, "Model providers, alert sources, trackers"), | |
| 390 | 390 | a("connect_integration", Op::ConnectIntegration, "Connect one"), | |
| ⋯ | |||
| 494 | 494 | Tool { | |
| 495 | 495 | name: "account", | |
| 496 | 496 | title: "Your account", | |
| 497 | − | description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to repositories waiting for you.", | |
| 497 | + | description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to workspaces and repositories waiting for you.", | |
| 498 | 498 | default_action: Some("whoami"), | |
| 499 | 499 | actions: &[ | |
| 500 | 500 | a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"), | |
| ⋯ | |||
| 506 | 506 | a("list_invites", Op::ListInvites, "Your invites to g1t"), | |
| 507 | 507 | a("create_invite", Op::CreateInvite, "Make an invite"), | |
| 508 | 508 | a("revoke_invite", Op::RevokeInvite, "Revoke one"), | |
| 509 | + | a("list_workspace_invitations", Op::ListInvitations, "Invitations to workspaces for you"), | |
| 510 | + | a("accept_workspace_invitation", Op::AcceptInvitation, "Accept one and join"), | |
| 511 | + | a("decline_workspace_invitation", Op::DeclineInvitation, "Decline one"), | |
| 509 | 512 | a("list_repository_invitations", Op::ListMyRepoInvitations, "Invitations to repositories for you"), | |
| 510 | 513 | a("accept_repository_invitation", Op::AcceptRepoInvitation, "Accept one"), | |
| 511 | 514 | a("decline_repository_invitation", Op::DeclineRepoInvitation, "Decline one"), | |
| 252 | 252 | ||
| 253 | 253 | Owners see every outside collaborator, and the repositories and roles each | |
| 254 | 254 | has, on the **Outside collaborators** tab of the workspace's | |
| 255 | − | **People**. **Convert to member** adds one to the workspace | |
| 256 | − | (see [members and roles](/guides/workspaces/#members-and-roles)); the | |
| 257 | − | roles they have stay, and the base permission adds to them. | |
| 255 | + | **People**. **Invite as a member** sends one an invitation to join the | |
| 256 | + | workspace as a member (see [add people](/guides/workspaces/#add-people)); | |
| 257 | + | once they accept, the roles they have stay, and the base permission adds | |
| 258 | + | to them. | |
| 258 | 259 | ||
| 259 | 260 | Removing a member from a workspace, or their leaving it, also removes the | |
| 260 | 261 | roles they were given on its repositories, and takes them out of its teams. |
| 146 | 146 | for an account that has confirmed that address. The page says so and | |
| 147 | 147 | offers **Sign out and continue**. | |
| 148 | 148 | ||
| 149 | − | Once you have signed in, or your new account has confirmed its address, | |
| 150 | − | you land in the workspace (or the repository) the invite was for, already | |
| 151 | − | a member, with a one-time "You're in" banner, and it becomes the workspace | |
| 152 | − | your sidebar shows. | |
| 149 | + | Nobody joins a workspace without saying yes. With an existing account, | |
| 150 | + | accepting on the invite's page is that yes: you land in the workspace (or | |
| 151 | + | the repository) the invite was for, with a one-time "You're in" banner, | |
| 152 | + | and it becomes the workspace your sidebar shows. | |
| 153 | + | ||
| 154 | + | A new account made from an invite that names a workspace is invited to | |
| 155 | + | it: once the account's address is confirmed, g1t takes you to | |
| 156 | + | [g1t.sh/invitations](https://g1t.sh/invitations), where you | |
| 157 | + | [accept or decline](#workspace-invitations) it. Until you answer, you have | |
| 158 | + | no workspace of your own, so you never end up with two. An invite that | |
| 159 | + | names no workspace gives the new account a workspace of its own instead; | |
| 160 | + | see [your first workspace](#your-first-workspace). | |
| 153 | 161 | ||
| 154 | 162 | Signing up spends the invite at once, so nobody else can use it while you | |
| 155 | − | confirm your address, but you join its workspace only when you confirm, in | |
| 156 | − | the same step. Until then the invite shows as **confirming their email** to | |
| 157 | − | whoever made it, and they can still revoke it. If the invite is revoked or | |
| 158 | − | expires, or its workspace is deleted, before you confirm, your address is | |
| 159 | − | confirmed all the same and g1t tells you the invite no longer applies: ask | |
| 160 | − | whoever invited you to add you again. A | |
| 163 | + | confirm your address. Until you confirm, the invite shows as **confirming | |
| 164 | + | their email** to whoever made it, and they can still revoke it. If the | |
| 165 | + | invite is revoked or expires, or its workspace is deleted, before you | |
| 166 | + | confirm, your address is confirmed all the same and g1t tells you the | |
| 167 | + | invite no longer applies: ask whoever invited you to invite you again. A | |
| 161 | 168 | code typed at [g1t.sh/register](https://g1t.sh/register) goes to the | |
| 162 | 169 | same page. | |
| 163 | 170 | ||
| ⋯ | |||
| 174 | 181 | ||
| 175 | 182 | - the invite page says the address is confirmed because you came from the | |
| 176 | 183 | invite email, and the email field stays locked to it; | |
| 177 | − | - your new account starts with the address confirmed: no code is sent, and | |
| 178 | − | you land in the workspace or repository the invite was for straight | |
| 179 | − | away. | |
| 184 | + | - your new account starts with the address confirmed: no code is sent. A | |
| 185 | + | repository the invite was for is yours straight away; a workspace it | |
| 186 | + | names is a [workspace invitation](#workspace-invitations) you accept or | |
| 187 | + | decline straight away, since nobody joins a workspace without saying yes. | |
| 180 | 188 | ||
| 181 | 189 | Anything else confirms the address the usual way, after you sign up: the | |
| 182 | 190 | code typed at [g1t.sh/register](https://g1t.sh/register), an invite link | |
| ⋯ | |||
| 217 | 225 | 2. Optionally enter the email address of the person you are inviting. | |
| 218 | 226 | With one, g1t emails them the invite, and only that address can use it. | |
| 219 | 227 | Without one, anyone with the link can, once. | |
| 220 | − | 3. Select **Create invite**, then copy the link. | |
| 228 | + | 3. Under **Bring them into**, choose the workspace they are invited to | |
| 229 | + | join, or **No workspace — they'll get their own**. The workspace you | |
| 230 | + | are in is chosen to start with, when you can bring people into it. | |
| 231 | + | 4. Select **Create invite**, then copy the link. | |
| 232 | + | ||
| 233 | + | **Bring them into** lists the workspaces you can add members to: the ones | |
| 234 | + | you own that are on the g1t plan. A workspace on the free plan cannot add | |
| 235 | + | people, so it is not offered, and the form says so when it is the one you | |
| 236 | + | are in. With a workspace chosen, the new account gets a | |
| 237 | + | [workspace invitation](#workspace-invitations) to it, to join as a member, | |
| 238 | + | once its address is confirmed; it is not given a workspace of its own. | |
| 221 | 239 | ||
| 222 | 240 | Each person can have **5** invites out at a time. Pending and used invites | |
| 223 | 241 | count; an invite you revoke, or one that expires before anyone uses it, | |
| 224 | 242 | comes back to you. The list under the form shows each invite's state: | |
| 225 | 243 | pending, confirming their email (used to sign up by someone who has not | |
| 226 | − | confirmed their address yet), joined (with the username of who joined), | |
| 227 | − | expired or revoked. You | |
| 244 | + | confirmed their address yet), waiting for them to accept (the account is | |
| 245 | + | made and confirmed, and the workspace invitation waits for its answer), | |
| 246 | + | joined (with the username of who joined), declined, expired or revoked. You | |
| 228 | 247 | must confirm your email before you can make invites. An agent's token and | |
| 229 | 248 | a workspace's token cannot make them. | |
| 230 | 249 | ||
| 231 | 250 | ### Inviting someone into a workspace | |
| 232 | 251 | ||
| 233 | − | An owner can invite an email address straight into a workspace from its | |
| 234 | − | People page; see [members and roles](/guides/workspaces/#members-and-roles). | |
| 235 | − | When the address has no g1t account, the invite makes the account, which | |
| 236 | − | joins the workspace once it confirms its email address (at once when it | |
| 237 | − | was made from the invite email's link), and it uses one invite. Inviting someone who is | |
| 238 | − | already on g1t costs nothing. | |
| 252 | + | An owner can invite someone into a workspace from its People page, by | |
| 253 | + | username or by email address, with the role they join as; see | |
| 254 | + | [add people](/guides/workspaces/#add-people). Nobody is added without | |
| 255 | + | saying yes: someone on g1t gets a [workspace invitation](#workspace-invitations) | |
| 256 | + | to accept or decline. When an address has no g1t account, the invite makes | |
| 257 | + | the account first, and the invitation follows once the account's address | |
| 258 | + | is confirmed (at once when it was made from the invite email's link); it | |
| 259 | + | uses one invite. Inviting someone who is already on g1t costs nothing. | |
| 260 | + | ||
| 261 | + | ### Workspace invitations | |
| 262 | + | ||
| 263 | + | A workspace invitation asks one account to join one workspace, with the | |
| 264 | + | role chosen when it was sent. You hear of it in your inbox and by email, | |
| 265 | + | and answer it at [g1t.sh/invitations](https://g1t.sh/invitations): | |
| 266 | + | ||
| 267 | + | - **Accept** joins the workspace with that role, and takes you there. | |
| 268 | + | - **Decline** joins nothing; whoever invited you is told in their inbox. | |
| 269 | + | ||
| 270 | + | An invitation works for 30 days, the same as an invite. Until it is | |
| 271 | + | answered, the workspace's owners see it under **Pending invitations** on | |
| 272 | + | its People page and can revoke it. A workspace on the free plan cannot add | |
| 273 | + | people, so an invitation to one cannot be accepted until it starts the | |
| 274 | + | plan. Only you can answer your invitations: an agent's token and a | |
| 275 | + | workspace's token cannot. | |
| 276 | + | ||
| 277 | + | ### Your first workspace | |
| 239 | 278 | ||
| 279 | + | Everything on g1t lives in a workspace, so every new account gets one: | |
| 280 | + | ||
| 281 | + | - An account whose invite brings it into a workspace gets the invitation | |
| 282 | + | to it, and no workspace of its own. | |
| 283 | + | - Every other account (signed up with a password, with GitHub, from a | |
| 284 | + | shared invite link or an invite from g1t staff, or with an invite that | |
| 285 | + | names no workspace) gets a workspace of its own, named for its username, | |
| 286 | + | on the free plan. Rename it or start the plan on it whenever you like. | |
| 287 | + | ||
| 288 | + | Signed in without any workspace (you declined an invitation, or left the | |
| 289 | + | only workspace you were in), g1t shows **Create your workspace or ask to | |
| 290 | + | join one** in place of Mission control: the invitations waiting for you, if | |
| 291 | + | any, and the form to create a workspace. To join a team already on g1t, | |
| 292 | + | ask one of its owners to invite you by your username. | |
| 293 | + | ||
| 240 | 294 | ### Need more invites? | |
| 241 | 295 | ||
| 242 | 296 | Write to [hey@flagon.io](mailto:hey@flagon.io?subject=%5Bg1t%20Invites%5D%20) | |
| ⋯ | |||
| 262 | 316 | | Route | MCP tool and action | What it does | | |
| 263 | 317 | | --- | --- | --- | | |
| 264 | 318 | | [`GET /user/invites`](/reference/api/invites/list-invites/) | `account` `list_invites` | Your invites and how many you have left | | |
| 265 | − | | [`POST /user/invites`](/reference/api/invites/create-invite/) | `account` `create_invite` | Make an invite, optionally for one `email` | | |
| 319 | + | | [`POST /user/invites`](/reference/api/invites/create-invite/) | `account` `create_invite` | Make an invite, optionally for one `email`; with `workspace` (a slug), the new account is invited to that workspace | | |
| 266 | 320 | | [`DELETE /user/invites/{id}`](/reference/api/invites/revoke-invite/) | `account` `revoke_invite` | Revoke a pending invite, or one whose new account has not confirmed its address | | |
| 267 | − | | [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/) | `workspace` `invite_member` | Invite an address into a workspace. Owners only. | | |
| 321 | + | | [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/) | `workspace` `invite_member` | Invite a `username` or an `email` into a workspace, with a `role`. Owners only. | | |
| 322 | + | | [`GET /user/invitations`](/reference/api/invites/list-invitations/) | `account` `list_workspace_invitations` | The workspace invitations waiting for your answer | | |
| 323 | + | | [`POST /user/invitations/{id}/accept`](/reference/api/invites/accept-invitation/) | `account` `accept_workspace_invitation` | Join the invitation's workspace with its role | | |
| 324 | + | | [`POST /user/invitations/{id}/decline`](/reference/api/invites/decline-invitation/) | `account` `decline_invitation` | Decline it; whoever sent it is told | | |
| 268 | 325 | ||
| 269 | 326 | ## Confirming your email address | |
| 270 | 327 | ||
| ⋯ | |||
| 307 | 364 | - **The MCP server** answers `403` with the same message. | |
| 308 | 365 | - **Git** over HTTPS refuses pushes and fetches with your credentials, with | |
| 309 | 366 | the same message. Package registries treat them as wrong credentials. | |
| 310 | − | - You cannot create a workspace, join the one your invite named, make | |
| 367 | + | - You cannot create a workspace, answer the invitation your invite brought, make | |
| 311 | 368 | invites or tokens, or approve a tool's sign-in. | |
| 312 | 369 | ||
| 313 | 370 | You cannot make a token before you confirm, so the API and MCP refusals | |
| ⋯ | |||
| 341 | 398 | | [`POST /user/emails/confirm`](/reference/api/accounts/confirm-email/) | `account` `confirm_email` | Confirm an address with the `code` from its email | | |
| 342 | 399 | ||
| 343 | 400 | The answer says whether the account is now confirmed (`verified`), the | |
| 344 | − | workspace confirming joined it to (`joined`), or why its invite no longer | |
| 345 | − | applies (`invite_lapsed`). | |
| 401 | + | workspace its invite invites it to (`invited_to`: accept or decline it with | |
| 402 | + | [`POST /user/invitations/{id}/accept`](/reference/api/invites/accept-invitation/) | |
| 403 | + | or [`/decline`](/reference/api/invites/decline-invitation/)), or why its | |
| 404 | + | invite no longer applies (`invite_lapsed`). `joined` is always null: nothing | |
| 405 | + | is joined without an answer. | |
| 346 | 406 | ||
| 347 | 407 | ## Email addresses | |
| 348 | 408 | ||
| ⋯ | |||
| 464 | 524 | ||
| 465 | 525 | ## Workspaces | |
| 466 | 526 | ||
| 467 | − | Your account does not own repositories itself: a workspace does. After | |
| 468 | − | confirming your email, the first thing you do is create one. Workspaces, | |
| 527 | + | Your account does not own repositories itself: a workspace does. A new | |
| 528 | + | account gets one of its own, named for its username, unless its invite | |
| 529 | + | brings it into one; see [your first workspace](#your-first-workspace). Workspaces, | |
| 469 | 530 | their members and roles, and the access tokens that belong to a workspace | |
| 470 | 531 | are covered in [workspaces](/guides/workspaces/). | |
| 471 | 532 | ||
| 10 | 10 | ||
| 11 | 11 | ## Create a workspace | |
| 12 | 12 | ||
| 13 | − | Your account does not own repositories itself. After confirming your email | |
| 14 | − | the first thing you do is create a workspace, and repositories go in it. | |
| 13 | + | Your account does not own repositories itself: a workspace does, and | |
| 14 | + | repositories go in it. Every new account gets a workspace of its own, named | |
| 15 | + | for its username and on the free plan, unless its invite brings it into | |
| 16 | + | someone else's workspace; see | |
| 17 | + | [your first workspace](/guides/authentication/#your-first-workspace). | |
| 18 | + | Signed in without a workspace, g1t shows **Create your workspace or ask to | |
| 19 | + | join one** in place of Mission control: the invitations waiting for you, | |
| 20 | + | and the form below. | |
| 21 | + | ||
| 22 | + | To create another: | |
| 15 | 23 | ||
| 16 | 24 | 1. Open [g1t.sh/workspaces/new](https://g1t.sh/workspaces/new). | |
| 17 | 25 | 2. Choose its name in URLs: lowercase letters, digits and single hyphens. | |
| ⋯ | |||
| 320 | 328 | ||
| 321 | 329 | ### Add people | |
| 322 | 330 | ||
| 323 | − | Whoever creates a workspace is its owner. An owner adds people on the | |
| 324 | − | workspace's **People**, `g1t.sh/<workspace>/-/people` (in the sidebar): | |
| 331 | + | Whoever creates a workspace is its owner. Nobody is added to a workspace | |
| 332 | + | without saying yes: an owner invites people, and each person accepts or | |
| 333 | + | declines. On the workspace's **People**, `g1t.sh/<workspace>/-/people` (in | |
| 334 | + | the sidebar): | |
| 335 | + | ||
| 336 | + | 1. Under **Invite someone**, type a username, a name or an email address. | |
| 337 | + | As you type, people on g1t are offered by username and name, with their | |
| 338 | + | pictures; hover over one for their card. Only usernames, names and | |
| 339 | + | pictures are shown, never anyone's email address. | |
| 340 | + | 2. Choose the **Role** they join with: **Member** or **Owner**. | |
| 341 | + | 3. Select **Invite**. | |
| 325 | 342 | ||
| 326 | − | - **By username**: someone already on g1t joins at once, as a member. | |
| 343 | + | - **By username**: they get a | |
| 344 | + | [workspace invitation](/guides/authentication/#workspace-invitations) in | |
| 345 | + | their inbox and by email, and join with that role when they accept at | |
| 346 | + | [g1t.sh/invitations](https://g1t.sh/invitations). If they decline, you | |
| 347 | + | are told in your inbox. It costs nothing. | |
| 327 | 348 | - **By email address**: g1t emails an invite that only that address can | |
| 328 | − | use. Without a g1t account, accepting it makes the account and joins the | |
| 329 | − | workspace in one step, and uses one of the workspace's granted invites, or | |
| 330 | − | else one of yours (see [invites](/guides/authentication/#invites)). With | |
| 331 | − | an account, it costs nothing, and they join when they accept. The page | |
| 332 | − | never says which it was. | |
| 349 | + | use. With a g1t account, it is a workspace invitation like the one above | |
| 350 | + | and costs nothing. Without one, the invite makes the account first, and | |
| 351 | + | uses one of the workspace's granted invites, or else one of yours (see | |
| 352 | + | [invites](/guides/authentication/#invites)); the new account is then | |
| 353 | + | invited to the workspace, and joins when it accepts. The page never says | |
| 354 | + | which it was. | |
| 333 | 355 | ||
| 334 | 356 | The email names you and the workspace and links to the invite's page. | |
| 335 | − | Someone new signs up right there, with the invited address filled in, and | |
| 336 | − | joins at once when they opened the page from that email (it proves the | |
| 337 | − | address is theirs), or otherwise once they confirm it with the code g1t | |
| 338 | − | emails them; someone with an account signs in. Either way they land in the workspace as a member, with | |
| 339 | − | a one-time welcome. Until a new account confirms its address, its invite | |
| 340 | − | shows as **confirming their email** under the members, and you can still | |
| 341 | − | revoke it. See | |
| 357 | + | Someone new signs up right there, with the invited address filled in; once | |
| 358 | + | the address is confirmed (straight away when they opened the page from | |
| 359 | + | that email, which proves the address is theirs, otherwise with the code g1t | |
| 360 | + | emails them), they are asked to accept or decline the invitation. Someone | |
| 361 | + | with an account signs in and accepts on the page. Accepting lands them in | |
| 362 | + | the workspace, with a one-time welcome. See | |
| 342 | 363 | [using an invite](/guides/authentication/#using-an-invite). | |
| 343 | 364 | ||
| 344 | − | Pending invites are listed under the members, with a link to copy and | |
| 345 | − | **Revoke**. Through the API, use | |
| 365 | + | Pending invitations are listed under the members, with the person or | |
| 366 | + | address, the role, until when it works (30 days), a link to copy and | |
| 367 | + | **Revoke**: one waiting to be used, one whose new account is **confirming | |
| 368 | + | their email**, and one **waiting for them to accept**. Converting an | |
| 369 | + | outside collaborator to a member sends them an invitation the same way. | |
| 370 | + | Through the API, use | |
| 346 | 371 | [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/) | |
| 347 | − | (the `workspace` tool's `invite_member` action over MCP). | |
| 372 | + | with a `username` or an `email` and a `role` (the `workspace` tool's | |
| 373 | + | `invite_member` action over MCP); the person answers with | |
| 374 | + | [`POST /user/invitations/{id}/accept`](/reference/api/invites/accept-invitation/) | |
| 375 | + | or [`/decline`](/reference/api/invites/decline-invitation/). | |
| 348 | 376 | ||
| 349 | 377 | To give someone a role on one repository without making them a member, | |
| 350 | 378 | add them as an [outside collaborator](/guides/access-and-roles/#outside-collaborators). | |
| 656 | 656 | | [`leave`](/reference/api/members/leave-workspace/) | Leave it yourself. Never the last owner. | `workspace` | `account:write` | | |
| 657 | 657 | | [`delete`](/reference/api/workspaces/delete-workspace/) | Delete an empty workspace whose billing is settled; `confirm` is its slug. Owners only. See [deleting a workspace](/guides/workspaces/#delete-a-workspace). | `workspace`, `confirm` | `workspace:admin` | | |
| 658 | 658 | | [`list_invites`](/reference/api/invites/list-workspace-invites/) | A workspace's invites. Owners only. | `workspace` | `workspace:read` | | |
| 659 | − | | [`invite_member`](/reference/api/invites/invite-member/) | Invite an address into a workspace, with an invite bound to it. Owners only. A free workspace cannot invite: refused with `402` until it starts the plan. | `workspace`, `email` | `workspace:admin` | | |
| 659 | + | | [`invite_member`](/reference/api/invites/invite-member/) | Invite someone into a workspace by `username` or `email`, to join with `role` (`member` or `owner`; `member` when left out). Nobody joins without saying yes: they get an invitation to accept or decline. Owners only. A free workspace cannot invite: refused with `402` until it starts the plan. | `workspace`, and `username` or `email` | `workspace:admin` | | |
| 660 | 660 | | [`revoke_invite`](/reference/api/invites/revoke-workspace-invite/) | Revoke a workspace's pending invite. Owners only. | `workspace`, `id` | `workspace:admin` | | |
| 661 | 661 | | [`list_integrations`](/reference/api/integrations/list-integrations/) | The workspace's connections. Secrets are never returned. Members only. | `workspace` | `workspace:read` | | |
| 662 | 662 | | [`connect_integration`](/reference/api/integrations/connect-integration/) | Connect a model provider (Anthropic, OpenAI, Gemini, or a compatible endpoint), Sentry, Datadog, a webhook, Jira or Linear, with `config` and `secret`. Owners only. | `workspace`, `provider` | `workspace:admin` | | |
| ⋯ | |||
| 737 | 737 | ||
| 738 | 738 | Who the token acts as and its workspaces, your email addresses, your | |
| 739 | 739 | invites while g1t is [invite-only](/guides/authentication/#invites), and | |
| 740 | − | invitations to repositories waiting for you. `whoami` is the default | |
| 740 | + | invitations to workspaces and repositories waiting for you. `whoami` is the default | |
| 741 | 741 | action, and needs no scope. An agent's token and a workspace's token cannot | |
| 742 | 742 | use the email and invite actions. An account that has not confirmed its | |
| 743 | 743 | email address gets `403` from every tool until it does; see | |
| ⋯ | |||
| 752 | 752 | | [`remove_email`](/reference/api/accounts/remove-email/) | Remove an address; never the primary or the last confirmed one. | `email`, `password` | `account:write` | | |
| 753 | 753 | | [`update_email_settings`](/reference/api/accounts/update-email-settings/) | Change `primary` or `backup` (with `password`), `private_email` or `block_private_pushes`. See [email addresses](/guides/authentication/#email-addresses). | None | `account:write` | | |
| 754 | 754 | | [`list_invites`](/reference/api/invites/list-invites/) | Your invites, newest first, and how many you have left. | None | `account:read` | | |
| 755 | − | | [`create_invite`](/reference/api/invites/create-invite/) | Make an invite; with `email`, only that address can use it and it is emailed there. With `workspace`, use that workspace's granted invites. | None | `account:write` | | |
| 755 | + | | [`create_invite`](/reference/api/invites/create-invite/) | Make an invite; with `email`, only that address can use it and it is emailed there. With `workspace`, the new account is invited to that workspace (one you own, on the g1t plan) once it confirms its address, instead of getting a workspace of its own. With `charge_workspace`, use that workspace's granted invites instead of yours. | None | `account:write` | | |
| 756 | 756 | | [`revoke_invite`](/reference/api/invites/revoke-invite/) | Revoke a pending invite; it comes back to whoever it was charged to. | `id` | `account:write` | | |
| 757 | + | | [`list_workspace_invitations`](/reference/api/invites/list-invitations/) | The invitations to workspaces waiting for your answer, each with its `workspace`, the `role` it gives and who sent it. | None | `account:read` | | |
| 758 | + | | [`accept_workspace_invitation`](/reference/api/invites/accept-invitation/) | Accept one; you join the workspace at once with its role. | `id` | `account:write` | | |
| 759 | + | | [`decline_workspace_invitation`](/reference/api/invites/decline-invitation/) | Decline one; whoever sent it is told. | `id` | `account:write` | | |
| 757 | 760 | | [`list_repository_invitations`](/reference/api/access/list-my-repo-invitations/) | The invitations to repositories waiting for your answer. | None | `account:read` | | |
| 758 | 761 | | [`accept_repository_invitation`](/reference/api/access/accept-repo-invitation/) | Accept one; its role is yours at once. | `id` | `account:write` | | |
| 759 | 762 | | [`decline_repository_invitation`](/reference/api/access/decline-repo-invitation/) | Decline one. | `id` | `account:write` | | |
Binary or large file; its contents are not shown.
| 1 | + | import { Form } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { WorkspaceInvitation } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import { Avatar, ErrorText, SubmitButton } from "./ui"; | |
| 6 | + | import { UserCard } from "./user-card"; | |
| 7 | + | import { invitationLine } from "../lib/invitations"; | |
| 8 | + | ||
| 9 | + | /** | |
| 10 | + | * The workspace invitations waiting for the person's answer, each with | |
| 11 | + | * Accept and Decline. Accepting joins with the role it names; declining | |
| 12 | + | * tells whoever sent it. `next` is where accepting goes, when not to the | |
| 13 | + | * workspace itself. | |
| 14 | + | */ | |
| 15 | + | export function InvitationList({ | |
| 16 | + | invitations, | |
| 17 | + | error, | |
| 18 | + | errorFor, | |
| 19 | + | next, | |
| 20 | + | }: { | |
| 21 | + | invitations: WorkspaceInvitation[]; | |
| 22 | + | error?: string | null; | |
| 23 | + | errorFor?: string | null; | |
| 24 | + | next?: string | null; | |
| 25 | + | }) { | |
| 26 | + | return ( | |
| 27 | + | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 28 | + | {invitations.map((invitation) => ( | |
| 29 | + | <li key={invitation.id} className="px-4 py-4"> | |
| 30 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 31 | + | <Avatar name={invitation.workspace.slug} image={invitation.workspace.avatar} size={32} square /> | |
| 32 | + | <div className="min-w-0 grow basis-48"> | |
| 33 | + | <p className="text-sm font-medium"> | |
| 34 | + | {invitation.workspace.name}{" "} | |
| 35 | + | <span className="font-mono text-xs font-normal text-faint">{invitation.workspace.slug}</span> | |
| 36 | + | </p> | |
| 37 | + | <p className="mt-0.5 text-xs text-muted"> | |
| 38 | + | {invitation.invitedBy ? ( | |
| 39 | + | <> | |
| 40 | + | <UserCard username={invitation.invitedBy.username}> | |
| 41 | + | <span className="font-mono text-fg/90">@{invitation.invitedBy.username}</span> | |
| 42 | + | </UserCard>{" "} | |
| 43 | + | </> | |
| 44 | + | ) : ( | |
| 45 | + | "The g1t team " | |
| 46 | + | )} | |
| 47 | + | {invitationLine(invitation)} | |
| 48 | + | </p> | |
| 49 | + | </div> | |
| 50 | + | <div className="flex items-center gap-2"> | |
| 51 | + | <Form method="post"> | |
| 52 | + | <input type="hidden" name="intent" value="decline-invitation" /> | |
| 53 | + | <input type="hidden" name="id" value={invitation.id} /> | |
| 54 | + | <input type="hidden" name="workspace" value={invitation.workspace.name} /> | |
| 55 | + | <SubmitButton variant="quiet" match={{ intent: "decline-invitation", id: invitation.id }} pending="Declining…"> | |
| 56 | + | Decline | |
| 57 | + | </SubmitButton> | |
| 58 | + | </Form> | |
| 59 | + | <Form method="post"> | |
| 60 | + | <input type="hidden" name="intent" value="accept-invitation" /> | |
| 61 | + | <input type="hidden" name="id" value={invitation.id} /> | |
| 62 | + | {next && <input type="hidden" name="next" value={next} />} | |
| 63 | + | <SubmitButton match={{ intent: "accept-invitation", id: invitation.id }} pending="Joining…"> | |
| 64 | + | Accept | |
| 65 | + | </SubmitButton> | |
| 66 | + | </Form> | |
| 67 | + | </div> | |
| 68 | + | </div> | |
| 69 | + | {errorFor === invitation.id && ( | |
| 70 | + | <div className="mt-2"> | |
| 71 | + | <ErrorText>{error}</ErrorText> | |
| 72 | + | </div> | |
| 73 | + | )} | |
| 74 | + | </li> | |
| 75 | + | ))} | |
| 76 | + | </ul> | |
| 77 | + | ); | |
| 78 | + | } |
| 4 | 4 | import type { Invite, InvitesOverview } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 7 | − | import { inviteFor, inviteLink, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites"; | |
| 7 | + | import { type BringInto, OWN_WORKSPACE, inviteFor, inviteLink, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites"; | |
| 8 | + | ||
| 9 | + | const SELECT = | |
| 10 | + | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim sm:w-auto"; | |
| 8 | 11 | ||
| 9 | 12 | const TONE: Record<"pending" | "done" | "dead", string> = { | |
| 10 | 13 | pending: "border-accent/40 text-accent", | |
| ⋯ | |||
| 19 | 22 | <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> | |
| 20 | 23 | <span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs ${TONE[state.tone]}`}>{state.label}</span> | |
| 21 | 24 | <span className="min-w-0 truncate text-sm">{inviteFor(invite)}</span> | |
| 22 | − | {(invite.status === "pending" || invite.status === "awaiting_confirmation") && ( | |
| 25 | + | {(invite.status === "pending" || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer") && ( | |
| 23 | 26 | <Form method="post" className="ml-auto"> | |
| 24 | 27 | <input type="hidden" name="intent" value="revoke-invite" /> | |
| 25 | 28 | <input type="hidden" name="id" value={invite.id} /> | |
| ⋯ | |||
| 52 | 55 | created, | |
| 53 | 56 | error, | |
| 54 | 57 | origin = "https://g1t.sh", | |
| 58 | + | bringInto = { options: [], chosen: OWN_WORKSPACE, note: null }, | |
| 55 | 59 | }: { | |
| 56 | 60 | overview: InvitesOverview | null; | |
| 61 | + | /** The workspaces an invite can bring its person into (lib/invites.ts, `bringIntoChoices`). */ | |
| 62 | + | bringInto?: { options: BringInto[]; chosen: string; note: string | null }; | |
| 57 | 63 | created?: Invite; | |
| 58 | 64 | error?: string; | |
| 59 | 65 | origin?: string; | |
| ⋯ | |||
| 124 | 130 | {workspaces.length > 0 && ( | |
| 125 | 131 | <label className="block"> | |
| 126 | 132 | <span className="mb-1.5 block text-sm font-medium text-muted">Use</span> | |
| 127 | − | <select | |
| 128 | − | name="charge" | |
| 129 | − | className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim sm:w-auto" | |
| 130 | − | > | |
| 133 | + | <select name="charge" className={SELECT}> | |
| 131 | 134 | <option value="mine">Your invites</option> | |
| 132 | 135 | {workspaces | |
| 133 | 136 | .filter((workspace) => workspace.allowance.remaining !== 0) | |
| ⋯ | |||
| 143 | 146 | Create invite | |
| 144 | 147 | </SubmitButton> | |
| 145 | 148 | </div> | |
| 149 | + | <label className="block"> | |
| 150 | + | <span className="mb-1.5 block text-sm font-medium text-muted">Bring them into</span> | |
| 151 | + | <select name="join" defaultValue={bringInto.chosen} className={SELECT} aria-describedby="bring-into-hint"> | |
| 152 | + | {bringInto.options.map((workspace) => ( | |
| 153 | + | <option key={workspace.slug} value={workspace.slug}> | |
| 154 | + | {workspace.name === workspace.slug ? workspace.slug : `${workspace.name} (${workspace.slug})`} | |
| 155 | + | </option> | |
| 156 | + | ))} | |
| 157 | + | <option value={OWN_WORKSPACE}>No workspace — they'll get their own</option> | |
| 158 | + | </select> | |
| 159 | + | </label> | |
| 160 | + | <p id="bring-into-hint" className="text-xs text-faint"> | |
| 161 | + | With a workspace, they are invited to join it as a member once their account is made, and accept or decline it. Without one, their account gets a workspace of its own. | |
| 162 | + | {bringInto.note && <> {bringInto.note}</>} | |
| 163 | + | </p> | |
| 146 | 164 | <p className="text-xs text-faint"> | |
| 147 | 165 | With an email, the invite is sent there and only that address can use it. | |
| 148 | 166 | </p> | |
| 1 | + | import { useEffect, useId, useRef, useState } from "react"; | |
| 2 | + | ||
| 3 | + | import type { PersonMatch } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import { Avatar, Input } from "./ui"; | |
| 6 | + | import { UserCard } from "./user-card"; | |
| 7 | + | import { PEOPLE_SEARCH_PATH, peopleQuery } from "../lib/people-search"; | |
| 8 | + | ||
| 9 | + | /** | |
| 10 | + | * The invite form's "who" field on People: type a username, a name or an | |
| 11 | + | * email address. As someone types a name, people on g1t are offered by | |
| 12 | + | * username and name, with their avatars and the card over each name; an | |
| 13 | + | * email address is never looked up. Posts what is in the field as `name`. | |
| 14 | + | */ | |
| 15 | + | export function PeoplePicker({ name, placeholder }: { name: string; placeholder?: string }) { | |
| 16 | + | const [text, setText] = useState(""); | |
| 17 | + | const [people, setPeople] = useState<PersonMatch[]>([]); | |
| 18 | + | const [open, setOpen] = useState(false); | |
| 19 | + | const [active, setActive] = useState(0); | |
| 20 | + | const listId = useId(); | |
| 21 | + | const asked = useRef(0); | |
| 22 | + | ||
| 23 | + | useEffect(() => { | |
| 24 | + | const query = peopleQuery(text); | |
| 25 | + | if (!query) { | |
| 26 | + | setPeople([]); | |
| 27 | + | return; | |
| 28 | + | } | |
| 29 | + | const ask = ++asked.current; | |
| 30 | + | const timer = setTimeout(() => { | |
| 31 | + | fetch(`${PEOPLE_SEARCH_PATH}?q=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } }) | |
| 32 | + | .then((response) => (response.ok ? (response.json() as Promise<{ people?: PersonMatch[] }>) : { people: [] })) | |
| 33 | + | .then((found) => { | |
| 34 | + | if (ask !== asked.current) return; | |
| 35 | + | setPeople(found.people ?? []); | |
| 36 | + | setActive(0); | |
| 37 | + | }) | |
| 38 | + | .catch(() => { | |
| 39 | + | if (ask === asked.current) setPeople([]); | |
| 40 | + | }); | |
| 41 | + | }, 150); | |
| 42 | + | return () => clearTimeout(timer); | |
| 43 | + | }, [text]); | |
| 44 | + | ||
| 45 | + | const choose = (person: PersonMatch) => { | |
| 46 | + | setText(person.username); | |
| 47 | + | setPeople([]); | |
| 48 | + | setOpen(false); | |
| 49 | + | }; | |
| 50 | + | const showing = open && people.length > 0; | |
| 51 | + | ||
| 52 | + | return ( | |
| 53 | + | <div className="relative"> | |
| 54 | + | <Input | |
| 55 | + | name={name} | |
| 56 | + | required | |
| 57 | + | maxLength={254} | |
| 58 | + | autoComplete="off" | |
| 59 | + | placeholder={placeholder} | |
| 60 | + | value={text} | |
| 61 | + | role="combobox" | |
| 62 | + | aria-expanded={showing} | |
| 63 | + | aria-controls={listId} | |
| 64 | + | aria-autocomplete="list" | |
| 65 | + | aria-activedescendant={showing ? `${listId}-${active}` : undefined} | |
| 66 | + | onChange={(event) => { | |
| 67 | + | setText(event.currentTarget.value); | |
| 68 | + | setOpen(true); | |
| 69 | + | }} | |
| 70 | + | onFocus={() => setOpen(true)} | |
| 71 | + | onBlur={() => setTimeout(() => setOpen(false), 150)} | |
| 72 | + | onKeyDown={(event) => { | |
| 73 | + | if (!showing) return; | |
| 74 | + | if (event.key === "ArrowDown") { | |
| 75 | + | event.preventDefault(); | |
| 76 | + | setActive((at) => (at + 1) % people.length); | |
| 77 | + | } else if (event.key === "ArrowUp") { | |
| 78 | + | event.preventDefault(); | |
| 79 | + | setActive((at) => (at - 1 + people.length) % people.length); | |
| 80 | + | } else if (event.key === "Enter" && people[active] && people[active].username !== text.trim().replace(/^@+/, "")) { | |
| 81 | + | event.preventDefault(); | |
| 82 | + | choose(people[active]); | |
| 83 | + | } else if (event.key === "Escape") { | |
| 84 | + | setOpen(false); | |
| 85 | + | } | |
| 86 | + | }} | |
| 87 | + | /> | |
| 88 | + | {showing && ( | |
| 89 | + | <ul | |
| 90 | + | id={listId} | |
| 91 | + | role="listbox" | |
| 92 | + | className="absolute top-full right-0 left-0 z-20 mt-1 max-h-72 overflow-y-auto rounded-md border border-line bg-surface p-1 shadow-lg" | |
| 93 | + | > | |
| 94 | + | {people.map((person, index) => ( | |
| 95 | + | <li | |
| 96 | + | key={person.username} | |
| 97 | + | id={`${listId}-${index}`} | |
| 98 | + | role="option" | |
| 99 | + | aria-selected={index === active} | |
| 100 | + | className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm ${index === active ? "bg-line text-fg" : "text-fg/90"}`} | |
| 101 | + | onMouseEnter={() => setActive(index)} | |
| 102 | + | // Before the input's blur, so the choice is not lost. | |
| 103 | + | onMouseDown={(event) => { | |
| 104 | + | event.preventDefault(); | |
| 105 | + | choose(person); | |
| 106 | + | }} | |
| 107 | + | > | |
| 108 | + | <Avatar name={person.username} image={person.avatar} size={20} /> | |
| 109 | + | <UserCard username={person.username}> | |
| 110 | + | <span className="font-mono">{person.username}</span> | |
| 111 | + | </UserCard> | |
| 112 | + | {person.name && <span className="min-w-0 truncate text-muted">{person.name}</span>} | |
| 113 | + | </li> | |
| 114 | + | ))} | |
| 115 | + | </ul> | |
| 116 | + | )} | |
| 117 | + | </div> | |
| 118 | + | ); | |
| 119 | + | } |
| 67 | 67 | assert.equal(afterConfirming("", null), "/"); | |
| 68 | 68 | }); | |
| 69 | 69 | ||
| 70 | + | test("confirming an account whose invite names a workspace goes on to accept or decline it", () => { | |
| 71 | + | assert.match(confirmedLine({ invitedTo: "flagon-io" }), /invited to join flagon-io: accept or decline/); | |
| 72 | + | assert.equal(afterConfirming("/flagon-io", null, "flagon-io"), "/invitations"); | |
| 73 | + | assert.equal(afterConfirming("/", null, null), "/"); | |
| 74 | + | }); | |
| 75 | + | ||
| 70 | 76 | test("the code field is a one-time code typed with a number pad", () => { | |
| 71 | 77 | const page = readFileSync(new URL("../routes/confirm-email.tsx", import.meta.url), "utf8"); | |
| 72 | 78 | const input = /<Input[\s\S]*?name="code"[\s\S]*?\/>/.exec(page)?.[0] ?? ""; |
| 10 | 10 | /** Where an account confirms its address: the code, a new one, a new address. */ | |
| 11 | 11 | export const CONFIRM_PATH = "/confirm-email"; | |
| 12 | 12 | ||
| 13 | + | /** Where a person answers the workspace invitations waiting for them. */ | |
| 14 | + | export const INVITATIONS_PATH = "/invitations"; | |
| 15 | + | ||
| 13 | 16 | /** Pages a pending account can open as they are. */ | |
| 14 | 17 | const OPEN = new Set([ | |
| 15 | 18 | CONFIRM_PATH, | |
| ⋯ | |||
| 68 | 71 | } | |
| 69 | 72 | ||
| 70 | 73 | /** What the confirmation page says once a code or link has worked. */ | |
| 71 | − | export function confirmedLine(done: { joined?: string | null; inviteLapsed?: string | null }): string { | |
| 74 | + | export function confirmedLine(done: { joined?: string | null; invitedTo?: string | null; inviteLapsed?: string | null }): string { | |
| 72 | 75 | if (done.inviteLapsed) return done.inviteLapsed; | |
| 73 | 76 | if (done.joined) return `Your email address is confirmed, and you have joined ${done.joined}.`; | |
| 77 | + | if (done.invitedTo) return `Your email address is confirmed. You are invited to join ${done.invitedTo}: accept or decline the invitation next.`; | |
| 74 | 78 | return "Your email address is confirmed."; | |
| 75 | 79 | } | |
| 76 | 80 | ||
| 77 | − | /** Where to go once confirmed: back where they were going, else the workspace joined, else home. */ | |
| 78 | − | export function afterConfirming(next: string, joined?: string | null): string { | |
| 81 | + | /** | |
| 82 | + | * Where to go once confirmed: the invitation the invite brought, to accept or | |
| 83 | + | * decline; else back where they were going, else the workspace joined, else home. | |
| 84 | + | */ | |
| 85 | + | export function afterConfirming(next: string, joined?: string | null, invitedTo?: string | null): string { | |
| 86 | + | if (invitedTo) return INVITATIONS_PATH; | |
| 79 | 87 | if (next && next !== "/") return next; | |
| 80 | 88 | return joined ? `/${joined}` : "/"; | |
| 81 | 89 | } | |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.