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.

199 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,
107 or follow the link in the email instead. 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.
112 Add the address you want to use below.
113 </p>
114 )}
115
116 {said?.sent && (
117 <p className="mt-4 flex items-start gap-2 rounded-lg border border-accent/30 bg-accent/5 p-3 text-sm" role="status">
118 <MailCheck size={16} className="mt-0.5 shrink-0 text-accent" aria-hidden="true" />
119 A new code and link are on their way. The ones before stop working.
120 </p>
121 )}
122 {said?.changed && (
123 <p className="mt-4 flex items-start gap-2 rounded-lg border border-accent/30 bg-accent/5 p-3 text-sm" role="status">
124 <MailCheck size={16} className="mt-0.5 shrink-0 text-accent" aria-hidden="true" />
125 Address changed. We sent a code and link there.
126 </p>
127 )}
128
129 {address && (
130 <Form method="post" className="mt-6 space-y-4">
131 <input type="hidden" name="intent" value="confirm" />
132 {/* The boxes sit in the middle, under the line that says what to enter; sent as soon as the sixth digit is in. */}
133 <div>
134 <label htmlFor="email-verification-code" className="sr-only">
135 Confirmation code
136 </label>
137 <CodeSlots id="email-verification-code" autoFocus submit centered invalid={errorFor("confirm") != null} describedBy="code-error" />
138 <p className="mt-2 text-center text-xs text-faint">The code and the link work for {MINUTES} minutes.</p>
139 </div>
140 <div id="code-error" className="text-center">
141 <ErrorText>{errorFor("confirm")}</ErrorText>
142 </div>
143 <div className="*:w-full">
144 <SubmitButton name="intent" value="confirm" pending="Confirming…">
145 Confirm email
146 </SubmitButton>
147 </div>
148 </Form>
149 )}
150
151 <div className="mt-8 space-y-4 border-t border-line pt-6 text-sm">
152 {address && (
153 <Form method="post" className="flex flex-wrap items-center justify-between gap-2">
154 <span className="text-muted">Did not get it?</span>
155 <Hint label="Sends a new code and link. You can ask once a minute.">
156 <SubmitButton variant="outline" name="intent" value="resend" pending="Sending…">
157 Send a new code
158 </SubmitButton>
159 </Hint>
160 {errorFor("resend") && (
161 <p className="basis-full text-sm text-danger" role="alert">
162 {errorFor("resend")}
163 </p>
164 )}
165 </Form>
166 )}
167
168 <details className="group" open={!address || said?.intent === "change"}>
169 <summary className="cursor-pointer list-none text-muted hover:text-fg [&::-webkit-details-marker]:hidden">
170 {address ? (
171 <>
172 Wrong address? <span className="text-accent underline underline-offset-4">Change it</span>
173 </>
174 ) : (
175 "Add an address"
176 )}
177 </summary>
178 <Form method="post" className="mt-3 space-y-3">
179 <input type="hidden" name="intent" value="change" />
180 <Field label="Email address" hint="We send a new code and link there. The address you signed up with is removed.">
181 <Input name="email" type="email" autoComplete="email" required maxLength={254} />
182 </Field>
183 <ErrorText>{errorFor("change")}</ErrorText>
184 <SubmitButton variant="outline" name="intent" value="change" pending="Changing…">
185 Use this address
186 </SubmitButton>
187 </Form>
188 </details>
189 </div>
190
191 <Form method="post" action="/logout" className="mt-8 text-center text-sm text-muted">
192 Signed in as <span className="font-mono text-fg">{username}</span> ·{" "}
193 <Button type="submit" variant="link" size="inline" className="text-fg underline hover:text-accent">
194 Sign out
195 </Button>
196 </Form>
197 </main>
198 );
199}