Skip to content
478 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.

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 confirm1import { CircleAlert, Lock, MailCheck, Ticket } from "lucide-react";
Merge branch 'worktree-agent-a985c01f43538af01'2import { Form, Link, data, redirect } from "react-router";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look3
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas4import type { InvitePreview, User } from "@g1t/contracts";
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers5import { USERNAME_PATTERN } from "@g1t/contracts";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look6
7import type { Route } from "./+types/invite";
8import { page } from "../lib/meta";
9import { Mark } from "../components/logo";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas10import { ContinueWithGithub, OrDivider } from "../components/github";
11import { Honeypot } from "../components/honeypot";
Merge branch 'worktree-agent-a985c01f43538af01'12import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas13import { githubSignInEnabled } from "../lib/github.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look14import { identity } from "../lib/services.server";
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)15import { cleanCode, cleanProof, invitePageCopy, inviteSignUpCopy, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look16import { clientKey } from "../lib/registration.server";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas17import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server";
18import { rememberWorkspace } from "../lib/workspace-choice";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look19
20export function meta(args: Route.MetaArgs) {
21 return page(args, {
22 title: "You're invited · g1t",
Chat and workspace agents: channels, DMs and named agents you talk to23 description: "An invite to g1t, one workspace where a team and its agents talk, work and ship.",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look24 });
25}
26
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas27/**
28 * Where an invite is used, from first click to landing inside: who sent
29 * it and what it is for, then signing up on this page (or signing in, for
30 * an address that has an account), and the workspace or repository it
31 * gives. Signing in or up elsewhere (GitHub, /login) comes back here with
32 * `?accept=1`, which finishes the job.
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 confirm33 *
34 * The link in the invite's own email also carries `?proof=`, which only
35 * that email has: signing up from it makes the account with the address
36 * confirmed already. The code alone (typed in, or a link passed on) does
37 * not, and the address is confirmed after sign-up as usual.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas38 */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look39export async function loader({ request, context, params }: Route.LoaderArgs) {
40 const code = cleanCode(params.code);
41 const viewer = getViewer(context);
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 confirm42 const search = new URL(request.url).searchParams;
43 const accepting = search.get("accept") === "1";
44 const emailProof = cleanProof(search.get("proof"));
45 const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true, emailProof });
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas46 const invite = checked.ok ? checked.value : null;
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)47 // A shared link for a group signs up on /register, which names the group.
48 if (invite?.sharedLabel) throw redirect(`/register?invite=${encodeURIComponent(code)}`);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas49 const lands = invite ? landingFor(invite) : null;
50
51 if (viewer && invite) {
52 // Used by this person already: just made the account with it (through
53 // GitHub), or an old link opened again.
54 if (invite.status === "redeemed" && invite.forViewer) throw landIn(request, invite, [], accepting);
55 // Back from signing in to accept an invite sent to their address.
56 if (accepting && invite.status === "pending" && invite.forViewer === true && lands && !alreadyIn(viewer, invite)) {
57 const accepted = await identity.acceptInvite(viewer, code);
58 if (accepted.ok) throw landIn(request, invite, [], true);
59 return { ...base(), acceptError: accepted.error.message };
60 }
61 }
62
63 function base() {
64 return {
65 code,
66 invite,
67 error: checked.ok ? null : checked.error.message,
68 viewer: viewer ? { username: viewer.username, avatar: viewer.avatar ?? null } : null,
69 alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false,
70 github: false,
71 suggestion: suggestUsername(invite?.address),
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 confirm72 // Only a proof identity accepted goes back into the form.
73 proof: invite?.emailProven ? emailProof : null,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas74 started: Date.now(),
75 acceptError: null as string | null,
76 };
77 }
78 const signingUp = !viewer && invite?.status === "pending" && invite.kind === "account" && !invite.hasAccount;
79 return { ...base(), github: signingUp ? await githubSignInEnabled() : false };
80}
81
82/** Whether the viewer is in what the invite gives already. */
83function alreadyIn(viewer: User, invite: InvitePreview): boolean {
84 if (invite.workspace) return roleIn(viewer, invite.workspace.slug) !== null;
85 return false;
86}
87
88/**
89 * The redirect into what the invite gave, with the session (when one was
90 * just made), the welcome for that first view, and the workspace made the
91 * one the sidebar is about.
92 */
93function landIn(request: Request, invite: InvitePreview, cookies: string[], welcome: boolean): Response {
94 const secure = new URL(request.url).protocol === "https:";
95 const target = landingFor(invite);
96 const headers = new Headers();
97 for (const cookie of cookies) headers.append("set-cookie", cookie);
98 if (target && welcome) headers.append("set-cookie", welcomeCookie(target, secure));
99 if (invite.workspace) headers.append("set-cookie", rememberWorkspace(invite.workspace.slug, secure));
100 return redirect(target ? `/${target}` : "/", { headers });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look101}
102
103export async function action({ request, context, params }: Route.ActionArgs) {
104 assertSameOrigin(request);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas105 const code = cleanCode(params.code);
106 const form = await request.formData();
107 const client = clientKey(request);
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 confirm108 const emailProof = cleanProof(String(form.get("proof") ?? ""));
109 const checked = await identity.checkInvite(code, client, { viewer: getViewer(context), emailProof });
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas110 if (!checked.ok) return data({ error: checked.error.message }, { status: 422 });
111 const invite = checked.value;
112
113 if (form.get("intent") === "register") {
114 if (looksAutomated(form)) return data({ error: "Something went wrong. Try again." }, { status: 422 });
115 // An invite for one address makes the account with that address,
116 // whatever the form sent.
117 const email = invite.address ?? String(form.get("email") ?? "");
118 const result = await identity.register(
119 String(form.get("username") ?? ""),
120 email,
121 String(form.get("password") ?? ""),
122 code,
123 client,
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 confirm124 // Identity checks it again, against this invite and this address.
125 emailProof,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas126 );
127 if (!result.ok) return data({ error: result.error.message }, { status: 422 });
128 throw landIn(request, invite, [startSession(result.value.sessionToken)], true);
129 }
130
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look131 const user = requireUser(context, request);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas132 const result = await identity.acceptInvite(user, code);
Merge branch 'worktree-agent-a985c01f43538af01'133 // Said with a 200, so the page loads again and shows the invite as it
134 // now stands (used up, revoked) beside the reason: after a 4xx answer
135 // React Router keeps the page's data as it was.
136 if (!result.ok) return { error: result.error.message };
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas137 throw landIn(request, invite, [], true);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look138}
139
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas140/** Who sent it, and the workspace it joins, as faces. */
141function Faces({ invite }: { invite: InvitePreview }) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look142 const from = invite.invitedBy;
143 return (
144 <div className="flex items-center gap-3">
145 {from ? (
146 <Avatar name={from.username} image={from.avatar} size={44} />
147 ) : (
148 <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
149 <Ticket size={20} />
150 </span>
151 )}
152 {invite.workspace && (
153 <>
154 <span className="text-faint">→</span>
155 <Avatar name={invite.workspace.slug} image={invite.workspace.avatar} size={44} square />
156 </>
157 )}
158 </div>
159 );
160}
161
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)162/**
163 * Which invite it is, in the headline (`invitePageCopy`): "@syntaqx
164 * invited you to join Flagon, Inc. on g1t" (a workspace invitation, with
165 * the place in bold), "… to collaborate on acme/web", or "@syntaqx invited
166 * you to g1t" (an account, and no workspace).
167 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas168function Headline({ invite }: { invite: InvitePreview }) {
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)169 const copy = invitePageCopy(invite, false);
170 return (
171 <>
172 {copy.before}
173 {copy.place && (
174 <strong className={`font-semibold text-fg ${invite.repository && !invite.workspace ? "font-mono" : ""}`}>{copy.place}</strong>
175 )}
176 {copy.after}
177 </>
178 );
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas179}
180
181/** What accepting is called on its button. */
182function joinLabel(invite: InvitePreview): string {
183 if (invite.workspace) return `Join ${invite.workspace.name}`;
184 if (invite.repository) return `Accept access to ${invite.repository.name}`;
185 return "Accept invite";
186}
187
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look188const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
189
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas190type Loaded = Route.ComponentProps["loaderData"];
191
192/** Signing up, on this page: the address the invite was sent to, a username, a password. */
193function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) {
194 const invite = loaded.invite!;
195 const here = `/invite/${loaded.code}`;
196 const back = `${here}?accept=1`;
197 const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`;
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 confirm198 const copy = inviteSignUpCopy(invite);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas199 return (
200 <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6">
201 <h2 id="sign-up" className="text-base font-semibold">
202 Create your account
203 </h2>
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 confirm204 <p className="mt-1 text-sm text-muted">{copy.intro}</p>
205 {copy.confirmed && (
206 <p className="mt-4 flex items-start gap-2 rounded-md border border-success/40 bg-success/5 p-3 text-sm" role="status">
207 <MailCheck size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-success" />
208 <span>{copy.confirmed}</span>
209 </p>
210 )}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas211 {loaded.github && (
212 <div className="mt-5">
213 <ContinueWithGithub href={github} />
214 <OrDivider />
215 </div>
216 )}
217 <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}>
218 <input type="hidden" name="intent" value="register" />
219 <Honeypot started={loaded.started} />
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 confirm220 {loaded.proof && <input type="hidden" name="proof" value={loaded.proof} />}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas221 {invite.address ? (
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 confirm222 <Field label="Email" hint={copy.hint}>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas223 <span className="relative block">
224 <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" />
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 confirm225 {copy.confirmed ? (
226 <MailCheck size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-success" />
227 ) : (
228 <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" />
229 )}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas230 </span>
231 </Field>
232 ) : (
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 confirm233 <Field label="Email" hint={copy.hint}>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas234 <Input name="email" type="email" autoComplete="email" required maxLength={254} />
235 </Field>
236 )}
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers237 <Field label="Username" hint="Letters, digits and single hyphens. It is how you sign in, and how others see you, in the case you type it.">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas238 <Input
239 name="username"
240 autoComplete="username"
241 required
242 autoFocus
243 maxLength={39}
244 defaultValue={loaded.suggestion}
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers245 pattern={USERNAME_PATTERN}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas246 />
247 </Field>
248 <Field label="Password" hint="At least 10 characters.">
249 <Input name="password" type="password" autoComplete="new-password" required minLength={10} />
250 </Field>
251 <ErrorText>{error}</ErrorText>
252 <div className="pt-1 *:w-full">
Merge branch 'worktree-agent-a985c01f43538af01'253 <SubmitButton pending="Creating account…" match={{ intent: "register" }}>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas254 {invite.workspace
255 ? `Create account and join ${invite.workspace.name}`
256 : invite.repository
257 ? "Create account and accept"
258 : "Create account"}
Merge branch 'worktree-agent-a985c01f43538af01'259 </SubmitButton>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas260 </div>
261 </Form>
262 <p className="mt-5 text-center text-sm text-muted">
263 Already on g1t?{" "}
264 <Link to={`/login?next=${encodeURIComponent(back)}`} className="text-fg underline underline-offset-4">
265 Sign in to accept
266 </Link>
267 </p>
268 </section>
269 );
270}
271
272/** What to do next: join, sign in, sign out, or sign up. */
273function Next({ loaded, error }: { loaded: Loaded; error: string | null }) {
274 const invite = loaded.invite!;
275 const viewer = loaded.viewer;
276 const here = `/invite/${loaded.code}`;
277 const back = `${here}?accept=1`;
278 const joinable = invite.workspace !== null || invite.repository !== null;
279
280 if (viewer) {
281 const signedInAs = (
282 <p className="flex items-center gap-2 text-sm text-muted">
283 <Avatar name={viewer.username} image={viewer.avatar} size={20} />
284 Signed in as <span className="font-mono text-fg">{viewer.username}</span>
285 </p>
286 );
Merge branch 'worktree-agent-a985c01f43538af01'287 // Its own intent, which /logout ignores, so only its button says it is working.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas288 const signOut = (label: string, variant: "primary" | "quiet") => (
289 <Form method="post" action={`/logout?next=${encodeURIComponent(here)}`}>
Merge branch 'worktree-agent-a985c01f43538af01'290 <input type="hidden" name="intent" value="sign-out" />
291 <SubmitButton variant={variant} pending="Signing out…" match={{ intent: "sign-out" }}>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas292 {label}
Merge branch 'worktree-agent-a985c01f43538af01'293 </SubmitButton>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas294 </Form>
295 );
296 if (invite.forViewer === false) {
297 return (
298 <div className="space-y-4">
299 {signedInAs}
300 <div className="rounded-md border border-warn/40 bg-warn/5 p-4 text-sm" role="status">
301 <p className="font-medium text-fg">This invite is for {invite.address ?? invite.email}, not this account.</p>
302 <p className="mt-1 text-muted">
303 Only an account with that address confirmed can use it. Sign out, then sign in or sign up with it.
304 </p>
305 </div>
306 <ErrorText>{error}</ErrorText>
307 <div className="flex flex-wrap gap-3">{signOut("Sign out and continue", "primary")}</div>
308 </div>
309 );
310 }
311 if (loaded.alreadyIn && invite.workspace) {
312 return (
313 <div className="space-y-4">
314 {signedInAs}
315 <p className="text-sm text-muted">You are in {invite.workspace.name} already.</p>
316 <ButtonLink to={`/${invite.workspace.slug}`}>Go to {invite.workspace.name}</ButtonLink>
317 </div>
318 );
319 }
320 if (!joinable) {
321 return (
322 <div className="space-y-4">
323 {signedInAs}
324 <div className="rounded-md border border-line bg-surface p-4 text-sm">
325 <p>
326 You already have a g1t account, <span className="font-mono">{viewer.username}</span>, so this invite has
327 nothing more to give you.
328 </p>
329 <p className="mt-2 text-muted">Pass it on to whoever it was meant for, or keep it for someone else.</p>
330 </div>
331 <div className="flex flex-wrap gap-3">{signOut("Sign out to use it", "quiet")}</div>
332 </div>
333 );
334 }
335 return (
Merge branch 'worktree-agent-a985c01f43538af01'336 <>
337 {/* "Not you?" posts this form, so it does not carry the accept form's intent. */}
338 <Form id="invite-sign-out" method="post" action={`/logout?next=${encodeURIComponent(here)}`} hidden>
339 <input type="hidden" name="intent" value="sign-out" />
340 </Form>
341 <Form method="post" className="space-y-4">
342 <input type="hidden" name="intent" value="accept" />
343 {signedInAs}
344 {invite.forViewer === null && (
345 <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p>
346 )}
347 <ErrorText>{error}</ErrorText>
348 <div className="flex flex-wrap items-center gap-3">
349 <SubmitButton pending={invite.workspace ? "Joining…" : "Accepting…"} match={{ intent: "accept" }}>
350 {joinLabel(invite)}
351 </SubmitButton>
352 <span className="text-sm text-muted">
353 Not you?{" "}
354 <SubmitButton
355 form="invite-sign-out"
356 pending="Signing out…"
357 match={{ intent: "sign-out" }}
358 className="inline-flex items-center gap-1 text-fg underline underline-offset-4 disabled:opacity-50"
359 >
360 Sign out
361 </SubmitButton>
362 </span>
363 </div>
364 </Form>
365 </>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas366 );
367 }
368
369 // Someone already on g1t: sign in, and the invite is accepted on return.
370 if (invite.hasAccount || invite.kind === "workspace") {
371 return (
372 <div className="space-y-4">
373 <div className="rounded-md border border-line bg-surface p-4 text-sm">
374 <p className="font-medium text-fg">{invite.address ?? invite.email ?? "This address"} has a g1t account.</p>
375 <p className="mt-1 text-muted">
376 Sign in to it and {joinable ? `you ${invite.workspace ? `join ${invite.workspace.name}` : `get access to ${invite.repository!.name}`} straight away` : "the invite is accepted"}.
377 </p>
378 </div>
379 <ErrorText>{error}</ErrorText>
380 <ButtonLink to={`/login?next=${encodeURIComponent(back)}`}>Sign in to accept</ButtonLink>
381 </div>
382 );
383 }
384 return <SignUp loaded={loaded} error={error} />;
385}
386
387/** A code that cannot be used: why, and whom to ask. */
388function Dead({ loaded }: { loaded: Loaded }) {
389 const invite = loaded.invite;
390 const title = !invite
391 ? "This invite link does not work"
392 : invite.status === "expired"
393 ? "This invite has expired"
394 : invite.status === "revoked"
395 ? "This invite was withdrawn"
396 : "This invite has been used";
397 const from = invite?.invitedBy;
398 return (
399 <>
400 <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight">
401 <CircleAlert size={22} className="shrink-0 text-warn" />
402 {title}
403 </h1>
404 {invite ? (
405 <p className="mt-3 text-sm leading-6 text-muted">
406 It was {invite.workspace ? `an invite to join ${invite.workspace.name}` : invite.repository ? `an invite to ${invite.repository.name}` : "an invite to g1t"}
407 {invite.status === "expired" && `, and stopped working on ${LONG_DATE.format(new Date(invite.expiresAt))}`}.{" "}
408 {from ? "Ask the person who sent it for a new one." : "Ask for access and we will send a new one."}
409 </p>
410 ) : (
411 <p className="mt-3 text-sm leading-6 text-muted">{loaded.error}</p>
412 )}
413 {from && (
414 <div className="mt-5 flex items-center gap-3 rounded-lg border border-line bg-surface p-3">
415 <Avatar name={from.username} image={from.avatar} size={36} />
416 <p className="min-w-0 text-sm">
417 Ask <span className="font-medium text-fg">{from.name ?? from.username}</span>{" "}
418 <Link to={`/u/${from.username}`} className="font-mono text-muted hover:text-fg">
419 @{from.username}
420 </Link>{" "}
421 <span className="text-muted">for a new invite.</span>
422 </p>
423 </div>
424 )}
425 <div className="mt-8 flex flex-wrap gap-3">
Sign-up buttons say Sign up everywhere; only the sign-up page says registration takes an invite426 <ButtonLink to="/register#request">Sign up</ButtonLink>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas427 <ButtonLink to={loaded.viewer ? "/" : "/login"} variant="quiet">
428 {loaded.viewer ? "Go to g1t" : "Sign in"}
429 </ButtonLink>
430 </div>
431 </>
432 );
433}
434
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look435export default function Invite({ loaderData, actionData }: Route.ComponentProps) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas436 const { invite } = loaderData;
437 const error = (actionData && "error" in actionData ? actionData.error : null) ?? loaderData.acceptError;
438 const usable = invite?.status === "pending";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look439 return (
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas440 <main className="mx-auto flex max-w-md flex-col px-4 pt-16 pb-12 sm:pt-20">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look441 <Mark className="size-9" />
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas442 {usable ? (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look443 <>
444 <div className="mt-8">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas445 <Faces invite={invite} />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look446 </div>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas447 <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance text-fg-soft">
448 <Headline invite={invite} />
449 </h1>
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)450 <p className="mt-2 text-sm leading-6 text-muted">{invitePageCopy(invite, loaderData.viewer !== null).about}</p>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look451 <dl className="mt-5 space-y-1 text-sm">
452 {invite.invitedBy && (
453 <div className="flex gap-2">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas454 <dt className="w-24 shrink-0 text-faint">From</dt>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look455 <dd className="font-mono text-fg-soft">@{invite.invitedBy.username}</dd>
456 </div>
457 )}
458 {invite.email && (
459 <div className="flex gap-2">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas460 <dt className="w-24 shrink-0 text-faint">For</dt>
461 <dd className="font-mono break-all text-fg-soft">{invite.address ?? invite.email}</dd>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look462 </div>
463 )}
464 <div className="flex gap-2">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas465 <dt className="w-24 shrink-0 text-faint">Works until</dt>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look466 <dd className="text-fg-soft">{LONG_DATE.format(new Date(invite.expiresAt))}</dd>
467 </div>
468 </dl>
469 <div className="mt-8">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas470 <Next loaded={loaderData} error={error} />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look471 </div>
472 </>
473 ) : (
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas474 <Dead loaded={loaderData} />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look475 )}
476 </main>
477 );
478}

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