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