Skip to content
479 linesCodeBlameRaw
1import { CircleAlert, Lock, Ticket } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
4import type { InvitePreview, User } from "@g1t/contracts";
5
6import type { Route } from "./+types/invite";
7import { page } from "../lib/meta";
8import { Mark } from "../components/logo";
9import { ContinueWithGithub, OrDivider } from "../components/github";
10import { Honeypot } from "../components/honeypot";
11import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui";
12import { githubSignInEnabled } from "../lib/github.server";
13import { identity } from "../lib/services.server";
14import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites";
15import { clientKey } from "../lib/registration.server";
16import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server";
17import { rememberWorkspace } from "../lib/workspace-choice";
18
19export function meta(args: Route.MetaArgs) {
20 return page(args, {
21 title: "You're invited · g1t",
22 description: "An invite to g1t, where people and agents ship software together.",
23 });
24}
25
26/**
27 * Where an invite is used, from first click to landing inside: who sent
28 * it and what it is for, then signing up on this page (or signing in, for
29 * an address that has an account), and the workspace or repository it
30 * gives. Signing in or up elsewhere (GitHub, /login) comes back here with
31 * `?accept=1`, which finishes the job.
32 */
33export async function loader({ request, context, params }: Route.LoaderArgs) {
34 const code = cleanCode(params.code);
35 const viewer = getViewer(context);
36 const accepting = new URL(request.url).searchParams.get("accept") === "1";
37 const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true });
38 const invite = checked.ok ? checked.value : null;
39 // A shared link for a group signs up on /register, which names the group.
40 if (invite?.sharedLabel) throw redirect(`/register?invite=${encodeURIComponent(code)}`);
41 const lands = invite ? landingFor(invite) : null;
42
43 if (viewer && invite) {
44 // Used by this person already: just made the account with it (through
45 // GitHub), or an old link opened again.
46 if (invite.status === "redeemed" && invite.forViewer) throw landIn(request, invite, [], accepting);
47 // Back from signing in to accept an invite sent to their address.
48 if (accepting && invite.status === "pending" && invite.forViewer === true && lands && !alreadyIn(viewer, invite)) {
49 const accepted = await identity.acceptInvite(viewer, code);
50 if (accepted.ok) throw landIn(request, invite, [], true);
51 return { ...base(), acceptError: accepted.error.message };
52 }
53 }
54
55 function base() {
56 return {
57 code,
58 invite,
59 error: checked.ok ? null : checked.error.message,
60 viewer: viewer ? { username: viewer.username, avatar: viewer.avatar ?? null } : null,
61 alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false,
62 github: false,
63 suggestion: suggestUsername(invite?.address),
64 started: Date.now(),
65 acceptError: null as string | null,
66 };
67 }
68 const signingUp = !viewer && invite?.status === "pending" && invite.kind === "account" && !invite.hasAccount;
69 return { ...base(), github: signingUp ? await githubSignInEnabled() : false };
70}
71
72/** Whether the viewer is in what the invite gives already. */
73function alreadyIn(viewer: User, invite: InvitePreview): boolean {
74 if (invite.workspace) return roleIn(viewer, invite.workspace.slug) !== null;
75 return false;
76}
77
78/**
79 * The redirect into what the invite gave, with the session (when one was
80 * just made), the welcome for that first view, and the workspace made the
81 * one the sidebar is about.
82 */
83function landIn(request: Request, invite: InvitePreview, cookies: string[], welcome: boolean): Response {
84 const secure = new URL(request.url).protocol === "https:";
85 const target = landingFor(invite);
86 const headers = new Headers();
87 for (const cookie of cookies) headers.append("set-cookie", cookie);
88 if (target && welcome) headers.append("set-cookie", welcomeCookie(target, secure));
89 if (invite.workspace) headers.append("set-cookie", rememberWorkspace(invite.workspace.slug, secure));
90 return redirect(target ? `/${target}` : "/", { headers });
91}
92
93export async function action({ request, context, params }: Route.ActionArgs) {
94 assertSameOrigin(request);
95 const code = cleanCode(params.code);
96 const form = await request.formData();
97 const client = clientKey(request);
98 const checked = await identity.checkInvite(code, client, { viewer: getViewer(context) });
99 if (!checked.ok) return data({ error: checked.error.message }, { status: 422 });
100 const invite = checked.value;
101
102 if (form.get("intent") === "register") {
103 if (looksAutomated(form)) return data({ error: "Something went wrong. Try again." }, { status: 422 });
104 // An invite for one address makes the account with that address,
105 // whatever the form sent.
106 const email = invite.address ?? String(form.get("email") ?? "");
107 const result = await identity.register(
108 String(form.get("username") ?? ""),
109 email,
110 String(form.get("password") ?? ""),
111 code,
112 client,
113 );
114 if (!result.ok) return data({ error: result.error.message }, { status: 422 });
115 throw landIn(request, invite, [startSession(result.value.sessionToken)], true);
116 }
117
118 const user = requireUser(context, request);
119 const result = await identity.acceptInvite(user, code);
120 // Said with a 200, so the page loads again and shows the invite as it
121 // now stands (used up, revoked) beside the reason: after a 4xx answer
122 // React Router keeps the page's data as it was.
123 if (!result.ok) return { error: result.error.message };
124 throw landIn(request, invite, [], true);
125}
126
127/** Who sent it, and the workspace it joins, as faces. */
128function Faces({ invite }: { invite: InvitePreview }) {
129 const from = invite.invitedBy;
130 return (
131 <div className="flex items-center gap-3">
132 {from ? (
133 <Avatar name={from.username} image={from.avatar} size={44} />
134 ) : (
135 <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
136 <Ticket size={20} />
137 </span>
138 )}
139 {invite.workspace && (
140 <>
141 <span className="text-faint">→</span>
142 <Avatar name={invite.workspace.slug} image={invite.workspace.avatar} size={44} square />
143 </>
144 )}
145 </div>
146 );
147}
148
149function senderName(invite: InvitePreview): string {
150 return invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team";
151}
152
153/** "Chase Pierce invited you to join Flagon, Inc. on g1t", with the place in bold. */
154function Headline({ invite }: { invite: InvitePreview }) {
155 const from = senderName(invite);
156 if (invite.workspace) {
157 return (
158 <>
159 {from} invited you to join <strong className="font-semibold text-fg">{invite.workspace.name}</strong> on g1t
160 </>
161 );
162 }
163 if (invite.repository) {
164 return (
165 <>
166 {from} invited you to collaborate on <strong className="font-mono font-semibold text-fg">{invite.repository.name}</strong>
167 </>
168 );
169 }
170 return <>{from} invited you to g1t</>;
171}
172
173function about(invite: InvitePreview, signedIn: boolean): string {
174 const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account";
175 if (invite.workspace) {
176 return `g1t is where people and agents ship software together. ${
177 signingUp ? "Make your account below and you join" : "Accepting joins you to"
178 } ${invite.workspace.name} as a member.`;
179 }
180 if (invite.repository) {
181 return `g1t is where people and agents ship software together. ${
182 signingUp ? "Make your account below and you get" : "Accepting gives you"
183 } the ${invite.repository.role} role on ${invite.repository.name}.`;
184 }
185 return "g1t is where people and agents ship software together: plan in issues, assign work to agents like teammates, and land it through checks that hold. It is invite-only for now; this invite gets you in.";
186}
187
188/** What accepting is called on its button. */
189function joinLabel(invite: InvitePreview): string {
190 if (invite.workspace) return `Join ${invite.workspace.name}`;
191 if (invite.repository) return `Accept access to ${invite.repository.name}`;
192 return "Accept invite";
193}
194
195const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
196
197type Loaded = Route.ComponentProps["loaderData"];
198
199/** Signing up, on this page: the address the invite was sent to, a username, a password. */
200function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) {
201 const invite = loaded.invite!;
202 const here = `/invite/${loaded.code}`;
203 const back = `${here}?accept=1`;
204 const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`;
205 return (
206 <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6">
207 <h2 id="sign-up" className="text-base font-semibold">
208 Create your account
209 </h2>
210 <p className="mt-1 text-sm text-muted">
211 {invite.workspace
212 ? `You join ${invite.workspace.name} as soon as you confirm your email.`
213 : invite.repository
214 ? `You get ${invite.repository.name} as soon as you confirm your email.`
215 : "It takes a minute."}
216 </p>
217 {loaded.github && (
218 <div className="mt-5">
219 <ContinueWithGithub href={github} />
220 <OrDivider />
221 </div>
222 )}
223 <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}>
224 <input type="hidden" name="intent" value="register" />
225 <Honeypot started={loaded.started} />
226 {invite.address ? (
227 <Field label="Email" hint="Your invite was sent here. We email it a code to confirm it before you start.">
228 <span className="relative block">
229 <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" />
230 <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" />
231 </span>
232 </Field>
233 ) : (
234 <Field label="Email" hint="We email it a code to confirm it before you start.">
235 <Input name="email" type="email" autoComplete="email" required maxLength={254} />
236 </Field>
237 )}
238 <Field label="Username" hint="Lowercase letters, digits and hyphens. It is how you sign in and how others see you.">
239 <Input
240 name="username"
241 autoComplete="username"
242 required
243 autoFocus
244 maxLength={39}
245 defaultValue={loaded.suggestion}
246 pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*"
247 />
248 </Field>
249 <Field label="Password" hint="At least 10 characters.">
250 <Input name="password" type="password" autoComplete="new-password" required minLength={10} />
251 </Field>
252 <ErrorText>{error}</ErrorText>
253 <div className="pt-1 *:w-full">
254 <SubmitButton pending="Creating account…" match={{ intent: "register" }}>
255 {invite.workspace
256 ? `Create account and join ${invite.workspace.name}`
257 : invite.repository
258 ? "Create account and accept"
259 : "Create account"}
260 </SubmitButton>
261 </div>
262 </Form>
263 <p className="mt-5 text-center text-sm text-muted">
264 Already on g1t?{" "}
265 <Link to={`/login?next=${encodeURIComponent(back)}`} className="text-fg underline underline-offset-4">
266 Sign in to accept
267 </Link>
268 </p>
269 </section>
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: "primary" | "quiet") => (
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", "primary")}</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", "quiet")}</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 className="inline-flex items-center gap-1 text-fg underline underline-offset-4 disabled:opacity-50"
360 >
361 Sign out
362 </SubmitButton>
363 </span>
364 </div>
365 </Form>
366 </>
367 );
368 }
369
370 // Someone already on g1t: sign in, and the invite is accepted on return.
371 if (invite.hasAccount || invite.kind === "workspace") {
372 return (
373 <div className="space-y-4">
374 <div className="rounded-md border border-line bg-surface p-4 text-sm">
375 <p className="font-medium text-fg">{invite.address ?? invite.email ?? "This address"} has a g1t account.</p>
376 <p className="mt-1 text-muted">
377 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"}.
378 </p>
379 </div>
380 <ErrorText>{error}</ErrorText>
381 <ButtonLink to={`/login?next=${encodeURIComponent(back)}`}>Sign in to accept</ButtonLink>
382 </div>
383 );
384 }
385 return <SignUp loaded={loaded} error={error} />;
386}
387
388/** A code that cannot be used: why, and whom to ask. */
389function Dead({ loaded }: { loaded: Loaded }) {
390 const invite = loaded.invite;
391 const title = !invite
392 ? "This invite link does not work"
393 : invite.status === "expired"
394 ? "This invite has expired"
395 : invite.status === "revoked"
396 ? "This invite was withdrawn"
397 : "This invite has been used";
398 const from = invite?.invitedBy;
399 return (
400 <>
401 <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight">
402 <CircleAlert size={22} className="shrink-0 text-warn" />
403 {title}
404 </h1>
405 {invite ? (
406 <p className="mt-3 text-sm leading-6 text-muted">
407 It was {invite.workspace ? `an invite to join ${invite.workspace.name}` : invite.repository ? `an invite to ${invite.repository.name}` : "an invite to g1t"}
408 {invite.status === "expired" && `, and stopped working on ${LONG_DATE.format(new Date(invite.expiresAt))}`}.{" "}
409 {from ? "Ask the person who sent it for a new one." : "Ask for access and we will send a new one."}
410 </p>
411 ) : (
412 <p className="mt-3 text-sm leading-6 text-muted">{loaded.error}</p>
413 )}
414 {from && (
415 <div className="mt-5 flex items-center gap-3 rounded-lg border border-line bg-surface p-3">
416 <Avatar name={from.username} image={from.avatar} size={36} />
417 <p className="min-w-0 text-sm">
418 Ask <span className="font-medium text-fg">{from.name ?? from.username}</span>{" "}
419 <Link to={`/u/${from.username}`} className="font-mono text-muted hover:text-fg">
420 @{from.username}
421 </Link>{" "}
422 <span className="text-muted">for a new invite.</span>
423 </p>
424 </div>
425 )}
426 <div className="mt-8 flex flex-wrap gap-3">
427 <ButtonLink to="/register#request">Request access</ButtonLink>
428 <ButtonLink to={loaded.viewer ? "/" : "/login"} variant="quiet">
429 {loaded.viewer ? "Go to g1t" : "Sign in"}
430 </ButtonLink>
431 </div>
432 </>
433 );
434}
435
436export default function Invite({ loaderData, actionData }: Route.ComponentProps) {
437 const { invite } = loaderData;
438 const error = (actionData && "error" in actionData ? actionData.error : null) ?? loaderData.acceptError;
439 const usable = invite?.status === "pending";
440 return (
441 <main className="mx-auto flex max-w-md flex-col px-4 pt-16 pb-12 sm:pt-20">
442 <Mark className="size-9" />
443 {usable ? (
444 <>
445 <div className="mt-8">
446 <Faces invite={invite} />
447 </div>
448 <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance text-fg-soft">
449 <Headline invite={invite} />
450 </h1>
451 <p className="mt-2 text-sm leading-6 text-muted">{about(invite, loaderData.viewer !== null)}</p>
452 <dl className="mt-5 space-y-1 text-sm">
453 {invite.invitedBy && (
454 <div className="flex gap-2">
455 <dt className="w-24 shrink-0 text-faint">From</dt>
456 <dd className="font-mono text-fg-soft">@{invite.invitedBy.username}</dd>
457 </div>
458 )}
459 {invite.email && (
460 <div className="flex gap-2">
461 <dt className="w-24 shrink-0 text-faint">For</dt>
462 <dd className="font-mono break-all text-fg-soft">{invite.address ?? invite.email}</dd>
463 </div>
464 )}
465 <div className="flex gap-2">
466 <dt className="w-24 shrink-0 text-faint">Works until</dt>
467 <dd className="text-fg-soft">{LONG_DATE.format(new Date(invite.expiresAt))}</dd>
468 </div>
469 </dl>
470 <div className="mt-8">
471 <Next loaded={loaderData} error={error} />
472 </div>
473 </>
474 ) : (
475 <Dead loaded={loaderData} />
476 )}
477 </main>
478 );
479}