From 2ced7f3ebf5ae8c3f837f05525a40e4fde7e8ce5 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sat, 9 May 2026 23:53:13 -0700 Subject: [PATCH] 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 --- app/app.css | 2 + app/lib/auth.server.ts | 13 ++++ app/routes.ts | 1 + app/routes/__tests__/check-email.test.ts | 32 +++++++++ app/routes/check-email.tsx | 89 ++++++++++++++++++++++++ app/routes/register.tsx | 4 +- scripts/migrate.mjs | 12 ++++ 7 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 app/routes/__tests__/check-email.test.ts create mode 100644 app/routes/check-email.tsx diff --git a/app/app.css b/app/app.css index 0218139..2af3b25 100644 --- a/app/app.css +++ b/app/app.css @@ -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; diff --git a/app/lib/auth.server.ts b/app/lib/auth.server.ts index b857d24..0d78911 100644 --- a/app/lib/auth.server.ts +++ b/app/lib/auth.server.ts @@ -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 ", + to: user.email, + subject: "Verify your Brackt email", + html: `

Thanks for signing up for Brackt!

Click the link below to verify your email address. This link expires in 24 hours.

Verify my email

Or copy this link: ${url}

`, + }); + }, + }, socialProviders: { ...googleProvider, ...discordProvider, diff --git a/app/routes.ts b/app/routes.ts index 28bfdde..134ef15 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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"), diff --git a/app/routes/__tests__/check-email.test.ts b/app/routes/__tests__/check-email.test.ts new file mode 100644 index 0000000..d3bf55c --- /dev/null +++ b/app/routes/__tests__/check-email.test.ts @@ -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("/"); + }); +}); diff --git a/app/routes/check-email.tsx b/app/routes/check-email.tsx new file mode 100644 index 0000000..3455b36 --- /dev/null +++ b/app/routes/check-email.tsx @@ -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(); + const [resendState, setResendState] = useState("idle"); + const [resendError, setResendError] = useState(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 ( +
+ + + Check your email + We sent a verification link to {email} + + +

+ Click the link in the email to verify your account and get started. If you + don't see it, check your spam folder. +

+ +
+ + {resendState === "sent" && ( +

Email sent! Check your inbox.

+ )} + {resendState === "error" && resendError && ( +

{resendError}

+ )} +
+ +

+ Wrong account?{" "} + + Sign in + +

+
+
+
+ ); +} diff --git a/app/routes/register.tsx b/app/routes/register.tsx index f9a6d3e..2d640e8 100644 --- a/app/routes/register.tsx +++ b/app/routes/register.tsx @@ -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()}`; } } diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 56e60cd..a4e1d9f 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -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) {