Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 | 4x 12x 11x 1x 80x 80x 80x 80x 80x 80x 80x 80x 80x 80x 80x 80x 31x 31x 2x 2x 2x 29x 29x 27x 12x 12x 4x 12x 12x 15x 15x 1x 28x 31x 80x 43x 12x 80x 6x 6x 6x 4x 4x 4x 3x 3x 1x 4x 80x 31x 49x 37x 7x 7x 2x 12x | /**
* Email verification — the hop between signing up and setting up.
*
* Reskinned to the Job Book (redesign plan 4.2). The SESSION LOGIC below is
* deliberately untouched: Better Auth establishes the session as part of
* verifying the token, so this page lands on `/onboarding` by itself. The copy
* matches that — nobody is asked to sign in again on the success path.
*/
import { Link, useSearch } from "@tanstack/react-router";
import { type FormEvent, useEffect, useState } from "react";
import { AuthShell } from "../components/auth/auth-shell";
import { Alert } from "../components/ui/alert";
import { Button } from "../components/ui/button";
import { Card, CardContent } from "../components/ui/card";
import { Field, Label } from "../components/ui/field";
import { Input } from "../components/ui/input";
import { authApi } from "../lib/api";
import { API_BASE_URL } from "../lib/api/base";
import { notify } from "../lib/notify";
import { isValidEmail } from "../lib/validation";
// Where a freshly-verified pro lands.
//
// Better Auth establishes the session as part of verifying the token
// (`emailVerification.autoSignInAfterVerification` in apps/api/src/lib/auth.ts),
// so by the time this page shows its success state the pro is already signed
// in. Sending them to /login would ask for a password they just proved they
// own: sign-up -> verified -> onboarding is one continuous motion.
const POST_VERIFY_PATH = "/onboarding";
// The destination is now followed automatically, so it must be a path on this
// origin — never an absolute or protocol-relative URL. Anything else is
// discarded in favour of onboarding rather than becoming an open redirect.
function sameOriginPath(candidate: string | null): string | null {
if (!candidate || !candidate.startsWith("/") || candidate.startsWith("//")) {
return null;
}
return candidate;
}
export function VerifyEmailPage() {
const search = useSearch({ strict: false }) as { token?: string };
const token = search.token || "";
const [isLoading, setIsLoading] = useState(true);
const [success, setSuccess] = useState(false);
const [error, setError] = useState("");
// Set once verification succeeds; drives the automatic redirect below.
const [destination, setDestination] = useState<string | null>(null);
// Resend verification state
const [resendEmail, setResendEmail] = useState("");
const [resendingEmail, setResendingEmail] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
const [resendTouched, setResendTouched] = useState(false);
const resendEmailError =
resendTouched && !resendEmail.trim()
? "Email is required"
: resendTouched && !isValidEmail(resendEmail.trim())
? "Enter a valid email"
: undefined;
useEffect(() => {
const verifyEmail = async () => {
if (!token) {
setError("Invalid verification link");
setIsLoading(false);
return;
}
try {
// Call Better Auth's verify email endpoint.
// Use redirect: "manual" because Better Auth may redirect after
// verification, which causes a cross-origin fetch failure.
const response = await fetch(
`${API_BASE_URL}/api/auth/verify-email?token=${encodeURIComponent(token)}`,
{
method: "GET",
credentials: "include",
redirect: "manual",
},
);
// A redirect (3xx) or 200 both indicate successful verification
if (
response.ok ||
response.type === "opaqueredirect" ||
(response.status >= 300 && response.status < 400)
) {
// An invitation flow owns its own destination, recorded at
// registration. Read it once and clear it so it does not
// persist across sessions.
const pendingRedirect =
typeof window !== "undefined"
? localStorage.getItem("pendingInvitationRedirect")
: null;
if (pendingRedirect) {
localStorage.removeItem("pendingInvitationRedirect");
}
setDestination(sameOriginPath(pendingRedirect) ?? POST_VERIFY_PATH);
setSuccess(true);
} else {
const data = await response.json().catch(() => ({}));
setError(
data?.message || "Verification failed. The link may have expired.",
);
}
} catch (_err) {
setError("An error occurred during verification. Please try again.");
} finally {
setIsLoading(false);
}
};
verifyEmail();
}, [token]);
// Land on the destination without a second click.
//
// A full-document navigation, not a router `navigate()`, is deliberate: the
// session cookie was set by the API on the response we just read, and only a
// fresh load has AuthProvider pick it up before the route guards run. A
// client-side navigate would race GuestGuard, which still believes this tab
// belongs to a guest and would bounce us to "/".
useEffect(() => {
if (!destination || typeof window === "undefined") return;
window.location.replace(destination);
}, [destination]);
const handleResendVerification = async (e: FormEvent) => {
e.preventDefault();
setResendTouched(true);
if (!resendEmail.trim() || !isValidEmail(resendEmail.trim())) return;
setResendingEmail(true);
try {
await authApi.resendVerification(resendEmail.trim());
setResendSuccess(true);
notify.success("Verification email sent! Check your inbox.");
} catch {
notify.error("Failed to send verification email. Please try again.");
} finally {
setResendingEmail(false);
}
};
if (isLoading) {
return (
<AuthShell title="Verifying your email" lead="One moment.">
<Card>
<CardContent className="flex items-center gap-3 p-5">
<div className="size-5 animate-spin rounded-full border-2 border-border-strong border-t-foreground-default" />
<p className="text-sm text-foreground-muted">
Please wait while we verify your email address.
</p>
</CardContent>
</Card>
</AuthShell>
);
}
if (error || !success) {
return (
<AuthShell
title="That link didn't work"
lead="The verification link may have expired or already been used."
footer={
<Link
to="/login"
className="font-semibold text-foreground-default underline decoration-2 underline-offset-4 hover:text-primary-600"
>
Back to sign in
</Link>
}
>
<Card>
<CardContent className="space-y-4 p-5">
<Alert
variant="error"
title="Not verified"
live={false}
className="-mx-5"
>
{error || "Unable to verify email"}
</Alert>
<p className="text-sm text-foreground-muted">
Enter your email below and we'll send a new verification link.
</p>
<form
onSubmit={handleResendVerification}
className="space-y-4"
noValidate
>
<Field controlId="resend-email">
<Label>Email</Label>
<Input
id="resend-email"
type="email"
placeholder="you@example.com"
value={resendEmail}
onChange={(e) => {
setResendEmail(e.target.value);
setResendSuccess(false);
}}
onBlur={() => setResendTouched(true)}
error={resendEmailError}
autoComplete="email"
/>
</Field>
<Button
type="submit"
className="w-full"
isLoading={resendingEmail}
disabled={resendSuccess}
>
{resendSuccess ? "Email sent" : "Resend verification email"}
</Button>
</form>
{resendSuccess && (
<p className="text-sm text-foreground-muted">
Check your inbox for the new verification link.
</p>
)}
</CardContent>
</Card>
</AuthShell>
);
}
return (
<AuthShell title="Email verified" lead="Your email address is confirmed.">
<Card>
<CardContent className="space-y-4 p-5">
<p className="text-sm text-foreground-muted">
You're signed in. Taking you to your setup now.
</p>
{/* Fallback for the automatic redirect above. A plain anchor,
not a router Link: the destination needs a document load
so the just-established session is read. */}
<a href={destination ?? POST_VERIFY_PATH} className="block">
<Button className="w-full">Continue</Button>
</a>
</CardContent>
</Card>
</AuthShell>
);
}
|