# autumn-admin-plugin
`autumn-admin-plugin` adds a server-rendered admin panel to an `autumn-web`
application. Register one or more models, mount the plugin, and it serves a
CRUD UI with list/detail/edit screens, search, filtering, bulk actions, CSRF
integration, and HTMX-driven interactions with no frontend build step.
## Features
- Mounts an admin UI under `/admin` by default
- Generates list, create, detail, edit, delete, and bulk-action flows
- Includes a built-in jobs dashboard for queued, running, completed, and failed work
- Uses Maud + HTMX and works under Autumn's default `Content-Security-Policy`
- Reads and writes model data through a small `AdminModel` trait
- Requires an authenticated session with the `"admin"` role by default
## Installation
```bash
autumn plugin add autumn-admin-plugin
```
One command adds the dependency at a version compatible with your app's
`autumn-web`, mounts the plugin in your `autumn_web::app()` builder chain, and
prints any configuration still needed. It is safe to re-run, and it refuses —
before touching any file — to install into an app on an incompatible
`autumn-web` version. See [docs/plugins.md](https://github.com/autumn-foundation/autumn/blob/main/docs/plugins.md#installing-a-plugin).
### Manual install
If you would rather wire it yourself (or `autumn plugin add` could not find your
builder chain and printed these lines for you):
```toml
[dependencies]
autumn-web = { version = "0.9", features = ["db", "flash", "htmx", "maud"] }
autumn-admin-plugin = "0.9"
```
`autumn-admin-plugin` expects a configured Autumn database pool: model
operations receive the app's pool. The built-in jobs dashboard can render
without a database pool when no model route is accessed.
## Quick Start
```rust,ignore
use autumn_admin_plugin::{prelude::*, AdminPlugin};
struct ProjectAdmin;
// Implement `AdminModel` for your type.
// Supply field metadata plus `list`, `get`, `create`, `update`, and `delete`.
#[autumn_web::main]
async fn main() {
autumn_web::app()
.plugin(AdminPlugin::new().register(ProjectAdmin))
.run()
.await;
}
```
## What the Plugin Mounts
When mounted at the default `/admin` prefix, the plugin serves:
- `GET /admin/` — dashboard
- `GET /admin/jobs` — background jobs dashboard
- `GET /admin/jobs/counters` — HTMX counter fragment, refreshed at most every 2s
- `POST /admin/jobs/{id}/retry` — retry a failed job
- `POST /admin/jobs/{id}/discard` — discard a failed job
- `POST /admin/jobs/{id}/cancel` — cancel an enqueued job
- `GET /admin/{slug}` — paginated list view
- `POST /admin/{slug}` — create record
- `GET /admin/{slug}/new` — new-record form
- `GET /admin/{slug}/{id}` — detail view
- `POST /admin/{slug}/{id}` — update record
- `DELETE /admin/{slug}/{id}` — delete record
- `GET /admin/{slug}/{id}/edit` — edit form
- `POST /admin/{slug}/actions` — bulk actions
The plugin also serves its same-origin JavaScript through
`AppBuilder::plugin_assets`, at a content-hashed URL under
`/static/_plugins/autumn-admin/` (`admin.<hash>.js`), so long-lived caching
stays safe across deploys.
## Database Backends
The plugin core is backend-agnostic. It takes the app's
`autumn_web::RuntimeConnection` pool, which is Postgres by default and SQLite
under the `autumn-web/sqlite` feature. An app on either backend can register
its own models.
The three **built-in** models are Postgres-only:
| `tokens::TokenAdminModel` | `api_tokens` | — |
| `experiments::ExperimentAdminModel` | `autumn_experiments` | `autumn_experiment_changes` |
| `feature_flags::FeatureFlagAdminModel` | `autumn_feature_flags` | `feature_flag_changes` |
Do not register those three on SQLite. Their migrations use Postgres-only DDL,
so the tables do not exist on SQLite. Autumn's experiment and feature-flag
stores also refuse a SQLite target.
On SQLite each of the three refuses every call with an error that names the
model and points here. Before issue #2108 the crate did not compile at all on
SQLite, so this was a build error; now it is a clear run-time one.
## `AdminModel` Contract
Each registered model supplies:
- A URL slug and singular/plural display names
- A field schema via `Vec<AdminField>`
- `list`, `get`, `create`, `update`, and `delete` operations
`AdminField` covers the common form/display shapes: `Text`, `TextArea`,
`Integer`, `Float`, `Boolean`, `Date`, `DateTime`, `Select`, `Hidden`,
`Password`, and `Json`.
Optional hooks let you customize:
- `actions()` for extra bulk actions beyond the built-in delete action
- `execute_action()` to implement those custom actions
- `record_display()` for breadcrumbs and page titles
- `per_page()` and `count()` for pagination behavior
All values flow through `serde_json::Value` so the plugin stays object-safe and
does not need to know your application's concrete model types.
### Writing a model that runs on both backends
Your own models choose their own SQL. Four rules keep it portable.
1. **Write `$N` placeholders in ascending order. Use each placeholder one
time.** Postgres reads the digits. SQLite gives each distinct `$name` the
next free index, in order of first appearance. If you write
`SET name = $2 ... WHERE id = $1`, SQLite binds the id into `name`. To
repeat a value, bind it two times under two placeholders.
2. **Do not use `ILIKE`.** Write `LOWER(col) LIKE LOWER($1)`. Both backends
then match without case for ASCII text. SQLite's `lower()` folds ASCII only,
so non-ASCII text stays case-sensitive there.
3. **Do not use a `::type` cast, an `ANY($1)` array bind, or a writable CTE.**
SQLite has none of the three. Use `CAST(x AS TEXT)`, an `IN` list, and
separate statements in one transaction.
4. **Use `CURRENT_TIMESTAMP`, not `NOW()`.** Read a timestamp column as
`Timestamp` into a `NaiveDateTime`. The `Timestamptz` SQL type is
Postgres-only.
`tests/custom_admin_model.rs` is a worked example. The same test body runs on
both backends.
## Configuration
`AdminPlugin::new()` defaults to:
- Prefix: `/admin`
- Required role: `"admin"`
- Session auth key: `"user_id"`
- Actuator prefix: `/actuator`
You can override those defaults with:
- `prefix(...)`
- `require_role(...)`
- `auth_session_key(...)`
- `actuator_prefix(...)`
Example:
```rust,ignore
let plugin = AdminPlugin::new()
.prefix("/backoffice")
.actuator_prefix("/ops")
.auth_session_key("uid")
.require_role(Some("staff".to_owned()));
```
## Security Notes
- The plugin assumes Autumn session/auth middleware is already configured
- Role checks run before any admin handler by default
- CSRF tokens are rendered automatically when Autumn's `CsrfLayer` is enabled
- No inline JavaScript is used; the UI is compatible with Autumn's default CSP
- `Password` fields are treated as write-only and never rendered back to users
## Status
This crate is intended as the first-party admin plugin for `autumn-web`. The
API is pragmatic and functional, but still young enough that you should expect
incremental improvements around model ergonomics, docs, and batteries-included
examples.