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:
| Section | Route | What it does |
|---|---|---|
| Dashboard | /admin | Overview landing page |
| Users | /admin/users | Browse users, view detail, ban, and impersonate |
| Database | /admin/database | Browse and edit any table (see below) |
| Analytics | /admin/analytics | App/usage analytics |
| AI Models | /admin/ai-models | Configure available AI models |
| AI Usage | /admin/ai-usage | AI usage/consumption metrics |
| Health | /admin/health | System health checks |
| CRM | /admin/crm | Contact/lead management |
| Campaigns | /admin/campaigns | Email marketing campaigns |
| Roles | /admin/roles | Create and assign RBAC roles |
| Waitlist | /admin/waitlist | Review and approve waitlist signups |
| Admin Actions | /admin/logs | The narrow admin_logs table |
| Activity Log | /admin/audit-log | The general-purpose audit trail |
| Dev Wiki | /admin/docs | The 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:
ADMIN_EMAILSenv 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 thedatabaseHooks.user.create.afterhook inlib/auth.ts. Both paths also set the Better Authrole = "admin".- Dev seed — in development only (never in production),
seedDefaultAdmin()createsadmin@example.com/passwordas a ready-to-use admin so you can log into/adminimmediately. - Direct database write — setting
isAdmin = 1on 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 offrole. They're kept in lockstep —role = "admin"is synced whereverisAdmin = 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:createoradmin:users. The wildcard*grants everything. The full set lives inPERMISSIONSinlib/permissions.ts(items CRUD plusadmin: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 totruefor 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.ts — logAudit() 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'simpersonatedByfield.
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
| Concern | File |
|---|---|
| Admin gate + logging helpers | apps/web/lib/admin.ts |
| RBAC (roles, permissions, checks) | apps/web/lib/rbac.ts, apps/web/lib/permissions.ts |
| Admin bootstrap + dev seed | apps/web/lib/db.ts |
| Layout + sidebar | apps/web/app/admin/layout.tsx, apps/web/components/admin/sidebar.tsx |
| Audit log | apps/web/lib/audit.ts |
Related
- Authentication — sessions, the admin plugin, and how
isAdmin/roleare set
Realtime
A generic per-channel live-event layer — publish() on the server, useRealtime() on the client, gated by a single deny-by-default authorization seam and fanned out by a Durable Object.
Billing & subscriptions
Stripe-powered subscription and lifetime checkout, the customer portal, and how webhooks keep each user's plan in sync.