autumn-admin-plugin 0.9.0

Out-of-the-box admin panel plugin for autumn-web applications
Documentation
# 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:

| Model | Manages | Audit table |
| --- | --- | --- |
| `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.