# Admin UI

> **Available in Early Access**
>
> The Admin UI is available today for any Quickback-compiled API that emits
> a schema registry. Core schema-driven CRUD, views, actions, access, and masking
> workflows are ready to use. The CMS is evolving quickly, so review release notes
> when upgrading projects that depend on customized admin workflows.


# Admin UI

A schema-driven admin interface that reads `schema-registry.json` generated by the Quickback compiler. Every table, column, action, view, and security rule is rendered automatically. Zero UI code per table.

## Overview

The CMS generates its entire UI from your Quickback definitions. Define a table with columns, guards, masking, views, and actions in your feature files. Run the compiler. The CMS reads the resulting schema registry and renders a complete admin interface — data tables, inline editing, action dialogs, role-based access, and field masking — all without writing a single line of UI code.

## Key Features

- **Schema-driven** — Zero UI code per table. Add a table, recompile, and it appears in the CMS.
- **Dashboard** — Stats grid and feature cards showing tables, columns, actions, views, and masked fields at a glance.
- **Custom pages** — Split-panel layouts with drag-and-drop, matching engine, and page-level actions for workflows like reconciliation.
- **Embedded in your Worker** — Set `cms: true` in config. The CMS is served as static assets from the same Cloudflare Worker as your API — same origin, no CORS, auth cookies work naturally.
- **Dual view modes** — Table browse mode for navigation and Data Table mode for spreadsheet-style editing.
- **Role-based access** — Owner, admin, and member roles with live switching. CRUD buttons hidden when unauthorized.
- **Multi-tenant & fixed-org** — Org-scoped access by default, with pinned-organization mode for simpler deployments.
- **Inline spreadsheet editing** — Excel/Google Sheets-like editing with keyboard navigation (arrows, Tab, Enter, Escape).
- **FK typeahead** — Server-side search for foreign key fields with debounced queries and keyboard navigation.
- **Field masking** — Email, phone, SSN, and redaction patterns applied per role. Masked fields show a lock icon.
- **Custom actions** — Action dialogs with auto-generated input forms, access filtering, CMS metadata (icons, categories, confirmations), and side effects warnings.
- **Views** — Named column-level projections per role. "All Fields" plus custom views in the toolbar.
- **Auto-form generation** — Create and edit forms built from guards (createable/updatable fields).
- **Display column auto-detection** — FK labels resolved automatically from `name`, `title`, `label`, `code`, and other common patterns.

## Architecture

The CMS sits at the end of the Quickback compilation pipeline:

```
Quickback Definitions (feature files)
        |
        v
    Compiler
        |
        v
  schema-registry.json
        |
        v
    CMS reads it
        |
        v
  Renders admin UI
```

Your feature definitions are the single source of truth. The compiler extracts all metadata — columns, types, guards, masking rules, views, actions, validation, and firewall config — into a static JSON file. The CMS consumes that file and renders the appropriate UI for each table.

### How Embedded Serving Works

When `cms: true` is set, the compiler builds the CMS SPA from source at compile time (with your project-specific env vars baked in) and outputs the assets to `src/apps/cms/`. It also configures `wrangler.toml`:

```toml
[assets]
binding = "ASSETS"
directory = "src/apps"
not_found_handling = "none"
run_worker_first = true
```

All requests go through the Worker first. The Worker handles SPA routing — serving CMS at `/cms/` on the unified domain and at root (`/`) on a custom CMS domain. API paths (`/api/*`, `/auth/*`, etc.) are handled by Hono as normal.

See [Multi-Domain Architecture](/configure/domains) for details on hostname-based routing.

> **Zero UI Code**
>
> The CMS generates its entire UI from your Quickback definitions. Add a table, recompile, and it appears in the CMS. No UI code to write.


## Quick Start

### 1. Enable CMS in config

```typescript title="quickback/quickback.config.ts"
export default defineConfig({
  name: "my-app",
  cms: true,
  // ...providers
});
```

### 2. Compile

```bash
quickback compile
```

The compiler generates `schema-registry.json` and copies CMS static assets to `src/apps/`. It also adds an `[assets]` section to `wrangler.toml` so Cloudflare serves the CMS SPA automatically.

### 3. Run

```bash
npm run dev
```

Open your Worker URL in a browser — the CMS is served at the root. API routes (`/api/*`, `/auth/*`, etc.) pass through to your Hono app as normal. Everything runs on the same origin — no CORS configuration needed, auth cookies work naturally.

### Optional: Custom CMS domain

```typescript
cms: { domain: "cms.example.com" }
```

This adds a custom domain route to `wrangler.toml`. Both domains serve the same Worker — `api.example.com` for the API, `cms.example.com` for the CMS. The compiler also auto-infers a unified `quickback.example.com` domain where everything is available. See [Multi-Domain Architecture](/configure/domains).

### Access gate

`cms.access` answers one question: **can anyone in the organization hierarchy use the CMS, or only a platform sysadmin?**

| Value | Who gets in |
|-------|-------------|
| `"sysadmin"` (default) | Only `user.role === "sysadmin"` — the cross-tenant data-plane tier. They choose a tenant from the org picker. |
| `"member"` | The above, **plus** any caller holding a membership in an organization, scoped to that organization. |

```typescript
cms: { access: "member" }   // let organization members in
```

> **`user.role === "appmanager"` does not open the CMS in either mode.** appmanager is the *control plane* — users, organizations, and subscriptions at [`/account/admin`](/ui/account) — and deliberately holds no tenant-data powers. Keeping those separable is the entire point of the appmanager/sysadmin split. An appmanager who is also a member of an organization gets in as that member, on the membership, not on the platform role.


#### Where the gate actually lives

The CMS **shell** is served to any signed-in caller. Authorization is decided in the SPA, which renders a screen naming the caller's state and what to do about it — a signed-in user who can't get in is told why, instead of being silently bounced.

That is a deliberate trade: the old gate answered a wrong-role caller with a `302` so it couldn't be used to probe roles. The shell is a generic prebuilt bundle with no project data in it, and being unable to distinguish a permissions problem from a broken deploy cost more than the deniability was worth.

The gates that carry real data:

1. **`/api/v1/schema`** — the metadata endpoint. Admits `sysadmin`, plus any caller with a membership when `access: "member"`. Otherwise `403` with `CMS_ACCESS_DENIED` and a hint naming the requirement. Without the registry there is no CMS.
2. **`custom_view` routes** — saved views follow whoever is admitted: the `sysadmin` tier in `"sysadmin"` mode, org membership *or* sysadmin in `"member"` mode.
3. **Account UI link** — the "Go to CMS" button is hidden for callers who wouldn't get in.

Your resource API endpoints (`/api/v1/<resource>`) are **not** affected — they continue to use the per-resource `read`, `create`, `update`, `delete`, and `upsert` access rules you defined.

> **Breaking in v0.62.** `cms.access` was renamed from `"admin" | "user"`, and both old values meant something different: `"admin"` admitted `appmanager`, and `"user"` admitted *any* authenticated caller — including one belonging to no organization, who then reached a CMS with nothing in it. The old spellings are rejected at compile time rather than aliased, because aliasing would preserve the old behaviour under a familiar name. Migrate `"admin"` → `"sysadmin"`, `"user"` → `"member"`.


#### What a blocked caller actually sees

The shell is served to any signed-in caller, and the SPA resolves one of six states — each its own screen naming what is true and what to do next. Nothing redirects: a signed-in user bounced elsewhere cannot tell a permissions problem from a broken deploy.

| State | Screen |
|-------|--------|
| Not signed in | Redirect to sign-in (the one redirect that remains — it is where you were going anyway) |
| `sysadmin`, no tenant chosen | Tenant picker, filterable, with **All organizations** as a first-class choice |
| `sysadmin`, tenant chosen | The CMS, scoped to that tenant |
| `appmanager`, `access: "sysadmin"` | *"The CMS shows organization data"* → **Go to admin** |
| Anyone else, `access: "sysadmin"` | *"This CMS is restricted"* — names the config value that would admit them |
| `access: "member"`, no membership | *"You're not in any organization yet"* → **View invitations** (an appmanager gets **Go to admin** instead — telling them to ask an admin for an invite when they are the admin is a dead end) |
| `access: "member"`, one membership | Auto-selected; straight in |
| `access: "member"`, several | Membership picker |

The decision is a pure function (`resolveCmsAccess`), so the table above is tested rather than described.

Sysadmin scope lives in the browser and rides every request as `?organizationId=` — it is not part of the session, because a sysadmin holds no membership row for Better Auth to make active. Change it from the sidebar.

#### `access: "sysadmin"` without `cms.sysadmin`

A sysadmin can still pin a single tenant — `?organizationId=` cross-tenant addressing is admitted for sysadmins unconditionally. The **"All organizations"** unfiltered view is what needs the firewall escape that `cms.sysadmin: true` emits.

### Optional: Skip SPA rebuild

After the first compile, you can skip rebuilding the CMS SPA on subsequent compiles:

```typescript
cms: { build: false }
```

This is useful when you're iterating on API features and don't need to rebuild the CMS UI each time — it saves significant compile time. Set `build: true` (or omit it) when you need to update the CMS assets.

### Optional: Custom output directory

```typescript
cms: { outputDir: "my-custom-path/cms" }
```

This changes where the compiled CMS assets are placed (relative to project root), instead of the default `src/apps/cms/`.

## Next Steps

- **[Schema Registry](/ui/admin/schema-registry)** — Understand the JSON format the compiler generates
- **[Connecting](/ui/admin/connecting)** — Demo mode, live mode, CLI command, and auth modes
- **[Dashboard](/ui/admin/dashboard)** — Stats grid and feature navigation
- **[Table Views](/ui/admin/table-views)** — Browse and Data Table view modes
- **[Custom Pages](/ui/admin/pages)** — Split-panel layouts, matching engine, and drag-drop
- **[Inline Editing](/ui/admin/inline-editing)** — Spreadsheet-style editing and FK typeahead
- **[Security](/ui/admin/security)** — How the CMS enforces all four security layers
- **[Actions](/ui/admin/actions)** — Custom actions with input forms, access filtering, and CMS metadata
- **[Schema Format Reference](/ui/admin/schema-format)** — Full TypeScript types for schema-registry.json
- **[Components Reference](/ui/admin/components)** — All CMS components and their props
