Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

485 linesCodeBlameRaw
1/**
2 * Invites, as the site shows them: links to an invite, and how each
3 * invitation reads in a list.
4 * No Workers or React imports, so it can be tested under Node.
5 */
6
7/** What the sign-up buttons say: Sign up. Anyone may register. */
8export function signUpCopy(): { primary: string; secondary: string | null } {
9 return { primary: "Sign up", secondary: null };
10}
11
12/** The address an invite link has. */
13export 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 */
22export 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))
27 text =
28 text
29 .replace(/[?#].*$/, "")
30 .split("/")
31 .filter(Boolean)
32 .pop() ?? "";
33 return text.replace(/\s+/g, "").slice(0, 80);
34}
35
36/**
37 * The `proof` an invite email's link carries, tidied: hex, or null for
38 * anything else. Identity decides whether it is the invite's own; this only
39 * keeps junk out of what is passed on and put back into a form.
40 */
41export function cleanProof(raw: string | null | undefined): string | null {
42 const text = (raw ?? "").trim().toLowerCase();
43 return /^[0-9a-f]{16,128}$/.test(text) ? text : null;
44}
45
46/** An invite's page, keeping the email's proof when there is one. */
47export function invitePath(code: string, proof?: string | null): string {
48 const path = `/invite/${encodeURIComponent(code)}`;
49 return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path;
50}
51
52type Proven = {
53 /** The bound address in full, or null for an invite to anyone with the code. */
54 address: string | null;
55 emailProven: boolean;
56 workspace: { name: string } | null;
57 repository: { name: string } | null;
58};
59
60/**
61 * What signing up on an invite's page says about the email address. Opened
62 * from the invite's own email (`emailProven`), the address is confirmed
63 * already, so there is no code to enter; otherwise the address is confirmed
64 * after sign-up, as it always is.
65 */
66export function inviteSignUpCopy(invite: Proven): {
67 /** Under "Create your account". */
68 intro: string;
69 /** Under the email field. */
70 hint: string;
71 /** Said plainly above the form when the address is confirmed already; null otherwise. */
72 confirmed: string | null;
73} {
74 const proven = invite.emailProven && invite.address !== null;
75 const when = proven ? "as soon as you create it" : "as soon as you confirm your email";
76 // A workspace is never joined without saying yes: the new account accepts its invitation.
77 const intro = invite.workspace
78 ? `You can join ${invite.workspace.name} ${when}: accept the invitation then.`
79 : invite.repository
80 ? `You get ${invite.repository.name} ${when}.`
81 : "It takes a minute.";
82 if (proven) {
83 return {
84 intro,
85 hint: "Your invite was sent here, and you opened it from that email, so this address is confirmed already.",
86 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.`,
87 };
88 }
89 return {
90 intro,
91 hint: invite.address
92 ? "Your invite was sent here. We email it a code to confirm it before you start."
93 : "We email it a code to confirm it before you start.",
94 confirmed: null,
95 };
96}
97
98type Listed = {
99 status: "pending" | "awaiting_confirmation" | "awaiting_answer" | "redeemed" | "declined" | "expired" | "revoked";
100 redeemedBy: string | null;
101 email: string | null;
102 workspace: string | null;
103 /** The account a workspace invitation is for, by username. */
104 invitee?: string | null;
105};
106
107/** How an invite's state reads in a list. */
108export function inviteState(invite: Listed): { label: string; tone: "pending" | "done" | "dead" } {
109 switch (invite.status) {
110 case "pending":
111 return { label: "Pending", tone: "pending" };
112 case "awaiting_confirmation":
113 // The account is made; it joins once it confirms its address.
114 return {
115 label: invite.redeemedBy ? `@${invite.redeemedBy} is confirming their email` : "Confirming their email",
116 tone: "pending",
117 };
118 case "awaiting_answer": {
119 // The account is made and confirmed; the workspace waits for its yes or no.
120 const who = invite.redeemedBy ?? invite.invitee;
121 return { label: who ? `Waiting for @${who} to accept` : "Waiting for an answer", tone: "pending" };
122 }
123 case "redeemed":
124 return { label: invite.redeemedBy ? `Joined as @${invite.redeemedBy}` : "Used", tone: "done" };
125 case "declined":
126 return { label: invite.invitee ? `@${invite.invitee} declined` : "Declined", tone: "dead" };
127 case "expired":
128 return { label: "Expired", tone: "dead" };
129 case "revoked":
130 return { label: "Revoked", tone: "dead" };
131 }
132}
133
134type Previewed = {
135 kind: "account" | "workspace";
136 invitedBy: { username: string } | null;
137 workspace: { name: string } | null;
138 repository: { name: string; role: string } | null;
139 hasAccount: boolean;
140};
141
142/**
143 * What an invite's page (/invite/:code) says it is, so nobody mistakes one
144 * kind for the other: the headline, in three parts with the place between
145 * (shown in bold), and the line under it. "@syntaqx invited you to g1t" is
146 * an account and no workspace; "@syntaqx invited you to join Flagon, Inc.
147 * on g1t" is a workspace invitation, which also makes the account of
148 * someone who has none.
149 */
150export function invitePageCopy(invite: Previewed, signedIn: boolean): { before: string; place: string | null; after: string; about: string } {
151 const from = invite.invitedBy ? `@${invite.invitedBy.username}` : "The g1t team";
152 const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account";
153 const g1t = "g1t is where a team and the coding agents it already uses ship code";
154 if (invite.workspace) {
155 const name = invite.workspace.name;
156 return {
157 before: `${from} invited you to join `,
158 place: name,
159 after: " on g1t",
160 about: `This is an invitation to join ${name}, which you accept or decline. ${
161 signingUp ? `You do not have a g1t account yet, so it also lets you make one: make it below, then join ${name}.` : `Accepting joins you to ${name}.`
162 }`,
163 };
164 }
165 if (invite.repository) {
166 return {
167 before: `${from} invited you to collaborate on `,
168 place: invite.repository.name,
169 after: "",
170 about: `${g1t}. ${signingUp ? "Make your account below and you get" : "Accepting gives you"} the ${invite.repository.role} role on ${invite.repository.name}.`,
171 };
172 }
173 return {
174 before: `${from} invited you to g1t`,
175 place: null,
176 after: "",
177 about: `${g1t}: repositories, pull requests and checks that hold, a merge queue and deployments, with chat for the team. Sign up from this invite and your address is filled in for you. It does not add you to anyone's workspace: your account starts with a workspace of its own.`,
178 };
179}
180
181/** What inviting someone came to (lib/member-invitations.server.ts): who was invited, or why not. */
182export type InviteResult = { invited: string } | { error: string };
183
184/** What revoking or resending an invitation came to. */
185export type InvitationResult = { done: "revoked" | "resent"; id: string } | { error: string; id: string };
186
187/**
188 * The Invitations page's filters: every invitation, or the ones in one
189 * state. Pending gathers the three states an invitation waits in (to be
190 * used, for the new account to confirm its address, for the person's
191 * answer); Accepted is the ones that joined. g1t does not record an email
192 * that could not be delivered, so an invitation nobody used shows as
193 * Expired once its 30 days are up; there is no Failed filter until it does.
194 */
195export type InvitationFilter = "all" | "pending" | "accepted" | "declined" | "expired" | "revoked";
196
197export const INVITATION_FILTERS: readonly { key: InvitationFilter; label: string }[] = [
198 { key: "all", label: "All" },
199 { key: "pending", label: "Pending" },
200 { key: "accepted", label: "Accepted" },
201 { key: "declined", label: "Declined" },
202 { key: "expired", label: "Expired" },
203 { key: "revoked", label: "Revoked" },
204];
205
206/** The filter an invitation's state falls under. */
207export function invitationFilterOf(status: Listed["status"]): Exclude<InvitationFilter, "all"> {
208 switch (status) {
209 case "pending":
210 case "awaiting_confirmation":
211 case "awaiting_answer":
212 return "pending";
213 case "redeemed":
214 return "accepted";
215 case "declined":
216 return "declined";
217 case "expired":
218 return "expired";
219 case "revoked":
220 return "revoked";
221 }
222}
223
224/** The filter `?state=` names; All for anything else. */
225export function invitationFilter(raw: string | null | undefined): InvitationFilter {
226 return INVITATION_FILTERS.some((filter) => filter.key === raw) ? (raw as InvitationFilter) : "all";
227}
228
229/** The invitations under `filter`. */
230export function filterInvitations<T extends Pick<Listed, "status">>(invites: readonly T[], filter: InvitationFilter): T[] {
231 if (filter === "all") return [...invites];
232 return invites.filter((invite) => invitationFilterOf(invite.status) === filter);
233}
234
235/** How many invitations each filter holds, for the counts on the chips. */
236export function invitationCounts(invites: readonly Pick<Listed, "status">[]): Record<InvitationFilter, number> {
237 const counts: Record<InvitationFilter, number> = { all: invites.length, pending: 0, accepted: 0, declined: 0, expired: 0, revoked: 0 };
238 for (const invite of invites) counts[invitationFilterOf(invite.status)] += 1;
239 return counts;
240}
241
242/** The tone of an invitation's status badge, from how its state reads. */
243export function invitationTone(status: Listed["status"]): "neutral" | "success" | "warn" | "danger" {
244 switch (invitationFilterOf(status)) {
245 case "pending":
246 return "warn";
247 case "accepted":
248 return "success";
249 case "declined":
250 case "revoked":
251 return "danger";
252 default:
253 return "neutral";
254 }
255}
256
257/** Who an invite is for, in a list. */
258export function inviteFor(invite: Listed): string {
259 return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link");
260}
261
262/**
263 * Which of the two invites a listed one is, in words: an invite to g1t
264 * (an account, and no workspace), or an invitation to join a workspace.
265 */
266export function inviteKind(invite: { workspace: string | null }): { kind: "g1t" | "workspace"; label: string } {
267 return invite.workspace ? { kind: "workspace", label: `Invite to join ${invite.workspace}` } : { kind: "g1t", label: "Invite to g1t" };
268}
269
270// --- Invite a friend ----------------------------------------------------------
271//
272// Settings → Invites to g1t (`/settings/invites`): any person invites a friend
273// to g1t by email. Anyone can sign up without one; the invite is a note from
274// someone they know, with a link that opens sign-up with their address filled
275// in and confirmed. Twenty each, for good: a revoked or expired one that was
276// never used comes back.
277
278/** The words for Invite a friend. Never says sign-up is held or needs an invite. */
279export const G1T_INVITES = {
280 /** Its name in the settings menu. */
281 nav: "Invites to g1t",
282 /** The page's heading. */
283 heading: "Invite friends to g1t",
284 about:
285 "Anyone can sign up for g1t. An invite is a note from you: your friend gets an email with a link that opens sign-up with their address filled in and confirmed, and you see when they join.",
286 /** The account menu's entry and the page's button. */
287 action: "Invite a friend",
288 /** The dialog's description. */
289 dialog: "They get an email from you with a link to sign up. Their account starts with a workspace of its own, and the invite works for 30 days.",
290 /** Under the count. */
291 comesBack: "A revoked invite that was never used comes back to you.",
292 /** The off-by-default box that also invites the person to a workspace. */
293 alsoJoin: "Also invite them to a workspace",
294 alsoJoinHint: "Once their account is made, they get an invitation to the workspace to accept or decline. Left off, the invite is to g1t only.",
295 /** When none are left: on the disabled button. */
296 out: "You have used all your invites. Your friends can still sign up at g1t.sh without one.",
297 /** When none are left: under the count, which says so already. */
298 outNote: "Your friends can still sign up at g1t.sh without one.",
299} as const;
300
301type Membership = { slug: string; name?: string | null; role: "owner" | "member" };
302
303/** One workspace an invite to g1t can also invite its person to. */
304export type BringInto = { slug: string; name: string };
305
306/**
307 * The workspaces Invite a friend can also invite the person to, when "Also
308 * invite them to a workspace" is ticked: the ones the viewer owns that are
309 * on a paid plan. A free workspace invites no one, so it is not offered, and
310 * with none to offer the box is not shown at all.
311 */
312export function bringIntoChoices(memberships: readonly Membership[], free: readonly string[]): BringInto[] {
313 const isFree = new Set(free.map((slug) => slug.toLowerCase()));
314 return memberships
315 .filter((m) => m.role === "owner" && !isFree.has(m.slug.toLowerCase()))
316 .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug }));
317}
318
319/**
320 * What the Invite a friend form sends to identity. The workspace goes with
321 * it only when "Also invite them to a workspace" (`also_join`) is ticked and
322 * it is one of `offered`: unticked, the invite is to g1t alone, whatever
323 * else the form held.
324 */
325export function inviteDraft(
326 form: { get(name: string): unknown },
327 offered: readonly BringInto[] = [],
328): { email: string; message: string | null; join?: string; joinRole?: "owner" | "member" } {
329 const text = (name: string) => {
330 const value = form.get(name);
331 return typeof value === "string" ? value.trim() : "";
332 };
333 const draft: ReturnType<typeof inviteDraft> = { email: text("email"), message: text("message") || null };
334 const join = text("join").toLowerCase();
335 if (text("also_join") === "on" && join && offered.some((workspace) => workspace.slug === join)) {
336 draft.join = join;
337 draft.joinRole = text("join_role") === "owner" ? "owner" : "member";
338 }
339 return draft;
340}
341
342/** How many invites are left, in words: "18 of 20 invites left". */
343export function remainingLine(allowance: { limit: number; remaining: number }): string {
344 if (allowance.limit === 0) return "Invites to g1t are turned off on this installation";
345 if (allowance.remaining === 0) return `You have used all ${allowance.limit} of your invites`;
346 return `${allowance.remaining} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`;
347}
348
349/** The account menu's badge: "18 left", or nothing when the count is not known. */
350export function leftBadge(left: number | null | undefined): string | null {
351 return left == null ? null : `${left} left`;
352}
353
354/** Which of a person's invites to g1t can be revoked, sent again, or copied. */
355export function inviteActions(invite: { status: Listed["status"]; code: string | null }): {
356 revoke: boolean;
357 resend: boolean;
358 copy: boolean;
359} {
360 const pending = invite.status === "pending";
361 return {
362 revoke: pending || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer",
363 resend: pending,
364 copy: pending && invite.code != null,
365 };
366}
367
368/** The words for a workspace's People page, the invitation to join it. */
369export function workspaceInviteCopy(name: string): { heading: string; hint: string } {
370 return {
371 heading: `Invite to ${name}`,
372 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.`,
373 };
374}
375
376/**
377 * The Members page's filters, from its query: the role to show (`role=`:
378 * owners or members), whether two-factor authentication is on (`2fa=`: on
379 * or off; owners only see it), a team by slug (`team=`) and words to
380 * search by (`q=`, matched against the username and the name). Anything
381 * else is every member.
382 */
383export type MemberFilters = { role: "all" | "owner" | "member"; twoFactor: "all" | "on" | "off"; team: string | null; query: string };
384
385export function memberFilters(search: URLSearchParams | string): MemberFilters {
386 const params = typeof search === "string" ? new URLSearchParams(search) : search;
387 const role = params.get("role");
388 const twoFactor = params.get("2fa");
389 return {
390 role: role === "owner" || role === "member" ? role : "all",
391 twoFactor: twoFactor === "on" || twoFactor === "off" ? twoFactor : "all",
392 team: params.get("team")?.trim().toLowerCase() || null,
393 query: params.get("q")?.trim() ?? "",
394 };
395}
396
397type Listable = {
398 username: string;
399 name?: string | null;
400 role: "owner" | "member";
401 two_factor?: boolean | null;
402};
403
404/** The members `filters` keep, in the order given. `teams` is each member's teams, by slug. */
405export function filterMembers<T extends Listable>(members: readonly T[], filters: MemberFilters, teams: Record<string, { slug: string }[]> = {}): T[] {
406 const words = filters.query.toLowerCase().replace(/^@+/, "");
407 return members.filter((member) => {
408 if (filters.role !== "all" && member.role !== filters.role) return false;
409 if (filters.twoFactor !== "all" && member.two_factor !== (filters.twoFactor === "on")) return false;
410 if (filters.team && !(teams[member.username] ?? []).some((team) => team.slug.toLowerCase() === filters.team)) return false;
411 if (words && !member.username.toLowerCase().includes(words) && !(member.name ?? "").toLowerCase().includes(words)) return false;
412 return true;
413 });
414}
415
416/**
417 * Whether a sign-up or access form was filled in by a bot: the hidden
418 * `website` field people never see, or a form sent back faster than a
419 * person types.
420 */
421export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean {
422 const trap = form.get("website");
423 if (typeof trap === "string" && trap.trim() !== "") return true;
424 const started = Number(form.get("started"));
425 return Number.isFinite(started) && started > 0 && now - started < 1500;
426}
427
428/**
429 * A username to offer someone signing up with `email`: its local part,
430 * lowercased, with anything a username cannot hold made single hyphens,
431 * up to 39. They can type it in any case they like. Empty when nothing
432 * usable is left. Identity checks it is free.
433 */
434export function suggestUsername(email: string | null | undefined): string {
435 const local = (email ?? "").split("@")[0]?.split("+")[0] ?? "";
436 return local
437 .toLowerCase()
438 .replace(/[^a-z0-9]+/g, "-")
439 .replace(/^-+|-+$/g, "")
440 .slice(0, 39)
441 .replace(/-+$/g, "");
442}
443
444type Lands = { workspace: { slug: string } | null; repository: { name: string } | null };
445
446/**
447 * Where using an invite lands: the workspace it joins, the repository it
448 * gives access to, or nowhere in particular.
449 */
450export function landingFor(invite: Lands): string | null {
451 if (invite.workspace) return invite.workspace.slug.toLowerCase();
452 if (invite.repository) return invite.repository.name.toLowerCase();
453 return null;
454}
455
456/** What someone who just joined is welcomed into, for one page view. */
457export const WELCOME_COOKIE = "g1t_welcome";
458
459const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/;
460
461/** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */
462export function welcomeCookie(target: string, secure: boolean): string {
463 return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
464}
465
466/** The `Set-Cookie` value that ends the welcome, once it has been shown. */
467export function clearWelcome(secure: boolean): string {
468 return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
469}
470
471/** Whether the request's cookies welcome someone into `target`. */
472export function welcomes(cookieHeader: string | null, target: string): boolean {
473 for (const part of (cookieHeader ?? "").split(";")) {
474 const [key, ...rest] = part.trim().split("=");
475 if (key !== WELCOME_COOKIE) continue;
476 let value: string;
477 try {
478 value = decodeURIComponent(rest.join("="));
479 } catch {
480 return false;
481 }
482 return TARGET.test(value) && value === target.toLowerCase();
483 }
484 return false;
485}