brackt/app/lib/auth.server.ts
Chris Parsons 2ced7f3ebf 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>
2026-05-09 23:53:13 -07:00

120 lines
3.7 KiB
TypeScript

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { eq } from "drizzle-orm";
import bcrypt from "bcrypt";
import { Resend } from "resend";
import { db } from "~/server/db";
import * as schema from "~/database/schema";
import { generateFlagConfig } from "~/lib/flag-generator";
if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET is required");
}
const resend = new Resend(process.env.RESEND_API_KEY);
const googleProvider = process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } }
: {};
const discordProvider = process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET
? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } }
: {};
const appBaseUrl = process.env.APP_URL ?? process.env.BETTER_AUTH_URL;
export const auth = betterAuth({
baseURL: appBaseUrl,
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
databaseHooks: {
user: {
create: {
before: async (data) => {
return {
data: {
...data,
flagConfig: generateFlagConfig(data.id),
} as typeof data,
};
},
},
update: {
before: async (data) => {
const incomingImage =
"imageUrl" in data ? data.imageUrl : "image" in data ? data.image : undefined;
const userId = typeof data.id === "string" ? data.id : null;
if (!incomingImage || !userId) return;
const existing = await db.query.users.findFirst({
where: eq(schema.users.id, userId),
});
if (existing?.avatarType === "uploaded" || existing?.avatarType === "flag") {
const next = { ...data };
delete next.imageUrl;
delete next.image;
return { data: next };
}
},
},
},
},
advanced: {
database: {
generateId: () => crypto.randomUUID(),
},
},
user: {
fields: {
name: "displayName",
image: "imageUrl",
},
additionalFields: {
username: { type: "string", required: false, fieldName: "username" },
isAdmin: { type: "boolean", defaultValue: false, fieldName: "isAdmin" },
},
},
emailAndPassword: {
enabled: true,
password: {
hash: (password: string) => bcrypt.hash(password, 10),
verify: ({ hash, password }: { hash: string; password: string }) =>
bcrypt.compare(password, hash),
},
sendResetPassword: async ({ user, url }) => {
await resend.emails.send({
from: "Brackt <noreply@brackt.com>",
to: user.email,
subject: "Reset your Brackt password",
html: `<p>Click the link below to reset your password. This link expires in 1 hour.</p><p><a href="${url}">${url}</a></p>`,
});
},
},
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,
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "discord"],
},
},
});