Skip to content
319 linesCodeBlameRaw
1import { CheckCircle2, Ticket } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
4import type { InvitePreview } from "@g1t/contracts";
5import { USERNAME_PATTERN } from "@g1t/contracts";
6
7import type { Route } from "./+types/register";
8import { page } from "../lib/meta";
9import { AuthCard } from "../components/auth-card";
10import { ContinueWithGithub, OrDivider } from "../components/github";
11import { Honeypot } from "../components/honeypot";
12import { githubSignInEnabled } from "../lib/github.server";
13import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../components/ui";
14import { identity } from "../lib/services.server";
15import { cleanCode, cleanProof, invitePath, looksAutomated, sharedDomainsHint, sharedInviteLine } from "../lib/invites";
16import { clientKey, registrationMode } from "../lib/registration.server";
17import {
18 assertSameOrigin,
19 getViewer,
20 nextPath,
21 startSession,
22} from "../lib/session.server";
23
24export function meta(args: Route.MetaArgs) {
25 return page(args, { title: "Create an account · g1t" });
26}
27
28export async function loader({ request, context }: Route.LoaderArgs) {
29 if (getViewer(context)) throw redirect(nextPath(request));
30 const inviteOnly = (await registrationMode()) !== "open";
31 const code = cleanCode(new URL(request.url).searchParams.get("invite"));
32 // The invite is checked before anything else on the page: without a good
33 // one, there is no form to fill in.
34 const checked = code ? await identity.checkInvite(code, clientKey(request)) : null;
35 const invite: InvitePreview | null = checked?.ok ? checked.value : null;
36 // A good invite is used on its own page, which knows whom it is from and
37 // where it leads; it signs up, joins and lands in one go. A shared link
38 // for a group signs up here: it joins nothing, and the form says which
39 // group it is for.
40 // The invite email's proof goes with it, so the address it proves stays
41 // confirmed there.
42 if (invite && code && !invite.sharedLabel) {
43 throw redirect(invitePath(code, cleanProof(new URL(request.url).searchParams.get("proof"))));
44 }
45 // Signing up with GitHub carries the invite code and `next` through it.
46 const params = new URLSearchParams();
47 if (code) params.set("invite", code);
48 if (nextPath(request) !== "/") params.set("next", nextPath(request));
49 const query = params.toString();
50 return {
51 inviteOnly,
52 code,
53 invite,
54 inviteError: checked && !checked.ok ? checked.error.message : null,
55 github: await githubSignInEnabled(),
56 githubUrl: query ? `/auth/github?${query}` : "/auth/github",
57 started: Date.now(),
58 };
59}
60
61export async function action({ request }: Route.ActionArgs) {
62 assertSameOrigin(request);
63 const form = await request.formData();
64 const client = clientKey(request);
65
66 if (form.get("intent") === "request-access") {
67 // A bot gets the same thanks as a person, and nothing is kept.
68 if (looksAutomated(form)) return { requested: true };
69 const result = await identity.requestAccess(
70 String(form.get("email") ?? ""),
71 String(form.get("about") ?? ""),
72 client,
73 );
74 if (!result.ok) return data({ requestError: result.error.message }, { status: 422 });
75 return { requested: true };
76 }
77
78 const code = cleanCode(String(form.get("invite") ?? ""));
79 if (looksAutomated(form)) return data({ error: "Something went wrong. Try again." }, { status: 422 });
80 // The invite first, so its problem is the one shown.
81 let joins: string | null = null;
82 if (code) {
83 const checked = await identity.checkInvite(code, client);
84 if (!checked.ok) return data({ error: checked.error.message }, { status: 422 });
85 joins = checked.value.workspace?.slug ?? null;
86 }
87 const result = await identity.register(
88 String(form.get("username") ?? ""),
89 String(form.get("email") ?? ""),
90 String(form.get("password") ?? ""),
91 code || null,
92 client,
93 );
94 if (!result.ok) return data({ error: result.error.message }, { status: 422 });
95 // An invite to a workspace lands in it; anything else goes where it was going.
96 const next = nextPath(request);
97 throw redirect(joins && next === "/" ? `/${joins}` : next, {
98 headers: { "set-cookie": startSession(result.value.sessionToken) },
99 });
100}
101
102/** Who sent the invite and what it joins, above the form; for a shared link, the group it is for. */
103function InvitedBy({ invite }: { invite: InvitePreview }) {
104 const from = invite.invitedBy;
105 const group = sharedInviteLine(invite.sharedLabel);
106 if (group) {
107 return (
108 <div className="mb-6 flex items-center gap-3 rounded-lg border border-accent/30 bg-accent/5 p-3" role="status">
109 <span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
110 <Ticket size={18} />
111 </span>
112 <p className="min-w-0 text-sm leading-5 font-medium text-fg">{group}</p>
113 </div>
114 );
115 }
116 return (
117 <div className="mb-6 flex items-center gap-3 rounded-lg border border-accent/30 bg-accent/5 p-3" role="status">
118 {invite.workspace ? (
119 <Avatar name={invite.workspace.slug} image={invite.workspace.avatar} size={36} square />
120 ) : from ? (
121 <Avatar name={from.username} image={from.avatar} size={36} />
122 ) : (
123 <span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
124 <Ticket size={18} />
125 </span>
126 )}
127 <p className="min-w-0 text-sm leading-5">
128 {from ? (
129 <>
130 <span className="font-medium text-fg">{from.name ?? from.username}</span>
131 {from.name && <span className="font-mono text-muted"> @{from.username}</span>}
132 </>
133 ) : (
134 <span className="font-medium text-fg">The g1t team</span>
135 )}{" "}
136 <span className="text-muted">
137 invited you
138 {invite.workspace ? (
139 <>
140 {" "}to <span className="font-medium text-fg">{invite.workspace.name}</span>
141 </>
142 ) : (
143 " to g1t"
144 )}
145 .
146 </span>
147 </p>
148 </div>
149 );
150}
151
152function SignUpForm({
153 loaderData,
154 error,
155}: {
156 loaderData: Route.ComponentProps["loaderData"];
157 error: string | undefined;
158}) {
159 const { invite, code, inviteOnly } = loaderData;
160 return (
161 <>
162 {invite && <InvitedBy invite={invite} />}
163 {loaderData.github && (
164 <>
165 <ContinueWithGithub href={loaderData.githubUrl} />
166 <OrDivider />
167 </>
168 )}
169 <Form method="post" className="relative space-y-4">
170 <Honeypot started={loaderData.started} />
171 {inviteOnly || code ? (
172 <Field label="Invite code" hint={invite ? undefined : "From your invite link or email."}>
173 <span className="block font-mono">
174 <Input name="invite" defaultValue={code} required={inviteOnly} spellCheck={false} autoCapitalize="none" />
175 </span>
176 </Field>
177 ) : null}
178 <Field
179 label="Username"
180 hint="Letters, digits and single hyphens. It is how you sign in, and how others see you, in the case you type it."
181 >
182 <Input
183 name="username"
184 autoComplete="username"
185 required
186 autoFocus
187 maxLength={39}
188 pattern={USERNAME_PATTERN}
189 />
190 </Field>
191 <Field
192 label="Email"
193 hint={invite?.email ? `This invite is for ${invite.email}. Use that address.` : sharedDomainsHint(invite?.sharedDomains)}
194 >
195 <Input name="email" type="email" autoComplete="email" required />
196 </Field>
197 <Field label="Password" hint="At least 10 characters.">
198 <Input
199 name="password"
200 type="password"
201 autoComplete="new-password"
202 required
203 minLength={10}
204 />
205 </Field>
206 <ErrorText>{error}</ErrorText>
207 <div className="pt-2 *:w-full">
208 <SubmitButton pending="Creating account…">
209 {invite?.workspace ? `Create account and join ${invite.workspace.slug}` : "Create account"}
210 </SubmitButton>
211 </div>
212 </Form>
213 </>
214 );
215}
216
217/** Invite-only and no good code: enter one, or ask for access. */
218function InviteOnly({
219 loaderData,
220 actionData,
221}: {
222 loaderData: Route.ComponentProps["loaderData"];
223 actionData: Route.ComponentProps["actionData"];
224}) {
225 const requested = actionData && "requested" in actionData;
226 return (
227 <div className="space-y-8">
228 <section id="invite" className="scroll-mt-24">
229 <h2 className="text-sm font-medium">Have an invite?</h2>
230 <p className="mt-1 text-sm text-muted">Paste the code or link from your invite.</p>
231 <Form method="get" className="mt-3 flex gap-2">
232 <div className="min-w-0 grow font-mono">
233 <Input
234 name="invite"
235 aria-label="Invite code"
236 placeholder="g1t-xxxx-xxxx-…"
237 defaultValue={loaderData.code}
238 required
239 spellCheck={false}
240 autoCapitalize="none"
241 />
242 </div>
243 <Button type="submit" variant="quiet">
244 Continue
245 </Button>
246 </Form>
247 {loaderData.inviteError && (
248 <div className="mt-2">
249 <ErrorText>{loaderData.inviteError}</ErrorText>
250 </div>
251 )}
252 </section>
253
254 <section id="request" className="scroll-mt-24 border-t border-line pt-8">
255 <h2 className="text-sm font-medium">Request access</h2>
256 {requested ? (
257 <p className="mt-3 flex items-start gap-2 rounded-md border border-success/40 bg-surface p-3 text-sm" role="status">
258 <CheckCircle2 size={16} className="mt-0.5 shrink-0 text-success" />
259 <span>
260 You are on the list, and your invite comes to that address when a place opens. The first time an address
261 asks, we email it to confirm.
262 </span>
263 </p>
264 ) : (
265 <>
266 <p className="mt-1 text-sm text-muted">
267 Leave your email and we will send an invite as we open up. Someone already on g1t can also invite you.
268 </p>
269 <Form method="post" className="relative mt-3 space-y-3">
270 <input type="hidden" name="intent" value="request-access" />
271 <Honeypot started={loaderData.started} />
272 <Field label="Email">
273 <Input name="email" type="email" autoComplete="email" required maxLength={254} />
274 </Field>
275 <Field label="What will you build? (optional)">
276 <textarea
277 name="about"
278 rows={3}
279 maxLength={1000}
280 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
281 />
282 </Field>
283 <ErrorText>{actionData && "requestError" in actionData ? actionData.requestError : undefined}</ErrorText>
284 <div className="*:w-full">
285 <SubmitButton pending="Sending…" match={{ intent: "request-access" }}>
286 Request access
287 </SubmitButton>
288 </div>
289 </Form>
290 </>
291 )}
292 </section>
293 </div>
294 );
295}
296
297export default function Register({ loaderData, actionData }: Route.ComponentProps) {
298 const { inviteOnly, invite } = loaderData;
299 const error = actionData && "error" in actionData ? actionData.error : undefined;
300 // Invite-only: the sign-up form only appears with a good code (or a
301 // submitted one that failed, so the person can correct the rest).
302 const showForm = !inviteOnly || invite !== null;
303 return (
304 <AuthCard
305 title={showForm ? "Create your account" : "g1t is invite-only for now"}
306 subtitle={showForm ? "Your team and its agents, working in one place" : "Enter your invite, or ask for one"}
307 footer={
308 <>
309 Already have an account?{" "}
310 <Link to="/login" className="text-fg underline underline-offset-4">
311 Sign in
312 </Link>
313 </>
314 }
315 >
316 {showForm ? <SignUpForm loaderData={loaderData} error={error} /> : <InviteOnly loaderData={loaderData} actionData={actionData} />}
317 </AuthCard>
318 );
319}