Valcraven Docs
Building with Valcraven

Add an admin page

How the /admin section is structured — the RBAC layout gate, the sidebar nav, and requireAdmin() for API routes — and how to add a new admin page.

Add an admin page

The admin panel lives under app/admin/ and is a separate surface from the user-facing /app. It has its own layout, its own dark zinc/emerald styling, and one thing that makes adding pages easy: the RBAC gate is enforced once, in the layout, so any page you drop under app/admin/ is admin-only automatically. This page shows how the section is wired and how to add to it.

For what already ships in the panel — user management, RBAC roles, audit log, impersonation, the database browser — see Admin panel.

How the section is structured

app/admin/
  layout.tsx      — the RBAC gate + AdminShell wrapper (runs for every admin page)
  page.tsx        — the dashboard
  users/          — one folder per page (users, database, analytics, roles, …)

components/admin/
  admin-shell.tsx — the responsive frame (sidebar + mobile drawer)
  sidebar.tsx     — the nav list you add a link to

The RBAC gate (you don't repeat it)

app/admin/layout.tsx is a server component that checks the session and admin status before rendering anything, and redirects if the caller isn't an admin:

// app/admin/layout.tsx (the gist)
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) redirect("/auth");
if (!(await isAdmin(session.user.id))) redirect("/app");
return <AdminShell>{children}</AdminShell>;

Because every page under app/admin/ renders inside this layout, a new admin page inherits the gate for free — you don't add another auth check to the page itself. isAdmin(userId) comes from @/lib/admin and returns true when the user's isAdmin column is 1. Admins are granted via the ADMIN_EMAILS env var (see Admin panel).

Add a page

1. Create the page

Add a folder and a page.tsx under app/admin/. It can be a server component (the layout already resolved the session) or a client component that fetches an admin API route:

// app/admin/reports/page.tsx
export const metadata = { title: "Reports" };

export default function ReportsPage() {
  return (
    <div>
      <h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100">Reports</h1>
      {/* … */}
    </div>
  );
}

Match the existing admin styling — the panel uses zinc surfaces and an emerald/primary accent. Look at an existing simple page (for example app/admin/waitlist/) as a style reference.

The nav is a plain array in components/admin/sidebar.tsx. Add an entry (icon from lucide-react); order in the array is order in the sidebar:

// components/admin/sidebar.tsx
import { FileBarChart } from "lucide-react";

const navItems = [
  // …existing entries…
  { href: "/admin/reports", label: "Reports", icon: FileBarChart },
];

Use exact: true on the entry only if the highlight should match the path exactly (that's set on the dashboard /admin entry so it doesn't stay highlighted on sub-pages).

Admin API routes

Pages that mutate data call admin API routes under app/api/admin/…. These are not covered by the layout gate (that only protects pages), so each admin route must guard itself. Use requireAdmin(request) from @/lib/admin, which returns either a session or a ready-made error response:

import { requireAdmin, logAdminAction } from "@/lib/admin";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

export async function POST(request: Request) {
  const result = await requireAdmin(request);
  if (result.error) return result.error; // 401 if no session, 403 if not admin

  const adminId = result.session.user.id;
  // … do the admin action …

  await logAdminAction(adminId, "report.export"); // records to admin_logs + audit_log
  return NextResponse.json({ ok: true });
}

logAdminAction(adminId, action, targetType?, targetId?, details?) writes to the admin_logs table and mirrors the entry into the general audit log, so privileged actions are traceable. The results show up on the admin Admin Actions and Activity Log pages.

Admin routes still follow the standard API route template — the force-dynamic + nodejs exports and errorResponse() for unexpected errors. requireAdmin replaces the plain session check; everything else is the same.

On this page