Property Management Network — Next.js 16 (App Router), Better Auth, Drizzle ORM over PostgreSQL, Stripe, OpenAI, Resend. Includes: - Security hardening: access-control/IDOR fixes, TLS-by-default DB layer, constant-time cron auth, strict security headers, atomic AI quota gating, HTML/email output encoding, demo-backdoor disabled in production. - Superadmin dashboard at /admin (overview/MRR, server-paginated users with ban/impersonate/plan/delete, billing, platform activity + admin audit log, AI usage, system health) via the Better Auth admin plugin. - Seed/migration utility scripts under scripts/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
91 lines
3.2 KiB
TypeScript
91 lines
3.2 KiB
TypeScript
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 } from "@/lib/db/schema"
|
|
import { sendEmail } from "@/lib/email/send"
|
|
|
|
// 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,
|
|
// Login works immediately; flip to true once verification email is desired.
|
|
// Recommended for production: set requireEmailVerification to true.
|
|
requireEmailVerification: false,
|
|
minPasswordLength: 8,
|
|
sendResetPassword: async ({ user: u, url }) => {
|
|
await sendEmail({
|
|
to: u.email,
|
|
subject: "Reset your Property Management Network password",
|
|
html: resetPasswordHtml(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
|
|
},
|
|
// 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.
|
|
}
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// `admin` enables role/ban/impersonation; `nextCookies` MUST stay last.
|
|
plugins: [
|
|
admin({ adminUserIds: ADMIN_USER_IDS }),
|
|
nextCookies(),
|
|
],
|
|
})
|
|
|
|
function resetPasswordHtml(url: string) {
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body style="font-family: sans-serif; background: #09090b; color: #fff; padding: 40px 20px; max-width: 560px; margin: 0 auto;">
|
|
<div style="background: #16161f; border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 32px;">
|
|
<h1 style="font-size: 20px; margin: 0 0 8px; color: #fff;">Reset your password</h1>
|
|
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
|
|
Click the button below to choose a new password. If you didn't request this, you can ignore this email.
|
|
</p>
|
|
<a href="${url}" style="display: inline-block; background: #6366f1; color: #fff; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">
|
|
Reset Password
|
|
</a>
|
|
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">Property Management Network</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
}
|