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