Add email verification for email/password signups

New email/password accounts must verify before signing in. Social login
users (Google, Discord) are unaffected. Existing users are grandfathered
via an idempotent migration. Adds a /check-email interstitial with a
resend button, and a --success CSS token for consistent styling.

Closes #343

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-09 23:53:13 -07:00
parent c9f08c869f
commit 2ced7f3ebf
7 changed files with 151 additions and 2 deletions

View file

@ -28,6 +28,7 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@ -72,6 +73,7 @@ html {
--accent: #2ce1c1;
--accent-foreground: #14171e;
--destructive: oklch(0.65 0.22 25);
--success: oklch(0.65 0.20 145);
--border: rgb(255 255 255 / 10%);
--input: rgb(255 255 255 / 12%);
--ring: #adf661;

View file

@ -94,6 +94,19 @@ export const auth = betterAuth({
});
},
},
emailVerification: {
sendOnSignUp: true,
requireEmailVerification: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
await resend.emails.send({
from: "Brackt <noreply@brackt.com>",
to: user.email,
subject: "Verify your Brackt email",
html: `<p>Thanks for signing up for Brackt!</p><p>Click the link below to verify your email address. This link expires in 24 hours.</p><p><a href="${url}">Verify my email</a></p><p>Or copy this link: ${url}</p>`,
});
},
},
socialProviders: {
...googleProvider,
...discordProvider,

View file

@ -57,6 +57,7 @@ export default [
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
route("login", "routes/login.tsx"),
route("register", "routes/register.tsx"),
route("check-email", "routes/check-email.tsx"),
route("forgot-password", "routes/forgot-password.tsx"),
route("reset-password", "routes/reset-password.tsx"),
route("user-profile", "routes/user-profile.tsx"),

View file

@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import { safeRedirectTo } from "../check-email";
describe("safeRedirectTo", () => {
it("returns / for null", () => {
expect(safeRedirectTo(null)).toBe("/");
});
it("returns / for empty string", () => {
expect(safeRedirectTo("")).toBe("/");
});
it("allows a root-relative path", () => {
expect(safeRedirectTo("/dashboard")).toBe("/dashboard");
});
it("allows a nested path", () => {
expect(safeRedirectTo("/leagues/123/draft/456")).toBe("/leagues/123/draft/456");
});
it("rejects an absolute URL", () => {
expect(safeRedirectTo("https://evil.com")).toBe("/");
});
it("rejects a protocol-relative URL", () => {
expect(safeRedirectTo("//evil.com")).toBe("/");
});
it("rejects a path starting with //", () => {
expect(safeRedirectTo("//not-a-host")).toBe("/");
});
});

View file

@ -0,0 +1,89 @@
import { useState } from "react";
import { Link, redirect, useLoaderData } from "react-router";
import { authClient } from "~/lib/auth-client";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
import type { Route } from "./+types/check-email";
export function meta(): Route.MetaDescriptors {
return [{ title: "Check Your Email - Brackt" }];
}
export function safeRedirectTo(value: string | null): string {
if (!value) return "/";
return value.startsWith("/") && !value.startsWith("//") ? value : "/";
}
export async function loader(args: Route.LoaderArgs) {
const url = new URL(args.request.url);
const email = url.searchParams.get("email");
if (!email) return redirect("/register");
return {
email,
redirectTo: safeRedirectTo(url.searchParams.get("redirectTo")),
};
}
type ResendState = "idle" | "sending" | "sent" | "error";
export default function CheckEmailPage() {
const { email, redirectTo } = useLoaderData<typeof loader>();
const [resendState, setResendState] = useState<ResendState>("idle");
const [resendError, setResendError] = useState<string | null>(null);
async function handleResend() {
setResendState("sending");
setResendError(null);
const { error } = await authClient.sendVerificationEmail({
email,
callbackURL: redirectTo,
});
if (error) {
setResendState("error");
setResendError(error.message ?? "Could not resend. Please try again.");
} else {
setResendState("sent");
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">Check your email</CardTitle>
<CardDescription>We sent a verification link to {email}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Click the link in the email to verify your account and get started. If you
don&apos;t see it, check your spam folder.
</p>
<div className="space-y-2">
<Button
variant="outline"
className="w-full"
onClick={handleResend}
disabled={resendState === "sending"}
>
{resendState === "sending" ? "Sending…" : "Resend verification email"}
</Button>
{resendState === "sent" && (
<p className="text-sm text-success">Email sent! Check your inbox.</p>
)}
{resendState === "error" && resendError && (
<p className="text-sm text-destructive">{resendError}</p>
)}
</div>
<p className="text-center text-sm text-muted-foreground">
Wrong account?{" "}
<Link to="/login" className="underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

View file

@ -52,8 +52,8 @@ export default function RegisterPage() {
setError(signUpError.message ?? "Registration failed. Please try again.");
setLoading(false);
} else {
// BetterAuth redirects via callbackURL; fallback in case it doesn't fire
window.location.href = redirectTo;
const params = new URLSearchParams({ email, redirectTo });
window.location.href = `/check-email?${params.toString()}`;
}
}

View file

@ -102,6 +102,18 @@ async function convertForeignKeys(sql) {
WHERE clerk_id IS NOT NULL
AND email_verified = false
`;
// Grandfather pre-enforcement BetterAuth signups: they have no clerk_id and
// registered before email verification was required.
const grandfatherResult = await sql`
UPDATE users
SET email_verified = true
WHERE email_verified = false
AND clerk_id IS NULL
`;
if (grandfatherResult.count > 0) {
console.log(` grandfathered ${grandfatherResult.count} pre-enforcement email/password users`);
}
}
async function importClerkOAuthAccounts(sql, secretKey) {