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

464 lines19,667 bytesCodeBlame
1import { CircleAlert, Lock, Ticket } from "lucide-react";
2import { Form, Link, data, redirect, useNavigation } 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, Button, ButtonLink, ErrorText, Field, Input } 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 if (!result.ok) return data({ error: result.error.message }, { status: 422 });
119 throw landIn(request, invite, [], true);
120}
121
122/** Who sent it, and the workspace it joins, as faces. */
123function Faces({ invite }: { invite: InvitePreview }) {
124 const from = invite.invitedBy;
125 return (
126 <div className="flex items-center gap-3">
127 {from ? (
128 <Avatar name={from.username} image={from.avatar} size={44} />
129 ) : (
130 <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
131 <Ticket size={20} />
132 </span>
133 )}
134 {invite.workspace && (
135 <>
136 <span className="text-faint">→</span>
137 <Avatar name={invite.workspace.slug} image={invite.workspace.avatar} size={44} square />
138 </>
139 )}
140 </div>
141 );
142}
143
144function senderName(invite: InvitePreview): string {
145 return invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team";
146}
147
148/** "Chase Pierce invited you to join Flagon, Inc. on g1t", with the place in bold. */
149function Headline({ invite }: { invite: InvitePreview }) {
150 const from = senderName(invite);
151 if (invite.workspace) {
152 return (
153 <>
154 {from} invited you to join <strong className="font-semibold text-fg">{invite.workspace.name}</strong> on g1t
155 </>
156 );
157 }
158 if (invite.repository) {
159 return (
160 <>
161 {from} invited you to collaborate on <strong className="font-mono font-semibold text-fg">{invite.repository.name}</strong>
162 </>
163 );
164 }
165 return <>{from} invited you to g1t</>;
166}
167
168function about(invite: InvitePreview, signedIn: boolean): string {
169 const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account";
170 if (invite.workspace) {
171 return `g1t is where people and agents ship software together. ${
172 signingUp ? "Make your account below and you join" : "Accepting joins you to"
173 } ${invite.workspace.name} as a member.`;
174 }
175 if (invite.repository) {
176 return `g1t is where people and agents ship software together. ${
177 signingUp ? "Make your account below and you get" : "Accepting gives you"
178 } the ${invite.repository.role} role on ${invite.repository.name}.`;
179 }
180 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.";
181}
182
183/** What accepting is called on its button. */
184function joinLabel(invite: InvitePreview): string {
185 if (invite.workspace) return `Join ${invite.workspace.name}`;
186 if (invite.repository) return `Accept access to ${invite.repository.name}`;
187 return "Accept invite";
188}
189
190const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
191
192type Loaded = Route.ComponentProps["loaderData"];
193
194/** Signing up, on this page: the address the invite was sent to, a username, a password. */
195function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) {
196 const invite = loaded.invite!;
197 const busy = useNavigation().state === "submitting";
198 const here = `/invite/${loaded.code}`;
199 const back = `${here}?accept=1`;
200 const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`;
201 return (
202 <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6">
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">
207 {invite.workspace
208 ? `You land in ${invite.workspace.name} as soon as it is made.`
209 : invite.repository
210 ? `You land in ${invite.repository.name} as soon as it is made.`
211 : "It takes a minute."}
212 </p>
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 {invite.address ? (
223 <Field label="Email" hint="Your invite was sent here, so this address is confirmed already.">
224 <span className="relative block">
225 <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" />
226 <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" />
227 </span>
228 </Field>
229 ) : (
230 <Field label="Email" hint="We send a link to confirm it.">
231 <Input name="email" type="email" autoComplete="email" required maxLength={254} />
232 </Field>
233 )}
234 <Field label="Username" hint="Lowercase letters, digits and hyphens. It is how you sign in and how others see you.">
235 <Input
236 name="username"
237 autoComplete="username"
238 required
239 autoFocus
240 maxLength={39}
241 defaultValue={loaded.suggestion}
242 pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*"
243 />
244 </Field>
245 <Field label="Password" hint="At least 10 characters.">
246 <Input name="password" type="password" autoComplete="new-password" required minLength={10} />
247 </Field>
248 <ErrorText>{error}</ErrorText>
249 <div className="pt-1 *:w-full">
250 <Button type="submit" disabled={busy}>
251 {invite.workspace
252 ? `Create account and join ${invite.workspace.name}`
253 : invite.repository
254 ? "Create account and accept"
255 : "Create account"}
256 </Button>
257 </div>
258 </Form>
259 <p className="mt-5 text-center text-sm text-muted">
260 Already on g1t?{" "}
261 <Link to={`/login?next=${encodeURIComponent(back)}`} className="text-fg underline underline-offset-4">
262 Sign in to accept
263 </Link>
264 </p>
265 </section>
266 );
267}
268
269/** What to do next: join, sign in, sign out, or sign up. */
270function Next({ loaded, error }: { loaded: Loaded; error: string | null }) {
271 const invite = loaded.invite!;
272 const viewer = loaded.viewer;
273 const here = `/invite/${loaded.code}`;
274 const back = `${here}?accept=1`;
275 const joinable = invite.workspace !== null || invite.repository !== null;
276
277 if (viewer) {
278 const signedInAs = (
279 <p className="flex items-center gap-2 text-sm text-muted">
280 <Avatar name={viewer.username} image={viewer.avatar} size={20} />
281 Signed in as <span className="font-mono text-fg">{viewer.username}</span>
282 </p>
283 );
284 const signOut = (label: string, variant: "primary" | "quiet") => (
285 <Form method="post" action={`/logout?next=${encodeURIComponent(here)}`}>
286 <Button type="submit" variant={variant}>
287 {label}
288 </Button>
289 </Form>
290 );
291 if (invite.forViewer === false) {
292 return (
293 <div className="space-y-4">
294 {signedInAs}
295 <div className="rounded-md border border-warn/40 bg-warn/5 p-4 text-sm" role="status">
296 <p className="font-medium text-fg">This invite is for {invite.address ?? invite.email}, not this account.</p>
297 <p className="mt-1 text-muted">
298 Only an account with that address confirmed can use it. Sign out, then sign in or sign up with it.
299 </p>
300 </div>
301 <ErrorText>{error}</ErrorText>
302 <div className="flex flex-wrap gap-3">{signOut("Sign out and continue", "primary")}</div>
303 </div>
304 );
305 }
306 if (loaded.alreadyIn && invite.workspace) {
307 return (
308 <div className="space-y-4">
309 {signedInAs}
310 <p className="text-sm text-muted">You are in {invite.workspace.name} already.</p>
311 <ButtonLink to={`/${invite.workspace.slug}`}>Go to {invite.workspace.name}</ButtonLink>
312 </div>
313 );
314 }
315 if (!joinable) {
316 return (
317 <div className="space-y-4">
318 {signedInAs}
319 <div className="rounded-md border border-line bg-surface p-4 text-sm">
320 <p>
321 You already have a g1t account, <span className="font-mono">{viewer.username}</span>, so this invite has
322 nothing more to give you.
323 </p>
324 <p className="mt-2 text-muted">Pass it on to whoever it was meant for, or keep it for someone else.</p>
325 </div>
326 <div className="flex flex-wrap gap-3">{signOut("Sign out to use it", "quiet")}</div>
327 </div>
328 );
329 }
330 return (
331 <Form method="post" className="space-y-4">
332 <input type="hidden" name="intent" value="accept" />
333 {signedInAs}
334 {invite.forViewer === null && (
335 <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p>
336 )}
337 <ErrorText>{error}</ErrorText>
338 <div className="flex flex-wrap items-center gap-3">
339 <Button type="submit">{joinLabel(invite)}</Button>
340 <span className="text-sm text-muted">
341 Not you?{" "}
342 <button
343 type="submit"
344 formAction={`/logout?next=${encodeURIComponent(here)}`}
345 className="text-fg underline underline-offset-4"
346 >
347 Sign out
348 </button>
349 </span>
350 </div>
351 </Form>
352 );
353 }
354
355 // Someone already on g1t: sign in, and the invite is accepted on return.
356 if (invite.hasAccount || invite.kind === "workspace") {
357 return (
358 <div className="space-y-4">
359 <div className="rounded-md border border-line bg-surface p-4 text-sm">
360 <p className="font-medium text-fg">{invite.address ?? invite.email ?? "This address"} has a g1t account.</p>
361 <p className="mt-1 text-muted">
362 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"}.
363 </p>
364 </div>
365 <ErrorText>{error}</ErrorText>
366 <ButtonLink to={`/login?next=${encodeURIComponent(back)}`}>Sign in to accept</ButtonLink>
367 </div>
368 );
369 }
370 return <SignUp loaded={loaded} error={error} />;
371}
372
373/** A code that cannot be used: why, and whom to ask. */
374function Dead({ loaded }: { loaded: Loaded }) {
375 const invite = loaded.invite;
376 const title = !invite
377 ? "This invite link does not work"
378 : invite.status === "expired"
379 ? "This invite has expired"
380 : invite.status === "revoked"
381 ? "This invite was withdrawn"
382 : "This invite has been used";
383 const from = invite?.invitedBy;
384 return (
385 <>
386 <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight">
387 <CircleAlert size={22} className="shrink-0 text-warn" />
388 {title}
389 </h1>
390 {invite ? (
391 <p className="mt-3 text-sm leading-6 text-muted">
392 It was {invite.workspace ? `an invite to join ${invite.workspace.name}` : invite.repository ? `an invite to ${invite.repository.name}` : "an invite to g1t"}
393 {invite.status === "expired" && `, and stopped working on ${LONG_DATE.format(new Date(invite.expiresAt))}`}.{" "}
394 {from ? "Ask the person who sent it for a new one." : "Ask for access and we will send a new one."}
395 </p>
396 ) : (
397 <p className="mt-3 text-sm leading-6 text-muted">{loaded.error}</p>
398 )}
399 {from && (
400 <div className="mt-5 flex items-center gap-3 rounded-lg border border-line bg-surface p-3">
401 <Avatar name={from.username} image={from.avatar} size={36} />
402 <p className="min-w-0 text-sm">
403 Ask <span className="font-medium text-fg">{from.name ?? from.username}</span>{" "}
404 <Link to={`/u/${from.username}`} className="font-mono text-muted hover:text-fg">
405 @{from.username}
406 </Link>{" "}
407 <span className="text-muted">for a new invite.</span>
408 </p>
409 </div>
410 )}
411 <div className="mt-8 flex flex-wrap gap-3">
412 <ButtonLink to="/register#request">Request access</ButtonLink>
413 <ButtonLink to={loaded.viewer ? "/" : "/login"} variant="quiet">
414 {loaded.viewer ? "Go to g1t" : "Sign in"}
415 </ButtonLink>
416 </div>
417 </>
418 );
419}
420
421export default function Invite({ loaderData, actionData }: Route.ComponentProps) {
422 const { invite } = loaderData;
423 const error = (actionData && "error" in actionData ? actionData.error : null) ?? loaderData.acceptError;
424 const usable = invite?.status === "pending";
425 return (
426 <main className="mx-auto flex max-w-md flex-col px-4 pt-16 pb-12 sm:pt-20">
427 <Mark className="size-9" />
428 {usable ? (
429 <>
430 <div className="mt-8">
431 <Faces invite={invite} />
432 </div>
433 <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance text-fg-soft">
434 <Headline invite={invite} />
435 </h1>
436 <p className="mt-2 text-sm leading-6 text-muted">{about(invite, loaderData.viewer !== null)}</p>
437 <dl className="mt-5 space-y-1 text-sm">
438 {invite.invitedBy && (
439 <div className="flex gap-2">
440 <dt className="w-24 shrink-0 text-faint">From</dt>
441 <dd className="font-mono text-fg-soft">@{invite.invitedBy.username}</dd>
442 </div>
443 )}
444 {invite.email && (
445 <div className="flex gap-2">
446 <dt className="w-24 shrink-0 text-faint">For</dt>
447 <dd className="font-mono break-all text-fg-soft">{invite.address ?? invite.email}</dd>
448 </div>
449 )}
450 <div className="flex gap-2">
451 <dt className="w-24 shrink-0 text-faint">Works until</dt>
452 <dd className="text-fg-soft">{LONG_DATE.format(new Date(invite.expiresAt))}</dd>
453 </div>
454 </dl>
455 <div className="mt-8">
456 <Next loaded={loaderData} error={error} />
457 </div>
458 </>
459 ) : (
460 <Dead loaded={loaderData} />
461 )}
462 </main>
463 );
464}