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

464 lines19,667 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1import { CircleAlert, Lock, Ticket } from "lucide-react";
2import { Form, Link, data, redirect, useNavigation } from "react-router";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look3
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas4import type { InvitePreview, User } from "@g1t/contracts";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look5
6import type { Route } from "./+types/invite";
7import { page } from "../lib/meta";
8import { Mark } from "../components/logo";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas9import { 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";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look13import { identity } from "../lib/services.server";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas14import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look15import { clientKey } from "../lib/registration.server";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas16import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server";
17import { rememberWorkspace } from "../lib/workspace-choice";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look18
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
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas26/**
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 */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look33export async function loader({ request, context, params }: Route.LoaderArgs) {
34 const code = cleanCode(params.code);
35 const viewer = getViewer(context);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas36 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 });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look89}
90
91export async function action({ request, context, params }: Route.ActionArgs) {
92 assertSameOrigin(request);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas93 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look116 const user = requireUser(context, request);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas117 const result = await identity.acceptInvite(user, code);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look118 if (!result.ok) return data({ error: result.error.message }, { status: 422 });
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas119 throw landIn(request, invite, [], true);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look120}
121
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas122/** Who sent it, and the workspace it joins, as faces. */
123function Faces({ invite }: { invite: InvitePreview }) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look124 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
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas144function senderName(invite: InvitePreview): string {
145 return invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look146}
147
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas148/** "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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look190const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
191
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas192type 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look421export default function Invite({ loaderData, actionData }: Route.ComponentProps) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas422 const { invite } = loaderData;
423 const error = (actionData && "error" in actionData ? actionData.error : null) ?? loaderData.acceptError;
424 const usable = invite?.status === "pending";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look425 return (
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas426 <main className="mx-auto flex max-w-md flex-col px-4 pt-16 pb-12 sm:pt-20">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look427 <Mark className="size-9" />
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas428 {usable ? (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look429 <>
430 <div className="mt-8">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas431 <Faces invite={invite} />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look432 </div>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas433 <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>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look437 <dl className="mt-5 space-y-1 text-sm">
438 {invite.invitedBy && (
439 <div className="flex gap-2">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas440 <dt className="w-24 shrink-0 text-faint">From</dt>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look441 <dd className="font-mono text-fg-soft">@{invite.invitedBy.username}</dd>
442 </div>
443 )}
444 {invite.email && (
445 <div className="flex gap-2">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas446 <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>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look448 </div>
449 )}
450 <div className="flex gap-2">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas451 <dt className="w-24 shrink-0 text-faint">Works until</dt>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look452 <dd className="text-fg-soft">{LONG_DATE.format(new Date(invite.expiresAt))}</dd>
453 </div>
454 </dl>
455 <div className="mt-8">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas456 <Next loaded={loaderData} error={error} />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look457 </div>
458 </>
459 ) : (
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas460 <Dead loaded={loaderData} />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look461 )}
462 </main>
463 );
464}