Skip to content
546 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/** What inviting someone came to (lib/member-invitations.server.ts): who was invited, or why not. */
219export type InviteResult = { invited: string; outOfInvites: false } | { error: string; outOfInvites: boolean };
220
221/** What revoking or resending an invitation came to. */
222export type InvitationResult = { done: "revoked" | "resent"; id: string } | { error: string; id: string };
223
224/**
225 * The Invitations page's filters: every invitation, or the ones in one
226 * state. Pending gathers the three states an invitation waits in (to be
227 * used, for the new account to confirm its address, for the person's
228 * answer); Accepted is the ones that joined. g1t does not record an email
229 * that could not be delivered, so an invitation nobody used shows as
230 * Expired once its 30 days are up; there is no Failed filter until it does.
231 */
232export type InvitationFilter = "all" | "pending" | "accepted" | "declined" | "expired" | "revoked";
233
234export const INVITATION_FILTERS: readonly { key: InvitationFilter; label: string }[] = [
235 { key: "all", label: "All" },
236 { key: "pending", label: "Pending" },
237 { key: "accepted", label: "Accepted" },
238 { key: "declined", label: "Declined" },
239 { key: "expired", label: "Expired" },
240 { key: "revoked", label: "Revoked" },
241];
242
243/** The filter an invitation's state falls under. */
244export function invitationFilterOf(status: Listed["status"]): Exclude<InvitationFilter, "all"> {
245 switch (status) {
246 case "pending":
247 case "awaiting_confirmation":
248 case "awaiting_answer":
249 return "pending";
250 case "redeemed":
251 return "accepted";
252 case "declined":
253 return "declined";
254 case "expired":
255 return "expired";
256 case "revoked":
257 return "revoked";
258 }
259}
260
261/** The filter `?state=` names; All for anything else. */
262export function invitationFilter(raw: string | null | undefined): InvitationFilter {
263 return INVITATION_FILTERS.some((filter) => filter.key === raw) ? (raw as InvitationFilter) : "all";
264}
265
266/** The invitations under `filter`. */
267export function filterInvitations<T extends Pick<Listed, "status">>(invites: readonly T[], filter: InvitationFilter): T[] {
268 if (filter === "all") return [...invites];
269 return invites.filter((invite) => invitationFilterOf(invite.status) === filter);
270}
271
272/** How many invitations each filter holds, for the counts on the chips. */
273export function invitationCounts(invites: readonly Pick<Listed, "status">[]): Record<InvitationFilter, number> {
274 const counts: Record<InvitationFilter, number> = { all: invites.length, pending: 0, accepted: 0, declined: 0, expired: 0, revoked: 0 };
275 for (const invite of invites) counts[invitationFilterOf(invite.status)] += 1;
276 return counts;
277}
278
279/** The tone of an invitation's status badge, from how its state reads. */
280export function invitationTone(status: Listed["status"]): "neutral" | "success" | "warn" | "danger" {
281 switch (invitationFilterOf(status)) {
282 case "pending":
283 return "warn";
284 case "accepted":
285 return "success";
286 case "declined":
287 case "revoked":
288 return "danger";
289 default:
290 return "neutral";
291 }
292}
293
294/** Who an invite is for, in a list. */
295export function inviteFor(invite: Listed): string {
296 return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link");
297}
298
299/**
300 * Which of the two invites a listed one is, in words: an invite to g1t
301 * (an account, and no workspace), or an invitation to join a workspace.
302 */
303export function inviteKind(invite: { workspace: string | null }): { kind: "g1t" | "workspace"; label: string } {
304 return invite.workspace
305 ? { kind: "workspace", label: `Invite to join ${invite.workspace}` }
306 : { kind: "g1t", label: "Invite to g1t" };
307}
308
309type Membership = { slug: string; name?: string | null; role: "owner" | "member" };
310
311/** One workspace an own invite can also invite its person to. */
312export type BringInto = { slug: string; name: string };
313
314/**
315 * The workspaces Settings → Invites can also invite the person to, when
316 * "Also invite them to a workspace" is ticked: the ones the viewer owns
317 * that are not on the free plan, which adds no one. None is chosen for
318 * them: the box is off at first, and the list starts on "Choose a
319 * workspace". `note` says why the viewer's current workspace is missing.
320 */
321export function bringIntoChoices(
322 memberships: Membership[],
323 free: string[],
324 current: string | null | undefined,
325): { options: BringInto[]; note: string | null } {
326 const isFree = new Set(free.map((slug) => slug.toLowerCase()));
327 const options = memberships
328 .filter((m) => m.role === "owner" && !isFree.has(m.slug.toLowerCase()))
329 .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug }));
330 const here = current?.trim().toLowerCase() || null;
331 let note: string | null = null;
332 const membership = here ? memberships.find((m) => m.slug.toLowerCase() === here) : undefined;
333 if (membership && !options.some((option) => option.slug === here)) {
334 note =
335 membership.role !== "owner"
336 ? `Only the owners of ${membership.slug} can invite people to it.`
337 : `${membership.slug} is on the free plan, so it cannot add people. Start the plan to invite people to it.`;
338 }
339 return { options, note };
340}
341
342/**
343 * What the Settings → Invites form sends to identity. The workspace goes
344 * with it only when "Also invite them to a workspace" (`also_join`) is
345 * ticked: unticked, the invite is to g1t alone, whatever else the form held.
346 */
347export function inviteDraft(form: { get(name: string): unknown }): {
348 email: string | null;
349 workspace: string | null;
350 join?: string;
351 joinRole?: "owner" | "member";
352} {
353 const text = (name: string) => {
354 const value = form.get(name);
355 return typeof value === "string" ? value.trim() : "";
356 };
357 const charge = text("charge");
358 const draft: ReturnType<typeof inviteDraft> = {
359 email: text("email") || null,
360 workspace: charge && charge !== "mine" ? charge : null,
361 };
362 const join = text("join");
363 if (text("also_join") === "on" && join) {
364 draft.join = join;
365 draft.joinRole = text("join_role") === "owner" ? "owner" : "member";
366 }
367 return draft;
368}
369
370/**
371 * What Settings → Invites shows. Invites to g1t exist only while sign-up
372 * takes one: then the page has the form. Once anyone can sign up, it
373 * keeps only the list of invites already made, and the settings menu
374 * lists the page only when there are some.
375 */
376export function invitesPage(mode: "invite" | "open" | null | undefined, made: number): { form: boolean; listed: boolean } {
377 const inviteOnly = mode !== "open";
378 return { form: inviteOnly, listed: inviteOnly || made > 0 };
379}
380
381/** The words for Settings → Invites, the invite to g1t. */
382export const G1T_INVITES = {
383 /** The page's heading. */
384 heading: "Invite people to g1t",
385 /** Its name in the settings menu and the account menu. */
386 nav: "Invites to g1t",
387 about:
388 "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.",
389 /** In place of the form once anyone can sign up. */
390 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.",
391 /** The off-by-default box that also invites the person to a workspace. */
392 alsoJoin: "Also invite them to a workspace",
393 alsoJoinHint:
394 "Once their account is made, they get an invitation to the workspace to accept or decline. Left off, the invite is to g1t only.",
395 /** Where the other kind of invite lives. */
396 elsewhere: "To bring someone into a workspace, invite them from that workspace's People page",
397} as const;
398
399/**
400 * The words for a workspace's People page, the invitation to join it.
401 * `inviteOnly` says whether sign-up takes an invite: then an invitation to
402 * an address with no account also lets it make one (and costs an invite),
403 * and the page points to Settings → Invites for an invite to g1t alone.
404 */
405export function workspaceInviteCopy(name: string, inviteOnly: boolean): { heading: string; hint: string; elsewhere: string | null } {
406 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.`;
407 return {
408 heading: `Invite to ${name}`,
409 hint: inviteOnly
410 ? `${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.`
411 : `${base} If they do not have a g1t account yet, they sign up from the invitation first.`,
412 elsewhere: inviteOnly ? `To invite someone to g1t without adding them to ${name}, use Settings → Invites.` : null,
413 };
414}
415
416/**
417 * The Members page's filters, from its query: the role to show (`role=`:
418 * owners or members), whether two-factor authentication is on (`2fa=`: on
419 * or off; owners only see it), a team by slug (`team=`) and words to
420 * search by (`q=`, matched against the username and the name). Anything
421 * else is every member.
422 */
423export type MemberFilters = { role: "all" | "owner" | "member"; twoFactor: "all" | "on" | "off"; team: string | null; query: string };
424
425export function memberFilters(search: URLSearchParams | string): MemberFilters {
426 const params = typeof search === "string" ? new URLSearchParams(search) : search;
427 const role = params.get("role");
428 const twoFactor = params.get("2fa");
429 return {
430 role: role === "owner" || role === "member" ? role : "all",
431 twoFactor: twoFactor === "on" || twoFactor === "off" ? twoFactor : "all",
432 team: params.get("team")?.trim().toLowerCase() || null,
433 query: params.get("q")?.trim() ?? "",
434 };
435}
436
437type Listable = {
438 username: string;
439 name?: string | null;
440 role: "owner" | "member";
441 two_factor?: boolean | null;
442};
443
444/** The members `filters` keep, in the order given. `teams` is each member's teams, by slug. */
445export function filterMembers<T extends Listable>(members: readonly T[], filters: MemberFilters, teams: Record<string, { slug: string }[]> = {}): T[] {
446 const words = filters.query.toLowerCase().replace(/^@+/, "");
447 return members.filter((member) => {
448 if (filters.role !== "all" && member.role !== filters.role) return false;
449 if (filters.twoFactor !== "all" && member.two_factor !== (filters.twoFactor === "on")) return false;
450 if (filters.team && !(teams[member.username] ?? []).some((team) => team.slug.toLowerCase() === filters.team)) return false;
451 if (words && !member.username.toLowerCase().includes(words) && !(member.name ?? "").toLowerCase().includes(words)) return false;
452 return true;
453 });
454}
455
456/**
457 * The People pages Settings → Invites points to for a workspace
458 * invitation: the workspaces the viewer owns (only owners invite), the
459 * current one first.
460 */
461export function peoplePages(memberships: Membership[], current: string | null | undefined): { slug: string; name: string; to: string }[] {
462 const here = current?.trim().toLowerCase() || null;
463 return memberships
464 .filter((m) => m.role === "owner")
465 .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug, to: `/${m.slug.toLowerCase()}/-/members` }))
466 .sort((a, b) => Number(b.slug === here) - Number(a.slug === here));
467}
468
469/** How many invites are left, in words. */
470export function remainingLine(allowance: { limit: number | null; used: number; remaining: number | null }): string {
471 if (allowance.limit == null) return "No limit on your invites";
472 const left = allowance.remaining ?? 0;
473 if (left === 0) return `You have used all ${allowance.limit} of your invites`;
474 return `${left} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`;
475}
476
477/**
478 * Whether a sign-up or access form was filled in by a bot: the hidden
479 * `website` field people never see, or a form sent back faster than a
480 * person types.
481 */
482export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean {
483 const trap = form.get("website");
484 if (typeof trap === "string" && trap.trim() !== "") return true;
485 const started = Number(form.get("started"));
486 return Number.isFinite(started) && started > 0 && now - started < 1500;
487}
488
489/**
490 * A username to offer someone signing up with `email`: its local part,
491 * lowercased, with anything a username cannot hold made single hyphens,
492 * up to 39. They can type it in any case they like. Empty when nothing
493 * usable is left. Identity checks it is free.
494 */
495export function suggestUsername(email: string | null | undefined): string {
496 const local = (email ?? "").split("@")[0]?.split("+")[0] ?? "";
497 return local
498 .toLowerCase()
499 .replace(/[^a-z0-9]+/g, "-")
500 .replace(/^-+|-+$/g, "")
501 .slice(0, 39)
502 .replace(/-+$/g, "");
503}
504
505type Lands = { workspace: { slug: string } | null; repository: { name: string } | null };
506
507/**
508 * Where using an invite lands: the workspace it joins, the repository it
509 * gives access to, or nowhere in particular.
510 */
511export function landingFor(invite: Lands): string | null {
512 if (invite.workspace) return invite.workspace.slug.toLowerCase();
513 if (invite.repository) return invite.repository.name.toLowerCase();
514 return null;
515}
516
517/** What someone who just joined is welcomed into, for one page view. */
518export const WELCOME_COOKIE = "g1t_welcome";
519
520const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/;
521
522/** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */
523export function welcomeCookie(target: string, secure: boolean): string {
524 return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
525}
526
527/** The `Set-Cookie` value that ends the welcome, once it has been shown. */
528export function clearWelcome(secure: boolean): string {
529 return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
530}
531
532/** Whether the request's cookies welcome someone into `target`. */
533export function welcomes(cookieHeader: string | null, target: string): boolean {
534 for (const part of (cookieHeader ?? "").split(";")) {
535 const [key, ...rest] = part.trim().split("=");
536 if (key !== WELCOME_COOKIE) continue;
537 let value: string;
538 try {
539 value = decodeURIComponent(rest.join("="));
540 } catch {
541 return false;
542 }
543 return TARGET.test(value) && value === target.toLowerCase();
544 }
545 return false;
546}