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