Un build di admin interno è un problema di traduzione prima che di UI. Il team fa già girare l'azienda; il lavoro semplicemente succede negli strumenti sbagliati. I fogli di calcolo sono senza versione e lenti; l'SQL ad hoc è veloce e poco sicuro; i thread Slack non sopravvivono alle persone che li hanno scritti. L'admin tool trasforma quei workflow in azioni tipizzate, tracciate, ritagliate per ruolo, che tutto il team può usare senza perdere la velocità di farle nel foglio.
I cinque file qui sotto sono l'ossatura che l'ingaggio lascia in piedi. Il middleware SSO che presidia l'admin, l'helper di query basato sui ruoli che applica RLS a ogni lettura, il runner di bulk action con audit, il wrapper dell'agente Claude, e lo schema admin che definisce cosa un ruolo admin può toccare.
1. Il middleware SSO
Ogni route admin gira dietro SSO. Il middleware legge la sessione, cerca il ruolo dell'utente nel database, e o lascia passare la richiesta o torna 403. Niente endpoint pubblici. Niente flusso "ho dimenticato la password". L'identity provider del cliente è la fonte di verità.
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { getServerSession } from '@/lib/auth/session'
import { lookupAdminRole } from '@/lib/admin/roles'
const ALLOWED_DOMAINS = ['adamarant.com']
export async function middleware(req: NextRequest): Promise<NextResponse> {
const session = await getServerSession(req)
if (!session) {
return NextResponse.redirect(new URL('/auth/sign-in', req.url))
}
const email = session.user.email ?? ''
const domain = email.split('@')[1]
if (!ALLOWED_DOMAINS.includes(domain)) {
return new NextResponse('forbidden', { status: 403 })
}
const role = await lookupAdminRole(session.user.id)
if (!role) {
return new NextResponse('not provisioned', { status: 403 })
}
const requestHeaders = new Headers(req.headers)
requestHeaders.set('x-admin-role', role)
requestHeaders.set('x-admin-user-id', session.user.id)
return NextResponse.next({ request: { headers: requestHeaders } })
}
export const config = { matcher: ['/((?!auth|api/auth|_next).*)'] }
2. L'helper di query basato sui ruoli
L'helper legge il ruolo admin dagli header della request, apre un client Supabase ritagliato su quel ruolo, e gira la query. La JWT Supabase porta il claim di ruolo; le policy del database lo applicano. L'engineer di admin non aggira la RLS nemmeno volendo.
// lib/admin/db.ts
import { createClient } from '@supabase/supabase-js'
import { headers } from 'next/headers'
import { signRoleJwt } from './role-jwt'
interface AdminContext {
role: 'admin' | 'ops' | 'support' | 'finance' | 'read_only'
userId: string
}
export async function adminContext(): Promise<AdminContext> {
const h = await headers()
const role = h.get('x-admin-role') as AdminContext['role'] | null
const userId = h.get('x-admin-user-id')
if (!role || !userId) throw new Error('missing admin context')
return { role, userId }
}
export async function adminDb(): Promise<ReturnType<typeof createClient>> {
const ctx = await adminContext()
const jwt = signRoleJwt({ sub: ctx.userId, role: ctx.role })
return createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, {
global: { headers: { Authorization: `Bearer ${jwt}` } },
})
}
3. Il runner di bulk action con audit log
Una bulk action prende una lista di ID, una funzione da applicare per ID, e una giustificazione. Gira in batch con controllo di concorrenza, intercetta gli errori per riga, e scrive una riga di audit prima e dopo ogni cambio. La UI mostra il progresso; il team chiude la tab e riceve un ping Slack quando il run finisce.
// lib/admin/bulk.ts
import { adminContext, adminDb } from './db'
import { sendSlack } from '@/lib/integrations/slack'
interface BulkRunOptions<T> {
entityType: string
ids: string[]
justification: string
apply: (id: string, db: Awaited<ReturnType<typeof adminDb>>) => Promise<T>
concurrency?: number
}
export async function runBulk<T>(opts: BulkRunOptions<T>): Promise<{ ok: number; failed: number }> {
const ctx = await adminContext()
const db = await adminDb()
const concurrency = opts.concurrency ?? 5
let ok = 0
let failed = 0
for (let i = 0; i < opts.ids.length; i += concurrency) {
const batch = opts.ids.slice(i, i + concurrency)
const results = await Promise.allSettled(
batch.map(async (id) => {
const before = await db.from(opts.entityType).select('*').eq('id', id).single()
const next = await opts.apply(id, db)
const after = await db.from(opts.entityType).select('*').eq('id', id).single()
await db.from('audit_log').insert({
actor_user_id: ctx.userId,
actor_role: ctx.role,
entity_type: opts.entityType,
entity_id: id,
action: 'bulk_update',
before: before.data,
after: after.data,
justification: opts.justification,
})
return next
}),
)
for (const r of results) {
if (r.status === 'fulfilled') ok += 1
else failed += 1
}
}
await sendSlack({
channel: '#ops',
text: `bulk run on ${opts.entityType}: ${ok} ok, ${failed} failed (actor=${ctx.userId})`,
})
return { ok, failed }
}
4. Il wrapper di agente Claude
L'agente è una funzione sola. Prende un task, un set di tool e un contesto. Ogni tool è un piccolo wrapper tracciato attorno a un'azione admin. L'agente sceglie i tool, li chiama, e scrive un transcript che il team può leggere. L'agente non ha mai più permessi di quanti il suo ruolo gli consenta.
// lib/admin/agent.ts
import Anthropic from '@anthropic-ai/sdk'
import { adminContext, adminDb } from './db'
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
interface AgentTool {
name: string
description: string
inputSchema: Record<string, unknown>
run: (input: Record<string, unknown>) => Promise<string>
}
export async function runAgent(
task: string,
tools: AgentTool[],
): Promise<{ transcript: string; result: string }> {
const ctx = await adminContext()
const db = await adminDb()
const transcript: string[] = []
const response = await anthropic.messages.create({
model: 'claude-opus-4-7',
max_tokens: 4096,
messages: [{ role: 'user', content: task }],
tools: tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema as Anthropic.Tool.InputSchema })),
})
// Per brevità: un giro di tool use. In pratica si fa il loop fino a quando il modello torna solo testo.
for (const block of response.content) {
if (block.type === 'tool_use') {
const tool = tools.find((t) => t.name === block.name)
if (!tool) continue
const result = await tool.run(block.input as Record<string, unknown>)
transcript.push(`tool=${tool.name} input=${JSON.stringify(block.input)} result=${result}`)
} else if (block.type === 'text') {
transcript.push(`text=${block.text}`)
}
}
await db.from('agent_transcripts').insert({
actor_user_id: ctx.userId,
actor_role: ctx.role,
task,
transcript: transcript.join('\n'),
})
return { transcript: transcript.join('\n'), result: transcript[transcript.length - 1] ?? '' }
}
5. Lo schema admin
Cinque tabelle. admin_roles elenca il team. audit_log registra ogni scrittura. internal_notes conserva le note che il team attacca alle entità. saved_searches lascia al team salvare i set di filtri che riusa. agent_transcripts conserva ogni run di agente. Policy RLS su ogni tabella; solo i ruoli giusti vedono le righe giuste.
-- supabase/schema/admin.sql
create table if not exists admin_roles (
user_id uuid primary key references auth.users(id) on delete cascade,
role text not null check (role in ('admin', 'ops', 'support', 'finance', 'read_only')),
created_at timestamptz default now()
);
create table if not exists audit_log (
id uuid primary key default gen_random_uuid(),
actor_user_id uuid references admin_roles(user_id),
actor_role text not null,
entity_type text not null,
entity_id text not null,
action text not null,
before jsonb,
after jsonb,
justification text,
created_at timestamptz default now()
);
create index audit_log_entity_idx on audit_log (entity_type, entity_id, created_at desc);
create table if not exists internal_notes (
id uuid primary key default gen_random_uuid(),
entity_type text not null,
entity_id text not null,
author_user_id uuid references admin_roles(user_id),
body text not null,
created_at timestamptz default now()
);
create table if not exists saved_searches (
id uuid primary key default gen_random_uuid(),
owner_user_id uuid references admin_roles(user_id),
name text not null,
entity_type text not null,
filters jsonb not null,
created_at timestamptz default now()
);
create table if not exists agent_transcripts (
id uuid primary key default gen_random_uuid(),
actor_user_id uuid references admin_roles(user_id),
actor_role text not null,
task text not null,
transcript text not null,
created_at timestamptz default now()
);
alter table admin_roles enable row level security;
alter table audit_log enable row level security;
alter table internal_notes enable row level security;
alter table saved_searches enable row level security;
alter table agent_transcripts enable row level security;
create policy "admin_roles read self" on admin_roles for select
using (auth.uid() = user_id or current_setting('request.jwt.claim.role') = 'admin');
create policy "audit_log read by role" on audit_log for select
using (current_setting('request.jwt.claim.role') in ('admin', 'finance'));
6. Cosa compone questo
Il middleware SSO tiene fuori le persone sbagliate. L'helper di ruoli tiene le persone giuste nella loro corsia. Il runner di bulk rende il team veloce e i cambi tracciabili. L'agente fa il lavoro che il team altrimenti darebbe a un intern, con un transcript che il team può leggere. Lo schema fa vivere tutta la cosa in un posto che non scola nel prodotto.
Il team di ops smette di essere una pipeline ETL umana. Il foglio Excel che faceva da specchio alla tabella clienti è sparito. La riconciliazione CSV del venerdì gira dall'admin in 12 secondi. I thread Slack che chiudevano con aggiornate questi ID per favore diventano form con dialog di conferma e riga di audit. L'engineer che scriveva uno script ogni settimana torna a lavorare sul prodotto. Il team che era cresciuto trasformandosi in un'azienda di manodopera torna a essere un'azienda di software.