Admin

An auto-admin back-office that discovers your api's entities at runtime (capability map) and renders a live, reactive, permission-gated CRUD view per entity — DataTable + AutoForm + provenance + undo, assembled from shipped primitives as editable template code.

The back-office, done for you — without a sealed generated cage. The admin reads your api's capability map (/_voltro/inspect/manifest) at runtime and renders a live, reactive view for every entity it exposes: a <DataTable> over the list query and an <AutoForm> over the create mutation, columns and fields straight from the descriptors' schemas. Point it at any Voltro api and the sidebar + pages adapt — there's no per-entity code. And it's editable template code you own, not a runtime feature (the lesson of the schema-driven-UI graveyard). Template id: frontend-admin.

Scaffold

voltro create-project acme --api=api-backend --web=frontend-admin
voltro dev   # the admin discovers api-backend's entities

Sign in at /login (a demo cookie gate) → /admin.

How discovery works

The sidebar and every entity page are driven by two @voltro/client exports:

import { useCapabilityManifest, deriveEntityAdmins } from '@voltro/client'

const { manifest } = useCapabilityManifest('app')   // one-shot fetch of the capability map
const entities = manifest ? deriveEntityAdmins(manifest) : []
// each entity: { table, columns, serverOnlyColumns, sensitiveColumns, reactive,
//                pkColumn?, editable, list, create, update, delete }
// each of list/create/update/delete: { tag?, guards? }

deriveEntityAdmins joins each user table to the procedures that actually serve it — the query whose source is the table (→ the list <DataTable>), the mutations whose target is {table, op} (→ create/edit/delete) — and carries each one's declared access. So the admin binds only to procedures that exist, and gates only on permissions the api really declares; it never guesses either by naming convention.

Per-entity CRUD

// src/pages/admin/[entity]/page.tsx (abridged)
const create = useAccessDecision(spec.create.guards)   // the procedure's OWN guards
{spec.create.tag && create !== 'denied' ? (
  <AutoForm api="app" mutation={spec.create.tag} submitLabel={`Add ${spec.table}`} />
) : null}

{spec.list.tag ? (
  <DataTable api="app" query={spec.list.tag} rowActions={rowActions} />
) : null}

The list is a live subscription — a create from the form (or anyone, in another tab) appears without a refetch. Each row's actions include a delete (keyed on spec.pkColumn, gated by useAccessDecision(spec.delete.guards), run via useMutation(spec.delete.tag)) and a provenance drawer (useProvenance) answering "why is this row here?".

Permission gating — from the api's own declaration

Each action carries guards: the procedure's guards: / openAccess: declaration, straight off the capability map. useAccessDecision judges it against the scopes <PermissionProvider> supplies, so the UI gate and the server check read the same data and cannot drift. The demo seeds admin:full (bypass) — swap it for your session's real scopes:

const { data } = useSubscription<{ scopes: string[] }>('app', 'auth.session')
<PermissionProvider scopes={data?.scopes ?? []}>…</PermissionProvider>

The decision is three-valued. allowed and denied are what they sound like; unknown means only the server can answer — a guard carrying a resource extractor is checked per row, and a browser holding global scopes cannot pre-compute it. The admin shows those controls and lets the api reply with a typed ScopeError. Hiding them would empty the back-office for exactly the multi-tenant apps whose subjects are minted with no global scopes at all.

This is still UX gating, not enforcement — the api's own guards remain the real authorization boundary.

The three exposure axes

The columns come from your schema, and the admin honours each marker differently — they are orthogonal, and substituting one for another leaks or hides data:

Marker Admin behaviour
.serverOnly() Never rendered and never submitted. It is absent from spec.columns; the page names it from serverOnlyColumns so the omission is visible rather than silent. The runtime refuses it as mutation input anyway (assertNoServerOnlyInput) — this is the same rule one layer earlier.
.encrypted() Shown and editable. At-rest encryption is not wire exposure; your procedures read it decrypted.
.sensitive(class) Shown, and listed in sensitiveColumns so a bulk export masks it.

Rows are keyed on spec.pkColumn, not a hard-coded id. A table with no single primary key reports editable: false and renders list-only.

Undo / redo

The topbar's undo bar wraps the framework's built-in undo (useUndoLog('app') → the __voltro.undo.* built-ins): revert the last change across the whole admin, server-persisted so it survives reload. It requires undo capture on (VOLTRO_UNDO, on by default in dev); if you run with it off, delete the UndoBar lines.

What ships

apps/acme/web/
├── app.config.ts            # type:web, apis:{ app } — the api the admin introspects
└── src/
    ├── config.ts            # APP_NAME
    ├── lib/{auth,admin}.ts  # demo cookie gate + demo scopes (both swap for real)
    └── pages/
        ├── (marketing)/     # silent group — public landing + /login
        └── admin/           # /admin SSR gate (layout loader → RedirectError)
            ├── layout.tsx    # auth gate + capability-map nav + PermissionProvider + undo bar
            ├── index.tsx     # entity overview cards
            ├── [entity].tsx  # the per-entity CRUD binding
            └── error|loading|not-found.tsx

Make it yours

Everything under src/pages/admin/ is yours. Swap the cookie gate (lib/auth.ts) + demo scopes (lib/admin.ts) for real auth; replace the generic [entity]/page.tsx with a hand-built page for any entity that needs more than CRUD.

When to use frontend-admin vs. the other web templates

You want… Pick
A back-office over your api's entities, done for you frontend-admin
An authenticated app shell (public + gated dashboard), no backend frontend-dashboard
The reactive end-to-end loop (one entity, hand-wired) frontend-app

Pairs well with

  • Any api template — the admin adapts to whatever entities + procedures it exposes. api-data-advanced (authors + books) shows it rendering related entities; api-rbac supplies real scopes for the gating.

Anti-patterns

  • Shipping the demo admin:full scopes to production. That bypasses every gate. Feed the subject's real scopes to <PermissionProvider>.
  • Treating the UI decision as authorization. It hides buttons; the server's guards are the real gate. A hidden action is still callable over rpc by a crafted client.
  • Assuming a naming convention — for tags or for scopes. The admin binds to discovered tags and gates on discovered guards. A UI that invents <table>:create hides every write from every app that named its scope anything else: a total outage of the affordance that looks like a working permission check, and that a demo seeding admin:full can never surface.
  • Collapsing unknown into denied. That is the same outage by another route — it is the answer for every per-resource guard, which is the whole multi-tenant case.
  • Hiding a column because it is .encrypted(). Encryption-at-rest is not wire exposure. .serverOnly() is the only one of the three axes that means "do not render this".