Caso de uso · Dashboard de admin

El cockpit interno que tus equipos de soporte y finanzas necesitan de verdad

Búsqueda de clientes cross-tenant, impersonation firmada que termina con un click, emisión de refunds y créditos con audit, override de subscriptions, replay de webhook events. La superficie de admin modelada como una aplicación aparte, no como feature flags repartidos por la app customer-facing.

El problema

La mayoría de los dashboards de admin obliga al equipo operativo a usar la app pensada para el cliente

El patrón que se rompe es el de funcionalidad de admin atornillada en la app del cliente con checks `if (user.role === 'admin')`. Los agentes de soporte acaban saltando entre cinco páginas pensadas para el cliente para hacer su trabajo. El ingeniero de producto sigue añadiendo casos límite que el cliente no ve pero que pesan en el código. La historia de auditoría es lo poco que queda en los logs de Postgres. Construimos la app de admin aparte: route group propio, middleware propio, auth propia, audit log en cada acción. El soporte recibe una herramienta que encaja con el trabajo, y el codebase del cliente deja de absorber cada petición operativa.

Cómo lo abordamos

Las seis capas de la construcción de un dashboard de admin

Cada capa resuelve un problema que soporte, finanzas y operaciones se encuentran de verdad. Ninguna de ellas es opcional una vez que un SaaS pasa de cien clientes pagantes.

  1. 01

    Modelar el rol de admin separado de los roles de tenant

    Una tabla `admin_users` aparte traza quién tiene privilegios de admin. El rol de admin no es una columna en `members`; no puede ser concedido por el owner de un tenant. Promover a alguien a admin es en sí misma una acción loggeada; la traza de auditoría arranca antes de la primera llamada de un cliente.

  2. 02

    Construir el route group /admin con auth elevada

    Un layout completamente aparte bajo `/admin/*` con su propio middleware que exige autenticación de admin con segundo factor. Las sesiones de admin son cortas; las cookies de admin son distintas de las cookies de cliente. La app de admin no se alcanza desde el lado cliente por mucha confusión de roles que haya.

  3. 03

    Patrones de lectura cross-tenant

    Las queries de admin corren como service-role para esquivar row-level security, pero cada lectura escribe en el audit log. "Soporte vio los tickets del cliente X" es en sí un registro. El cliente luego puede ver quién accedió a sus datos y cuándo, el regulador recibe la traza sin que el equipo la construya bajo presión.

  4. 04

    Server Actions de admin con auditoría completa

    Refunds, créditos, cambios de plan, suspensiones y resets son Server Actions tipadas que envuelven una transacción con una escritura de audit-event. La firma del handler es la misma que una Server Action customer; lo que cambia es el actor en el audit log y el privilegio elevado.

  5. 05

    Impersonation como patrón de primera clase

    Un token firmado concede al admin una vista acotada en alcance, limitada en el tiempo, como un cliente específico. El banner de impersonation siempre se ve; cada acción bajo impersonation se loggea dos veces (el actor admin y el sujeto cliente). Salir de la impersonation es un click.

  6. 06

    Vista del audit log

    La propia página del dashboard de admin lista cada audit event con filtros por tenant, actor, acción y tiempo. Los mismos datos alimentan la vista "actividad reciente" del lado cliente (acotada solo a su tenant) y el export para el regulador (traza completa, limitada en el tiempo).

Qué entregamos

Modelo del rol de admin

Una tabla `admin_users` aparte con enum de rol (support, finance, engineer, owner). Las concesiones son acciones loggeadas; el schema vive en migrations bajo source control.

Route group /admin

Layout dedicado bajo `app/admin/*`, middleware de auth aparte, segundo factor obligatorio, sesión corta, nombre de cookie distinto del de la app customer.

Búsqueda de clientes cross-tenant

Un Server Component de búsqueda que consulta entre tenants por email, nombre del cliente o ID de customer Stripe. Cada búsqueda escribe una fila `audit_events` con qué se buscó y por quién.

Vista de inspección de tenant

Una vista profunda de solo lectura del estado de subscription de cualquier tenant, lista de miembros, actividad reciente, historial de facturas Stripe y conteos de filas Supabase. Todas las lecturas loggeadas.

Override de subscriptions

Server Action para cambiar un plan, extender un trial, conceder un crédito, cancelar una subscription o reembolsar un cargo. Cada una transaccional con la fila de audit-event escrita en la misma transacción.

Emisión de refunds y créditos

Llamadas envueltas a Stripe con códigos de motivo, refunds parciales soportados, tokens de idempotencia para evitar refunds dobles cuando un admin clica dos veces.

Modo de impersonation

Una Server Action "ver como cliente" que acuña una sesión admin con permisos limitados, muestra un banner en cada página y cierra la impersonation de forma limpia cuando el admin clica Salir.

Visor del audit log

Vista paginada y filtrable de cada acción privilegiada. Scope por tenant, scope por actor, filtro de acción, rango de fechas, export a CSV.

Dashboard de salud del sistema

Tasa de éxito de webhook events, fallos recientes, profundidad de la cola, tasa de error, timestamps del último run de los cron jobs. La señal operativa en un solo sitio.

Replay de webhook events

El admin puede hacer replay de un webhook event Stripe (u otro proveedor) fallido tras arreglar el handler subyacente. La idempotencia mantiene seguro el replay.

Gestión de usuarios

Reset de contraseña como admin (manda al usuario un enlace de reset), suspensión de un usuario, borrado de un usuario con cascade GDPR, merge de dos cuentas cuando un cliente lo pide.

Export de datos por tenant

Un procedimiento de export documentado para los datos completos del tenant (cumplimiento GDPR Artículo 20), corre como job en background, entrega vía enlace firmado.

El middleware de admin, una Server Action auditada y la impersonation

Tres archivos que juntos sostienen la superficie de admin. El middleware bloquea a cualquiera sin sesión de admin. El template de Server Action envuelve cada escritura privilegiada con una fila audit-event. El flujo de impersonation acuña una sesión acotada que tanto el middleware admin como el customer-facing reconocen.

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.

Stacks relacionados

Preguntas frecuentes

¿Por qué una app de admin aparte en lugar de feature flags?

Porque el modo de fallo de "funciones de admin en la app customer" es un ingeniero junior exponiendo por error una ruta privilegiada. Un route group `/admin/*` aparte con su propio middleware hace que eso sea imposible por accidente. La app de admin comparte el codebase pero es una aplicación distinta desde la perspectiva del cliente.

¿Los admins deberían estar en el mismo sistema de auth que los clientes?

Mismo proveedor de auth (Supabase Auth en nuestro default), rol distinto, sesión distinta, a menudo segundo factor obligatorio para el sign-in de admin. Los admins no tienen tenant; tienen un rol de admin concedido explícitamente a través de la tabla `admin_users`. La promoción a admin es en sí misma una acción loggeada que no se puede hacer desde la app customer.

¿Cómo manejáis la impersonation con seguridad?

Token firmado con TTL corto (típicamente treinta minutos), con permisos limitados a lectura o lectura-escritura según la tarea de soporte, banner visible en cada página, tanto el actor admin como el sujeto cliente loggeados en cada acción. Salir de la impersonation es un click; el token expira en servidor, no solo en la cookie.

¿Los admins esquivan row-level security?

Sí, los admins leen como service-role. El precio de ese bypass es que cada lectura de admin se loggea. El cliente ve la traza en su propio dashboard; el regulador la recibe a petición. Esquivar RLS sin logging es una regresión de seguridad; esquivar RLS con auditoría completa es una funcionalidad de cumplimiento.

¿Cómo mantenéis segura la app de admin?

Segundo factor obligatorio al sign-in, sesiones cortas, allowlist de IPs para operaciones sensibles, dominio aparte (o scope de path) para las cookies, rate limiting en cada acción privilegiada. La misma higiene que aplicamos a la app customer, con las palancas subidas porque el blast radius es mayor.

¿Cuánto tarda en construirse un dashboard de admin?

Una primera versión útil (búsqueda, inspección de tenant, audit log, impersonation básica) sale en dos o tres semanas. Refunds, override de subscriptions, replay de webhooks y export de datos añaden otra semana o dos. El trabajo crece con el alcance operativo; lo definimos por feature para que el contrato tenga una definición verificable de done.

¿El cliente puede ver el audit log de las acciones de admin sobre su tenant?

Sí. La misma tabla `audit_events` alimenta la vista "actividad reciente" del lado cliente, acotada solo a su tenant. Ve exactamente cuándo accedió un admin a sus datos, qué acción se hizo y el motivo si la acción lo requería. La transparencia es una herramienta de venta.

Cuéntanos vuestras necesidades de admin

Una llamada de scoping, un número concreto en la primera respuesta, sin teatro de agencia. Un dashboard de admin útil en dos o tres semanas.