Files
property-management-network/lib/session.ts
T
Leon SerfatyandClaude Opus 5 1d02598786 chore: sync in-progress work across marketing, admin, API and tests
Snapshot of uncommitted work that had accumulated in the tree alongside
the Turnstile changes:

- marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and
  structured data
- admin billing actions and a per-user portfolio view, plus an admin
  error boundary
- rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface
- CSP and proxy adjustments, accounting/webhook lib updates
- Playwright config and an e2e/unit test suite
- next bumped to ^16.3.4 with the lockfile regenerated
- generated AGENTS.md / CLAUDE.md

Authored by other sessions working in this tree; committed here so the
Turnstile work could be pushed without leaving the tree dirty.
Typecheck passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:27:16 -04:00

81 lines
3.2 KiB
TypeScript

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; emailVerified?: boolean; role?: string | null }
| null
| undefined
): boolean {
if (!u) return false
if (u.role === "admin") return true
// ADMIN_USER_IDS is the unconditional bootstrap path: an id can only come from
// a row we created, so it is not attacker-selectable.
if (u.id && ADMIN_USER_IDS.includes(u.id)) return true
// ADMIN_EMAILS is matched on a user-supplied string, so it additionally
// requires a VERIFIED address. Otherwise, in any environment where
// REQUIRE_EMAIL_VERIFICATION is off, simply signing up with a listed address
// would grant admin without ever controlling the mailbox.
if (u.email && u.emailVerified && 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)) 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)) redirect("/dashboard")
const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user.id) })
return { user, profile: profile ?? null }
}