| 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`). */ |
| 8 | export const INVITES_CONTACT = "hey@flagon.io"; |
| 9 | |
| 10 | /** The subject that sorts a request for invites, as the support page lists them. */ |
| 11 | export const INVITES_SUBJECT = "[g1t Invites] "; |
| 12 | |
| 13 | /** A mail link asking for more invites, for a person or a workspace. */ |
| 14 | export 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. */ |
| 20 | export 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. */ |
| 25 | export const HAVE_AN_INVITE = "/register#invite"; |
| 26 | |
| 27 | /** The address a shared invite link has: sign-up, with its code filled in. */ |
| 28 | export 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. */ |
| 33 | export 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. */ |
| 39 | export 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. */ |
| 47 | export 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 | */ |
| 56 | export 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 | |
| 64 | type 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. */ |
| 72 | export 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. */ |
| 92 | export 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. */ |
| 98 | export 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 | */ |
| 110 | export 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 | */ |
| 122 | export 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 | |
| 132 | type 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 | */ |
| 138 | export 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. */ |
| 145 | export const WELCOME_COOKIE = "g1t_welcome"; |
| 146 | |
| 147 | const 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`). */ |
| 150 | export 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. */ |
| 155 | export 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`. */ |
| 160 | export 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 | } |