# Helm

> An admin panel generated from your entities, with fields, filters, search, actions and metrics checked against the access rules your API already enforces.

## Introduction

(Feature: `helm`) Helm gives your team an admin panel without building one. You describe each entity once, as a **resource**: which fields to show and edit, how to filter and search it, which actions run on it and which numbers matter. Helm turns that into list, detail, create and edit screens, bulk actions and metric cards, served by your app at `/admin`.

Helm works with what your app already has:

- **Your entities.** A resource wraps a SeaORM entity. There is no second schema to keep in sync.
- **Your access rules.** Every screen, row, field and action is checked against your [abilities](https://navree.com/docs/security/authorization.md), the same rules your API enforces. A sales rep who may only edit their own deals sees exactly that in Helm.
- **Your unit of work.** Every change runs in a transaction with the signed-in user as the actor, so your observers run and the [audit log](https://navree.com/docs/security/audit-log.md) records who changed what.

The screens are rendered on the server from templates, with a small stylesheet and script embedded in the crate: there is nothing to build and no front-end toolchain. They work without the script, which only adds conveniences such as confirmations, local times, searchable pickers, the command menu and the collapsible sidebar.

## Installation

The `helm` feature of the `navree` facade brings the crate `navree-helm`. It is part of `full`, the default. Add the module to your app root:

```rust title="src/app.rs"
pub fn app(sources: ConfigSources) -> AppBuilder {
    Navree::app()
        .module(ConfigModule::for_root(sources))
        .module(CryptoModule::from_config())
        .module(HttpModule::from_config())
        .module(DatabaseModule::from_config())
        .module(AuthModule::from_config())
        .module(AccessModule::for_root(access()))
        // ... your feature modules
        .module(HelmModule::from_config())
}
```

`HelmModule` is global and needs `DatabaseModule`, `AuthModule` (sign-in), `AccessModule` (every check) and `CryptoModule` (the session cookie). It has no tables of its own. `HelmModule::for_root(HelmOptions { .. })` takes the [options](#configuration) in code instead.

Helm registers the subject `Helm` with the permission prefix `helm`. The `view` ability on it lets a user into the panel; what they can do inside depends on their abilities on each resource. Grant it to the roles that should have a panel:

```sh
navree access:grant manager helm.view
navree access:grant rep helm.view
```

Open `http://127.0.0.1:8080/admin` and sign in with your app's account. Until you add a resource, the panel shows an empty dashboard and a hint to run `make:resource`.

## Your first resource

Generate a resource for an entity with the CLI. The first argument is the module, the second the entity:

```sh
navree make:resource deals Deal
```

```text
  created  src/modules/deals/resources/deal.rs
  created  src/modules/deals/resources/mod.rs
  updated  src/modules/deals/mod.rs
  updated  src/modules/deals/mod.rs
```

The generator reads the entity's columns (from `entities/deal.rs`, or the module's `entity.rs`) and writes a field for each one: text, numbers, money when there is a `currency` column, dates, booleans, read-only timestamps, and a comment suggesting `Field::belongs_to` for each foreign key. It then declares the `resources` module and registers the resource in the module's `wire` method, at the `// navree:wire` marker:

```rust title="src/modules/deals/mod.rs"
impl DealsModule {
    fn wire(&self, m: &mut ModuleBuilder) {
        m.migrations(migrations::migrations());
        navree::helm::ModuleBuilderHelmExt::resource::<resources::deal::DealResource>(m);
        // navree:wire
    }
}
```

`m.resource::<R>()` registers `R` as a provider of the module and adds it to the panel. The prelude brings it into scope, so in code you write by hand it is `m.resource::<DealResource>()`. When the entity doesn't implement `Subject` yet, the generator prints how to add it. Reload `/admin`: **Deals** is in the menu, with a list, filters and search, a detail page, and create and edit forms.

## Resources

A resource is a provider that implements `Resource` for one entity. This is a complete one, for deals:

```rust title="src/modules/deals/resources/deal.rs"
use navree::helm::{Action, ActionResult, Field, Filter, Metric, Resource};
use navree::prelude::*;
use sea_orm::{ColumnTrait, QueryFilter};

use crate::modules::companies::resources::{CompanyResource, ContactResource};
use crate::modules::deals::entity::{self as deal, Stage};
use crate::modules::deals::{DealService, Move, MoveDeal};
use crate::modules::users::resources::UserResource;

#[injectable]
pub struct DealResource;

impl Resource for DealResource {
    type Entity = deal::Entity;

    const LABEL: &'static str = "Deals";
    const SINGULAR: &'static str = "Deal";
    const GROUP: &'static str = "Sales";
    const ICON: &'static str = "handshake";
    const TITLE: &'static str = "title";
    const SEARCH: &'static [&'static str] = &["title"];

    fn fields(&self) -> Vec<Field> {
        vec![
            Field::text("title").required().max_length(200).sortable(),
            Field::money("amount_cents", "currency").label("Amount").min(0).sortable(),
            Field::select("stage")
                .options(Stage::ALL.iter().map(|s| (s.as_str(), s.label())))
                .badge()
                .sortable(),
            Field::belongs_to::<CompanyResource>("company_id").label("Company").required(),
            Field::belongs_to::<ContactResource>("contact_id").label("Contact"),
            Field::belongs_to::<UserResource>("owner_id").label("Owner"),
            Field::date("expected_close_date").sortable(),
            Field::computed("probability", |deal: &deal::Model| format!("{}%", deal.stage.probability())),
            Field::textarea("lost_reason").hide_on_index(),
            Field::datetime("closed_at").readonly(),
            Field::datetime("created_at").readonly().sortable(),
        ]
    }

    fn filters(&self) -> Vec<Filter<deal::Entity>> {
        vec![
            Filter::select("stage").options(Stage::ALL.iter().map(|s| (s.as_str(), s.label()))),
            Filter::belongs_to::<UserResource>("owner_id").label("Owner"),
            Filter::date_range("expected_close_date"),
            Filter::boolean("open", "Open deals only", |select, on| {
                if on { select.filter(deal::Column::ClosedAt.is_null()) } else { select }
            }),
        ]
    }

    fn actions(&self) -> Vec<Action<deal::Entity>> {
        vec![
            Action::new("mark-won", "Mark as won")
                .ability(Move)
                .confirm("Mark the selected deals as won?")
                .handle(|ctx, deals| async move {
                    let service = ctx.get::<DealService>().await?;
                    let count = deals.len();
                    for deal in deals {
                        let input = MoveDeal { stage: Some(Stage::Won), ..MoveDeal::default() };
                        service.move_to(ctx.uow(), ctx.user_id(), deal, input).await?;
                    }
                    Ok(ActionResult::message(format!("{count} deals marked as won.")))
                }),
        ]
    }

    fn metrics(&self) -> Vec<Metric<deal::Entity>> {
        vec![
            Metric::value("Open pipeline")
                .sum(deal::Column::AmountCents)
                .filter(deal::Column::ClosedAt.is_null())
                .money("USD")
                .on_dashboard(),
            Metric::trend("New deals").count().by_day(deal::Column::CreatedAt, 30),
            Metric::partition("Deals by stage").count().group_by(deal::Column::Stage),
        ]
    }
}
```

| Item | What it does |
|---|---|
| `type Entity` | The SeaORM entity. It must implement `Subject`, which it already does if your routes check it with `#[can]`. `m.resource::<R>()` registers it with navree-access (like `AccessModule::for_feature`), so permissions such as `deals.read` map to it. |
| `LABEL`, `SINGULAR` | The plural and singular names on screen. `SINGULAR` defaults to `LABEL`. |
| `GROUP` | The menu group. Resources without one go under **Resources**. |
| `ICON` | A [Lucide](https://lucide.dev/icons/) icon name for the menu, such as `handshake`, `users`, `building-2`, `receipt` or `package`. Helm embeds about 140 common Lucide icons (no external requests); any other name shows a folder icon and logs a warning at boot. Custom front ends get the name from the [metadata API](#the-json-api). |
| `TITLE` | The column that names a record: in headings, breadcrumbs and relationship fields. Defaults to `id`. |
| `SEARCH` | The columns the search box matches, case-insensitively. |
| `SLUG` | The resource's path segment, `/admin/resources/deals`. Defaults to the table name, with `-` for `_`. |
| `PER_PAGE` | Rows per page on the list (at most 100). `0`, the default, uses `helm.per_page` (25). |
| `DEFAULT_SORT` | The list's order, such as `"-amount_cents"`. Defaults to newest first. See [sorting](#search-sorting-and-filters). |
| `ORDER` | The position in the menu within the group (lower first). |
| `fields`, `filters`, `actions`, `metrics` | What the screens show. Only `fields` is required. |
| `query` | The base query every screen starts from. See [scoping the query](#scoping-the-query). |
| `create`, `update`, `delete` | How records are saved. See [saving through your services](#saving-through-your-services). |

Helm checks every resource when the app boots, so a mistake fails the boot with a message that names the resource: a field or `TITLE` that isn't a column, an invalid `pattern`, a relationship to a resource that isn't registered, an action without `.handle(..)`, a trend without a period, or two resources with the same slug.

### The screens

- **List.** A table of the fields shown on the index, with a search box, the filters in a side panel, sortable column headers and pagination. Tick rows to run an action on them. The resource's metrics sit above the table as cards.
- **Detail.** Every field shown on the detail page, a header with the record's title, buttons for edit, delete and the actions, and below it a panel for each `has_many` relationship, with its own actions.
- **Create and edit.** A form with every editable field: a text input, a number input with the currency beside it, a select, a picker for a related record (searchable when the script runs), a date or date-time picker. Validation errors appear under their fields.
- **Dashboard.** The panel's home page, `/admin`, shows the metrics marked `.on_dashboard()` from every resource the user may read.

Destructive buttons and actions that ask a question show a confirmation first: a dialog when the script runs, otherwise a confirmation page.

## Fields

Each field names a column of the entity, except `computed`, `has_many` and `view`.

| Field | Shows | In forms |
|---|---|---|
| `Field::id()` | The primary key | Never editable |
| `Field::text(column)` | The text | A text input |
| `Field::textarea(column)` | The first line on the list, everything on the detail page | A text area |
| `Field::markdown(column)` | Rendered Markdown on the detail page, without raw HTML | A text area with a preview |
| `Field::number(column)` | The number | A number input |
| `Field::money(column, currency_column)` | An amount in minor units with its currency, as `$12,500.00` (`USD` when the record has none) | A decimal input; stored in minor units |
| `Field::boolean(column)` | A check mark | A switch |
| `Field::select(column)` | The option's label, or a badge with `.badge()` | A select of `.options(..)` |
| `Field::date(column)`, `Field::datetime(column)` | The date; a date-time in the viewer's time zone | A date or date-time picker (in the viewer's time zone; stored in UTC) |
| `Field::email(column)`, `Field::url(column)` | A link | A text input, validated |
| `Field::file(column, disk)` | A download link, or a preview for images | An upload to that [storage disk](https://navree.com/docs/digging-deeper/storage.md) (needs `StorageModule`); the column holds the file's path, and a refused file (its type or size) shows its error under the field |
| `Field::belongs_to::<R>(column)` | The related record's title, linked to its page | A picker of the records of `R` the user may read |
| `Field::has_many::<R>(foreign_key)` | A panel on the detail page listing the related records, with their own actions | Not in forms |
| `Field::computed(name, f)` | The value `f(&model)` returns | Not in forms |
| `Field::view(name, template)` | A [template](https://navree.com/docs/digging-deeper/views.md) rendered with `record` (the record's columns) and `field` (needs `ViewModule`) | Not in forms |

Modifiers chain after the constructor:

| Modifier | What it does |
|---|---|
| `.label("Amount")` | The label. Defaults to the column name in sentence case. |
| `.help("Shown under the input")` | Help text in forms. |
| `.sortable()` | Lets the list sort by this column. |
| `.readonly()` | Shown but never editable (and left out of create forms). |
| `.hide_on_index()`, `.hide_on_detail()`, `.hide_on_forms()`, `.only_on_forms()` | Where the field appears. |
| `.required()`, `.min(n)`, `.max(n)`, `.min_length(n)`, `.max_length(n)`, `.pattern(regex)` | Validation in forms, with the same messages as [validation](https://navree.com/docs/basics/validation.md) (`title should not be empty`). For money, `min` and `max` are in minor units. |
| `.default(value)` | The value a create form starts with, and the value saved when a create leaves the field out. |
| `.options(pairs)`, `.badge()` | The `(value, label)` pairs of a select, and showing the label as a badge. |
| `.accept(["image/*"])`, `.max_size(bytes)` | The types a file field accepts (sniffed from the content) and its size limit (10 MiB by default). |

On the company resource, a `has_many` field lists each company's deals on its page. The related resource needs a `Field::belongs_to` on that foreign key, which `DealResource` has:

```rust title="src/modules/companies/resources/company.rs"
fn fields(&self) -> Vec<Field> {
    vec![
        Field::text("name").required().sortable(),
        Field::url("website"),
        Field::text("city").sortable(),
        Field::belongs_to::<UserResource>("owner_id").label("Owner"),
        Field::has_many::<DealResource>("company_id").label("Deals"),
    ]
}
```

## Search, sorting and filters

The search box matches the `SEARCH` columns, ignoring case (non-ASCII letters included). The list sorts by any `.sortable()` field, newest first by default (`-created_at` when the entity has that column, else its primary key descending); set `const DEFAULT_SORT: &'static str = "-amount_cents";` to change it (a leading `-` sorts descending).

Filters narrow the list from the side panel:

| Filter | Lets the user pick |
|---|---|
| `Filter::select(column).options(..)` | One or more values |
| `Filter::belongs_to::<R>(column)` | One or more related records |
| `Filter::boolean(name, label, f)` | On or off; `f(select, on)` changes the query |
| `Filter::date_range(column)` | A range of dates (`2026-10-01..2026-10-31`, either end optional), or a preset: today, this week, this month, this quarter |
| `Filter::custom(name, label, f)` | A value typed in; `f(select, value)` changes the query |

The search, filters, sort and page are in the URL (`?search=globex&filters[stage]=won&sort=-amount_cents&page=2`), so a filtered list is a link you can share. A filter takes one or more values: repeat it, or separate them with commas.

### Scoping the query

`query` is the starting point of every screen, metric and relationship picker. Use it to hide soft-deleted rows or to narrow what the panel works on:

```rust
fn query(&self, select: Select<company::Entity>) -> Select<company::Entity> {
    select.filter(company::Column::DeletedAt.is_null())
}
```

Helm applies the user's access filter after `query`, so `query` never widens what a user may see.

## Actions

An action runs on the records ticked on the list, or on one record from its page. It names an ability, and runs only on the records the user has that ability on:

```rust
Action::new("mark-lost", "Mark as lost")
    .ability(Move)
    .fields(vec![Field::textarea("lost_reason").label("Reason").required()])
    .handle(|ctx, deals| async move {
        let reason: String = ctx.input("lost_reason")?;
        let service = ctx.get::<DealService>().await?;
        for deal in deals {
            let input = MoveDeal {
                stage: Some(Stage::Lost),
                lost_reason: Some(reason.clone()),
                ..MoveDeal::default()
            };
            service.move_to(ctx.uow(), ctx.user_id(), deal, input).await?;
        }
        Ok(ActionResult::message("Deals marked as lost."))
    })
```

| Builder | What it does |
|---|---|
| `Action::new(name, label)` | The action's name (in URLs and the audit log) and its button label. |
| `.ability(Move)` | The action marker each selected record is checked against. Defaults to `Update`. |
| `.confirm(text)` | Asks before running. Actions with `.destructive()` always ask, in red. |
| `.fields(vec![..])` | A small form shown before the action runs; read the values with `ctx.input(name)`. |
| `.only_on_detail()`, `.only_on_index()` | Where the button appears. |
| `.queued()` | Runs the action as a `helm.action` job on your [queue](https://navree.com/docs/digging-deeper/queues.md) and answers at once. Use it for slow work, such as exports. Without a queue in the app it runs in the request. |
| `.handle(f)` | The work. `f(ctx, records)` gets an `ActionContext` and the records the user may act on. |

The handler runs inside the request's unit of work: return an error and every change rolls back, and the error's message appears on screen. `ActionContext` has `uow()`, `user_id()`, `get::<T>()` to resolve services (with the visibility of the resource's module), `input(name)` and `inputs()`. Records the user isn't allowed to act on are left out, and the result says how many were skipped; when none is left, the action is refused with a 403. A queued action checks the user's abilities again when the job runs.

When the handler calls a method on a record itself (rather than passing it to a service), Rust can't infer the records' type: write `|ctx, deals: Vec<deal::Model>|`.

`ActionResult::message(text)` shows a message, `ActionResult::redirect(url)` goes somewhere, and `ActionResult::download(url)` starts a download, for example of an export a queued action stored on a [disk](https://navree.com/docs/digging-deeper/storage.md) with a temporary URL. A redirect or download to another site opens from a link on a confirmation page.

## Metrics

Metrics are cards above a resource's list, and on the dashboard when marked `.on_dashboard()`. Each is one query, computed over the rows the user may read:

| Metric | Shows | Built with |
|---|---|---|
| `Metric::value(label)` | One number | `.count()`, `.sum(col)`, `.average(col)`, `.min(col)`, `.max(col)`, `.filter(cond)`, `.money(currency)`, `.over(col)` |
| `Metric::trend(label)` | A line over time | an aggregate, then `.by_day(col, days)`, `.by_week(col, weeks)` or `.by_month(col, months)` |
| `Metric::partition(label)` | A breakdown by a column | an aggregate, then `.group_by(col)` |

A value metric with a date column (`.over(deal::Column::CreatedAt)`) gets a range picker (7, 30 or 90 days, this month, this year; 30 days by default) and shows the change against the period before. A partition labels its groups with a select field's option labels, or with the titles of related records for a `belongs_to` column. Results are cached in memory for `helm.metrics_cache_secs` (60) per user, access version and range.

## Authorization

Helm doesn't have its own permission system. It asks your abilities, with the [access rules](https://navree.com/docs/security/authorization.md) and the same SQL filters your API uses:

| In Helm | Requires |
|---|---|
| Entering the panel | `view` on `Helm` (permission `helm.view`) |
| A resource in the menu, its list and its metrics | `read` on its subject, as a type-level check; rows are filtered like `Accessible<E, Read>` |
| A record's page | `read` on that record; a record the filter hides is a 404 |
| The create form | `create` on the subject; the saved record must then pass your `create` rules too |
| Editing a record | `update` on that record; fields your rules don't permit (`.fields([..])` rules) are shown read-only, and changing them is a 403 |
| Deleting a record | `delete` on that record |
| An action | its ability on each selected record |
| A relationship picker | `read` on the related resource; it lists only readable records |
| A field's value | `read` on that field (a `cannot read .. fields [..]` rule hides it) |

So with rules where reps read every deal but update only their own, and only some of its fields, a rep's Helm lists every deal, shows an **Edit** button only on their own, and grays out the fields they may not change. The API under `/admin/api` enforces the same checks: hiding a button is never the only protection.

Every create, update and delete runs through `Repo` in the request's [unit of work](https://navree.com/docs/database/unit-of-work.md), so [audit](https://navree.com/docs/security/audit-log.md) entries name the user who made the change (`deal.updated`), and each action run adds an entry `helm.action` with the action's name, the resource and the record ids.

### Saving through your services

By default Helm writes the form's values straight to the entity's columns: a create gets a UUID v7 key when the key is a UUID and `created_at` and `updated_at` set to now when the entity has them, and an update sets `updated_at`. When your service does more than that, such as moving the other deals of a stage, emitting events or checking a business rule, save through it instead by overriding `create`, `update` or `delete`:

```rust
use navree::BoxFuture;
use navree::helm::{FormData, SaveContext};

impl Resource for DealResource {
    // ...

    fn update<'a>(
        &'a self,
        ctx: &'a SaveContext,
        deal: deal::Model,
        form: FormData,
    ) -> BoxFuture<'a, Result<deal::Model>> {
        Box::pin(async move {
            let deals = ctx.get::<DealService>().await?;
            let input: UpdateDeal = form.parse().await?; // garde validation, errors under each field
            deals.update_model(ctx.uow(), ctx.user_id(), deal, input).await
        })
    }
}
```

`form.parse::<T>()` deserializes the submitted values into your DTO and validates it with its garde rules, exactly as `Valid<Json<T>>` does, so the panel and your API apply the same validation. The values have already passed the fields' own rules; money is in minor units, dates are `YYYY-MM-DD` and date-times RFC 3339. `form.values()` and `form.get(name)` give them as JSON. An `AppError` from your service appears on the form: a 400 with field errors under the fields, anything else as a message.

## The panel's look and pages

The `helm` section sets the panel's name and look:

```toml title="config/app.toml"
[helm]
title = "Acme CRM"
logo = "/brand/logo.svg"   # a URL; served by your app or a CDN
accent = "#3dff8f"
per_page = 25
```

The look is dark, on ink surfaces, with your accent on the primary button of each screen, focus rings and positive figures. The layout is a sidebar and a top bar:

- **The sidebar** lists the dashboard and your resources and pages, grouped by `GROUP`, then ordered by `const ORDER: i32` (lower first) and label, each with its `ICON`. Its header shows `helm.title` with `helm.logo`, and its footer the signed-in user, with **Sign out**. `Ctrl+B` (`⌘B` on a Mac) or the button at the top left collapses it to its icons; the panel remembers the choice in a cookie (`navree_helm_sidebar`), so the next page opens the same way.
- **The top bar** shows the breadcrumb and **Go to** (`Ctrl+K`, `⌘K` on a Mac), a command menu that opens any resource or page of the menu by name and, on a list with a search box, searches it.
- **On phones** the sidebar opens as a sheet over the page, and tables become lists of cards.

### Custom pages

For a screen that isn't a resource, add a page rendered from a [template](https://navree.com/docs/digging-deeper/views.md) inside the panel's layout:

```rust
use navree::helm::HelmPage;

m.helm_page(
    HelmPage::new("pipeline", "Pipeline board")
        .group("Sales")
        .icon("kanban")
        .template("admin/pipeline")
        .can("read", "Deal"),
);
```

The page is served at `/admin/pages/pipeline` and appears in the menu for users who pass its check. Its template is one of your app's templates, so the app needs `ViewModule`. It gets `title`, `user` (`{id, name, email}`), `csrf_token`, `path` (the panel's path) and `data`, and can call your API, or get its data from `.data(|ctx| async move { .. })`, which returns a `Result<serde_json::Value>`. `ctx` is a `PageContext` with `uow()`, `user_id()` and `get::<T>()`. The panel's Content Security Policy allows scripts from your app's origin only, so a page's script is a file your app serves, never inline.

### Settings

(Feature: `settings`) When the app has `SettingsModule`, the panel edits every [settings type](https://navree.com/docs/packages/settings.md) your modules register. **System → Settings** lists the types the signed-in user manages: those whose `permission` they hold app-wide, else those `settings.manage` covers. Each type opens at `/admin/settings/{key}` for a level: global, or an organization, workspace or user named by its id or slug. Every field shows where its value comes from; saving stores only the fields you changed, so the others keep inheriting, and a field set at the level has an **Inherit** button. Writes go through the `Settings` service in the request's unit of work, so they are validated, audited and announced like any other. The screens come with the `helm` and `settings` features together (both are in `full`; on the crate, `navree-helm`'s `settings` feature).

## Security

- **Sign-in.** The panel's sign-in screen uses your app's users through `AuthService`: password, then a two-factor code (or a recovery code) when the account has [two-factor authentication](https://navree.com/docs/security/account-flows.md) on, with the same login throttle as `POST /api/auth/login`. The session is an encrypted, httpOnly, `SameSite=Strict` cookie named `navree_helm`, scoped to `/admin`, so your API never accepts it. It lasts `helm.session_minutes`, renewed while you use the panel, and is `Secure` on every host but `localhost`. Signing out revokes the sign-in's refresh token.
- **CSRF.** Every change the panel makes sends a CSRF token (a `_csrf` form field, or the `X-CSRF-Token` header), and requests without it are refused with 403. The sign-in form has its own token. If your app also uses `SessionModule`, add the panel to its exceptions (`session.csrf_except = ["/admin/*"]`): Helm checks its own token there.
- **Deny by default.** Nobody enters without `view` on `Helm`, and every screen and API call checks the user's ability on the subject and the record, as [above](#authorization). The super-admin role passes every check.
- **Strict pages.** Pages are served with a strict Content Security Policy (no inline scripts or styles), `X-Frame-Options: DENY` and `Cache-Control: no-store`. Field values are escaped; `Field::markdown` renders without raw HTML and keeps only `http(s)`, `mailto` and relative links.
- **Rate limits.** The panel and its API are subject to your app's per-IP [rate limit](https://navree.com/docs/basics/rate-limiting.md).

The panel and its JSON API also accept an access token in `Authorization: Bearer`, which needs no CSRF token. That is how you test it, and how a [backend-for-frontend](https://navree.com/docs/patterns/backend-for-frontend.md) serves it: when the BFF forwards `/admin` with the signed-in user's token, as the [SaaS starter kit](https://navree.com/docs/getting-started/starter-kits.md) does, Helm opens on the web app's origin with the same session and its sign-in screen never appears.

## Testing

The panel's API is ordinary JSON under `/admin/api`, so you test resources with [HTTP tests](https://navree.com/docs/testing/http-tests.md), acting as users with different roles:

```rust title="tests/helm_deals.rs"
use navree::prelude::*;
use navree::testing::principal;
use serde_json::json;

#[tokio::test]
async fn reps_read_every_deal_but_edit_only_their_own() {
    // `boot_app`, `create_user` and `create_deal` are your tests' own helpers.
    let app = boot_app().await;
    let priya = create_user(&app, "priya@example.com", "rep").await;
    let tom = create_user(&app, "tom@example.com", "rep").await;
    let toms_deal = create_deal(&app, tom.id, "Globex renewal").await;

    let rep = app.acting_as(principal(priya.id));
    rep.get("/admin/api/resources/deals")
        .query(&[("search", "Globex")])
        .send()
        .await
        .assert_status(200)
        .assert_json_path("meta.total", 1.into());

    rep.patch(&format!("/admin/api/resources/deals/{}", toms_deal.id))
        .json(&json!({ "title": "Mine now" }))
        .send()
        .await
        .assert_error(403, None);
}
```

`boot_app` builds the app with `TestApp::builder().fake_auth()` so `acting_as` works next to `AuthModule`.

### The JSON API

| Route | What it does |
|---|---|
| `GET /admin/api/resources` | The resources the user may read, with their fields, filters, actions, metrics and `can.create`, and the pages they may open |
| `GET /admin/api/resources/{resource}` | The list: `search`, `filters[name]`, `sort`, `page`, `per_page`; answers `{"data": [..], "meta": {..}}` |
| `GET /admin/api/resources/{resource}/{id}` | One record with its fields |
| `GET /admin/api/resources/{resource}/options` | Records for a relationship picker: `search`; answers `{"data": [{"id", "title"}]}`, at most 20 |
| `POST /admin/api/resources/{resource}` | Creates a record from `{"field": value, ..}` (201) |
| `PATCH /admin/api/resources/{resource}/{id}` | Updates a record with the fields sent |
| `DELETE /admin/api/resources/{resource}/{id}` | Deletes a record (204) |
| `POST /admin/api/resources/{resource}/actions/{action}` | Runs an action: `{"ids": [..], "input": {..}}`; answers the result (`{"type": "message", "message"}`, or `"redirect"` / `"download"` with `url`) with `ran`, `skipped` and `queued` |
| `GET /admin/api/resources/{resource}/metrics/{metric}` | One metric (its name is its label in kebab-case, `open-pipeline`): `range`; answers `value`, `previous`, `change` and `display`, trend `points` or partition `groups` |

A record is `{"id", "title", "values", "display", "can": {"update", "delete"}}`: `values` holds the raw column values (money in minor units), `display` the text the screens show, and a single record also lists the fields the user may not change under `readonly`. A field sent that isn't an editable field of the resource is a 400 (`property created_at should not exist`). The API doesn't take uploads: a file field accepts only the path of a file the panel's forms stored for that resource (`deals/<random name>.pdf`), and anything else is a 400 (`attachment is invalid`).

Errors use Navree's JSON error shape (`{ "statusCode", "message", "error", "code" }`), and a validation error lists its field messages under `errors`, as in your API.

## Configuration

Every key of the `helm` section is optional. From the environment: `APP_HELM__TITLE="Acme CRM"`, `APP_HELM__PER_PAGE=50`.

| Key | Default | What it does |
|---|---|---|
| `helm.path` | `/admin` | Where the panel is served. |
| `helm.title` | `Admin` | The name in the header and the browser tab. |
| `helm.logo` | none | A logo URL for the sidebar's header and the sign-in screen: a path on your app or an `http(s)` URL. |
| `helm.accent` | `#3dff8f` | The accent color of the primary buttons, focus rings and positive figures (`#rgb` or `#rrggbb`). |
| `helm.per_page` | `25` | Rows per page on lists (at most 100). |
| `helm.metrics_cache_secs` | `60` | How long metric results are cached per user and range; `0` turns the cache off. |
| `helm.dashboard` | `true` | Show metric cards on the panel's home page. |
| `helm.session_minutes` | `480` | How long a sign-in to the panel lasts. |

| Command | What it does |
|---|---|
| `navree make:resource <module> <Entity> [--path src/modules]` | Generates a resource with a field for every column of the entity, and registers it. The entity may be written `Deal` or `deal`. |

- [Authorization](https://navree.com/docs/security/authorization.md): The abilities every Helm screen checks.
- [Entities and repositories](https://navree.com/docs/database/entities-and-repositories.md): The entities resources are built on.
- [The audit log](https://navree.com/docs/security/audit-log.md): Who changed what, from the API or from Helm.
