Skip to content
477 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, invitePageCopy, 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
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 <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6">
200 <h2 id="sign-up" className="text-base font-semibold">
201 Create your account
202 </h2>
203 <p className="mt-1 text-sm text-muted">{copy.intro}</p>
204 {copy.confirmed && (
205 <p className="mt-4 flex items-start gap-2 rounded-md border border-success/40 bg-success/5 p-3 text-sm" role="status">
206 <MailCheck size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-success" />
207 <span>{copy.confirmed}</span>
208 </p>
209 )}
210 {loaded.github && (
211 <div className="mt-5">
212 <ContinueWithGithub href={github} />
213 <OrDivider />
214 </div>
215 )}
216 <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}>
217 <input type="hidden" name="intent" value="register" />
218 <Honeypot started={loaded.started} />
219 {loaded.proof && <input type="hidden" name="proof" value={loaded.proof} />}
220 {invite.address ? (
221 <Field label="Email" hint={copy.hint}>
222 <span className="relative block">
223 <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" />
224 {copy.confirmed ? (
225 <MailCheck size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-success" />
226 ) : (
227 <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" />
228 )}
229 </span>
230 </Field>
231 ) : (
232 <Field label="Email" hint={copy.hint}>
233 <Input name="email" type="email" autoComplete="email" required maxLength={254} />
234 </Field>
235 )}
236 <Field label="Username" hint="Lowercase letters, digits and hyphens. It is how you sign in and how others see you.">
237 <Input
238 name="username"
239 autoComplete="username"
240 required
241 autoFocus
242 maxLength={39}
243 defaultValue={loaded.suggestion}
244 pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*"
245 />
246 </Field>
247 <Field label="Password" hint="At least 10 characters.">
248 <Input name="password" type="password" autoComplete="new-password" required minLength={10} />
249 </Field>
250 <ErrorText>{error}</ErrorText>
251 <div className="pt-1 *:w-full">
252 <SubmitButton pending="Creating account…" match={{ intent: "register" }}>
253 {invite.workspace
254 ? `Create account and join ${invite.workspace.name}`
255 : invite.repository
256 ? "Create account and accept"
257 : "Create account"}
258 </SubmitButton>
259 </div>
260 </Form>
261 <p className="mt-5 text-center text-sm text-muted">
262 Already on g1t?{" "}
263 <Link to={`/login?next=${encodeURIComponent(back)}`} className="text-fg underline underline-offset-4">
264 Sign in to accept
265 </Link>
266 </p>
267 </section>
268 );
269}
270
271/** What to do next: join, sign in, sign out, or sign up. */
272function Next({ loaded, error }: { loaded: Loaded; error: string | null }) {
273 const invite = loaded.invite!;
274 const viewer = loaded.viewer;
275 const here = `/invite/${loaded.code}`;
276 const back = `${here}?accept=1`;
277 const joinable = invite.workspace !== null || invite.repository !== null;
278
279 if (viewer) {
280 const signedInAs = (
281 <p className="flex items-center gap-2 text-sm text-muted">
282 <Avatar name={viewer.username} image={viewer.avatar} size={20} />
283 Signed in as <span className="font-mono text-fg">{viewer.username}</span>
284 </p>
285 );
286 // Its own intent, which /logout ignores, so only its button says it is working.
287 const signOut = (label: string, variant: "primary" | "quiet") => (
288 <Form method="post" action={`/logout?next=${encodeURIComponent(here)}`}>
289 <input type="hidden" name="intent" value="sign-out" />
290 <SubmitButton variant={variant} pending="Signing out…" match={{ intent: "sign-out" }}>
291 {label}
292 </SubmitButton>
293 </Form>
294 );
295 if (invite.forViewer === false) {
296 return (
297 <div className="space-y-4">
298 {signedInAs}
299 <div className="rounded-md border border-warn/40 bg-warn/5 p-4 text-sm" role="status">
300 <p className="font-medium text-fg">This invite is for {invite.address ?? invite.email}, not this account.</p>
301 <p className="mt-1 text-muted">
302 Only an account with that address confirmed can use it. Sign out, then sign in or sign up with it.
303 </p>
304 </div>
305 <ErrorText>{error}</ErrorText>
306 <div className="flex flex-wrap gap-3">{signOut("Sign out and continue", "primary")}</div>
307 </div>
308 );
309 }
310 if (loaded.alreadyIn && invite.workspace) {
311 return (
312 <div className="space-y-4">
313 {signedInAs}
314 <p className="text-sm text-muted">You are in {invite.workspace.name} already.</p>
315 <ButtonLink to={`/${invite.workspace.slug}`}>Go to {invite.workspace.name}</ButtonLink>
316 </div>
317 );
318 }
319 if (!joinable) {
320 return (
321 <div className="space-y-4">
322 {signedInAs}
323 <div className="rounded-md border border-line bg-surface p-4 text-sm">
324 <p>
325 You already have a g1t account, <span className="font-mono">{viewer.username}</span>, so this invite has
326 nothing more to give you.
327 </p>
328 <p className="mt-2 text-muted">Pass it on to whoever it was meant for, or keep it for someone else.</p>
329 </div>
330 <div className="flex flex-wrap gap-3">{signOut("Sign out to use it", "quiet")}</div>
331 </div>
332 );
333 }
334 return (
335 <>
336 {/* "Not you?" posts this form, so it does not carry the accept form's intent. */}
337 <Form id="invite-sign-out" method="post" action={`/logout?next=${encodeURIComponent(here)}`} hidden>
338 <input type="hidden" name="intent" value="sign-out" />
339 </Form>
340 <Form method="post" className="space-y-4">
341 <input type="hidden" name="intent" value="accept" />
342 {signedInAs}
343 {invite.forViewer === null && (
344 <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p>
345 )}
346 <ErrorText>{error}</ErrorText>
347 <div className="flex flex-wrap items-center gap-3">
348 <SubmitButton pending={invite.workspace ? "Joining…" : "Accepting…"} match={{ intent: "accept" }}>
349 {joinLabel(invite)}
350 </SubmitButton>
351 <span className="text-sm text-muted">
352 Not you?{" "}
353 <SubmitButton
354 form="invite-sign-out"
355 pending="Signing out…"
356 match={{ intent: "sign-out" }}
357 className="inline-flex items-center gap-1 text-fg underline underline-offset-4 disabled:opacity-50"
358 >
359 Sign out
360 </SubmitButton>
361 </span>
362 </div>
363 </Form>
364 </>
365 );
366 }
367
368 // Someone already on g1t: sign in, and the invite is accepted on return.
369 if (invite.hasAccount || invite.kind === "workspace") {
370 return (
371 <div className="space-y-4">
372 <div className="rounded-md border border-line bg-surface p-4 text-sm">
373 <p className="font-medium text-fg">{invite.address ?? invite.email ?? "This address"} has a g1t account.</p>
374 <p className="mt-1 text-muted">
375 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"}.
376 </p>
377 </div>
378 <ErrorText>{error}</ErrorText>
379 <ButtonLink to={`/login?next=${encodeURIComponent(back)}`}>Sign in to accept</ButtonLink>
380 </div>
381 );
382 }
383 return <SignUp loaded={loaded} error={error} />;
384}
385
386/** A code that cannot be used: why, and whom to ask. */
387function Dead({ loaded }: { loaded: Loaded }) {
388 const invite = loaded.invite;
389 const title = !invite
390 ? "This invite link does not work"
391 : invite.status === "expired"
392 ? "This invite has expired"
393 : invite.status === "revoked"
394 ? "This invite was withdrawn"
395 : "This invite has been used";
396 const from = invite?.invitedBy;
397 return (
398 <>
399 <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight">
400 <CircleAlert size={22} className="shrink-0 text-warn" />
401 {title}
402 </h1>
403 {invite ? (
404 <p className="mt-3 text-sm leading-6 text-muted">
405 It was {invite.workspace ? `an invite to join ${invite.workspace.name}` : invite.repository ? `an invite to ${invite.repository.name}` : "an invite to g1t"}
406 {invite.status === "expired" && `, and stopped working on ${LONG_DATE.format(new Date(invite.expiresAt))}`}.{" "}
407 {from ? "Ask the person who sent it for a new one." : "Ask for access and we will send a new one."}
408 </p>
409 ) : (
410 <p className="mt-3 text-sm leading-6 text-muted">{loaded.error}</p>
411 )}
412 {from && (
413 <div className="mt-5 flex items-center gap-3 rounded-lg border border-line bg-surface p-3">
414 <Avatar name={from.username} image={from.avatar} size={36} />
415 <p className="min-w-0 text-sm">
416 Ask <span className="font-medium text-fg">{from.name ?? from.username}</span>{" "}
417 <Link to={`/u/${from.username}`} className="font-mono text-muted hover:text-fg">
418 @{from.username}
419 </Link>{" "}
420 <span className="text-muted">for a new invite.</span>
421 </p>
422 </div>
423 )}
424 <div className="mt-8 flex flex-wrap gap-3">
425 <ButtonLink to="/register#request">Sign up</ButtonLink>
426 <ButtonLink to={loaded.viewer ? "/" : "/login"} variant="quiet">
427 {loaded.viewer ? "Go to g1t" : "Sign in"}
428 </ButtonLink>
429 </div>
430 </>
431 );
432}
433
434export default function Invite({ loaderData, actionData }: Route.ComponentProps) {
435 const { invite } = loaderData;
436 const error = (actionData && "error" in actionData ? actionData.error : null) ?? loaderData.acceptError;
437 const usable = invite?.status === "pending";
438 return (
439 <main className="mx-auto flex max-w-md flex-col px-4 pt-16 pb-12 sm:pt-20">
440 <Mark className="size-9" />
441 {usable ? (
442 <>
443 <div className="mt-8">
444 <Faces invite={invite} />
445 </div>
446 <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance text-fg-soft">
447 <Headline invite={invite} />
448 </h1>
449 <p className="mt-2 text-sm leading-6 text-muted">{invitePageCopy(invite, loaderData.viewer !== null).about}</p>
450 <dl className="mt-5 space-y-1 text-sm">
451 {invite.invitedBy && (
452 <div className="flex gap-2">
453 <dt className="w-24 shrink-0 text-faint">From</dt>
454 <dd className="font-mono text-fg-soft">@{invite.invitedBy.username}</dd>
455 </div>
456 )}
457 {invite.email && (
458 <div className="flex gap-2">
459 <dt className="w-24 shrink-0 text-faint">For</dt>
460 <dd className="font-mono break-all text-fg-soft">{invite.address ?? invite.email}</dd>
461 </div>
462 )}
463 <div className="flex gap-2">
464 <dt className="w-24 shrink-0 text-faint">Works until</dt>
465 <dd className="text-fg-soft">{LONG_DATE.format(new Date(invite.expiresAt))}</dd>
466 </div>
467 </dl>
468 <div className="mt-8">
469 <Next loaded={loaderData} error={error} />
470 </div>
471 </>
472 ) : (
473 <Dead loaded={loaderData} />
474 )}
475 </main>
476 );
477}