import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" import { nextCookies } from "better-auth/next-js" import { admin } from "better-auth/plugins" import { db } from "@/lib/db" import { user, session, account, verification, profiles, consent_log } from "@/lib/db/schema" import { sendEmail, resetPasswordHtml, verifyEmailHtml } from "@/lib/email/send" import { LEGAL } from "@/lib/legal" // Bootstrap superadmins from env — no API path lets a user self-promote. const ADMIN_USER_IDS = (process.env.ADMIN_USER_IDS ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean) export const auth = betterAuth({ baseURL: process.env.BETTER_AUTH_URL, secret: process.env.BETTER_AUTH_SECRET, database: drizzleAdapter(db, { provider: "pg", schema: { user, session, account, verification }, }), emailAndPassword: { enabled: true, // Env-gated so production can require a verified email without breaking // local dev (where SMTP is typically unconfigured). Set // REQUIRE_EMAIL_VERIFICATION=true in production to enforce. requireEmailVerification: process.env.REQUIRE_EMAIL_VERIFICATION === "true", minPasswordLength: 8, sendResetPassword: async ({ user: u, url }) => { await sendEmail({ to: u.email, subject: "Reset your Property Management Network password", html: resetPasswordHtml(url), }) }, }, // Send a verification email on sign-up. Enforcement of verified-email login // is gated by REQUIRE_EMAIL_VERIFICATION (see emailAndPassword above). emailVerification: { sendOnSignUp: true, // After the user clicks the verification link, sign them in and send them // to the callbackURL (set to /dashboard on sign-up). autoSignInAfterVerification: true, sendVerificationEmail: async ({ user: u, url }) => { await sendEmail({ to: u.email, subject: "Verify your email — Property Management Network", html: verifyEmailHtml(url), }) }, }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID ?? "", clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "", }, }, // Throttle auth endpoints (per IP) to slow brute-force / credential stuffing. rateLimit: { enabled: true, window: 60, // seconds max: 20, // requests per window per IP for auth endpoints customRules: { // Tighter limit on the password sign-in endpoint to slow credential // stuffing / brute-force attempts. "/sign-in/email": { window: 60, max: 10 }, }, }, // Auto-create the app `profiles` row whenever Better Auth creates a user // (replaces the old `handle_new_user` Postgres trigger). databaseHooks: { user: { create: { after: async (u) => { try { await db .insert(profiles) .values({ id: u.id, email: u.email, full_name: u.name ?? null }) .onConflictDoNothing() } catch { // Never block sign-up on profile creation. } // GDPR proof of acceptance: the signup form states that creating an // account means agreeing to the Terms and Privacy Policy. try { await db.insert(consent_log).values([ { user_id: u.id, email: u.email, kind: "terms" as const, granted: true, policy_version: LEGAL.lastUpdated, source: "signup", }, { user_id: u.id, email: u.email, kind: "privacy" as const, granted: true, policy_version: LEGAL.lastUpdated, source: "signup", }, ]) } catch { // Never block sign-up on consent logging. } }, }, }, }, // `admin` enables role/ban/impersonation; `nextCookies` MUST stay last. plugins: [ admin({ adminUserIds: ADMIN_USER_IDS }), nextCookies(), ], }) /** * Whether Google OAuth is configured. The auth pages hide the "Continue with * Google" button unless BOTH credentials are present, so users never see a * social option that can't complete. Mirrors socialProviders.google above. */ export function isGoogleConfigured(): boolean { return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) }