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 } { | |
| g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them. | 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 notifications and by email, and join only if they accept.`; |
| 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) | 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") | |
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 349 | .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug, to: `/${m.slug.toLowerCase()}/-/members` })) |
| 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) | 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.