Skip to content
203 linesCodeBlameRaw

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)1import { CircleCheck, MailCheck } from "lucide-react";
2import { Form, data } from "react-router";
3
4import { CONFIRM_TTL_SECONDS, tidyConfirmCode } from "@g1t/contracts";
5
6import type { Route } from "./+types/confirm-email";
7import { page } from "../lib/meta";
8import { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui";
9import { Hint } from "../components/ui/hint";
10import { afterConfirming, confirmedLine } from "../lib/confirm-gate";
11import { safeNext } from "../lib/next";
12import { accounts, identity } from "../lib/services.server";
13import { assertSameOrigin, clientOf, requireUser } from "../lib/session.server";
14
15export function meta(args: Route.MetaArgs) {
16 return page(args, { title: "Confirm your email · g1t" });
17}
18
19const 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 */
26export 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
40export 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
75export 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.