Un build de portal de cliente es un problema de deflection antes que un problema de UI. La bandeja de soporte ya nos dice qué workflows está dispuesto a hacer el cliente por su cuenta; el portal es el sitio donde se lo permitimos. Cada pantalla tiene un trabajo de una línea: saca este tipo de ticket de la bandeja. La métrica de éxito no es el diseño de la pantalla, es el porcentaje de tickets que dejan de llegar.
Los cinco archivos de abajo son la armadura que deja el encargo. El layout que comparte auth, el creador de sesión de Stripe Customer Portal, el endpoint de gestión de equipo con role guards, el gráfico de usage, y la página de API keys.
1. El layout que comparte auth
El portal vive en /account/* dentro de la app del producto. El layout lee la sesión existente; una visita sin autenticar redirige al sign-in del producto. No hay una superficie auth separada que mantener.
// app/account/layout.tsx
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth/session'
import { AccountNav } from '@/components/account/AccountNav'
export default async function AccountLayout({ children }: { children: React.ReactNode }) {
const session = await getServerSession()
if (!session) {
redirect('/auth/sign-in?next=/account')
}
return (
<div className="ds-account-shell">
<AccountNav user={session.user} />
<main className="ds-account-content">{children}</main>
</div>
)
}
2. El creador de sesión de Stripe Customer Portal
El endpoint crea una sesión de portal y devuelve una URL que el cliente abre. La return URL deja al cliente dentro del producto. El configuration ID del portal está fijado por entorno, así un cambio en staging no se cuela en producción.
// app/api/billing/portal-session/route.ts
import Stripe from 'stripe'
import { getServerSession } from '@/lib/auth/session'
import { getCustomer } from '@/lib/billing/customer'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(): Promise<Response> {
const session = await getServerSession()
if (!session) {
return Response.json({ success: false, error: 'unauthorized' }, { status: 401 })
}
const customer = await getCustomer(session.user.id)
if (!customer?.stripeCustomerId) {
return Response.json({ success: false, error: 'no_customer' }, { status: 400 })
}
const portal = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: `${process.env.PUBLIC_APP_URL}/account/billing`,
configuration: process.env.STRIPE_PORTAL_CONFIG_ID,
})
return Response.json({ success: true, data: { url: portal.url } })
}
3. El endpoint de gestión de equipo con role guards
Las escrituras de equipo pasan por un único endpoint que lee el rol del actor, valida la acción contra una matriz de permisos y audita el cambio. La matriz vive en un archivo de config que el equipo puede leer; el endpoint es pequeño.
// app/api/team/members/route.ts
import { getServerSession } from '@/lib/auth/session'
import { adminDb } from '@/lib/admin/db'
import { canPerform } from '@/lib/permissions/can'
interface ChangeRolePayload {
memberId: string
newRole: 'admin' | 'member' | 'viewer'
}
export async function PATCH(req: Request): Promise<Response> {
const session = await getServerSession()
if (!session) {
return Response.json({ success: false, error: 'unauthorized' }, { status: 401 })
}
const body = (await req.json()) as ChangeRolePayload
const actor = await loadMember(session.user.id)
if (!canPerform(actor, 'team.change_role', { targetMemberId: body.memberId })) {
return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
}
const db = await adminDb()
const before = await db.from('team_members').select('*').eq('id', body.memberId).single()
await db.from('team_members').update({ role: body.newRole }).eq('id', body.memberId)
const after = await db.from('team_members').select('*').eq('id', body.memberId).single()
await db.from('portal_audit').insert({
actor_user_id: session.user.id,
entity_type: 'team_member',
entity_id: body.memberId,
action: 'change_role',
before: before.data,
after: after.data,
})
await notifyTeam({ event: 'role_changed', memberId: body.memberId, newRole: body.newRole })
return Response.json({ success: true, data: after.data })
}
async function loadMember(userId: string) { /* ... */ return { id: userId, role: 'admin' as const } }
async function notifyTeam(_input: { event: string; memberId: string; newRole: string }) { /* ... */ }
4. El gráfico de usage
El componente del gráfico lee del mismo origen de metering que lee finance. El número del cliente es el número de finance; el portal no tiene una copia propia que pueda derivar. Dos rangos (últimos 30 días, últimos 12 meses) y un export CSV.
// app/account/usage/UsageChart.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
import { LineChart } from '@/components/charts/LineChart'
interface UsageRange {
date: string
count: number
}
interface UsageResponse {
success: boolean
data: { last30: UsageRange[]; last12Months: UsageRange[]; forecastNextMonth: number }
}
export function UsageChart() {
const { data, isLoading } = useQuery<UsageResponse>({
queryKey: ['account', 'usage'],
queryFn: async () => (await fetch('/api/account/usage')).json() as Promise<UsageResponse>,
staleTime: 5 * 60_000,
})
if (isLoading || !data?.success) return <p>Cargando el usage…</p>
return (
<section className="ds-stack ds-stack--md">
<h2 className="ds-heading-ui">Últimos 30 días</h2>
<LineChart series={data.data.last30} xKey="date" yKey="count" />
<h2 className="ds-heading-ui">Últimos 12 meses</h2>
<LineChart series={data.data.last12Months} xKey="date" yKey="count" />
<p className="ds-text-sm ds-text-secondary">
Previsión del mes que viene: {data.data.forecastNextMonth.toLocaleString('es-ES')} unidades.
</p>
<a href="/api/account/usage/export.csv" className="ds-btn ds-btn--ghost">Descargar CSV</a>
</section>
)
}
5. La página de API keys
El cliente crea, nombra, recorta y revoca claves sin que engineering intervenga. Las claves se muestran solo una vez en la creación; guardamos un hash. La rotación es un clic, que crea una clave nueva y le mete a la vieja un periodo de gracia antes de expirar.
// app/account/api-keys/page.tsx
import { adminDb } from '@/lib/admin/db'
import { getServerSession } from '@/lib/auth/session'
import { CreateKeyForm } from './CreateKeyForm'
import { KeyList } from './KeyList'
export default async function ApiKeysPage() {
const session = await getServerSession()
if (!session) return null
const db = await adminDb()
const { data: keys } = await db
.from('api_keys')
.select('id, name, scope, last_used_at, created_at, revoked_at')
.eq('owner_user_id', session.user.id)
.order('created_at', { ascending: false })
return (
<section className="ds-stack ds-stack--lg">
<header className="ds-stack ds-stack--sm">
<h1 className="ds-editorial-title">API keys</h1>
<p className="ds-editorial-lede">Crea claves recortadas para las integraciones que usan esta cuenta.</p>
</header>
<CreateKeyForm />
<KeyList keys={keys ?? []} />
</section>
)
}
6. Lo que esto compone
El layout compartido mantiene al cliente en una sola sesión. El creador de sesión de portal le pasa el billing a Stripe sin sacar al cliente del producto. El endpoint de equipo mueve los cambios de rol de la bandeja de soporte a un botón. El gráfico de usage le enseña al cliente el mismo número que ve finance. La página de API keys cierra el ticket que más tiempo le hacía perder al engineer.
La bandeja de soporte vuelve a respirar. La cola del lunes por la mañana son los casos verdaderamente humanos en los que el equipo quería trabajar. Las preguntas que se repiten dejan de llegar porque la respuesta está a un clic. El equipo que creció para gestionar el volumen de tickets recupera el tiempo para entregar el producto del que ahora el portal forma parte.