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.

g1t.sh
480 linesCodeBlameRaw
1import { CircleAlert, Lock, MailCheck, Ticket } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
4import type { InvitePreview, User } from "@g1t/contracts";
5import { USERNAME_PATTERN } from "@g1t/contracts";
6
7import type { Route } from "./+types/invite";
8import { page } from "../lib/meta";
9import { ContinueWithGithub, OrDivider } from "../components/github";
10import { Honeypot } from "../components/honeypot";
11import { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui";
12import { Avatar } from "../components/ui/avatar";
13import { Card } from "../components/ui/card";
14import { githubSignInEnabled } from "../lib/github.server";
15import { identity } from "../lib/services.server";
16import { cleanCode, cleanProof, invitePageCopy, inviteSignUpCopy, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites";
17import { clientKey } from "../lib/registration.server";
18import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server";
19import { rememberWorkspace } from "../lib/workspace-choice";
20
21export function meta(args: Route.MetaArgs) {
22 return page(args, {
23 title: "You're invited · g1t",
24 description: "An invite to g1t, where a team and the coding agents it already uses ship code.",
25 });
26}
27
28/**
29 * Where an invite is used, from first click to landing inside: who sent
30 * it and what it is for, then signing up on this page (or signing in, for
31 * an address that has an account), and the workspace or repository it
32 * gives. Signing in or up elsewhere (GitHub, /login) comes back here with
33 * `?accept=1`, which finishes the job.
34 *
35 * The link in the invite's own email also carries `?proof=`, which only
36 * that email has: signing up from it makes the account with the address
37 * confirmed already. The code alone (typed in, or a link passed on) does
38 * not, and the address is confirmed after sign-up as usual.
39 */
40export async function loader({ request, context, params }: Route.LoaderArgs) {
41 const code = cleanCode(params.code);
42 const viewer = getViewer(context);
43 const search = new URL(request.url).searchParams;
44 const accepting = search.get("accept") === "1";
45 const emailProof = cleanProof(search.get("proof"));
46 const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true, emailProof });
47 const invite = checked.ok ? checked.value : null;
48 const lands = invite ? landingFor(invite) : null;
49
50 if (viewer && invite) {
51 // Used by this person already: just made the account with it (through
52 // GitHub), or an old link opened again.
53 if (invite.status === "redeemed" && invite.forViewer) throw landIn(request, invite, [], accepting);
54 // Back from signing in to accept an invite sent to their address.
55 if (accepting && invite.status === "pending" && invite.forViewer === true && lands && !alreadyIn(viewer, invite)) {
56 const accepted = await identity.acceptInvite(viewer, code);
57 if (accepted.ok) throw landIn(request, invite, [], true);
58 return { ...base(), acceptError: accepted.error.message };
59 }
60 }
61
62 function base() {
63 return {
64 code,
65 invite,
66 error: checked.ok ? null : checked.error.message,
67 viewer: viewer ? { username: viewer.username, avatar: viewer.avatar ?? null } : null,
68 alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false,
69 github: false,
70 suggestion: suggestUsername(invite?.address),
71 // Only a proof identity accepted goes back into the form.
72 proof: invite?.emailProven ? emailProof : null,
73 started: Date.now(),
74 acceptError: null as string | null,
75 };
76 }
77 const signingUp = !viewer && invite?.status === "pending" && invite.kind === "account" && !invite.hasAccount;
78 return { ...base(), github: signingUp ? await githubSignInEnabled() : false };
79}
80
81/** Whether the viewer is in what the invite gives already. */
82function alreadyIn(viewer: User, invite: InvitePreview): boolean {
83 if (invite.workspace) return roleIn(viewer, invite.workspace.slug) !== null;
84 return false;
85}
86
87/**
88 * The redirect into what the invite gave, with the session (when one was
89 * just made), the welcome for that first view, and the workspace made the
90 * one the sidebar is about.
91 */
92function landIn(request: Request, invite: InvitePreview, cookies: string[], welcome: boolean): Response {
93 const secure = new URL(request.url).protocol === "https:";
94 const target = landingFor(invite);
95 const headers = new Headers();
96 for (const cookie of cookies) headers.append("set-cookie", cookie);
97 if (target && welcome) headers.append("set-cookie", welcomeCookie(target, secure));
98 if (invite.workspace) headers.append("set-cookie", rememberWorkspace(invite.workspace.slug, secure));
99 return redirect(target ? `/${target}` : "/", { headers });
100}
101
102export async function action({ request, context, params }: Route.ActionArgs) {
103 assertSameOrigin(request);
104 const code = cleanCode(params.code);
105 const form = await request.formData();
106 const client = clientKey(request);
107 const emailProof = cleanProof(String(form.get("proof") ?? ""));
108 const checked = await identity.checkInvite(code, client, { viewer: getViewer(context), emailProof });
109 if (!checked.ok) return data({ error: checked.error.message }, { status: 422 });
110 const invite = checked.value;
111
112 if (form.get("intent") === "register") {
113 if (looksAutomated(form)) return data({ error: "Something went wrong. Try again." }, { status: 422 });
114 // An invite for one address makes the account with that address,
115 // whatever the form sent.
116 const email = invite.address ?? String(form.get("email") ?? "");
117 const result = await identity.register(
118 String(form.get("username") ?? ""),
119 email,
120 String(form.get("password") ?? ""),
121 code,
122 client,
123 // Identity checks it again, against this invite and this address.
124 emailProof,
125 );
126 if (!result.ok) return data({ error: result.error.message }, { status: 422 });
127 throw landIn(request, invite, [startSession(result.value.sessionToken)], true);
128 }
129
130 const user = requireUser(context, request);
131 const result = await identity.acceptInvite(user, code);
132 // Said with a 200, so the page loads again and shows the invite as it
133 // now stands (used up, revoked) beside the reason: after a 4xx answer
134 // React Router keeps the page's data as it was.
135 if (!result.ok) return { error: result.error.message };
136 throw landIn(request, invite, [], true);
137}
138
139/** Who sent it, and the workspace it joins, as faces. */
140function Faces({ invite }: { invite: InvitePreview }) {
141 const from = invite.invitedBy;
142 return (
143 <div className="flex items-center gap-3">
144 {from ? (
145 <Avatar name={from.username} image={from.avatar} size={44} />
146 ) : (
147 <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
148 <Ticket size={20} />
149 </span>
150 )}
151 {invite.workspace && (
152 <>
153 <span className="text-faint">→</span>
154 <Avatar name={invite.workspace.slug} image={invite.workspace.avatar} size={44} square />
155 </>
156 )}
157 </div>
158 );
159}
160
161/**
162 * Which invite it is, in the headline (`invitePageCopy`): "@syntaqx
163 * invited you to join Flagon, Inc. on g1t" (a workspace invitation, with
164 * the place in bold), "… to collaborate on acme/web", or "@syntaqx invited
165 * you to g1t" (an account, and no workspace).
166 */
167function Headline({ invite }: { invite: InvitePreview }) {
168 const copy = invitePageCopy(invite, false);
169 return (
170 <>
171 {copy.before}
172 {copy.place && (
173 <strong className={`font-semibold text-fg ${invite.repository && !invite.workspace ? "font-mono" : ""}`}>{copy.place}</strong>
174 )}
175 {copy.after}
176 </>
177 );
178}
179
180/** What accepting is called on its button. */
181function joinLabel(invite: InvitePreview): string {
182 if (invite.workspace) return `Join ${invite.workspace.name}`;
183 if (invite.repository) return `Accept access to ${invite.repository.name}`;
184 return "Accept invite";
185}
186
187const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
188
189type Loaded = Route.ComponentProps["loaderData"];
190
191/** Signing up, on this page: the address the invite was sent to, a username, a password. */
192function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) {
193 const invite = loaded.invite!;
194 const here = `/invite/${loaded.code}`;
195 const back = `${here}?accept=1`;
196 const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`;
197 const copy = inviteSignUpCopy(invite);
198 return (
199 <Card asChild tone="plain" className="bg-surface/60 p-5 sm:p-6">
200 <section aria-labelledby="sign-up">
201 <h2 id="sign-up" className="text-base font-semibold">
202 Create your account
203 </h2>
204 <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 )}
211 {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} />
220 {loaded.proof && <input type="hidden" name="proof" value={loaded.proof} />}
221 {invite.address ? (
222 <Field label="Email" hint={copy.hint}>
223 <span className="relative block">
224 <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" />
225 {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 )}
230 </span>
231 </Field>
232 ) : (
233 <Field label="Email" hint={copy.hint}>
234 <Input name="email" type="email" autoComplete="email" required maxLength={254} />
235 </Field>
236 )}
237 <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.">
238 <Input
239 name="username"
240 autoComplete="username"
241 required
242 autoFocus
243 maxLength={39}
244 defaultValue={loaded.suggestion}
245 pattern={USERNAME_PATTERN}
246 />
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">
253 <SubmitButton pending="Creating account…" match={{ intent: "register" }}>
254 {invite.workspace
255 ? `Create account and join ${invite.workspace.name}`
256 : invite.repository
257 ? "Create account and accept"
258 : "Create account"}
259 </SubmitButton>
260 </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 </Card>
270 );
271}
272
273/** What to do next: join, sign in, sign out, or sign up. */
274function Next({ loaded, error }: { loaded: Loaded; error: string | null }) {
275 const invite = loaded.invite!;
276 const viewer = loaded.viewer;
277 const here = `/invite/${loaded.code}`;
278 const back = `${here}?accept=1`;
279 const joinable = invite.workspace !== null || invite.repository !== null;
280
281 if (viewer) {
282 const signedInAs = (
283 <p className="flex items-center gap-2 text-sm text-muted">
284 <Avatar name={viewer.username} image={viewer.avatar} size={20} />
285 Signed in as <span className="font-mono text-fg">{viewer.username}</span>
286 </p>
287 );
288 // Its own intent, which /logout ignores, so only its button says it is working.
289 const signOut = (label: string, variant: "default" | "outline") => (
290 <Form method="post" action={`/logout?next=${encodeURIComponent(here)}`}>
291 <input type="hidden" name="intent" value="sign-out" />
292 <SubmitButton variant={variant} pending="Signing out…" match={{ intent: "sign-out" }}>
293 {label}
294 </SubmitButton>
295 </Form>
296 );
297 if (invite.forViewer === false) {
298 return (
299 <div className="space-y-4">
300 {signedInAs}
301 <div className="rounded-md border border-warn/40 bg-warn/5 p-4 text-sm" role="status">
302 <p className="font-medium text-fg">This invite is for {invite.address ?? invite.email}, not this account.</p>
303 <p className="mt-1 text-muted">
304 Only an account with that address confirmed can use it. Sign out, then sign in or sign up with it.
305 </p>
306 </div>
307 <ErrorText>{error}</ErrorText>
308 <div className="flex flex-wrap gap-3">{signOut("Sign out and continue", "default")}</div>
309 </div>
310 );
311 }
312 if (loaded.alreadyIn && invite.workspace) {
313 return (
314 <div className="space-y-4">
315 {signedInAs}
316 <p className="text-sm text-muted">You are in {invite.workspace.name} already.</p>
317 <ButtonLink to={`/${invite.workspace.slug}`}>Go to {invite.workspace.name}</ButtonLink>
318 </div>
319 );
320 }
321 if (!joinable) {
322 return (
323 <div className="space-y-4">
324 {signedInAs}
325 <div className="rounded-md border border-line bg-surface p-4 text-sm">
326 <p>
327 You already have a g1t account, <span className="font-mono">{viewer.username}</span>, so this invite has
328 nothing more to give you.
329 </p>
330 <p className="mt-2 text-muted">Pass it on to whoever it was meant for, or keep it for someone else.</p>
331 </div>
332 <div className="flex flex-wrap gap-3">{signOut("Sign out to use it", "outline")}</div>
333 </div>
334 );
335 }
336 return (
337 <>
338 {/* "Not you?" posts this form, so it does not carry the accept form's intent. */}
339 <Form id="invite-sign-out" method="post" action={`/logout?next=${encodeURIComponent(here)}`} hidden>
340 <input type="hidden" name="intent" value="sign-out" />
341 </Form>
342 <Form method="post" className="space-y-4">
343 <input type="hidden" name="intent" value="accept" />
344 {signedInAs}
345 {invite.forViewer === null && (
346 <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p>
347 )}
348 <ErrorText>{error}</ErrorText>
349 <div className="flex flex-wrap items-center gap-3">
350 <SubmitButton pending={invite.workspace ? "Joining…" : "Accepting…"} match={{ intent: "accept" }}>
351 {joinLabel(invite)}
352 </SubmitButton>
353 <span className="text-sm text-muted">
354 Not you?{" "}
355 <SubmitButton
356 form="invite-sign-out"
357 pending="Signing out…"
358 match={{ intent: "sign-out" }}
359 variant="link"
360 size="inline"
361 className="gap-1 text-fg underline"
362 >
363 Sign out
364 </SubmitButton>
365 </span>
366 </div>
367 </Form>
368 </>
369 );
370 }
371
372 // Someone already on g1t: sign in, and the invite is accepted on return.
373 if (invite.hasAccount || invite.kind === "workspace") {
374 return (
375 <div className="space-y-4">
376 <div className="rounded-md border border-line bg-surface p-4 text-sm">
377 <p className="font-medium text-fg">{invite.address ?? invite.email ?? "This address"} has a g1t account.</p>
378 <p className="mt-1 text-muted">
379 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"}.
380 </p>
381 </div>
382 <ErrorText>{error}</ErrorText>
383 <ButtonLink to={`/login?next=${encodeURIComponent(back)}`}>Sign in to accept</ButtonLink>
384 </div>
385 );
386 }
387 return <SignUp loaded={loaded} error={error} />;
388}
389
390/** A code that cannot be used: why, and whom to ask. */
391function Dead({ loaded }: { loaded: Loaded }) {
392 const invite = loaded.invite;
393 const title = !invite
394 ? "This invite link does not work"
395 : invite.status === "expired"
396 ? "This invite has expired"
397 : invite.status === "revoked"
398 ? "This invite was withdrawn"
399 : "This invite has been used";
400 const from = invite?.invitedBy;
401 return (
402 <>
403 <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight">
404 <CircleAlert size={22} className="shrink-0 text-warn" />
405 {title}
406 </h1>
407 {invite ? (
408 <p className="mt-3 text-sm leading-6 text-muted">
409 It was {invite.workspace ? `an invite to join ${invite.workspace.name}` : invite.repository ? `an invite to ${invite.repository.name}` : "an invite to g1t"}
410 {invite.status === "expired" && `, and stopped working on ${LONG_DATE.format(new Date(invite.expiresAt))}`}.{" "}
411 {from ? "Ask the person who sent it for a new one." : "Ask for access and we will send a new one."}
412 </p>
413 ) : (
414 <p className="mt-3 text-sm leading-6 text-muted">{loaded.error}</p>
415 )}
416 {from && (
417 <Card radius="lg" className="mt-5 flex items-center gap-3 p-3">
418 <Avatar name={from.username} image={from.avatar} size={36} />
419 <p className="min-w-0 text-sm">
420 Ask <span className="font-medium text-fg">{from.name ?? from.username}</span>{" "}
421 <Link to={`/u/${from.username}`} className="font-mono text-muted hover:text-fg">
422 @{from.username}
423 </Link>{" "}
424 <span className="text-muted">for a new invite.</span>
425 </p>
426 </Card>
427 )}
428 <div className="mt-8 flex flex-wrap gap-3">
429 <ButtonLink to="/register">Sign up</ButtonLink>
430 <ButtonLink to={loaded.viewer ? "/" : "/login"} variant="outline">
431 {loaded.viewer ? "Go to g1t" : "Sign in"}
432 </ButtonLink>
433 </div>
434 </>
435 );
436}
437
438export default function Invite({ loaderData, actionData }: Route.ComponentProps) {
439 const { invite } = loaderData;
440 const error = (actionData && "error" in actionData ? actionData.error : null) ?? loaderData.acceptError;
441 const usable = invite?.status === "pending";
442 return (
443 <main className="mx-auto flex max-w-md flex-col pt-8">
444 {usable ? (
445 <>
446 <div className="mt-8">
447 <Faces invite={invite} />
448 </div>
449 <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance text-fg-soft">
450 <Headline invite={invite} />
451 </h1>
452 <p className="mt-2 text-sm leading-6 text-muted">{invitePageCopy(invite, loaderData.viewer !== null).about}</p>
453 <dl className="mt-5 space-y-1 text-sm">
454 {invite.invitedBy && (
455 <div className="flex gap-2">
456 <dt className="w-24 shrink-0 text-faint">From</dt>
457 <dd className="font-mono text-fg-soft">@{invite.invitedBy.username}</dd>
458 </div>
459 )}
460 {invite.email && (
461 <div className="flex gap-2">
462 <dt className="w-24 shrink-0 text-faint">For</dt>
463 <dd className="font-mono break-all text-fg-soft">{invite.address ?? invite.email}</dd>
464 </div>
465 )}
466 <div className="flex gap-2">
467 <dt className="w-24 shrink-0 text-faint">Works until</dt>
468 <dd className="text-fg-soft">{LONG_DATE.format(new Date(invite.expiresAt))}</dd>
469 </div>
470 </dl>
471 <div className="mt-8">
472 <Next loaded={loaderData} error={error} />
473 </div>
474 </>
475 ) : (
476 <Dead loaded={loaderData} />
477 )}
478 </main>
479 );
480}