g1t/apps/web/app/routes/invite.tsx

477 lines20,566 bytesCodeBlame
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 const lands = invite ? landingFor(invite) : null;
40
41 if (viewer && invite) {
42 // Used by this person already: just made the account with it (through
43 // GitHub), or an old link opened again.
44 if (invite.status === "redeemed" && invite.forViewer) throw landIn(request, invite, [], accepting);
45 // Back from signing in to accept an invite sent to their address.
46 if (accepting && invite.status === "pending" && invite.forViewer === true && lands && !alreadyIn(viewer, invite)) {
47 const accepted = await identity.acceptInvite(viewer, code);
48 if (accepted.ok) throw landIn(request, invite, [], true);
49 return { ...base(), acceptError: accepted.error.message };
50 }
51 }
52
53 function base() {
54 return {
55 code,
56 invite,
57 error: checked.ok ? null : checked.error.message,
58 viewer: viewer ? { username: viewer.username, avatar: viewer.avatar ?? null } : null,
59 alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false,
60 github: false,
61 suggestion: suggestUsername(invite?.address),
62 started: Date.now(),
63 acceptError: null as string | null,
64 };
65 }
66 const signingUp = !viewer && invite?.status === "pending" && invite.kind === "account" && !invite.hasAccount;
67 return { ...base(), github: signingUp ? await githubSignInEnabled() : false };
68}
69
70/** Whether the viewer is in what the invite gives already. */
71function alreadyIn(viewer: User, invite: InvitePreview): boolean {
72 if (invite.workspace) return roleIn(viewer, invite.workspace.slug) !== null;
73 return false;
74}
75
76/**
77 * The redirect into what the invite gave, with the session (when one was
78 * just made), the welcome for that first view, and the workspace made the
79 * one the sidebar is about.
80 */
81function landIn(request: Request, invite: InvitePreview, cookies: string[], welcome: boolean): Response {
82 const secure = new URL(request.url).protocol === "https:";
83 const target = landingFor(invite);
84 const headers = new Headers();
85 for (const cookie of cookies) headers.append("set-cookie", cookie);
86 if (target && welcome) headers.append("set-cookie", welcomeCookie(target, secure));
87 if (invite.workspace) headers.append("set-cookie", rememberWorkspace(invite.workspace.slug, secure));
88 return redirect(target ? `/${target}` : "/", { headers });
89}
90
91export async function action({ request, context, params }: Route.ActionArgs) {
92 assertSameOrigin(request);
93 const code = cleanCode(params.code);
94 const form = await request.formData();
95 const client = clientKey(request);
96 const checked = await identity.checkInvite(code, client, { viewer: getViewer(context) });
97 if (!checked.ok) return data({ error: checked.error.message }, { status: 422 });
98 const invite = checked.value;
99
100 if (form.get("intent") === "register") {
101 if (looksAutomated(form)) return data({ error: "Something went wrong. Try again." }, { status: 422 });
102 // An invite for one address makes the account with that address,
103 // whatever the form sent.
104 const email = invite.address ?? String(form.get("email") ?? "");
105 const result = await identity.register(
106 String(form.get("username") ?? ""),
107 email,
108 String(form.get("password") ?? ""),
109 code,
110 client,
111 );
112 if (!result.ok) return data({ error: result.error.message }, { status: 422 });
113 throw landIn(request, invite, [startSession(result.value.sessionToken)], true);
114 }
115
116 const user = requireUser(context, request);
117 const result = await identity.acceptInvite(user, code);
118 // Said with a 200, so the page loads again and shows the invite as it
119 // now stands (used up, revoked) beside the reason: after a 4xx answer
120 // React Router keeps the page's data as it was.
121 if (!result.ok) return { error: result.error.message };
122 throw landIn(request, invite, [], true);
123}
124
125/** Who sent it, and the workspace it joins, as faces. */
126function Faces({ invite }: { invite: InvitePreview }) {
127 const from = invite.invitedBy;
128 return (
129 <div className="flex items-center gap-3">
130 {from ? (
131 <Avatar name={from.username} image={from.avatar} size={44} />
132 ) : (
133 <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
134 <Ticket size={20} />
135 </span>
136 )}
137 {invite.workspace && (
138 <>
139 <span className="text-faint">→</span>
140 <Avatar name={invite.workspace.slug} image={invite.workspace.avatar} size={44} square />
141 </>
142 )}
143 </div>
144 );
145}
146
147function senderName(invite: InvitePreview): string {
148 return invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team";
149}
150
151/** "Chase Pierce invited you to join Flagon, Inc. on g1t", with the place in bold. */
152function Headline({ invite }: { invite: InvitePreview }) {
153 const from = senderName(invite);
154 if (invite.workspace) {
155 return (
156 <>
157 {from} invited you to join <strong className="font-semibold text-fg">{invite.workspace.name}</strong> on g1t
158 </>
159 );
160 }
161 if (invite.repository) {
162 return (
163 <>
164 {from} invited you to collaborate on <strong className="font-mono font-semibold text-fg">{invite.repository.name}</strong>
165 </>
166 );
167 }
168 return <>{from} invited you to g1t</>;
169}
170
171function about(invite: InvitePreview, signedIn: boolean): string {
172 const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account";
173 if (invite.workspace) {
174 return `g1t is where people and agents ship software together. ${
175 signingUp ? "Make your account below and you join" : "Accepting joins you to"
176 } ${invite.workspace.name} as a member.`;
177 }
178 if (invite.repository) {
179 return `g1t is where people and agents ship software together. ${
180 signingUp ? "Make your account below and you get" : "Accepting gives you"
181 } the ${invite.repository.role} role on ${invite.repository.name}.`;
182 }
183 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.";
184}
185
186/** What accepting is called on its button. */
187function joinLabel(invite: InvitePreview): string {
188 if (invite.workspace) return `Join ${invite.workspace.name}`;
189 if (invite.repository) return `Accept access to ${invite.repository.name}`;
190 return "Accept invite";
191}
192
193const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
194
195type Loaded = Route.ComponentProps["loaderData"];
196
197/** Signing up, on this page: the address the invite was sent to, a username, a password. */
198function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) {
199 const invite = loaded.invite!;
200 const here = `/invite/${loaded.code}`;
201 const back = `${here}?accept=1`;
202 const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`;
203 return (
204 <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6">
205 <h2 id="sign-up" className="text-base font-semibold">
206 Create your account
207 </h2>
208 <p className="mt-1 text-sm text-muted">
209 {invite.workspace
210 ? `You land in ${invite.workspace.name} as soon as it is made.`
211 : invite.repository
212 ? `You land in ${invite.repository.name} as soon as it is made.`
213 : "It takes a minute."}
214 </p>
215 {loaded.github && (
216 <div className="mt-5">
217 <ContinueWithGithub href={github} />
218 <OrDivider />
219 </div>
220 )}
221 <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}>
222 <input type="hidden" name="intent" value="register" />
223 <Honeypot started={loaded.started} />
224 {invite.address ? (
225 <Field label="Email" hint="Your invite was sent here, so this address is confirmed already.">
226 <span className="relative block">
227 <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" />
228 <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" />
229 </span>
230 </Field>
231 ) : (
232 <Field label="Email" hint="We send a link to confirm it.">
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">Request access</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">{about(invite, loaderData.viewer !== null)}</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}