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