Skip to content
430 linesCodeBlameRaw
1/**
2 * Invites, as the site shows them: what sign-up says while g1t is
3 * invite-only, links to an invite, and how each invite reads in a list.
4 * No Workers or React imports, so it can be tested under Node.
5 */
6
7/** Where people ask for more invites: support's mailbox (`CONTACT.support`). */
8export const INVITES_CONTACT = "hey@flagon.io";
9
10/** The subject that sorts a request for invites, as the support page lists them. */
11export const INVITES_SUBJECT = "[g1t Invites] ";
12
13/** A mail link asking for more invites, for a person or a workspace. */
14export function moreInvitesMailto(about?: string): string {
15 const subject = `${INVITES_SUBJECT}${about ? `More invites for ${about}` : "More invites"}`;
16 return `mailto:${INVITES_CONTACT}?subject=${encodeURIComponent(subject)}`;
17}
18
19/**
20 * What the sign-up buttons say: Sign up, whether or not registration is
21 * invite-only. Only the sign-up page itself says how to get in (an invite,
22 * or a request for one), so nothing else reads as a waiting room.
23 */
24export function signUpCopy(): { primary: string; secondary: string | null } {
25 return { primary: "Sign up", secondary: null };
26}
27
28/** The /register address that opens on the invite-code field. */
29export const HAVE_AN_INVITE = "/register#invite";
30
31/** The address a shared invite link has: sign-up, with its code filled in. */
32export function sharedInviteLink(code: string, origin = "https://g1t.sh"): string {
33 return `${origin.replace(/\/+$/, "")}/register?invite=${encodeURIComponent(code)}`;
34}
35
36/** What sign-up says above the form for a shared invite link's group. Null for a one-person invite. */
37export function sharedInviteLine(label: string | null | undefined): string | null {
38 const group = (label ?? "").trim();
39 return group ? `Invited as part of ${group}` : null;
40}
41
42/** The email field's hint for a shared invite link limited to some domains. */
43export function sharedDomainsHint(domains: string[] | null | undefined): string | undefined {
44 const list = (domains ?? []).filter(Boolean);
45 if (list.length === 0) return undefined;
46 const named = list.length === 1 ? list[0] : `${list.slice(0, -1).join(", ")} or ${list.at(-1)}`;
47 return `This invite is for addresses at ${named}. Use yours there.`;
48}
49
50/** The address an invite link has. */
51export function inviteLink(code: string, origin = "https://g1t.sh"): string {
52 return `${origin.replace(/\/+$/, "")}/invite/${code}`;
53}
54
55/**
56 * An invite code from however someone pasted it: the code, a whole
57 * invite link, or a /register?invite= address. Identity reads it again;
58 * this only tidies what is put back into a form.
59 */
60export function cleanCode(raw: string | null | undefined): string {
61 let text = (raw ?? "").trim();
62 const param = /[?&]invite=([^&#\s]+)/i.exec(text);
63 if (param) text = decodeURIComponent(param[1]!);
64 else if (/^https?:\/\//i.test(text)) text = text.replace(/[?#].*$/, "").split("/").filter(Boolean).pop() ?? "";
65 return text.replace(/\s+/g, "").slice(0, 80);
66}
67
68/**
69 * The `proof` an invite email's link carries, tidied: hex, or null for
70 * anything else. Identity decides whether it is the invite's own; this only
71 * keeps junk out of what is passed on and put back into a form.
72 */
73export function cleanProof(raw: string | null | undefined): string | null {
74 const text = (raw ?? "").trim().toLowerCase();
75 return /^[0-9a-f]{16,128}$/.test(text) ? text : null;
76}
77
78/** An invite's page, keeping the email's proof when there is one. */
79export function invitePath(code: string, proof?: string | null): string {
80 const path = `/invite/${encodeURIComponent(code)}`;
81 return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path;
82}
83
84type Proven = {
85 /** The bound address in full, or null for an invite to anyone with the code. */
86 address: string | null;
87 emailProven: boolean;
88 workspace: { name: string } | null;
89 repository: { name: string } | null;
90};
91
92/**
93 * What signing up on an invite's page says about the email address. Opened
94 * from the invite's own email (`emailProven`), the address is confirmed
95 * already, so there is no code to enter; otherwise the address is confirmed
96 * after sign-up, as it always is.
97 */
98export function inviteSignUpCopy(invite: Proven): {
99 /** Under "Create your account". */
100 intro: string;
101 /** Under the email field. */
102 hint: string;
103 /** Said plainly above the form when the address is confirmed already; null otherwise. */
104 confirmed: string | null;
105} {
106 const proven = invite.emailProven && invite.address !== null;
107 const when = proven ? "as soon as you create it" : "as soon as you confirm your email";
108 // A workspace is never joined without saying yes: the new account accepts its invitation.
109 const intro = invite.workspace
110 ? `You can join ${invite.workspace.name} ${when}: accept the invitation then.`
111 : invite.repository
112 ? `You get ${invite.repository.name} ${when}.`
113 : "It takes a minute.";
114 if (proven) {
115 return {
116 intro,
117 hint: "Your invite was sent here, and you opened it from that email, so this address is confirmed already.",
118 confirmed: `${invite.address} is confirmed: you came here from the invite we emailed to it, so there is no code to enter after you sign up.`,
119 };
120 }
121 return {
122 intro,
123 hint: invite.address
124 ? "Your invite was sent here. We email it a code to confirm it before you start."
125 : "We email it a code to confirm it before you start.",
126 confirmed: null,
127 };
128}
129
130type Listed = {
131 status: "pending" | "awaiting_confirmation" | "awaiting_answer" | "redeemed" | "declined" | "expired" | "revoked";
132 redeemedBy: string | null;
133 email: string | null;
134 workspace: string | null;
135 /** The account a workspace invitation is for, by username. */
136 invitee?: string | null;
137};
138
139/** How an invite's state reads in a list. */
140export function inviteState(invite: Listed): { label: string; tone: "pending" | "done" | "dead" } {
141 switch (invite.status) {
142 case "pending":
143 return { label: "Pending", tone: "pending" };
144 case "awaiting_confirmation":
145 // The account is made; it joins once it confirms its address.
146 return {
147 label: invite.redeemedBy ? `@${invite.redeemedBy} is confirming their email` : "Confirming their email",
148 tone: "pending",
149 };
150 case "awaiting_answer": {
151 // The account is made and confirmed; the workspace waits for its yes or no.
152 const who = invite.redeemedBy ?? invite.invitee;
153 return { label: who ? `Waiting for @${who} to accept` : "Waiting for an answer", tone: "pending" };
154 }
155 case "redeemed":
156 return { label: invite.redeemedBy ? `Joined as @${invite.redeemedBy}` : "Used", tone: "done" };
157 case "declined":
158 return { label: invite.invitee ? `@${invite.invitee} declined` : "Declined", tone: "dead" };
159 case "expired":
160 return { label: "Expired", tone: "dead" };
161 case "revoked":
162 return { label: "Revoked", tone: "dead" };
163 }
164}
165
166type 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 */
182export 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
218/** Who an invite is for, in a list. */
219export function inviteFor(invite: Listed): string {
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 */
227export 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" };
231}
232
233type Membership = { slug: string; name?: string | null; role: "owner" | "member" };
234
235/** One workspace an own invite can also invite its person to. */
236export type BringInto = { slug: string; name: string };
237
238/**
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.
244 */
245export function bringIntoChoices(
246 memberships: Membership[],
247 free: string[],
248 current: string | null | undefined,
249): { options: BringInto[]; note: string | null } {
250 const isFree = new Set(free.map((slug) => slug.toLowerCase()));
251 const options = memberships
252 .filter((m) => m.role === "owner" && !isFree.has(m.slug.toLowerCase()))
253 .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug }));
254 const here = current?.trim().toLowerCase() || null;
255 let note: string | null = null;
256 const membership = here ? memberships.find((m) => m.slug.toLowerCase() === here) : undefined;
257 if (membership && !options.some((option) => option.slug === here)) {
258 note =
259 membership.role !== "owner"
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 */
271export 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";
290 }
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 */
300export 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. */
306export 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 */
329export 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 notifications 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 */
345export 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));
351}
352
353/** How many invites are left, in words. */
354export function remainingLine(allowance: { limit: number | null; used: number; remaining: number | null }): string {
355 if (allowance.limit == null) return "No limit on your invites";
356 const left = allowance.remaining ?? 0;
357 if (left === 0) return `You have used all ${allowance.limit} of your invites`;
358 return `${left} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`;
359}
360
361/**
362 * Whether a sign-up or access form was filled in by a bot: the hidden
363 * `website` field people never see, or a form sent back faster than a
364 * person types.
365 */
366export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean {
367 const trap = form.get("website");
368 if (typeof trap === "string" && trap.trim() !== "") return true;
369 const started = Number(form.get("started"));
370 return Number.isFinite(started) && started > 0 && now - started < 1500;
371}
372
373/**
374 * A username to offer someone signing up with `email`: its local part,
375 * lowercased, with anything a username cannot hold made single hyphens,
376 * up to 39. They can type it in any case they like. Empty when nothing
377 * usable is left. Identity checks it is free.
378 */
379export function suggestUsername(email: string | null | undefined): string {
380 const local = (email ?? "").split("@")[0]?.split("+")[0] ?? "";
381 return local
382 .toLowerCase()
383 .replace(/[^a-z0-9]+/g, "-")
384 .replace(/^-+|-+$/g, "")
385 .slice(0, 39)
386 .replace(/-+$/g, "");
387}
388
389type Lands = { workspace: { slug: string } | null; repository: { name: string } | null };
390
391/**
392 * Where using an invite lands: the workspace it joins, the repository it
393 * gives access to, or nowhere in particular.
394 */
395export function landingFor(invite: Lands): string | null {
396 if (invite.workspace) return invite.workspace.slug.toLowerCase();
397 if (invite.repository) return invite.repository.name.toLowerCase();
398 return null;
399}
400
401/** What someone who just joined is welcomed into, for one page view. */
402export const WELCOME_COOKIE = "g1t_welcome";
403
404const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/;
405
406/** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */
407export function welcomeCookie(target: string, secure: boolean): string {
408 return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
409}
410
411/** The `Set-Cookie` value that ends the welcome, once it has been shown. */
412export function clearWelcome(secure: boolean): string {
413 return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
414}
415
416/** Whether the request's cookies welcome someone into `target`. */
417export function welcomes(cookieHeader: string | null, target: string): boolean {
418 for (const part of (cookieHeader ?? "").split(";")) {
419 const [key, ...rest] = part.trim().split("=");
420 if (key !== WELCOME_COOKIE) continue;
421 let value: string;
422 try {
423 value = decodeURIComponent(rest.join("="));
424 } catch {
425 return false;
426 }
427 return TARGET.test(value) && value === target.toLowerCase();
428 }
429 return false;
430}