Skip to content
546 linesCodeBlameRaw

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 look1/**
2 * Invites, as the site shows them: what sign-up says while g1t is
3 * invite-only, links to an invite, and how each invite reads in a list.
4 * No Workers or React imports, so it can be tested under Node.
5 */
6
7/** Where people ask for more invites: support's mailbox (`CONTACT.support`). */
8export const INVITES_CONTACT = "hey@flagon.io";
9
10/** The subject that sorts a request for invites, as the support page lists them. */
11export const INVITES_SUBJECT = "[g1t Invites] ";
12
13/** A mail link asking for more invites, for a person or a workspace. */
14export function moreInvitesMailto(about?: string): string {
15 const subject = `${INVITES_SUBJECT}${about ? `More invites for ${about}` : "More invites"}`;
16 return `mailto:${INVITES_CONTACT}?subject=${encodeURIComponent(subject)}`;
17}
18
Sign-up buttons say Sign up everywhere; only the sign-up page says registration takes an invite19/**
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 */
24export 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 look26}
27
28/** The /register address that opens on the invite-code field. */
29export 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. */
32export 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. */
37export 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. */
43export 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 look50/** The address an invite link has. */
51export 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 */
60export 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 confirm68/**
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 */
73export 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. */
79export function invitePath(code: string, proof?: string | null): string {
80 const path = `/invite/${encodeURIComponent(code)}`;
81 return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path;
82}
83
84type 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 */
98export 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 confirm109 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 confirm111 : 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 look130type 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 look132 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 look137};
138
139/** How an invite's state reads in a list. */
140export 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 look155 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 look159 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)166type 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 */
182export 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
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.218/** What inviting someone came to (lib/member-invitations.server.ts): who was invited, or why not. */
219export type InviteResult = { invited: string; outOfInvites: false } | { error: string; outOfInvites: boolean };
220
221/** What revoking or resending an invitation came to. */
222export type InvitationResult = { done: "revoked" | "resent"; id: string } | { error: string; id: string };
223
224/**
225 * The Invitations page's filters: every invitation, or the ones in one
226 * state. Pending gathers the three states an invitation waits in (to be
227 * used, for the new account to confirm its address, for the person's
228 * answer); Accepted is the ones that joined. g1t does not record an email
229 * that could not be delivered, so an invitation nobody used shows as
230 * Expired once its 30 days are up; there is no Failed filter until it does.
231 */
232export type InvitationFilter = "all" | "pending" | "accepted" | "declined" | "expired" | "revoked";
233
234export const INVITATION_FILTERS: readonly { key: InvitationFilter; label: string }[] = [
235 { key: "all", label: "All" },
236 { key: "pending", label: "Pending" },
237 { key: "accepted", label: "Accepted" },
238 { key: "declined", label: "Declined" },
239 { key: "expired", label: "Expired" },
240 { key: "revoked", label: "Revoked" },
241];
242
243/** The filter an invitation's state falls under. */
244export function invitationFilterOf(status: Listed["status"]): Exclude<InvitationFilter, "all"> {
245 switch (status) {
246 case "pending":
247 case "awaiting_confirmation":
248 case "awaiting_answer":
249 return "pending";
250 case "redeemed":
251 return "accepted";
252 case "declined":
253 return "declined";
254 case "expired":
255 return "expired";
256 case "revoked":
257 return "revoked";
258 }
259}
260
261/** The filter `?state=` names; All for anything else. */
262export function invitationFilter(raw: string | null | undefined): InvitationFilter {
263 return INVITATION_FILTERS.some((filter) => filter.key === raw) ? (raw as InvitationFilter) : "all";
264}
265
266/** The invitations under `filter`. */
267export function filterInvitations<T extends Pick<Listed, "status">>(invites: readonly T[], filter: InvitationFilter): T[] {
268 if (filter === "all") return [...invites];
269 return invites.filter((invite) => invitationFilterOf(invite.status) === filter);
270}
271
272/** How many invitations each filter holds, for the counts on the chips. */
273export function invitationCounts(invites: readonly Pick<Listed, "status">[]): Record<InvitationFilter, number> {
274 const counts: Record<InvitationFilter, number> = { all: invites.length, pending: 0, accepted: 0, declined: 0, expired: 0, revoked: 0 };
275 for (const invite of invites) counts[invitationFilterOf(invite.status)] += 1;
276 return counts;
277}
278
279/** The tone of an invitation's status badge, from how its state reads. */
280export function invitationTone(status: Listed["status"]): "neutral" | "success" | "warn" | "danger" {
281 switch (invitationFilterOf(status)) {
282 case "pending":
283 return "warn";
284 case "accepted":
285 return "success";
286 case "declined":
287 case "revoked":
288 return "danger";
289 default:
290 return "neutral";
291 }
292}
293
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look294/** Who an invite is for, in a list. */
295export 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)296 return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link");
297}
298
299/**
300 * Which of the two invites a listed one is, in words: an invite to g1t
301 * (an account, and no workspace), or an invitation to join a workspace.
302 */
303export function inviteKind(invite: { workspace: string | null }): { kind: "g1t" | "workspace"; label: string } {
304 return invite.workspace
305 ? { kind: "workspace", label: `Invite to join ${invite.workspace}` }
306 : { 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)307}
308
309type Membership = { slug: string; name?: string | null; role: "owner" | "member" };
310
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)311/** 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)312export type BringInto = { slug: string; name: string };
313
314/**
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)315 * The workspaces Settings → Invites can also invite the person to, when
316 * "Also invite them to a workspace" is ticked: the ones the viewer owns
317 * that are not on the free plan, which adds no one. None is chosen for
318 * them: the box is off at first, and the list starts on "Choose a
319 * 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)320 */
321export function bringIntoChoices(
322 memberships: Membership[],
323 free: string[],
324 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)325): { 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)326 const isFree = new Set(free.map((slug) => slug.toLowerCase()));
327 const options = memberships
328 .filter((m) => m.role === "owner" && !isFree.has(m.slug.toLowerCase()))
329 .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug }));
330 const here = current?.trim().toLowerCase() || null;
331 let note: string | null = null;
332 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)333 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)334 note =
335 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)336 ? `Only the owners of ${membership.slug} can invite people to it.`
337 : `${membership.slug} is on the free plan, so it cannot add people. Start the plan to invite people to it.`;
338 }
339 return { options, note };
340}
341
342/**
343 * What the Settings → Invites form sends to identity. The workspace goes
344 * with it only when "Also invite them to a workspace" (`also_join`) is
345 * ticked: unticked, the invite is to g1t alone, whatever else the form held.
346 */
347export function inviteDraft(form: { get(name: string): unknown }): {
348 email: string | null;
349 workspace: string | null;
350 join?: string;
351 joinRole?: "owner" | "member";
352} {
353 const text = (name: string) => {
354 const value = form.get(name);
355 return typeof value === "string" ? value.trim() : "";
356 };
357 const charge = text("charge");
358 const draft: ReturnType<typeof inviteDraft> = {
359 email: text("email") || null,
360 workspace: charge && charge !== "mine" ? charge : null,
361 };
362 const join = text("join");
363 if (text("also_join") === "on" && join) {
364 draft.join = join;
365 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)366 }
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)367 return draft;
368}
369
370/**
371 * What Settings → Invites shows. Invites to g1t exist only while sign-up
372 * takes one: then the page has the form. Once anyone can sign up, it
373 * keeps only the list of invites already made, and the settings menu
374 * lists the page only when there are some.
375 */
376export function invitesPage(mode: "invite" | "open" | null | undefined, made: number): { form: boolean; listed: boolean } {
377 const inviteOnly = mode !== "open";
378 return { form: inviteOnly, listed: inviteOnly || made > 0 };
379}
380
381/** The words for Settings → Invites, the invite to g1t. */
382export const G1T_INVITES = {
383 /** The page's heading. */
384 heading: "Invite people to g1t",
385 /** Its name in the settings menu and the account menu. */
386 nav: "Invites to g1t",
387 about:
388 "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.",
389 /** In place of the form once anyone can sign up. */
390 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.",
391 /** The off-by-default box that also invites the person to a workspace. */
392 alsoJoin: "Also invite them to a workspace",
393 alsoJoinHint:
394 "Once their account is made, they get an invitation to the workspace to accept or decline. Left off, the invite is to g1t only.",
395 /** Where the other kind of invite lives. */
396 elsewhere: "To bring someone into a workspace, invite them from that workspace's People page",
397} as const;
398
399/**
400 * The words for a workspace's People page, the invitation to join it.
401 * `inviteOnly` says whether sign-up takes an invite: then an invitation to
402 * an address with no account also lets it make one (and costs an invite),
403 * and the page points to Settings → Invites for an invite to g1t alone.
404 */
405export 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.406 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)407 return {
408 heading: `Invite to ${name}`,
409 hint: inviteOnly
410 ? `${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.`
411 : `${base} If they do not have a g1t account yet, they sign up from the invitation first.`,
412 elsewhere: inviteOnly ? `To invite someone to g1t without adding them to ${name}, use Settings → Invites.` : null,
413 };
414}
415
416/**
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.417 * The Members page's filters, from its query: the role to show (`role=`:
418 * owners or members), whether two-factor authentication is on (`2fa=`: on
419 * or off; owners only see it), a team by slug (`team=`) and words to
420 * search by (`q=`, matched against the username and the name). Anything
421 * else is every member.
422 */
423export type MemberFilters = { role: "all" | "owner" | "member"; twoFactor: "all" | "on" | "off"; team: string | null; query: string };
424
425export function memberFilters(search: URLSearchParams | string): MemberFilters {
426 const params = typeof search === "string" ? new URLSearchParams(search) : search;
427 const role = params.get("role");
428 const twoFactor = params.get("2fa");
429 return {
430 role: role === "owner" || role === "member" ? role : "all",
431 twoFactor: twoFactor === "on" || twoFactor === "off" ? twoFactor : "all",
432 team: params.get("team")?.trim().toLowerCase() || null,
433 query: params.get("q")?.trim() ?? "",
434 };
435}
436
437type Listable = {
438 username: string;
439 name?: string | null;
440 role: "owner" | "member";
441 two_factor?: boolean | null;
442};
443
444/** The members `filters` keep, in the order given. `teams` is each member's teams, by slug. */
445export function filterMembers<T extends Listable>(members: readonly T[], filters: MemberFilters, teams: Record<string, { slug: string }[]> = {}): T[] {
446 const words = filters.query.toLowerCase().replace(/^@+/, "");
447 return members.filter((member) => {
448 if (filters.role !== "all" && member.role !== filters.role) return false;
449 if (filters.twoFactor !== "all" && member.two_factor !== (filters.twoFactor === "on")) return false;
450 if (filters.team && !(teams[member.username] ?? []).some((team) => team.slug.toLowerCase() === filters.team)) return false;
451 if (words && !member.username.toLowerCase().includes(words) && !(member.name ?? "").toLowerCase().includes(words)) return false;
452 return true;
453 });
454}
455
456/**
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)457 * The People pages Settings → Invites points to for a workspace
458 * invitation: the workspaces the viewer owns (only owners invite), the
459 * current one first.
460 */
461export function peoplePages(memberships: Membership[], current: string | null | undefined): { slug: string; name: string; to: string }[] {
462 const here = current?.trim().toLowerCase() || null;
463 return memberships
464 .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.465 .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)466 .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 look467}
468
469/** How many invites are left, in words. */
470export function remainingLine(allowance: { limit: number | null; used: number; remaining: number | null }): string {
471 if (allowance.limit == null) return "No limit on your invites";
472 const left = allowance.remaining ?? 0;
473 if (left === 0) return `You have used all ${allowance.limit} of your invites`;
474 return `${left} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`;
475}
476
477/**
478 * Whether a sign-up or access form was filled in by a bot: the hidden
479 * `website` field people never see, or a form sent back faster than a
480 * person types.
481 */
482export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean {
483 const trap = form.get("website");
484 if (typeof trap === "string" && trap.trim() !== "") return true;
485 const started = Number(form.get("started"));
486 return Number.isFinite(started) && started > 0 && now - started < 1500;
487}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas488
489/**
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers490 * A username to offer someone signing up with `email`: its local part,
491 * lowercased, with anything a username cannot hold made single hyphens,
492 * up to 39. They can type it in any case they like. Empty when nothing
493 * 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 quotas494 */
495export function suggestUsername(email: string | null | undefined): string {
496 const local = (email ?? "").split("@")[0]?.split("+")[0] ?? "";
497 return local
498 .toLowerCase()
499 .replace(/[^a-z0-9]+/g, "-")
500 .replace(/^-+|-+$/g, "")
501 .slice(0, 39)
502 .replace(/-+$/g, "");
503}
504
505type Lands = { workspace: { slug: string } | null; repository: { name: string } | null };
506
507/**
508 * Where using an invite lands: the workspace it joins, the repository it
509 * gives access to, or nowhere in particular.
510 */
511export function landingFor(invite: Lands): string | null {
512 if (invite.workspace) return invite.workspace.slug.toLowerCase();
513 if (invite.repository) return invite.repository.name.toLowerCase();
514 return null;
515}
516
517/** What someone who just joined is welcomed into, for one page view. */
518export const WELCOME_COOKIE = "g1t_welcome";
519
520const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/;
521
522/** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */
523export function welcomeCookie(target: string, secure: boolean): string {
524 return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
525}
526
527/** The `Set-Cookie` value that ends the welcome, once it has been shown. */
528export function clearWelcome(secure: boolean): string {
529 return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
530}
531
532/** Whether the request's cookies welcome someone into `target`. */
533export function welcomes(cookieHeader: string | null, target: string): boolean {
534 for (const part of (cookieHeader ?? "").split(";")) {
535 const [key, ...rest] = part.trim().split("=");
536 if (key !== WELCOME_COOKIE) continue;
537 let value: string;
538 try {
539 value = decodeURIComponent(rest.join("="));
540 } catch {
541 return false;
542 }
543 return TARGET.test(value) && value === target.toLowerCase();
544 }
545 return false;
546}

This file's history is long; its oldest lines are credited to the oldest commit read.