The internal cockpit your support and finance teams actually need
Cross-tenant customer search, signed impersonation that ends with a click, refund and credit issuance with audit, subscription overrides, webhook event replay. The admin surface modelled as a separate application, not as feature flags scattered across the customer app.
The problem
Most admin dashboards leak the customer app into the operations team
The pattern that breaks is admin functionality bolted onto the customer surface with `if (user.role === 'admin')` checks. Support reps end up tabbing between five customer-facing pages to do their job. The product engineer keeps adding edge cases the customer never sees but cannot delete. The audit story is whatever Postgres logs happen to record. We build the admin surface as a separate application: own route group, own middleware, own auth, own audit log on every action. Support gets a tool that fits the job; the customer codebase stops absorbing every operational request.
Our approach
The six layers of an admin dashboard build
Each layer solves a problem support, finance and operations actually hit. None of them is optional once a SaaS has more than a hundred paying customers.
- 01
Model the admin role separately from tenant roles
A separate `admin_users` table tracks who has admin privileges. Admin role is not a column on `members`; it cannot be granted by a tenant owner. Promoting someone to admin is itself a logged action; the audit trail starts before the first customer call.
- 02
Build the /admin route group with elevated auth
An entirely separate layout under `/admin/*` with its own middleware that requires admin authentication via a second factor. Admin sessions are short-lived; admin cookies are different from customer cookies. The admin app cannot be reached by a customer regardless of role confusion.
- 03
Cross-tenant read patterns
Admin queries run as service-role to bypass row-level security, but every read writes to the audit log. "Support viewed customer X's tickets" is itself a record. The customer can later see who accessed their data and when, the regulator gets the trail without the team building it under pressure.
- 04
Admin Server Actions with full audit
Refunds, credits, plan changes, suspensions and resets are typed Server Actions that wrap a transaction with an audit-event write. The handler signature is the same as a customer Server Action; the difference is the actor in the audit log and the elevated privilege.
- 05
Impersonation as a first-class pattern
A signed token grants the admin a time-limited, scope-limited view as a specific customer. The impersonation banner is always visible; every action under impersonation is double-logged (the admin actor and the customer subject). Ending impersonation is one click.
- 06
Audit log surface
The admin dashboard's own page lists every audit event with filters by tenant, actor, action and time. The same data feeds the customer-side "recent activity" view (scoped to their tenant only) and the regulator export (full trail, time-bounded).
What we deliver
Admin role model
A separate `admin_users` table with role enum (support, finance, engineer, owner). Grants are logged actions; the schema is in source-controlled migrations.
/admin route group
Dedicated layout under `app/admin/*`, separate auth middleware, second-factor required, short session, distinct cookie name from the customer app.
Cross-tenant customer search
A search Server Component that queries across tenants by email, customer name, or Stripe customer ID. Every search writes an `audit_events` row with what was looked up and by whom.
Tenant inspection view
A read-only deep view of any tenant's subscription state, member list, recent activity, Stripe invoice history and Supabase row counts. All reads logged.
Subscription overrides
Server Action to switch a plan, extend a trial, grant a credit, cancel a subscription or refund a charge. Each one transactional with the audit-event row written in the same transaction.
Refund and credit issuance
Wrapped calls to Stripe with reason codes, partial refunds supported, idempotency tokens to prevent double-refunds when an admin clicks twice.
Impersonation mode
A "view as customer" Server Action that mints a scoped admin session, displays a banner across every page, and ends impersonation cleanly when the admin clicks Exit.
Audit log viewer
Paginated, filterable view of every privileged action. Tenant scope, actor scope, action filter, date-range, export to CSV.
System health dashboard
Webhook event success rates, recent failures, queue depth, error rates, cron job last-run timestamps. The operational signal in one place.
Webhook event replay
Admin can replay a failed Stripe (or other provider) webhook event after fixing the underlying handler. Idempotency keeps the replay safe.
User management
Reset password as admin (sends the user a reset link), suspend a user, delete a user with GDPR cascade, merge two accounts when a customer asks.
Data export per tenant
A documented export procedure for full tenant data (GDPR Article 20 compliance), runs as a background job, delivers via signed link.
The admin middleware, an audited Server Action and impersonation
Three files that together carry the admin surface. The middleware blocks anyone without an admin session. The Server Action template wraps every privileged write with an audit-event row. The impersonation flow mints a scoped session that the customer-facing middleware also recognises.
The admin dashboard is not a customer-app surface with extra checks. It is a different application that shares the codebase. The three files below are what we ship: the middleware that gates every /admin/* route, a Server Action template that wraps every privileged write in an audit-event row, and the impersonation flow that mints a scoped session both the admin and the customer-facing middleware recognise.
1. The admin middleware
A second middleware (or a check at the top of the existing one) requires an admin session before allowing the request through. Admin cookies are distinct; admin sessions are short; second factor is enforced server-side.
// src/middleware.ts (admin segment)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyAdminSession } from '@/lib/auth/admin'
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/admin')) {
const adminSession = request.cookies.get('admin_session')?.value
if (!adminSession) {
const loginUrl = new URL('/admin/login', request.url)
loginUrl.searchParams.set('return', request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}
const adminContext = await verifyAdminSession(adminSession)
if (!adminContext) {
return NextResponse.redirect(new URL('/admin/login', request.url))
}
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-admin-id', adminContext.adminId)
requestHeaders.set('x-admin-role', adminContext.role)
return NextResponse.next({ request: { headers: requestHeaders } })
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
// src/lib/auth/admin.ts
import { jwtVerify } from 'jose'
import { adminClient } from '@/lib/supabase/admin'
const ADMIN_SESSION_SECRET = new TextEncoder().encode(
process.env.ADMIN_SESSION_SECRET!,
)
export interface AdminContext {
adminId: string
role: 'support' | 'finance' | 'engineer' | 'owner'
}
export async function verifyAdminSession(
token: string,
): Promise<AdminContext | null> {
let payload
try {
const verified = await jwtVerify(token, ADMIN_SESSION_SECRET)
payload = verified.payload as { adminId: string; mfaAt: number }
} catch {
return null
}
// Reject if MFA happened more than four hours ago.
if (Date.now() - payload.mfaAt > 4 * 60 * 60 * 1000) return null
// Verify the admin row still exists and the role hasn't been revoked.
const { data } = await adminClient
.from('admin_users')
.select('id, role, status')
.eq('id', payload.adminId)
.single()
if (!data || data.status !== 'active') return null
return { adminId: data.id, role: data.role }
}
2. The audited Server Action template
Every admin write goes through one wrapper that takes the privileged action, the audit metadata, and the function body. The wrapper writes the audit-event row in the same transaction as the action so partial failures cannot leave the database with the action done but unlogged.
// src/lib/admin/with-audit.ts
import { adminClient } from '@/lib/supabase/admin'
import { getAdminContext } from '@/lib/auth/admin-server'
interface AuditInput {
tenantId: string
action: string
resourceId?: string
metadata?: Record<string, unknown>
reason?: string
}
export async function withAudit<T>(
audit: AuditInput,
fn: () => Promise<T>,
): Promise<T> {
const adminContext = await getAdminContext()
if (!adminContext) throw new Error('admin auth required')
// Write the audit-event row first; the action runs only if the audit
// write succeeds, so we never run an action without a record of it.
const { data: eventRow, error: auditError } = await adminClient
.from('audit_events')
.insert({
tenant_id: audit.tenantId,
actor_admin_id: adminContext.adminId,
actor_role: adminContext.role,
action: audit.action,
resource_id: audit.resourceId ?? null,
metadata: audit.metadata ?? {},
reason: audit.reason ?? null,
status: 'pending',
})
.select('id')
.single()
if (auditError || !eventRow) throw new Error('audit write failed')
try {
const result = await fn()
await adminClient
.from('audit_events')
.update({ status: 'success' })
.eq('id', eventRow.id)
return result
} catch (err) {
await adminClient
.from('audit_events')
.update({
status: 'failed',
error: err instanceof Error ? err.message : 'unknown',
})
.eq('id', eventRow.id)
throw err
}
}
// app/admin/customers/[tenantId]/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { withAudit } from '@/lib/admin/with-audit'
import { stripe } from '@/lib/stripe/server'
const Input = z.object({
tenantId: z.string().uuid(),
chargeId: z.string(),
amountCents: z.number().int().positive(),
reason: z.string().min(5).max(500),
})
export async function refundCharge(
raw: unknown,
): Promise<{ ok: true; refundId: string } | { ok: false; error: string }> {
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
try {
const refundId = await withAudit(
{
tenantId: parsed.data.tenantId,
action: 'charge.refund',
resourceId: parsed.data.chargeId,
metadata: { amount_cents: parsed.data.amountCents },
reason: parsed.data.reason,
},
async () => {
const refund = await stripe.refunds.create({
charge: parsed.data.chargeId,
amount: parsed.data.amountCents,
reason: 'requested_by_customer',
metadata: {
tenant_id: parsed.data.tenantId,
admin_reason: parsed.data.reason,
},
})
return refund.id
},
)
revalidatePath(`/admin/customers/${parsed.data.tenantId}`)
return { ok: true, refundId }
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : 'refund failed',
}
}
}
3. The impersonation flow
When a support admin needs to see what a customer sees, the application mints a scoped impersonation token. The token carries the customer's tenant ID plus the admin's identity. The customer-facing middleware recognises the token, exposes both IDs in headers, and surfaces a banner on every page so the admin always knows they are inside someone else's account.
// app/admin/customers/[tenantId]/impersonate.ts
'use server'
import { SignJWT } from 'jose'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { withAudit } from '@/lib/admin/with-audit'
import { getAdminContext } from '@/lib/auth/admin-server'
const IMPERSONATION_SECRET = new TextEncoder().encode(
process.env.IMPERSONATION_SECRET!,
)
const TTL_MINUTES = 30
export async function startImpersonation(
tenantId: string,
reason: string,
): Promise<void> {
const adminContext = await getAdminContext()
if (!adminContext) throw new Error('admin auth required')
await withAudit(
{
tenantId,
action: 'impersonation.start',
metadata: { ttl_minutes: TTL_MINUTES },
reason,
},
async () => {
const token = await new SignJWT({
adminId: adminContext.adminId,
adminRole: adminContext.role,
impersonatingTenantId: tenantId,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${TTL_MINUTES}m`)
.sign(IMPERSONATION_SECRET)
const cookieStore = await cookies()
cookieStore.set('impersonation', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: TTL_MINUTES * 60,
path: '/',
})
},
)
redirect(`/dashboard?impersonating=1`)
}
export async function endImpersonation(): Promise<void> {
const cookieStore = await cookies()
cookieStore.delete('impersonation')
redirect('/admin')
}
4. What this composes
The middleware blocks anyone without an admin session from the /admin/* route group. The audit wrapper records every privileged action before it runs, then records its outcome. The impersonation flow mints a scoped session both middlewares recognise, with a banner that makes the state visible and an exit that ends it cleanly.
The admin dashboard is the application built on those three primitives. Refunds, plan changes, customer search, webhook replay, GDPR exports: each one is a Server Action wrapped in withAudit, written under the admin route group, with the same conventions as the customer-side code but a different actor in the audit trail. The customer codebase stops absorbing operational requests; the support team gets a tool that fits the job; the regulator gets a complete trail without anyone building it after the fact.
Frequently asked questions
Why a separate admin app instead of feature flags?
Because the failure mode of 'admin features in the customer app' is a junior engineer accidentally exposing a privileged route. A separate `/admin/*` group with its own middleware makes that impossible to do by accident. The admin app shares the codebase but is a different application from the customer's perspective.
Should admins be in the same auth system as customers?
Same auth provider (Supabase Auth in our default), different role, different session, often second factor required for admin sign-in. Admins do not have a tenant; they have an admin role granted explicitly through the `admin_users` table. Promotion to admin is itself a logged action that cannot be done from the customer app.
How do you handle impersonation safely?
Time-limited signed token (typically thirty minutes), scope-limited to read or read-write depending on the support task, visible banner on every page, both the admin actor and the customer subject logged on every action. Ending impersonation is a single click; the token expires server-side, not just in the cookie.
Do admins bypass row-level security?
Yes, admins read as service-role. The price of that bypass is that every admin read is logged. The customer sees the trail in their own dashboard; the regulator gets it on request. Bypassing RLS without logging is a security regression; bypassing RLS with full audit is a compliance feature.
How do you keep the admin app secure?
Second factor required for sign-in, short sessions, IP allowlist for sensitive operations, separate domain (or path scope) for cookies, rate limiting on every privileged action. The same hygiene we apply to the customer app, with the dials turned up because the blast radius is bigger.
How long does an admin dashboard take to build?
A useful first version (search, tenant inspection, audit log, basic impersonation) ships in two to three weeks. Refunds, subscription overrides, webhook replay and data export add another one to two weeks. The build scales with the operational surface; we scope per feature so the contract has a verifiable definition of done.
Can the customer see the audit log of admin actions on their tenant?
Yes. The same `audit_events` table feeds the customer-side 'recent activity' view, scoped to their tenant only. They see exactly when an admin accessed their data, what action was performed, and the reason if the action required one. Transparency is a sales tool.
Tell us about your admin needs
A scoping call, a concrete number in the first reply, no agency theater. A useful admin dashboard in two to three weeks.