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

165 lines7,184 bytesCodeBlame
1import { CircleAlert, Ticket } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
4import type { InvitePreview } from "@g1t/contracts";
5
6import type { Route } from "./+types/invite";
7import { page } from "../lib/meta";
8import { Mark } from "../components/logo";
9import { Avatar, Button, ButtonLink, ErrorText } from "../components/ui";
10import { identity } from "../lib/services.server";
11import { cleanCode } from "../lib/invites";
12import { clientKey } from "../lib/registration.server";
13import { assertSameOrigin, getViewer, requireUser } from "../lib/session.server";
14
15export function meta(args: Route.MetaArgs) {
16 return page(args, {
17 title: "You're invited · g1t",
18 description: "An invite to g1t, where people and agents ship software together.",
19 });
20}
21
22export async function loader({ request, context, params }: Route.LoaderArgs) {
23 const code = cleanCode(params.code);
24 const viewer = getViewer(context);
25 const checked = await identity.checkInvite(code, clientKey(request));
26 return {
27 code,
28 viewer: viewer ? { username: viewer.username, avatar: viewer.avatar ?? null } : null,
29 invite: checked.ok ? checked.value : null,
30 error: checked.ok ? null : checked.error.message,
31 };
32}
33
34export async function action({ request, context, params }: Route.ActionArgs) {
35 assertSameOrigin(request);
36 const user = requireUser(context, request);
37 const result = await identity.acceptInvite(user, cleanCode(params.code));
38 if (!result.ok) return data({ error: result.error.message }, { status: 422 });
39 throw redirect(`/${result.value}`);
40}
41
42function Sender({ invite }: { invite: InvitePreview }) {
43 const from = invite.invitedBy;
44 return (
45 <div className="flex items-center gap-3">
46 {from ? (
47 <Avatar name={from.username} image={from.avatar} size={44} />
48 ) : (
49 <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
50 <Ticket size={20} />
51 </span>
52 )}
53 {invite.workspace && (
54 <>
55 <span className="text-faint">→</span>
56 <Avatar name={invite.workspace.slug} image={invite.workspace.avatar} size={44} square />
57 </>
58 )}
59 </div>
60 );
61}
62
63function headline(invite: InvitePreview): string {
64 const from = invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team";
65 return invite.workspace ? `${from} invited you to ${invite.workspace.name}` : `${from} invited you to g1t`;
66}
67
68const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
69
70export default function Invite({ loaderData, actionData }: Route.ComponentProps) {
71 const { invite, viewer, code, error } = loaderData;
72 const here = `/invite/${code}`;
73 return (
74 <main className="mx-auto flex max-w-md flex-col px-4 pt-20 pb-10">
75 <Mark className="size-9" />
76 {invite ? (
77 <>
78 <div className="mt-8">
79 <Sender invite={invite} />
80 </div>
81 <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance">{headline(invite)}</h1>
82 <p className="mt-2 text-sm leading-6 text-muted">
83 {invite.workspace
84 ? `g1t is where people and agents ship software together. Accepting ${
85 invite.kind === "account" ? "makes your account and " : ""
86 }joins you to the ${invite.workspace.slug} workspace.`
87 : "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."}
88 </p>
89 <dl className="mt-5 space-y-1 text-sm">
90 {invite.invitedBy && (
91 <div className="flex gap-2">
92 <dt className="w-20 shrink-0 text-faint">From</dt>
93 <dd className="font-mono text-fg-soft">@{invite.invitedBy.username}</dd>
94 </div>
95 )}
96 {invite.email && (
97 <div className="flex gap-2">
98 <dt className="w-20 shrink-0 text-faint">For</dt>
99 <dd className="font-mono text-fg-soft">{invite.email}</dd>
100 </div>
101 )}
102 <div className="flex gap-2">
103 <dt className="w-20 shrink-0 text-faint">Works until</dt>
104 <dd className="text-fg-soft">{LONG_DATE.format(new Date(invite.expiresAt))}</dd>
105 </div>
106 </dl>
107
108 <div className="mt-8">
109 {viewer ? (
110 invite.workspace ? (
111 <Form method="post" className="space-y-3">
112 <p className="flex items-center gap-2 text-sm text-muted">
113 <Avatar name={viewer.username} image={viewer.avatar} size={20} />
114 Signed in as <span className="font-mono text-fg">{viewer.username}</span>
115 </p>
116 <ErrorText>{actionData?.error}</ErrorText>
117 <div className="*:w-full">
118 <Button type="submit">Join {invite.workspace.slug}</Button>
119 </div>
120 </Form>
121 ) : (
122 <div className="rounded-md border border-line bg-surface p-4 text-sm">
123 <p>
124 You already have a g1t account, <span className="font-mono">{viewer.username}</span>, so this
125 invite has nothing more to give you.
126 </p>
127 <p className="mt-2 text-muted">Pass it on to whoever it was meant for, or keep it for someone else.</p>
128 </div>
129 )
130 ) : invite.kind === "workspace" ? (
131 <div className="space-y-3">
132 <ButtonLink to={`/login?next=${encodeURIComponent(here)}`}>Sign in to join</ButtonLink>
133 <p className="text-sm text-muted">This invite is for an address that already has a g1t account.</p>
134 </div>
135 ) : (
136 <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
137 <ButtonLink to={`/register?invite=${encodeURIComponent(code)}`}>Accept invite</ButtonLink>
138 <p className="text-sm text-muted">
139 Already on g1t?{" "}
140 <Link to={`/login?next=${encodeURIComponent(here)}`} className="text-fg underline underline-offset-4">
141 Sign in
142 </Link>
143 </p>
144 </div>
145 )}
146 </div>
147 </>
148 ) : (
149 <>
150 <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight">
151 <CircleAlert size={22} className="text-warn" />
152 This invite cannot be used
153 </h1>
154 <p className="mt-3 text-sm leading-6 text-muted">{error}</p>
155 <div className="mt-8 flex flex-wrap gap-3">
156 <ButtonLink to="/register">Request access</ButtonLink>
157 <ButtonLink to={viewer ? "/" : "/login"} variant="quiet">
158 {viewer ? "Go to g1t" : "Sign in"}
159 </ButtonLink>
160 </div>
161 </>
162 )}
163 </main>
164 );
165}