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.