auth.server.ts: switch generateId to application-generated UUIDs so the ID is known before the insert, then move flagConfig assignment from create.after (a separate UPDATE) to create.before so it lands in the initial INSERT with no race window. FlagSvg.tsx: validate the config with parseFlagConfig before rendering; corrupt or missing data falls back to a neutral gray triband flag rather than silently rendering blank SVG shapes. https://claude.ai/code/session_01T7iTb9YZLuWJFtKV753tdB
109 lines
3.3 KiB
TypeScript
109 lines
3.3 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: {
|
|
firstName: { type: "string", required: false, fieldName: "firstName" },
|
|
lastName: { type: "string", required: false, fieldName: "lastName" },
|
|
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>`,
|
|
});
|
|
},
|
|
},
|
|
socialProviders: {
|
|
...googleProvider,
|
|
...discordProvider,
|
|
},
|
|
account: {
|
|
accountLinking: {
|
|
enabled: true,
|
|
trustedProviders: ["google", "discord"],
|
|
},
|
|
},
|
|
});
|