Skip to content

Commit

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)

syntaqxcommitted Parents58dd85e9bc8b2eBrowse files
53 files+561−960/53 viewed
+7−1
8282 ),
8383 (
8484 "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.",
8686 &[
8787 Op::ListInvites,
8888 Op::CreateInvite,
9090 Op::ListWorkspaceInvites,
9191 Op::InviteMember,
9292 Op::RevokeWorkspaceInvite,
93+ Op::ListInvitations,
94+ Op::AcceptInvitation,
95+ Op::DeclineInvitation,
9396 ],
9497 ),
9598 (
549552 Op::RevokeInvite => "Revoke an invite",
550553 Op::ListWorkspaceInvites => "List a workspace's invites",
551554 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",
552558 Op::RevokeWorkspaceInvite => "Revoke a workspace's invite",
553559 Op::TransferRepo => "Transfer a repository",
554560 Op::RenameRepo => "Rename a repository",
+82−9
123123 ListWorkspaceInvites,
124124 InviteMember,
125125 RevokeWorkspaceInvite,
126+ ListInvitations,
127+ AcceptInvitation,
128+ DeclineInvitation,
126129 ListRepos,
127130 GetRepo,
128131 CreateRepo,
683686 }
684687
685688 impl Op {
686− pub const ALL: [Op; 315] = [
689+ pub const ALL: [Op; 318] = [
687690 Op::Whoami,
688691 Op::GetWorkspace,
689692 Op::CreateWorkspace,
705708 Op::ListWorkspaceInvites,
706709 Op::InviteMember,
707710 Op::RevokeWorkspaceInvite,
711+ Op::ListInvitations,
712+ Op::AcceptInvitation,
713+ Op::DeclineInvitation,
708714 Op::ListRepos,
709715 Op::GetRepo,
710716 Op::CreateRepo,
10291035 Op::ListWorkspaceInvites => "list_workspace_invites",
10301036 Op::InviteMember => "invite_member",
10311037 Op::RevokeWorkspaceInvite => "revoke_workspace_invite",
1038+ Op::ListInvitations => "list_invitations",
1039+ Op::AcceptInvitation => "accept_invitation",
1040+ Op::DeclineInvitation => "decline_invitation",
10321041 Op::ListRepos => "list_repos",
10331042 Op::GetRepo => "get_repo",
10341043 Op::CreateRepo => "create_repo",
12251234 "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."
12261235 }
12271236 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."
12291238 }
12301239 Op::RemoveEmail => {
12311240 "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."
12341243 "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."
12351244 }
12361245 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`."
12381247 }
12391248 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."
12411250 }
12421251 Op::RevokeInvite => {
12431252 "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."
12461255 "The invites made for a workspace, newest first, with each pending one's `code`. Owners only."
12471256 }
12481257 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."
12501259 }
12511260 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+ }
12521270 Op::DeleteWorkspace => {
12531271 "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."
12541272 }
18321850 },
18331851 "workspace": {
18341852 "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",
18351857 "description": "Use one of the invites g1t granted this workspace instead of yours, by slug. Owners only.",
18361858 },
18371859 }),
18451867 Op::InviteMember => object(
18461868 json!({
18471869 "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+ },
18491880 }),
1850− &["workspace", "email"],
1881+ &["workspace"],
18511882 ),
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+ ),
18521890 Op::RevokeWorkspaceInvite => object(
18531891 json!({
18541892 "workspace": workspace_schema(),
33273365 | Op::ListWorkspaceInvites
33283366 | Op::InviteMember
33293367 | Op::RevokeWorkspaceInvite
3368+ | Op::ListInvitations
3369+ | Op::AcceptInvitation
3370+ | Op::DeclineInvitation
33303371 | Op::ListDeletedRepos
33313372 | Op::SearchContext
33323373 | Op::GetEntity
36423683 &json!({
36433684 "user": actor(),
36443685 "email": optional_text(input, "email"),
3645− "workspace": optional_text(input, "workspace"),
3686+ "workspace": optional_text(input, "charge_workspace"),
3687+ "join": optional_text(input, "workspace"),
36463688 "surface": services.audit.surface,
36473689 }),
36483690 )
36553697 pass(identity, "workspace_invites", &json!({ "slug": workspace(), "viewer": viewer })).await
36563698 }
36573699 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+ }
36583704 pass(
36593705 identity,
36603706 "invite_member",
36613707 &json!({
36623708 "actor": actor(),
36633709 "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,
36653713 "surface": services.audit.surface,
36663714 }),
36673715 )
36683716 .await
36693717 }
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+ }
36703743 Op::RevokeWorkspaceInvite => {
36713744 pass(
36723745 identity,
+76−20
292292 "username": "ada",
293293 "email": "ada@example.com",
294294 "verified": true,
295− "joined": "acme",
295+ "joined": null,
296+ "invited_to": "acme",
296297 "invite_lapsed": null
297298 },
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)."
299300 },
300301 "remove_email": {
301302 "request": {
409410 "created_at": "2026-10-05T17:00:00.000Z",
410411 "expires_at": "2026-11-04T17:00:00.000Z",
411412 "redeemed_at": null,
412− "revoked_at": null
413+ "revoked_at": null,
414+ "invitee": null,
415+ "role": null
413416 },
414417 {
415418 "id": "inv_01kp1v3c4d5e6f7g8h9j0k1m2n",
425428 "created_at": "2026-10-02T09:12:40.000Z",
426429 "expires_at": "2026-11-01T09:12:40.000Z",
427430 "redeemed_at": "2026-10-02T11:30:05.000Z",
428− "revoked_at": null
431+ "revoked_at": null,
432+ "invitee": "grace",
433+ "role": null
429434 }
430435 ]
431436 },
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)."
433438 },
434439 "create_invite": {
435440 "request": {
436− "email": "ada@example.com"
441+ "email": "ada@example.com",
442+ "workspace": "acme-labs"
437443 },
438444 "response": {
439445 "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
441447 "hint": "g1t-k7m2",
442448 "email": "ada@example.com",
443449 "kind": "account",
444− "workspace": null,
450+ "workspace": "acme-labs",
445451 "status": "pending",
446452 "charged_to": "user",
447453 "invited_by": "syntaqx",
449455 "created_at": "2026-10-05T17:00:00.000Z",
450456 "expires_at": "2026-11-04T17:00:00.000Z",
451457 "redeemed_at": null,
452− "revoked_at": null
458+ "revoked_at": null,
459+ "invitee": null,
460+ "role": "member"
453461 },
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>`."
455463 },
456464 "revoke_invite": {
457465 "response": {
468476 "created_at": "2026-10-05T17:00:00.000Z",
469477 "expires_at": "2026-11-04T17:00:00.000Z",
470478 "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
472482 }
473483 },
474484 "list_workspace_invites": {
487497 "created_at": "2026-10-05T17:00:00.000Z",
488498 "expires_at": "2026-11-04T17:00:00.000Z",
489499 "redeemed_at": null,
490− "revoked_at": null
500+ "revoked_at": null,
501+ "invitee": null,
502+ "role": "member"
491503 },
492504 {
493505 "id": "inv_01kp1z9y8x7w6v5t4s3r2q1p0n",
494506 "code": "g1t-k7m2-q9xd-4hpw-…",
495507 "hint": "g1t-w2vb",
496− "email": "linus@example.com",
508+ "email": null,
497509 "kind": "workspace",
498510 "workspace": "acme-labs",
499511 "status": "pending",
503515 "created_at": "2026-10-05T17:00:00.000Z",
504516 "expires_at": "2026-11-04T17:00:00.000Z",
505517 "redeemed_at": null,
506− "revoked_at": null
518+ "revoked_at": null,
519+ "invitee": "linus",
520+ "role": "owner"
507521 }
508522 ]
509523 },
510524 "invite_member": {
511525 "request": {
512− "email": "ada@example.com"
526+ "username": "ada",
527+ "role": "member"
513528 },
514529 "response": {
515530 "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
516531 "code": "g1t-k7m2-q9xd-4hpw-…",
517532 "hint": "g1t-k7m2",
518− "email": "ada@example.com",
519− "kind": "account",
533+ "email": null,
534+ "kind": "workspace",
520535 "workspace": "acme-labs",
521536 "status": "pending",
522− "charged_to": "workspace",
537+ "charged_to": "none",
523538 "invited_by": "syntaqx",
524539 "redeemed_by": null,
525540 "created_at": "2026-10-05T17:00:00.000Z",
526541 "expires_at": "2026-11-04T17:00:00.000Z",
527542 "redeemed_at": null,
528− "revoked_at": null
543+ "revoked_at": null,
544+ "invitee": "ada",
545+ "role": "member"
529546 },
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."
531548 },
532549 "revoke_workspace_invite": {
533550 "response": {
544561 "created_at": "2026-10-05T17:00:00.000Z",
545562 "expires_at": "2026-11-04T17:00:00.000Z",
546563 "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"
548567 }
549568 },
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+ },
550606 "list_repos": {
551607 "query": {
552608 "q": "hello"
+1−0
210210 through::<g1t_contracts::identity::Invite>(op, sent)
211211 }
212212 Op::ListWorkspaceInvites => through::<Vec<g1t_contracts::identity::Invite>>(op, sent),
213+ Op::ListInvitations => through::<Vec<g1t_contracts::identity::WorkspaceInvitation>>(op, sent),
213214 Op::ListNotifications => through::<g1t_contracts::inbox::InboxPage>(op, sent),
214215 Op::GetNotificationThread | Op::MarkThreadRead | Op::MarkThreadDone | Op::SaveThread | Op::SnoozeThread => {
215216 through::<g1t_contracts::inbox::InboxThread>(op, sent)
+4−0
5050 route("GET", "/user/invites", Op::ListInvites, &[]),
5151 route("POST", "/user/invites", Op::CreateInvite, &[]),
5252 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, &[]),
5357 route("GET", "/workspaces/:workspace/invitations", Op::ListWorkspaceInvites, &[]),
5458 // A workspace's rules for personal access tokens, and its members' tokens.
5559 route("GET", "/workspaces/:workspace/personal-access-token-policy", Op::Tokens(TokenOp::GetTokenPolicy), &[]),
+5−2
384384 a("transfer_ownership", Op::TransferOwnership, "Hand it to another member: they become an owner, you a member"),
385385 a("leave", Op::LeaveWorkspace, "Leave it yourself"),
386386 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"),
388388 a("revoke_invite", Op::RevokeWorkspaceInvite, "Revoke a pending invite"),
389389 a("list_integrations", Op::ListIntegrations, "Model providers, alert sources, trackers"),
390390 a("connect_integration", Op::ConnectIntegration, "Connect one"),
494494 Tool {
495495 name: "account",
496496 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.",
498498 default_action: Some("whoami"),
499499 actions: &[
500500 a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"),
506506 a("list_invites", Op::ListInvites, "Your invites to g1t"),
507507 a("create_invite", Op::CreateInvite, "Make an invite"),
508508 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"),
509512 a("list_repository_invitations", Op::ListMyRepoInvitations, "Invitations to repositories for you"),
510513 a("accept_repository_invitation", Op::AcceptRepoInvitation, "Accept one"),
511514 a("decline_repository_invitation", Op::DeclineRepoInvitation, "Decline one"),
+4−3
252252
253253 Owners see every outside collaborator, and the repositories and roles each
254254 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.
258259
259260 Removing a member from a workspace, or their leaving it, also removes the
260261 roles they were given on its repositories, and takes them out of its teams.
+90−29
146146 for an account that has confirmed that address. The page says so and
147147 offers **Sign out and continue**.
148148
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).
153161
154162 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
161168 code typed at [g1t.sh/register](https://g1t.sh/register) goes to the
162169 same page.
163170
174181
175182 - the invite page says the address is confirmed because you came from the
176183 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.
180188
181189 Anything else confirms the address the usual way, after you sign up: the
182190 code typed at [g1t.sh/register](https://g1t.sh/register), an invite link
217225 2. Optionally enter the email address of the person you are inviting.
218226 With one, g1t emails them the invite, and only that address can use it.
219227 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.
221239
222240 Each person can have **5** invites out at a time. Pending and used invites
223241 count; an invite you revoke, or one that expires before anyone uses it,
224242 comes back to you. The list under the form shows each invite's state:
225243 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
228247 must confirm your email before you can make invites. An agent's token and
229248 a workspace's token cannot make them.
230249
231250 ### Inviting someone into a workspace
232251
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
239278
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+
240294 ### Need more invites?
241295
242296 Write to [hey@flagon.io](mailto:hey@flagon.io?subject=%5Bg1t%20Invites%5D%20)
262316 | Route | MCP tool and action | What it does |
263317 | --- | --- | --- |
264318 | [`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 |
266320 | [`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 |
268325
269326 ## Confirming your email address
270327
307364 - **The MCP server** answers `403` with the same message.
308365 - **Git** over HTTPS refuses pushes and fetches with your credentials, with
309366 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
311368 invites or tokens, or approve a tool's sign-in.
312369
313370 You cannot make a token before you confirm, so the API and MCP refusals
341398 | [`POST /user/emails/confirm`](/reference/api/accounts/confirm-email/) | `account` `confirm_email` | Confirm an address with the `code` from its email |
342399
343400 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.
346406
347407 ## Email addresses
348408
464524
465525 ## Workspaces
466526
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,
469530 their members and roles, and the access tokens that belong to a workspace
470531 are covered in [workspaces](/guides/workspaces/).
471532
+48−20
1010
1111 ## Create a workspace
1212
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:
1523
1624 1. Open [g1t.sh/workspaces/new](https://g1t.sh/workspaces/new).
1725 2. Choose its name in URLs: lowercase letters, digits and single hyphens.
320328
321329 ### Add people
322330
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**.
325342
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.
327348 - **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.
333355
334356 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
342363 [using an invite](/guides/authentication/#using-an-invite).
343364
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
346371 [`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/).
348376
349377 To give someone a role on one repository without making them a member,
350378 add them as an [outside collaborator](/guides/access-and-roles/#outside-collaborators).
+6−3
656656 | [`leave`](/reference/api/members/leave-workspace/) | Leave it yourself. Never the last owner. | `workspace` | `account:write` |
657657 | [`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` |
658658 | [`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` |
660660 | [`revoke_invite`](/reference/api/invites/revoke-workspace-invite/) | Revoke a workspace's pending invite. Owners only. | `workspace`, `id` | `workspace:admin` |
661661 | [`list_integrations`](/reference/api/integrations/list-integrations/) | The workspace's connections. Secrets are never returned. Members only. | `workspace` | `workspace:read` |
662662 | [`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` |
737737
738738 Who the token acts as and its workspaces, your email addresses, your
739739 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
741741 action, and needs no scope. An agent's token and a workspace's token cannot
742742 use the email and invite actions. An account that has not confirmed its
743743 email address gets `403` from every tool until it does; see
752752 | [`remove_email`](/reference/api/accounts/remove-email/) | Remove an address; never the primary or the last confirmed one. | `email`, `password` | `account:write` |
753753 | [`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` |
754754 | [`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` |
756756 | [`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` |
757760 | [`list_repository_invitations`](/reference/api/access/list-my-repo-invitations/) | The invitations to repositories waiting for your answer. | None | `account:read` |
758761 | [`accept_repository_invitation`](/reference/api/access/accept-repo-invitation/) | Accept one; its role is yours at once. | `id` | `account:write` |
759762 | [`decline_repository_invitation`](/reference/api/access/decline-repo-invitation/) | Decline one. | `id` | `account:write` |
+0−0

Binary or large file; its contents are not shown.

+78−0
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+}
+24−6
44 import type { Invite, InvitesOverview } from "@g1t/contracts";
55
66 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";
811
912 const TONE: Record<"pending" | "done" | "dead", string> = {
1013 pending: "border-accent/40 text-accent",
1922 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
2023 <span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs ${TONE[state.tone]}`}>{state.label}</span>
2124 <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") && (
2326 <Form method="post" className="ml-auto">
2427 <input type="hidden" name="intent" value="revoke-invite" />
2528 <input type="hidden" name="id" value={invite.id} />
5255 created,
5356 error,
5457 origin = "https://g1t.sh",
58+ bringInto = { options: [], chosen: OWN_WORKSPACE, note: null },
5559 }: {
5660 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 };
5763 created?: Invite;
5864 error?: string;
5965 origin?: string;
124130 {workspaces.length > 0 && (
125131 <label className="block">
126132 <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}>
131134 <option value="mine">Your invites</option>
132135 {workspaces
133136 .filter((workspace) => workspace.allowance.remaining !== 0)
143146 Create invite
144147 </SubmitButton>
145148 </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>
146164 <p className="text-xs text-faint">
147165 With an email, the invite is sent there and only that address can use it.
148166 </p>
+119−0
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+}
+6−0
6767 assert.equal(afterConfirming("", null), "/");
6868 });
6969
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+
7076 test("the code field is a one-time code typed with a number pad", () => {
7177 const page = readFileSync(new URL("../routes/confirm-email.tsx", import.meta.url), "utf8");
7278 const input = /<Input[\s\S]*?name="code"[\s\S]*?\/>/.exec(page)?.[0] ?? "";
+11−3
1010 /** Where an account confirms its address: the code, a new one, a new address. */
1111 export const CONFIRM_PATH = "/confirm-email";
1212
13+/** Where a person answers the workspace invitations waiting for them. */
14+export const INVITATIONS_PATH = "/invitations";
15+
1316 /** Pages a pending account can open as they are. */
1417 const OPEN = new Set([
1518 CONFIRM_PATH,
6871 }
6972
7073 /** 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 {
7275 if (done.inviteLapsed) return done.inviteLapsed;
7376 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.`;
7478 return "Your email address is confirmed.";
7579 }
7680
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;
7987 if (next && next !== "/") return next;
8088 return joined ? `/${joined}` : "/";
8189 }
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.