Initial import: property management SaaS + security hardening + admin dashboard
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>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { headers } from "next/headers"
|
||||
import { redirect } from "next/navigation"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { db } from "@/lib/db"
|
||||
import { profiles } from "@/lib/db/schema"
|
||||
|
||||
/**
|
||||
* Returns the authenticated Better Auth user for the current request, or null.
|
||||
*
|
||||
* Usage in a route / server component:
|
||||
* const user = await getSessionUser()
|
||||
* if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
*/
|
||||
export async function getSessionUser() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
return session?.user ?? null
|
||||
}
|
||||
|
||||
export async function getSession() {
|
||||
return auth.api.getSession({ headers: await headers() })
|
||||
}
|
||||
|
||||
// ── Admin gating ─────────────────────────────────────────────────────────────
|
||||
// Admins come from the Better Auth `user.role === "admin"` field (set via the
|
||||
// admin plugin / bootstrap env). ADMIN_USER_IDS / ADMIN_EMAILS act as an
|
||||
// env-level fallback so the first admin can be bootstrapped without DB access.
|
||||
const ADMIN_USER_IDS = (process.env.ADMIN_USER_IDS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
|
||||
export function isAdminUser(
|
||||
u: { id?: string; email?: string; role?: string | null } | null | undefined
|
||||
): boolean {
|
||||
if (!u) return false
|
||||
if (u.role === "admin") return true
|
||||
if (u.id && ADMIN_USER_IDS.includes(u.id)) return true
|
||||
if (u.email && ADMIN_EMAILS.includes(u.email.toLowerCase())) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* For API routes / server actions: returns `{ user, profile }` if the caller is
|
||||
* an admin, otherwise null (caller returns 401/403). NEVER skip this — admin
|
||||
* queries bypass user_id scoping, so this gate is the only data protection.
|
||||
*/
|
||||
export async function getAdminSession() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
const user = session?.user ?? null
|
||||
if (!isAdminUser(user as { role?: string | null })) return null
|
||||
const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user!.id) })
|
||||
return { user: user!, profile: profile ?? null }
|
||||
}
|
||||
|
||||
/**
|
||||
* For server components / the (admin) layout: redirects non-admins
|
||||
* (anonymous → /login, logged-in non-admin → /dashboard).
|
||||
*/
|
||||
export async function requireAdmin() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
const user = session?.user ?? null
|
||||
if (!user) redirect("/login")
|
||||
if (!isAdminUser(user as { role?: string | null })) redirect("/dashboard")
|
||||
const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user.id) })
|
||||
return { user, profile: profile ?? null }
|
||||
}
|
||||
Reference in New Issue
Block a user