Skip to content

Commit

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.

syntaqxcommitted Parent8b6c7eaBrowse files
21 files+670−2510/21 viewed
+1−1
12551255 "The invites made for a workspace, newest first, with each pending one's `code`. Owners only."
12561256 }
12571257 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."
12591259 }
12601260 Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.",
12611261 Op::ListInvitations => {
+49−13
3030 | --- | --- | --- |
3131 | 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. |
3232 | 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. |
3434 | 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. |
3535 | Access tokens | [`/settings/tokens`](https://g1t.sh/settings/tokens) | Your [personal access tokens](#access-tokens): fine-grained and classic. |
3636 | GitHub | [`/settings/github`](https://g1t.sh/settings/github) | [Linking and unlinking GitHub](/guides/github/#link-and-unlink-github). |
114114
115115 ## Invites
116116
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+
117132 While g1t is invite-only, every new account needs an invite code, such as
118133 `g1t-k7m2-q9xd-…`. People already on g1t make them, and g1t sends them to
119134 people who [asked for access](#asking-for-access). An invite:
221236
222237 ### Making invites
223238
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).
225244 2. Optionally enter the email address of the person you are inviting.
226245 With one, g1t emails them the invite, and only that address can use it.
227246 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.
231251 4. Select **Create invite**, then copy the link.
232252
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
234258 you own that are on the g1t plan. A workspace on the free plan cannot add
235259 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.
239266
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+
240273 Each person can have **5** invites out at a time. Pending and used invites
241274 count; an invite you revoke, or one that expires before anyone uses it,
242275 comes back to you. The list under the form shows each invite's state:
253286 username or by email address, with the role they join as; see
254287 [add people](/guides/workspaces/#add-people). Nobody is added without
255288 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.
260296
261297 ### Workspace invitations
262298
+25−8
330330
331331 Whoever creates a workspace is its owner. Nobody is added to a workspace
332332 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.
335334
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.
337353 As you type, people on g1t are offered by username and name, with their
338354 pictures; hover over one for their card. Only usernames, names and
339355 pictures are shown, never anyone's email address.
347363 are told in your inbox. It costs nothing.
348364 - **By email address**: g1t emails an invite that only that address can
349365 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.
355372
356373 The email names you and the workspace and links to the invite's page.
357374 Someone new signs up right there, with the invited address filled in; once
+0−0

Binary or large file; its contents are not shown.

+176−100
11 import { Mail } from "lucide-react";
2+import { useState } from "react";
23 import { Form, Link } from "react-router";
34
45 import type { Invite, InvitesOverview } from "@g1t/contracts";
56
67 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";
812
913 const SELECT =
1014 "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";
1721
1822 function InviteRow({ invite, origin }: { invite: Invite; origin: string }) {
1923 const state = inviteState(invite);
24+ const kind = inviteKind(invite);
2025 return (
2126 <li className="space-y-2 px-4 py-3">
2227 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
2328 <span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs ${TONE[state.tone]}`}>{state.label}</span>
2429 <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>
2539 {(invite.status === "pending" || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer") && (
2640 <Form method="post" className="ml-auto">
2741 <input type="hidden" name="intent" value="revoke-invite" />
4660 );
4761 }
4862
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+
49160 /**
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.
52167 */
53168 export function InvitesSection({
54169 overview,
55170 created,
56171 error,
57172 origin = "https://g1t.sh",
58− bringInto = { options: [], chosen: OWN_WORKSPACE, note: null },
173+ bringInto = { options: [], note: null, people: [] },
59174 }: {
60175 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;
63178 created?: Invite;
64179 error?: string;
65180 origin?: string;
72187 );
73188 }
74189 const { allowance, workspaces, invites } = overview;
190+ const shows = invitesPage(overview.mode, invites.length);
75191 const out = allowance.remaining === 0 && workspaces.every((workspace) => workspace.allowance.remaining === 0);
76192 return (
77193 <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+ ))}
98204 </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+ )}
99237 <div className="mt-2">
100− <CopyLine text={inviteLink(created.code, origin)} />
238+ <ErrorText>{error}</ErrorText>
101239 </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+ </>
120242 ) : (
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>
168244 )}
169− <div className="mt-2">
170− <ErrorText>{error}</ErrorText>
171− </div>
172245
173246 {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+ </>
179255 )}
180256 </section>
181257 );
+23−3
3939 import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings";
4040 import { GithubMark } from "./github";
4141 import { withNext } from "../lib/next";
42−import { useSignUpCopy } from "../lib/registration";
42+import { useInviteOnly, useSignUpCopy } from "../lib/registration";
4343 import { STATUS_URL, statusTitle } from "../lib/status";
4444 import { type ShortcutProject, movedPin, recentWith } from "../lib/pins";
4545 import type { AccountMenuData } from "../routes/settings-menu-json";
406406 const me = details.data ?? null;
407407 // Still on its way: shapes where the words will be, the same size.
408408 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;
409411 const profile = `/u/${user.username}`;
410412 return (
411413 <DropdownMenu open={open} onOpenChange={setOpen}>
466468 Your settings
467469 </Link>
468470 </DropdownMenuItem>
471+ {invitesListed && (
469472 <DropdownMenuItem asChild className={MENU_ROW}>
470473 <Link to="/settings/invites">
471474 <Ticket />
472− Invites
475+ {ACCOUNT_SETTINGS.invites.title}
473476 {loading && <Skeleton className="ml-auto h-4 w-12 rounded-full" />}
474477 {me?.invites_left != null && (
475478 <span className="ml-auto rounded-full bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
478481 )}
479482 </Link>
480483 </DropdownMenuItem>
484+ )}
481485 </DropdownMenuGroup>
482486 <DropdownMenuSeparator className="my-1.5" />
483487 <DropdownMenuGroup>
11811185 );
11821186 }
11831187
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+
11841203 /** Your own settings, drilled into from Your settings: one page each. */
11851204 function AccountSettingsMenu({ username }: { username: string }) {
1205+ const invites = useInvitesListed();
11861206 const link = (page: AccountSettingsPage, icon: ReactNode) => (
11871207 <SidebarLink to={`/settings/${page}`} icon={icon}>
11881208 {ACCOUNT_SETTINGS[page].title}
11941214 <div className="mt-2 space-y-px">
11951215 {link("profile", <CircleUserRound size={15} />)}
11961216 {link("emails", <Mail size={15} />)}
1197− {link("invites", <Ticket size={15} />)}
1217+ {invites && link("invites", <Ticket size={15} />)}
11981218 </div>
11991219 <Rule />
12001220 <div className="space-y-px">
+8−2
1717 | "account";
1818
1919 /** 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 }> = {
2121 profile: { title: "Profile", about: "Your picture, and what everyone sees on your profile." },
2222 emails: {
2323 title: "Emails",
2828 title: "Notifications",
2929 about: "What you are also emailed for, and how you watch repositories. Everything comes to your inbox either way.",
3030 },
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+ },
3238 keys: { title: "SSH keys", about: "Keys that let git on your computers reach g1t as you." },
3339 tokens: {
3440 title: "Access tokens",
+18−16
11 import type { Invite, InvitesOverview, User } from "@g1t/contracts";
22
3−import { type BringInto, bringIntoChoices } from "./invites";
3+import { type BringInto, bringIntoChoices, inviteDraft, peoplePages } from "./invites";
44 import { readCookie } from "./mission";
55 import { billing, identity } from "./services.server";
66 import { WORKSPACE_COOKIE, chosenWorkspace } from "./workspace-choice";
88 /** What the Invites section's forms answer. */
99 export type InviteActionData = { inviteCreated?: Invite; inviteError?: string; inviteRevoked?: boolean } | null;
1010
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+};
1321
1422 /** A person's invites, or null when they cannot make any yet (email unconfirmed). */
1523 export async function loadInvites(user: User): Promise<InvitesOverview | null> {
1826 }
1927
2028 /**
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.
2533 */
2634 export async function loadBringInto(user: User, request: Request): Promise<BringIntoChoices> {
2735 const memberships = user.workspaces ?? [];
2836 const owned = memberships.filter((m) => m.role === "owner").map((m) => m.slug);
2937 const free = owned.length > 0 ? await billing.freeWorkspaces(owned).catch(() => owned) : [];
3038 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) };
3240 }
3341
3442 /** Handles the Invites section's intents; null for any other intent. */
3543 export async function inviteAction(user: User, form: FormData): Promise<InviteActionData | undefined> {
3644 switch (form.get("intent")) {
3745 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));
4648 return result.ok ? { inviteCreated: result.value } : { inviteError: result.error.message };
4749 }
4850 case "revoke-invite": {
+112−19
22 import { readFileSync } from "node:fs";
33 import { test } from "node:test";
44
5+import { ACCOUNT_SETTINGS } from "./account-settings.ts";
56 import { CONTACT } from "./legal.ts";
67 import {
8+ G1T_INVITES,
79 HAVE_AN_INVITE,
8− OWN_WORKSPACE,
910 bringIntoChoices,
11+ inviteDraft,
12+ inviteKind,
13+ invitePageCopy,
14+ invitesPage,
15+ peoplePages,
16+ workspaceInviteCopy,
1017 INVITES_CONTACT,
1118 cleanCode,
1219 cleanProof,
110117 assert.deepEqual(inviteState({ ...base, status: "expired" }), { label: "Expired", tone: "dead" });
111118 assert.deepEqual(inviteState({ ...base, status: "revoked" }), { label: "Revoked", tone: "dead" });
112119 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" });
115125 assert.deepEqual(inviteState({ ...base, status: "awaiting_answer", redeemedBy: "daweazl" }), { label: "Waiting for @daweazl to accept", tone: "pending" });
116126 assert.deepEqual(inviteState({ ...base, status: "declined", invitee: "daweazl" }), { label: "@daweazl declined", tone: "dead" });
117127 });
187197 );
188198 });
189199
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", () => {
191201 const memberships = [
192202 { slug: "flagon-io", name: "Flagon, Inc.", role: "owner" as const },
193203 { slug: "side", name: "side", role: "owner" as const },
194204 { slug: "friends", name: "Friends", role: "member" as const },
195205 ];
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.
197207 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/);
205212 // In one you are only a member of.
206213 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");
209240 });
210241
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", () => {
212243 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");
217310 });
+161−17
163163 }
164164 }
165165
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+
166218 /** Who an invite is for, in a list. */
167219 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" };
170231 }
171232
172233 type Membership = { slug: string; name?: string | null; role: "owner" | "member" };
173234
174−/** One workspace an own invite can bring its person into. */
235+/** One workspace an own invite can also invite its person to. */
175236 export type BringInto = { slug: string; name: string };
176237
177−/** The value of "No workspace — they'll get their own" in the form. */
178−export const OWN_WORKSPACE = "";
179−
180238 /**
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.
186244 */
187245 export function bringIntoChoices(
188246 memberships: Membership[],
189247 free: string[],
190248 current: string | null | undefined,
191−): { options: BringInto[]; chosen: string; note: string | null } {
249+): { options: BringInto[]; note: string | null } {
192250 const isFree = new Set(free.map((slug) => slug.toLowerCase()));
193251 const options = memberships
194252 .filter((m) => m.role === "owner" && !isFree.has(m.slug.toLowerCase()))
195253 .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug }));
196254 const here = current?.trim().toLowerCase() || null;
197− const chosen = here && options.some((option) => option.slug === here) ? here : OWN_WORKSPACE;
198255 let note: string | null = null;
199256 const membership = here ? memberships.find((m) => m.slug.toLowerCase() === here) : undefined;
200− if (membership && !chosen) {
257+ if (membership && !options.some((option) => option.slug === here)) {
201258 note =
202259 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";
205290 }
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));
207351 }
208352
209353 /** How many invites are left, in words. */
+3−2
8989 export async function loader({ context, params, request }: Route.LoaderArgs) {
9090 const user = getViewer(context);
9191 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.
9394 const [shell, mode] = await Promise.all([
9495 // An account still confirming its address sees only the pages that
9596 // allows (lib/confirm-gate.ts), in the visitor's frame.
101102 return bareShell(user, params, chosen);
102103 })
103104 : visitorShell(params, context),
104− user ? Promise.resolve(null) : registrationMode(),
105+ registrationMode(),
105106 ]);
106107 // Where this g1t lives, for clone lines, agent setup and link previews.
107108 return { user, shell, inviteOnly: mode !== "open", addresses: addresses() };
+18−38
1111 import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui";
1212 import { githubSignInEnabled } from "../lib/github.server";
1313 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";
1515 import { clientKey } from "../lib/registration.server";
1616 import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server";
1717 import { rememberWorkspace } from "../lib/workspace-choice";
158158 );
159159 }
160160
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+ */
166167 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+ );
198178 }
199179
200180 /** What accepting is called on its button. */
466446 <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance text-fg-soft">
467447 <Headline invite={invite} />
468448 </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>
470450 <dl className="mt-5 space-y-1 text-sm">
471451 {invite.invitedBy && (
472452 <div className="flex gap-2">
+11−1
88 import { accounts, identity } from "../lib/services.server";
99 import { getViewer } from "../lib/session.server";
1010 import { loadInvites } from "../lib/invites.server";
11+import { invitesPage } from "../lib/invites";
1112
1213 export type AccountMenuData = {
1314 name: string | null;
1415 email: string | null;
1516 /** Invites left, or null when there is no limit (or none can be made yet). */
1617 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;
1724 };
1825
1926 export async function loader({ context }: Route.LoaderArgs) {
2835 const data: AccountMenuData = {
2936 name: profile?.name?.trim() || null,
3037 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,
3242 };
3343 // Not kept by the browser: the menu asks again after a name, address or
3444 // invite changes, and a kept copy would show the old one for a minute.
+1−1
55 import { inviteAction, loadBringInto, loadInvites } from "../../lib/invites.server";
66
77 export function meta(args: Route.MetaArgs) {
8− return page(args, { title: "Invites · Settings · g1t" });
8+ return page(args, { title: "Invites to g1t · Settings · g1t" });
99 }
1010
1111 export async function loader({ request, context }: Route.LoaderArgs) {
+1−1
2525 <main className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
2626 {about && (
2727 <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>
2929 <p className="mt-1 max-w-2xl text-sm text-muted">{about.about}</p>
3030 </header>
3131 )}
+28−9
4040 import { forgetWorkspace } from "../../lib/workspace-choice";
4141 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
4242 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";
4445 import { billing, identity } from "../../lib/services.server";
4546 import { StartPlanToInvite } from "../../components/start-plan";
4647 import {
6566 // someone a repository is shared with, gets nothing here.
6667 if (!role) throw data(null, { status: 404 });
6768 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([
6970 identity.listMembers(params.owner, viewer),
7071 owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null,
7172 identity.getWorkspace(params.owner),
7576 // A free workspace adds no one until it starts the plan; identity
7677 // refuses it either way, so a failure here only hides the note.
7778 billing.freeWorkspaces([params.owner]).catch(() => [] as string[]),
79+ // Whether sign-up takes an invite: the invite form says what that means.
80+ registrationMode(),
7881 ]);
7982 return {
8083 role,
84+ name: workspace?.name?.trim() || params.owner,
85+ inviteOnly: mode !== "open",
8186 free: free.includes(params.owner.toLowerCase()),
8287 members: unwrap(members),
8388 invites: invites?.ok ? invites.value : [],
308313 }
309314
310315 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;
312317 const owner = role === "owner";
318+ // The invitation to join this workspace, not an invite to g1t (Settings → Invites).
319+ const inviteCopy = workspaceInviteCopy(name, inviteOnly);
313320 const owners = members.filter((member) => member.role === "owner").length;
314321 const rowError = (username: string) =>
315322 actionData && "row" in actionData && actionData.row === username ? (actionData.error ?? null) : null;
372379 )}
373380 {owner && !free && (
374381 // 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>
375387 <Form
376388 method="post"
377389 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"
379391 >
380392 <input type="hidden" name="action" value="add" />
381393 <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">
386395 <PeoplePicker name="member" placeholder="username, name or name@example.com" />
387396 </Field>
388397 </div>
403412 </SubmitButton>
404413 </div>
405414 </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>
406425 )}
407426 {actionData && "invited" in actionData && actionData.invited && (
408427 <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.
410429 </p>
411430 )}
412431 {actionData && "transferred" in actionData && (
+4−0
12831283 /// member, and no workspace of its own is made for it.
12841284 #[serde(default)]
12851285 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>,
12861290 /// Where the request came in, for the audit log; g1t.sh when absent.
12871291 #[serde(default)]
12881292 pub surface: Option<crate::audit::Surface>,
+1−1
152152 registration: () => call("registration", {}),
153153 listInvites: (user) => call("list_invites", { user }),
154154 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 }),
156156 revokeInvite: (user, id) => call("revoke_invite", { user, id }),
157157 checkInvite: (code, client, options = {}) =>
158158 call("check_invite", {
+1−1
932932 */
933933 createInvite(
934934 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 },
936936 ): Promise<Result<Invite>>;
937937 /** Its maker, or an owner of its workspace, revokes a pending invite; the invite comes back. */
938938 revokeInvite(user: User, id: string): Promise<Result<Invite>>;
+8−7
520520 let who = from.unwrap_or("The g1t team");
521521 match (workspace, joins_existing_account) {
522522 (Some(workspace), true) => (
523− format!("{who} invited you to {workspace} on g1t"),
523+ format!("{who} invited you to join {workspace} on g1t"),
524524 format!("{who} invited you to join the {workspace} workspace on g1t."),
525525 ),
526526 (Some(workspace), false) => (
527− format!("{who} invited you to {workspace} on g1t"),
527+ format!("{who} invited you to join {workspace} on g1t"),
528528 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}."
530530 ),
531531 ),
532532 (None, _) => (
534534 Some(from) => format!("{from} invited you to g1t"),
535535 None => "Your invite to g1t".to_owned(),
536536 },
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."),
538538 ),
539539 }
540540 }
575575 let (subject, intro) = invite_wording(Some("ada"), None, false);
576576 assert_eq!(subject, "ada invited you to g1t");
577577 assert!(intro.starts_with("ada invited you to g1t"));
578+ assert!(intro.contains("does not add you to anyone's workspace"));
578579 let (subject, _) = invite_wording(None, None, false);
579580 assert_eq!(subject, "Your invite to g1t");
580581 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");
582583 assert_eq!(intro, "ada invited you to join the acme workspace on g1t.");
583584 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"));
585586 }
586587
587588 fn invite<'a>(note: Option<&'a str>, from: Option<&'a str>) -> InviteEmail<'a> {
600601 #[test]
601602 fn an_invite_links_to_its_page_and_carries_a_note() {
602603 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");
604605 assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/invite/g1t-abcd");
605606 assert_eq!(letter.quotes, vec![("A note from the g1t team".to_owned(), "Welcome aboard <3".to_owned())]);
606607 let (text, html) = render(&letter, SITE);
+21−11
1818 //! back. Staff grant more in sudo, to a person or to a workspace, whose
1919 //! owners share them. Owners of the workspaces in
2020 //! `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.
2325 //!
2426 //! A code may instead be a shared invite link's, which staff hand to a
2527 //! group: it makes up to a set number of accounts, each its own, and is
13171319 charged_workspace_id: workspace_id.as_deref(),
13181320 limit,
13191321 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" }),
13211323 };
13221324 let Some(invite) = self.insert_invite(draft).await? else {
13231325 return Ok(Self::out_of_invites());
17331735 role: Some(role_name),
17341736 }
17351737 } 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+ }
17391753 } 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)
17451755 };
17461756 Draft {
17471757 email: Some(&email),