Skip to content
204 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 { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui";
9import { Button } from "../components/ui/button";
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, done.invitedTo),
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 w-full max-w-85 flex-col pt-8">
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 w-full max-w-85 flex-col pt-8">
100 <h1 className="text-center text-xl font-semibold tracking-tight">Confirm your email</h1>
101 <p className="mt-1 text-center text-sm text-muted">One step before you start</p>
102
103 {address ? (
104 <p className="mt-6 text-sm leading-6 text-muted">
105 We sent a 6-digit code to <span className="font-medium break-all text-fg">{address}</span>. Enter it here,
106 or follow the link in the email instead. Either one works.
107 </p>
108 ) : (
109 <p className="mt-6 text-sm leading-6 text-muted">
110 Your account has no address to confirm: another g1t account confirmed the one you signed up with first.
111 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" />
118 A new code and link are on their way. The ones before stop working.
119 </p>
120 )}
121 {said?.changed && (
122 <p className="mt-4 flex items-start gap-2 rounded-lg border border-accent/30 bg-accent/5 p-3 text-sm" role="status">
123 <MailCheck size={16} className="mt-0.5 shrink-0 text-accent" aria-hidden="true" />
124 Address changed. We sent a code and link there.
125 </p>
126 )}
127
128 {address && (
129 <Form method="post" className="mt-6 space-y-4">
130 <input type="hidden" name="intent" value="confirm" />
131 <Field label="Confirmation code" hint={`The code and the link work for ${MINUTES} minutes.`}>
132 <Input
133 name="code"
134 autoComplete="one-time-code"
135 inputMode="numeric"
136 pattern="[0-9 -]*"
137 maxLength={9}
138 required
139 autoFocus
140 placeholder="123456"
141 aria-describedby="code-error"
142 style={{ fontSize: "1.5rem", letterSpacing: "0.4em", fontFamily: "var(--font-mono)", textAlign: "center" }}
143 />
144 </Field>
145 <div id="code-error">
146 <ErrorText>{errorFor("confirm")}</ErrorText>
147 </div>
148 <div className="*:w-full">
149 <SubmitButton name="intent" value="confirm" pending="Confirming…">
150 Confirm email
151 </SubmitButton>
152 </div>
153 </Form>
154 )}
155
156 <div className="mt-8 space-y-4 border-t border-line pt-6 text-sm">
157 {address && (
158 <Form method="post" className="flex flex-wrap items-center justify-between gap-2">
159 <span className="text-muted">Did not get it?</span>
160 <Hint label="Sends a new code and link. You can ask once a minute.">
161 <SubmitButton variant="outline" name="intent" value="resend" pending="Sending…">
162 Send a new code
163 </SubmitButton>
164 </Hint>
165 {errorFor("resend") && (
166 <p className="basis-full text-sm text-danger" role="alert">
167 {errorFor("resend")}
168 </p>
169 )}
170 </Form>
171 )}
172
173 <details className="group" open={!address || said?.intent === "change"}>
174 <summary className="cursor-pointer list-none text-muted hover:text-fg [&::-webkit-details-marker]:hidden">
175 {address ? (
176 <>
177 Wrong address? <span className="text-accent underline underline-offset-4">Change it</span>
178 </>
179 ) : (
180 "Add an address"
181 )}
182 </summary>
183 <Form method="post" className="mt-3 space-y-3">
184 <input type="hidden" name="intent" value="change" />
185 <Field label="Email address" hint="We send a new code and link there. The address you signed up with is removed.">
186 <Input name="email" type="email" autoComplete="email" required maxLength={254} />
187 </Field>
188 <ErrorText>{errorFor("change")}</ErrorText>
189 <SubmitButton variant="outline" name="intent" value="change" pending="Changing…">
190 Use this address
191 </SubmitButton>
192 </Form>
193 </details>
194 </div>
195
196 <Form method="post" action="/logout" className="mt-8 text-center text-sm text-muted">
197 Signed in as <span className="font-mono text-fg">{username}</span> ·{" "}
198 <Button type="submit" variant="link" size="inline" className="text-fg underline hover:text-accent">
199 Sign out
200 </Button>
201 </Form>
202 </main>
203 );
204}