Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 1 | import { CircleCheck, MailCheck } from "lucide-react"; |
| 2 | import { Form, data } from "react-router"; | |
| 3 | ||
| 4 | import { CONFIRM_TTL_SECONDS, tidyConfirmCode } from "@g1t/contracts"; | |
| 5 | ||
| 6 | import type { Route } from "./+types/confirm-email"; | |
| 7 | import { page } from "../lib/meta"; | |
| 8 | import { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 9 | import { Hint } from "../components/ui/hint"; | |
| 10 | import { afterConfirming, confirmedLine } from "../lib/confirm-gate"; | |
| 11 | import { safeNext } from "../lib/next"; | |
| 12 | import { accounts, identity } from "../lib/services.server"; | |
| 13 | import { assertSameOrigin, clientOf, requireUser } from "../lib/session.server"; | |
| 14 | ||
| 15 | export function meta(args: Route.MetaArgs) { | |
| 16 | return page(args, { title: "Confirm your email · g1t" }); | |
| 17 | } | |
| 18 | ||
| 19 | const MINUTES = CONFIRM_TTL_SECONDS / 60; | |
| 20 | ||
| 21 | /** | |
| 22 | * The confirmation page: where a new account (or one that never confirmed | |
| 23 | * its address) is kept until it types the code from its email or follows | |
| 24 | * the link in it (lib/confirm-gate.ts sends it here from everywhere else). | |
| 25 | */ | |
| 26 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 27 | const user = requireUser(context, request); | |
| 28 | const next = safeNext(new URL(request.url).searchParams.get("next")); | |
| 29 | // Confirmed already: from a bookmark, or the page loading again right | |
| 30 | // after the code worked, when it shows what that did. | |
| 31 | if (user.verified) return { username: user.username, address: null, next, verified: true }; | |
| 32 | const emails = await accounts.listEmails(user); | |
| 33 | const list = emails.ok ? emails.value.emails : []; | |
| 34 | // The address the code went to: the primary, or (when another account | |
| 35 | // confirmed that first) the oldest address still to confirm. | |
| 36 | const address = (list.find((email) => email.primary && !email.verified) ?? list.find((email) => !email.verified))?.email ?? null; | |
| 37 | return { username: user.username, address, next, verified: false }; | |
| 38 | } | |
| 39 | ||
| 40 | export async function action({ request, context }: Route.ActionArgs) { | |
| 41 | assertSameOrigin(request); | |
| 42 | const user = requireUser(context, request); | |
| 43 | const form = await request.formData(); | |
| 44 | const intent = String(form.get("intent") ?? ""); | |
| 45 | const next = safeNext(new URL(request.url).searchParams.get("next")); | |
| 46 | ||
| 47 | if (intent === "resend") { | |
| 48 | const sent = await identity.resendVerification(user); | |
| 49 | if (!sent.ok) return data({ intent, error: sent.error.message }, { status: 422 }); | |
| 50 | return { intent, sent: true as const }; | |
| 51 | } | |
| 52 | ||
| 53 | if (intent === "change") { | |
| 54 | const changed = await accounts.changePendingEmail(user, String(form.get("email") ?? "")); | |
| 55 | if (!changed.ok) return data({ intent, error: changed.error.message }, { status: 422 }); | |
| 56 | return { intent, changed: true as const }; | |
| 57 | } | |
| 58 | ||
| 59 | const typed = String(form.get("code") ?? ""); | |
| 60 | if (!tidyConfirmCode(typed)) { | |
| 61 | return data({ intent: "confirm", error: "Enter the 6-digit code from the email." }, { status: 422 }); | |
| 62 | } | |
| 63 | const confirmed = await accounts.confirmEmailCode(user, typed, clientOf(request)); | |
| 64 | if (!confirmed.ok) return data({ intent: "confirm", error: confirmed.error.message }, { status: 422 }); | |
| 65 | const done = confirmed.value; | |
| 66 | return { | |
| 67 | intent: "confirm", | |
| 68 | confirmed: true as const, | |
| 69 | line: confirmedLine(done), | |
| 70 | lapsed: Boolean(done.inviteLapsed), | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 71 | to: afterConfirming(next, done.joined, done.invitedTo), |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 72 | }; |
| 73 | } | |
| 74 | ||
| 75 | export default function ConfirmEmail({ loaderData, actionData }: Route.ComponentProps) { | |
| 76 | const { username, address } = loaderData; | |
| 77 | const said = actionData as | |
| 78 | | { intent: string; error?: string; sent?: true; changed?: true; confirmed?: true; line?: string; lapsed?: boolean; to?: string } | |
| 79 | | undefined; | |
| 80 | const errorFor = (intent: string) => (said?.intent === intent ? (said.error ?? null) : null); | |
| 81 | ||
| 82 | if (said?.confirmed || loaderData.verified) { | |
| 83 | return ( | |
| g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them. | 84 | <main className="mx-auto flex w-full max-w-85 flex-col pt-8"> |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 85 | <CircleCheck size={36} className="text-success" aria-hidden="true" /> |
| 86 | <h1 className="mt-6 text-2xl font-semibold tracking-tight">Email confirmed</h1> | |
| 87 | <p className={`mt-2 text-sm leading-6 ${said?.lapsed ? "text-fg" : "text-muted"}`} role="status"> | |
| 88 | {said?.line ?? "Your email address is confirmed."} | |
| 89 | </p> | |
| 90 | <div className="mt-8 *:w-full"> | |
| 91 | <ButtonLink to={said?.to ?? loaderData.next}>Continue</ButtonLink> | |
| 92 | </div> | |
| 93 | </main> | |
| 94 | ); | |
| 95 | } | |
| 96 | ||
| 97 | return ( | |
| g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them. | 98 | <main className="mx-auto flex w-full max-w-85 flex-col pt-8"> |
| 99 | <h1 className="text-center text-xl font-semibold tracking-tight">Confirm your email</h1> | |
| 100 | <p className="mt-1 text-center text-sm text-muted">One step before you start</p> | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 101 | |
| 102 | {address ? ( | |
| 103 | <p className="mt-6 text-sm leading-6 text-muted"> | |
| 104 | We sent a 6-digit code to <span className="font-medium break-all text-fg">{address}</span>. Enter it here, | |
| 105 | or follow the link in the email instead. Either one works. | |
| 106 | </p> | |
| 107 | ) : ( | |
| 108 | <p className="mt-6 text-sm leading-6 text-muted"> | |
| 109 | Your account has no address to confirm: another g1t account confirmed the one you signed up with first. | |
| 110 | Add the address you want to use below. | |
| 111 | </p> | |
| 112 | )} | |
| 113 | ||
| 114 | {said?.sent && ( | |
| 115 | <p className="mt-4 flex items-start gap-2 rounded-lg border border-accent/30 bg-accent/5 p-3 text-sm" role="status"> | |
| 116 | <MailCheck size={16} className="mt-0.5 shrink-0 text-accent" aria-hidden="true" /> | |
| 117 | A new code and link are on their way. The ones before stop working. | |
| 118 | </p> | |
| 119 | )} | |
| 120 | {said?.changed && ( | |
| 121 | <p className="mt-4 flex items-start gap-2 rounded-lg border border-accent/30 bg-accent/5 p-3 text-sm" role="status"> | |
| 122 | <MailCheck size={16} className="mt-0.5 shrink-0 text-accent" aria-hidden="true" /> | |
| 123 | Address changed. We sent a code and link there. | |
| 124 | </p> | |
| 125 | )} | |
| 126 | ||
| 127 | {address && ( | |
| 128 | <Form method="post" className="mt-6 space-y-4"> | |
| 129 | <input type="hidden" name="intent" value="confirm" /> | |
| 130 | <Field label="Confirmation code" hint={`The code and the link work for ${MINUTES} minutes.`}> | |
| 131 | <Input | |
| 132 | name="code" | |
| 133 | autoComplete="one-time-code" | |
| 134 | inputMode="numeric" | |
| 135 | pattern="[0-9 -]*" | |
| 136 | maxLength={9} | |
| 137 | required | |
| 138 | autoFocus | |
| 139 | placeholder="123456" | |
| 140 | aria-describedby="code-error" | |
| 141 | style={{ fontSize: "1.5rem", letterSpacing: "0.4em", fontFamily: "var(--font-mono)", textAlign: "center" }} | |
| 142 | /> | |
| 143 | </Field> | |
| 144 | <div id="code-error"> | |
| 145 | <ErrorText>{errorFor("confirm")}</ErrorText> | |
| 146 | </div> | |
| 147 | <div className="*:w-full"> | |
| 148 | <SubmitButton name="intent" value="confirm" pending="Confirming…"> | |
| 149 | Confirm email | |
| 150 | </SubmitButton> | |
| 151 | </div> | |
| 152 | </Form> | |
| 153 | )} | |
| 154 | ||
| 155 | <div className="mt-8 space-y-4 border-t border-line pt-6 text-sm"> | |
| 156 | {address && ( | |
| 157 | <Form method="post" className="flex flex-wrap items-center justify-between gap-2"> | |
| 158 | <span className="text-muted">Did not get it?</span> | |
| 159 | <Hint label="Sends a new code and link. You can ask once a minute."> | |
| 160 | <SubmitButton variant="quiet" name="intent" value="resend" pending="Sending…"> | |
| 161 | Send a new code | |
| 162 | </SubmitButton> | |
| 163 | </Hint> | |
| 164 | {errorFor("resend") && ( | |
| 165 | <p className="basis-full text-sm text-danger" role="alert"> | |
| 166 | {errorFor("resend")} | |
| 167 | </p> | |
| 168 | )} | |
| 169 | </Form> | |
| 170 | )} | |
| 171 | ||
| 172 | <details className="group" open={!address || said?.intent === "change"}> | |
| 173 | <summary className="cursor-pointer list-none text-muted hover:text-fg [&::-webkit-details-marker]:hidden"> | |
| 174 | {address ? ( | |
| 175 | <> | |
| 176 | Wrong address? <span className="text-accent underline underline-offset-4">Change it</span> | |
| 177 | </> | |
| 178 | ) : ( | |
| 179 | "Add an address" | |
| 180 | )} | |
| 181 | </summary> | |
| 182 | <Form method="post" className="mt-3 space-y-3"> | |
| 183 | <input type="hidden" name="intent" value="change" /> | |
| 184 | <Field label="Email address" hint="We send a new code and link there. The address you signed up with is removed."> | |
| 185 | <Input name="email" type="email" autoComplete="email" required maxLength={254} /> | |
| 186 | </Field> | |
| 187 | <ErrorText>{errorFor("change")}</ErrorText> | |
| 188 | <SubmitButton variant="quiet" name="intent" value="change" pending="Changing…"> | |
| 189 | Use this address | |
| 190 | </SubmitButton> | |
| 191 | </Form> | |
| 192 | </details> | |
| 193 | </div> | |
| 194 | ||
| 195 | <Form method="post" action="/logout" className="mt-8 text-center text-sm text-muted"> | |
| 196 | Signed in as <span className="font-mono text-fg">{username}</span> ·{" "} | |
| 197 | <button type="submit" className="text-fg underline underline-offset-4 hover:text-accent"> | |
| 198 | Sign out | |
| 199 | </button> | |
| 200 | </Form> | |
| 201 | </main> | |
| 202 | ); | |
| 203 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.