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:
parent
c9f08c869f
commit
2ced7f3ebf
7 changed files with 151 additions and 2 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
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.");
|
||||
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()}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue