Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | ||
| Sign-up buttons say Sign up everywhere; only the sign-up page says registration takes an invite | 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 | */ | |
| 24 | export function signUpCopy(): { primary: string; secondary: string | null } { | |
| 25 | return { primary: "Sign up", secondary: null }; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 26 | } |
| 27 | ||
| 28 | /** The /register address that opens on the invite-code field. */ | |
| 29 | export const HAVE_AN_INVITE = "/register#invite"; | |
| 30 | ||
| Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038) | 31 | /** The address a shared invite link has: sign-up, with its code filled in. */ |
| 32 | export 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. */ | |
| 37 | export 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. */ | |
| 43 | export 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 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 50 | /** The address an invite link has. */ |
| 51 | export 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 | */ | |
| 60 | export 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 | ||
| Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm | 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 | */ | |
| 73 | export 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. */ | |
| 79 | export function invitePath(code: string, proof?: string | null): string { | |
| 80 | const path = `/invite/${encodeURIComponent(code)}`; | |
| 81 | return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path; | |
| 82 | } | |
| 83 | ||
| 84 | type 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 | */ | |
| 98 | export 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"; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 108 | // A workspace is never joined without saying yes: the new account accepts its invitation. |
| Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm | 109 | const intro = invite.workspace |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 110 | ? `You can join ${invite.workspace.name} ${when}: accept the invitation then.` |
| Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm | 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 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 130 | type Listed = { |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 131 | status: "pending" | "awaiting_confirmation" | "awaiting_answer" | "redeemed" | "declined" | "expired" | "revoked"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 132 | redeemedBy: string | null; |
| 133 | email: string | null; | |
| 134 | workspace: string | null; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 135 | /** The account a workspace invitation is for, by username. */ |
| 136 | invitee?: string | null; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 137 | }; |
| 138 | ||
| 139 | /** How an invite's state reads in a list. */ | |
| 140 | export function inviteState(invite: Listed): { label: string; tone: "pending" | "done" | "dead" } { | |
| 141 | switch (invite.status) { | |
| 142 | case "pending": | |
| 143 | return { label: "Pending", tone: "pending" }; | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 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 | }; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 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 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 155 | case "redeemed": |
| 156 | return { label: invite.redeemedBy ? `Joined as @${invite.redeemedBy}` : "Used", tone: "done" }; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 157 | case "declined": |
| 158 | return { label: invite.invitee ? `@${invite.invitee} declined` : "Declined", tone: "dead" }; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 159 | case "expired": |
| 160 | return { label: "Expired", tone: "dead" }; | |
| 161 | case "revoked": | |
| 162 | return { label: "Revoked", tone: "dead" }; | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 166 | type 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 | */ | |
| 182 | export 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 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 218 | /** Who an invite is for, in a list. */ |
| 219 | export function inviteFor(invite: Listed): string { | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 220 | return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link"); |
| 221 | } | |
| 222 | ||
| 223 | /** | |
| 224 | * Which of the two invites a listed one is, in words: an invite to g1t | |
| 225 | * (an account, and no workspace), or an invitation to join a workspace. | |
| 226 | */ | |
| 227 | export function inviteKind(invite: { workspace: string | null }): { kind: "g1t" | "workspace"; label: string } { | |
| 228 | return invite.workspace | |
| 229 | ? { kind: "workspace", label: `Invite to join ${invite.workspace}` } | |
| 230 | : { kind: "g1t", label: "Invite to g1t" }; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 231 | } |
| 232 | ||
| 233 | type Membership = { slug: string; name?: string | null; role: "owner" | "member" }; | |
| 234 | ||
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 235 | /** One workspace an own invite can also invite its person to. */ |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 236 | export type BringInto = { slug: string; name: string }; |
| 237 | ||
| 238 | /** | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 239 | * The workspaces Settings → Invites can also invite the person to, when |
| 240 | * "Also invite them to a workspace" is ticked: the ones the viewer owns | |
| 241 | * that are not on the free plan, which adds no one. None is chosen for | |
| 242 | * them: the box is off at first, and the list starts on "Choose a | |
| 243 | * workspace". `note` says why the viewer's current workspace is missing. | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 244 | */ |
| 245 | export function bringIntoChoices( | |
| 246 | memberships: Membership[], | |
| 247 | free: string[], | |
| 248 | current: string | null | undefined, | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 249 | ): { options: BringInto[]; note: string | null } { |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 250 | const isFree = new Set(free.map((slug) => slug.toLowerCase())); |
| 251 | const options = memberships | |
| 252 | .filter((m) => m.role === "owner" && !isFree.has(m.slug.toLowerCase())) | |
| 253 | .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug })); | |
| 254 | const here = current?.trim().toLowerCase() || null; | |
| 255 | let note: string | null = null; | |
| 256 | const membership = here ? memberships.find((m) => m.slug.toLowerCase() === here) : undefined; | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 257 | if (membership && !options.some((option) => option.slug === here)) { |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 258 | note = |
| 259 | membership.role !== "owner" | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 260 | ? `Only the owners of ${membership.slug} can invite people to it.` |
| 261 | : `${membership.slug} is on the free plan, so it cannot add people. Start the plan to invite people to it.`; | |
| 262 | } | |
| 263 | return { options, note }; | |
| 264 | } | |
| 265 | ||
| 266 | /** | |
| 267 | * What the Settings → Invites form sends to identity. The workspace goes | |
| 268 | * with it only when "Also invite them to a workspace" (`also_join`) is | |
| 269 | * ticked: unticked, the invite is to g1t alone, whatever else the form held. | |
| 270 | */ | |
| 271 | export function inviteDraft(form: { get(name: string): unknown }): { | |
| 272 | email: string | null; | |
| 273 | workspace: string | null; | |
| 274 | join?: string; | |
| 275 | joinRole?: "owner" | "member"; | |
| 276 | } { | |
| 277 | const text = (name: string) => { | |
| 278 | const value = form.get(name); | |
| 279 | return typeof value === "string" ? value.trim() : ""; | |
| 280 | }; | |
| 281 | const charge = text("charge"); | |
| 282 | const draft: ReturnType<typeof inviteDraft> = { | |
| 283 | email: text("email") || null, | |
| 284 | workspace: charge && charge !== "mine" ? charge : null, | |
| 285 | }; | |
| 286 | const join = text("join"); | |
| 287 | if (text("also_join") === "on" && join) { | |
| 288 | draft.join = join; | |
| 289 | draft.joinRole = text("join_role") === "owner" ? "owner" : "member"; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 290 | } |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 291 | return draft; |
| 292 | } | |
| 293 | ||
| 294 | /** | |
| 295 | * What Settings → Invites shows. Invites to g1t exist only while sign-up | |
| 296 | * takes one: then the page has the form. Once anyone can sign up, it | |
| 297 | * keeps only the list of invites already made, and the settings menu | |
| 298 | * lists the page only when there are some. | |
| 299 | */ | |
| 300 | export function invitesPage(mode: "invite" | "open" | null | undefined, made: number): { form: boolean; listed: boolean } { | |
| 301 | const inviteOnly = mode !== "open"; | |
| 302 | return { form: inviteOnly, listed: inviteOnly || made > 0 }; | |
| 303 | } | |
| 304 | ||
| 305 | /** The words for Settings → Invites, the invite to g1t. */ | |
| 306 | export const G1T_INVITES = { | |
| 307 | /** The page's heading. */ | |
| 308 | heading: "Invite people to g1t", | |
| 309 | /** Its name in the settings menu and the account menu. */ | |
| 310 | nav: "Invites to g1t", | |
| 311 | about: | |
| 312 | "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.", | |
| 313 | /** In place of the form once anyone can sign up. */ | |
| 314 | 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.", | |
| 315 | /** The off-by-default box that also invites the person to a workspace. */ | |
| 316 | alsoJoin: "Also invite them to a workspace", | |
| 317 | alsoJoinHint: | |
| 318 | "Once their account is made, they get an invitation to the workspace to accept or decline. Left off, the invite is to g1t only.", | |
| 319 | /** Where the other kind of invite lives. */ | |
| 320 | elsewhere: "To bring someone into a workspace, invite them from that workspace's People page", | |
| 321 | } as const; | |
| 322 | ||
| 323 | /** | |
| 324 | * The words for a workspace's People page, the invitation to join it. | |
| 325 | * `inviteOnly` says whether sign-up takes an invite: then an invitation to | |
| 326 | * an address with no account also lets it make one (and costs an invite), | |
| 327 | * and the page points to Settings → Invites for an invite to g1t alone. | |
| 328 | */ | |
| 329 | export function workspaceInviteCopy(name: string, inviteOnly: boolean): { heading: string; hint: string; elsewhere: string | null } { | |
| 330 | const base = `Search people on g1t by username or name, or enter an email address. They get an invitation to join ${name}, in their inbox and by email, and join only if they accept.`; | |
| 331 | return { | |
| 332 | heading: `Invite to ${name}`, | |
| 333 | hint: inviteOnly | |
| 334 | ? `${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.` | |
| 335 | : `${base} If they do not have a g1t account yet, they sign up from the invitation first.`, | |
| 336 | elsewhere: inviteOnly ? `To invite someone to g1t without adding them to ${name}, use Settings → Invites.` : null, | |
| 337 | }; | |
| 338 | } | |
| 339 | ||
| 340 | /** | |
| 341 | * The People pages Settings → Invites points to for a workspace | |
| 342 | * invitation: the workspaces the viewer owns (only owners invite), the | |
| 343 | * current one first. | |
| 344 | */ | |
| 345 | export function peoplePages(memberships: Membership[], current: string | null | undefined): { slug: string; name: string; to: string }[] { | |
| 346 | const here = current?.trim().toLowerCase() || null; | |
| 347 | return memberships | |
| 348 | .filter((m) => m.role === "owner") | |
| 349 | .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug, to: `/${m.slug.toLowerCase()}/-/people` })) | |
| 350 | .sort((a, b) => Number(b.slug === here) - Number(a.slug === here)); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 351 | } |
| 352 | ||
| 353 | /** How many invites are left, in words. */ | |
| 354 | export function remainingLine(allowance: { limit: number | null; used: number; remaining: number | null }): string { | |
| 355 | if (allowance.limit == null) return "No limit on your invites"; | |
| 356 | const left = allowance.remaining ?? 0; | |
| 357 | if (left === 0) return `You have used all ${allowance.limit} of your invites`; | |
| 358 | return `${left} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`; | |
| 359 | } | |
| 360 | ||
| 361 | /** | |
| 362 | * Whether a sign-up or access form was filled in by a bot: the hidden | |
| 363 | * `website` field people never see, or a form sent back faster than a | |
| 364 | * person types. | |
| 365 | */ | |
| 366 | export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean { | |
| 367 | const trap = form.get("website"); | |
| 368 | if (typeof trap === "string" && trap.trim() !== "") return true; | |
| 369 | const started = Number(form.get("started")); | |
| 370 | return Number.isFinite(started) && started > 0 && now - started < 1500; | |
| 371 | } | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 372 | |
| 373 | /** | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 374 | * A username to offer someone signing up with `email`: its local part, |
| 375 | * lowercased, with anything a username cannot hold made single hyphens, | |
| 376 | * up to 39. They can type it in any case they like. Empty when nothing | |
| 377 | * usable is left. Identity checks it is free. | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 378 | */ |
| 379 | export function suggestUsername(email: string | null | undefined): string { | |
| 380 | const local = (email ?? "").split("@")[0]?.split("+")[0] ?? ""; | |
| 381 | return local | |
| 382 | .toLowerCase() | |
| 383 | .replace(/[^a-z0-9]+/g, "-") | |
| 384 | .replace(/^-+|-+$/g, "") | |
| 385 | .slice(0, 39) | |
| 386 | .replace(/-+$/g, ""); | |
| 387 | } | |
| 388 | ||
| 389 | type Lands = { workspace: { slug: string } | null; repository: { name: string } | null }; | |
| 390 | ||
| 391 | /** | |
| 392 | * Where using an invite lands: the workspace it joins, the repository it | |
| 393 | * gives access to, or nowhere in particular. | |
| 394 | */ | |
| 395 | export function landingFor(invite: Lands): string | null { | |
| 396 | if (invite.workspace) return invite.workspace.slug.toLowerCase(); | |
| 397 | if (invite.repository) return invite.repository.name.toLowerCase(); | |
| 398 | return null; | |
| 399 | } | |
| 400 | ||
| 401 | /** What someone who just joined is welcomed into, for one page view. */ | |
| 402 | export const WELCOME_COOKIE = "g1t_welcome"; | |
| 403 | ||
| 404 | const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/; | |
| 405 | ||
| 406 | /** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */ | |
| 407 | export function welcomeCookie(target: string, secure: boolean): string { | |
| 408 | return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`; | |
| 409 | } | |
| 410 | ||
| 411 | /** The `Set-Cookie` value that ends the welcome, once it has been shown. */ | |
| 412 | export function clearWelcome(secure: boolean): string { | |
| 413 | return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`; | |
| 414 | } | |
| 415 | ||
| 416 | /** Whether the request's cookies welcome someone into `target`. */ | |
| 417 | export function welcomes(cookieHeader: string | null, target: string): boolean { | |
| 418 | for (const part of (cookieHeader ?? "").split(";")) { | |
| 419 | const [key, ...rest] = part.trim().split("="); | |
| 420 | if (key !== WELCOME_COOKIE) continue; | |
| 421 | let value: string; | |
| 422 | try { | |
| 423 | value = decodeURIComponent(rest.join("=")); | |
| 424 | } catch { | |
| 425 | return false; | |
| 426 | } | |
| 427 | return TARGET.test(value) && value === target.toLowerCase(); | |
| 428 | } | |
| 429 | return false; | |
| 430 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.