Skip to content
177 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
68type Listed = {
69 status: "pending" | "awaiting_confirmation" | "redeemed" | "expired" | "revoked";
70 redeemedBy: string | null;
71 email: string | null;
72 workspace: string | null;
73};
74
75/** How an invite's state reads in a list. */
76export function inviteState(invite: Listed): { label: string; tone: "pending" | "done" | "dead" } {
77 switch (invite.status) {
78 case "pending":
79 return { label: "Pending", tone: "pending" };
80 case "awaiting_confirmation":
81 // The account is made; it joins once it confirms its address.
82 return {
83 label: invite.redeemedBy ? `@${invite.redeemedBy} is confirming their email` : "Confirming their email",
84 tone: "pending",
85 };
86 case "redeemed":
87 return { label: invite.redeemedBy ? `Joined as @${invite.redeemedBy}` : "Used", tone: "done" };
88 case "expired":
89 return { label: "Expired", tone: "dead" };
90 case "revoked":
91 return { label: "Revoked", tone: "dead" };
92 }
93}
94
95/** Who an invite is for, in a list. */
96export function inviteFor(invite: Listed): string {
97 const who = invite.email ?? "Anyone with the link";
98 return invite.workspace ? `${who} · joins ${invite.workspace}` : who;
99}
100
101/** How many invites are left, in words. */
102export function remainingLine(allowance: { limit: number | null; used: number; remaining: number | null }): string {
103 if (allowance.limit == null) return "No limit on your invites";
104 const left = allowance.remaining ?? 0;
105 if (left === 0) return `You have used all ${allowance.limit} of your invites`;
106 return `${left} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`;
107}
108
109/**
110 * Whether a sign-up or access form was filled in by a bot: the hidden
111 * `website` field people never see, or a form sent back faster than a
112 * person types.
113 */
114export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean {
115 const trap = form.get("website");
116 if (typeof trap === "string" && trap.trim() !== "") return true;
117 const started = Number(form.get("started"));
118 return Number.isFinite(started) && started > 0 && now - started < 1500;
119}
120
121/**
122 * A username to offer someone signing up with `email`: its local part, as
123 * usernames are written (lowercase letters, digits and single hyphens, up
124 * to 39). Empty when nothing usable is left. Identity checks it is free.
125 */
126export function suggestUsername(email: string | null | undefined): string {
127 const local = (email ?? "").split("@")[0]?.split("+")[0] ?? "";
128 return local
129 .toLowerCase()
130 .replace(/[^a-z0-9]+/g, "-")
131 .replace(/^-+|-+$/g, "")
132 .slice(0, 39)
133 .replace(/-+$/g, "");
134}
135
136type Lands = { workspace: { slug: string } | null; repository: { name: string } | null };
137
138/**
139 * Where using an invite lands: the workspace it joins, the repository it
140 * gives access to, or nowhere in particular.
141 */
142export function landingFor(invite: Lands): string | null {
143 if (invite.workspace) return invite.workspace.slug.toLowerCase();
144 if (invite.repository) return invite.repository.name.toLowerCase();
145 return null;
146}
147
148/** What someone who just joined is welcomed into, for one page view. */
149export const WELCOME_COOKIE = "g1t_welcome";
150
151const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/;
152
153/** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */
154export function welcomeCookie(target: string, secure: boolean): string {
155 return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
156}
157
158/** The `Set-Cookie` value that ends the welcome, once it has been shown. */
159export function clearWelcome(secure: boolean): string {
160 return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
161}
162
163/** Whether the request's cookies welcome someone into `target`. */
164export function welcomes(cookieHeader: string | null, target: string): boolean {
165 for (const part of (cookieHeader ?? "").split(";")) {
166 const [key, ...rest] = part.trim().split("=");
167 if (key !== WELCOME_COOKIE) continue;
168 let value: string;
169 try {
170 value = decodeURIComponent(rest.join("="));
171 } catch {
172 return false;
173 }
174 return TARGET.test(value) && value === target.toLowerCase();
175 }
176 return false;
177}