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.