MODULES / OPERATIONS

Admin Dashboard

Prov0.1.0

The admin shell for your app: a responsive /admin layout and home that bring every installed module's admin screen together, plus a safe, auto-generated data browser to view, create, edit and delete rows in your own Supabase tables.

terminalnpx ajolla add admin

What you get

  • Responsive admin shell with a permission-filtered sidebar, mobile drawer and breadcrumbs
  • Admin home with a card for every installed module, quick stats and recent audit entries
  • Auto-generated data browser for your tables, introspected from PostgREST — no SQL functions
  • Paginated browsing with sort, filters and search; typed forms for create and edit
  • Foreign keys shown as links with readable labels
  • Table allow/deny lists, read-only mode and internal tables hidden by default
  • Every write recorded as an admin.row_changed event (ids only, never row content)
  • Extension points for your own admin pages and dashboard widgets

Routes it adds

/admin/admin/data/admin/data/settings/admin/data/tables/admin/data/tables/[table]/admin/data/tables/[table]/new/admin/data/tables/[table]/rows/admin/data/tables/[table]/rows/[key]

Plus 1 Supabase migration with row-level security, and an AGENTS.md your coding agent reads after install.

Installs with

Kernel (added automatically).

Configuration

ADMIN_DATA_ALLOW
Comma-separated tables ("*" wildcards allowed) the data browser may show. Empty = every table not denied. Admin settings can only narrow this.
ADMIN_DATA_DENY
Comma-separated tables ("*" wildcards allowed) the data browser never shows, whatever the admin settings say.
ADMIN_DATA_READ_ONLY
Set to "true" to make the data browser read-only for everyone, whatever the admin settings say.

Events

Other modules (and your own code) can react to these:

admin.row_changedadmin.settings_updated

AGENTS.md

Read the instructions installed with this module
markdown# Ajolla Admin Dashboard

The admin panel for your app. It has two parts:

- **The shell**: the `/admin` layout and home page. They bring every installed module's admin screen together.
- **The data browser**: `/admin/data`, a safe, auto-generated CRUD for your own Supabase tables.

## What was installed

**Routes**
- `app/admin/layout.tsx`: the shell every `/admin/**` page renders inside. It has a sidebar, which becomes a drawer on phones, breadcrumbs, a "Back to the app" link and a skip link. The nav comes from the CLI-generated `lib/ajolla/admin/index.ts` and is filtered by the viewer's permissions. The layout guards nothing on its own: every page still checks its own permission. Signed-out users and users with no admin permission get the bare page, so that page's own redirect or 404 applies. Only this module ships `app/admin/layout.tsx` and `app/admin/page.tsx`.
- `/admin`: the home page. Any user holding at least one admin page's permission can open it. It shows:
  - a card for each module page the viewer may open;
  - quick stats for `admin.access` holders: users, tables in the data browser, queued events and failed events;
  - recent audit entries, when Audit Log is installed and the viewer holds `audit.read`. These are read with the viewer's client, so RLS applies;
  - your widgets.
- `/admin/data`: lists your tables. From here you can search them, refresh the schema after a migration, and open the settings.
- `/admin/data/tables/[table]`: browses a table. It has:
  - pages of 25, 50 or 100 rows;
  - sorting by column header;
  - filters such as equals, contains, greater than or is NULL, validated against the column type;
  - free-text search (ILIKE on text columns, plus exact matches on id, number and enum columns);
  - foreign keys shown as links with a readable label.
- `/admin/data/tables/[table]/new`: creates a row.
- `/admin/data/tables/[table]/rows/[key]`: views or edits one row, lists the tables that point at it ("referenced by") and deletes it after a confirmation. `key` is the base64url JSON of the primary key values.
- `/admin/data/settings`: the table allow and deny lists, read-only mode, and whether Ajolla's own tables are shown.
- All of these require `admin.access`.

**Code**

Framework-free, unit-tested code in `lib/ajolla/admin/`:

| File | Contents |
|---|---|
| `core.ts` | Settings, plus row CRUD, all through `table()` |
| `schema.ts` | Converts the OpenAPI description into a table and column model |
| `policy.ts` | Allow and deny lists, read-only mode |
| `coerce.ts` | Typed form input and minimal update diffs |
| `query.ts` | Filters, sort, search, paging and row keys |
| `nav.ts` | Nav filtering and breadcrumbs |

The rest of the code:
- `lib/ajolla/admin/server.ts`: server-only helpers. It holds the viewer's permissions (cached per request), the OpenAPI fetch, the data context and the home stats.
- `lib/ajolla/admin/admin.ts`: the "Data" entry in the sidebar and on the home page.
- `app/admin/data/actions.ts`: the server actions.
- `components/ajolla/admin/*`: the shell, forms and `widgets.tsx`.

**Table**: `ajolla_admin_settings` holds one row with:
- `allow_tables` and `deny_tables` (`text[]`, at most 200 entries each);
- `read_only` and `show_internal`;
- `updated_by` and `updated_at`.

RLS lets only `ajolla_has_permission('admin.access')` read, insert or update it, and `updated_by` must be the signed-in user. There is no delete policy.

**Events**
- `admin.row_changed` `{ table, pk, action: "insert" | "update" | "delete" }`: sent for every data-browser write. It carries the primary key only, never row content. Audit Log records it automatically.
- `admin.settings_updated` `{ readOnly, showInternal, allowCount, denyCount }`.

**Env** (all optional; they can only make the admin settings stricter, never looser):
- `ADMIN_DATA_ALLOW`: comma-separated tables, `*` wildcards allowed. The browser shows only these.
- `ADMIN_DATA_DENY`: tables that are never shown.
- `ADMIN_DATA_READ_ONLY=true`: no writes for anyone.

The browser also uses the kernel's `NEXT_PUBLIC_SUPABASE_URL` and `SUPABASE_SECRET_KEY`.

## How the data browser works and stays safe
- **Introspection.** It reads `GET <SUPABASE_URL>/rest/v1/`, PostgREST's OpenAPI description, with the secret key, on the server only. This gives column types, NOT NULL columns, defaults, enums, varchar lengths, primary and foreign keys, and which views are writable. No SQL functions are needed. The result is cached for one minute per server instance; use "Refresh schema" after a migration. Only tables and views in your exposed API schemas appear.
- **Access.** Every page and action first calls `requirePermission("admin.access")`. Only then does it create the secret-key client. That client bypasses RLS because your tables' policies are written for end users. The core re-checks the table policy on every query.
- **Writes.**
  - Every write is addressed by the full primary key and touches exactly one row.
  - Updates send only the columns that changed and never change primary keys.
  - Deletes ask for confirmation.
  - Tables without a primary key can only be browsed and inserted into.
- **Ajolla tables.** Tables named `ajolla_*` are hidden by default. Even when shown, they stay read-only: use each module's own admin screens to change them.
- **Customise.** Lock it down further by setting `ADMIN_DATA_DENY` for sensitive tables, or `ADMIN_DATA_READ_ONLY=true` in production.

## Setup
1. Apply migrations: `supabase db push`.
2. Give yourself admin rights. Until Roles is installed, admins are users with `app_metadata.role = "admin"` (see the kernel's AGENTS.md). With Roles, grant `admin.access`.
3. Open `/admin`. Optionally restrict tables at `/admin/data/settings` or with the `ADMIN_DATA_*` variables.

## Extending the admin

**Register an admin page.** Add `lib/ajolla/admin/<name>.ts`, which default-exports an `AdminPage` from `@ajolla/kernel`, then put the page under `app/admin/<name>/page.tsx`. The page renders inside the shell and must check its own permission with `requirePermission`:

```ts
import type { AdminPage } from "@ajolla/kernel";
const page: AdminPage = { module: "reports", title: "Reports", href: "/admin/reports", permission: "reports.read", description: "Monthly numbers." };
export default page;
```

Modules ship this file, and the CLI lists it in `lib/ajolla/admin/index.ts` on `ajolla add`, `update` and `remove`. Never edit that generated index. For your own app's pages, add the `AdminPage` to `EXTRA_PAGES` in `lib/ajolla/admin/server.ts` instead.

**Add dashboard widgets.** Edit `components/ajolla/admin/widgets.tsx`. Each `AdminWidget` has:
- `id` and `title`;
- an optional `permission`;
- `size` (`"normal"` or `"wide"`);
- `render`, which can be async and runs on the server.

Widgets render as cards on `/admin`. A widget that throws shows an error in its own card and doesn't break the page.

**Other changes.**
- Change the brand and the "Back to the app" target with the `BRAND` and `APP_HREF` constants in `app/admin/layout.tsx`.
- Change the styles in `components/ajolla/admin/admin.module.css` and the kit's `--aj-*` variables.
- To change how a column type is edited, edit `coerce.ts` (validation) and `components/ajolla/admin/row-form.tsx` (inputs).