Valcraven Docs
Features

Admin panel

The /admin dashboard — user management, RBAC roles, audit log, impersonation, and the database browser, plus how a user becomes an admin.

Admin panel

Valcraven ships a full admin console at /admin, gated so only admins can reach it. The layout (apps/web/app/admin/layout.tsx) checks the session and redirects non-admins: unauthenticated visitors go to /auth, signed-in non-admins go to /app. Everything under /admin renders inside the AdminShell with a left sidebar.

This page covers what's in the panel, how admin access works, and the two features that need the most explanation: the audit log and impersonation.

Admin sections

The sidebar (apps/web/components/admin/sidebar.tsx) links these sections:

SectionRouteWhat it does
Dashboard/adminOverview landing page
Users/admin/usersBrowse users, view detail, ban, and impersonate
Database/admin/databaseBrowse and edit any table (see below)
Analytics/admin/analyticsApp/usage analytics
AI Models/admin/ai-modelsConfigure available AI models
AI Usage/admin/ai-usageAI usage/consumption metrics
Health/admin/healthSystem health checks
CRM/admin/crmContact/lead management
Campaigns/admin/campaignsEmail marketing campaigns
Roles/admin/rolesCreate and assign RBAC roles
Waitlist/admin/waitlistReview and approve waitlist signups
Admin Actions/admin/logsThe narrow admin_logs table
Activity Log/admin/audit-logThe general-purpose audit trail
Dev Wiki/admin/docsThe admin-only developer documentation

Related feature pages: Campaigns, Waitlist, and AI chat cover those sections from the product side.

How a user becomes an admin

Admin identity has one source of truth: the integer isAdmin column on the user table (isAdmin = 1 means admin). There are three ways it gets set:

  1. ADMIN_EMAILS env var — a comma-separated allowlist. On startup, bootstrapAdminUsers() (lib/db.ts) promotes any existing user whose email matches. A user who signs up after startup is promoted by the databaseHooks.user.create.after hook in lib/auth.ts. Both paths also set the Better Auth role = "admin".
  2. Dev seed — in development only (never in production), seedDefaultAdmin() creates admin@example.com / password as a ready-to-use admin so you can log into /admin immediately.
  3. Direct database write — setting isAdmin = 1 on a row (e.g. via the database browser) makes that user an admin.

Why two admin fields? The app keys off isAdmin, but the Better Auth admin plugin keys off role. They're kept in lockstep — role = "admin" is synced wherever isAdmin = 1 — so both the app's checks and the plugin's impersonation gate agree. See Authentication for the plugin details.

Gating your own admin routes

Use the helpers in lib/admin.ts. requireAdmin(request) returns either a session or a ready-to-return error response:

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

export async function GET(request: Request) {
  const { session, error } = await requireAdmin(request);
  if (error) return error; // 401 if not signed in, 403 if not admin
  // ... session.user.id is a confirmed admin
}

isAdmin(userId) is the lower-level boolean check if you just need a yes/no.

RBAC: roles and permissions

Alongside the binary isAdmin superadmin flag, Valcraven has a full role-based access control layer (lib/rbac.ts, lib/permissions.ts) managed from /admin/roles. This lets you grant fine-grained access without making someone a full superadmin.

  • Permissions are colon-separated strings like items:create or admin:users. The wildcard * grants everything. The full set lives in PERMISSIONS in lib/permissions.ts (items CRUD plus admin:users, admin:roles, admin:settings, admin:waitlist, admin:logs, admin:analytics, admin:crm, admin:database, admin:campaigns).
  • Roles bundle permissions. Three system roles are seeded (migration 009_create_rbac.sql): owner (*), admin, and member. System roles can't be edited or deleted; you can create your own custom roles from the Roles page.
  • Assignment — users can be assigned one or more roles; their effective permissions are the union.

Gate a route on a permission with requirePermission:

import { requirePermission } from "@/lib/rbac";
import { PERMISSIONS } from "@/lib/permissions";

export async function POST(request: Request) {
  const { session, error } = await requirePermission(request, PERMISSIONS.ADMIN_CRM);
  if (error) return error;
  // ...
}

Superadmins (isAdmin = 1) bypass RBAC entirely — hasPermission() short-circuits to true for them. RBAC governs everyone else.

Audit log

Every security- and account-relevant event is recorded to a single audit_log table, viewable at /admin/audit-log (sidebar → Activity Log). You can filter by action, resource type, actor, and date range, and free-text search over metadata and actor email.

Auth events (auth.login, auth.logout, auth.password_change, 2FA changes), admin actions, billing events, and the example items CRUD are wired to log automatically. To record your own events, use lib/audit.tslogAudit() never throws, so a failed audit write can't break the operation that triggered it:

import { logAuditFromRequest } from "@/lib/audit";

// Inside an API route — pulls ip + user-agent from the request headers
await logAuditFromRequest(request, {
  action: "item.create",
  actorId: session.user.id,
  actorEmail: session.user.email,
  resourceType: "item",
  resourceId: id,
});

The older, narrower admin_logs table is still written (and shown at /admin/logs, Admin Actions) for backward compatibility — logAdminAction() mirrors into both. Entries older than AUDIT_LOG_RETENTION_DAYS (default 90) are pruned by a daily cleanup job. Full reference: /admin/docs/audit-log.

Impersonation

From a user's detail page (/admin/users), an admin can impersonate that user — sign in as them to reproduce a support issue from their perspective. This is built on the Better Auth admin plugin, so the session cookie is swapped correctly and restored on exit; no route needs special handling to "see" the impersonated user.

Key guardrails (enforced server-side in lib/auth.ts, not just the UI):

  • You cannot impersonate another admin, and you cannot impersonate yourself (both return 403).
  • Sessions auto-expire after 30 minutes.
  • Destructive and billing actions are blocked while impersonating: change password, change email, delete account, enable/disable 2FA, and Stripe checkout/portal.
  • A sticky amber banner appears app-wide during impersonation with an Exit button; start and stop are both audit-logged (admin.impersonate_start / admin.impersonate_stop), and any action taken while impersonating carries the admin's id in the audit entry's impersonatedBy field.

Full mechanics and the audit contract: /admin/docs/impersonation.

Database browser

/admin/database is a built-in table browser. It offers curated views of commonly inspected tables with sensible defaults, plus a generic browser (/admin/database/raw) that auto-discovers columns for any table and supports pagination, sorting, filtering, inline cell editing, and row deletion.

This is a powerful, admin-only tool: it can write to any column, including isAdmin. Treat access to it as equivalent to full control of the app, and keep the admin allowlist tight in production.

Where to look

ConcernFile
Admin gate + logging helpersapps/web/lib/admin.ts
RBAC (roles, permissions, checks)apps/web/lib/rbac.ts, apps/web/lib/permissions.ts
Admin bootstrap + dev seedapps/web/lib/db.ts
Layout + sidebarapps/web/app/admin/layout.tsx, apps/web/components/admin/sidebar.tsx
Audit logapps/web/lib/audit.ts
  • Authentication — sessions, the admin plugin, and how isAdmin/role are set

On this page