Il pannello interno per chi gestisce clienti, rimborsi e fatture
Cerca un cliente fra tutti gli account, entra temporaneamente nel suo spazio per aiutarlo, emette rimborsi e crediti lasciando una traccia, cambia il piano dall'esterno, rilancia una notifica Stripe non ricevuta. Tutto in un'app separata, non incollata al codice che vede il cliente.
Il problema
Quasi tutti i pannelli interni costringono il team operativo a usare l'app pensata per il cliente
L'errore più comune è incollare la parte admin a quella cliente con un controllo del tipo `if (user.role === 'admin')`. Risultato: chi gestisce l'assistenza salta fra cinque schermate fatte per il cliente per portare a termine il proprio lavoro. Lo sviluppatore continua ad aggiungere eccezioni che il cliente non vede ma che pesano sul codice. Lo storico di chi ha fatto cosa è quel poco che resta nei log del database. La nostra strada è diversa: il pannello admin è un'applicazione separata, con la sua route, il suo filtro di accesso, la sua autenticazione e una traccia su ogni azione. Chi fa assistenza riceve uno strumento adatto al lavoro, e il codice del cliente smette di accumulare richieste operative.
Come lo affrontiamo
I sei livelli di un pannello di amministrazione
Ogni livello risolve un problema che chi fa assistenza, chi gestisce fatture e chi tiene in piedi il sistema vive davvero. Nessuno è opzionale, una volta che il SaaS supera i 100 clienti paganti.
- 01
Tenere il ruolo admin separato dai ruoli interni del cliente
Una tabella `admin_users` a parte traccia chi ha privilegi di amministratore del prodotto. Il ruolo admin non vive nella tabella dei membri di un cliente; non può essere assegnato dal cliente. Promuovere qualcuno ad admin è di per sé un'azione registrata; la traccia parte prima ancora che arrivi la prima telefonata di assistenza.
- 02
Una sezione `/admin` con autenticazione rinforzata
Sotto `/admin/*` vive un'app separata con il suo filtro di accesso, che chiede autenticazione admin con secondo fattore. Le sessioni admin durano poco; i cookie sono distinti da quelli del cliente. L'app admin non è raggiungibile da un cliente in nessun modo, anche con un errore di assegnazione di ruolo.
- 03
Lettura su tutti i clienti contemporaneamente
Le interrogazioni dell'admin girano con permessi elevati che attraversano la barriera fra i dati dei vari clienti, ma ogni lettura scrive una riga nello storico delle azioni. "L'assistenza ha visto i ticket del cliente X" è di per sé un evento tracciato. Il cliente può vedere chi ha acceduto ai suoi dati e quando; il regolatore riceve la traccia completa senza che il team la ricostruisca sotto pressione.
- 04
Le azioni dell'admin con traccia completa
Rimborsi, crediti, cambi piano, sospensioni e reset password sono Server Action tipizzate che racchiudono una transazione e scrivono una riga di tracciamento nello stesso istante. La firma è la stessa di una Server Action lato cliente; la differenza è chi appare come autore nello storico e il livello di permessi.
- 05
Vedere il prodotto come lo vede il cliente
Un token firmato dà all'admin una sessione temporanea a permessi ridotti, che vede il prodotto come lo vede uno specifico cliente. In ogni pagina compare un avviso ben visibile; ogni azione fatta in questa modalità è registrata due volte (l'admin che la compie e il cliente che la subisce). Uscire dalla modalità è un click.
- 06
Lo storico completo delle azioni
La pagina dedicata del pannello admin elenca ogni evento registrato, con filtri per cliente, autore dell'azione, tipo di azione e momento. Gli stessi dati alimentano la sezione "attività recenti" che vede il cliente (limitata al suo solo account) e l'export per il regolatore (storico completo, in finestra temporale).
Cosa consegniamo
Tabella dei ruoli admin
Una tabella `admin_users` separata con i ruoli previsti (assistenza, amministrazione, sviluppatore, proprietario). Ogni assegnazione è un'azione registrata; lo schema vive nelle migration in versionamento.
Sezione `/admin` dedicata
Un layout a parte sotto `app/admin/*`, con un proprio filtro di autenticazione, secondo fattore obbligatorio, sessione di breve durata, nome del cookie distinto da quello dell'app cliente.
Ricerca su tutti i clienti
Un Server Component di ricerca che cerca su tutti i clienti per email, nome o ID cliente Stripe. Ogni ricerca scrive una riga `audit_events` con cosa è stato cercato e da chi.
Pagina di ispezione del cliente
Una pagina di sola lettura sullo stato dell'abbonamento di qualunque cliente, con elenco membri, attività recente, storico fatture Stripe e conteggi delle righe Supabase. Ogni lettura registrata.
Modifica manuale degli abbonamenti
Server Action per cambiare il piano, prolungare la prova, concedere un credito, disdire un abbonamento o rimborsare un pagamento. Ogni operazione è transazionale, con la riga di tracciamento scritta nella stessa transazione.
Rimborsi e crediti
Chiamate avvolte a Stripe con codice di motivo, rimborsi parziali supportati, token di idempotenza per evitare doppi rimborsi se l'admin clicca due volte.
Vedere come il cliente
Una Server Action "view as customer" che conia una sessione admin a permessi ridotti, mostra un avviso su ogni pagina e termina la modalità in modo pulito quando l'admin clicca Esci.
Storico delle azioni
Pagina paginata e filtrabile di ogni azione registrata. Filtri per cliente, autore, tipo di azione, intervallo di date, export in CSV.
Stato di salute del sistema
Tasso di successo dei webhook ricevuti, errori recenti, profondità delle code di lavoro, error rate, ultimo run dei cron job. I segnali operativi in un unico posto.
Rilancio dei webhook
L'admin può rilanciare un webhook Stripe (o di un altro provider) andato a vuoto dopo aver corretto l'handler. L'idempotenza fa sì che il rilancio resti sicuro.
Gestione utenti
Reset password lato admin (l'utente riceve un link via email), sospensione, cancellazione con eliminazione a cascata per GDPR, fusione di due account quando il cliente lo chiede.
Export dati di un cliente
Una procedura documentata per esportare tutti i dati di un cliente (conformità GDPR Articolo 20), gira come job in background, consegna tramite link firmato.
Il filtro admin, una Server Action con traccia e la modalità Vedi come il cliente
Tre file portano l'intera area admin. Il filtro blocca chi non ha una sessione admin valida. Il template di Server Action racchiude ogni operazione privilegiata e ne scrive una riga di tracciamento. Il flusso Vedi come il cliente conia una sessione a permessi ridotti riconosciuta anche dal filtro lato cliente.
Il pannello di amministrazione non è una parte dell'app cliente con qualche controllo in più. È un'applicazione diversa che condivide il codice. I tre file qui sotto sono il cuore di quello che rilasciamo: il filtro che intercetta ogni rotta /admin/*, un template di Server Action che racchiude ogni scrittura privilegiata in una riga di tracciamento e il flusso Vedi come il cliente che conia una sessione a permessi ridotti riconosciuta sia dal filtro admin sia da quello lato cliente.
1. Il filtro di accesso admin
Un secondo filtro (o un controllo in testa a quello già esistente) chiede una sessione admin valida prima di lasciar passare la richiesta. I cookie admin sono distinti; le sessioni durano poco; il secondo fattore è verificato lato server.
// 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
}
// Rifiuta se l'MFA è avvenuto più di quattro ore fa.
if (Date.now() - payload.mfaAt > 4 * 60 * 60 * 1000) return null
// Verifica che la riga admin esista ancora e che il ruolo non sia stato revocato.
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. Il template di Server Action con audit
Ogni scrittura admin passa per un wrapper che riceve l'azione privilegiata, i dati di tracciamento e il corpo della funzione. Il wrapper scrive la riga di tracciamento nella stessa transazione dell'azione, così un'interruzione a metà non può lasciare il database con l'azione compiuta ma non registrata.
// 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')
// Scrive la riga audit-event per prima; l'azione gira solo se la scrittura
// di audit ha successo, così non facciamo mai girare un'azione senza un
// record di essa.
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. Il flusso Vedi come il cliente
Quando un admin dell'assistenza deve vedere il prodotto come lo vede un cliente, l'app conia un token con permessi ridotti. Il token contiene l'ID del cliente più l'identità dell'admin. Il filtro lato cliente riconosce il token, espone entrambi gli ID negli header e mostra un avviso in ogni pagina, così l'admin sa sempre di stare dentro l'account di qualcun altro.
// 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. Cosa nasce da queste tre primitive
Il filtro blocca chi non ha una sessione admin sotto /admin/*. Il wrapper di tracciamento registra ogni azione privilegiata prima che parta, poi ne registra l'esito. Il flusso Vedi come il cliente conia una sessione a permessi ridotti riconosciuta da entrambi i filtri, con un avviso che rende lo stato visibile e un'uscita pulita.
Il pannello di amministrazione si fonda su queste tre primitive. Rimborsi, cambi piano, ricerca cliente, rilancio dei webhook, export GDPR: ogni operazione è una Server Action racchiusa in withAudit, scritta sotto la sezione admin, con le stesse convenzioni del codice lato cliente ma un autore diverso nello storico. Il codice del cliente smette di assorbire richieste operative; chi fa assistenza riceve uno strumento adatto al lavoro; il regolatore riceve uno storico completo senza che nessuno lo ricostruisca a posteriori.
Domande frequenti
Perché un'app admin separata, invece di interruttori dentro l'app cliente?
Il rischio del modello "funzioni admin dentro l'app cliente" è che uno sviluppatore junior esponga per errore una rotta privilegiata. Un'area dedicata `/admin/*` con il suo filtro rende questo errore impossibile per disattenzione. L'app admin condivide il codice ma, vista dal cliente, è un'applicazione separata.
Gli admin dovrebbero stare nello stesso sistema di autenticazione dei clienti?
Stesso provider di autenticazione (nel nostro default Supabase Auth), ruolo diverso, sessione diversa, secondo fattore quasi sempre obbligatorio al login. Gli admin non appartengono a un cliente; hanno un ruolo amministratore concesso esplicitamente nella tabella `admin_users`. La promozione ad admin è di per sé un'azione registrata che non può partire dall'app cliente.
Come gestite la modalità Vedi come il cliente in sicurezza?
Token firmato a tempo limitato (tipicamente 30 minuti), permessi ridotti in sola lettura o lettura e scrittura a seconda del compito, avviso ben visibile in ogni pagina, registrazione di chi compie l'azione e di chi la subisce. Uscire dalla modalità è un click; il token scade lato server, non solo nel cookie.
Gli admin saltano la separazione fra i dati dei clienti?
Sì, gli admin leggono con permessi elevati. Il prezzo di questo accesso è che ogni lettura admin viene registrata. Il cliente vede lo storico nel suo pannello; il regolatore lo ottiene su richiesta. Saltare la separazione senza tracciatura è una falla di sicurezza; saltarla con tracciatura completa è uno strumento di compliance.
Come tenete l'app admin in sicurezza?
Secondo fattore obbligatorio al login, sessioni brevi, lista di IP autorizzati per le operazioni sensibili, dominio separato (o path dedicato) per i cookie, limite di chiamate al secondo su ogni azione privilegiata. La stessa cura che applichiamo all'app cliente, col volume alzato perché un errore qui ha conseguenze più grandi.
Quanto ci vuole a costruire un pannello di amministrazione?
Una prima versione utile (ricerca cliente, pagina di ispezione, storico azioni, modalità Vedi come il cliente di base) esce in due-tre settimane. Rimborsi, modifica abbonamenti, rilancio dei webhook ed export dati aggiungono altre una-due settimane. Il lavoro cresce con il perimetro operativo; lo definiamo per funzione, così il contratto ha una definizione verificabile di "fatto".
Il cliente può vedere lo storico delle azioni che gli admin compiono sul suo account?
Sì. La stessa tabella `audit_events` alimenta la sezione "attività recenti" che vede il cliente, limitata al suo solo account. Il cliente vede esattamente quando un admin ha acceduto ai suoi dati, quale azione è stata compiuta e con quale motivo, se l'azione ne richiedeva uno. La trasparenza vende.
Raccontaci di cosa ha bisogno il tuo team interno
Una chiamata per inquadrare il progetto, un numero concreto nella prima risposta, zero recite da agenzia. Un pannello di amministrazione utile in due-tre settimane.