Two invites, said plainly: an invite to g1t joins no workspace, an invitation to a workspace is made on its People page
Settings → Invites is now "Invite people to g1t" (Invites to g1t in the menus): it lets one person make an account and adds them to no workspace. Inviting them to a workspace as well is an explicit, off-by-default "Also invite them to a workspace" box that reveals the workspace and the role; unticked, no `join` is sent, and no workspace is ever chosen for you. The page links to the People pages of the workspaces you own. Invites to g1t exist only while sign-up is invite-only. Once anyone can sign up, the page keeps only the invites already made, says so, and is left out of the settings and account menus when there are none. The root loader now knows the registration mode for signed-in people too. A workspace's People page says "Invite to <Workspace>": an invitation to accept or decline, which also lets someone without an account sign up while g1t is invite-only, and points to Settings → Invites for an invite to g1t alone. Once registration is open, an email invitation to a workspace costs no invite (identity). The invite list, the invite email ("… invited you to join Flagon, Inc. on g1t") and /invite/:code ("@syntaqx invited you to g1t") each say which kind it is. create_invite takes an optional join_role. The authentication and workspaces guides describe the two kinds in a table.
| 1255 | 1255 | "The invites made for a workspace, newest first, with each pending one's `code`. Owners only." | |
| 1256 | 1256 | } | |
| 1257 | 1257 | Op::InviteMember => { | |
| 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." | |
| 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; while g1t is invite-only that uses one of the workspace's granted invites, or else one of yours, and once anyone can sign up it costs nothing. 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." | |
| 1259 | 1259 | } | |
| 1260 | 1260 | Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.", | |
| 1261 | 1261 | Op::ListInvitations => { |
| 30 | 30 | | --- | --- | --- | | |
| 31 | 31 | | Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location, website and time zone. | | |
| 32 | 32 | | Emails | [`/settings/emails`](https://g1t.sh/settings/emails) | Your [email addresses](#email-addresses), the backup address, and [keeping your address private](#keeping-your-address-private). | | |
| 33 | − | | Invites | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites](#invites). | | |
| 33 | + | | Invites to g1t | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites to g1t](#making-invites), while g1t is invite-only; after that, the invites you made. | | |
| 34 | 34 | | SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/#ssh), each with when it was added and last used. | | |
| 35 | 35 | | Access tokens | [`/settings/tokens`](https://g1t.sh/settings/tokens) | Your [personal access tokens](#access-tokens): fine-grained and classic. | | |
| 36 | 36 | | GitHub | [`/settings/github`](https://g1t.sh/settings/github) | [Linking and unlinking GitHub](/guides/github/#link-and-unlink-github). | | |
| ⋯ | |||
| 114 | 114 | ||
| 115 | 115 | ## Invites | |
| 116 | 116 | ||
| 117 | + | There are two kinds of invite, and they do different things: | |
| 118 | + | ||
| 119 | + | | | Invite to g1t | Invite to a workspace | | |
| 120 | + | | --- | --- | --- | | |
| 121 | + | | Made from | [Settings → Invites](https://g1t.sh/settings/invites), your own | The workspace's **People** page, by its owners | | |
| 122 | + | | What it gives | One new account. It adds them to no workspace: the account gets a workspace of its own | An invitation to join that workspace, which they accept or decline | | |
| 123 | + | | Someone without an account | Makes their account with it | Makes their account with it too, while g1t is invite-only, then answers the invitation | | |
| 124 | + | | Someone already on g1t | Nothing: they have an account | The invitation, in their inbox and by email | | |
| 125 | + | | Exists | Only while g1t is invite-only | Always | | |
| 126 | + | | What its page and email say | "@syntaqx invited you to g1t" | "@syntaqx invited you to join Flagon, Inc. on g1t" | | |
| 127 | + | ||
| 128 | + | So you can invite someone to g1t without inviting them into any | |
| 129 | + | workspace; an invite to g1t only adds them to a workspace when you tick | |
| 130 | + | **Also invite them to a workspace** (see [making invites](#making-invites)). | |
| 131 | + | ||
| 117 | 132 | While g1t is invite-only, every new account needs an invite code, such as | |
| 118 | 133 | `g1t-k7m2-q9xd-…`. People already on g1t make them, and g1t sends them to | |
| 119 | 134 | people who [asked for access](#asking-for-access). An invite: | |
| ⋯ | |||
| 221 | 236 | ||
| 222 | 237 | ### Making invites | |
| 223 | 238 | ||
| 224 | − | 1. Open [Settings → Invites](https://g1t.sh/settings/invites). | |
| 239 | + | These are invites to g1t. They let one person make an account, and add | |
| 240 | + | them to no workspace unless you say so. | |
| 241 | + | ||
| 242 | + | 1. Open [Settings → Invites](https://g1t.sh/settings/invites) (**Invites | |
| 243 | + | to g1t** in your settings and account menu). | |
| 225 | 244 | 2. Optionally enter the email address of the person you are inviting. | |
| 226 | 245 | With one, g1t emails them the invite, and only that address can use it. | |
| 227 | 246 | Without one, anyone with the link can, once. | |
| 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. | |
| 247 | + | 3. Optionally tick **Also invite them to a workspace**, then choose the | |
| 248 | + | workspace and the role (**Member** or **Owner**) they are invited with. | |
| 249 | + | It is off to start with, and no workspace is chosen for you, not even | |
| 250 | + | the one you are in. | |
| 231 | 251 | 4. Select **Create invite**, then copy the link. | |
| 232 | 252 | ||
| 233 | − | **Bring them into** lists the workspaces you can add members to: the ones | |
| 253 | + | Left unticked, the invite is to g1t only: the new account gets a | |
| 254 | + | [workspace of its own](#your-first-workspace). Ticked, the new account | |
| 255 | + | gets a [workspace invitation](#workspace-invitations) to the workspace you | |
| 256 | + | chose once its address is confirmed, and is not given a workspace of its | |
| 257 | + | own. The list of workspaces holds the ones you can add members to: the ones | |
| 234 | 258 | you own that are on the g1t plan. A workspace on the free plan cannot add | |
| 235 | 259 | 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. | |
| 260 | + | are in. | |
| 261 | + | ||
| 262 | + | To bring someone into a workspace, you do not need an invite to g1t: | |
| 263 | + | invite them from the workspace's People page instead (see | |
| 264 | + | [inviting someone into a workspace](#inviting-someone-into-a-workspace)). | |
| 265 | + | Settings → Invites links to the People pages of the workspaces you own. | |
| 239 | 266 | ||
| 267 | + | Once anyone can sign up for g1t, there are no invites to g1t to make: | |
| 268 | + | Settings → Invites keeps only the list of invites you already made, and | |
| 269 | + | says that anyone can sign up now and that workspace invitations live on | |
| 270 | + | each workspace's People page. With no invites made, the page is not listed | |
| 271 | + | in your settings or account menu. | |
| 272 | + | ||
| 240 | 273 | Each person can have **5** invites out at a time. Pending and used invites | |
| 241 | 274 | count; an invite you revoke, or one that expires before anyone uses it, | |
| 242 | 275 | comes back to you. The list under the form shows each invite's state: | |
| ⋯ | |||
| 253 | 286 | username or by email address, with the role they join as; see | |
| 254 | 287 | [add people](/guides/workspaces/#add-people). Nobody is added without | |
| 255 | 288 | 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. | |
| 289 | + | to accept or decline. When an address has no g1t account, the invitation | |
| 290 | + | also lets it make one first, and the invitation is answered once the | |
| 291 | + | account's address is confirmed (at once when it was made from the invite | |
| 292 | + | email's link). While g1t is invite-only, that uses one invite: one of the | |
| 293 | + | workspace's shared invites when it has any, otherwise one of yours. Once | |
| 294 | + | anyone can sign up, it costs nothing. Inviting someone who is already on | |
| 295 | + | g1t never costs anything. | |
| 260 | 296 | ||
| 261 | 297 | ### Workspace invitations | |
| 262 | 298 | ||
| 330 | 330 | ||
| 331 | 331 | Whoever creates a workspace is its owner. Nobody is added to a workspace | |
| 332 | 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): | |
| 333 | + | declines. | |
| 335 | 334 | ||
| 336 | − | 1. Under **Invite someone**, type a username, a name or an email address. | |
| 335 | + | This is an invitation to join one workspace. It is not the same as an | |
| 336 | + | invite to g1t, which only lets someone make an account: | |
| 337 | + | ||
| 338 | + | | | Invite to a workspace | Invite to g1t | | |
| 339 | + | | --- | --- | --- | | |
| 340 | + | | Where | The workspace's **People** page, **Invite to** *workspace* | [Settings → Invites](https://g1t.sh/settings/invites) | | |
| 341 | + | | What they get | An invitation to join the workspace, to accept or decline | One new account, in no workspace but its own | | |
| 342 | + | | Without an account | The invitation lets them sign up first, while g1t is invite-only | It lets them sign up | | |
| 343 | + | | When | Always | Only while g1t is invite-only | | |
| 344 | + | ||
| 345 | + | To invite someone to g1t without adding them to your workspace, use | |
| 346 | + | [Settings → Invites](/guides/authentication/#making-invites); the People | |
| 347 | + | page links there while g1t is invite-only. | |
| 348 | + | ||
| 349 | + | On the workspace's **People**, `g1t.sh/<workspace>/-/people` (in the | |
| 350 | + | sidebar): | |
| 351 | + | ||
| 352 | + | 1. Under **Invite to** *workspace name*, type a username, a name or an email address. | |
| 337 | 353 | As you type, people on g1t are offered by username and name, with their | |
| 338 | 354 | pictures; hover over one for their card. Only usernames, names and | |
| 339 | 355 | pictures are shown, never anyone's email address. | |
| ⋯ | |||
| 347 | 363 | are told in your inbox. It costs nothing. | |
| 348 | 364 | - **By email address**: g1t emails an invite that only that address can | |
| 349 | 365 | 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. | |
| 366 | + | and costs nothing. Without one, the invitation also lets them make the | |
| 367 | + | account first; while g1t is invite-only that uses one of the workspace's | |
| 368 | + | granted invites, or else one of yours (see | |
| 369 | + | [invites](/guides/authentication/#invites)), and once anyone can sign up | |
| 370 | + | it costs nothing. The new account is then invited to the workspace, and | |
| 371 | + | joins when it accepts. The page never says which it was. | |
| 355 | 372 | ||
| 356 | 373 | The email names you and the workspace and links to the invite's page. | |
| 357 | 374 | Someone new signs up right there, with the invited address filled in; once | |
Binary or large file; its contents are not shown.
| 1 | 1 | import { Mail } from "lucide-react"; | |
| 2 | + | import { useState } from "react"; | |
| 2 | 3 | import { Form, Link } from "react-router"; | |
| 3 | 4 | ||
| 4 | 5 | import type { Invite, InvitesOverview } from "@g1t/contracts"; | |
| 5 | 6 | ||
| 6 | 7 | import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 7 | − | import { type BringInto, OWN_WORKSPACE, inviteFor, inviteLink, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites"; | |
| 8 | + | import { CheckboxOption } from "./ui/checkbox"; | |
| 9 | + | import { Hint } from "./ui/hint"; | |
| 10 | + | import { G1T_INVITES, inviteFor, inviteKind, inviteLink, invitesPage, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites"; | |
| 11 | + | import type { BringIntoChoices } from "../lib/invites.server"; | |
| 8 | 12 | ||
| 9 | 13 | const SELECT = | |
| 10 | 14 | "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"; | |
| ⋯ | |||
| 17 | 21 | ||
| 18 | 22 | function InviteRow({ invite, origin }: { invite: Invite; origin: string }) { | |
| 19 | 23 | const state = inviteState(invite); | |
| 24 | + | const kind = inviteKind(invite); | |
| 20 | 25 | return ( | |
| 21 | 26 | <li className="space-y-2 px-4 py-3"> | |
| 22 | 27 | <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> | |
| 23 | 28 | <span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs ${TONE[state.tone]}`}>{state.label}</span> | |
| 24 | 29 | <span className="min-w-0 truncate text-sm">{inviteFor(invite)}</span> | |
| 30 | + | <Hint | |
| 31 | + | label={ | |
| 32 | + | kind.kind === "g1t" | |
| 33 | + | ? "Lets them make an account. It adds them to no workspace." | |
| 34 | + | : "An invitation to join this workspace, which they accept or decline. Without an account, it lets them make one first." | |
| 35 | + | } | |
| 36 | + | > | |
| 37 | + | <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">{kind.label}</span> | |
| 38 | + | </Hint> | |
| 25 | 39 | {(invite.status === "pending" || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer") && ( | |
| 26 | 40 | <Form method="post" className="ml-auto"> | |
| 27 | 41 | <input type="hidden" name="intent" value="revoke-invite" /> | |
| ⋯ | |||
| 46 | 60 | ); | |
| 47 | 61 | } | |
| 48 | 62 | ||
| 63 | + | /** "To bring someone into a workspace, invite them from that workspace's People page", with those pages. */ | |
| 64 | + | function Elsewhere({ people }: { people: BringIntoChoices["people"] }) { | |
| 65 | + | return ( | |
| 66 | + | <p className="mt-4 text-sm text-muted"> | |
| 67 | + | {G1T_INVITES.elsewhere} | |
| 68 | + | {people.length > 0 ? ": " : "."} | |
| 69 | + | {people.map((workspace, index) => ( | |
| 70 | + | <span key={workspace.slug}> | |
| 71 | + | {index > 0 && ", "} | |
| 72 | + | <Link to={workspace.to} className="text-fg underline underline-offset-4 hover:text-accent"> | |
| 73 | + | {workspace.name} | |
| 74 | + | </Link> | |
| 75 | + | </span> | |
| 76 | + | ))} | |
| 77 | + | {people.length > 0 && "."} | |
| 78 | + | </p> | |
| 79 | + | ); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /** | |
| 83 | + | * Making an invite to g1t: an address (or anyone with the link), whose | |
| 84 | + | * invites it uses, and, only when its box is ticked, a workspace to invite | |
| 85 | + | * them to as well. Unticked, nothing about a workspace is sent. | |
| 86 | + | */ | |
| 87 | + | function InviteForm({ overview, bringInto }: { overview: InvitesOverview; bringInto: BringIntoChoices }) { | |
| 88 | + | const [alsoJoin, setAlsoJoin] = useState(false); | |
| 89 | + | const { workspaces } = overview; | |
| 90 | + | return ( | |
| 91 | + | <Form method="post" className="mt-4 space-y-4"> | |
| 92 | + | <input type="hidden" name="intent" value="create-invite" /> | |
| 93 | + | <div className="flex flex-col gap-3 sm:flex-row sm:items-end"> | |
| 94 | + | <div className="grow"> | |
| 95 | + | <Field label="Email (optional)"> | |
| 96 | + | <Input name="email" type="email" maxLength={254} placeholder="Anyone with the link" /> | |
| 97 | + | </Field> | |
| 98 | + | </div> | |
| 99 | + | {workspaces.length > 0 && ( | |
| 100 | + | <label className="block"> | |
| 101 | + | <span className="mb-1.5 block text-sm font-medium text-muted">Use</span> | |
| 102 | + | <select name="charge" className={SELECT}> | |
| 103 | + | <option value="mine">Your invites</option> | |
| 104 | + | {workspaces | |
| 105 | + | .filter((workspace) => workspace.allowance.remaining !== 0) | |
| 106 | + | .map((workspace) => ( | |
| 107 | + | <option key={workspace.slug} value={workspace.slug}> | |
| 108 | + | {workspace.slug}'s invites | |
| 109 | + | </option> | |
| 110 | + | ))} | |
| 111 | + | </select> | |
| 112 | + | </label> | |
| 113 | + | )} | |
| 114 | + | <SubmitButton match={{ intent: "create-invite" }} pending="Creating…"> | |
| 115 | + | Create invite | |
| 116 | + | </SubmitButton> | |
| 117 | + | </div> | |
| 118 | + | <p className="text-xs text-faint">With an email, the invite is sent there and only that address can use it.</p> | |
| 119 | + | ||
| 120 | + | {bringInto.options.length > 0 && ( | |
| 121 | + | <div className="space-y-3 rounded-md border border-line p-3"> | |
| 122 | + | <CheckboxOption | |
| 123 | + | name="also_join" | |
| 124 | + | checked={alsoJoin} | |
| 125 | + | onCheckedChange={(checked) => setAlsoJoin(checked === true)} | |
| 126 | + | label={G1T_INVITES.alsoJoin} | |
| 127 | + | description={G1T_INVITES.alsoJoinHint} | |
| 128 | + | /> | |
| 129 | + | {alsoJoin && ( | |
| 130 | + | <div className="flex flex-col gap-3 pl-6 sm:flex-row sm:items-end"> | |
| 131 | + | <label className="block"> | |
| 132 | + | <span className="mb-1.5 block text-sm font-medium text-muted">Workspace</span> | |
| 133 | + | <select name="join" required defaultValue="" className={SELECT}> | |
| 134 | + | <option value="" disabled> | |
| 135 | + | Choose a workspace | |
| 136 | + | </option> | |
| 137 | + | {bringInto.options.map((workspace) => ( | |
| 138 | + | <option key={workspace.slug} value={workspace.slug}> | |
| 139 | + | {workspace.name === workspace.slug ? workspace.slug : `${workspace.name} (${workspace.slug})`} | |
| 140 | + | </option> | |
| 141 | + | ))} | |
| 142 | + | </select> | |
| 143 | + | </label> | |
| 144 | + | <label className="block"> | |
| 145 | + | <span className="mb-1.5 block text-sm font-medium text-muted">Role</span> | |
| 146 | + | <select name="join_role" defaultValue="member" className={SELECT}> | |
| 147 | + | <option value="member">Member</option> | |
| 148 | + | <option value="owner">Owner</option> | |
| 149 | + | </select> | |
| 150 | + | </label> | |
| 151 | + | </div> | |
| 152 | + | )} | |
| 153 | + | {alsoJoin && bringInto.note && <p className="pl-6 text-xs text-faint">{bringInto.note}</p>} | |
| 154 | + | </div> | |
| 155 | + | )} | |
| 156 | + | </Form> | |
| 157 | + | ); | |
| 158 | + | } | |
| 159 | + | ||
| 49 | 160 | /** | |
| 50 | − | * Settings → Invites (`/settings/invites`): what is left, making one (for anyone with the | |
| 51 | − | * link, or for one address), copying links, and revoking. | |
| 161 | + | * Settings → Invites (`/settings/invites`): invites to g1t. Each lets one | |
| 162 | + | * person make an account and adds them to no workspace, unless "Also | |
| 163 | + | * invite them to a workspace" is ticked. They exist only while sign-up | |
| 164 | + | * takes an invite; once anyone can sign up, the page keeps only the list | |
| 165 | + | * of invites already made. An invitation to a workspace is made on its | |
| 166 | + | * People page, which this page points to. | |
| 52 | 167 | */ | |
| 53 | 168 | export function InvitesSection({ | |
| 54 | 169 | overview, | |
| 55 | 170 | created, | |
| 56 | 171 | error, | |
| 57 | 172 | origin = "https://g1t.sh", | |
| 58 | − | bringInto = { options: [], chosen: OWN_WORKSPACE, note: null }, | |
| 173 | + | bringInto = { options: [], note: null, people: [] }, | |
| 59 | 174 | }: { | |
| 60 | 175 | 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 }; | |
| 176 | + | /** The workspaces an invite can also invite its person to, and the People pages (lib/invites.server.ts). */ | |
| 177 | + | bringInto?: BringIntoChoices; | |
| 63 | 178 | created?: Invite; | |
| 64 | 179 | error?: string; | |
| 65 | 180 | origin?: string; | |
| ⋯ | |||
| 72 | 187 | ); | |
| 73 | 188 | } | |
| 74 | 189 | const { allowance, workspaces, invites } = overview; | |
| 190 | + | const shows = invitesPage(overview.mode, invites.length); | |
| 75 | 191 | const out = allowance.remaining === 0 && workspaces.every((workspace) => workspace.allowance.remaining === 0); | |
| 76 | 192 | return ( | |
| 77 | 193 | <section id="invites" className="scroll-mt-20"> | |
| 78 | − | <p className="text-sm text-muted"> | |
| 79 | − | {overview.mode === "invite" | |
| 80 | − | ? "Each invite lets one person make an account. " | |
| 81 | − | : "Anyone can make an account, but an invite still says who sent it. "} | |
| 82 | − | A revoked or expired invite that was never used comes back to you. | |
| 83 | − | </p> | |
| 84 | − | <p className="mt-3 text-sm"> | |
| 85 | − | <span className="font-medium">{remainingLine(allowance)}</span> | |
| 86 | − | {workspaces.map((workspace) => ( | |
| 87 | − | <span key={workspace.slug} className="text-muted"> | |
| 88 | − | {" "}· {workspace.slug} has {workspace.allowance.remaining ?? "unlimited"} to share | |
| 89 | − | </span> | |
| 90 | − | ))} | |
| 91 | − | </p> | |
| 92 | − | ||
| 93 | − | {created?.code && ( | |
| 94 | − | <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4"> | |
| 95 | − | <p className="text-sm"> | |
| 96 | − | {created.email ? `Invite sent to ${created.email}. ` : ""}Share this link; it works once | |
| 97 | − | {created.email ? ", for that address only" : ""}. | |
| 194 | + | {shows.form ? ( | |
| 195 | + | <> | |
| 196 | + | <p className="text-sm text-muted">A revoked or expired invite that was never used comes back to you.</p> | |
| 197 | + | <p className="mt-3 text-sm"> | |
| 198 | + | <span className="font-medium">{remainingLine(allowance)}</span> | |
| 199 | + | {workspaces.map((workspace) => ( | |
| 200 | + | <span key={workspace.slug} className="text-muted"> | |
| 201 | + | {" "}· {workspace.slug} has {workspace.allowance.remaining ?? "unlimited"} to share | |
| 202 | + | </span> | |
| 203 | + | ))} | |
| 98 | 204 | </p> | |
| 205 | + | ||
| 206 | + | {created?.code && ( | |
| 207 | + | <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4"> | |
| 208 | + | <p className="text-sm"> | |
| 209 | + | {created.email ? `Invite sent to ${created.email}. ` : ""}Share this link; it works once | |
| 210 | + | {created.email ? ", for that address only" : ""}. | |
| 211 | + | </p> | |
| 212 | + | <div className="mt-2"> | |
| 213 | + | <CopyLine text={inviteLink(created.code, origin)} /> | |
| 214 | + | </div> | |
| 215 | + | </div> | |
| 216 | + | )} | |
| 217 | + | ||
| 218 | + | {out ? ( | |
| 219 | + | <div className="mt-4 flex items-start gap-3 rounded-md border border-line bg-surface p-4 text-sm"> | |
| 220 | + | <Mail size={16} className="mt-0.5 shrink-0 text-muted" /> | |
| 221 | + | <p> | |
| 222 | + | Need more invites?{" "} | |
| 223 | + | <a href={moreInvitesMailto()} className="text-accent underline underline-offset-4"> | |
| 224 | + | Contact us | |
| 225 | + | </a>{" "} | |
| 226 | + | and say who you would like to bring, or ask on the{" "} | |
| 227 | + | <Link to="/support" className="underline underline-offset-4"> | |
| 228 | + | support page | |
| 229 | + | </Link> | |
| 230 | + | . | |
| 231 | + | </p> | |
| 232 | + | </div> | |
| 233 | + | ) : ( | |
| 234 | + | // Empty again once the invite is made; kept as filled in when it failed. | |
| 235 | + | <InviteForm key={created?.id ?? ""} overview={overview} bringInto={bringInto} /> | |
| 236 | + | )} | |
| 99 | 237 | <div className="mt-2"> | |
| 100 | − | <CopyLine text={inviteLink(created.code, origin)} /> | |
| 238 | + | <ErrorText>{error}</ErrorText> | |
| 101 | 239 | </div> | |
| 102 | − | </div> | |
| 103 | − | )} | |
| 104 | − | ||
| 105 | − | {out ? ( | |
| 106 | − | <div className="mt-4 flex items-start gap-3 rounded-md border border-line bg-surface p-4 text-sm"> | |
| 107 | − | <Mail size={16} className="mt-0.5 shrink-0 text-muted" /> | |
| 108 | − | <p> | |
| 109 | − | Need more invites?{" "} | |
| 110 | − | <a href={moreInvitesMailto()} className="text-accent underline underline-offset-4"> | |
| 111 | − | Contact us | |
| 112 | − | </a>{" "} | |
| 113 | − | and say who you would like to bring, or ask on the{" "} | |
| 114 | − | <Link to="/support" className="underline underline-offset-4"> | |
| 115 | − | support page | |
| 116 | − | </Link> | |
| 117 | − | . | |
| 118 | − | </p> | |
| 119 | − | </div> | |
| 240 | + | <Elsewhere people={bringInto.people} /> | |
| 241 | + | </> | |
| 120 | 242 | ) : ( | |
| 121 | − | // Empty again once the invite is made; kept as filled in when it failed. | |
| 122 | − | <Form method="post" key={created?.id ?? ""} className="mt-4 space-y-3"> | |
| 123 | − | <input type="hidden" name="intent" value="create-invite" /> | |
| 124 | − | <div className="flex flex-col gap-3 sm:flex-row sm:items-end"> | |
| 125 | − | <div className="grow"> | |
| 126 | − | <Field label="Email (optional)"> | |
| 127 | − | <Input name="email" type="email" maxLength={254} placeholder="Anyone with the link" /> | |
| 128 | − | </Field> | |
| 129 | − | </div> | |
| 130 | − | {workspaces.length > 0 && ( | |
| 131 | − | <label className="block"> | |
| 132 | − | <span className="mb-1.5 block text-sm font-medium text-muted">Use</span> | |
| 133 | − | <select name="charge" className={SELECT}> | |
| 134 | − | <option value="mine">Your invites</option> | |
| 135 | − | {workspaces | |
| 136 | − | .filter((workspace) => workspace.allowance.remaining !== 0) | |
| 137 | − | .map((workspace) => ( | |
| 138 | − | <option key={workspace.slug} value={workspace.slug}> | |
| 139 | − | {workspace.slug}'s invites | |
| 140 | − | </option> | |
| 141 | − | ))} | |
| 142 | − | </select> | |
| 143 | − | </label> | |
| 144 | − | )} | |
| 145 | − | <SubmitButton match={{ intent: "create-invite" }} pending="Creating…"> | |
| 146 | − | Create invite | |
| 147 | − | </SubmitButton> | |
| 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> | |
| 164 | − | <p className="text-xs text-faint"> | |
| 165 | − | With an email, the invite is sent there and only that address can use it. | |
| 166 | − | </p> | |
| 167 | − | </Form> | |
| 243 | + | <p className="text-sm text-muted">{G1T_INVITES.open}</p> | |
| 168 | 244 | )} | |
| 169 | − | <div className="mt-2"> | |
| 170 | − | <ErrorText>{error}</ErrorText> | |
| 171 | − | </div> | |
| 172 | 245 | ||
| 173 | 246 | {invites.length > 0 && ( | |
| 174 | − | <ul className="mt-4 divide-y divide-line rounded-md border border-line"> | |
| 175 | − | {invites.map((invite) => ( | |
| 176 | − | <InviteRow key={invite.id} invite={invite} origin={origin} /> | |
| 177 | − | ))} | |
| 178 | − | </ul> | |
| 247 | + | <> | |
| 248 | + | {!shows.form && <h2 className="mt-6 text-sm font-medium">Invites you made</h2>} | |
| 249 | + | <ul className="mt-4 divide-y divide-line rounded-md border border-line"> | |
| 250 | + | {invites.map((invite) => ( | |
| 251 | + | <InviteRow key={invite.id} invite={invite} origin={origin} /> | |
| 252 | + | ))} | |
| 253 | + | </ul> | |
| 254 | + | </> | |
| 179 | 255 | )} | |
| 180 | 256 | </section> | |
| 181 | 257 | ); | |
| 39 | 39 | import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings"; | |
| 40 | 40 | import { GithubMark } from "./github"; | |
| 41 | 41 | import { withNext } from "../lib/next"; | |
| 42 | − | import { useSignUpCopy } from "../lib/registration"; | |
| 42 | + | import { useInviteOnly, useSignUpCopy } from "../lib/registration"; | |
| 43 | 43 | import { STATUS_URL, statusTitle } from "../lib/status"; | |
| 44 | 44 | import { type ShortcutProject, movedPin, recentWith } from "../lib/pins"; | |
| 45 | 45 | import type { AccountMenuData } from "../routes/settings-menu-json"; | |
| ⋯ | |||
| 406 | 406 | const me = details.data ?? null; | |
| 407 | 407 | // Still on its way: shapes where the words will be, the same size. | |
| 408 | 408 | const loading = details.data === undefined && details.state !== "idle"; | |
| 409 | + | // While anyone can sign up, listed once the data says there are invites to look back on. | |
| 410 | + | const invitesListed = useInviteOnly() || me?.invites_page === true; | |
| 409 | 411 | const profile = `/u/${user.username}`; | |
| 410 | 412 | return ( | |
| 411 | 413 | <DropdownMenu open={open} onOpenChange={setOpen}> | |
| ⋯ | |||
| 466 | 468 | Your settings | |
| 467 | 469 | </Link> | |
| 468 | 470 | </DropdownMenuItem> | |
| 471 | + | {invitesListed && ( | |
| 469 | 472 | <DropdownMenuItem asChild className={MENU_ROW}> | |
| 470 | 473 | <Link to="/settings/invites"> | |
| 471 | 474 | <Ticket /> | |
| 472 | − | Invites | |
| 475 | + | {ACCOUNT_SETTINGS.invites.title} | |
| 473 | 476 | {loading && <Skeleton className="ml-auto h-4 w-12 rounded-full" />} | |
| 474 | 477 | {me?.invites_left != null && ( | |
| 475 | 478 | <span className="ml-auto rounded-full bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted"> | |
| ⋯ | |||
| 478 | 481 | )} | |
| 479 | 482 | </Link> | |
| 480 | 483 | </DropdownMenuItem> | |
| 484 | + | )} | |
| 481 | 485 | </DropdownMenuGroup> | |
| 482 | 486 | <DropdownMenuSeparator className="my-1.5" /> | |
| 483 | 487 | <DropdownMenuGroup> | |
| ⋯ | |||
| 1181 | 1185 | ); | |
| 1182 | 1186 | } | |
| 1183 | 1187 | ||
| 1188 | + | /** | |
| 1189 | + | * Whether the menus list Settings → Invites (invites to g1t): always while | |
| 1190 | + | * sign-up takes an invite; once anyone can sign up, only for someone with | |
| 1191 | + | * invites already made to look back on, which the account menu's data says. | |
| 1192 | + | */ | |
| 1193 | + | function useInvitesListed(): boolean { | |
| 1194 | + | const inviteOnly = useInviteOnly(); | |
| 1195 | + | const details = useFetcher<AccountMenuData | null>({ key: "account-menu" }); | |
| 1196 | + | useEffect(() => { | |
| 1197 | + | if (!inviteOnly && details.state === "idle" && details.data === undefined) details.load("/settings/menu.json"); | |
| 1198 | + | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 1199 | + | }, [inviteOnly]); | |
| 1200 | + | return inviteOnly || details.data?.invites_page === true; | |
| 1201 | + | } | |
| 1202 | + | ||
| 1184 | 1203 | /** Your own settings, drilled into from Your settings: one page each. */ | |
| 1185 | 1204 | function AccountSettingsMenu({ username }: { username: string }) { | |
| 1205 | + | const invites = useInvitesListed(); | |
| 1186 | 1206 | const link = (page: AccountSettingsPage, icon: ReactNode) => ( | |
| 1187 | 1207 | <SidebarLink to={`/settings/${page}`} icon={icon}> | |
| 1188 | 1208 | {ACCOUNT_SETTINGS[page].title} | |
| ⋯ | |||
| 1194 | 1214 | <div className="mt-2 space-y-px"> | |
| 1195 | 1215 | {link("profile", <CircleUserRound size={15} />)} | |
| 1196 | 1216 | {link("emails", <Mail size={15} />)} | |
| 1197 | − | {link("invites", <Ticket size={15} />)} | |
| 1217 | + | {invites && link("invites", <Ticket size={15} />)} | |
| 1198 | 1218 | </div> | |
| 1199 | 1219 | <Rule /> | |
| 1200 | 1220 | <div className="space-y-px"> | |
| 17 | 17 | | "account"; | |
| 18 | 18 | ||
| 19 | 19 | /** Each page's name and what it is for, in the sidebar's order. */ | |
| 20 | − | export const ACCOUNT_SETTINGS: Record<AccountSettingsPage, { title: string; about: string }> = { | |
| 20 | + | export const ACCOUNT_SETTINGS: Record<AccountSettingsPage, { title: string; about: string; heading?: string }> = { | |
| 21 | 21 | profile: { title: "Profile", about: "Your picture, and what everyone sees on your profile." }, | |
| 22 | 22 | emails: { | |
| 23 | 23 | title: "Emails", | |
| ⋯ | |||
| 28 | 28 | title: "Notifications", | |
| 29 | 29 | about: "What you are also emailed for, and how you watch repositories. Everything comes to your inbox either way.", | |
| 30 | 30 | }, | |
| 31 | − | invites: { title: "Invites", about: "Bring people to g1t, and see which invites were used." }, | |
| 31 | + | // The invite to g1t, not a workspace's invitation (lib/invites.ts, G1T_INVITES). | |
| 32 | + | invites: { | |
| 33 | + | title: "Invites to g1t", | |
| 34 | + | heading: "Invite people to g1t", | |
| 35 | + | about: | |
| 36 | + | "An invite to g1t lets one person make an account. It does not add them to any workspace: their account starts with a workspace of its own.", | |
| 37 | + | }, | |
| 32 | 38 | keys: { title: "SSH keys", about: "Keys that let git on your computers reach g1t as you." }, | |
| 33 | 39 | tokens: { | |
| 34 | 40 | title: "Access tokens", | |
| 1 | 1 | import type { Invite, InvitesOverview, User } from "@g1t/contracts"; | |
| 2 | 2 | ||
| 3 | − | import { type BringInto, bringIntoChoices } from "./invites"; | |
| 3 | + | import { type BringInto, bringIntoChoices, inviteDraft, peoplePages } from "./invites"; | |
| 4 | 4 | import { readCookie } from "./mission"; | |
| 5 | 5 | import { billing, identity } from "./services.server"; | |
| 6 | 6 | import { WORKSPACE_COOKIE, chosenWorkspace } from "./workspace-choice"; | |
| ⋯ | |||
| 8 | 8 | /** What the Invites section's forms answer. */ | |
| 9 | 9 | export type InviteActionData = { inviteCreated?: Invite; inviteError?: string; inviteRevoked?: boolean } | null; | |
| 10 | 10 | ||
| 11 | − | /** The Invites section's "Bring them into" choices (`bringIntoChoices`). */ | |
| 12 | − | export type BringIntoChoices = { options: BringInto[]; chosen: string; note: string | null }; | |
| 11 | + | /** | |
| 12 | + | * The workspaces an invite to g1t can also invite its person to | |
| 13 | + | * (`bringIntoChoices`), and the People pages where a workspace invitation | |
| 14 | + | * is made instead (`peoplePages`). | |
| 15 | + | */ | |
| 16 | + | export type BringIntoChoices = { | |
| 17 | + | options: BringInto[]; | |
| 18 | + | note: string | null; | |
| 19 | + | people: { slug: string; name: string; to: string }[]; | |
| 20 | + | }; | |
| 13 | 21 | ||
| 14 | 22 | /** A person's invites, or null when they cannot make any yet (email unconfirmed). */ | |
| 15 | 23 | export async function loadInvites(user: User): Promise<InvitesOverview | null> { | |
| ⋯ | |||
| 18 | 26 | } | |
| 19 | 27 | ||
| 20 | 28 | /** | |
| 21 | − | * The workspaces an invite can bring its person into, with the one the | |
| 22 | − | * person is in (the shell's current workspace, from its cookie) chosen | |
| 23 | − | * when it is one of them. A workspace whose plan cannot be checked is not | |
| 24 | − | * offered: identity would refuse it. | |
| 29 | + | * The workspaces an invite can also invite its person to, and the People | |
| 30 | + | * pages to invite someone to a workspace from, the one the person is in | |
| 31 | + | * (the shell's current workspace, from its cookie) first. A workspace | |
| 32 | + | * whose plan cannot be checked is not offered: identity would refuse it. | |
| 25 | 33 | */ | |
| 26 | 34 | export async function loadBringInto(user: User, request: Request): Promise<BringIntoChoices> { | |
| 27 | 35 | const memberships = user.workspaces ?? []; | |
| 28 | 36 | const owned = memberships.filter((m) => m.role === "owner").map((m) => m.slug); | |
| 29 | 37 | const free = owned.length > 0 ? await billing.freeWorkspaces(owned).catch(() => owned) : []; | |
| 30 | 38 | const current = chosenWorkspace(memberships, readCookie(request.headers.get("cookie"), WORKSPACE_COOKIE))?.slug ?? null; | |
| 31 | − | return bringIntoChoices(memberships, free, current); | |
| 39 | + | return { ...bringIntoChoices(memberships, free, current), people: peoplePages(memberships, current) }; | |
| 32 | 40 | } | |
| 33 | 41 | ||
| 34 | 42 | /** Handles the Invites section's intents; null for any other intent. */ | |
| 35 | 43 | export async function inviteAction(user: User, form: FormData): Promise<InviteActionData | undefined> { | |
| 36 | 44 | switch (form.get("intent")) { | |
| 37 | 45 | case "create-invite": { | |
| 38 | − | const email = String(form.get("email") ?? "").trim(); | |
| 39 | − | const workspace = String(form.get("charge") ?? "").trim(); | |
| 40 | − | const join = String(form.get("join") ?? "").trim(); | |
| 41 | − | const result = await identity.createInvite(user, { | |
| 42 | − | email: email || null, | |
| 43 | − | workspace: workspace && workspace !== "mine" ? workspace : null, | |
| 44 | − | join: join || null, | |
| 45 | − | }); | |
| 46 | + | // A workspace goes with it only when its box is ticked (`inviteDraft`). | |
| 47 | + | const result = await identity.createInvite(user, inviteDraft(form)); | |
| 46 | 48 | return result.ok ? { inviteCreated: result.value } : { inviteError: result.error.message }; | |
| 47 | 49 | } | |
| 48 | 50 | case "revoke-invite": { | |
| 2 | 2 | import { readFileSync } from "node:fs"; | |
| 3 | 3 | import { test } from "node:test"; | |
| 4 | 4 | ||
| 5 | + | import { ACCOUNT_SETTINGS } from "./account-settings.ts"; | |
| 5 | 6 | import { CONTACT } from "./legal.ts"; | |
| 6 | 7 | import { | |
| 8 | + | G1T_INVITES, | |
| 7 | 9 | HAVE_AN_INVITE, | |
| 8 | − | OWN_WORKSPACE, | |
| 9 | 10 | bringIntoChoices, | |
| 11 | + | inviteDraft, | |
| 12 | + | inviteKind, | |
| 13 | + | invitePageCopy, | |
| 14 | + | invitesPage, | |
| 15 | + | peoplePages, | |
| 16 | + | workspaceInviteCopy, | |
| 10 | 17 | INVITES_CONTACT, | |
| 11 | 18 | cleanCode, | |
| 12 | 19 | cleanProof, | |
| ⋯ | |||
| 110 | 117 | assert.deepEqual(inviteState({ ...base, status: "expired" }), { label: "Expired", tone: "dead" }); | |
| 111 | 118 | assert.deepEqual(inviteState({ ...base, status: "revoked" }), { label: "Revoked", tone: "dead" }); | |
| 112 | 119 | assert.equal(inviteFor({ ...base, status: "pending" }), "Anyone with the link"); | |
| 113 | − | assert.equal(inviteFor({ ...base, status: "pending", email: "ada@example.com", workspace: "acme" }), "ada@example.com · invited to acme"); | |
| 114 | − | assert.equal(inviteFor({ ...base, status: "pending", invitee: "daweazl", workspace: "flagon-io" }), "@daweazl · invited to flagon-io"); | |
| 120 | + | assert.equal(inviteFor({ ...base, status: "pending", email: "ada@example.com", workspace: "acme" }), "ada@example.com"); | |
| 121 | + | assert.equal(inviteFor({ ...base, status: "pending", invitee: "daweazl", workspace: "flagon-io" }), "@daweazl"); | |
| 122 | + | // Which kind each is, beside whom it is for. | |
| 123 | + | assert.deepEqual(inviteKind({ workspace: null }), { kind: "g1t", label: "Invite to g1t" }); | |
| 124 | + | assert.deepEqual(inviteKind({ workspace: "flagon-io" }), { kind: "workspace", label: "Invite to join flagon-io" }); | |
| 115 | 125 | assert.deepEqual(inviteState({ ...base, status: "awaiting_answer", redeemedBy: "daweazl" }), { label: "Waiting for @daweazl to accept", tone: "pending" }); | |
| 116 | 126 | assert.deepEqual(inviteState({ ...base, status: "declined", invitee: "daweazl" }), { label: "@daweazl declined", tone: "dead" }); | |
| 117 | 127 | }); | |
| ⋯ | |||
| 187 | 197 | ); | |
| 188 | 198 | }); | |
| 189 | 199 | ||
| 190 | − | test("an own invite brings its person into a workspace you own that can add people, the current one first", () => { | |
| 200 | + | test("an invite to g1t can also invite its person to a workspace you own that can add people, never chosen for you", () => { | |
| 191 | 201 | const memberships = [ | |
| 192 | 202 | { slug: "flagon-io", name: "Flagon, Inc.", role: "owner" as const }, | |
| 193 | 203 | { slug: "side", name: "side", role: "owner" as const }, | |
| 194 | 204 | { slug: "friends", name: "Friends", role: "member" as const }, | |
| 195 | 205 | ]; | |
| 196 | − | // The current workspace is chosen; free ones and ones you only belong to are not offered. | |
| 206 | + | // Free ones and ones you only belong to are not offered; nothing is chosen, not even the current one. | |
| 197 | 207 | const here = bringIntoChoices(memberships, ["side"], "flagon-io"); | |
| 198 | − | assert.deepEqual(here.options, [{ slug: "flagon-io", name: "Flagon, Inc." }]); | |
| 199 | − | assert.equal(here.chosen, "flagon-io"); | |
| 200 | − | assert.equal(here.note, null); | |
| 201 | − | // In a free workspace: not offered, and the form says why; no workspace is chosen. | |
| 202 | − | const free = bringIntoChoices(memberships, ["side"], "side"); | |
| 203 | − | assert.equal(free.chosen, OWN_WORKSPACE); | |
| 204 | − | assert.match(free.note ?? "", /side is on the free plan, so it cannot add people/); | |
| 208 | + | assert.deepEqual(here, { options: [{ slug: "flagon-io", name: "Flagon, Inc." }], note: null }); | |
| 209 | + | assert.equal("chosen" in here, false); | |
| 210 | + | // In a free workspace: not offered, and the form says why. | |
| 211 | + | assert.match(bringIntoChoices(memberships, ["side"], "side").note ?? "", /side is on the free plan, so it cannot add people/); | |
| 205 | 212 | // In one you are only a member of. | |
| 206 | 213 | assert.match(bringIntoChoices(memberships, [], "friends").note ?? "", /Only the owners of friends/); | |
| 207 | − | // No workspace at all: their own. | |
| 208 | − | assert.deepEqual(bringIntoChoices([], [], null), { options: [], chosen: OWN_WORKSPACE, note: null }); | |
| 214 | + | assert.deepEqual(bringIntoChoices([], [], null), { options: [], note: null }); | |
| 215 | + | }); | |
| 216 | + | ||
| 217 | + | /** A submitted form, as `inviteDraft` reads it. */ | |
| 218 | + | const form = (fields: Record<string, string>) => ({ get: (name: string) => fields[name] ?? null }); | |
| 219 | + | ||
| 220 | + | test("an invite to g1t sends no workspace unless its box is ticked", () => { | |
| 221 | + | // Off by default: a form without the box sends no `join`, even with a workspace left in it. | |
| 222 | + | assert.deepEqual(inviteDraft(form({ intent: "create-invite", email: " ada@example.com ", charge: "mine" })), { | |
| 223 | + | email: "ada@example.com", | |
| 224 | + | workspace: null, | |
| 225 | + | }); | |
| 226 | + | const untickedButFilled = inviteDraft(form({ email: "", join: "flagon-io", join_role: "owner" })); | |
| 227 | + | assert.equal("join" in untickedButFilled, false); | |
| 228 | + | assert.equal("joinRole" in untickedButFilled, false); | |
| 229 | + | assert.equal(untickedButFilled.email, null); | |
| 230 | + | // Ticked: the workspace and its role go with it. | |
| 231 | + | assert.deepEqual(inviteDraft(form({ also_join: "on", join: "flagon-io", join_role: "owner", charge: "flagon-io" })), { | |
| 232 | + | email: null, | |
| 233 | + | workspace: "flagon-io", | |
| 234 | + | join: "flagon-io", | |
| 235 | + | joinRole: "owner", | |
| 236 | + | }); | |
| 237 | + | // Ticked with no workspace chosen: still none. Any role but owner is member. | |
| 238 | + | assert.equal("join" in inviteDraft(form({ also_join: "on", join: "" })), false); | |
| 239 | + | assert.equal(inviteDraft(form({ also_join: "on", join: "acme", join_role: "admin" })).joinRole, "member"); | |
| 209 | 240 | }); | |
| 210 | 241 | ||
| 211 | − | test("the invites form offers each workspace and no workspace, named for what it does", () => { | |
| 242 | + | test("the invites form keeps the workspace behind an unticked box", () => { | |
| 212 | 243 | const section = readFileSync(new URL("../components/invites-section.tsx", import.meta.url), "utf8"); | |
| 213 | − | assert.match(section, /Bring them into/); | |
| 214 | − | assert.match(section, /name="join"/); | |
| 215 | − | assert.match(section, /No workspace — they'll get their own/); | |
| 216 | − | assert.match(section, /defaultValue=\{bringInto\.chosen\}/); | |
| 244 | + | assert.match(section, /useState\(false\)/); | |
| 245 | + | assert.match(section, /name="also_join"/); | |
| 246 | + | // The workspace and role fields are drawn only once the box is ticked, so nothing else is sent. | |
| 247 | + | assert.match(section, /\{alsoJoin && \(\s*<div[^]*?name="join"[^]*?name="join_role"/); | |
| 248 | + | assert.match(section, /<option value="" disabled>\s*Choose a workspace/); | |
| 249 | + | assert.doesNotMatch(section, /bringInto\.chosen|Bring them into/); | |
| 250 | + | }); | |
| 251 | + | ||
| 252 | + | test("invites to g1t are made only while sign-up takes one; after that only the list stays", () => { | |
| 253 | + | assert.deepEqual(invitesPage("invite", 0), { form: true, listed: true }); | |
| 254 | + | assert.deepEqual(invitesPage(null, 0), { form: true, listed: true }); | |
| 255 | + | // Open: no form; the menus list the page only with invites to look back on. | |
| 256 | + | assert.deepEqual(invitesPage("open", 0), { form: false, listed: false }); | |
| 257 | + | assert.deepEqual(invitesPage("open", 3), { form: false, listed: true }); | |
| 258 | + | assert.match(G1T_INVITES.open, /^Anyone can sign up for g1t now/); | |
| 259 | + | assert.match(G1T_INVITES.open, /workspace's People page/); | |
| 260 | + | }); | |
| 261 | + | ||
| 262 | + | test("the two invites say which they are", () => { | |
| 263 | + | // Settings → Invites: an account, and no workspace. | |
| 264 | + | assert.equal(G1T_INVITES.heading, "Invite people to g1t"); | |
| 265 | + | assert.equal(ACCOUNT_SETTINGS.invites.heading, G1T_INVITES.heading); | |
| 266 | + | assert.equal(ACCOUNT_SETTINGS.invites.title, G1T_INVITES.nav); | |
| 267 | + | assert.equal(ACCOUNT_SETTINGS.invites.about, G1T_INVITES.about); | |
| 268 | + | assert.match(G1T_INVITES.about, /lets one person make an account\. It does not add them to any workspace/); | |
| 269 | + | assert.equal(G1T_INVITES.alsoJoin, "Also invite them to a workspace"); | |
| 270 | + | // A workspace's People page: an invitation to accept or decline, which signs up whoever has no account. | |
| 271 | + | const closed = workspaceInviteCopy("Flagon, Inc.", true); | |
| 272 | + | assert.equal(closed.heading, "Invite to Flagon, Inc."); | |
| 273 | + | assert.match(closed.hint, /invitation to join Flagon, Inc\..*join only if they accept/); | |
| 274 | + | assert.match(closed.hint, /If they do not have a g1t account yet, the invitation also lets them sign up/); | |
| 275 | + | assert.equal(closed.elsewhere, "To invite someone to g1t without adding them to Flagon, Inc., use Settings → Invites."); | |
| 276 | + | // Once anyone can sign up, there is no invite to g1t to point to. | |
| 277 | + | const open = workspaceInviteCopy("Flagon, Inc.", false); | |
| 278 | + | assert.equal(open.elsewhere, null); | |
| 279 | + | assert.doesNotMatch(open.hint, /one of yours/); | |
| 280 | + | // Settings → Invites points to the People pages of the workspaces you own, the current one first. | |
| 281 | + | assert.deepEqual( | |
| 282 | + | peoplePages( | |
| 283 | + | [ | |
| 284 | + | { slug: "side", name: null, role: "owner" }, | |
| 285 | + | { slug: "Flagon-io", name: "Flagon, Inc.", role: "owner" }, | |
| 286 | + | { slug: "friends", name: "Friends", role: "member" }, | |
| 287 | + | ], | |
| 288 | + | "flagon-io", | |
| 289 | + | ), | |
| 290 | + | [ | |
| 291 | + | { slug: "flagon-io", name: "Flagon, Inc.", to: "/flagon-io/-/people" }, | |
| 292 | + | { slug: "side", name: "side", to: "/side/-/people" }, | |
| 293 | + | ], | |
| 294 | + | ); | |
| 295 | + | }); | |
| 296 | + | ||
| 297 | + | test("an invite's page names the invite it is", () => { | |
| 298 | + | const base = { kind: "account" as const, invitedBy: { username: "syntaqx" }, workspace: null, repository: null, hasAccount: false }; | |
| 299 | + | const g1t = invitePageCopy(base, false); | |
| 300 | + | assert.equal(`${g1t.before}${g1t.place ?? ""}${g1t.after}`, "@syntaqx invited you to g1t"); | |
| 301 | + | assert.match(g1t.about, /lets you make an account\. It does not add you to anyone's workspace/); | |
| 302 | + | const join = invitePageCopy({ ...base, workspace: { name: "Flagon, Inc." } }, false); | |
| 303 | + | assert.equal(`${join.before}${join.place}${join.after}`, "@syntaqx invited you to join Flagon, Inc. on g1t"); | |
| 304 | + | assert.equal(join.place, "Flagon, Inc."); | |
| 305 | + | assert.match(join.about, /invitation to join Flagon, Inc\., which you accept or decline/); | |
| 306 | + | assert.match(join.about, /You do not have a g1t account yet, so it also lets you make one/); | |
| 307 | + | // Someone with an account, or signed in, just accepts. | |
| 308 | + | assert.match(invitePageCopy({ ...base, kind: "workspace", workspace: { name: "Flagon, Inc." }, hasAccount: true }, false).about, /Accepting joins you to Flagon, Inc\./); | |
| 309 | + | assert.equal(invitePageCopy({ ...base, invitedBy: null }, false).before, "The g1t team invited you to g1t"); | |
| 217 | 310 | }); | |
| 163 | 163 | } | |
| 164 | 164 | } | |
| 165 | 165 | ||
| 166 | + | type Previewed = { | |
| 167 | + | kind: "account" | "workspace"; | |
| 168 | + | invitedBy: { username: string } | null; | |
| 169 | + | workspace: { name: string } | null; | |
| 170 | + | repository: { name: string; role: string } | null; | |
| 171 | + | hasAccount: boolean; | |
| 172 | + | }; | |
| 173 | + | ||
| 174 | + | /** | |
| 175 | + | * What an invite's page (/invite/:code) says it is, so nobody mistakes one | |
| 176 | + | * kind for the other: the headline, in three parts with the place between | |
| 177 | + | * (shown in bold), and the line under it. "@syntaqx invited you to g1t" is | |
| 178 | + | * an account and no workspace; "@syntaqx invited you to join Flagon, Inc. | |
| 179 | + | * on g1t" is a workspace invitation, which also makes the account of | |
| 180 | + | * someone who has none. | |
| 181 | + | */ | |
| 182 | + | export function invitePageCopy( | |
| 183 | + | invite: Previewed, | |
| 184 | + | signedIn: boolean, | |
| 185 | + | ): { before: string; place: string | null; after: string; about: string } { | |
| 186 | + | const from = invite.invitedBy ? `@${invite.invitedBy.username}` : "The g1t team"; | |
| 187 | + | const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account"; | |
| 188 | + | const g1t = "g1t is one workspace where a team and its agents talk, work and ship"; | |
| 189 | + | if (invite.workspace) { | |
| 190 | + | const name = invite.workspace.name; | |
| 191 | + | return { | |
| 192 | + | before: `${from} invited you to join `, | |
| 193 | + | place: name, | |
| 194 | + | after: " on g1t", | |
| 195 | + | about: `This is an invitation to join ${name}, which you accept or decline. ${ | |
| 196 | + | signingUp | |
| 197 | + | ? `You do not have a g1t account yet, so it also lets you make one: make it below, then join ${name}.` | |
| 198 | + | : `Accepting joins you to ${name}.` | |
| 199 | + | }`, | |
| 200 | + | }; | |
| 201 | + | } | |
| 202 | + | if (invite.repository) { | |
| 203 | + | return { | |
| 204 | + | before: `${from} invited you to collaborate on `, | |
| 205 | + | place: invite.repository.name, | |
| 206 | + | after: "", | |
| 207 | + | about: `${g1t}. ${signingUp ? "Make your account below and you get" : "Accepting gives you"} the ${invite.repository.role} role on ${invite.repository.name}.`, | |
| 208 | + | }; | |
| 209 | + | } | |
| 210 | + | return { | |
| 211 | + | before: `${from} invited you to g1t`, | |
| 212 | + | place: null, | |
| 213 | + | after: "", | |
| 214 | + | about: `${g1t}: chat with people and agents, give agents a job and a budget, and land code through checks that hold. This invite lets you make an account. It does not add you to anyone's workspace: your account starts with a workspace of its own.`, | |
| 215 | + | }; | |
| 216 | + | } | |
| 217 | + | ||
| 166 | 218 | /** Who an invite is for, in a list. */ | |
| 167 | 219 | export function inviteFor(invite: Listed): string { | |
| 168 | − | const who = invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link"); | |
| 169 | − | return invite.workspace ? `${who} · invited to ${invite.workspace}` : who; | |
| 220 | + | return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link"); | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | /** | |
| 224 | + | * Which of the two invites a listed one is, in words: an invite to g1t | |
| 225 | + | * (an account, and no workspace), or an invitation to join a workspace. | |
| 226 | + | */ | |
| 227 | + | export function inviteKind(invite: { workspace: string | null }): { kind: "g1t" | "workspace"; label: string } { | |
| 228 | + | return invite.workspace | |
| 229 | + | ? { kind: "workspace", label: `Invite to join ${invite.workspace}` } | |
| 230 | + | : { kind: "g1t", label: "Invite to g1t" }; | |
| 170 | 231 | } | |
| 171 | 232 | ||
| 172 | 233 | type Membership = { slug: string; name?: string | null; role: "owner" | "member" }; | |
| 173 | 234 | ||
| 174 | − | /** One workspace an own invite can bring its person into. */ | |
| 235 | + | /** One workspace an own invite can also invite its person to. */ | |
| 175 | 236 | export type BringInto = { slug: string; name: string }; | |
| 176 | 237 | ||
| 177 | − | /** The value of "No workspace — they'll get their own" in the form. */ | |
| 178 | − | export const OWN_WORKSPACE = ""; | |
| 179 | − | ||
| 180 | 238 | /** | |
| 181 | − | * The "Bring them into" choices on Settings → Invites: the workspaces the | |
| 182 | − | * person may add members to (ones they own that are not on the free plan, | |
| 183 | − | * which adds no one), and which is chosen at first: the workspace they are | |
| 184 | − | * in (`current`) when it is one of those, else none (the new account gets | |
| 185 | − | * a workspace of its own). `note` says why the current one is not offered. | |
| 239 | + | * The workspaces Settings → Invites can also invite the person to, when | |
| 240 | + | * "Also invite them to a workspace" is ticked: the ones the viewer owns | |
| 241 | + | * that are not on the free plan, which adds no one. None is chosen for | |
| 242 | + | * them: the box is off at first, and the list starts on "Choose a | |
| 243 | + | * workspace". `note` says why the viewer's current workspace is missing. | |
| 186 | 244 | */ | |
| 187 | 245 | export function bringIntoChoices( | |
| 188 | 246 | memberships: Membership[], | |
| 189 | 247 | free: string[], | |
| 190 | 248 | current: string | null | undefined, | |
| 191 | − | ): { options: BringInto[]; chosen: string; note: string | null } { | |
| 249 | + | ): { options: BringInto[]; note: string | null } { | |
| 192 | 250 | const isFree = new Set(free.map((slug) => slug.toLowerCase())); | |
| 193 | 251 | const options = memberships | |
| 194 | 252 | .filter((m) => m.role === "owner" && !isFree.has(m.slug.toLowerCase())) | |
| 195 | 253 | .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug })); | |
| 196 | 254 | const here = current?.trim().toLowerCase() || null; | |
| 197 | − | const chosen = here && options.some((option) => option.slug === here) ? here : OWN_WORKSPACE; | |
| 198 | 255 | let note: string | null = null; | |
| 199 | 256 | const membership = here ? memberships.find((m) => m.slug.toLowerCase() === here) : undefined; | |
| 200 | − | if (membership && !chosen) { | |
| 257 | + | if (membership && !options.some((option) => option.slug === here)) { | |
| 201 | 258 | note = | |
| 202 | 259 | membership.role !== "owner" | |
| 203 | − | ? `Only the owners of ${membership.slug} can bring people into it.` | |
| 204 | − | : `${membership.slug} is on the free plan, so it cannot add people. Start the plan to bring people into it.`; | |
| 260 | + | ? `Only the owners of ${membership.slug} can invite people to it.` | |
| 261 | + | : `${membership.slug} is on the free plan, so it cannot add people. Start the plan to invite people to it.`; | |
| 262 | + | } | |
| 263 | + | return { options, note }; | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /** | |
| 267 | + | * What the Settings → Invites form sends to identity. The workspace goes | |
| 268 | + | * with it only when "Also invite them to a workspace" (`also_join`) is | |
| 269 | + | * ticked: unticked, the invite is to g1t alone, whatever else the form held. | |
| 270 | + | */ | |
| 271 | + | export function inviteDraft(form: { get(name: string): unknown }): { | |
| 272 | + | email: string | null; | |
| 273 | + | workspace: string | null; | |
| 274 | + | join?: string; | |
| 275 | + | joinRole?: "owner" | "member"; | |
| 276 | + | } { | |
| 277 | + | const text = (name: string) => { | |
| 278 | + | const value = form.get(name); | |
| 279 | + | return typeof value === "string" ? value.trim() : ""; | |
| 280 | + | }; | |
| 281 | + | const charge = text("charge"); | |
| 282 | + | const draft: ReturnType<typeof inviteDraft> = { | |
| 283 | + | email: text("email") || null, | |
| 284 | + | workspace: charge && charge !== "mine" ? charge : null, | |
| 285 | + | }; | |
| 286 | + | const join = text("join"); | |
| 287 | + | if (text("also_join") === "on" && join) { | |
| 288 | + | draft.join = join; | |
| 289 | + | draft.joinRole = text("join_role") === "owner" ? "owner" : "member"; | |
| 205 | 290 | } | |
| 206 | − | return { options, chosen, note }; | |
| 291 | + | return draft; | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | /** | |
| 295 | + | * What Settings → Invites shows. Invites to g1t exist only while sign-up | |
| 296 | + | * takes one: then the page has the form. Once anyone can sign up, it | |
| 297 | + | * keeps only the list of invites already made, and the settings menu | |
| 298 | + | * lists the page only when there are some. | |
| 299 | + | */ | |
| 300 | + | export function invitesPage(mode: "invite" | "open" | null | undefined, made: number): { form: boolean; listed: boolean } { | |
| 301 | + | const inviteOnly = mode !== "open"; | |
| 302 | + | return { form: inviteOnly, listed: inviteOnly || made > 0 }; | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /** The words for Settings → Invites, the invite to g1t. */ | |
| 306 | + | export const G1T_INVITES = { | |
| 307 | + | /** The page's heading. */ | |
| 308 | + | heading: "Invite people to g1t", | |
| 309 | + | /** Its name in the settings menu and the account menu. */ | |
| 310 | + | nav: "Invites to g1t", | |
| 311 | + | about: | |
| 312 | + | "An invite to g1t lets one person make an account. It does not add them to any workspace: their account starts with a workspace of its own.", | |
| 313 | + | /** In place of the form once anyone can sign up. */ | |
| 314 | + | open: "Anyone can sign up for g1t now, so there are no invites to make here. Invitations to a workspace live on each workspace's People page.", | |
| 315 | + | /** The off-by-default box that also invites the person to a workspace. */ | |
| 316 | + | alsoJoin: "Also invite them to a workspace", | |
| 317 | + | alsoJoinHint: | |
| 318 | + | "Once their account is made, they get an invitation to the workspace to accept or decline. Left off, the invite is to g1t only.", | |
| 319 | + | /** Where the other kind of invite lives. */ | |
| 320 | + | elsewhere: "To bring someone into a workspace, invite them from that workspace's People page", | |
| 321 | + | } as const; | |
| 322 | + | ||
| 323 | + | /** | |
| 324 | + | * The words for a workspace's People page, the invitation to join it. | |
| 325 | + | * `inviteOnly` says whether sign-up takes an invite: then an invitation to | |
| 326 | + | * an address with no account also lets it make one (and costs an invite), | |
| 327 | + | * and the page points to Settings → Invites for an invite to g1t alone. | |
| 328 | + | */ | |
| 329 | + | export function workspaceInviteCopy(name: string, inviteOnly: boolean): { heading: string; hint: string; elsewhere: string | null } { | |
| 330 | + | const base = `Search people on g1t by username or name, or enter an email address. They get an invitation to join ${name}, in their inbox and by email, and join only if they accept.`; | |
| 331 | + | return { | |
| 332 | + | heading: `Invite to ${name}`, | |
| 333 | + | hint: inviteOnly | |
| 334 | + | ? `${base} If they do not have a g1t account yet, the invitation also lets them sign up; that uses one of ${name}'s shared invites, or else one of yours.` | |
| 335 | + | : `${base} If they do not have a g1t account yet, they sign up from the invitation first.`, | |
| 336 | + | elsewhere: inviteOnly ? `To invite someone to g1t without adding them to ${name}, use Settings → Invites.` : null, | |
| 337 | + | }; | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | /** | |
| 341 | + | * The People pages Settings → Invites points to for a workspace | |
| 342 | + | * invitation: the workspaces the viewer owns (only owners invite), the | |
| 343 | + | * current one first. | |
| 344 | + | */ | |
| 345 | + | export function peoplePages(memberships: Membership[], current: string | null | undefined): { slug: string; name: string; to: string }[] { | |
| 346 | + | const here = current?.trim().toLowerCase() || null; | |
| 347 | + | return memberships | |
| 348 | + | .filter((m) => m.role === "owner") | |
| 349 | + | .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug, to: `/${m.slug.toLowerCase()}/-/people` })) | |
| 350 | + | .sort((a, b) => Number(b.slug === here) - Number(a.slug === here)); | |
| 207 | 351 | } | |
| 208 | 352 | ||
| 209 | 353 | /** How many invites are left, in words. */ |
| 89 | 89 | export async function loader({ context, params, request }: Route.LoaderArgs) { | |
| 90 | 90 | const user = getViewer(context); | |
| 91 | 91 | const chosen = readCookie(request.headers.get("cookie"), WORKSPACE_COOKIE); | |
| 92 | − | // Whether sign-up takes an invite: only the sign-up page says so. | |
| 92 | + | // Whether sign-up takes an invite: the sign-up page says so, and | |
| 93 | + | // Settings → Invites offers invites to g1t only then. Cached per isolate. | |
| 93 | 94 | const [shell, mode] = await Promise.all([ | |
| 94 | 95 | // An account still confirming its address sees only the pages that | |
| 95 | 96 | // allows (lib/confirm-gate.ts), in the visitor's frame. | |
| ⋯ | |||
| 101 | 102 | return bareShell(user, params, chosen); | |
| 102 | 103 | }) | |
| 103 | 104 | : visitorShell(params, context), | |
| 104 | − | user ? Promise.resolve(null) : registrationMode(), | |
| 105 | + | registrationMode(), | |
| 105 | 106 | ]); | |
| 106 | 107 | // Where this g1t lives, for clone lines, agent setup and link previews. | |
| 107 | 108 | return { user, shell, inviteOnly: mode !== "open", addresses: addresses() }; | |
| 11 | 11 | import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 12 | 12 | import { githubSignInEnabled } from "../lib/github.server"; | |
| 13 | 13 | import { identity } from "../lib/services.server"; | |
| 14 | − | import { cleanCode, cleanProof, inviteSignUpCopy, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; | |
| 14 | + | import { cleanCode, cleanProof, invitePageCopy, inviteSignUpCopy, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; | |
| 15 | 15 | import { clientKey } from "../lib/registration.server"; | |
| 16 | 16 | import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server"; | |
| 17 | 17 | import { rememberWorkspace } from "../lib/workspace-choice"; | |
| ⋯ | |||
| 158 | 158 | ); | |
| 159 | 159 | } | |
| 160 | 160 | ||
| 161 | − | function senderName(invite: InvitePreview): string { | |
| 162 | − | return invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team"; | |
| 163 | − | } | |
| 164 | − | ||
| 165 | − | /** "Chase Pierce invited you to join Flagon, Inc. on g1t", with the place in bold. */ | |
| 161 | + | /** | |
| 162 | + | * Which invite it is, in the headline (`invitePageCopy`): "@syntaqx | |
| 163 | + | * invited you to join Flagon, Inc. on g1t" (a workspace invitation, with | |
| 164 | + | * the place in bold), "… to collaborate on acme/web", or "@syntaqx invited | |
| 165 | + | * you to g1t" (an account, and no workspace). | |
| 166 | + | */ | |
| 166 | 167 | function Headline({ invite }: { invite: InvitePreview }) { | |
| 167 | − | const from = senderName(invite); | |
| 168 | − | if (invite.workspace) { | |
| 169 | − | return ( | |
| 170 | − | <> | |
| 171 | − | {from} invited you to join <strong className="font-semibold text-fg">{invite.workspace.name}</strong> on g1t | |
| 172 | − | </> | |
| 173 | − | ); | |
| 174 | − | } | |
| 175 | − | if (invite.repository) { | |
| 176 | − | return ( | |
| 177 | − | <> | |
| 178 | − | {from} invited you to collaborate on <strong className="font-mono font-semibold text-fg">{invite.repository.name}</strong> | |
| 179 | − | </> | |
| 180 | − | ); | |
| 181 | − | } | |
| 182 | − | return <>{from} invited you to g1t</>; | |
| 183 | − | } | |
| 184 | − | ||
| 185 | − | function about(invite: InvitePreview, signedIn: boolean): string { | |
| 186 | − | const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account"; | |
| 187 | − | if (invite.workspace) { | |
| 188 | − | return `g1t is one workspace where a team and its agents talk, work and ship. ${ | |
| 189 | − | signingUp ? "Make your account below and you join" : "Accepting joins you to" | |
| 190 | − | } ${invite.workspace.name} as a member.`; | |
| 191 | − | } | |
| 192 | − | if (invite.repository) { | |
| 193 | − | return `g1t is one workspace where a team and its agents talk, work and ship. ${ | |
| 194 | − | signingUp ? "Make your account below and you get" : "Accepting gives you" | |
| 195 | − | } the ${invite.repository.role} role on ${invite.repository.name}.`; | |
| 196 | − | } | |
| 197 | − | return "g1t is one workspace where a team and its agents talk, work and ship: chat with people and agents, give agents a job and a budget, and land code through checks that hold. This invite gets you in."; | |
| 168 | + | const copy = invitePageCopy(invite, false); | |
| 169 | + | return ( | |
| 170 | + | <> | |
| 171 | + | {copy.before} | |
| 172 | + | {copy.place && ( | |
| 173 | + | <strong className={`font-semibold text-fg ${invite.repository && !invite.workspace ? "font-mono" : ""}`}>{copy.place}</strong> | |
| 174 | + | )} | |
| 175 | + | {copy.after} | |
| 176 | + | </> | |
| 177 | + | ); | |
| 198 | 178 | } | |
| 199 | 179 | ||
| 200 | 180 | /** What accepting is called on its button. */ | |
| ⋯ | |||
| 466 | 446 | <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance text-fg-soft"> | |
| 467 | 447 | <Headline invite={invite} /> | |
| 468 | 448 | </h1> | |
| 469 | − | <p className="mt-2 text-sm leading-6 text-muted">{about(invite, loaderData.viewer !== null)}</p> | |
| 449 | + | <p className="mt-2 text-sm leading-6 text-muted">{invitePageCopy(invite, loaderData.viewer !== null).about}</p> | |
| 470 | 450 | <dl className="mt-5 space-y-1 text-sm"> | |
| 471 | 451 | {invite.invitedBy && ( | |
| 472 | 452 | <div className="flex gap-2"> | |
| 8 | 8 | import { accounts, identity } from "../lib/services.server"; | |
| 9 | 9 | import { getViewer } from "../lib/session.server"; | |
| 10 | 10 | import { loadInvites } from "../lib/invites.server"; | |
| 11 | + | import { invitesPage } from "../lib/invites"; | |
| 11 | 12 | ||
| 12 | 13 | export type AccountMenuData = { | |
| 13 | 14 | name: string | null; | |
| 14 | 15 | email: string | null; | |
| 15 | 16 | /** Invites left, or null when there is no limit (or none can be made yet). */ | |
| 16 | 17 | invites_left: number | null; | |
| 18 | + | /** | |
| 19 | + | * Whether the menus list Settings → Invites: while sign-up takes an | |
| 20 | + | * invite, or, once anyone can sign up, when there are invites already | |
| 21 | + | * made to look back on (`invitesPage`). | |
| 22 | + | */ | |
| 23 | + | invites_page: boolean; | |
| 17 | 24 | }; | |
| 18 | 25 | ||
| 19 | 26 | export async function loader({ context }: Route.LoaderArgs) { | |
| ⋯ | |||
| 28 | 35 | const data: AccountMenuData = { | |
| 29 | 36 | name: profile?.name?.trim() || null, | |
| 30 | 37 | email: primary, | |
| 31 | − | invites_left: invites?.allowance.limit != null ? (invites.allowance.remaining ?? 0) : null, | |
| 38 | + | // Nothing to make once anyone can sign up, so no count either. | |
| 39 | + | invites_left: invites?.mode !== "open" && invites?.allowance.limit != null ? (invites.allowance.remaining ?? 0) : null, | |
| 40 | + | // An account that cannot list its invites yet (unconfirmed) keeps the page: it says why. | |
| 41 | + | invites_page: invites ? invitesPage(invites.mode, invites.invites.length).listed : true, | |
| 32 | 42 | }; | |
| 33 | 43 | // Not kept by the browser: the menu asks again after a name, address or | |
| 34 | 44 | // invite changes, and a kept copy would show the old one for a minute. | |
| 5 | 5 | import { inviteAction, loadBringInto, loadInvites } from "../../lib/invites.server"; | |
| 6 | 6 | ||
| 7 | 7 | export function meta(args: Route.MetaArgs) { | |
| 8 | − | return page(args, { title: "Invites · Settings · g1t" }); | |
| 8 | + | return page(args, { title: "Invites to g1t · Settings · g1t" }); | |
| 9 | 9 | } | |
| 10 | 10 | ||
| 11 | 11 | export async function loader({ request, context }: Route.LoaderArgs) { |
| 25 | 25 | <main className="mx-auto max-w-5xl px-4 py-10 sm:px-8"> | |
| 26 | 26 | {about && ( | |
| 27 | 27 | <header className="mb-8 border-b border-line pb-5"> | |
| 28 | − | <h1 className="text-lg font-semibold tracking-tight">{about.title}</h1> | |
| 28 | + | <h1 className="text-lg font-semibold tracking-tight">{about.heading ?? about.title}</h1> | |
| 29 | 29 | <p className="mt-1 max-w-2xl text-sm text-muted">{about.about}</p> | |
| 30 | 30 | </header> | |
| 31 | 31 | )} |
| 40 | 40 | import { forgetWorkspace } from "../../lib/workspace-choice"; | |
| 41 | 41 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 42 | 42 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; | |
| 43 | − | import { inviteLink, inviteState, moreInvitesMailto } from "../../lib/invites"; | |
| 43 | + | import { inviteLink, inviteState, moreInvitesMailto, workspaceInviteCopy } from "../../lib/invites"; | |
| 44 | + | import { registrationMode } from "../../lib/registration.server"; | |
| 44 | 45 | import { billing, identity } from "../../lib/services.server"; | |
| 45 | 46 | import { StartPlanToInvite } from "../../components/start-plan"; | |
| 46 | 47 | import { | |
| ⋯ | |||
| 65 | 66 | // someone a repository is shared with, gets nothing here. | |
| 66 | 67 | if (!role) throw data(null, { status: 404 }); | |
| 67 | 68 | const owner = role === "owner"; | |
| 68 | − | const [members, invites, workspace, outside, teams, free] = await Promise.all([ | |
| 69 | + | const [members, invites, workspace, outside, teams, free, mode] = await Promise.all([ | |
| 69 | 70 | identity.listMembers(params.owner, viewer), | |
| 70 | 71 | owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null, | |
| 71 | 72 | identity.getWorkspace(params.owner), | |
| ⋯ | |||
| 75 | 76 | // A free workspace adds no one until it starts the plan; identity | |
| 76 | 77 | // refuses it either way, so a failure here only hides the note. | |
| 77 | 78 | billing.freeWorkspaces([params.owner]).catch(() => [] as string[]), | |
| 79 | + | // Whether sign-up takes an invite: the invite form says what that means. | |
| 80 | + | registrationMode(), | |
| 78 | 81 | ]); | |
| 79 | 82 | return { | |
| 80 | 83 | role, | |
| 84 | + | name: workspace?.name?.trim() || params.owner, | |
| 85 | + | inviteOnly: mode !== "open", | |
| 81 | 86 | free: free.includes(params.owner.toLowerCase()), | |
| 82 | 87 | members: unwrap(members), | |
| 83 | 88 | invites: invites?.ok ? invites.value : [], | |
| ⋯ | |||
| 308 | 313 | } | |
| 309 | 314 | ||
| 310 | 315 | export default function WorkspacePeople({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 311 | − | const { role, members, invites, origin, base, outside, teams, free, me } = loaderData; | |
| 316 | + | const { role, members, invites, origin, base, outside, teams, free, me, name, inviteOnly } = loaderData; | |
| 312 | 317 | const owner = role === "owner"; | |
| 318 | + | // The invitation to join this workspace, not an invite to g1t (Settings → Invites). | |
| 319 | + | const inviteCopy = workspaceInviteCopy(name, inviteOnly); | |
| 313 | 320 | const owners = members.filter((member) => member.role === "owner").length; | |
| 314 | 321 | const rowError = (username: string) => | |
| 315 | 322 | actionData && "row" in actionData && actionData.row === username ? (actionData.error ?? null) : null; | |
| ⋯ | |||
| 372 | 379 | )} | |
| 373 | 380 | {owner && !free && ( | |
| 374 | 381 | // Empty again once the person is on the list; kept as typed when it failed. | |
| 382 | + | <section aria-labelledby="invite-to-workspace" className="mt-6"> | |
| 383 | + | <h2 id="invite-to-workspace" className="text-sm font-medium"> | |
| 384 | + | {inviteCopy.heading} | |
| 385 | + | </h2> | |
| 386 | + | <p className="mt-1 text-xs text-faint">{inviteCopy.hint}</p> | |
| 375 | 387 | <Form | |
| 376 | 388 | method="post" | |
| 377 | 389 | key={`${members.length}:${pending.length}`} | |
| 378 | − | className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-start" | |
| 390 | + | className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-start" | |
| 379 | 391 | > | |
| 380 | 392 | <input type="hidden" name="action" value="add" /> | |
| 381 | 393 | <div className="grow"> | |
| 382 | − | <Field | |
| 383 | − | label="Invite someone" | |
| 384 | − | hint="Search people on g1t by username or name, or enter an email address. They get an invitation in their inbox and by email, and join once they accept. An address without a g1t account gets an invite to make one, using one of your invites." | |
| 385 | − | > | |
| 394 | + | <Field label="Who"> | |
| 386 | 395 | <PeoplePicker name="member" placeholder="username, name or name@example.com" /> | |
| 387 | 396 | </Field> | |
| 388 | 397 | </div> | |
| ⋯ | |||
| 403 | 412 | </SubmitButton> | |
| 404 | 413 | </div> | |
| 405 | 414 | </Form> | |
| 415 | + | {inviteCopy.elsewhere && ( | |
| 416 | + | <p className="mt-2 text-xs text-faint"> | |
| 417 | + | {inviteCopy.elsewhere.replace(/Settings → Invites\.$/, "")} | |
| 418 | + | <Link to="/settings/invites" className="text-muted underline underline-offset-4 hover:text-fg"> | |
| 419 | + | Settings → Invites | |
| 420 | + | </Link> | |
| 421 | + | . | |
| 422 | + | </p> | |
| 423 | + | )} | |
| 424 | + | </section> | |
| 406 | 425 | )} | |
| 407 | 426 | {actionData && "invited" in actionData && actionData.invited && ( | |
| 408 | 427 | <p className="text-sm text-muted" role="status"> | |
| 409 | − | Invitation sent to <span className="text-fg">{actionData.invited}</span>. They join once they accept. | |
| 428 | + | Invitation to join {name} sent to <span className="text-fg">{actionData.invited}</span>. They join once they accept. | |
| 410 | 429 | </p> | |
| 411 | 430 | )} | |
| 412 | 431 | {actionData && "transferred" in actionData && ( | |
| 1283 | 1283 | /// member, and no workspace of its own is made for it. | |
| 1284 | 1284 | #[serde(default)] | |
| 1285 | 1285 | pub join: Option<String>, | |
| 1286 | + | /// The role `join` invites them with; member when absent. Ignored | |
| 1287 | + | /// without `join`. | |
| 1288 | + | #[serde(default)] | |
| 1289 | + | pub join_role: Option<crate::Role>, | |
| 1286 | 1290 | /// Where the request came in, for the audit log; g1t.sh when absent. | |
| 1287 | 1291 | #[serde(default)] | |
| 1288 | 1292 | pub surface: Option<crate::audit::Surface>, |
| 152 | 152 | registration: () => call("registration", {}), | |
| 153 | 153 | listInvites: (user) => call("list_invites", { user }), | |
| 154 | 154 | createInvite: (user, options = {}) => | |
| 155 | − | call("create_invite", { user, email: options.email ?? null, workspace: options.workspace ?? null, join: options.join ?? null }), | |
| 155 | + | call("create_invite", { user, email: options.email ?? null, workspace: options.workspace ?? null, join: options.join ?? null, join_role: options.join ? (options.joinRole ?? null) : null }), | |
| 156 | 156 | revokeInvite: (user, id) => call("revoke_invite", { user, id }), | |
| 157 | 157 | checkInvite: (code, client, options = {}) => | |
| 158 | 158 | call("check_invite", { |
| 932 | 932 | */ | |
| 933 | 933 | createInvite( | |
| 934 | 934 | user: User, | |
| 935 | − | options?: { email?: string | null; workspace?: string | null; join?: string | null }, | |
| 935 | + | options?: { email?: string | null; workspace?: string | null; join?: string | null; joinRole?: "owner" | "member" | null }, | |
| 936 | 936 | ): Promise<Result<Invite>>; | |
| 937 | 937 | /** Its maker, or an owner of its workspace, revokes a pending invite; the invite comes back. */ | |
| 938 | 938 | revokeInvite(user: User, id: string): Promise<Result<Invite>>; |
| 520 | 520 | let who = from.unwrap_or("The g1t team"); | |
| 521 | 521 | match (workspace, joins_existing_account) { | |
| 522 | 522 | (Some(workspace), true) => ( | |
| 523 | − | format!("{who} invited you to {workspace} on g1t"), | |
| 523 | + | format!("{who} invited you to join {workspace} on g1t"), | |
| 524 | 524 | format!("{who} invited you to join the {workspace} workspace on g1t."), | |
| 525 | 525 | ), | |
| 526 | 526 | (Some(workspace), false) => ( | |
| 527 | − | format!("{who} invited you to {workspace} on g1t"), | |
| 527 | + | format!("{who} invited you to join {workspace} on g1t"), | |
| 528 | 528 | format!( | |
| 529 | − | "{who} invited you to join the {workspace} workspace on g1t, where people and agents ship software together. Accepting makes your account, and then you can join {workspace}." | |
| 529 | + | "{who} invited you to join the {workspace} workspace on g1t, where people and agents ship software together. You do not have a g1t account yet, so this invitation also lets you make one; then you accept or decline joining {workspace}." | |
| 530 | 530 | ), | |
| 531 | 531 | ), | |
| 532 | 532 | (None, _) => ( | |
| ⋯ | |||
| 534 | 534 | Some(from) => format!("{from} invited you to g1t"), | |
| 535 | 535 | None => "Your invite to g1t".to_owned(), | |
| 536 | 536 | }, | |
| 537 | − | format!("{who} invited you to g1t, where people and agents ship software together. g1t is invite-only for now; this invite lets you make your account."), | |
| 537 | + | format!("{who} invited you to g1t, where people and agents ship software together. g1t is invite-only for now; this invite lets you make your account. It does not add you to anyone's workspace: your account starts with a workspace of its own."), | |
| 538 | 538 | ), | |
| 539 | 539 | } | |
| 540 | 540 | } | |
| ⋯ | |||
| 575 | 575 | let (subject, intro) = invite_wording(Some("ada"), None, false); | |
| 576 | 576 | assert_eq!(subject, "ada invited you to g1t"); | |
| 577 | 577 | assert!(intro.starts_with("ada invited you to g1t")); | |
| 578 | + | assert!(intro.contains("does not add you to anyone's workspace")); | |
| 578 | 579 | let (subject, _) = invite_wording(None, None, false); | |
| 579 | 580 | assert_eq!(subject, "Your invite to g1t"); | |
| 580 | 581 | let (subject, intro) = invite_wording(Some("ada"), Some("acme"), true); | |
| 581 | − | assert_eq!(subject, "ada invited you to acme on g1t"); | |
| 582 | + | assert_eq!(subject, "ada invited you to join acme on g1t"); | |
| 582 | 583 | assert_eq!(intro, "ada invited you to join the acme workspace on g1t."); | |
| 583 | 584 | let (_, intro) = invite_wording(Some("ada"), Some("acme"), false); | |
| 584 | − | assert!(intro.contains("makes your account, and then you can join acme")); | |
| 585 | + | assert!(intro.contains("also lets you make one; then you accept or decline joining acme")); | |
| 585 | 586 | } | |
| 586 | 587 | ||
| 587 | 588 | fn invite<'a>(note: Option<&'a str>, from: Option<&'a str>) -> InviteEmail<'a> { | |
| ⋯ | |||
| 600 | 601 | #[test] | |
| 601 | 602 | fn an_invite_links_to_its_page_and_carries_a_note() { | |
| 602 | 603 | let (subject, letter) = invite_letter(&invite(Some("Welcome aboard <3"), None), SITE); | |
| 603 | − | assert_eq!(subject, "The g1t team invited you to Flagon, Inc. on g1t"); | |
| 604 | + | assert_eq!(subject, "The g1t team invited you to join Flagon, Inc. on g1t"); | |
| 604 | 605 | assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/invite/g1t-abcd"); | |
| 605 | 606 | assert_eq!(letter.quotes, vec![("A note from the g1t team".to_owned(), "Welcome aboard <3".to_owned())]); | |
| 606 | 607 | let (text, html) = render(&letter, SITE); | |
| 18 | 18 | //! back. Staff grant more in sudo, to a person or to a workspace, whose | |
| 19 | 19 | //! owners share them. Owners of the workspaces in | |
| 20 | 20 | //! `INVITE_STAFF_WORKSPACES` (g1t's own) have no limit. Inviting an address | |
| 21 | − | //! into a workspace always makes an invite bound to it, and costs one only | |
| 22 | − | //! when the address has no account, so the answer never says which. | |
| 21 | + | //! into a workspace always makes an invite bound to it, and, while g1t is | |
| 22 | + | //! invite-only, costs one only when the address has no account (the | |
| 23 | + | //! invitation then lets it make one), so the answer never says which. | |
| 24 | + | //! Once registration is open it costs nothing. | |
| 23 | 25 | //! | |
| 24 | 26 | //! A code may instead be a shared invite link's, which staff hand to a | |
| 25 | 27 | //! group: it makes up to a set number of accounts, each its own, and is | |
| ⋯ | |||
| 1317 | 1319 | charged_workspace_id: workspace_id.as_deref(), | |
| 1318 | 1320 | limit, | |
| 1319 | 1321 | invitee_id: None, | |
| 1320 | − | role: joins.as_ref().map(|_| "member"), | |
| 1322 | + | role: joins.as_ref().map(|_| if a.join_role == Some(Role::Owner) { "owner" } else { "member" }), | |
| 1321 | 1323 | }; | |
| 1322 | 1324 | let Some(invite) = self.insert_invite(draft).await? else { | |
| 1323 | 1325 | return Ok(Self::out_of_invites()); | |
| ⋯ | |||
| 1733 | 1735 | role: Some(role_name), | |
| 1734 | 1736 | } | |
| 1735 | 1737 | } else { | |
| 1736 | − | let shared = self.workspace_allowance(&workspace_id).await?; | |
| 1737 | − | let (charged_to, charged_workspace_id, limit) = if shared.remaining.is_some_and(|left| left > 0) { | |
| 1738 | − | ("workspace", Some(workspace_id.as_str()), shared.limit) | |
| 1738 | + | // While g1t is invite-only, the invitation also lets the address | |
| 1739 | + | // make its account, so it costs an invite: the workspace's shared | |
| 1740 | + | // ones first, then the owner's own. Once anyone can sign up, an | |
| 1741 | + | // account needs no invite and it costs nothing. | |
| 1742 | + | let (charged_to, charged_workspace_id, limit) = if self.invites_required() { | |
| 1743 | + | let shared = self.workspace_allowance(&workspace_id).await?; | |
| 1744 | + | if shared.remaining.is_some_and(|left| left > 0) { | |
| 1745 | + | ("workspace", Some(workspace_id.as_str()), shared.limit) | |
| 1746 | + | } else { | |
| 1747 | + | let own = self.user_allowance(&a.actor.id).await?; | |
| 1748 | + | if own.exhausted() { | |
| 1749 | + | return Ok(Self::out_of_invites()); | |
| 1750 | + | } | |
| 1751 | + | ("user", None, own.limit) | |
| 1752 | + | } | |
| 1739 | 1753 | } else { | |
| 1740 | − | let own = self.user_allowance(&a.actor.id).await?; | |
| 1741 | − | if own.exhausted() { | |
| 1742 | − | return Ok(Self::out_of_invites()); | |
| 1743 | − | } | |
| 1744 | − | ("user", None, own.limit) | |
| 1754 | + | ("none", None, None) | |
| 1745 | 1755 | }; | |
| 1746 | 1756 | Draft { | |
| 1747 | 1757 | email: Some(&email), | |