El dashboard de admin no es una vista de la app del cliente con checks extra, es una aplicación distinta que comparte el codebase. Los tres archivos de abajo son los que enviamos: el middleware que controla cada ruta /admin/*, un template de Server Action que envuelve cada escritura privilegiada en una fila de audit-event, y el flujo de impersonation que acuña una sesión acotada que tanto el middleware admin como el del lado cliente reconocen.
1. El middleware de admin
Un segundo middleware (o un check al principio del existente) exige una sesión de admin antes de dejar pasar la petición. Las cookies de admin son distintas; las sesiones de admin son cortas; el segundo factor se aplica en servidor.
// src/middleware.ts (segmento admin)
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
}
// Rechaza si el MFA ocurrió hace más de cuatro horas.
if (Date.now() - payload.mfaAt > 4 * 60 * 60 * 1000) return null
// Verifica que la fila admin siga existiendo y que el rol no se haya revocado.
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. El template de Server Action auditada
Cada escritura de admin pasa por un wrapper que toma la acción privilegiada, los metadatos de auditoría y el cuerpo de la función. El wrapper escribe la fila de audit-event en la misma transacción que la acción, así los fallos parciales no pueden dejar la base de datos con la acción hecha pero sin registrar.
// 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')
// Escribe primero la fila de audit-event; la acción corre solo si la
// escritura de auditoría tiene éxito, así nunca corremos una acción sin
// un registro de ella.
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. El flujo de impersonation
Cuando un admin de soporte necesita ver lo que ve un cliente, la aplicación acuña un token de impersonation acotado. El token lleva el ID del tenant del cliente más la identidad del admin. El middleware customer-facing reconoce el token, expone los dos IDs en headers y hace surgir un banner en cada página para que el admin sepa siempre que está dentro de la cuenta de otro.
// 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. Cómo encaja todo esto
El middleware bloquea el route group /admin/* a cualquiera sin sesión de admin. El wrapper de auditoría registra cada acción privilegiada antes de que corra, y luego registra su resultado. El flujo de impersonation acuña una sesión acotada que los dos middlewares reconocen, con un banner que hace visible el estado y una salida que la cierra limpia.
El dashboard de admin es la aplicación construida sobre esas tres primitivas. Refunds, cambios de plan, búsqueda de clientes, replay de webhooks, export GDPR: cada una es una Server Action envuelta en withAudit, escrita bajo el route group de admin, con las mismas convenciones que el código del lado cliente pero un actor distinto en la traza de auditoría. El codebase del cliente deja de absorber peticiones operativas, el equipo de soporte recibe una herramienta que encaja con el trabajo, y el regulador recibe una traza completa sin que nadie la construya a posteriori.