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