Skip to content
497 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";
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, cleanProof, inviteSignUpCopy, 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, one workspace where a team and its agents talk, work and ship.",
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 *
33 * The link in the invite's own email also carries `?proof=`, which only
34 * that email has: signing up from it makes the account with the address
35 * confirmed already. The code alone (typed in, or a link passed on) does
36 * not, and the address is confirmed after sign-up as usual.
37 */
38export async function loader({ request, context, params }: Route.LoaderArgs) {
39 const code = cleanCode(params.code);
40 const viewer = getViewer(context);
41 const search = new URL(request.url).searchParams;
42 const accepting = search.get("accept") === "1";
43 const emailProof = cleanProof(search.get("proof"));
44 const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true, emailProof });
45 const invite = checked.ok ? checked.value : null;
46 // A shared link for a group signs up on /register, which names the group.
47 if (invite?.sharedLabel) throw redirect(`/register?invite=${encodeURIComponent(code)}`);
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
161function senderName(invite: InvitePreview): string {
162 return invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team";
163}
164
165/** "Chase Pierce invited you to join Flagon, Inc. on g1t", with the place in bold. */
166function Headline({ invite }: { invite: InvitePreview }) {
167 const from = senderName(invite);
168 if (invite.workspace) {
169 return (
170 <>
171 {from} invited you to join <strong className="font-semibold text-fg">{invite.workspace.name}</strong> on g1t
172 </>
173 );
174 }
175 if (invite.repository) {
176 return (
177 <>
178 {from} invited you to collaborate on <strong className="font-mono font-semibold text-fg">{invite.repository.name}</strong>
179 </>
180 );
181 }
182 return <>{from} invited you to g1t</>;
183}
184
185function about(invite: InvitePreview, signedIn: boolean): string {
186 const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account";
187 if (invite.workspace) {
188 return `g1t is one workspace where a team and its agents talk, work and ship. ${
189 signingUp ? "Make your account below and you join" : "Accepting joins you to"
190 } ${invite.workspace.name} as a member.`;
191 }
192 if (invite.repository) {
193 return `g1t is one workspace where a team and its agents talk, work and ship. ${
194 signingUp ? "Make your account below and you get" : "Accepting gives you"
195 } the ${invite.repository.role} role on ${invite.repository.name}.`;
196 }
197 return "g1t is one workspace where a team and its agents talk, work and ship: chat with people and agents, give agents a job and a budget, and land code through checks that hold. This invite gets you in.";
198}
199
200/** What accepting is called on its button. */
201function joinLabel(invite: InvitePreview): string {
202 if (invite.workspace) return `Join ${invite.workspace.name}`;
203 if (invite.repository) return `Accept access to ${invite.repository.name}`;
204 return "Accept invite";
205}
206
207const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
208
209type Loaded = Route.ComponentProps["loaderData"];
210
211/** Signing up, on this page: the address the invite was sent to, a username, a password. */
212function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) {
213 const invite = loaded.invite!;
214 const here = `/invite/${loaded.code}`;
215 const back = `${here}?accept=1`;
216 const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`;
217 const copy = inviteSignUpCopy(invite);
218 return (
219 <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6">
220 <h2 id="sign-up" className="text-base font-semibold">
221 Create your account
222 </h2>
223 <p className="mt-1 text-sm text-muted">{copy.intro}</p>
224 {copy.confirmed && (
225 <p className="mt-4 flex items-start gap-2 rounded-md border border-success/40 bg-success/5 p-3 text-sm" role="status">
226 <MailCheck size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-success" />
227 <span>{copy.confirmed}</span>
228 </p>
229 )}
230 {loaded.github && (
231 <div className="mt-5">
232 <ContinueWithGithub href={github} />
233 <OrDivider />
234 </div>
235 )}
236 <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}>
237 <input type="hidden" name="intent" value="register" />
238 <Honeypot started={loaded.started} />
239 {loaded.proof && <input type="hidden" name="proof" value={loaded.proof} />}
240 {invite.address ? (
241 <Field label="Email" hint={copy.hint}>
242 <span className="relative block">
243 <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" />
244 {copy.confirmed ? (
245 <MailCheck size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-success" />
246 ) : (
247 <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" />
248 )}
249 </span>
250 </Field>
251 ) : (
252 <Field label="Email" hint={copy.hint}>
253 <Input name="email" type="email" autoComplete="email" required maxLength={254} />
254 </Field>
255 )}
256 <Field label="Username" hint="Lowercase letters, digits and hyphens. It is how you sign in and how others see you.">
257 <Input
258 name="username"
259 autoComplete="username"
260 required
261 autoFocus
262 maxLength={39}
263 defaultValue={loaded.suggestion}
264 pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*"
265 />
266 </Field>
267 <Field label="Password" hint="At least 10 characters.">
268 <Input name="password" type="password" autoComplete="new-password" required minLength={10} />
269 </Field>
270 <ErrorText>{error}</ErrorText>
271 <div className="pt-1 *:w-full">
272 <SubmitButton pending="Creating account…" match={{ intent: "register" }}>
273 {invite.workspace
274 ? `Create account and join ${invite.workspace.name}`
275 : invite.repository
276 ? "Create account and accept"
277 : "Create account"}
278 </SubmitButton>
279 </div>
280 </Form>
281 <p className="mt-5 text-center text-sm text-muted">
282 Already on g1t?{" "}
283 <Link to={`/login?next=${encodeURIComponent(back)}`} className="text-fg underline underline-offset-4">
284 Sign in to accept
285 </Link>
286 </p>
287 </section>
288 );
289}
290
291/** What to do next: join, sign in, sign out, or sign up. */
292function Next({ loaded, error }: { loaded: Loaded; error: string | null }) {
293 const invite = loaded.invite!;
294 const viewer = loaded.viewer;
295 const here = `/invite/${loaded.code}`;
296 const back = `${here}?accept=1`;
297 const joinable = invite.workspace !== null || invite.repository !== null;
298
299 if (viewer) {
300 const signedInAs = (
301 <p className="flex items-center gap-2 text-sm text-muted">
302 <Avatar name={viewer.username} image={viewer.avatar} size={20} />
303 Signed in as <span className="font-mono text-fg">{viewer.username}</span>
304 </p>
305 );
306 // Its own intent, which /logout ignores, so only its button says it is working.
307 const signOut = (label: string, variant: "primary" | "quiet") => (
308 <Form method="post" action={`/logout?next=${encodeURIComponent(here)}`}>
309 <input type="hidden" name="intent" value="sign-out" />
310 <SubmitButton variant={variant} pending="Signing out…" match={{ intent: "sign-out" }}>
311 {label}
312 </SubmitButton>
313 </Form>
314 );
315 if (invite.forViewer === false) {
316 return (
317 <div className="space-y-4">
318 {signedInAs}
319 <div className="rounded-md border border-warn/40 bg-warn/5 p-4 text-sm" role="status">
320 <p className="font-medium text-fg">This invite is for {invite.address ?? invite.email}, not this account.</p>
321 <p className="mt-1 text-muted">
322 Only an account with that address confirmed can use it. Sign out, then sign in or sign up with it.
323 </p>
324 </div>
325 <ErrorText>{error}</ErrorText>
326 <div className="flex flex-wrap gap-3">{signOut("Sign out and continue", "primary")}</div>
327 </div>
328 );
329 }
330 if (loaded.alreadyIn && invite.workspace) {
331 return (
332 <div className="space-y-4">
333 {signedInAs}
334 <p className="text-sm text-muted">You are in {invite.workspace.name} already.</p>
335 <ButtonLink to={`/${invite.workspace.slug}`}>Go to {invite.workspace.name}</ButtonLink>
336 </div>
337 );
338 }
339 if (!joinable) {
340 return (
341 <div className="space-y-4">
342 {signedInAs}
343 <div className="rounded-md border border-line bg-surface p-4 text-sm">
344 <p>
345 You already have a g1t account, <span className="font-mono">{viewer.username}</span>, so this invite has
346 nothing more to give you.
347 </p>
348 <p className="mt-2 text-muted">Pass it on to whoever it was meant for, or keep it for someone else.</p>
349 </div>
350 <div className="flex flex-wrap gap-3">{signOut("Sign out to use it", "quiet")}</div>
351 </div>
352 );
353 }
354 return (
355 <>
356 {/* "Not you?" posts this form, so it does not carry the accept form's intent. */}
357 <Form id="invite-sign-out" method="post" action={`/logout?next=${encodeURIComponent(here)}`} hidden>
358 <input type="hidden" name="intent" value="sign-out" />
359 </Form>
360 <Form method="post" className="space-y-4">
361 <input type="hidden" name="intent" value="accept" />
362 {signedInAs}
363 {invite.forViewer === null && (
364 <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p>
365 )}
366 <ErrorText>{error}</ErrorText>
367 <div className="flex flex-wrap items-center gap-3">
368 <SubmitButton pending={invite.workspace ? "Joining…" : "Accepting…"} match={{ intent: "accept" }}>
369 {joinLabel(invite)}
370 </SubmitButton>
371 <span className="text-sm text-muted">
372 Not you?{" "}
373 <SubmitButton
374 form="invite-sign-out"
375 pending="Signing out…"
376 match={{ intent: "sign-out" }}
377 className="inline-flex items-center gap-1 text-fg underline underline-offset-4 disabled:opacity-50"
378 >
379 Sign out
380 </SubmitButton>
381 </span>
382 </div>
383 </Form>
384 </>
385 );
386 }
387
388 // Someone already on g1t: sign in, and the invite is accepted on return.
389 if (invite.hasAccount || invite.kind === "workspace") {
390 return (
391 <div className="space-y-4">
392 <div className="rounded-md border border-line bg-surface p-4 text-sm">
393 <p className="font-medium text-fg">{invite.address ?? invite.email ?? "This address"} has a g1t account.</p>
394 <p className="mt-1 text-muted">
395 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"}.
396 </p>
397 </div>
398 <ErrorText>{error}</ErrorText>
399 <ButtonLink to={`/login?next=${encodeURIComponent(back)}`}>Sign in to accept</ButtonLink>
400 </div>
401 );
402 }
403 return <SignUp loaded={loaded} error={error} />;
404}
405
406/** A code that cannot be used: why, and whom to ask. */
407function Dead({ loaded }: { loaded: Loaded }) {
408 const invite = loaded.invite;
409 const title = !invite
410 ? "This invite link does not work"
411 : invite.status === "expired"
412 ? "This invite has expired"
413 : invite.status === "revoked"
414 ? "This invite was withdrawn"
415 : "This invite has been used";
416 const from = invite?.invitedBy;
417 return (
418 <>
419 <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight">
420 <CircleAlert size={22} className="shrink-0 text-warn" />
421 {title}
422 </h1>
423 {invite ? (
424 <p className="mt-3 text-sm leading-6 text-muted">
425 It was {invite.workspace ? `an invite to join ${invite.workspace.name}` : invite.repository ? `an invite to ${invite.repository.name}` : "an invite to g1t"}
426 {invite.status === "expired" && `, and stopped working on ${LONG_DATE.format(new Date(invite.expiresAt))}`}.{" "}
427 {from ? "Ask the person who sent it for a new one." : "Ask for access and we will send a new one."}
428 </p>
429 ) : (
430 <p className="mt-3 text-sm leading-6 text-muted">{loaded.error}</p>
431 )}
432 {from && (
433 <div className="mt-5 flex items-center gap-3 rounded-lg border border-line bg-surface p-3">
434 <Avatar name={from.username} image={from.avatar} size={36} />
435 <p className="min-w-0 text-sm">
436 Ask <span className="font-medium text-fg">{from.name ?? from.username}</span>{" "}
437 <Link to={`/u/${from.username}`} className="font-mono text-muted hover:text-fg">
438 @{from.username}
439 </Link>{" "}
440 <span className="text-muted">for a new invite.</span>
441 </p>
442 </div>
443 )}
444 <div className="mt-8 flex flex-wrap gap-3">
445 <ButtonLink to="/register#request">Sign up</ButtonLink>
446 <ButtonLink to={loaded.viewer ? "/" : "/login"} variant="quiet">
447 {loaded.viewer ? "Go to g1t" : "Sign in"}
448 </ButtonLink>
449 </div>
450 </>
451 );
452}
453
454export default function Invite({ loaderData, actionData }: Route.ComponentProps) {
455 const { invite } = loaderData;
456 const error = (actionData && "error" in actionData ? actionData.error : null) ?? loaderData.acceptError;
457 const usable = invite?.status === "pending";
458 return (
459 <main className="mx-auto flex max-w-md flex-col px-4 pt-16 pb-12 sm:pt-20">
460 <Mark className="size-9" />
461 {usable ? (
462 <>
463 <div className="mt-8">
464 <Faces invite={invite} />
465 </div>
466 <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance text-fg-soft">
467 <Headline invite={invite} />
468 </h1>
469 <p className="mt-2 text-sm leading-6 text-muted">{about(invite, loaderData.viewer !== null)}</p>
470 <dl className="mt-5 space-y-1 text-sm">
471 {invite.invitedBy && (
472 <div className="flex gap-2">
473 <dt className="w-24 shrink-0 text-faint">From</dt>
474 <dd className="font-mono text-fg-soft">@{invite.invitedBy.username}</dd>
475 </div>
476 )}
477 {invite.email && (
478 <div className="flex gap-2">
479 <dt className="w-24 shrink-0 text-faint">For</dt>
480 <dd className="font-mono break-all text-fg-soft">{invite.address ?? invite.email}</dd>
481 </div>
482 )}
483 <div className="flex gap-2">
484 <dt className="w-24 shrink-0 text-faint">Works until</dt>
485 <dd className="text-fg-soft">{LONG_DATE.format(new Date(invite.expiresAt))}</dd>
486 </div>
487 </dl>
488 <div className="mt-8">
489 <Next loaded={loaderData} error={error} />
490 </div>
491 </>
492 ) : (
493 <Dead loaded={loaderData} />
494 )}
495 </main>
496 );
497}