| 1 | import { Form, Link, data, redirect } from "react-router"; |
| 2 | |
| 3 | import { USERNAME_PATTERN } from "@g1t/contracts"; |
| 4 | |
| 5 | import type { Route } from "./+types/register"; |
| 6 | import { page } from "../lib/meta"; |
| 7 | import { AuthCard } from "../components/auth-card"; |
| 8 | import { ContinueWithGithub, OrDivider } from "../components/github"; |
| 9 | import { Honeypot } from "../components/honeypot"; |
| 10 | import { githubSignInEnabled } from "../lib/github.server"; |
| 11 | import { ErrorText, Field, Input, SubmitButton } from "../components/ui"; |
| 12 | import { identity } from "../lib/services.server"; |
| 13 | import { cleanCode, cleanProof, invitePath, looksAutomated } from "../lib/invites"; |
| 14 | import { clientKey } from "../lib/registration.server"; |
| 15 | import { TURNSTILE_FAILED, TURNSTILE_SCRIPT } from "../lib/turnstile"; |
| 16 | import { turnstile, turnstileOk } from "../lib/turnstile.server"; |
| 17 | import { |
| 18 | assertSameOrigin, |
| 19 | getViewer, |
| 20 | nextPath, |
| 21 | startSession, |
| 22 | } from "../lib/session.server"; |
| 23 | |
| 24 | export function meta(args: Route.MetaArgs) { |
| 25 | return page(args, { title: "Create an account · g1t" }); |
| 26 | } |
| 27 | |
| 28 | export async function loader({ request, context }: Route.LoaderArgs) { |
| 29 | if (getViewer(context)) throw redirect(nextPath(request)); |
| 30 | const search = new URL(request.url).searchParams; |
| 31 | const code = cleanCode(search.get("invite")); |
| 32 | // A good invite is used on its own page, which knows whom it is from and |
| 33 | // where it leads; it signs up, joins and lands in one go. The invite |
| 34 | // email's proof goes with it, so the address it proves stays confirmed |
| 35 | // there. Anyone may sign up, so a code that is no good is just dropped. |
| 36 | if (code) { |
| 37 | const checked = await identity.checkInvite(code, clientKey(request)); |
| 38 | if (checked.ok) throw redirect(invitePath(code, cleanProof(search.get("proof")))); |
| 39 | } |
| 40 | const next = nextPath(request); |
| 41 | return { |
| 42 | github: await githubSignInEnabled(), |
| 43 | githubUrl: next !== "/" ? `/auth/github?${new URLSearchParams({ next })}` : "/auth/github", |
| 44 | started: Date.now(), |
| 45 | // Turnstile's public site key when it is on (lib/turnstile.ts). |
| 46 | turnstile: turnstile()?.siteKey ?? null, |
| 47 | }; |
| 48 | } |
| 49 | |
| 50 | export async function action({ request }: Route.ActionArgs) { |
| 51 | assertSameOrigin(request); |
| 52 | const form = await request.formData(); |
| 53 | if (looksAutomated(form)) return data({ error: "Something went wrong. Try again." }, { status: 422 }); |
| 54 | if (!(await turnstileOk(form, clientKey(request)))) return data({ error: TURNSTILE_FAILED }, { status: 422 }); |
| 55 | const result = await identity.register( |
| 56 | String(form.get("username") ?? ""), |
| 57 | String(form.get("email") ?? ""), |
| 58 | String(form.get("password") ?? ""), |
| 59 | null, |
| 60 | clientKey(request), |
| 61 | ); |
| 62 | if (!result.ok) return data({ error: result.error.message }, { status: 422 }); |
| 63 | throw redirect(nextPath(request), { |
| 64 | headers: { "set-cookie": startSession(result.value.sessionToken) }, |
| 65 | }); |
| 66 | } |
| 67 | |
| 68 | export default function Register({ loaderData, actionData }: Route.ComponentProps) { |
| 69 | const error = actionData && "error" in actionData ? actionData.error : undefined; |
| 70 | return ( |
| 71 | <AuthCard |
| 72 | title="Create your account" |
| 73 | subtitle="Where your team and its coding agents ship code" |
| 74 | footer={ |
| 75 | <> |
| 76 | Already have an account?{" "} |
| 77 | <Link to="/login" className="text-fg underline underline-offset-4"> |
| 78 | Sign in |
| 79 | </Link> |
| 80 | </> |
| 81 | } |
| 82 | > |
| 83 | {loaderData.github && ( |
| 84 | <> |
| 85 | <ContinueWithGithub href={loaderData.githubUrl} /> |
| 86 | <OrDivider /> |
| 87 | </> |
| 88 | )} |
| 89 | <Form method="post" className="relative space-y-4"> |
| 90 | <Honeypot started={loaderData.started} /> |
| 91 | <Field |
| 92 | label="Username" |
| 93 | hint="Letters, digits and single hyphens. It is how you sign in, and how others see you, in the case you type it." |
| 94 | > |
| 95 | <Input |
| 96 | name="username" |
| 97 | autoComplete="username" |
| 98 | required |
| 99 | autoFocus |
| 100 | maxLength={39} |
| 101 | pattern={USERNAME_PATTERN} |
| 102 | /> |
| 103 | </Field> |
| 104 | <Field label="Email" hint="We send a code to this address to confirm it."> |
| 105 | <Input name="email" type="email" autoComplete="email" required /> |
| 106 | </Field> |
| 107 | <Field label="Password" hint="At least 10 characters."> |
| 108 | <Input |
| 109 | name="password" |
| 110 | type="password" |
| 111 | autoComplete="new-password" |
| 112 | required |
| 113 | minLength={10} |
| 114 | /> |
| 115 | </Field> |
| 116 | {loaderData.turnstile && ( |
| 117 | <> |
| 118 | <script src={TURNSTILE_SCRIPT} async defer /> |
| 119 | <div className="cf-turnstile" data-sitekey={loaderData.turnstile} data-theme="auto" data-size="flexible" /> |
| 120 | </> |
| 121 | )} |
| 122 | <ErrorText>{error}</ErrorText> |
| 123 | <div className="pt-2 *:w-full"> |
| 124 | <SubmitButton pending="Creating account…">Create account</SubmitButton> |
| 125 | </div> |
| 126 | </Form> |
| 127 | </AuthCard> |
| 128 | ); |
| 129 | } |