Skip to content
203 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 { 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),
71 to: afterConfirming(next, done.joined, done.invitedTo),
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 (
84 <main className="mx-auto flex w-full max-w-85 flex-col pt-8">
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 (
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>
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}