Add email verification for email/password signups (#400)
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:
parent
c9f08c869f
commit
c8748d1b14
7 changed files with 151 additions and 2 deletions
|
|
@ -28,6 +28,7 @@
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
|
--color-success: var(--success);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
|
@ -72,6 +73,7 @@ html {
|
||||||
--accent: #2ce1c1;
|
--accent: #2ce1c1;
|
||||||
--accent-foreground: #14171e;
|
--accent-foreground: #14171e;
|
||||||
--destructive: oklch(0.65 0.22 25);
|
--destructive: oklch(0.65 0.22 25);
|
||||||
|
--success: oklch(0.65 0.20 145);
|
||||||
--border: rgb(255 255 255 / 10%);
|
--border: rgb(255 255 255 / 10%);
|
||||||
--input: rgb(255 255 255 / 12%);
|
--input: rgb(255 255 255 / 12%);
|
||||||
--ring: #adf661;
|
--ring: #adf661;
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
socialProviders: {
|
||||||
...googleProvider,
|
...googleProvider,
|
||||||
...discordProvider,
|
...discordProvider,
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ export default [
|
||||||
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
|
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
|
||||||
route("login", "routes/login.tsx"),
|
route("login", "routes/login.tsx"),
|
||||||
route("register", "routes/register.tsx"),
|
route("register", "routes/register.tsx"),
|
||||||
|
route("check-email", "routes/check-email.tsx"),
|
||||||
route("forgot-password", "routes/forgot-password.tsx"),
|
route("forgot-password", "routes/forgot-password.tsx"),
|
||||||
route("reset-password", "routes/reset-password.tsx"),
|
route("reset-password", "routes/reset-password.tsx"),
|
||||||
route("user-profile", "routes/user-profile.tsx"),
|
route("user-profile", "routes/user-profile.tsx"),
|
||||||
|
|
|
||||||
32
app/routes/__tests__/check-email.test.ts
Normal file
32
app/routes/__tests__/check-email.test.ts
Normal 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("/");
|
||||||
|
});
|
||||||
|
});
|
||||||
89
app/routes/check-email.tsx
Normal file
89
app/routes/check-email.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -52,8 +52,8 @@ export default function RegisterPage() {
|
||||||
setError(signUpError.message ?? "Registration failed. Please try again.");
|
setError(signUpError.message ?? "Registration failed. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} else {
|
} else {
|
||||||
// BetterAuth redirects via callbackURL; fallback in case it doesn't fire
|
const params = new URLSearchParams({ email, redirectTo });
|
||||||
window.location.href = redirectTo;
|
window.location.href = `/check-email?${params.toString()}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,18 @@ async function convertForeignKeys(sql) {
|
||||||
WHERE clerk_id IS NOT NULL
|
WHERE clerk_id IS NOT NULL
|
||||||
AND email_verified = false
|
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) {
|
async function importClerkOAuthAccounts(sql, secretKey) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue