An internal admin build is a translation problem before it is a UI problem. The team already runs the business; the work just happens in the wrong tools. Spreadsheets are versionless and slow; ad-hoc SQL is fast and unsafe; Slack threads do not survive the people who wrote them. The admin tool turns those workflows into typed, audited, role-scoped actions the whole team can use without losing the speed of running them in a spreadsheet.
The five files below are the scaffolding the engagement leaves behind. The SSO middleware that gates the admin, the role-based query helper that enforces RLS at every read, the bulk-action runner with audit logging, the Claude agent wrapper, and the admin schema that defines what an admin role can touch.
1. The SSO middleware
Every admin route runs behind SSO. The middleware reads the session, looks up the user's role from the database, and either lets the request through or returns 403. No public endpoints. No "I forgot my password" flow. The customer's existing identity provider is the source of truth.
// 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. The role-based query helper
The helper reads the admin role from the request headers, opens a Supabase client scoped to that role, and runs the query. The Supabase JWT carries the role claim; the database policies enforce it. The admin engineer cannot bypass RLS even if they wanted to.
// 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. The bulk-action runner with audit log
A bulk action takes a list of entity IDs, a function to apply per ID, and a justification. It runs in batches with concurrency control, captures errors per row, and writes an audit row before and after each change. The UI shows progress; the team closes the tab and gets a Slack ping when the run completes.
// 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. The Claude agent wrapper
The agent is one function. It takes a task, a set of tools, and a context. Each tool is a small, audited wrapper around an admin action. The agent picks tools, calls them, and writes a transcript the team can read. The agent never has more permission than its role allows.
// 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 })),
})
// For brevity: one round of tool use. In practice this loops until the model returns text-only.
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. The admin schema
Five tables. admin_roles lists the team. audit_log records every write. internal_notes stores the notes the team attaches to entities. saved_searches lets the team save the filter sets they reuse. agent_transcripts stores every agent run. RLS policies on every table; only the right roles see the right rows.
-- 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. What this composes
The SSO middleware keeps the wrong people out. The role helper keeps the right people inside their lane. The bulk runner makes the team fast and the changes traceable. The agent does the work the team would otherwise hand to an intern, with a transcript the team can read. The schema makes the whole thing live in one place that does not leak into the product.
The ops team stops being a human ETL pipeline. The spreadsheet that mirrored the customer table is gone. The Friday CSV reconciliation runs from the admin in 12 seconds. The Slack threads that ended with please update these IDs become forms with a confirm dialog and an audit row. The engineer who used to write a script every week works on the product again. The team that grew into a manual-labor company shrinks back into a software company.