Skip to content
173 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/** What the sign-up buttons say. While invite-only, nobody can just sign up. */
20export function signUpCopy(inviteOnly: boolean): { primary: string; secondary: string | null } {
21 return inviteOnly ? { primary: "Request access", secondary: "Have an invite?" } : { primary: "Sign up", secondary: null };
22}
23
24/** The /register address that opens on the invite-code field. */
25export const HAVE_AN_INVITE = "/register#invite";
26
27/** The address a shared invite link has: sign-up, with its code filled in. */
28export function sharedInviteLink(code: string, origin = "https://g1t.sh"): string {
29 return `${origin.replace(/\/+$/, "")}/register?invite=${encodeURIComponent(code)}`;
30}
31
32/** What sign-up says above the form for a shared invite link's group. Null for a one-person invite. */
33export function sharedInviteLine(label: string | null | undefined): string | null {
34 const group = (label ?? "").trim();
35 return group ? `Invited as part of ${group}` : null;
36}
37
38/** The email field's hint for a shared invite link limited to some domains. */
39export function sharedDomainsHint(domains: string[] | null | undefined): string | undefined {
40 const list = (domains ?? []).filter(Boolean);
41 if (list.length === 0) return undefined;
42 const named = list.length === 1 ? list[0] : `${list.slice(0, -1).join(", ")} or ${list.at(-1)}`;
43 return `This invite is for addresses at ${named}. Use yours there.`;
44}
45
46/** The address an invite link has. */
47export function inviteLink(code: string, origin = "https://g1t.sh"): string {
48 return `${origin.replace(/\/+$/, "")}/invite/${code}`;
49}
50
51/**
52 * An invite code from however someone pasted it: the code, a whole
53 * invite link, or a /register?invite= address. Identity reads it again;
54 * this only tidies what is put back into a form.
55 */
56export function cleanCode(raw: string | null | undefined): string {
57 let text = (raw ?? "").trim();
58 const param = /[?&]invite=([^&#\s]+)/i.exec(text);
59 if (param) text = decodeURIComponent(param[1]!);
60 else if (/^https?:\/\//i.test(text)) text = text.replace(/[?#].*$/, "").split("/").filter(Boolean).pop() ?? "";
61 return text.replace(/\s+/g, "").slice(0, 80);
62}
63
64type Listed = {
65 status: "pending" | "awaiting_confirmation" | "redeemed" | "expired" | "revoked";
66 redeemedBy: string | null;
67 email: string | null;
68 workspace: string | null;
69};
70
71/** How an invite's state reads in a list. */
72export function inviteState(invite: Listed): { label: string; tone: "pending" | "done" | "dead" } {
73 switch (invite.status) {
74 case "pending":
75 return { label: "Pending", tone: "pending" };
76 case "awaiting_confirmation":
77 // The account is made; it joins once it confirms its address.
78 return {
79 label: invite.redeemedBy ? `@${invite.redeemedBy} is confirming their email` : "Confirming their email",
80 tone: "pending",
81 };
82 case "redeemed":
83 return { label: invite.redeemedBy ? `Joined as @${invite.redeemedBy}` : "Used", tone: "done" };
84 case "expired":
85 return { label: "Expired", tone: "dead" };
86 case "revoked":
87 return { label: "Revoked", tone: "dead" };
88 }
89}
90
91/** Who an invite is for, in a list. */
92export function inviteFor(invite: Listed): string {
93 const who = invite.email ?? "Anyone with the link";
94 return invite.workspace ? `${who} · joins ${invite.workspace}` : who;
95}
96
97/** How many invites are left, in words. */
98export function remainingLine(allowance: { limit: number | null; used: number; remaining: number | null }): string {
99 if (allowance.limit == null) return "No limit on your invites";
100 const left = allowance.remaining ?? 0;
101 if (left === 0) return `You have used all ${allowance.limit} of your invites`;
102 return `${left} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`;
103}
104
105/**
106 * Whether a sign-up or access form was filled in by a bot: the hidden
107 * `website` field people never see, or a form sent back faster than a
108 * person types.
109 */
110export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean {
111 const trap = form.get("website");
112 if (typeof trap === "string" && trap.trim() !== "") return true;
113 const started = Number(form.get("started"));
114 return Number.isFinite(started) && started > 0 && now - started < 1500;
115}
116
117/**
118 * A username to offer someone signing up with `email`: its local part, as
119 * usernames are written (lowercase letters, digits and single hyphens, up
120 * to 39). Empty when nothing usable is left. Identity checks it is free.
121 */
122export function suggestUsername(email: string | null | undefined): string {
123 const local = (email ?? "").split("@")[0]?.split("+")[0] ?? "";
124 return local
125 .toLowerCase()
126 .replace(/[^a-z0-9]+/g, "-")
127 .replace(/^-+|-+$/g, "")
128 .slice(0, 39)
129 .replace(/-+$/g, "");
130}
131
132type Lands = { workspace: { slug: string } | null; repository: { name: string } | null };
133
134/**
135 * Where using an invite lands: the workspace it joins, the repository it
136 * gives access to, or nowhere in particular.
137 */
138export function landingFor(invite: Lands): string | null {
139 if (invite.workspace) return invite.workspace.slug.toLowerCase();
140 if (invite.repository) return invite.repository.name.toLowerCase();
141 return null;
142}
143
144/** What someone who just joined is welcomed into, for one page view. */
145export const WELCOME_COOKIE = "g1t_welcome";
146
147const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/;
148
149/** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */
150export function welcomeCookie(target: string, secure: boolean): string {
151 return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
152}
153
154/** The `Set-Cookie` value that ends the welcome, once it has been shown. */
155export function clearWelcome(secure: boolean): string {
156 return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
157}
158
159/** Whether the request's cookies welcome someone into `target`. */
160export function welcomes(cookieHeader: string | null, target: string): boolean {
161 for (const part of (cookieHeader ?? "").split(";")) {
162 const [key, ...rest] = part.trim().split("=");
163 if (key !== WELCOME_COOKIE) continue;
164 let value: string;
165 try {
166 value = decodeURIComponent(rest.join("="));
167 } catch {
168 return false;
169 }
170 return TARGET.test(value) && value === target.toLowerCase();
171 }
172 return false;
173}