Un build di portale clienti è un problema di deflection prima che un problema di UI. La inbox di supporto ci dice già quali workflow il cliente è disposto a fare da sé; il portale è il posto che glielo permette. Ogni schermata ha un compito di una riga: togli questo tipo di ticket dalla inbox. La metrica di successo non è il design della schermata, è la percentuale di ticket che smettono di arrivare.
I cinque file qui sotto sono l'ossatura che l'ingaggio lascia in piedi. Il layout che condivide auth, il creatore di sessione Stripe Customer Portal, l'endpoint di gestione team con role guard, il grafico di usage, e la pagina delle API key.
1. Il layout che condivide auth
Il portale vive su /account/* dentro l'app del prodotto. Il layout legge la sessione esistente; una visita non autenticata fa redirect al sign-in del prodotto. Non c'è una superficie auth separata da mantenere.
// 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. Il creatore di sessione Stripe Customer Portal
L'endpoint crea una sessione di portale e torna una URL che il client apre. La return URL fa atterrare il cliente dentro il prodotto. La configuration ID del portale è fissata per ambiente, così un cambio in staging non può scolare in produzione.
// 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. L'endpoint di gestione team con role guard
Le scritture team passano da un endpoint unico che legge il ruolo dell'attore, valida l'azione contro una matrice di permessi e audita il cambio. La matrice vive in un file di config che il team può leggere; l'endpoint è piccolo.
// 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. Il grafico di usage
Il componente grafico legge dalla stessa fonte di metering che legge il finance. Il numero del cliente è il numero del finance; il portale non ha una copia propria che possa derivare. Due range (ultimi 30 giorni, ultimi 12 mesi) e 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>Carico l'usage…</p>
return (
<section className="ds-stack ds-stack--md">
<h2 className="ds-heading-ui">Ultimi 30 giorni</h2>
<LineChart series={data.data.last30} xKey="date" yKey="count" />
<h2 className="ds-heading-ui">Ultimi 12 mesi</h2>
<LineChart series={data.data.last12Months} xKey="date" yKey="count" />
<p className="ds-text-sm ds-text-secondary">
Proiezione del prossimo mese: {data.data.forecastNextMonth.toLocaleString('it-IT')} unità.
</p>
<a href="/api/account/usage/export.csv" className="ds-btn ds-btn--ghost">Scarica CSV</a>
</section>
)
}
5. La pagina delle API key
Il cliente crea, nomina, ritaglia e revoca le chiavi senza coinvolgimento di engineering. Le chiavi si mostrano una volta sola alla creazione; ne conserviamo un hash. La rotazione è un click, che crea una chiave nuova e fa scadere quella vecchia con un periodo di grazia.
// 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 key</h1>
<p className="ds-editorial-lede">Crea chiavi ritagliate per le integrazioni che usano questo account.</p>
</header>
<CreateKeyForm />
<KeyList keys={keys ?? []} />
</section>
)
}
6. Cosa compone questo
Il layout condiviso tiene il cliente in una sessione sola. Il creatore di sessione di portale passa il billing a Stripe senza far uscire il cliente dal prodotto. L'endpoint team sposta i cambi ruolo dalla inbox di supporto a un bottone. Il grafico di usage mostra al cliente lo stesso numero che vede il finance. La pagina delle API key chiude il ticket che fa perdere più tempo agli engineer.
La inbox di supporto torna a respirare. La coda del lunedì mattina è fatta dei casi veramente umani su cui il team voleva lavorare. Le domande ripetute smettono di arrivare perché la risposta è a un click. Il team cresciuto per gestire il volume di ticket si riprende il tempo per spedire il prodotto di cui il portale ora fa parte.