navree
Documentation

Helm

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

On this page

Introduction

Featurehelm 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, 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 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:

src/app.rs

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 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:

Shell

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:

Shell
Text

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:

src/modules/deals/mod.rs

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:

src/modules/deals/resources/deal.rs
ItemWhat it does
type EntityThe 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, SINGULARThe plural and singular names on screen. SINGULAR defaults to LABEL.
GROUPThe menu group. Resources without one go under Resources.
ICONA Lucide 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.
TITLEThe column that names a record: in headings, breadcrumbs and relationship fields. Defaults to id.
SEARCHThe columns the search box matches, case-insensitively.
SLUGThe resource's path segment, /admin/resources/deals. Defaults to the table name, with - for _.
PER_PAGERows per page on the list (at most 100). 0, the default, uses helm.per_page (25).
DEFAULT_SORTThe list's order, such as "-amount_cents". Defaults to newest first. See sorting.
ORDERThe position in the menu within the group (lower first).
fields, filters, actions, metricsWhat the screens show. Only fields is required.
queryThe base query every screen starts from. See scoping the query.
create, update, deleteHow records are saved. See 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.

FieldShowsIn forms
Field::id()The primary keyNever editable
Field::text(column)The textA text input
Field::textarea(column)The first line on the list, everything on the detail pageA text area
Field::markdown(column)Rendered Markdown on the detail page, without raw HTMLA text area with a preview
Field::number(column)The numberA 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 markA 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 zoneA date or date-time picker (in the viewer's time zone; stored in UTC)
Field::email(column), Field::url(column)A linkA text input, validated
Field::file(column, disk)A download link, or a preview for imagesAn upload to that storage disk (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 pageA 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 actionsNot in forms
Field::computed(name, f)The value f(&model) returnsNot in forms
Field::view(name, template)A template rendered with record (the record's columns) and field (needs ViewModule)Not in forms

Modifiers chain after the constructor:

ModifierWhat 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 (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:

src/modules/companies/resources/company.rs

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:

FilterLets 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

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
BuilderWhat 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 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 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:

MetricShowsBuilt 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 timean aggregate, then .by_day(col, days), .by_week(col, weeks) or .by_month(col, months)
Metric::partition(label)A breakdown by a columnan 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 and the same SQL filters your API uses:

In HelmRequires
Entering the panelview on Helm (permission helm.view)
A resource in the menu, its list and its metricsread on its subject, as a type-level check; rows are filtered like Accessible<E, Read>
A record's pageread on that record; a record the filter hides is a 404
The create formcreate on the subject; the saved record must then pass your create rules too
Editing a recordupdate on that record; fields your rules don't permit (.fields([..]) rules) are shown read-only, and changing them is a 403
Deleting a recorddelete on that record
An actionits ability on each selected record
A relationship pickerread on the related resource; it lists only readable records
A field's valueread 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, so audit 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

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:

config/app.toml

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 inside the panel's layout:

Rust

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

Featuresettings When the app has SettingsModule, the panel edits every settings type 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 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. 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.

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 serves it: when the BFF forwards /admin with the signed-in user's token, as the SaaS starter kit 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, acting as users with different roles:

tests/helm_deals.rs

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

The JSON API

RouteWhat it does
GET /admin/api/resourcesThe 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}/optionsRecords 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.

KeyDefaultWhat it does
helm.path/adminWhere the panel is served.
helm.titleAdminThe name in the header and the browser tab.
helm.logononeA logo URL for the sidebar's header and the sign-in screen: a path on your app or an http(s) URL.
helm.accent#3dff8fThe accent color of the primary buttons, focus rings and positive figures (#rgb or #rrggbb).
helm.per_page25Rows per page on lists (at most 100).
helm.metrics_cache_secs60How long metric results are cached per user and range; 0 turns the cache off.
helm.dashboardtrueShow metric cards on the panel's home page.
helm.session_minutes480How long a sign-in to the panel lasts.
CommandWhat 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.