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