Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

197 linesCodeBlameRaw
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 { CodeSlots } from "../components/one-time-code";
9import { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui";
10import { Button } from "../components/ui/button";
11import { Hint } from "../components/ui/hint";
12import { afterConfirming, confirmedLine } from "../lib/confirm-gate";
13import { safeNext } from "../lib/next";
14import { accounts, identity } from "../lib/services.server";
15import { assertSameOrigin, clientOf, requireUser } from "../lib/session.server";
16
17export function meta(args: Route.MetaArgs) {
18 return page(args, { title: "Confirm your email · g1t" });
19}
20
21const MINUTES = CONFIRM_TTL_SECONDS / 60;
22
23/**
24 * The confirmation page: where a new account (or one that never confirmed
25 * its address) is kept until it types the code from its email or follows
26 * the link in it (lib/confirm-gate.ts sends it here from everywhere else).
27 */
28export async function loader({ request, context }: Route.LoaderArgs) {
29 const user = requireUser(context, request);
30 const next = safeNext(new URL(request.url).searchParams.get("next"));
31 // Confirmed already: from a bookmark, or the page loading again right
32 // after the code worked, when it shows what that did.
33 if (user.verified) return { username: user.username, address: null, next, verified: true };
34 const emails = await accounts.listEmails(user);
35 const list = emails.ok ? emails.value.emails : [];
36 // The address the code went to: the primary, or (when another account
37 // confirmed that first) the oldest address still to confirm.
38 const address = (list.find((email) => email.primary && !email.verified) ?? list.find((email) => !email.verified))?.email ?? null;
39 return { username: user.username, address, next, verified: false };
40}
41
42export async function action({ request, context }: Route.ActionArgs) {
43 assertSameOrigin(request);
44 const user = requireUser(context, request);
45 const form = await request.formData();
46 const intent = String(form.get("intent") ?? "");
47 const next = safeNext(new URL(request.url).searchParams.get("next"));
48
49 if (intent === "resend") {
50 const sent = await identity.resendVerification(user);
51 if (!sent.ok) return data({ intent, error: sent.error.message }, { status: 422 });
52 return { intent, sent: true as const };
53 }
54
55 if (intent === "change") {
56 const changed = await accounts.changePendingEmail(user, String(form.get("email") ?? ""));
57 if (!changed.ok) return data({ intent, error: changed.error.message }, { status: 422 });
58 return { intent, changed: true as const };
59 }
60
61 const typed = String(form.get("code") ?? "");
62 if (!tidyConfirmCode(typed)) {
63 return data({ intent: "confirm", error: "Enter the 6-digit code from the email." }, { status: 422 });
64 }
65 const confirmed = await accounts.confirmEmailCode(user, typed, clientOf(request));
66 if (!confirmed.ok) return data({ intent: "confirm", error: confirmed.error.message }, { status: 422 });
67 const done = confirmed.value;
68 return {
69 intent: "confirm",
70 confirmed: true as const,
71 line: confirmedLine(done),
72 lapsed: Boolean(done.inviteLapsed),
73 to: afterConfirming(next, done.joined, done.invitedTo),
74 };
75}
76
77export default function ConfirmEmail({ loaderData, actionData }: Route.ComponentProps) {
78 const { username, address } = loaderData;
79 const said = actionData as
80 | { intent: string; error?: string; sent?: true; changed?: true; confirmed?: true; line?: string; lapsed?: boolean; to?: string }
81 | undefined;
82 const errorFor = (intent: string) => (said?.intent === intent ? (said.error ?? null) : null);
83
84 if (said?.confirmed || loaderData.verified) {
85 return (
86 <main className="mx-auto flex w-full max-w-85 flex-col pt-8">
87 <CircleCheck size={36} className="text-success" aria-hidden="true" />
88 <h1 className="mt-6 text-2xl font-semibold tracking-tight">Email confirmed</h1>
89 <p className={`mt-2 text-sm leading-6 ${said?.lapsed ? "text-fg" : "text-muted"}`} role="status">
90 {said?.line ?? "Your email address is confirmed."}
91 </p>
92 <div className="mt-8 *:w-full">
93 <ButtonLink to={said?.to ?? loaderData.next}>Continue</ButtonLink>
94 </div>
95 </main>
96 );
97 }
98
99 return (
100 <main className="mx-auto flex w-full max-w-85 flex-col pt-8">
101 <h1 className="text-center text-xl font-semibold tracking-tight">Confirm your email</h1>
102 <p className="mt-1 text-center text-sm text-muted">One step before you start</p>
103
104 {address ? (
105 <p className="mt-6 text-sm leading-6 text-muted">
106 We sent a 6-digit code to <span className="font-medium break-all text-fg">{address}</span>. Enter it here, or follow the link in the email instead.
107 Either one works.
108 </p>
109 ) : (
110 <p className="mt-6 text-sm leading-6 text-muted">
111 Your account has no address to confirm: another g1t account confirmed the one you signed up with first. Add the address you want to use below.
112 </p>
113 )}
114
115 {said?.sent && (
116 <p className="mt-4 flex items-start gap-2 rounded-lg border border-accent/30 bg-accent/5 p-3 text-sm" role="status">
117 <MailCheck size={16} className="mt-0.5 shrink-0 text-accent" aria-hidden="true" />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 {/* The boxes sit in the middle, under the line that says what to enter; sent as soon as the sixth digit is in. */}
131 <div>
132 <label htmlFor="email-verification-code" className="sr-only">
133 Confirmation code
134 </label>
135 <CodeSlots id="email-verification-code" autoFocus submit centered invalid={errorFor("confirm") != null} describedBy="code-error" />
136 <p className="mt-2 text-center text-xs text-faint">The code and the link work for {MINUTES} minutes.</p>
137 </div>
138 <div id="code-error" className="text-center">
139 <ErrorText>{errorFor("confirm")}</ErrorText>
140 </div>
141 <div className="*:w-full">
142 <SubmitButton name="intent" value="confirm" pending="Confirming…">
143 Confirm email
144 </SubmitButton>
145 </div>
146 </Form>
147 )}
148
149 <div className="mt-8 space-y-4 border-t border-line pt-6 text-sm">
150 {address && (
151 <Form method="post" className="flex flex-wrap items-center justify-between gap-2">
152 <span className="text-muted">Did not get it?</span>
153 <Hint label="Sends a new code and link. You can ask once a minute.">
154 <SubmitButton variant="outline" name="intent" value="resend" pending="Sending…">
155 Send a new code
156 </SubmitButton>
157 </Hint>
158 {errorFor("resend") && (
159 <p className="basis-full text-sm text-danger" role="alert">
160 {errorFor("resend")}
161 </p>
162 )}
163 </Form>
164 )}
165
166 <details className="group" open={!address || said?.intent === "change"}>
167 <summary className="cursor-pointer list-none text-muted hover:text-fg [&::-webkit-details-marker]:hidden">
168 {address ? (
169 <>
170 Wrong address? <span className="text-accent underline underline-offset-4">Change it</span>
171 </>
172 ) : (
173 "Add an address"
174 )}
175 </summary>
176 <Form method="post" className="mt-3 space-y-3">
177 <input type="hidden" name="intent" value="change" />
178 <Field label="Email address" hint="We send a new code and link there. The address you signed up with is removed.">
179 <Input name="email" type="email" autoComplete="email" required maxLength={254} />
180 </Field>
181 <ErrorText>{errorFor("change")}</ErrorText>
182 <SubmitButton variant="outline" name="intent" value="change" pending="Changing…">
183 Use this address
184 </SubmitButton>
185 </Form>
186 </details>
187 </div>
188
189 <Form method="post" action="/logout" className="mt-8 text-center text-sm text-muted">
190 Signed in as <span className="font-mono text-fg">{username}</span> ·{" "}
191 <Button type="submit" variant="link" size="inline" className="text-fg underline hover:text-accent">
192 Sign out
193 </Button>
194 </Form>
195 </main>
196 );
197}