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.
| The plan says what g1t is for without measuring it against other products | 1 | /** |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 2 | * Invites, as the site shows them: links to an invite, and how each |
| 3 | * invitation reads in a list. | |
| The plan says what g1t is for without measuring it against other products | 4 | * No Workers or React imports, so it can be tested under Node. |
| 5 | */ | |
| 6 | ||
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 7 | /** What the sign-up buttons say: Sign up. Anyone may register. */ |
| Sign-up buttons say Sign up everywhere; only the sign-up page says registration takes an invite | 8 | export function signUpCopy(): { primary: string; secondary: string | null } { |
| 9 | return { primary: "Sign up", secondary: null }; | |
| Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038) | 10 | } |
| 11 | ||
| The plan says what g1t is for without measuring it against other products | 12 | /** The address an invite link has. */ |
| 13 | export function inviteLink(code: string, origin = "https://g1t.sh"): string { | |
| 14 | return `${origin.replace(/\/+$/, "")}/invite/${code}`; | |
| 15 | } | |
| 16 | ||
| 17 | /** | |
| 18 | * An invite code from however someone pasted it: the code, a whole | |
| 19 | * invite link, or a /register?invite= address. Identity reads it again; | |
| 20 | * this only tidies what is put back into a form. | |
| 21 | */ | |
| 22 | export function cleanCode(raw: string | null | undefined): string { | |
| 23 | let text = (raw ?? "").trim(); | |
| 24 | const param = /[?&]invite=([^&#\s]+)/i.exec(text); | |
| 25 | if (param) text = decodeURIComponent(param[1]!); | |
| 26 | else if (/^https?:\/\//i.test(text)) text = text.replace(/[?#].*$/, "").split("/").filter(Boolean).pop() ?? ""; | |
| 27 | return text.replace(/\s+/g, "").slice(0, 80); | |
| 28 | } | |
| 29 | ||
| 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 | 30 | /** |
| 31 | * The `proof` an invite email's link carries, tidied: hex, or null for | |
| 32 | * anything else. Identity decides whether it is the invite's own; this only | |
| 33 | * keeps junk out of what is passed on and put back into a form. | |
| 34 | */ | |
| 35 | export function cleanProof(raw: string | null | undefined): string | null { | |
| 36 | const text = (raw ?? "").trim().toLowerCase(); | |
| 37 | return /^[0-9a-f]{16,128}$/.test(text) ? text : null; | |
| 38 | } | |
| 39 | ||
| 40 | /** An invite's page, keeping the email's proof when there is one. */ | |
| 41 | export function invitePath(code: string, proof?: string | null): string { | |
| 42 | const path = `/invite/${encodeURIComponent(code)}`; | |
| 43 | return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path; | |
| 44 | } | |
| 45 | ||
| 46 | type Proven = { | |
| 47 | /** The bound address in full, or null for an invite to anyone with the code. */ | |
| 48 | address: string | null; | |
| 49 | emailProven: boolean; | |
| 50 | workspace: { name: string } | null; | |
| 51 | repository: { name: string } | null; | |
| 52 | }; | |
| 53 | ||
| 54 | /** | |
| 55 | * What signing up on an invite's page says about the email address. Opened | |
| 56 | * from the invite's own email (`emailProven`), the address is confirmed | |
| 57 | * already, so there is no code to enter; otherwise the address is confirmed | |
| 58 | * after sign-up, as it always is. | |
| 59 | */ | |
| 60 | export function inviteSignUpCopy(invite: Proven): { | |
| 61 | /** Under "Create your account". */ | |
| 62 | intro: string; | |
| 63 | /** Under the email field. */ | |
| 64 | hint: string; | |
| 65 | /** Said plainly above the form when the address is confirmed already; null otherwise. */ | |
| 66 | confirmed: string | null; | |
| 67 | } { | |
| 68 | const proven = invite.emailProven && invite.address !== null; | |
| 69 | 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) | 70 | // 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 | 71 | 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) | 72 | ? `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 | 73 | : invite.repository |
| 74 | ? `You get ${invite.repository.name} ${when}.` | |
| 75 | : "It takes a minute."; | |
| 76 | if (proven) { | |
| 77 | return { | |
| 78 | intro, | |
| 79 | hint: "Your invite was sent here, and you opened it from that email, so this address is confirmed already.", | |
| 80 | 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.`, | |
| 81 | }; | |
| 82 | } | |
| 83 | return { | |
| 84 | intro, | |
| 85 | hint: invite.address | |
| 86 | ? "Your invite was sent here. We email it a code to confirm it before you start." | |
| 87 | : "We email it a code to confirm it before you start.", | |
| 88 | confirmed: null, | |
| 89 | }; | |
| 90 | } | |
| 91 | ||
| The plan says what g1t is for without measuring it against other products | 92 | 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) | 93 | status: "pending" | "awaiting_confirmation" | "awaiting_answer" | "redeemed" | "declined" | "expired" | "revoked"; |
| The plan says what g1t is for without measuring it against other products | 94 | redeemedBy: string | null; |
| 95 | email: string | null; | |
| 96 | 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) | 97 | /** The account a workspace invitation is for, by username. */ |
| 98 | invitee?: string | null; | |
| The plan says what g1t is for without measuring it against other products | 99 | }; |
| 100 | ||
| 101 | /** How an invite's state reads in a list. */ | |
| 102 | export function inviteState(invite: Listed): { label: string; tone: "pending" | "done" | "dead" } { | |
| 103 | switch (invite.status) { | |
| 104 | case "pending": | |
| 105 | return { label: "Pending", tone: "pending" }; | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 106 | case "awaiting_confirmation": |
| 107 | // The account is made; it joins once it confirms its address. | |
| 108 | return { | |
| 109 | label: invite.redeemedBy ? `@${invite.redeemedBy} is confirming their email` : "Confirming their email", | |
| 110 | tone: "pending", | |
| 111 | }; | |
| 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) | 112 | case "awaiting_answer": { |
| 113 | // The account is made and confirmed; the workspace waits for its yes or no. | |
| 114 | const who = invite.redeemedBy ?? invite.invitee; | |
| 115 | return { label: who ? `Waiting for @${who} to accept` : "Waiting for an answer", tone: "pending" }; | |
| 116 | } | |
| The plan says what g1t is for without measuring it against other products | 117 | case "redeemed": |
| 118 | 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) | 119 | case "declined": |
| 120 | return { label: invite.invitee ? `@${invite.invitee} declined` : "Declined", tone: "dead" }; | |
| The plan says what g1t is for without measuring it against other products | 121 | case "expired": |
| 122 | return { label: "Expired", tone: "dead" }; | |
| 123 | case "revoked": | |
| 124 | return { label: "Revoked", tone: "dead" }; | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 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) | 128 | type Previewed = { |
| 129 | kind: "account" | "workspace"; | |
| 130 | invitedBy: { username: string } | null; | |
| 131 | workspace: { name: string } | null; | |
| 132 | repository: { name: string; role: string } | null; | |
| 133 | hasAccount: boolean; | |
| 134 | }; | |
| 135 | ||
| 136 | /** | |
| 137 | * What an invite's page (/invite/:code) says it is, so nobody mistakes one | |
| 138 | * kind for the other: the headline, in three parts with the place between | |
| 139 | * (shown in bold), and the line under it. "@syntaqx invited you to g1t" is | |
| 140 | * an account and no workspace; "@syntaqx invited you to join Flagon, Inc. | |
| 141 | * on g1t" is a workspace invitation, which also makes the account of | |
| 142 | * someone who has none. | |
| 143 | */ | |
| 144 | export function invitePageCopy( | |
| 145 | invite: Previewed, | |
| 146 | signedIn: boolean, | |
| 147 | ): { before: string; place: string | null; after: string; about: string } { | |
| 148 | const from = invite.invitedBy ? `@${invite.invitedBy.username}` : "The g1t team"; | |
| 149 | const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account"; | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 150 | const g1t = "g1t is where a team and the coding agents it already uses ship code"; |
| 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) | 151 | if (invite.workspace) { |
| 152 | const name = invite.workspace.name; | |
| 153 | return { | |
| 154 | before: `${from} invited you to join `, | |
| 155 | place: name, | |
| 156 | after: " on g1t", | |
| 157 | about: `This is an invitation to join ${name}, which you accept or decline. ${ | |
| 158 | signingUp | |
| 159 | ? `You do not have a g1t account yet, so it also lets you make one: make it below, then join ${name}.` | |
| 160 | : `Accepting joins you to ${name}.` | |
| 161 | }`, | |
| 162 | }; | |
| 163 | } | |
| 164 | if (invite.repository) { | |
| 165 | return { | |
| 166 | before: `${from} invited you to collaborate on `, | |
| 167 | place: invite.repository.name, | |
| 168 | after: "", | |
| 169 | about: `${g1t}. ${signingUp ? "Make your account below and you get" : "Accepting gives you"} the ${invite.repository.role} role on ${invite.repository.name}.`, | |
| 170 | }; | |
| 171 | } | |
| 172 | return { | |
| 173 | before: `${from} invited you to g1t`, | |
| 174 | place: null, | |
| 175 | after: "", | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 176 | about: `${g1t}: repositories, pull requests and checks that hold, a merge queue and deployments, with chat for the team. 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.`, |
| 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) | 177 | }; |
| 178 | } | |
| 179 | ||
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 180 | /** What inviting someone came to (lib/member-invitations.server.ts): who was invited, or why not. */ |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 181 | export type InviteResult = { invited: string } | { error: string }; |
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 182 | |
| 183 | /** What revoking or resending an invitation came to. */ | |
| 184 | export type InvitationResult = { done: "revoked" | "resent"; id: string } | { error: string; id: string }; | |
| 185 | ||
| 186 | /** | |
| 187 | * The Invitations page's filters: every invitation, or the ones in one | |
| 188 | * state. Pending gathers the three states an invitation waits in (to be | |
| 189 | * used, for the new account to confirm its address, for the person's | |
| 190 | * answer); Accepted is the ones that joined. g1t does not record an email | |
| 191 | * that could not be delivered, so an invitation nobody used shows as | |
| 192 | * Expired once its 30 days are up; there is no Failed filter until it does. | |
| 193 | */ | |
| 194 | export type InvitationFilter = "all" | "pending" | "accepted" | "declined" | "expired" | "revoked"; | |
| 195 | ||
| 196 | export const INVITATION_FILTERS: readonly { key: InvitationFilter; label: string }[] = [ | |
| 197 | { key: "all", label: "All" }, | |
| 198 | { key: "pending", label: "Pending" }, | |
| 199 | { key: "accepted", label: "Accepted" }, | |
| 200 | { key: "declined", label: "Declined" }, | |
| 201 | { key: "expired", label: "Expired" }, | |
| 202 | { key: "revoked", label: "Revoked" }, | |
| 203 | ]; | |
| 204 | ||
| 205 | /** The filter an invitation's state falls under. */ | |
| 206 | export function invitationFilterOf(status: Listed["status"]): Exclude<InvitationFilter, "all"> { | |
| 207 | switch (status) { | |
| 208 | case "pending": | |
| 209 | case "awaiting_confirmation": | |
| 210 | case "awaiting_answer": | |
| 211 | return "pending"; | |
| 212 | case "redeemed": | |
| 213 | return "accepted"; | |
| 214 | case "declined": | |
| 215 | return "declined"; | |
| 216 | case "expired": | |
| 217 | return "expired"; | |
| 218 | case "revoked": | |
| 219 | return "revoked"; | |
| 220 | } | |
| 221 | } | |
| 222 | ||
| 223 | /** The filter `?state=` names; All for anything else. */ | |
| 224 | export function invitationFilter(raw: string | null | undefined): InvitationFilter { | |
| 225 | return INVITATION_FILTERS.some((filter) => filter.key === raw) ? (raw as InvitationFilter) : "all"; | |
| 226 | } | |
| 227 | ||
| 228 | /** The invitations under `filter`. */ | |
| 229 | export function filterInvitations<T extends Pick<Listed, "status">>(invites: readonly T[], filter: InvitationFilter): T[] { | |
| 230 | if (filter === "all") return [...invites]; | |
| 231 | return invites.filter((invite) => invitationFilterOf(invite.status) === filter); | |
| 232 | } | |
| 233 | ||
| 234 | /** How many invitations each filter holds, for the counts on the chips. */ | |
| 235 | export function invitationCounts(invites: readonly Pick<Listed, "status">[]): Record<InvitationFilter, number> { | |
| 236 | const counts: Record<InvitationFilter, number> = { all: invites.length, pending: 0, accepted: 0, declined: 0, expired: 0, revoked: 0 }; | |
| 237 | for (const invite of invites) counts[invitationFilterOf(invite.status)] += 1; | |
| 238 | return counts; | |
| 239 | } | |
| 240 | ||
| 241 | /** The tone of an invitation's status badge, from how its state reads. */ | |
| 242 | export function invitationTone(status: Listed["status"]): "neutral" | "success" | "warn" | "danger" { | |
| 243 | switch (invitationFilterOf(status)) { | |
| 244 | case "pending": | |
| 245 | return "warn"; | |
| 246 | case "accepted": | |
| 247 | return "success"; | |
| 248 | case "declined": | |
| 249 | case "revoked": | |
| 250 | return "danger"; | |
| 251 | default: | |
| 252 | return "neutral"; | |
| 253 | } | |
| 254 | } | |
| 255 | ||
| The plan says what g1t is for without measuring it against other products | 256 | /** Who an invite is for, in a list. */ |
| 257 | 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) | 258 | return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link"); |
| 259 | } | |
| 260 | ||
| 261 | /** | |
| 262 | * Which of the two invites a listed one is, in words: an invite to g1t | |
| 263 | * (an account, and no workspace), or an invitation to join a workspace. | |
| 264 | */ | |
| 265 | export function inviteKind(invite: { workspace: string | null }): { kind: "g1t" | "workspace"; label: string } { | |
| 266 | return invite.workspace | |
| 267 | ? { kind: "workspace", label: `Invite to join ${invite.workspace}` } | |
| 268 | : { 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) | 269 | } |
| 270 | ||
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 271 | /** The words for a workspace's People page, the invitation to join it. */ |
| 272 | export function workspaceInviteCopy(name: string): { heading: string; hint: 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) | 273 | return { |
| 274 | heading: `Invite to ${name}`, | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 275 | hint: `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. If they do not have a g1t account yet, they sign up from the invitation first.`, |
| 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) | 276 | }; |
| 277 | } | |
| 278 | ||
| 279 | /** | |
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 280 | * The Members page's filters, from its query: the role to show (`role=`: |
| 281 | * owners or members), whether two-factor authentication is on (`2fa=`: on | |
| 282 | * or off; owners only see it), a team by slug (`team=`) and words to | |
| 283 | * search by (`q=`, matched against the username and the name). Anything | |
| 284 | * else is every member. | |
| 285 | */ | |
| 286 | export type MemberFilters = { role: "all" | "owner" | "member"; twoFactor: "all" | "on" | "off"; team: string | null; query: string }; | |
| 287 | ||
| 288 | export function memberFilters(search: URLSearchParams | string): MemberFilters { | |
| 289 | const params = typeof search === "string" ? new URLSearchParams(search) : search; | |
| 290 | const role = params.get("role"); | |
| 291 | const twoFactor = params.get("2fa"); | |
| 292 | return { | |
| 293 | role: role === "owner" || role === "member" ? role : "all", | |
| 294 | twoFactor: twoFactor === "on" || twoFactor === "off" ? twoFactor : "all", | |
| 295 | team: params.get("team")?.trim().toLowerCase() || null, | |
| 296 | query: params.get("q")?.trim() ?? "", | |
| 297 | }; | |
| 298 | } | |
| 299 | ||
| 300 | type Listable = { | |
| 301 | username: string; | |
| 302 | name?: string | null; | |
| 303 | role: "owner" | "member"; | |
| 304 | two_factor?: boolean | null; | |
| 305 | }; | |
| 306 | ||
| 307 | /** The members `filters` keep, in the order given. `teams` is each member's teams, by slug. */ | |
| 308 | export function filterMembers<T extends Listable>(members: readonly T[], filters: MemberFilters, teams: Record<string, { slug: string }[]> = {}): T[] { | |
| 309 | const words = filters.query.toLowerCase().replace(/^@+/, ""); | |
| 310 | return members.filter((member) => { | |
| 311 | if (filters.role !== "all" && member.role !== filters.role) return false; | |
| 312 | if (filters.twoFactor !== "all" && member.two_factor !== (filters.twoFactor === "on")) return false; | |
| 313 | if (filters.team && !(teams[member.username] ?? []).some((team) => team.slug.toLowerCase() === filters.team)) return false; | |
| 314 | if (words && !member.username.toLowerCase().includes(words) && !(member.name ?? "").toLowerCase().includes(words)) return false; | |
| 315 | return true; | |
| 316 | }); | |
| The plan says what g1t is for without measuring it against other products | 317 | } |
| 318 | ||
| 319 | /** | |
| 320 | * Whether a sign-up or access form was filled in by a bot: the hidden | |
| 321 | * `website` field people never see, or a form sent back faster than a | |
| 322 | * person types. | |
| 323 | */ | |
| 324 | export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean { | |
| 325 | const trap = form.get("website"); | |
| 326 | if (typeof trap === "string" && trap.trim() !== "") return true; | |
| 327 | const started = Number(form.get("started")); | |
| 328 | return Number.isFinite(started) && started > 0 && now - started < 1500; | |
| 329 | } | |
| 330 | ||
| 331 | /** | |
| 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 | 332 | * A username to offer someone signing up with `email`: its local part, |
| 333 | * lowercased, with anything a username cannot hold made single hyphens, | |
| 334 | * up to 39. They can type it in any case they like. Empty when nothing | |
| 335 | * usable is left. Identity checks it is free. | |
| The plan says what g1t is for without measuring it against other products | 336 | */ |
| 337 | export function suggestUsername(email: string | null | undefined): string { | |
| 338 | const local = (email ?? "").split("@")[0]?.split("+")[0] ?? ""; | |
| 339 | return local | |
| 340 | .toLowerCase() | |
| 341 | .replace(/[^a-z0-9]+/g, "-") | |
| 342 | .replace(/^-+|-+$/g, "") | |
| 343 | .slice(0, 39) | |
| 344 | .replace(/-+$/g, ""); | |
| 345 | } | |
| 346 | ||
| 347 | type Lands = { workspace: { slug: string } | null; repository: { name: string } | null }; | |
| 348 | ||
| 349 | /** | |
| 350 | * Where using an invite lands: the workspace it joins, the repository it | |
| 351 | * gives access to, or nowhere in particular. | |
| 352 | */ | |
| 353 | export function landingFor(invite: Lands): string | null { | |
| 354 | if (invite.workspace) return invite.workspace.slug.toLowerCase(); | |
| 355 | if (invite.repository) return invite.repository.name.toLowerCase(); | |
| 356 | return null; | |
| 357 | } | |
| 358 | ||
| 359 | /** What someone who just joined is welcomed into, for one page view. */ | |
| 360 | export const WELCOME_COOKIE = "g1t_welcome"; | |
| 361 | ||
| 362 | const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/; | |
| 363 | ||
| 364 | /** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */ | |
| 365 | export function welcomeCookie(target: string, secure: boolean): string { | |
| 366 | return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`; | |
| 367 | } | |
| 368 | ||
| 369 | /** The `Set-Cookie` value that ends the welcome, once it has been shown. */ | |
| 370 | export function clearWelcome(secure: boolean): string { | |
| 371 | return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`; | |
| 372 | } | |
| 373 | ||
| 374 | /** Whether the request's cookies welcome someone into `target`. */ | |
| 375 | export function welcomes(cookieHeader: string | null, target: string): boolean { | |
| 376 | for (const part of (cookieHeader ?? "").split(";")) { | |
| 377 | const [key, ...rest] = part.trim().split("="); | |
| 378 | if (key !== WELCOME_COOKIE) continue; | |
| 379 | let value: string; | |
| 380 | try { | |
| 381 | value = decodeURIComponent(rest.join("=")); | |
| 382 | } catch { | |
| 383 | return false; | |
| 384 | } | |
| 385 | return TARGET.test(value) && value === target.toLowerCase(); | |
| 386 | } | |
| 387 | return false; | |
| 388 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.