Skip to main content

laterite_admin/
lib.rs

1//! Laterite admin: the operator-facing web surface.
2//!
3//! An Axum router mounted under a configurable path (default `/admin`, set by
4//! [`AdminConfig::path`]): a login screen and session cookie verified against
5//! `laterite-auth`, and descriptor-driven screens.
6//!
7//! Screens are **resources**: a module declares a [`Resource`] (a
8//! [`list::ListConfig`], optionally a [`form::FormConfig`], a base path, and a
9//! menu label), and the framework mounts the list, create, and edit routes and
10//! adds it to the menu. This is the extension point that lets an application
11//! contribute its own admin screens. The framework's own screens (users, roles)
12//! are just built-in resources.
13//!
14//! An application usually boots through [`Bootstrap`], which loads config,
15//! connects, migrates, and serves this router in one call.
16
17mod audit;
18pub mod bootstrap;
19mod bulk;
20pub mod checklist;
21mod clientip;
22pub mod descriptor;
23mod error;
24mod export;
25pub mod field;
26pub mod form;
27pub mod html;
28pub mod http_cache;
29mod icons;
30pub mod import;
31pub(crate) mod keyed;
32pub mod list;
33pub mod persist;
34pub mod picker;
35pub mod plugins;
36pub mod relation;
37mod roles;
38mod routemap;
39pub mod routes;
40mod session;
41pub mod settings;
42mod sql;
43mod upload;
44mod users;
45
46/// The axum a contributed route must build against.
47///
48/// A [`Screen`](routes::Screen) or [`PublicRoute`](routes::PublicRoute) returns an
49/// `axum::Router`, so a plugin needs the type. Take it from here and never declare
50/// axum in a plugin manifest: two majors linked side by side make `Router` a
51/// different type from `Router`, and the diagnostic for that is famously unhelpful.
52/// Re-exported so this crate's manifest is the only place the version is chosen.
53pub use axum;
54
55pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
56pub use error::AdminError;
57pub use session::{FlashLevel, SessionHandle};
58pub use upload::VerifiedUpload;
59
60use std::collections::HashMap;
61use std::sync::{Arc, RwLock};
62
63use askama::Template;
64use axum::body::Body;
65use axum::extract::{Path, Query, Request, State};
66use axum::http::{header, HeaderMap};
67use axum::middleware::{self, Next};
68use axum::response::{Html, IntoResponse, Redirect, Response};
69use axum::routing::{any, get, post};
70use axum::{Extension, Form, Router};
71use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
72use chrono_tz::{Tz, TZ_VARIANTS};
73use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
74use laterite_core::{t, CatalogStore, Db, Text, Translator};
75use serde::{Deserialize, Serialize};
76
77/// Typed contribution channels for the framework's admin surfaces, as an
78/// extension trait over the generic [`laterite_core::Registry`]. A module
79/// contributes its admin screens, permissions, and settings from its `register`;
80/// a wrong-type contribution is a compile error, not one silently ignored. The
81/// generic `add`/`items` underneath remains for open, plugin-defined points.
82pub trait AdminRegistry {
83    /// Adds a list/form resource (an admin screen).
84    fn add_resource(&mut self, resource: Resource);
85    /// Adds a permission, offered in the role editor.
86    fn add_permission(&mut self, permission: Permission);
87    /// Adds a settings model.
88    fn add_settings(&mut self, item: settings::SettingsItem);
89    /// Adds a reference-picker source, callable by a picker field's endpoints.
90    fn add_picker_source(&mut self, source: picker::PickerSourceReg);
91    /// Adds a form persister, selectable by a form descriptor's `persist` key.
92    fn add_persister(&mut self, persister: persist::PersisterReg);
93    /// Adds a field type, nameable by any form or settings descriptor.
94    fn add_field_type(&mut self, field_type: field::FieldTypeReg);
95    /// Mounts a screen of this module's own: any routes descriptors cannot
96    /// express, inside the admin with its session, permission and error handling.
97    fn add_screen(&mut self, screen: routes::ScreenReg);
98    /// Mounts a route outside the admin, at a literal path: robots.txt, a
99    /// sitemap, a feed, a webhook receiver.
100    fn add_public_route(&mut self, route: routes::PublicRouteReg);
101}
102
103impl AdminRegistry for laterite_core::Registry {
104    fn add_resource(&mut self, resource: Resource) {
105        self.add(resource);
106    }
107    fn add_permission(&mut self, permission: Permission) {
108        self.add(permission);
109    }
110    fn add_settings(&mut self, item: settings::SettingsItem) {
111        self.add(item);
112    }
113    fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
114        self.add(source);
115    }
116    fn add_screen(&mut self, screen: routes::ScreenReg) {
117        self.add(screen);
118    }
119    fn add_public_route(&mut self, route: routes::PublicRouteReg) {
120        self.add(route);
121    }
122    fn add_persister(&mut self, persister: persist::PersisterReg) {
123        self.add(persister);
124    }
125    fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
126        self.add(field_type);
127    }
128}
129
130const SESSION_COOKIE: &str = "laterite_session";
131/// The long-lived "stay signed in" credential. Separate from the session cookie
132/// so the session itself stays short and the credential can be rotated and
133/// revoked on its own.
134const REMEMBER_COOKIE: &str = "laterite_remember";
135
136/// The icon sprite's registry key. One request, cached forever, in place of a
137/// few hundred bytes of repeated markup per icon per render.
138pub(crate) const ICON_SPRITE: &str = "icons.svg";
139
140/// Shared state for the admin router. Constructed by [`router`].
141#[derive(Clone)]
142pub(crate) struct AdminState {
143    auth: AuthService,
144    db: Db,
145    nav: Arc<Vec<NavLink>>,
146    settings: Arc<Vec<settings::SettingsItem>>,
147    permissions: Arc<Vec<Permission>>,
148    /// The URL path the panel is mounted under, without a trailing slash (e.g.
149    /// `/admin`). Every route, redirect, link, and the session-cookie scope is
150    /// built from it, so one config value moves the whole panel.
151    admin_path: Arc<str>,
152    secure_cookie: bool,
153    /// Networks whose `X-Forwarded-For` is believed. Parsed once at boot.
154    trusted_proxies: Arc<Vec<ipnet::IpNet>>,
155    /// The framework's icon set plus whatever modules contributed.
156    icons: Arc<laterite_core::icons::IconSet>,
157    /// Where the sprite is served, content-named. Resolved once at boot.
158    sprite_url: Arc<str>,
159    /// The public origin for the CSRF origin check (see [`AdminConfig::origin`]).
160    origin: Arc<str>,
161    timezone: Tz,
162    /// The deployment default UI locale (a base tag like `en`), the last stop in a
163    /// request's locale chain before the English source. An operator's own
164    /// preference overrides it.
165    default_locale: Arc<str>,
166    /// The shared message catalogs, resolved once at boot and looked up per request
167    /// through the [`Translator`]. Empty until module catalogs are loaded.
168    catalogs: Arc<CatalogStore>,
169    /// The configured application name (the baseline brand). A brand setting
170    /// overrides it; see [`AdminState::brand`].
171    app_name: String,
172    /// The resolved brand name, cached across requests so the brand setting is
173    /// not read from the database on every page. Invalidated when the setting is
174    /// saved. `None` means "not resolved yet".
175    brand_cache: Arc<RwLock<Option<String>>>,
176    /// Each registered asset's content-named path. See [`asset_urls`].
177    asset_urls: Arc<AssetUrls>,
178    /// The field-type registry (built-ins plus contributions): resolves a form
179    /// descriptor's type key to its rendering + behaviour. See [`field`].
180    field_types: Arc<field::FieldRegistry>,
181    /// The column-type registry: resolves a list column's type key to its cell
182    /// rendering. Sibling to `field_types`; shares the override resolver.
183    column_types: Arc<list::ColumnRegistry>,
184    /// Contributions the framework does not consume, readable by a module's own
185    /// routes through `RouteCtx`.
186    plugin_defined: Arc<laterite_core::Registry>,
187    /// Embedded admin assets served under `{admin}/assets/` (see [`AdminAsset`]).
188    assets: Arc<AssetRegistry>,
189    /// The reference-picker source registry: resolves a picker field's `source`
190    /// key to the domain service its endpoints call. See [`picker`].
191    pickers: Arc<picker::PickerRegistry>,
192    /// The admin view-override resolver, default [`field::NoOverrides`] (the
193    /// compiled path). A theme layer injects a runtime-template-backed resolver
194    /// so users can override a field's presentation from outside the plugin.
195    overrides: Arc<dyn field::OverrideResolver>,
196}
197
198impl AdminState {
199    #[cfg(test)]
200    pub(crate) fn new(auth: AuthService, db: Db) -> Self {
201        Self {
202            auth,
203            db,
204            nav: Arc::new(Vec::new()),
205            settings: Arc::new(Vec::new()),
206            permissions: Arc::new(builtin_permissions()),
207            trusted_proxies: Arc::new(Vec::new()),
208            icons: Arc::new(laterite_core::icons::IconSet::new()),
209            sprite_url: Arc::from(""),
210            admin_path: Arc::from("/admin"),
211            secure_cookie: false,
212            origin: Arc::from(""),
213            timezone: Tz::UTC,
214            default_locale: Arc::from("en"),
215            catalogs: Arc::new(CatalogStore::default()),
216            app_name: "Laterite".to_string(),
217            brand_cache: Arc::new(RwLock::new(None)),
218            field_types: Arc::new(field::builtin_registry()),
219            column_types: Arc::new(list::builtin_column_registry()),
220            plugin_defined: Arc::new(laterite_core::Registry::new()),
221            assets: Arc::new(builtin_assets()),
222            asset_urls: Arc::new(asset_urls(&builtin_assets())),
223            pickers: Arc::new(picker::PickerRegistry::new()),
224            overrides: Arc::new(field::NoOverrides),
225        }
226    }
227
228    /// The brand name shown across the admin: the [`settings::BrandSetting`]
229    /// `app_name` when set, otherwise the configured application name. The
230    /// resolved value is cached until [`AdminState::invalidate_brand`] clears it.
231    async fn brand(&self) -> String {
232        {
233            let cached = self.brand_cache.read().unwrap().clone();
234            if let Some(name) = cached {
235                return name;
236            }
237        }
238        let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
239            Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
240            _ => self.app_name.clone(),
241        };
242        *self.brand_cache.write().unwrap() = Some(resolved.clone());
243        resolved
244    }
245
246    /// Clears the cached brand so the next resolution re-reads the setting.
247    fn invalidate_brand(&self) {
248        *self.brand_cache.write().unwrap() = None;
249    }
250}
251
252/// Deployment-level admin settings passed to [`router`]. Per-install brand and
253/// per-operator preferences are settings/preferences, not deployment config.
254///
255/// Non-exhaustive: build one with [`Default`] and set fields, so a field added
256/// in a later release is additive.
257#[derive(Clone)]
258#[non_exhaustive]
259pub struct AdminConfig {
260    /// Set the `Secure` attribute on the session cookie. Enable behind HTTPS in
261    /// production; leave off for plain-HTTP local development.
262    pub secure_cookie: bool,
263    /// Networks whose `X-Forwarded-For` may be believed, as CIDR ranges. Empty
264    /// trusts nothing and records the peer address. See
265    /// [`laterite_core::config::BackendConfig::trusted_proxies`].
266    pub trusted_proxies: Vec<String>,
267    /// Default display timezone for the admin (an IANA name like `Asia/Kolkata`).
268    /// Storage is UTC; this only affects rendering. Invalid or empty falls back
269    /// to UTC. An operator's own preference overrides it (later).
270    pub timezone: String,
271    /// Default UI locale for the admin (a base tag like `en` or `de`). An
272    /// operator's own preference and the request's `Accept-Language` override it;
273    /// a locale with no loaded catalog, or an empty value, falls back to `en`.
274    pub locale: String,
275    /// The application name, shown as the admin brand. This is the baseline; a
276    /// `BrandSetting` in the admin overrides it. Typically the configured
277    /// `app.name`. Empty falls back to `Laterite`.
278    pub app_name: String,
279    /// The URL path the panel is mounted under (typically the configured
280    /// `backend.path`). A leading slash is added if missing and a trailing slash
281    /// is stripped; empty falls back to `/admin`.
282    pub path: String,
283    /// The public origin (scheme://host[:port], no trailing slash) the panel is
284    /// served from, for the CSRF origin check. Typically the configured
285    /// `app.url`; empty falls back to the request `Host` (dev only), so set it in
286    /// production.
287    pub origin: String,
288    /// The directory files the admin keeps are stored under, relative to the
289    /// working directory unless absolute (typically the configured
290    /// `backend.storage`). Uploaded import files go to `private` beneath it
291    /// when the application registers no disk of that name.
292    pub storage_dir: String,
293}
294
295impl Default for AdminConfig {
296    fn default() -> Self {
297        Self {
298            secure_cookie: false,
299            trusted_proxies: Vec::new(),
300            timezone: "UTC".to_string(),
301            locale: "en".to_string(),
302            app_name: "Laterite".to_string(),
303            path: "/admin".to_string(),
304            origin: String::new(),
305            storage_dir: "storage".to_string(),
306        }
307    }
308}
309
310#[derive(Clone)]
311struct NavLink {
312    label: Text,
313    path: String,
314    /// An icon name (a Lucide subset, see [`icons`]), or `None` for a text-only
315    /// tab. The built-in Dashboard and Settings entries set one.
316    icon: Option<&'static str>,
317}
318
319/// The chrome shared by every authenticated page: the top-nav links and the
320/// signed-in operator. Built once by the auth guard and injected into request
321/// extensions, so page handlers render inside the same shell without each
322/// rebuilding it. Templates embed it as `shell` and `base.html` renders it.
323///
324/// A contributed screen takes it as `Extension<Shell>` and renders its own
325/// markup inside the chrome with [`Shell::page`]; [`Shell::t`] localizes its
326/// strings and [`Shell::csrf_token`] arms a form it builds by hand.
327#[derive(Clone)]
328pub struct Shell {
329    /// The admin mount path (e.g. `/admin`), so shared chrome (asset links, the
330    /// brand link, sign-out and preferences) builds URLs under the configured
331    /// panel path rather than a hardcoded `/admin`.
332    pub(crate) base: String,
333    /// The brand name shown in the top nav and drawer, resolved once per request
334    /// (the brand setting, or the configured application name). See
335    /// [`AdminState::brand`].
336    brand: String,
337    nav: Vec<NavView>,
338    full_name: String,
339    initial: String,
340    /// The timezone this operator's timestamps render in, resolved once per
341    /// request: the operator's own preference if set and valid, else the
342    /// deployment default. List and detail screens format dates in it.
343    tz: Tz,
344    /// The translator for this request's locale (resolved like `tz`), used by
345    /// `shell.t` / `shell.tt` in templates. A source with no catalog entry falls
346    /// back to itself.
347    i18n: Translator,
348    /// The context sidebar for the current section, resolved once per request
349    /// from the path (see [`resolve_nav_context`]). Empty means no sidebar.
350    /// `base.html` renders it, so any screen in a settings context shows it.
351    sidebar: Vec<settings::CategoryView>,
352    /// The icon set, for chrome that renders one outside a nav entry.
353    pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
354    /// Where the sprite lives, so chrome can reference a glyph rather than
355    /// carry its drawing instructions.
356    pub(crate) sprite_url: Arc<str>,
357    /// The site's own root, so the chrome can offer a way out to the front of
358    /// the site an operator is administering. The configured `app.url` when set,
359    /// else derived from the bind address.
360    site_url: String,
361    /// Each asset's content-named path, so chrome in `base.html` links assets
362    /// through [`Shell::asset`] rather than by a stable path the browser would
363    /// be entitled to keep.
364    asset_urls: Arc<AssetUrls>,
365    /// The current session's CSRF token, auto-injected into every rendered form
366    /// (a hidden field) and into HTMX requests (a header), so a mutating request
367    /// carries it without the handler doing anything. See [`session`].
368    pub(crate) csrf_token: String,
369    /// Flash messages to show once on this render, taken from the session on a
370    /// full-page GET (redirect-after-POST delivers them here) and localized in this
371    /// request's locale. See [`session`].
372    pub(crate) flash: Vec<FlashLine>,
373    /// Per-page widget assets (field/column scripts and styles) for this render,
374    /// deduped and emitted in the head after core `laterite.js`. See
375    /// [`page_assets`].
376    pub(crate) assets: Vec<PageAsset>,
377}
378
379/// A contributed screen's markup inside the chrome. See [`Shell::page`].
380#[derive(Template)]
381#[template(path = "page.html")]
382struct PageTemplate {
383    title: String,
384    body: html::Markup,
385    shell: Shell,
386}
387
388/// One per-page asset to load: a resolved URL and whether it is a stylesheet.
389#[derive(Clone)]
390pub(crate) struct PageAsset {
391    pub(crate) url: String,
392    pub(crate) css: bool,
393}
394
395/// A flash message localized for this render: the session's stored `Text` resolved
396/// to a display string in the operator's locale. `base.html` renders it.
397#[derive(Clone)]
398pub(crate) struct FlashLine {
399    pub(crate) level: session::FlashLevel,
400    pub(crate) text: String,
401    /// Stays until dismissed.
402    pub(crate) sticky: bool,
403}
404
405impl Shell {
406    #[allow(clippy::too_many_arguments)]
407    fn new(
408        base: &str,
409        brand: String,
410        nav: &[NavLink],
411        user: &AuthenticatedUser,
412        default_tz: Tz,
413        sidebar: Vec<settings::CategoryView>,
414        active_nav: Option<&str>,
415        csrf_token: String,
416        flash: Vec<session::Flash>,
417        i18n: Translator,
418        asset_urls: Arc<AssetUrls>,
419        site_url: String,
420        icon_set: Arc<laterite_core::icons::IconSet>,
421        sprite_url: Arc<str>,
422    ) -> Self {
423        let full_name = user.user.full_name();
424        let initial = full_name
425            .chars()
426            .next()
427            .map(|c| c.to_uppercase().to_string())
428            .unwrap_or_else(|| "?".to_string());
429        // Localize the flashes and nav labels here, where the request translator is
430        // in hand, before it is moved into the shell.
431        let flash = flash
432            .into_iter()
433            .map(|f| FlashLine {
434                sticky: f.sticky,
435                level: f.level,
436                text: i18n.t(&f.text),
437            })
438            .collect();
439        let nav = nav
440            .iter()
441            .map(|n| NavView {
442                label: i18n.t(&n.label),
443                path: n.path.clone(),
444                active: active_nav == Some(n.path.as_str()),
445                icon: n
446                    .icon
447                    .map(|name| icons::svg(&icon_set, &sprite_url, Some(name)))
448                    .unwrap_or_default(),
449            })
450            .collect();
451        Shell {
452            asset_urls,
453            icon_set,
454            sprite_url,
455            site_url,
456            base: base.to_string(),
457            brand,
458            nav,
459            full_name,
460            initial,
461            tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
462            i18n,
463            sidebar,
464            csrf_token,
465            flash,
466            assets: Vec::new(),
467        }
468    }
469
470    /// The site's own root, for the chrome's link out to the front of the site.
471    pub(crate) fn site_url(&self) -> &str {
472        &self.site_url
473    }
474
475    /// Inline SVG for a named icon, for chrome that is not a nav item.
476    pub(crate) fn icon(&self, name: &str) -> String {
477        icons::svg(&self.icon_set, &self.sprite_url, Some(name))
478    }
479
480    /// The URL for a built-in asset, named by a digest of its bytes. Templates
481    /// call `{{ shell.asset("laterite.css") }}`. An unregistered key yields the
482    /// plain path, which still serves (revalidating rather than immutable) and
483    /// trips a debug assertion.
484    pub(crate) fn asset(&self, key: &str) -> String {
485        match self.asset_urls.get(key) {
486            Some(path) => format!("{}/assets/{path}", self.base),
487            None => {
488                debug_assert!(false, "asset key `{key}` is not registered");
489                format!("{}/assets/{key}", self.base)
490            }
491        }
492    }
493
494    /// Localizes a source string in this request's locale, falling back to the
495    /// source itself. Templates call `{{ shell.t("Save") }}`.
496    pub fn t(&self, source: &str) -> String {
497        self.i18n.t(&Text::dynamic(source))
498    }
499
500    /// Localizes a prebuilt message (a `t!`/`tn!` value, a descriptor label, a
501    /// flash) into this request's locale.
502    pub fn tt(&self, text: &Text) -> String {
503        self.i18n.t(text)
504    }
505
506    /// The request's translator, for a field type that localizes strings of its
507    /// own (a repeater's sub-field labels) rather than only the label it is given.
508    pub fn i18n(&self) -> &Translator {
509        &self.i18n
510    }
511
512    /// The active locale (most specific in the chain), for `<html lang>`.
513    pub fn locale(&self) -> &str {
514        self.i18n.locale()
515    }
516
517    /// The admin mount (`/admin`), for a link into the panel.
518    pub fn base(&self) -> &str {
519        &self.base
520    }
521
522    /// This session's request token, for a form a screen builds by hand: a
523    /// hidden field named `_csrf`, placed first when the form uploads a file.
524    /// An HTMX request carries it already.
525    pub fn csrf_token(&self) -> &str {
526        &self.csrf_token
527    }
528
529    /// A UTC timestamp as stored (RFC 3339), shown in this operator's timezone.
530    /// Text that does not parse is shown as it is.
531    pub fn datetime(&self, utc: &str) -> String {
532        match chrono::DateTime::parse_from_rfc3339(utc) {
533            Ok(at) => at
534                .with_timezone(&self.tz)
535                .format("%Y-%m-%d %H:%M")
536                .to_string(),
537            Err(_) => utc.to_string(),
538        }
539    }
540
541    /// Renders `body` as a page in the admin chrome, headed by `title`. The
542    /// body is a screen's own markup, built from its templates through
543    /// [`html::Markup::from_template`].
544    pub fn page(&self, title: &Text, body: html::Markup) -> Response {
545        render(PageTemplate {
546            title: self.tt(title),
547            body,
548            shell: self.clone(),
549        })
550    }
551
552    /// Localizes a source string with integer `{name}` arguments, for a template
553    /// (`{{ shell.tf("Page {n} of {m}", [("n", page), ("m", pages)]) }}`). Nested-`Text`
554    /// arguments are built in Rust with `t!` and rendered with `tt`.
555    pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
556        let mut text = Text::dynamic(source);
557        for (name, value) in args {
558            text = text.arg(*name, *value);
559        }
560        self.i18n.t(&text)
561    }
562
563    /// Like [`Shell::tf`], but with string `{name}` arguments, for a template
564    /// (`{{ shell.tfs("Signed in as {name}", [("name", user)]) }}`).
565    pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
566        let mut text = Text::dynamic(source);
567        for (name, value) in args {
568            text = text.arg(*name, *value);
569        }
570        self.i18n.t(&text)
571    }
572
573    #[cfg(test)]
574    pub(crate) fn test() -> Self {
575        Shell {
576            asset_urls: Arc::new(asset_urls(&builtin_assets())),
577            icon_set: Arc::new(laterite_core::icons::IconSet::new()),
578            sprite_url: Arc::from("/admin/assets/icons.svg"),
579            site_url: "http://localhost".to_string(),
580            base: "/admin".to_string(),
581            brand: "Laterite".to_string(),
582            nav: Vec::new(),
583            full_name: "Test Operator".to_string(),
584            initial: "T".to_string(),
585            tz: Tz::UTC,
586            i18n: Translator::new("en"),
587            sidebar: Vec::new(),
588            csrf_token: "test-csrf-token".to_string(),
589            flash: Vec::new(),
590            assets: Vec::new(),
591        }
592    }
593}
594
595/// Whether `path` sits in the settings context, and if so which item code is
596/// active. A screen is in the settings context when it is the settings index or
597/// a settings form, or when its path falls under a settings item's `link` (its
598/// list, forms and sub-pages). The matching item is returned so the sidebar can
599/// highlight it. `visible` is the operator's permitted items, so a linked
600/// resource they cannot see never claims the context.
601fn settings_context(
602    visible: &[settings::SettingsItem],
603    admin_path: &str,
604    path: &str,
605) -> (bool, Option<String>) {
606    if path == format!("{admin_path}/settings") {
607        return (true, None);
608    }
609    // A settings screen mounts in its module's namespace, not under /settings,
610    // so the context is found by matching the resolved paths rather than by
611    // stripping a prefix. A linked item (a resource in the settings menu) owns
612    // the context across its sub-pages, so /admin/roles/5/edit still resolves;
613    // the longest match wins.
614    let own = visible
615        .iter()
616        .filter(|i| i.link.is_none())
617        .find(|i| path == format!("{admin_path}{}", i.route()))
618        .map(|i| i.code.clone());
619    if own.is_some() {
620        return (true, own);
621    }
622    let linked = visible
623        .iter()
624        .filter_map(|i| {
625            i.link
626                .as_deref()
627                .map(|link| (format!("{admin_path}{link}"), &i.code))
628        })
629        .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
630        .max_by_key(|(link, _)| link.len())
631        .map(|(_, code)| code.clone());
632    (linked.is_some(), linked)
633}
634
635/// The top-nav item to highlight for `path`. A screen in the settings context
636/// lights the Settings tab (so a linked resource such as the users list keeps
637/// Settings active); otherwise a section owns its own path subtree and stays
638/// active across its sub-pages, with the longest matching prefix winning. The
639/// `/admin` root is every path's ancestor, so it lights the Dashboard tab only
640/// on an exact match: a screen belonging to no section (Preferences, say) lights
641/// nothing rather than falling back to Dashboard.
642fn active_nav_path(
643    nav: &[NavLink],
644    admin_path: &str,
645    in_settings_context: bool,
646    path: &str,
647) -> Option<String> {
648    if in_settings_context {
649        return Some(format!("{admin_path}/settings"));
650    }
651    nav.iter()
652        .filter(|n| {
653            path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
654        })
655        .max_by_key(|n| n.path.len())
656        .map(|n| n.path.clone())
657}
658
659/// Resolves the per-request navigation context from the descriptors: the
660/// settings sidebar (empty when the screen sits outside any settings context)
661/// and the top-nav item to highlight. The auth guard runs this once per request
662/// and hands both to the [`Shell`].
663fn resolve_nav_context(
664    nav: &[NavLink],
665    items: &[settings::SettingsItem],
666    admin_path: &str,
667    perms: &PermissionSet,
668    path: &str,
669    tr: &Translator,
670    icons: icons::Icons<'_>,
671) -> (Vec<settings::CategoryView>, Option<String>) {
672    let visible = visible_settings(items, perms);
673    let (in_context, active) = settings_context(&visible, admin_path, path);
674    let sidebar = if in_context {
675        settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr, icons)
676    } else {
677        Vec::new()
678    };
679    let active_nav = active_nav_path(nav, admin_path, in_context, path);
680    (sidebar, active_nav)
681}
682
683/// Resolves the timezone an operator's timestamps render in: their own
684/// preference when it is set and a valid IANA name, otherwise the deployment
685/// default. An unparseable stored value falls back rather than erroring.
686fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
687    preference
688        .and_then(|name| name.parse::<Tz>().ok())
689        .unwrap_or(default_tz)
690}
691
692/// Whether a timezone is offered in the picker's zone list.
693fn zone_offered(name: &str) -> bool {
694    !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
695}
696
697/// The base language of a tag (`kn-IN` -> `kn`), lowercased. Catalogs are keyed by
698/// base language.
699fn base_lang(tag: &str) -> String {
700    tag.split(['-', '_'])
701        .next()
702        .unwrap_or(tag)
703        .to_ascii_lowercase()
704}
705
706/// Whether resolution can serve `base`: English (the source, always reachable), a
707/// locale with a loaded catalog, or the QA pseudo-locale (selectable via config or
708/// `Accept-Language`, though never shown in the picker). The serveable set is the
709/// deployment's loaded catalogs, so shipping a `de.po` makes `de` resolve with no
710/// code change.
711fn is_serveable(base: &str, serveable: &[String]) -> bool {
712    base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
713}
714
715/// The deployment default locale from config, normalized to a base tag the running
716/// catalogs can serve, else `en`.
717fn default_locale(configured: &str, serveable: &[String]) -> String {
718    let base = base_lang(configured);
719    if is_serveable(&base, serveable) {
720        base
721    } else {
722        "en".to_string()
723    }
724}
725
726/// The locales the language picker offers: English (the source, always) then every
727/// locale with a loaded catalog, sorted. The pseudo-locale is resolvable but never
728/// offered here.
729fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
730    let mut out = vec!["en".to_string()];
731    out.extend(catalogs.locales());
732    out
733}
734
735/// A locale's display name for the picker. The framework names only its own source
736/// language (English); every other locale shows by its tag. A locale's own name
737/// (endonym) is data that belongs with the catalog that provides the locale, not a
738/// table of specific languages the framework would otherwise have to hold for all.
739fn locale_name(code: &str) -> String {
740    if code == "en" {
741        "English".to_string()
742    } else {
743        code.to_string()
744    }
745}
746
747/// The tags in an `Accept-Language` header, most-preferred first. Each entry's
748/// optional `;q=` weight orders them (absent means `1.0`); `*` is dropped.
749fn parse_accept_language(header: &str) -> Vec<String> {
750    let mut items: Vec<(f32, String)> = header
751        .split(',')
752        .filter_map(|part| {
753            let mut bits = part.split(';');
754            let tag = bits.next()?.trim();
755            if tag.is_empty() || tag == "*" {
756                return None;
757            }
758            let q = bits
759                .find_map(|p| p.trim().strip_prefix("q="))
760                .and_then(|q| q.parse::<f32>().ok())
761                .unwrap_or(1.0);
762            Some((q, tag.to_string()))
763        })
764        .collect();
765    // Stable sort by weight descending, so equal weights keep header order.
766    items.sort_by(|a, b| b.0.total_cmp(&a.0));
767    items.into_iter().map(|(_, tag)| tag).collect()
768}
769
770/// Builds this request's locale fallback chain, most specific first and always
771/// ending at `en`. Candidates, in order: the operator's stored locale, the
772/// `Accept-Language` tags, the deployment default. Each is normalized to a base
773/// language the running catalogs can serve; unserveable tags are skipped and none
774/// repeats.
775fn resolve_locale_chain(
776    preference: Option<&str>,
777    accept_language: Option<&str>,
778    default: &str,
779    serveable: &[String],
780) -> Vec<String> {
781    let mut chain: Vec<String> = Vec::new();
782    let consider = |tag: &str, chain: &mut Vec<String>| {
783        let base = base_lang(tag);
784        if is_serveable(&base, serveable) && !chain.contains(&base) {
785            chain.push(base);
786        }
787    };
788    if let Some(pref) = preference {
789        consider(pref, &mut chain);
790    }
791    if let Some(header) = accept_language {
792        for tag in parse_accept_language(header) {
793            consider(&tag, &mut chain);
794        }
795    }
796    consider(default, &mut chain);
797    // Guarantee the source language is reachable, appended only if not already
798    // signaled earlier (so an explicit en preference keeps its position).
799    consider("en", &mut chain);
800    chain
801}
802
803/// A translator for a pre-auth screen (login, setup): no operator preference exists
804/// yet, so the chain is the request's `Accept-Language`, then the deployment default,
805/// then `en`, over the shared catalogs.
806fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
807    let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
808    let serveable = state.catalogs.locales();
809    let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
810    Translator::with_chain(chain, state.catalogs.clone())
811}
812
813/// An admin resource: a list screen, optionally with a create/edit form, mounted
814/// under `base_path` and shown in the menu as `nav_label`.
815///
816/// Built with [`Resource::new`] plus its builder methods. Non-exhaustive because
817/// this is the descriptor a plugin author writes and the one most likely to grow:
818/// a struct literal outside this crate would freeze its field set at 1.0, so the
819/// framework could never learn anything new about a resource.
820#[derive(Serialize)]
821#[non_exhaustive]
822pub struct Resource {
823    /// The path the resource mounts at, relative to the admin root and starting
824    /// with a slash (e.g. `/products`). The framework prepends the configured
825    /// admin mount, so a resource never hardcodes it and survives a moved panel.
826    /// The list's `edit_base` and the form's `base_path` are resolved the same
827    /// way.
828    pub base_path: String,
829    /// The menu label, localized at render. Serde stays a plain string.
830    pub nav_label: Text,
831    pub list: list::ListConfig,
832    pub form: Option<form::FormConfig>,
833    /// The permission an operator must hold to reach any of the resource's
834    /// routes. A dotted string gates every route the resource mounts, and an
835    /// operator who lacks it receives `403 Forbidden`; superusers pass regardless.
836    /// `None` leaves the resource open to any signed-in operator and is allowed
837    /// only for a read-only resource: a resource with a create/edit `form` must
838    /// declare a permission, or boot aborts (see [`mount_resource`]).
839    pub permission: Option<String>,
840    /// Whether records may be imported into this resource, and by whom. The
841    /// pages mount under the resource's path; see [`import`].
842    pub import: Option<ImportConfig>,
843    /// Other entities' records that belong to each of this resource's records,
844    /// each a section on the edit page. See [`relation::RelationConfig`].
845    pub relations: Vec<relation::RelationConfig>,
846}
847
848/// What a resource declares when records may be imported into it: `import:
849/// true` in a descriptor file, or a map with the keys below.
850#[derive(Clone, Debug, Default, Serialize, Deserialize)]
851#[non_exhaustive]
852pub struct ImportConfig {
853    /// The permission an operator needs to import. `None` asks for the
854    /// resource's own.
855    #[serde(default)]
856    pub permission: Option<String>,
857}
858
859impl ImportConfig {
860    pub fn new() -> Self {
861        Self::default()
862    }
863
864    /// The permission an operator needs to import, when it is not the
865    /// resource's own.
866    pub fn permission(mut self, code: impl Into<String>) -> Self {
867        self.permission = Some(code.into());
868        self
869    }
870}
871
872/// A permission an operator can be granted: a dotted `code`, a human `label`,
873/// and a `group` heading it sorts under in the role editor. The framework
874/// registers its own (see the built-in grants), and an application registers
875/// its permissions through [`router`] so they appear in the editor alongside.
876#[derive(Clone, Serialize)]
877#[non_exhaustive]
878pub struct Permission {
879    pub code: String,
880    /// The permission's display label and group heading, localized at render.
881    pub label: Text,
882    pub group: Text,
883    /// The built-in roles that hold this permission. Empty means
884    /// [`ROLE_ADMIN`] alone.
885    pub default_roles: Vec<String>,
886    /// Codes this permission used to have. At boot every role and override
887    /// still holding one is rewritten to the current code.
888    pub renamed_from: Vec<String>,
889}
890
891pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
892
893impl Permission {
894    /// A permission an operator can be granted, held by [`ROLE_ADMIN`] until it
895    /// names other roles with [`Permission::roles`].
896    pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
897        Self {
898            code: code.into(),
899            label: label.into(),
900            group: group.into(),
901            default_roles: Vec::new(),
902            renamed_from: Vec::new(),
903        }
904    }
905
906    /// Codes this permission used to have.
907    ///
908    /// A role or override still holding one is rewritten at the next boot, so a
909    /// rename repairs what operators saved rather than leaving them granting a
910    /// string nothing checks.
911    pub fn renamed_from<I, S>(mut self, codes: I) -> Self
912    where
913        I: IntoIterator<Item = S>,
914        S: Into<String>,
915    {
916        self.renamed_from = codes.into_iter().map(Into::into).collect();
917        self
918    }
919
920    /// The built-in roles that hold this permission by default. Naming
921    /// [`ROLE_EDITOR`] alone keeps it out of [`ROLE_ADMIN`].
922    pub fn roles<I, S>(mut self, roles: I) -> Self
923    where
924        I: IntoIterator<Item = S>,
925        S: Into<String>,
926    {
927        self.default_roles = roles.into_iter().map(Into::into).collect();
928        self
929    }
930}
931
932/// Every rename the registry declares, as `(old, new)` pairs.
933///
934/// Two permissions claiming one old code cannot both be right, and silently
935/// picking one would move an operator's grant to the wrong place, so it aborts
936/// the boot naming both.
937pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
938    let permissions: Vec<Permission> = builtin_permissions()
939        .into_iter()
940        .chain(contributed.iter().cloned())
941        .collect();
942    let mut out: Vec<(String, String)> = Vec::new();
943    for permission in &permissions {
944        for old in &permission.renamed_from {
945            if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
946                panic!(
947                    "permissions `{taken}` and `{}` both claim the old code `{old}`",
948                    permission.code
949                );
950            }
951            if permissions.iter().any(|p| &p.code == old) {
952                panic!(
953                    "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
954                    permission.code
955                );
956            }
957            out.push((old.clone(), permission.code.clone()));
958        }
959    }
960    out
961}
962
963/// The framework's roles, with the permissions the registry says they hold.
964///
965/// A permission that names no role belongs to [`ROLE_ADMIN`] alone: an
966/// undeclared permission reaching only the administrator is the safe reading,
967/// and the role editor shows the author it is not reaching editors.
968pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
969    // The framework's own permissions belong in its own roles, and `router` adds
970    // them after this runs, so take them from the same source it does.
971    let permissions: Vec<Permission> = builtin_permissions()
972        .into_iter()
973        .chain(contributed.iter().cloned())
974        .collect();
975    let held = |role: &str| -> Vec<String> {
976        permissions
977            .iter()
978            .filter(|p| {
979                if p.default_roles.is_empty() {
980                    role == ROLE_ADMIN
981                } else {
982                    p.default_roles.iter().any(|r| r == role)
983                }
984            })
985            .map(|p| p.code.clone())
986            .collect()
987    };
988    vec![
989        laterite_auth::store::SystemRole {
990            code: ROLE_ADMIN,
991            name: "Administrator",
992            description: "Administers the panel itself: operators, roles and settings.",
993            permissions: held(ROLE_ADMIN),
994        },
995        laterite_auth::store::SystemRole {
996            code: ROLE_EDITOR,
997            name: "Editor",
998            description: "Works with the content the application declares.",
999            permissions: held(ROLE_EDITOR),
1000        },
1001    ]
1002}
1003
1004impl Resource {
1005    /// A read-only resource: a list at `base_path`, in the menu as `nav_label`.
1006    ///
1007    /// A create/edit form is added with [`Resource::form`], which also requires
1008    /// [`Resource::permission`]: an unguarded write resource aborts boot.
1009    pub fn new(
1010        base_path: impl Into<String>,
1011        nav_label: impl Into<Text>,
1012        list: list::ListConfig,
1013    ) -> Self {
1014        Self {
1015            base_path: base_path.into(),
1016            nav_label: nav_label.into(),
1017            list,
1018            form: None,
1019            permission: None,
1020            import: None,
1021            relations: Vec::new(),
1022        }
1023    }
1024
1025    /// Adds a section of related records to the edit page. Needs a form.
1026    pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
1027        self.relations.push(relation);
1028        self
1029    }
1030
1031    /// Lets records be imported into this resource, which needs a form.
1032    pub fn import(mut self, import: ImportConfig) -> Self {
1033        self.import = Some(import);
1034        self
1035    }
1036
1037    /// Adds the create/edit form. A resource with one must also declare a
1038    /// permission, or boot aborts.
1039    pub fn form(mut self, form: form::FormConfig) -> Self {
1040        self.form = Some(form);
1041        self
1042    }
1043
1044    /// The permission gating every route this resource mounts.
1045    pub fn permission(mut self, permission: impl Into<String>) -> Self {
1046        self.permission = Some(permission.into());
1047        self
1048    }
1049}
1050
1051/// Refuses deletion of a role the framework owns.
1052///
1053/// The row would return at the next boot, but `backend_user_roles` cascades, so
1054/// every operator's assignment to it would be gone for good. The edit screen
1055/// already refuses; this closes the list's Delete.
1056struct SystemRoleGuard;
1057
1058#[laterite_core::strata::async_trait]
1059impl laterite_core::ModelListener for SystemRoleGuard {
1060    async fn before_delete(
1061        &self,
1062        cx: &mut laterite_core::SaveCx<'_>,
1063        rec: &laterite_core::Record,
1064    ) -> Result<(), Text> {
1065        let Some(id) = rec.id() else {
1066            return Ok(());
1067        };
1068        // Read on the delete's own connection, so it sees the transaction's state.
1069        let backend = cx.backend();
1070        match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1071            Ok(true) => Err(t!(
1072                "The framework maintains this role; it cannot be deleted."
1073            )),
1074            // A read that failed is not a licence to delete.
1075            Err(_) => Err(t!(
1076                "Could not check whether this role is maintained by the framework."
1077            )),
1078            Ok(false) => Ok(()),
1079        }
1080    }
1081}
1082
1083/// The framework's own permissions, offered in the role editor under a
1084/// "Backend" group. These gate the built-in Users and Roles screens.
1085fn builtin_permissions() -> Vec<Permission> {
1086    // Each names no role, so each belongs to the administrator alone: every one
1087    // of them administers the panel rather than its content.
1088    vec![
1089        Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1090        Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1091        Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1092        Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1093        Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1094        Permission::new("backend.view_access_log", "View the access log", "Backend"),
1095    ]
1096}
1097
1098/// Every source string in the framework's built-in admin descriptors: resource nav
1099/// labels and their list titles and column headers, settings items and their fields,
1100/// and permission labels and groups. `lat i18n extract` folds these into the admin
1101/// catalog. Collected through the serde source walk, so it never drifts as new label
1102/// fields are added.
1103pub fn descriptor_sources() -> Vec<String> {
1104    let mut out = Vec::new();
1105    out.extend(laterite_core::collect_sources(&builtin_resources()));
1106    out.extend(laterite_core::collect_sources(&builtin_settings()));
1107    out.extend(laterite_core::collect_sources(&builtin_permissions()));
1108    out.sort();
1109    out.dedup();
1110    out
1111}
1112
1113/// The migration sets for every module the admin mounts: the auth schema
1114/// (users, roles, sessions, access log) and the settings store. Run these
1115/// before serving [`router`] so its built-in screens have their tables, so an
1116/// application never has to know which framework modules the admin pulls in.
1117///
1118/// An application with its own modules appends their sets:
1119///
1120/// ```no_run
1121/// # async fn f(db: laterite_core::Db) -> Result<(), Box<dyn std::error::Error>> {
1122/// let mut migrations = laterite_admin::builtin_migrations();
1123/// // migrations.extend([my_module::migrations()]);
1124/// laterite_core::migration::run(&db.pool, db.backend, &migrations).await?;
1125/// # Ok(()) }
1126/// ```
1127pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1128    builtin_modules().iter().map(|m| m.migrations()).collect()
1129}
1130
1131/// The framework's built-in modules, in registration order. `Bootstrap`
1132/// registers these before an app's own modules, so the admin's tables migrate
1133/// first.
1134pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1135    vec![
1136        Box::new(laterite_auth::AuthModule),
1137        Box::new(settings::SettingsModule),
1138        Box::new(plugins::PluginsModule),
1139        Box::new(laterite_media::MediaModule),
1140        Box::new(import::ImportModule),
1141    ]
1142}
1143
1144/// Builds the admin router. `app_resources` are the application's own list/form
1145/// screens; `app_settings` are its settings models; `app_permissions` are the
1146/// permissions it defines, offered in the role editor alongside the framework's.
1147/// All are mounted alongside the framework's built-in equivalents.
1148/// Everything the modules and the application contribute to the admin, in one
1149/// value.
1150///
1151/// Build it with the fields you mean and leave the rest to `Default`:
1152///
1153/// ```
1154/// # use laterite_admin::Contributions;
1155/// let contributions = Contributions {
1156///     permissions: Vec::new(),
1157///     ..Default::default()
1158/// };
1159/// ```
1160///
1161/// It exists so that a new kind of contribution is a new field rather than a new
1162/// argument. The nine of them were positional before, and a run of interchangeable
1163/// `Vec::new()` placeholders is a mistake the compiler cannot catch: swap two and
1164/// it still builds. Naming them removes that whole class of error, and ending a
1165/// literal with `..Default::default()` means a later field does not break the
1166/// call.
1167#[derive(Default)]
1168pub struct Contributions {
1169    /// List and form screens, mounted beside the framework's own.
1170    pub resources: Vec<Resource>,
1171    /// Settings models, offered in the settings area.
1172    pub settings: Vec<settings::SettingsItem>,
1173    /// Permissions defined by the application, offered in the role editor.
1174    pub permissions: Vec<Permission>,
1175    /// Sources a reference field searches and resolves against.
1176    pub picker_sources: Vec<picker::PickerSourceReg>,
1177    /// Named write handlers a form can select.
1178    pub persisters: Vec<persist::PersisterReg>,
1179    /// Listeners run around every save and delete.
1180    pub listeners: Vec<laterite_core::ModelListenerReg>,
1181    /// Column types a list cell can render through.
1182    /// Field types a module contributes, offered to every form and settings
1183    /// screen beside the built-ins.
1184    pub field_types: Vec<field::FieldTypeReg>,
1185    pub column_types: Vec<list::ColumnTypeReg>,
1186    /// Icons a module contributes, for glyphs the framework's curated set does
1187    /// not carry. Each is namespaced to the contributing module.
1188    pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1189    /// Screens a module mounts itself, for anything that is not a list or form.
1190    pub screens: Vec<routes::ScreenReg>,
1191    /// Routes mounted outside the admin, at literal paths.
1192    pub public_routes: Vec<routes::PublicRouteReg>,
1193    /// Everything the framework itself does not consume: the contribution types
1194    /// modules define for each other. Kept whole so a contributed route can read
1195    /// them through [`routes::RouteCtx::contributions`], which is what makes a
1196    /// module's own extension point usable by another module.
1197    pub plugin_defined: Arc<laterite_core::Registry>,
1198}
1199
1200pub fn router(
1201    auth: AuthService,
1202    db: Db,
1203    contributions: Contributions,
1204    config: AdminConfig,
1205    catalogs: Arc<CatalogStore>,
1206) -> Router {
1207    let Contributions {
1208        resources: app_resources,
1209        settings: app_settings,
1210        permissions: app_permissions,
1211        picker_sources: app_picker_sources,
1212        persisters: app_persisters,
1213        listeners: app_listeners,
1214        field_types: app_field_types,
1215        column_types: app_column_types,
1216        icons: app_icons,
1217        screens: app_screens,
1218        public_routes: app_public,
1219        plugin_defined,
1220    } = contributions;
1221    let admin_path = normalize_path(&config.path);
1222
1223    let mut resources = builtin_resources();
1224    let mut app_resources = app_resources;
1225    // Descriptor paths are authored relative to the admin root; resolve them to
1226    // full paths under the configured mount so routes, the menu, and every link
1227    // built from them agree.
1228    for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1229        prefix_resource(&admin_path, resource);
1230    }
1231    // A resource that may be imported into shows the way to its import pages. A
1232    // write path, so it is gated: the key's permission, else the resource's,
1233    // and a resource with neither aborts boot like an unguarded form.
1234    for resource in app_resources.iter_mut() {
1235        if let Some(import) = &resource.import {
1236            let permission = import
1237                .permission
1238                .clone()
1239                .or_else(|| resource.permission.clone())
1240                .unwrap_or_else(|| {
1241                    panic!(
1242                        "admin resource `{}` declares `import` but no permission; importing \
1243                         writes records, so it must be gated",
1244                        resource.base_path
1245                    )
1246                });
1247            resource.list.import = Some(routes::feature_link(
1248                &resource.base_path,
1249                "import",
1250                &permission,
1251            ));
1252        }
1253    }
1254
1255    let mut settings = builtin_settings();
1256    settings.extend(app_settings);
1257    // The framework's own listeners run before the modules', so a contributed
1258    // one can see what they decided.
1259    let mut app_listeners = app_listeners;
1260    app_listeners.insert(
1261        0,
1262        laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1263    );
1264
1265    let mut permissions = builtin_permissions();
1266    permissions.extend(app_permissions);
1267    // The codes a column may require; `list::check` refuses one nobody registered.
1268    let permission_codes: std::collections::HashSet<String> =
1269        permissions.iter().map(|p| p.code.clone()).collect();
1270
1271    // Main menu (top nav): Dashboard, the application's own sections, then
1272    // Settings. Built-in Users and Roles are settings items (see the settings
1273    // menu), not main-menu tabs.
1274    let mut nav = vec![NavLink {
1275        label: "Dashboard".into(),
1276        path: admin_path.clone(),
1277        icon: Some("layout-dashboard"),
1278    }];
1279    for resource in &app_resources {
1280        nav.push(NavLink {
1281            label: resource.nav_label.clone(),
1282            path: resource.base_path.clone(),
1283            icon: None,
1284        });
1285    }
1286    // A screen that asked for a menu entry sits beside the resources, before
1287    // Settings. One without a label mounts silently, reached by a link elsewhere.
1288    for reg in &app_screens {
1289        if let Some(label) = &reg.nav_label {
1290            nav.push(NavLink {
1291                label: label.clone(),
1292                path: format!("{admin_path}{}", reg.base_path),
1293                icon: None,
1294            });
1295        }
1296    }
1297    nav.push(NavLink {
1298        label: "Settings".into(),
1299        path: format!("{admin_path}/settings"),
1300        icon: Some("settings"),
1301    });
1302    resources.extend(app_resources);
1303
1304    let app_name = if config.app_name.trim().is_empty() {
1305        "Laterite".to_string()
1306    } else {
1307        config.app_name.clone()
1308    };
1309
1310    // The picker-source registry, keyed by dotted name. A bad name or a duplicate
1311    // is a wiring bug, so it aborts boot.
1312    let mut pickers = picker::PickerRegistry::new();
1313    for reg in app_picker_sources {
1314        let name = reg.name.clone();
1315        if !field::is_name(&name, true) {
1316            panic!("invalid picker source name `{name}`");
1317        }
1318        if pickers.insert(name.clone(), reg).is_some() {
1319            panic!("duplicate picker source `{name}`");
1320        }
1321    }
1322
1323    // The reference field validates its `source` against the picker registry at
1324    // boot, so build the field registry here (over the arg-free built-ins) with
1325    // the picker field added and the registry injected.
1326    let pickers = Arc::new(pickers);
1327    let mut field_types = field::builtin_registry();
1328    field_types.insert(
1329        "reference".to_string(),
1330        Arc::new(field::RefPickerField::new(pickers.clone())),
1331    );
1332
1333    // The form-persister registry: a bad or duplicate name is a wiring bug, so it
1334    // aborts boot. It is not on AdminState; `prepare` resolves it per form.
1335    let mut persisters = persist::PersisterRegistry::new();
1336    for reg in app_persisters {
1337        let name = reg.name.clone();
1338        if !field::is_name(&name, true) {
1339            panic!("invalid persister name `{name}`");
1340        }
1341        if persisters.insert(name.clone(), reg.persister).is_some() {
1342            panic!("duplicate persister `{name}`");
1343        }
1344    }
1345
1346    // Module-contributed field types join the registry built above, rather than a
1347    // fresh one: rebuilding here would drop the reference field that was just
1348    // inserted, since that one needs the picker registry and cannot come from the
1349    // arg-free built-ins. A module can offer an input the framework has none of,
1350    // which is what keeps a custom input from meaning a framework change.
1351    for reg in app_field_types {
1352        let name = reg.name().to_string();
1353        if !field::is_name(&name, true) {
1354            panic!("invalid field type name `{name}`");
1355        }
1356        if field_types.insert(name.clone(), reg.field_type).is_some() {
1357            panic!("duplicate field type `{name}`");
1358        }
1359    }
1360
1361    // The icon set: the framework's curated names, plus whatever modules
1362    // contributed. A bad name or unusable SVG aborts the boot rather than
1363    // rendering something wrong later.
1364    let mut icon_set = laterite_core::icons::IconSet::new();
1365    for (owner, reg) in app_icons {
1366        if let Err(e) = icon_set.add(owner.as_str(), &reg) {
1367            panic!("module `{owner}` contributed an invalid icon: {e}");
1368        }
1369    }
1370
1371    // The sprite joins the asset registry, so it gets a content-named URL and
1372    // may be cached forever: a changed set is a changed URL.
1373    let mut assets = builtin_assets();
1374    assets.insert(
1375        ICON_SPRITE,
1376        AdminAsset {
1377            mime: "image/svg+xml",
1378            bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1379        },
1380    );
1381
1382    // Every descriptor icon is checked now, while the names are all in hand and
1383    // nobody is waiting on a response. A typo here used to reach production as a
1384    // wrong picture that looked like somebody's choice.
1385    for item in &settings {
1386        icons::require(
1387            &icon_set,
1388            &format!("settings item `{}`", item.code),
1389            item.icon.as_deref(),
1390        );
1391    }
1392    for link in &nav {
1393        icons::require(&icon_set, &format!("menu entry `{}`", link.path), link.icon);
1394    }
1395
1396    // The column-type registry: the built-ins, plus whatever the modules
1397    // contributed. A bad or duplicate key is a wiring bug, so it aborts boot the
1398    // way a duplicate persister or picker source does.
1399    let mut column_types = list::builtin_column_registry();
1400    for reg in app_column_types {
1401        let name = reg.name().to_string();
1402        if !field::is_name(&name, true) {
1403            panic!("invalid column type name `{name}`");
1404        }
1405        if column_types.insert(name.clone(), reg.column_type).is_some() {
1406            panic!("duplicate column type `{name}`");
1407        }
1408    }
1409
1410    let settings = Arc::new(settings);
1411    // One render of the asset URLs, shared by the state and the sprite lookup.
1412    let asset_urls_arc = Arc::new(asset_urls(&assets));
1413    let sprite_url: Arc<str> = Arc::from(
1414        asset_urls_arc
1415            .get(ICON_SPRITE)
1416            .map(|p| format!("{admin_path}/assets/{p}"))
1417            .unwrap_or_default()
1418            .as_str(),
1419    );
1420    let icons_arc = Arc::new(icon_set);
1421    let state = AdminState {
1422        auth,
1423        db,
1424        nav: Arc::new(nav),
1425        settings: settings.clone(),
1426        permissions: Arc::new(permissions),
1427        admin_path: Arc::from(admin_path.as_str()),
1428        secure_cookie: config.secure_cookie,
1429        trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1430        icons: icons_arc.clone(),
1431        sprite_url: sprite_url.clone(),
1432        origin: Arc::from(config.origin.trim_end_matches('/')),
1433        timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1434        default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1435        catalogs,
1436        app_name,
1437        brand_cache: Arc::new(RwLock::new(None)),
1438        field_types: Arc::new(field_types),
1439        column_types: Arc::new(column_types),
1440        plugin_defined,
1441        assets: Arc::new(assets),
1442        asset_urls: asset_urls_arc.clone(),
1443        pickers,
1444        overrides: Arc::new(field::NoOverrides),
1445    };
1446
1447    let mut protected = Router::new().route(&admin_path, get(dashboard));
1448    // The resources' forms, collected as they mount so a screen can write
1449    // through one (`RouteCtx::form`). Keyed by the path as authored, relative to
1450    // the admin root, which is how a screen names a resource.
1451    let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
1452    for resource in &resources {
1453        let (mounted, prepared) = mount_resource(
1454            resource,
1455            &state.field_types,
1456            &state.column_types,
1457            &permission_codes,
1458            &persisters,
1459            &app_listeners,
1460        );
1461        protected = protected.merge(mounted);
1462        if let Some(prepared) = prepared {
1463            let key = resource
1464                .base_path
1465                .strip_prefix(admin_path.as_str())
1466                .unwrap_or(&resource.base_path);
1467            forms.insert(key.to_string(), prepared);
1468        }
1469    }
1470    let forms: form::Forms = Arc::new(forms);
1471    // The import pages under each resource that declared them. Uploads go to
1472    // the disk the application registered as `private`, else the default under
1473    // the storage directory.
1474    let private_disk: Arc<dyn laterite_media::StorageDriver> = state
1475        .plugin_defined
1476        .items::<laterite_media::DiskReg>()
1477        .into_iter()
1478        .find(|d| d.name == import::DISK)
1479        .map(|d| d.driver.clone())
1480        .unwrap_or_else(|| {
1481            Arc::new(laterite_media::LocalDisk::new(format!(
1482                "{}/{}",
1483                config.storage_dir.trim_end_matches('/'),
1484                import::DISK
1485            )))
1486        });
1487    for resource in &resources {
1488        let Some(link) = &resource.list.import else {
1489            continue;
1490        };
1491        let key = resource
1492            .base_path
1493            .strip_prefix(admin_path.as_str())
1494            .unwrap_or(&resource.base_path)
1495            .to_string();
1496        let prepared = forms.get(&key).unwrap_or_else(|| {
1497            panic!(
1498                "admin resource `{}` declares `import` but has no form to import through",
1499                resource.base_path
1500            )
1501        });
1502        let feature = import::ImportFeature {
1503            disk: private_disk.clone(),
1504        };
1505        let base = link.href.clone();
1506        let ctx = routes::RouteCtx::new(
1507            state.db.clone(),
1508            &base,
1509            &admin_path,
1510            &state.origin,
1511            state.plugin_defined.clone(),
1512            state.auth.events().clone(),
1513            forms.clone(),
1514        );
1515        let info = routes::ResourceInfo::new(
1516            key,
1517            resource.base_path.clone(),
1518            resource.nav_label.clone(),
1519            link.permission.clone(),
1520            form::FormHandle::new(state.db.clone(), prepared.clone()),
1521        );
1522        protected = protected.merge(guard_with_permission(
1523            Router::new()
1524                .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
1525            &link.permission,
1526        ));
1527    }
1528    // A module's own screens: routes it wrote, mounted inside the protected tree
1529    // so they inherit the session, the permission guard, CSRF and the error pages.
1530    // Nested as a service, so the screen's router carries no framework state.
1531    for reg in &app_screens {
1532        let base = format!("{admin_path}{}", reg.base_path);
1533        let ctx = routes::RouteCtx::new(
1534            state.db.clone(),
1535            &base,
1536            &admin_path,
1537            &state.origin,
1538            state.plugin_defined.clone(),
1539            state.auth.events().clone(),
1540            forms.clone(),
1541        );
1542        protected = protected.merge(guard_with_permission(
1543            Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1544            &reg.permission,
1545        ));
1546    }
1547
1548    // The roles screen has a dedicated create/edit form (the permission editor),
1549    // gated by the same permission as its list.
1550    protected = protected.merge(guard_with_permission(
1551        Router::new()
1552            .route(
1553                &format!("{admin_path}/roles/new"),
1554                get(roles::new_form).post(roles::create),
1555            )
1556            .route(
1557                &format!("{admin_path}/roles/{{id}}/edit"),
1558                get(roles::edit_form).post(roles::update),
1559            ),
1560        "backend.manage_roles",
1561    ));
1562    // The backend users screen edits a user's per-permission overrides, gated by
1563    // the same permission as its list.
1564    protected = protected.merge(guard_with_permission(
1565        Router::new()
1566            .route(
1567                &format!("{admin_path}/users/new"),
1568                get(users::new_form).post(users::create),
1569            )
1570            .route(
1571                &format!("{admin_path}/users/{{id}}/edit"),
1572                get(users::edit_form).post(users::update),
1573            )
1574            .route(
1575                &format!("{admin_path}/users/{{id}}/active"),
1576                post(users::set_active),
1577            )
1578            .route(
1579                &format!("{admin_path}/users/{{id}}/password"),
1580                post(users::reset_password),
1581            )
1582            .route(
1583                &format!("{admin_path}/users/{{id}}/unlock"),
1584                post(users::unlock),
1585            ),
1586        "backend.manage_users",
1587    ));
1588    // The plugins screen lists the installed plugins and toggles each on or off
1589    // (an intent applied on the next boot), gated by its own permission.
1590    protected = protected.merge(guard_with_permission(
1591        Router::new()
1592            .route(&format!("{admin_path}/plugins"), get(plugins::index))
1593            .route(
1594                &format!("{admin_path}/plugins/toggle"),
1595                post(plugins::toggle),
1596            ),
1597        plugins::MANAGE_PERMISSION,
1598    ));
1599    protected = protected
1600        .route(&format!("{admin_path}/settings"), get(settings_index))
1601        .route(
1602            &format!("{admin_path}/preferences"),
1603            get(preferences_form).post(preferences_update),
1604        )
1605        .route(
1606            &format!("{admin_path}/preferences/password"),
1607            post(password_update),
1608        )
1609        .route(
1610            &format!("{admin_path}/preferences/sessions/revoke"),
1611            post(session_revoke),
1612        )
1613        .route(
1614            &format!("{admin_path}/preferences/sessions/revoke-others"),
1615            post(session_revoke_others),
1616        )
1617        // Picker endpoints, served with the QUERY method (a read; the guard in the
1618        // handler answers 405 for any other method).
1619        .route(
1620            &format!("{admin_path}/pickers/{{source}}/search"),
1621            any(picker::search),
1622        )
1623        .route(
1624            &format!("{admin_path}/pickers/{{source}}/resolve"),
1625            any(picker::resolve),
1626        )
1627        .route(&format!("{admin_path}/logout"), post(logout));
1628
1629    // A route per settings screen, at the path its module resolved to, rather
1630    // than one parameterised route keyed by storage code. The code is captured
1631    // here, so it never has to survive a trip through the URL.
1632    for item in settings.iter().filter(|i| i.link.is_none()) {
1633        let path = format!("{admin_path}{}", item.route());
1634        let code = item.code.clone();
1635        let edit_code = code.clone();
1636        protected = protected.route(
1637            &path,
1638            get(
1639                move |State(state): State<AdminState>,
1640                      Extension(shell): Extension<Shell>,
1641                      Extension(user): Extension<AuthenticatedUser>| {
1642                    let code = edit_code.clone();
1643                    async move { settings_edit(state, shell, user, code).await }
1644                },
1645            )
1646            .post(
1647                move |State(state): State<AdminState>,
1648                      Extension(shell): Extension<Shell>,
1649                      Extension(user): Extension<AuthenticatedUser>,
1650                      Extension(session): Extension<session::SessionHandle>,
1651                      Form(data): Form<HashMap<String, String>>| {
1652                    let code = code.clone();
1653                    async move { settings_update(state, shell, user, session, code, data).await }
1654                },
1655            ),
1656        );
1657    }
1658
1659    protected
1660        .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1661        // Public routes (not covered by the guard above): the login and first-run
1662        // setup screens and the embedded stylesheet and fonts (needed before
1663        // authentication).
1664        .route(
1665            &format!("{admin_path}/login"),
1666            get(login_form).post(login_submit),
1667        )
1668        .route(
1669            &format!("{admin_path}/setup"),
1670            get(setup_form).post(setup_submit),
1671        )
1672        .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1673        // A module's own public endpoints, at the literal paths they declared and
1674        // outside the admin entirely: no session, no permission, no chrome. They
1675        // sit above the fallback, so an unmatched URL still renders the 404.
1676        .merge(mount_public(
1677            &app_public,
1678            &state.db,
1679            &admin_path,
1680            &state.origin,
1681            &state.plugin_defined,
1682            state.auth.events(),
1683            &forms,
1684        ))
1685        // Unmatched URLs render the styled 404; a handler panic renders the 500.
1686        .fallback(not_found_fallback)
1687        // The CSRF origin gate wraps every route (login and setup included); the
1688        // panic layer is outermost so it catches everything.
1689        .layer(middleware::from_fn_with_state(
1690            state.clone(),
1691            enforce_origin,
1692        ))
1693        // Outside the origin gate, so a rejected request is still logged with
1694        // the address it came from.
1695        .layer(middleware::from_fn_with_state(
1696            state.clone(),
1697            capture_client,
1698        ))
1699        // Admin responses are per-operator and carry a request token, so no
1700        // shared cache may hold one and no browser may leave one on disk for
1701        // the next person at the machine. `if_not_present` leaves the asset
1702        // routes alone: those set their own policy from their URL.
1703        .layer(
1704            tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1705                header::CACHE_CONTROL,
1706                axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1707            ),
1708        )
1709        // An htmx fragment and the full page share a URL and differ only by the
1710        // request header, so any cache keying on URL alone would serve one for
1711        // the other.
1712        .layer(
1713            tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1714                header::VARY,
1715                axum::http::HeaderValue::from_static("HX-Request"),
1716            ),
1717        )
1718        .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1719            handle_panic,
1720        ))
1721        .with_state(state)
1722}
1723
1724/// Fallback for unmatched admin URLs: a styled 404.
1725async fn not_found_fallback() -> AdminError {
1726    AdminError::NotFound
1727}
1728
1729/// Turns a handler panic into a logged, styled 500.
1730fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1731    let cause = err
1732        .downcast_ref::<&str>()
1733        .map(|s| s.to_string())
1734        .or_else(|| err.downcast_ref::<String>().cloned())
1735        .unwrap_or_else(|| "unknown panic".to_string());
1736    tracing::error!(panic = cause, "admin handler panicked");
1737    error::masked_500()
1738}
1739
1740/// Normalises a configured admin path: a single leading slash, no trailing
1741/// slash, falling back to `/admin` when empty. `manage`, `/manage`, and
1742/// `/manage/` all become `/manage`. Shared by [`router`] and any caller building
1743/// the panel's URL (for example a startup banner), so the two never drift.
1744pub fn normalize_path(path: &str) -> String {
1745    let trimmed = path.trim().trim_matches('/');
1746    if trimmed.is_empty() {
1747        "/admin".to_string()
1748    } else {
1749        format!("/{trimmed}")
1750    }
1751}
1752
1753/// Resolves a resource's authored-relative paths (`base_path`, the list's
1754/// `edit_base`, the form's `base_path`) to full paths under the admin mount.
1755/// Every contributed public route, nested at its declared path.
1756fn mount_public(
1757    regs: &[routes::PublicRouteReg],
1758    db: &Db,
1759    admin_path: &str,
1760    origin: &str,
1761    plugin_defined: &Arc<laterite_core::Registry>,
1762    events: &laterite_core::Events,
1763    forms: &form::Forms,
1764) -> Router<AdminState> {
1765    let mut router = Router::new();
1766    for reg in regs {
1767        let ctx = routes::RouteCtx::new(
1768            db.clone(),
1769            &reg.path,
1770            admin_path,
1771            origin,
1772            plugin_defined.clone(),
1773            events.clone(),
1774            forms.clone(),
1775        );
1776        // A nested service does not inherit the outer fallback, so without this a
1777        // request *below* a public route (`/robots.txt/anything`) escapes into
1778        // axum's bodyless 404 instead of the deployment's styled error page.
1779        // Pinned by a test, because it is a behaviour rather than a guarantee.
1780        router = router.nest_service(
1781            &reg.path,
1782            reg.route.mount(&ctx).fallback(not_found_fallback),
1783        );
1784    }
1785    router
1786}
1787
1788/// Moves a resource to `base`, taking its list and form links with it. Used when
1789/// a module's screens resolve to a namespaced or overridden path.
1790pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1791    let old = resource.base_path.clone();
1792    resource.base_path = base.to_string();
1793    if let Some(edit_base) = &mut resource.list.edit_base {
1794        if edit_base == &old {
1795            *edit_base = base.to_string();
1796        }
1797    }
1798    if let Some(form) = &mut resource.form {
1799        if form.base_path == old {
1800            form.base_path = base.to_string();
1801        }
1802    }
1803}
1804
1805fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1806    resource.base_path = format!("{admin_path}{}", resource.base_path);
1807    if let Some(edit_base) = &mut resource.list.edit_base {
1808        *edit_base = format!("{admin_path}{edit_base}");
1809    }
1810    if let Some(form) = &mut resource.form {
1811        form.base_path = format!("{admin_path}{}", form.base_path);
1812    }
1813}
1814
1815/// One embedded admin asset served by [`serve_asset`].
1816pub(crate) struct AdminAsset {
1817    pub mime: &'static str,
1818    /// Borrowed for assets compiled in, owned for the icon sprite, which is
1819    /// assembled at boot from the framework's set plus whatever modules
1820    /// contributed and so cannot come from a file.
1821    pub bytes: std::borrow::Cow<'static, [u8]>,
1822}
1823
1824/// Admin assets served under `{admin}/assets/`, keyed by path. An open registry:
1825/// field types and plugins contribute their own (wired when the first one does).
1826pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
1827
1828/// Registry key to the path it is served at, each carrying a digest of its
1829/// bytes. Built once at boot, since the bytes are compiled in and cannot change
1830/// while the process runs.
1831pub(crate) type AssetUrls = HashMap<&'static str, String>;
1832
1833/// Names every registered asset by its content.
1834pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
1835    registry
1836        .iter()
1837        .map(|(&key, asset)| {
1838            (
1839                key,
1840                http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
1841            )
1842        })
1843        .collect()
1844}
1845
1846/// Resolves widget asset keys to per-page assets for the shell: an order-
1847/// preserving dedup, a single URL builder (`{base}/assets/{key}`), and
1848/// stylesheet-vs-script by the registry's mime. A key absent from the registry is
1849/// skipped and trips a debug assertion, since a declared asset must be registered.
1850/// The URL is stamped as `data-lat-asset` in the head so a later htmx fragment's
1851/// `lat.assets.ensure` of the same key is a no-op.
1852pub(crate) fn page_assets(
1853    keys: &[&str],
1854    base: &str,
1855    registry: &AssetRegistry,
1856    urls: &AssetUrls,
1857) -> Vec<PageAsset> {
1858    let mut seen = std::collections::HashSet::new();
1859    let mut out = Vec::new();
1860    for &key in keys {
1861        if !seen.insert(key) {
1862            continue;
1863        }
1864        match (registry.get(key), urls.get(key)) {
1865            (Some(asset), Some(path)) => out.push(PageAsset {
1866                url: format!("{base}/assets/{path}"),
1867                css: asset.mime.contains("css"),
1868            }),
1869            _ => debug_assert!(false, "asset key `{key}` is not registered"),
1870        }
1871    }
1872    out
1873}
1874
1875/// The framework's built-in assets: the stylesheet, brand marks, and webfonts.
1876///
1877/// None of them declares a cache policy. Every one is referenced through
1878/// [`asset_urls`], which names each by a digest of its bytes, and
1879/// [`serve_asset`] reads the policy off the URL: a request that named the
1880/// content may keep it forever, one that did not must revalidate. So an asset
1881/// changed by a framework upgrade reaches a browser that had cached the old
1882/// one, without anybody remembering to say so.
1883pub(crate) fn builtin_assets() -> AssetRegistry {
1884    let font = |bytes: &'static [u8]| AdminAsset {
1885        mime: "font/woff2",
1886        bytes: std::borrow::Cow::Borrowed(bytes),
1887    };
1888    HashMap::from([
1889        (
1890            "laterite.css",
1891            AdminAsset {
1892                mime: "text/css; charset=utf-8",
1893                bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
1894            },
1895        ),
1896        (
1897            "laterite.js",
1898            AdminAsset {
1899                mime: "text/javascript; charset=utf-8",
1900                bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
1901            },
1902        ),
1903        (
1904            "vendor/htmx.min.js",
1905            AdminAsset {
1906                mime: "text/javascript; charset=utf-8",
1907                bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
1908            },
1909        ),
1910        (
1911            "fields/ref-picker.js",
1912            AdminAsset {
1913                mime: "text/javascript; charset=utf-8",
1914                bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
1915            },
1916        ),
1917        (
1918            "fields/ref-picker.css",
1919            AdminAsset {
1920                mime: "text/css; charset=utf-8",
1921                bytes: std::borrow::Cow::Borrowed(include_bytes!(
1922                    "../assets/fields/ref-picker.css"
1923                )),
1924            },
1925        ),
1926        (
1927            "mark.svg",
1928            AdminAsset {
1929                mime: "image/svg+xml",
1930                bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
1931            },
1932        ),
1933        (
1934            "mark.png",
1935            AdminAsset {
1936                mime: "image/png",
1937                bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
1938            },
1939        ),
1940        (
1941            "fonts/space-grotesk-500.woff2",
1942            font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1943        ),
1944        (
1945            "fonts/space-grotesk-600.woff2",
1946            font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1947        ),
1948        (
1949            "fonts/space-grotesk-700.woff2",
1950            font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1951        ),
1952        (
1953            "fonts/ibm-plex-sans-400.woff2",
1954            font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1955        ),
1956        (
1957            "fonts/ibm-plex-sans-600.woff2",
1958            font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1959        ),
1960        (
1961            "fonts/ibm-plex-mono-400.woff2",
1962            font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1963        ),
1964        (
1965            "fonts/ibm-plex-mono-600.woff2",
1966            font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1967        ),
1968    ])
1969}
1970
1971/// Serves an embedded admin asset by path (public; no auth).
1972async fn serve_asset(
1973    State(state): State<AdminState>,
1974    headers: HeaderMap,
1975    Path(path): Path<String>,
1976) -> Response {
1977    // A URL that named the content is answerable forever, because different
1978    // bytes would have been a different URL. One that did not gets a validator
1979    // instead, so the next request is a cheap 304 rather than a fresh download.
1980    let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
1981    let Some(asset) = state.assets.get(key.as_str()) else {
1982        return not_found();
1983    };
1984    let policy = if fingerprinted {
1985        http_cache::IMMUTABLE
1986    } else {
1987        http_cache::REVALIDATE
1988    };
1989    http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
1990}
1991
1992/// Builds a resource's list, create, and edit routes as generic handlers that
1993/// carry the resource's descriptors. When the resource sets a `permission`, every
1994/// route it mounts is wrapped in a guard that answers `403 Forbidden` for an
1995/// operator who lacks it; the caller merges the result into the protected router.
1996fn mount_resource(
1997    resource: &Resource,
1998    field_types: &field::FieldRegistry,
1999    column_types: &list::ColumnRegistry,
2000    permission_codes: &std::collections::HashSet<String>,
2001    persisters: &persist::PersisterRegistry,
2002    listeners: &[laterite_core::ModelListenerReg],
2003) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
2004    // A column naming a type nobody registered used to render an empty cell;
2005    // it aborts boot instead, like a form field with an unregistered type. So
2006    // does a column requiring a permission nobody registered.
2007    list::check(&resource.list, column_types, permission_codes)
2008        .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2009    // A write resource must be permission-gated. A create/edit form with no
2010    // permission would expose its mutations to every signed-in operator, so an
2011    // unguarded form is a wiring bug that aborts boot (a read-only list may omit a
2012    // permission). If a public write resource is ever wanted, it opts in explicitly.
2013    if resource.form.is_some() && resource.permission.is_none() {
2014        panic!(
2015            "admin resource `{}` has a create/edit form but no permission; a write \
2016             resource must declare a permission",
2017            resource.base_path
2018        );
2019    }
2020    let base = resource.base_path.clone();
2021    let list_path = base.clone();
2022    let mut list_cfg = resource.list.clone();
2023    // Editable cells write through the resource's form, prepared here for
2024    // them: a column that edits a field the form lacks aborts boot.
2025    if resource.list.columns.iter().any(|c| c.editable) {
2026        let Some(form_cfg) = resource.form.clone() else {
2027            panic!(
2028                "admin resource `{}` has an editable column but no form to edit through",
2029                resource.base_path
2030            );
2031        };
2032        for column in resource.list.columns.iter().filter(|c| c.editable) {
2033            if !form_cfg.fields.iter().any(|f| f.name == column.field) {
2034                panic!(
2035                    "admin resource `{}`: column `{}` is editable but the form has no such field",
2036                    resource.base_path, column.field
2037                );
2038            }
2039        }
2040        let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2041            .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2042        list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
2043    }
2044    let cell_cfg = list_cfg.clone();
2045    let mut router = Router::new().route(
2046        &base,
2047        get(
2048            move |State(state): State<AdminState>,
2049                  Extension(shell): Extension<Shell>,
2050                  Query(params): Query<list::ListParams>,
2051                  Query(raw): Query<std::collections::HashMap<String, String>>,
2052                  axum::extract::RawQuery(query_string): axum::extract::RawQuery,
2053                  Extension(user): Extension<AuthenticatedUser>,
2054                  Extension(session): Extension<session::SessionHandle>,
2055                  headers: axum::http::HeaderMap| {
2056                let cfg = list_cfg.clone();
2057                let path = list_path.clone();
2058                async move {
2059                    list::handle_remembering(
2060                        &state,
2061                        &cfg,
2062                        &path,
2063                        params,
2064                        &raw,
2065                        &user,
2066                        shell,
2067                        &headers,
2068                        query_string.as_deref(),
2069                        Some(&session),
2070                    )
2071                    .await
2072                }
2073            },
2074        ),
2075    );
2076
2077    if cell_cfg.editor.is_some() {
2078        let (cfg, list_path) = (cell_cfg, base.clone());
2079        router = router.route(
2080            &format!("{base}/{{id}}/cell"),
2081            post(
2082                move |State(state): State<AdminState>,
2083                      Extension(shell): Extension<Shell>,
2084                      Extension(user): Extension<AuthenticatedUser>,
2085                      Extension(session): Extension<session::SessionHandle>,
2086                      Path(id): Path<String>,
2087                      headers: axum::http::HeaderMap,
2088                      Form(data): Form<HashMap<String, String>>| {
2089                    let (cfg, path) = (cfg.clone(), list_path.clone());
2090                    async move {
2091                        list::cell_handler(
2092                            &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
2093                        )
2094                        .await
2095                    }
2096                },
2097            ),
2098        );
2099    }
2100
2101    // Not registered at all when the resource has not opted in, so the capability
2102    // cannot be reached by guessing the URL.
2103    let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
2104    if resource.list.exportable {
2105        router = router.route(
2106            &format!("{base}/export"),
2107            get(
2108                move |state: State<AdminState>,
2109                      Query(raw): Query<std::collections::HashMap<String, String>>,
2110                      Extension(user): Extension<AuthenticatedUser>,
2111                      Extension(shell): Extension<Shell>,
2112                      Extension(session): Extension<session::SessionHandle>| {
2113                    let cfg = export_cfg.clone();
2114                    let path = export_path.clone();
2115                    async move {
2116                        export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
2117                    }
2118                },
2119            ),
2120        );
2121    }
2122
2123    let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
2124    router = router.route(
2125        &format!("{base}/columns"),
2126        post(
2127            move |State(state): State<AdminState>,
2128                  Extension(user): Extension<AuthenticatedUser>,
2129                  Extension(session): Extension<session::SessionHandle>,
2130                  headers: axum::http::HeaderMap,
2131                  Form(pairs): Form<Vec<(String, String)>>| {
2132                let cfg = columns_cfg.clone();
2133                let path = columns_path.clone();
2134                async move {
2135                    list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
2136                }
2137            },
2138        ),
2139    );
2140
2141    if resource.list.deletable {
2142        // The write path for a delete: the form's persister when the resource has
2143        // a generic form, otherwise one over the list's own table, so a bespoke
2144        // editor (roles) still deletes through the pipeline rather than beside it.
2145        let persister: Arc<dyn persist::Persister> =
2146            match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
2147                Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
2148                    panic!(
2149                        "admin resource `{}`: unregistered persister `{key}`",
2150                        resource.base_path
2151                    )
2152                }),
2153                None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
2154            };
2155        let ctx = Arc::new(bulk::BulkContext {
2156            base_path: resource.base_path.clone(),
2157            entity: resource.list.entity.clone(),
2158            persister,
2159            listeners: listeners
2160                .iter()
2161                .filter(|reg| reg.matches(&resource.list.entity))
2162                .map(|reg| reg.listener.clone())
2163                .collect(),
2164        });
2165        router =
2166            router.route(
2167                &format!("{base}/delete"),
2168                post(
2169                    move |state: State<AdminState>,
2170                          user: Extension<AuthenticatedUser>,
2171                          session: Extension<session::SessionHandle>,
2172                          headers: axum::http::HeaderMap,
2173                          form: Form<Vec<(String, String)>>| {
2174                        let ctx = ctx.clone();
2175                        async move {
2176                            bulk::delete_handler(state, user, session, ctx, headers, form).await
2177                        }
2178                    },
2179                ),
2180            );
2181    }
2182
2183    // A tree with a picker source offers "Move to": the one-field form over
2184    // the parent column, writing through the resource's own persister.
2185    if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
2186        let tree = resource.list.tree.clone().unwrap_or_default();
2187        let mut move_cfg = form::FormConfig::new(
2188            resource.list.entity.clone(),
2189            laterite_core::t!("Move to"),
2190            base.clone(),
2191            resource.list.id_field.clone(),
2192            vec![form::FormField::reference(
2193                &tree.parent_key,
2194                laterite_core::t!("New parent"),
2195                &source,
2196            )
2197            .help(laterite_core::t!("Leave empty for the top level."))],
2198        );
2199        if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
2200            move_cfg = move_cfg.persist(persist);
2201        }
2202        move_cfg.done_path = Some(base.clone());
2203        let move_form = Arc::new(
2204            form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
2205                .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
2206        );
2207        let (cfg, list_path) = (resource.list.clone(), base.clone());
2208        let (form_get, form_post) = (move_form.clone(), move_form);
2209        let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
2210        router = router.route(
2211            &format!("{base}/{{id}}/move"),
2212            get(
2213                move |State(state): State<AdminState>,
2214                      Extension(shell): Extension<Shell>,
2215                      Path(id): Path<String>| {
2216                    let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
2217                    async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
2218                },
2219            )
2220            .post(
2221                move |State(state): State<AdminState>,
2222                      Extension(shell): Extension<Shell>,
2223                      Extension(user): Extension<AuthenticatedUser>,
2224                      Extension(session): Extension<session::SessionHandle>,
2225                      Path(id): Path<String>,
2226                      headers: axum::http::HeaderMap,
2227                      Form(data): Form<HashMap<String, String>>| {
2228                    let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
2229                    async move {
2230                        list::move_record(
2231                            &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
2232                        )
2233                        .await
2234                    }
2235                },
2236            ),
2237        );
2238    }
2239
2240    if resource.list.reorder.is_some() {
2241        let (cfg, list_path) = (resource.list.clone(), base.clone());
2242        router = router.route(
2243            &format!("{base}/reorder"),
2244            post(
2245                move |State(state): State<AdminState>,
2246                      Extension(shell): Extension<Shell>,
2247                      Extension(user): Extension<AuthenticatedUser>,
2248                      Extension(session): Extension<session::SessionHandle>,
2249                      headers: axum::http::HeaderMap,
2250                      Form(pairs): Form<Vec<(String, String)>>| {
2251                    let cfg = cfg.clone();
2252                    let path = list_path.clone();
2253                    async move {
2254                        list::reorder_handler(
2255                            &state, &cfg, &path, "lat-list", None, &user, &session, shell,
2256                            &headers, &pairs,
2257                        )
2258                        .await
2259                    }
2260                },
2261            ),
2262        );
2263    }
2264
2265    // Relations, each resolved once here: its child form prepared, its routes
2266    // under `{base}/{id}/{name}`, its section on the parent's edit page.
2267    if !resource.relations.is_empty() && resource.form.is_none() {
2268        panic!(
2269            "admin resource `{}` declares relations but has no form to show them on",
2270            resource.base_path
2271        );
2272    }
2273    let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
2274    for rel in &resource.relations {
2275        let prepared = relation::PreparedRelation::prepare(
2276            resource,
2277            rel.clone(),
2278            field_types,
2279            column_types,
2280            permission_codes,
2281            persisters,
2282            listeners,
2283        )
2284        .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2285        relations.push(Arc::new(prepared));
2286    }
2287    let relations = Arc::new(relations);
2288
2289    let mut prepared_form = None;
2290    if let Some(form_cfg) = resource.form.clone() {
2291        // Resolve the form's field options once, here at router build. A malformed
2292        // option or unregistered type aborts boot naming the resource.
2293        let mut prepared =
2294            form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2295                .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2296        // Sections live on the edit page, so a form with them offers to stay there.
2297        prepared.stay = !relations.is_empty();
2298        let prepared = Arc::new(prepared);
2299        prepared_form = Some(prepared.clone());
2300        let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
2301        let relations_for_new = relations.clone();
2302        router = router.route(
2303            &format!("{base}/new"),
2304            get(
2305                move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
2306                    let pf = new_pf.clone();
2307                    let relations = relations_for_new.clone();
2308                    async move {
2309                        let pending = relations
2310                            .iter()
2311                            .map(|r| shell.tt(&r.config.title))
2312                            .collect();
2313                        form::new_form(&state, &pf, shell, pending)
2314                    }
2315                },
2316            )
2317            .post(
2318                move |State(state): State<AdminState>,
2319                      Extension(shell): Extension<Shell>,
2320                      Extension(user): Extension<AuthenticatedUser>,
2321                      Extension(session): Extension<session::SessionHandle>,
2322                      headers: axum::http::HeaderMap,
2323                      Form(data): Form<HashMap<String, String>>| {
2324                    let pf = create_pf.clone();
2325                    async move {
2326                        form::create(&state, &pf, data, shell, &user, &session, &headers).await
2327                    }
2328                },
2329            ),
2330        );
2331
2332        let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
2333        let relations_for_edit = relations.clone();
2334        router = router.route(
2335            &format!("{base}/{{id}}/edit"),
2336            get(
2337                move |State(state): State<AdminState>,
2338                      Extension(shell): Extension<Shell>,
2339                      Extension(user): Extension<AuthenticatedUser>,
2340                      Path(id): Path<String>| {
2341                    let pf = edit_pf.clone();
2342                    let relations = relations_for_edit.clone();
2343                    async move {
2344                        let mut sections = Vec::new();
2345                        for rel in relations.iter() {
2346                            if let Some(section) = rel.section(&state, &id, &user, &shell).await {
2347                                sections.push(section);
2348                            }
2349                        }
2350                        form::edit_form(&state, &pf, id, shell, sections).await
2351                    }
2352                },
2353            )
2354            .post(
2355                move |State(state): State<AdminState>,
2356                      Extension(shell): Extension<Shell>,
2357                      Extension(user): Extension<AuthenticatedUser>,
2358                      Extension(session): Extension<session::SessionHandle>,
2359                      Path(id): Path<String>,
2360                      headers: axum::http::HeaderMap,
2361                      Form(data): Form<HashMap<String, String>>| {
2362                    let pf = update_pf.clone();
2363                    async move {
2364                        form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
2365                    }
2366                },
2367            ),
2368        );
2369    }
2370
2371    for rel in relations.iter() {
2372        let name = rel.config.name.clone();
2373        let mut routes: Router<AdminState> = Router::new();
2374        let r = rel.clone();
2375        routes =
2376            routes.route(
2377                &format!("{base}/{{id}}/{name}"),
2378                get(
2379                    move |state: State<AdminState>,
2380                          shell: Extension<Shell>,
2381                          user: Extension<AuthenticatedUser>,
2382                          id: Path<String>,
2383                          params: Query<list::ListParams>,
2384                          raw: Query<HashMap<String, String>>,
2385                          headers: axum::http::HeaderMap| {
2386                        let r = r.clone();
2387                        async move {
2388                            relation::index(state, shell, user, id, params, raw, headers, r).await
2389                        }
2390                    },
2391                ),
2392            );
2393        let (r_new, r_create) = (rel.clone(), rel.clone());
2394        routes = routes.route(
2395            &format!("{base}/{{id}}/{name}/new"),
2396            get(
2397                move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
2398                    let r = r_new.clone();
2399                    async move { relation::new_form(state, shell, id, r).await }
2400                },
2401            )
2402            .post(
2403                move |state: State<AdminState>,
2404                      shell: Extension<Shell>,
2405                      user: Extension<AuthenticatedUser>,
2406                      session: Extension<session::SessionHandle>,
2407                      id: Path<String>,
2408                      headers: axum::http::HeaderMap,
2409                      data: Form<HashMap<String, String>>| {
2410                    let r = r_create.clone();
2411                    async move {
2412                        relation::create(state, shell, user, session, id, headers, data, r).await
2413                    }
2414                },
2415            ),
2416        );
2417        let (r_edit, r_update) = (rel.clone(), rel.clone());
2418        routes = routes.route(
2419            &format!("{base}/{{id}}/{name}/{{child}}/edit"),
2420            get(
2421                move |state: State<AdminState>,
2422                      shell: Extension<Shell>,
2423                      ids: Path<(String, String)>| {
2424                    let r = r_edit.clone();
2425                    async move { relation::edit_form(state, shell, ids, r).await }
2426                },
2427            )
2428            .post(
2429                move |state: State<AdminState>,
2430                      shell: Extension<Shell>,
2431                      user: Extension<AuthenticatedUser>,
2432                      session: Extension<session::SessionHandle>,
2433                      ids: Path<(String, String)>,
2434                      headers: axum::http::HeaderMap,
2435                      data: Form<HashMap<String, String>>| {
2436                    let r = r_update.clone();
2437                    async move {
2438                        relation::update(state, shell, user, session, ids, headers, data, r).await
2439                    }
2440                },
2441            ),
2442        );
2443        let r_delete = rel.clone();
2444        routes = routes.route(
2445            &format!("{base}/{{id}}/{name}/delete"),
2446            post(
2447                move |state: State<AdminState>,
2448                      shell: Extension<Shell>,
2449                      user: Extension<AuthenticatedUser>,
2450                      session: Extension<session::SessionHandle>,
2451                      id: Path<String>,
2452                      headers: axum::http::HeaderMap,
2453                      pairs: Form<Vec<(String, String)>>| {
2454                    let r = r_delete.clone();
2455                    async move {
2456                        relation::delete(state, shell, user, session, id, headers, pairs, r).await
2457                    }
2458                },
2459            ),
2460        );
2461        if rel.reorderable() {
2462            let r_reorder = rel.clone();
2463            routes = routes.route(
2464                &format!("{base}/{{id}}/{name}/reorder"),
2465                post(
2466                    move |state: State<AdminState>,
2467                          shell: Extension<Shell>,
2468                          user: Extension<AuthenticatedUser>,
2469                          session: Extension<session::SessionHandle>,
2470                          id: Path<String>,
2471                          headers: axum::http::HeaderMap,
2472                          pairs: Form<Vec<(String, String)>>| {
2473                        let r = r_reorder.clone();
2474                        async move {
2475                            relation::reorder(state, shell, user, session, id, headers, pairs, r)
2476                                .await
2477                        }
2478                    },
2479                ),
2480            );
2481        }
2482        if let Some(permission) = &rel.config.permission {
2483            routes = guard_with_permission(routes, permission);
2484        }
2485        router = router.merge(routes);
2486    }
2487
2488    // Gate every route the resource mounts on its permission.
2489    if let Some(permission) = &resource.permission {
2490        router = guard_with_permission(router, permission);
2491    }
2492    (router, prepared_form)
2493}
2494
2495/// Wraps every route currently in `router` in a permission guard: the auth guard
2496/// runs first and injects the identity, so this reads it from the request
2497/// extensions and answers `403 Forbidden` for an operator who lacks `permission`.
2498/// Shared by resource mounting and the roles permission editor.
2499fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
2500    let needed: Arc<str> = Arc::from(permission);
2501    router.route_layer(middleware::from_fn(
2502        move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
2503            let needed = needed.clone();
2504            async move {
2505                if user.allows(&needed) {
2506                    next.run(req).await
2507                } else {
2508                    forbidden()
2509                }
2510            }
2511        },
2512    ))
2513}
2514
2515/// The gate for authenticated admin routes. Resolves the session (identity plus
2516/// its opaque blob), verifies CSRF on state-changing requests, injects the
2517/// identity, shell, and a [`session::SessionHandle`] for handlers, and persists
2518/// the session blob afterwards when a handler changed it. Unauthenticated
2519/// requests redirect to the login screen.
2520async fn require_auth(
2521    State(state): State<AdminState>,
2522    jar: CookieJar,
2523    request: Request,
2524    next: Next,
2525) -> Response {
2526    let login = format!("{}/login", state.admin_path);
2527    let client_ctx = request
2528        .extensions()
2529        .get::<RequestContext>()
2530        .cloned()
2531        .unwrap_or_default();
2532    // A live session is the ordinary path. A missing or expired one falls back
2533    // to the stay-signed-in credential, which mints a fresh session and rotates
2534    // itself, so the cookie just presented is never accepted a second time.
2535    // A presented session that fails is asked why, so the login screen can say.
2536    let mut ended: Option<laterite_auth::SessionEnd> = None;
2537    let live = match jar.get(SESSION_COOKIE) {
2538        Some(cookie) => {
2539            let token = cookie.value().to_string();
2540            match state.auth.resolve_session(&token).await {
2541                Ok(resolved) => Some((token, resolved)),
2542                Err(_) => {
2543                    ended = state.auth.session_end(&token).await.ok().flatten();
2544                    None
2545                }
2546            }
2547        }
2548        None => None,
2549    };
2550    let htmx = request.headers().contains_key("hx-request");
2551    let (token, resolved, recalled) = match live {
2552        Some((token, resolved)) => (token, resolved, None),
2553        // Ended on purpose: the stay-signed-in credential was dropped with it,
2554        // and recalling past a deliberate sign-out would undo it anyway.
2555        None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
2556            return signed_out(&state, jar, &login, ended, htmx);
2557        }
2558        None => match recall(&state, &jar, &client_ctx).await {
2559            Some((token, resolved, credential)) => (token, resolved, Some(credential)),
2560            None => {
2561                // A device returning on its stay-signed-in cookie alone has no
2562                // session to say why; the credential itself may, and an ending
2563                // on purpose outranks a session that merely ran out.
2564                if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2565                    if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
2566                        ended = Some(laterite_auth::SessionEnd::Revoked(reason));
2567                    }
2568                }
2569                return signed_out(&state, jar, &login, ended, htmx);
2570            }
2571        },
2572    };
2573    let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
2574
2575    // CSRF token check (the origin gate already ran on the whole router). A
2576    // state-changing method must carry the session token in a header or the
2577    // `_csrf` field; buffering the body serves plain forms and HTMX alike and
2578    // fails closed when a form omitted the field.
2579    let mut request = request;
2580    let safe = session::is_safe_method(request.method());
2581    // Set when the token has to be checked by the handler's extractor instead.
2582    let mut deferred: Option<session::CsrfPending> = None;
2583    if !safe {
2584        let (parts, body) = request.into_parts();
2585        // A file upload is never buffered: the form limit would truncate it to
2586        // nothing (and the token check would then fail for the wrong reason),
2587        // and holding a whole file in memory to read one field defeats streaming
2588        // it. Such a request carries its token in the header, or in the form
2589        // action's query string when there is no scripting to set one. The body
2590        // passes through untouched.
2591        let (body, submitted) = if session::is_multipart(&parts.headers) {
2592            let token = session::header_token(&parts.headers)
2593                .or_else(|| session::query_token(parts.uri.query()));
2594            // No token beside the body means it is in the body, where the
2595            // reference system reads it from and where a plain form can put it.
2596            // The guard cannot look there without consuming the upload, so the
2597            // check is deferred to the extractor and enforced below.
2598            if token.is_none() {
2599                deferred = Some(session::CsrfPending::default());
2600            }
2601            (body, token)
2602        } else {
2603            let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
2604                .await
2605                .unwrap_or_default();
2606            let token = session::submitted_token(&parts.headers, &bytes);
2607            (Body::from(bytes), token)
2608        };
2609        if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
2610        {
2611            tracing::warn!(
2612                user_id = resolved.identity.user.id,
2613                "admin CSRF check failed"
2614            );
2615            return error::csrf_rejected();
2616        }
2617        request = Request::from_parts(parts, body);
2618    }
2619
2620    let user = resolved.identity;
2621    // Kept past the move into the request, for the deferred-check log below.
2622    let acting_user_id = user.user.id;
2623    let path = request.uri().path().to_string();
2624    // A temporary password opens nothing but the Preferences form that replaces
2625    // it, and the way out.
2626    let preferences = format!("{}/preferences", state.admin_path);
2627    if !path.starts_with(&preferences)
2628        && path != format!("{}/logout", state.admin_path)
2629        && state
2630            .auth
2631            .must_change_password(user.user.id)
2632            .await
2633            .unwrap_or(false)
2634    {
2635        let to = format!("{preferences}#password");
2636        let response = if htmx {
2637            (
2638                [("HX-Redirect", to.as_str())],
2639                axum::http::StatusCode::NO_CONTENT,
2640            )
2641                .into_response()
2642        } else {
2643            Redirect::to(&to).into_response()
2644        };
2645        return with_recall_cookies(response, recalled, jar, token, &state);
2646    }
2647    // Deliver and clear any queued flash on a full-page render; a mutating
2648    // request leaves it for the redirect target's GET.
2649    let flash = if safe {
2650        handle.take_flash()
2651    } else {
2652        Vec::new()
2653    };
2654    // Resolve the locale chain for this request (operator preference, then the
2655    // browser's Accept-Language, then the deployment default) over the shared
2656    // catalogs, before the nav context, which localizes the settings sidebar.
2657    let accept_language = request
2658        .headers()
2659        .get("accept-language")
2660        .and_then(|v| v.to_str().ok());
2661    let serveable = state.catalogs.locales();
2662    let chain = resolve_locale_chain(
2663        user.user.locale.as_deref(),
2664        accept_language,
2665        &state.default_locale,
2666        &serveable,
2667    );
2668    let i18n = Translator::with_chain(chain, state.catalogs.clone());
2669    // A clone kept past `next` (the shell's copy moves into the request) to localize
2670    // an error response the handler renders English (the `ErrorMeta` seam).
2671    let translator = i18n.clone();
2672    let (sidebar, active_nav) = resolve_nav_context(
2673        &state.nav,
2674        &state.settings,
2675        &state.admin_path,
2676        &user.permissions,
2677        &path,
2678        &i18n,
2679        icons::Icons::new(&state.icons, &state.sprite_url),
2680    );
2681    let brand = state.brand().await;
2682    let shell = Shell::new(
2683        &state.admin_path,
2684        brand,
2685        &state.nav,
2686        &user,
2687        state.timezone,
2688        sidebar,
2689        active_nav.as_deref(),
2690        handle.csrf_token(),
2691        flash,
2692        i18n,
2693        state.asset_urls.clone(),
2694        state.origin.to_string(),
2695        state.icons.clone(),
2696        state.sprite_url.clone(),
2697    );
2698    request.extensions_mut().insert(user);
2699    request.extensions_mut().insert(shell);
2700    request.extensions_mut().insert(handle.clone());
2701    // Present only when the token check was deferred to an upload extractor.
2702    if let Some(pending) = &deferred {
2703        request.extensions_mut().insert(pending.clone());
2704    }
2705    let mut response = next.run(request).await;
2706
2707    // A deferred check that never happened means the handler took the upload
2708    // without the verifying extractor. Refuse the response rather than let the
2709    // route quietly run unguarded: the origin gate held, but that is the outer
2710    // layer, not the whole of it.
2711    if let Some(pending) = &deferred {
2712        if !pending.was_satisfied() {
2713            tracing::error!(
2714                user_id = acting_user_id,
2715                "a multipart route returned without verifying its request token"
2716            );
2717            return error::csrf_rejected();
2718        }
2719    }
2720
2721    // The `ErrorMeta` seam: an `AdminError` renders its page in English from
2722    // `IntoResponse` (no request context there); re-render it localized now that a
2723    // translator is in hand. Status and kind are preserved.
2724    if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
2725        response = error::localized_error(meta.kind, &translator);
2726    }
2727
2728    // Persist the blob only when a handler changed it (flash set or consumed,
2729    // token rotated, or a token freshly minted this request).
2730    if let Some(blob) = handle.dirty_blob() {
2731        if let Err(e) = state.auth.set_session_data(&token, &blob).await {
2732            tracing::error!(error = %e, "persisting admin session failed");
2733        }
2734    }
2735    with_recall_cookies(response, recalled, jar, token, &state)
2736}
2737
2738/// A recall replaced both halves, the session it minted and the credential that
2739/// replaced the one just spent, so every response on such a request carries the
2740/// new cookies, whichever branch produced it.
2741fn with_recall_cookies(
2742    response: Response,
2743    recalled: Option<laterite_auth::RememberCredential>,
2744    jar: CookieJar,
2745    token: String,
2746    state: &AdminState,
2747) -> Response {
2748    match recalled {
2749        Some(credential) => {
2750            let jar = jar
2751                .add(session_cookie(
2752                    token,
2753                    &state.admin_path,
2754                    state.secure_cookie,
2755                ))
2756                .add(remember_cookie(
2757                    credential,
2758                    &state.admin_path,
2759                    state.secure_cookie,
2760                ));
2761            (jar, response).into_response()
2762        }
2763        None => response,
2764    }
2765}
2766
2767/// Trades a presented stay-signed-in cookie for a live session. Returns the new
2768/// session token, the resolved identity, and the credential that replaces the
2769/// one just spent.
2770async fn recall(
2771    state: &AdminState,
2772    jar: &CookieJar,
2773    ctx: &RequestContext,
2774) -> Option<(
2775    String,
2776    laterite_auth::ResolvedSession,
2777    laterite_auth::RememberCredential,
2778)> {
2779    let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
2780    let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
2781    let token = recalled.session.token.clone();
2782    let resolved = state.auth.resolve_session(&token).await.ok()?;
2783    Some((token, resolved, recalled.remember))
2784}
2785
2786/// Ceiling on a buffered admin form body for the CSRF check. Admin forms are
2787/// small; a larger body is rejected rather than buffered.
2788const MAX_FORM_BYTES: usize = 1024 * 1024;
2789
2790/// The primary CSRF gate, layered on the whole admin router: a state-changing
2791/// request must come from our own origin (see [`session::origin_ok`]). This
2792/// covers the login and setup screens, which are deliberately token-less (no
2793/// session exists yet), so the origin check is their sole CSRF defense.
2794/// Records where a request came from, once, for anything downstream that logs.
2795/// Doing it in one layer keeps the trusted-proxy rule in a single place rather
2796/// than at each call site that happens to want an address.
2797async fn capture_client(
2798    State(state): State<AdminState>,
2799    mut request: Request,
2800    next: Next,
2801) -> Response {
2802    let peer = request
2803        .extensions()
2804        .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
2805        .map(|info| info.0);
2806    let headers = request.headers();
2807    let ctx = RequestContext {
2808        ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
2809        user_agent: clientip::user_agent(headers),
2810    };
2811    request.extensions_mut().insert(ctx);
2812    next.run(request).await
2813}
2814
2815async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
2816    if !session::is_safe_method(request.method())
2817        && !session::origin_ok(request.headers(), &state.origin)
2818    {
2819        tracing::warn!(
2820            path = request.uri().path(),
2821            "admin CSRF origin check failed"
2822        );
2823        return error::csrf_rejected();
2824    }
2825    next.run(request).await
2826}
2827
2828#[derive(Deserialize)]
2829struct LoginQuery {
2830    ended: Option<String>,
2831}
2832
2833async fn login_form(
2834    State(state): State<AdminState>,
2835    headers: HeaderMap,
2836    Query(query): Query<LoginQuery>,
2837) -> Response {
2838    // A fresh install with no operators goes to first-run setup instead.
2839    match state.auth.has_any_operator().await {
2840        Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
2841        Ok(true) => {
2842            let i18n = pre_auth_translator(&state, &headers);
2843            let notice = query
2844                .ended
2845                .as_deref()
2846                .and_then(end_notice)
2847                .map(|text| i18n.t(&text));
2848            render(LoginTemplate {
2849                base: state.admin_path.to_string(),
2850                asset_urls: state.asset_urls.clone(),
2851                brand: state.brand().await,
2852                error: None,
2853                notice,
2854                i18n,
2855            })
2856        }
2857        Err(_) => render_error(),
2858    }
2859}
2860
2861#[derive(Deserialize)]
2862struct LoginForm {
2863    username: String,
2864    password: String,
2865    /// The "Stay signed in" box. Present only when ticked, as a checkbox is.
2866    #[serde(default)]
2867    remember: Option<String>,
2868}
2869
2870async fn login_submit(
2871    State(state): State<AdminState>,
2872    jar: CookieJar,
2873    headers: HeaderMap,
2874    Extension(ctx): Extension<RequestContext>,
2875    Form(form): Form<LoginForm>,
2876) -> Response {
2877    match state
2878        .auth
2879        .authenticate(&form.username, &form.password, &ctx)
2880        .await
2881    {
2882        Ok(session) => {
2883            let wants_remember = form.remember.is_some();
2884            let user_id = session.user_id;
2885            let mut jar = jar.add(session_cookie(
2886                session.token,
2887                &state.admin_path,
2888                state.secure_cookie,
2889            ));
2890            if wants_remember {
2891                match state.auth.issue_remember(user_id).await {
2892                    Ok(credential) => {
2893                        jar = jar.add(remember_cookie(
2894                            credential,
2895                            &state.admin_path,
2896                            state.secure_cookie,
2897                        ));
2898                    }
2899                    // The session still stands; the box just did not take.
2900                    Err(e) => {
2901                        tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
2902                    }
2903                }
2904            }
2905            (jar, Redirect::to(&state.admin_path)).into_response()
2906        }
2907        Err(_) => {
2908            let i18n = pre_auth_translator(&state, &headers);
2909            render(LoginTemplate {
2910                base: state.admin_path.to_string(),
2911                asset_urls: state.asset_urls.clone(),
2912                brand: state.brand().await,
2913                error: Some(i18n.t(&t!("Invalid username or password."))),
2914                notice: None,
2915                i18n,
2916            })
2917        }
2918    }
2919}
2920
2921/// Builds the session cookie, scoped to the admin mount and flagged `Secure`
2922/// behind HTTPS. Shared by login and first-run setup.
2923///
2924/// `remember` is what the login form's "Stay signed in" box asks for: the cookie
2925/// is given the session's own lifetime, so closing the browser does not end it.
2926/// Without it the cookie lasts the browser session, which is the right default
2927/// for a shared machine. Either way the cookie never outlives the session row it
2928/// names, because both take their length from the same place.
2929fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
2930    Cookie::build((SESSION_COOKIE, token))
2931        .path(admin_path.to_string())
2932        .http_only(true)
2933        .secure(secure)
2934        .same_site(SameSite::Lax)
2935        .build()
2936}
2937
2938/// The "stay signed in" cookie. Outlives the session deliberately: when the
2939/// session ends, this is what mints the next one.
2940fn remember_cookie(
2941    credential: laterite_auth::RememberCredential,
2942    admin_path: &str,
2943    secure: bool,
2944) -> Cookie<'static> {
2945    let seconds = (credential.expires_at - chrono::Utc::now())
2946        .num_seconds()
2947        .max(0);
2948    Cookie::build((REMEMBER_COOKIE, credential.cookie))
2949        .path(admin_path.to_string())
2950        .http_only(true)
2951        .secure(secure)
2952        .same_site(SameSite::Lax)
2953        .max_age(cookie::time::Duration::seconds(seconds))
2954        .build()
2955}
2956
2957/// Sends a signed-out request to the login screen, carrying why when known.
2958///
2959/// Both cookies are cleared: a credential that failed once fails every time,
2960/// and retrying it on each request is noise. An htmx request gets `HX-Redirect`
2961/// with no body, so the browser navigates to the login page rather than
2962/// swapping it into whatever region asked.
2963fn signed_out(
2964    state: &AdminState,
2965    jar: CookieJar,
2966    login: &str,
2967    ended: Option<laterite_auth::SessionEnd>,
2968    htmx: bool,
2969) -> Response {
2970    let to = match ended.map(laterite_auth::SessionEnd::code) {
2971        Some(code) => format!("{login}?ended={code}"),
2972        None => login.to_string(),
2973    };
2974    let jar = jar
2975        .remove(
2976            Cookie::build((SESSION_COOKIE, ""))
2977                .path(state.admin_path.to_string())
2978                .build(),
2979        )
2980        .remove(remember_removal(&state.admin_path));
2981    if htmx {
2982        let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
2983        if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
2984            response.headers_mut().insert("hx-redirect", value);
2985        }
2986        return response;
2987    }
2988    (jar, Redirect::to(&to)).into_response()
2989}
2990
2991/// What the login screen says for an `ended` code. A fixed set: an unknown code
2992/// shows nothing, so nothing in the URL is ever rendered back.
2993fn end_notice(code: &str) -> Option<Text> {
2994    Some(match code {
2995        "password_changed" => {
2996            t!("Your password was changed, so you were signed out. Sign in with the new one.")
2997        }
2998        "deactivated" => t!("This account was deactivated."),
2999        "signed_out_elsewhere" => t!("You were signed out from another device."),
3000        "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
3001        _ => return None,
3002    })
3003}
3004
3005/// Clears the remember cookie, for a logout or a credential that failed.
3006fn remember_removal(admin_path: &str) -> Cookie<'static> {
3007    Cookie::build((REMEMBER_COOKIE, ""))
3008        .path(admin_path.to_string())
3009        .build()
3010}
3011
3012#[derive(Deserialize)]
3013struct SetupForm {
3014    username: String,
3015    first_name: String,
3016    last_name: String,
3017    email: String,
3018    password: String,
3019    timezone: String,
3020}
3021
3022/// The first-run setup screen: shown only while no operator exists, so a fresh
3023/// install can create its first administrator without the CLI.
3024async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
3025    match state.auth.has_any_operator().await {
3026        Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3027        Ok(false) => render(setup_view(
3028            &state.admin_path,
3029            state.brand().await,
3030            state.timezone,
3031            None,
3032            pre_auth_translator(&state, &headers),
3033            state.asset_urls.clone(),
3034            state.auth.password_policy().min_length,
3035        )),
3036        Err(_) => render_error(),
3037    }
3038}
3039
3040async fn setup_submit(
3041    State(state): State<AdminState>,
3042    jar: CookieJar,
3043    headers: HeaderMap,
3044    Form(form): Form<SetupForm>,
3045) -> Response {
3046    // Setup only ever creates the first operator; once one exists it is closed.
3047    match state.auth.has_any_operator().await {
3048        Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3049        Ok(false) => {}
3050        Err(_) => return render_error(),
3051    }
3052
3053    let username = form.username.trim();
3054    let email = form.email.trim();
3055    let first_name = form.first_name.trim();
3056    let last_name = form.last_name.trim();
3057    let tz = form.timezone.trim();
3058    if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
3059    {
3060        return render(setup_view(
3061            &state.admin_path,
3062            state.brand().await,
3063            state.timezone,
3064            Some(t!(
3065                "Username, first name, email, and password are all required."
3066            )),
3067            pre_auth_translator(&state, &headers),
3068            state.asset_urls.clone(),
3069            state.auth.password_policy().min_length,
3070        ));
3071    }
3072    // The setup select always carries a value, but guard against a bad one.
3073    if tz.parse::<Tz>().is_err() {
3074        return render(setup_view(
3075            &state.admin_path,
3076            state.brand().await,
3077            state.timezone,
3078            Some(t!("That is not a recognised timezone.")),
3079            pre_auth_translator(&state, &headers),
3080            state.asset_urls.clone(),
3081            state.auth.password_policy().min_length,
3082        ));
3083    }
3084
3085    let min_length = state.auth.password_policy().min_length;
3086    if form.password.chars().count() < min_length {
3087        return render(setup_view(
3088            &state.admin_path,
3089            state.brand().await,
3090            state.timezone,
3091            Some(t!(
3092                "A password needs at least {n} characters.",
3093                n = min_length as i64
3094            )),
3095            pre_auth_translator(&state, &headers),
3096            state.asset_urls.clone(),
3097            state.auth.password_policy().min_length,
3098        ));
3099    }
3100    let new = NewOperator {
3101        username,
3102        email,
3103        first_name,
3104        last_name: (!last_name.is_empty()).then_some(last_name),
3105        password: &form.password,
3106        timezone: Some(tz),
3107    };
3108    let id = match state.auth.create_superuser(new).await {
3109        Ok(id) => id,
3110        Err(_) => {
3111            return render(setup_view(
3112                &state.admin_path,
3113                state.brand().await,
3114                state.timezone,
3115                Some(t!(
3116                    "Could not create the account. The username or email may already be taken."
3117                )),
3118                pre_auth_translator(&state, &headers),
3119                state.asset_urls.clone(),
3120                state.auth.password_policy().min_length,
3121            ));
3122        }
3123    };
3124    // Nobody is signed in yet, so the trail names the process rather than an
3125    // operator, the same as a command-line action.
3126    let actor = laterite_core::Actor::system("first-run setup");
3127    let target = id.to_string();
3128    let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
3129        "backend_user",
3130        &target,
3131        Some(username),
3132    );
3133    if let Err(e) = state.auth.record_audit(entry).await {
3134        tracing::error!(error = %e, "failed to write audit log entry");
3135    }
3136
3137    // Sign the new administrator straight in through the normal login path.
3138    match state
3139        .auth
3140        .authenticate(username, &form.password, &RequestContext::default())
3141        .await
3142    {
3143        Ok(session) => {
3144            let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
3145            (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
3146        }
3147        Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3148    }
3149}
3150
3151/// Builds the setup view, its timezone select defaulting to the deployment
3152/// default so the first administrator can accept or change it.
3153fn setup_view(
3154    admin_path: &str,
3155    brand: String,
3156    default_tz: Tz,
3157    error: Option<Text>,
3158    i18n: Translator,
3159    asset_urls: Arc<AssetUrls>,
3160    min_password: usize,
3161) -> SetupTemplate {
3162    let default_name = default_tz.name();
3163    let zones = TZ_VARIANTS
3164        .iter()
3165        .filter(|tz| zone_offered(tz.name()))
3166        .map(|tz| TzOption {
3167            name: tz.name().to_string(),
3168            selected: tz.name() == default_name,
3169        })
3170        .collect();
3171    SetupTemplate {
3172        base: admin_path.to_string(),
3173        asset_urls,
3174        brand,
3175        zones,
3176        error: error.map(|e| i18n.t(&e)),
3177        min_password: min_password as i64,
3178        i18n,
3179    }
3180}
3181
3182async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
3183    if let Some(cookie) = jar.get(SESSION_COOKIE) {
3184        let _ = state.auth.logout(cookie.value()).await;
3185    }
3186    // Without this the next request would present the credential and sign
3187    // straight back in, which is not what pressing Sign out means.
3188    if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3189        let _ = state.auth.revoke_remember(cookie.value()).await;
3190    }
3191    let removal = Cookie::build((SESSION_COOKIE, ""))
3192        .path(state.admin_path.to_string())
3193        .build();
3194    (
3195        jar.remove(removal)
3196            .remove(remember_removal(&state.admin_path)),
3197        Redirect::to(&format!("{}/login", state.admin_path)),
3198    )
3199        .into_response()
3200}
3201
3202async fn dashboard(
3203    Extension(shell): Extension<Shell>,
3204    Extension(user): Extension<AuthenticatedUser>,
3205) -> Response {
3206    render(DashboardTemplate {
3207        username: user.user.username,
3208        shell,
3209    })
3210}
3211
3212/// Whether an operator may see a settings item: items with no permission are
3213/// public, otherwise the operator must hold the item's permission.
3214fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
3215    match &item.permission {
3216        None => true,
3217        Some(p) => perms.allows(p),
3218    }
3219}
3220
3221/// The settings items this operator may see, in registry order. Both the index
3222/// and the form use this set, so an operator never sees or edits an item their
3223/// permissions do not allow.
3224fn visible_settings(
3225    items: &[settings::SettingsItem],
3226    perms: &PermissionSet,
3227) -> Vec<settings::SettingsItem> {
3228    items
3229        .iter()
3230        .filter(|item| operator_can_see(item, perms))
3231        .cloned()
3232        .collect()
3233}
3234
3235async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
3236    settings::index(shell)
3237}
3238
3239async fn settings_edit(
3240    state: AdminState,
3241    shell: Shell,
3242    user: AuthenticatedUser,
3243    code: String,
3244) -> Response {
3245    // Filter first, so an operator cannot open a settings form they lack the
3246    // permission to see.
3247    let items = visible_settings(&state.settings, &user.permissions);
3248    match items
3249        .iter()
3250        .find(|item| item.code == code && item.link.is_none())
3251    {
3252        Some(item) => settings::edit_form(&state, item, shell).await,
3253        None => not_found(),
3254    }
3255}
3256
3257async fn settings_update(
3258    state: AdminState,
3259    shell: Shell,
3260    user: AuthenticatedUser,
3261    session: session::SessionHandle,
3262    code: String,
3263    data: HashMap<String, String>,
3264) -> Response {
3265    let items = visible_settings(&state.settings, &user.permissions);
3266    match items
3267        .iter()
3268        .find(|item| item.code == code && item.link.is_none())
3269    {
3270        Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
3271        None => not_found(),
3272    }
3273}
3274
3275/// The self-service Preferences screen for the signed-in operator.
3276async fn preferences_form(
3277    State(state): State<AdminState>,
3278    Extension(shell): Extension<Shell>,
3279    Extension(user): Extension<AuthenticatedUser>,
3280    jar: CookieJar,
3281) -> Response {
3282    let account = account_security(&state, &user, &shell, &jar).await;
3283    render(preferences_view(
3284        &shell,
3285        &user,
3286        state.timezone,
3287        &state.default_locale,
3288        &offered_locales(&state.catalogs),
3289        account,
3290        state.auth.password_policy().min_length,
3291    ))
3292}
3293
3294#[derive(Deserialize)]
3295struct RevokeSessionForm {
3296    id: String,
3297}
3298
3299/// Ends one of the account's other sessions.
3300async fn session_revoke(
3301    State(state): State<AdminState>,
3302    Extension(user): Extension<AuthenticatedUser>,
3303    Extension(session): Extension<session::SessionHandle>,
3304    Form(form): Form<RevokeSessionForm>,
3305) -> Response {
3306    match state.auth.revoke_session(user.user.id, &form.id).await {
3307        Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
3308        Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
3309        Err(e) => {
3310            tracing::error!(error = %e, "revoking a session failed");
3311            session.push_flash(
3312                FlashLevel::Error,
3313                t!("That session could not be signed out."),
3314            );
3315        }
3316    }
3317    Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3318}
3319
3320/// Ends every session but this one, and drops every stay-signed-in credential.
3321async fn session_revoke_others(
3322    State(state): State<AdminState>,
3323    Extension(user): Extension<AuthenticatedUser>,
3324    Extension(session): Extension<session::SessionHandle>,
3325    jar: CookieJar,
3326) -> Response {
3327    let token = jar
3328        .get(SESSION_COOKIE)
3329        .map(|c| c.value().to_string())
3330        .unwrap_or_default();
3331    match state.auth.sign_out_everywhere(user.user.id, &token).await {
3332        Ok(_) => session.push_flash(
3333            FlashLevel::Success,
3334            t!("Every other session was signed out."),
3335        ),
3336        Err(e) => {
3337            tracing::error!(error = %e, "signing out other sessions failed");
3338            session.push_flash(
3339                FlashLevel::Error,
3340                t!("Those sessions could not be signed out."),
3341            );
3342        }
3343    }
3344    // The credential this browser holds went with the rest, so clear its cookie
3345    // rather than leave one that will fail on the next request.
3346    (
3347        jar.remove(remember_removal(&state.admin_path)),
3348        Redirect::to(&format!("{}/preferences", state.admin_path)),
3349    )
3350        .into_response()
3351}
3352
3353#[derive(Deserialize)]
3354struct PasswordForm {
3355    current_password: String,
3356    new_password: String,
3357    confirm_password: String,
3358}
3359
3360/// Changes the operator's own password. This session stays signed in, and a
3361/// stay-signed-in credential this browser holds for the account is reissued;
3362/// every other device is signed out and told why. A refusal redirects back
3363/// with its reason, so a reload never resubmits the form.
3364async fn password_update(
3365    State(state): State<AdminState>,
3366    Extension(user): Extension<AuthenticatedUser>,
3367    Extension(session): Extension<session::SessionHandle>,
3368    Extension(ctx): Extension<RequestContext>,
3369    jar: CookieJar,
3370    Form(form): Form<PasswordForm>,
3371) -> Response {
3372    let back = format!("{}/preferences#password", state.admin_path);
3373    let refuse = |message: Text| {
3374        session.push_flash(FlashLevel::Error, message);
3375        Redirect::to(&back).into_response()
3376    };
3377    if form.new_password != form.confirm_password {
3378        return refuse(t!("The new passwords do not match."));
3379    }
3380    let min_length = state.auth.password_policy().min_length;
3381    if form.new_password.chars().count() < min_length {
3382        return refuse(t!(
3383            "A password needs at least {n} characters.",
3384            n = min_length as i64
3385        ));
3386    }
3387    // Whether the stay-signed-in cookie here is this account's, asked before the
3388    // change revokes it: only then is a fresh one owed.
3389    let remembered_here = match jar.get(REMEMBER_COOKIE) {
3390        Some(cookie) => {
3391            state
3392                .auth
3393                .remember_holder(cookie.value())
3394                .await
3395                .ok()
3396                .flatten()
3397                == Some(user.user.id)
3398        }
3399        None => false,
3400    };
3401    let token = jar
3402        .get(SESSION_COOKIE)
3403        .map(|c| c.value().to_string())
3404        .unwrap_or_default();
3405    let changed = state
3406        .auth
3407        .change_own_password(
3408            user.user.id,
3409            &form.current_password,
3410            &form.new_password,
3411            &token,
3412            &ctx,
3413        )
3414        .await;
3415    match changed {
3416        Ok(()) => {}
3417        Err(laterite_auth::AuthError::InvalidCredentials) => {
3418            return refuse(t!("Your current password is not correct."));
3419        }
3420        Err(laterite_auth::AuthError::TooManyAttempts) => {
3421            return refuse(t!("Too many attempts. Try again later."));
3422        }
3423        Err(e) => {
3424            tracing::error!(error = %e, "changing a password failed");
3425            return refuse(t!("The password could not be changed."));
3426        }
3427    }
3428    session.push_flash_sticky(
3429        FlashLevel::Success,
3430        t!("Password changed. Every other device was signed out."),
3431    );
3432    let jar = if remembered_here {
3433        match state.auth.issue_remember(user.user.id).await {
3434            Ok(credential) => jar.add(remember_cookie(
3435                credential,
3436                &state.admin_path,
3437                state.secure_cookie,
3438            )),
3439            Err(e) => {
3440                tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
3441                jar.remove(remember_removal(&state.admin_path))
3442            }
3443        }
3444    } else if jar.get(REMEMBER_COOKIE).is_some() {
3445        // Not this account's, or no longer current: it would only fail on the
3446        // next request.
3447        jar.remove(remember_removal(&state.admin_path))
3448    } else {
3449        jar
3450    };
3451    (
3452        jar,
3453        Redirect::to(&format!("{}/preferences", state.admin_path)),
3454    )
3455        .into_response()
3456}
3457
3458#[derive(Deserialize)]
3459struct PreferencesForm {
3460    timezone: String,
3461    /// Omitted means no locale choice was submitted, treated as inherit.
3462    #[serde(default)]
3463    locale: String,
3464}
3465
3466async fn preferences_update(
3467    State(state): State<AdminState>,
3468    Extension(user): Extension<AuthenticatedUser>,
3469    Extension(session): Extension<session::SessionHandle>,
3470    Form(form): Form<PreferencesForm>,
3471) -> Response {
3472    let back = format!("{}/preferences", state.admin_path);
3473    let offered = offered_locales(&state.catalogs);
3474    // An empty choice clears a preference so the operator inherits the default.
3475    let tz = form.timezone.trim();
3476    let tz_stored = if tz.is_empty() {
3477        None
3478    } else if tz.parse::<Tz>().is_ok() {
3479        Some(tz)
3480    } else {
3481        session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
3482        return Redirect::to(&back).into_response();
3483    };
3484    let loc = form.locale.trim();
3485    let loc_stored = if loc.is_empty() {
3486        None
3487    } else if offered.iter().any(|l| l == loc) {
3488        Some(loc)
3489    } else {
3490        session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
3491        return Redirect::to(&back).into_response();
3492    };
3493    if state
3494        .auth
3495        .set_user_timezone(user.user.id, tz_stored)
3496        .await
3497        .is_err()
3498    {
3499        return render_error();
3500    }
3501    match state.auth.set_user_locale(user.user.id, loc_stored).await {
3502        Ok(()) => {
3503            session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
3504            Redirect::to(&back).into_response()
3505        }
3506        Err(_) => render_error(),
3507    }
3508}
3509
3510/// Builds the Preferences view. `shell.tz` is the timezone currently in force
3511/// (the operator's preference or the default); the operator's stored preference
3512/// selects the matching option, or the inherit option when unset.
3513fn preferences_view(
3514    shell: &Shell,
3515    user: &AuthenticatedUser,
3516    default_tz: Tz,
3517    default_locale: &str,
3518    offered: &[String],
3519    account: AccountSecurity,
3520    min_password: usize,
3521) -> PreferencesTemplate {
3522    let current = user.user.timezone.as_deref();
3523    let zones = TZ_VARIANTS
3524        .iter()
3525        .filter(|tz| zone_offered(tz.name()))
3526        .map(|tz| TzOption {
3527            name: tz.name().to_string(),
3528            selected: current == Some(tz.name()),
3529        })
3530        .collect();
3531    let current_locale = user.user.locale.as_deref();
3532    let locales = offered
3533        .iter()
3534        .map(|code| LocaleOption {
3535            name: locale_name(code),
3536            selected: current_locale == Some(code.as_str()),
3537            code: code.clone(),
3538        })
3539        .collect();
3540    PreferencesTemplate {
3541        shell: shell.clone(),
3542        zones,
3543        effective_tz: shell.tz.name().to_string(),
3544        default_tz: default_tz.name().to_string(),
3545        inherits: current.is_none(),
3546        locales,
3547        default_locale: locale_name(default_locale),
3548        inherits_locale: current_locale.is_none(),
3549        sessions: account.sessions,
3550        password_changed: account.password_changed,
3551        must_change: account.must_change,
3552        min_password: min_password as i64,
3553    }
3554}
3555
3556/// The Preferences screen's account section: live sessions and when the
3557/// password last changed, in the operator's own timezone and locale.
3558struct AccountSecurity {
3559    sessions: Vec<SessionRow>,
3560    password_changed: Option<String>,
3561    /// The password is temporary and must be replaced before anything else.
3562    must_change: bool,
3563}
3564
3565async fn account_security(
3566    state: &AdminState,
3567    user: &AuthenticatedUser,
3568    shell: &Shell,
3569    jar: &CookieJar,
3570) -> AccountSecurity {
3571    let locale = list::date_locale(shell.locale());
3572    let password_changed = match state.auth.password_changed_at(user.user.id).await {
3573        Ok(at) => {
3574            at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
3575        }
3576        Err(e) => {
3577            tracing::error!(error = %e, "reading the password change time failed");
3578            None
3579        }
3580    };
3581    AccountSecurity {
3582        sessions: session_rows(state, user, shell, jar).await,
3583        password_changed,
3584        must_change: state
3585            .auth
3586            .must_change_password(user.user.id)
3587            .await
3588            .unwrap_or(false),
3589    }
3590}
3591
3592/// Reads the account's live sessions and formats them in the operator's own
3593/// timezone and locale, the same way list columns render a timestamp.
3594async fn session_rows(
3595    state: &AdminState,
3596    user: &AuthenticatedUser,
3597    shell: &Shell,
3598    jar: &CookieJar,
3599) -> Vec<SessionRow> {
3600    let token = jar
3601        .get(SESSION_COOKIE)
3602        .map(|c| c.value().to_string())
3603        .unwrap_or_default();
3604    let locale = list::date_locale(shell.locale());
3605    let stamp = |dt: chrono::DateTime<chrono::Utc>| {
3606        list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
3607    };
3608    match state.auth.list_sessions(user.user.id, &token).await {
3609        Ok(sessions) => sessions
3610            .into_iter()
3611            .map(|s| SessionRow {
3612                id: s.id,
3613                started: stamp(s.created_at),
3614                last_seen: stamp(s.last_seen_at),
3615                expires: stamp(s.expires_at),
3616                device: clientip::describe(s.user_agent.as_deref())
3617                    .or(s.user_agent)
3618                    .unwrap_or_else(|| "-".to_string()),
3619                ip_address: s.ip_address.unwrap_or_default(),
3620                current: s.current,
3621            })
3622            .collect(),
3623        Err(e) => {
3624            tracing::error!(error = %e, "listing sessions failed");
3625            Vec::new()
3626        }
3627    }
3628}
3629
3630/// The framework's own admin screens.
3631fn builtin_resources() -> Vec<Resource> {
3632    vec![
3633        Resource {
3634            base_path: "/users".to_string(),
3635            nav_label: "Backend Users".into(),
3636            list: backend_users_list_config(),
3637            form: None,
3638            permission: Some("backend.manage_users".to_string()),
3639            import: None,
3640            relations: Vec::new(),
3641        },
3642        Resource {
3643            base_path: "/roles".to_string(),
3644            nav_label: "Roles".into(),
3645            list: roles_list_config(),
3646            // The create/edit form is the dedicated permission editor (see the
3647            // `roles` module), mounted separately, not the generic form.
3648            form: None,
3649            permission: Some("backend.manage_roles".to_string()),
3650            import: None,
3651            relations: Vec::new(),
3652        },
3653        resource!("descriptors/audit-log.yaml"),
3654        resource!("descriptors/access-log.yaml"),
3655    ]
3656}
3657
3658/// The framework's own settings items. The built-in Users and Roles resources
3659/// appear in the settings menu under a Users category (linking to their list
3660/// screens), rather than as main-menu tabs.
3661fn builtin_settings() -> Vec<settings::SettingsItem> {
3662    vec![
3663        settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
3664            .description("Manage backend administrator accounts.")
3665            .category("Users")
3666            .order(10)
3667            .icon("users")
3668            .permission("backend.manage_users")
3669            .link("/users"),
3670        settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
3671            .description("Manage roles and their permissions.")
3672            .category("Users")
3673            .order(20)
3674            .icon("shield")
3675            .permission("backend.manage_roles")
3676            .link("/roles"),
3677        settings::brand::settings_item(),
3678        settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
3679            .description("Enable or disable installed plugins.")
3680            .category("System")
3681            .order(10)
3682            .icon("plug")
3683            .permission(plugins::MANAGE_PERMISSION)
3684            .link("/plugins"),
3685        settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
3686            .description("Review the record of administrative changes.")
3687            .category("System")
3688            .order(20)
3689            .icon("history")
3690            .permission("backend.view_audit_log")
3691            .link("/audit-log"),
3692        settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
3693            .description("Review sign-ins, failures and lockouts.")
3694            .category("System")
3695            .order(30)
3696            .icon("log-in")
3697            .permission("backend.view_access_log")
3698            .link("/access-log"),
3699    ]
3700}
3701
3702fn backend_users_list_config() -> list::ListConfig {
3703    list::ListConfig {
3704        entity: "backend_users".to_string(),
3705        title: "Backend Users".into(),
3706        columns: vec![
3707            list::ListColumn::new("username", "Username"),
3708            list::ListColumn::new("email", "Email"),
3709            list::ListColumn::new("first_name", "First name"),
3710            list::ListColumn::new("last_name", "Last name"),
3711            list::ListColumn::new("is_superuser", "Superuser").yes_no(),
3712            list::ListColumn::new("is_active", "Active").yes_no(),
3713            list::ListColumn::new("created_at", "Created").datetime(),
3714        ],
3715        order_by: "created_at".to_string(),
3716        // Rows link to the per-user editor; New creates an account with a
3717        // temporary password.
3718        creatable: true,
3719        edit_base: Some("/users".to_string()),
3720        filters: vec![
3721            list::ListFilter::boolean("is_active", "Active"),
3722            list::ListFilter::boolean("is_superuser", "Superuser"),
3723        ],
3724        // Removing an operator from a list is too easy to do by accident;
3725        // deactivating is the reversible equivalent and is what the filter above
3726        // is for.
3727        ..Default::default()
3728    }
3729}
3730
3731fn roles_list_config() -> list::ListConfig {
3732    list::ListConfig {
3733        entity: "backend_roles".to_string(),
3734        title: "Roles".into(),
3735        columns: vec![
3736            list::ListColumn::new("code", "Code"),
3737            list::ListColumn::new("name", "Name"),
3738            list::ListColumn::new("created_at", "Created").datetime(),
3739        ],
3740        order_by: "created_at".to_string(),
3741        edit_base: Some("/roles".to_string()),
3742        creatable: true,
3743        deletable: true,
3744        ..Default::default()
3745    }
3746}
3747
3748#[derive(Template)]
3749#[template(path = "login.html")]
3750struct LoginTemplate {
3751    /// The admin mount path, so pre-auth asset and form URLs match the panel.
3752    base: String,
3753    /// Each asset's content-named path; see [`Shell::asset`].
3754    asset_urls: Arc<AssetUrls>,
3755    brand: String,
3756    error: Option<String>,
3757    /// Why the previous session ended, when the redirect said.
3758    notice: Option<String>,
3759    /// The pre-auth translator (config locale, then `Accept-Language`); no operator
3760    /// preference exists yet. `self.t` and `self.locale` localize this screen.
3761    i18n: Translator,
3762}
3763
3764/// Builds a content-named asset URL for the pre-auth screens, which render
3765/// before a [`Shell`] exists and so carry the map themselves.
3766fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
3767    match urls.get(key) {
3768        Some(path) => format!("{base}/assets/{path}"),
3769        None => {
3770            debug_assert!(false, "asset key `{key}` is not registered");
3771            format!("{base}/assets/{key}")
3772        }
3773    }
3774}
3775
3776impl LoginTemplate {
3777    fn asset(&self, key: &str) -> String {
3778        preauth_asset(&self.asset_urls, &self.base, key)
3779    }
3780    fn t(&self, source: &str) -> String {
3781        self.i18n.t(&Text::dynamic(source))
3782    }
3783    fn locale(&self) -> &str {
3784        self.i18n.locale()
3785    }
3786}
3787
3788#[derive(Template)]
3789#[template(path = "dashboard.html")]
3790struct DashboardTemplate {
3791    shell: Shell,
3792    username: String,
3793}
3794
3795#[derive(Template)]
3796#[template(path = "setup.html")]
3797struct SetupTemplate {
3798    /// The admin mount path, so pre-auth asset and form URLs match the panel.
3799    base: String,
3800    /// Each asset's content-named path; see [`Shell::asset`].
3801    asset_urls: Arc<AssetUrls>,
3802    brand: String,
3803    zones: Vec<TzOption>,
3804    error: Option<String>,
3805    /// The pre-auth translator, as on [`LoginTemplate`].
3806    i18n: Translator,
3807    /// The shortest password the policy accepts.
3808    min_password: i64,
3809}
3810
3811impl SetupTemplate {
3812    /// Localizes a source string with integer `{name}` arguments, as
3813    /// [`Shell::tf`] does for signed-in screens.
3814    fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
3815        let mut text = Text::dynamic(source);
3816        for (name, value) in args {
3817            text = text.arg(*name, *value);
3818        }
3819        self.i18n.t(&text)
3820    }
3821
3822    fn asset(&self, key: &str) -> String {
3823        preauth_asset(&self.asset_urls, &self.base, key)
3824    }
3825    fn t(&self, source: &str) -> String {
3826        self.i18n.t(&Text::dynamic(source))
3827    }
3828    fn locale(&self) -> &str {
3829        self.i18n.locale()
3830    }
3831}
3832
3833#[derive(Template)]
3834#[template(path = "preferences.html")]
3835struct PreferencesTemplate {
3836    shell: Shell,
3837    zones: Vec<TzOption>,
3838    /// The timezone dates currently render in for this operator.
3839    effective_tz: String,
3840    /// The deployment default, named in the inherit option.
3841    default_tz: String,
3842    /// Whether the operator currently inherits the default (no preference set).
3843    inherits: bool,
3844    locales: Vec<LocaleOption>,
3845    /// The deployment default locale, named in the inherit option.
3846    default_locale: String,
3847    /// Whether the operator currently inherits the default locale.
3848    inherits_locale: bool,
3849    /// The account's live sessions, most recently active first.
3850    sessions: Vec<SessionRow>,
3851    /// When the password last changed, formatted; `None` when not recorded.
3852    password_changed: Option<String>,
3853    /// The password is temporary; nothing else opens until it is replaced.
3854    must_change: bool,
3855    /// The shortest password accepted, for the form's help and `minlength`.
3856    min_password: i64,
3857}
3858
3859/// One of the account's live sessions, formatted for the page.
3860struct SessionRow {
3861    id: String,
3862    started: String,
3863    last_seen: String,
3864    expires: String,
3865    /// "Chrome on macOS", or the raw agent when it is not one we name, or a
3866    /// dash when the request carried none.
3867    device: String,
3868    /// Where it signed in from, blank when the deployment records no address.
3869    ip_address: String,
3870    current: bool,
3871}
3872
3873struct TzOption {
3874    name: String,
3875    selected: bool,
3876}
3877
3878struct LocaleOption {
3879    /// The base language tag (`en`, `kn`).
3880    code: String,
3881    /// The language's own name, shown untranslated.
3882    name: String,
3883    selected: bool,
3884}
3885
3886#[derive(Clone)]
3887struct NavView {
3888    label: String,
3889    path: String,
3890    active: bool,
3891    /// Inline SVG for the tab's icon, or empty for a text-only tab. Rendered raw
3892    /// with `|safe`.
3893    icon: String,
3894}
3895
3896/// Renders a template to an HTML response, mapping a render failure to a 500.
3897pub(crate) fn render<T: Template>(template: T) -> Response {
3898    match template.render() {
3899        Ok(html) => Html(html).into_response(),
3900        // A render failure is unexpected: log the cause via AdminError, then mask.
3901        Err(e) => AdminError::from(e).into_response(),
3902    }
3903}
3904
3905pub(crate) fn render_error() -> Response {
3906    error::masked_500()
3907}
3908
3909pub(crate) fn not_found() -> Response {
3910    AdminError::NotFound.into_response()
3911}
3912
3913pub(crate) fn forbidden() -> Response {
3914    AdminError::Forbidden.into_response()
3915}
3916
3917#[cfg(test)]
3918mod tests {
3919    use super::*;
3920
3921    fn write_form() -> form::FormConfig {
3922        form::FormConfig {
3923            enter: Default::default(),
3924            confirm_leave: true,
3925            focus: Default::default(),
3926            entity: "widgets".to_string(),
3927            title: "Widgets".into(),
3928            base_path: "/widgets".to_string(),
3929            id_field: "id".to_string(),
3930            fields: vec![],
3931            persist: None,
3932            timestamps: false,
3933            done_path: None,
3934        }
3935    }
3936
3937    fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
3938        Resource {
3939            base_path: "/widgets".to_string(),
3940            nav_label: "Widgets".into(),
3941            list: backend_users_list_config(),
3942            form,
3943            permission: permission.map(str::to_string),
3944            import: None,
3945            relations: Vec::new(),
3946        }
3947    }
3948
3949    #[test]
3950    #[should_panic(expected = "must declare a permission")]
3951    fn write_resource_without_permission_aborts_boot() {
3952        // A create/edit form with no permission would expose its mutations to any
3953        // signed-in operator; mounting it aborts boot.
3954        let resource = resource_with(Some(write_form()), None);
3955        let _ = mount_resource(
3956            &resource,
3957            &field::builtin_registry(),
3958            &list::builtin_column_registry(),
3959            &std::collections::HashSet::new(),
3960            &persist::PersisterRegistry::new(),
3961            &[],
3962        );
3963    }
3964
3965    /// The access log is a descriptor file too, pinned the same way.
3966    #[test]
3967    fn the_access_log_is_read_from_its_descriptor_file() {
3968        let access = builtin_resources()
3969            .into_iter()
3970            .find(|r| r.base_path == "/access-log")
3971            .expect("the access log is registered");
3972        assert_eq!(access.list.entity, "backend_access_log");
3973        assert_eq!(
3974            access.permission.as_deref(),
3975            Some("backend.view_access_log")
3976        );
3977        assert!(access.form.is_none(), "append-only");
3978        assert_eq!(
3979            access
3980                .list
3981                .columns
3982                .iter()
3983                .map(|c| c.field.as_str())
3984                .collect::<Vec<_>>(),
3985            [
3986                "created_at",
3987                "username_attempted",
3988                "event",
3989                "ip_address",
3990                "user_agent"
3991            ]
3992        );
3993    }
3994
3995    /// The audit log is a descriptor file. Its shape is pinned here, so the
3996    /// file and the screen cannot drift apart unnoticed.
3997    #[test]
3998    fn the_audit_log_is_read_from_its_descriptor_file() {
3999        let audit = builtin_resources()
4000            .into_iter()
4001            .find(|r| r.base_path == "/audit-log")
4002            .expect("the audit log is registered");
4003        assert_eq!(audit.list.entity, "backend_audit_log");
4004        assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
4005        assert_eq!(
4006            audit
4007                .list
4008                .columns
4009                .iter()
4010                .map(|c| c.field.as_str())
4011                .collect::<Vec<_>>(),
4012            [
4013                "created_at",
4014                "actor_username",
4015                "action",
4016                "target_type",
4017                "target_id",
4018                "target_label"
4019            ],
4020            "in the order the file writes them"
4021        );
4022        assert_eq!(audit.list.columns[0].column_type, "datetime");
4023        assert_eq!(audit.list.per_page, 50);
4024        assert_eq!(audit.list.per_page_options, [25, 50, 100]);
4025        assert!(audit.list.exportable, "evidence may leave the panel");
4026        assert!(audit.form.is_none(), "append-only: no form");
4027        assert!(!audit.list.deletable, "and nothing removes from it");
4028    }
4029
4030    #[test]
4031    #[should_panic(expected = "uses unregistered type `nope`")]
4032    fn unregistered_column_type_aborts_boot() {
4033        let mut resource = resource_with(None, None);
4034        let mut score = list::ListColumn::new("score", "Score");
4035        score.column_type = "nope".into();
4036        resource.list.columns.push(score);
4037        let _ = mount_resource(
4038            &resource,
4039            &field::builtin_registry(),
4040            &list::builtin_column_registry(),
4041            &std::collections::HashSet::new(),
4042            &persist::PersisterRegistry::new(),
4043            &[],
4044        );
4045    }
4046
4047    #[test]
4048    fn read_only_resource_may_omit_permission() {
4049        // No write path, so no permission is required: this mounts without panicking.
4050        let resource = resource_with(None, None);
4051        let _ = mount_resource(
4052            &resource,
4053            &field::builtin_registry(),
4054            &list::builtin_column_registry(),
4055            &std::collections::HashSet::new(),
4056            &persist::PersisterRegistry::new(),
4057            &[],
4058        );
4059    }
4060
4061    #[test]
4062    fn shell_localizes_through_the_translator_and_falls_back() {
4063        // The default translator has no catalog, so each source returns itself.
4064        let shell = Shell::test();
4065        assert_eq!(shell.t("Save"), "Save");
4066        assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
4067    }
4068
4069    #[test]
4070    fn shell_tf_interpolates_integer_arguments() {
4071        let shell = Shell::test();
4072        assert_eq!(
4073            shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
4074            "Page 2 of 5"
4075        );
4076    }
4077
4078    #[test]
4079    fn display_tz_prefers_a_valid_operator_preference() {
4080        assert_eq!(
4081            resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
4082            Tz::Asia__Kolkata
4083        );
4084    }
4085
4086    #[test]
4087    fn display_tz_falls_back_when_unset_or_invalid() {
4088        let default = Tz::Europe__London;
4089        // No preference: use the deployment default.
4090        assert_eq!(resolve_display_tz(None, default), default);
4091        // Junk stored value: fall back rather than error.
4092        assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
4093        assert_eq!(resolve_display_tz(Some(""), default), default);
4094    }
4095
4096    #[test]
4097    fn accept_language_orders_by_weight_and_drops_wildcard() {
4098        assert_eq!(
4099            parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
4100            vec!["en-US", "en", "kn"]
4101        );
4102        // A higher-weight later tag wins; the wildcard is dropped.
4103        assert_eq!(
4104            parse_accept_language("en;q=0.5,kn,*;q=0.1"),
4105            vec!["kn", "en"]
4106        );
4107    }
4108
4109    #[test]
4110    fn locale_chain_prefers_operator_then_header_then_default() {
4111        // A deployment with hi/kn/ta catalogs loaded (en is always serveable).
4112        let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
4113        // Operator preference leads, then header order, then the default; en is
4114        // appended last as the source fallback, and nothing repeats.
4115        assert_eq!(
4116            resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
4117            vec!["kn", "ta", "hi", "en"]
4118        );
4119        // An explicit en in the header keeps its signaled position, not forced last.
4120        assert_eq!(
4121            resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
4122            vec!["kn", "en", "ta"]
4123        );
4124        // A regional tag normalizes to its base language.
4125        assert_eq!(
4126            resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
4127            vec!["kn", "en"]
4128        );
4129    }
4130
4131    #[test]
4132    fn locale_chain_skips_unserveable_and_defaults_to_en() {
4133        // Only hi has a catalog here; en is always serveable.
4134        let loaded = ["hi".to_string()];
4135        // Tags with no loaded catalog (and not en) are skipped; an unserveable
4136        // default falls to en, leaving just the source.
4137        assert_eq!(
4138            resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
4139            vec!["en"]
4140        );
4141        // No signal at all is still a valid en chain.
4142        assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
4143        // A loaded catalog makes its locale serveable with no other change.
4144        assert_eq!(
4145            resolve_locale_chain(Some("hi"), None, "en", &loaded),
4146            vec!["hi", "en"]
4147        );
4148        // The pseudo-locale resolves without a catalog (QA selects it explicitly).
4149        assert_eq!(
4150            resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
4151            vec![laterite_core::PSEUDO_LOCALE, "en"]
4152        );
4153    }
4154
4155    #[test]
4156    fn page_assets_dedupe_preserve_order_and_classify() {
4157        let mut reg = AssetRegistry::new();
4158        reg.insert(
4159            "a.js",
4160            AdminAsset {
4161                mime: "text/javascript; charset=utf-8",
4162                bytes: std::borrow::Cow::Borrowed(b"//"),
4163            },
4164        );
4165        reg.insert(
4166            "b.css",
4167            AdminAsset {
4168                mime: "text/css; charset=utf-8",
4169                bytes: std::borrow::Cow::Borrowed(b"body{}"),
4170            },
4171        );
4172        // A repeated key collapses to one, keeping first-seen order; the URL is
4173        // built from the base and names the content; css-vs-js follows the mime.
4174        let urls = asset_urls(&reg);
4175        let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", &reg, &urls);
4176        assert_eq!(assets.len(), 2);
4177        assert_eq!(
4178            assets[0].url,
4179            format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
4180        );
4181        assert!(!assets[0].css);
4182        assert_eq!(
4183            assets[1].url,
4184            format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
4185        );
4186        assert!(assets[1].css);
4187        // Different bytes must produce different URLs, which is the whole
4188        // reason these may be served as immutable.
4189        assert_ne!(assets[0].url, assets[1].url);
4190    }
4191
4192    /// A fresh test database with no migrations applied, the blank slate an
4193    /// application starts from before it runs its migration set. Hold the guard
4194    /// for the test's lifetime.
4195    async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
4196        laterite_core::testing::connect_test(&[]).await
4197    }
4198
4199    #[tokio::test]
4200    async fn builtin_migrations_create_the_admin_tables() {
4201        let (db, _guard) = empty_db().await;
4202        laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
4203            .await
4204            .unwrap();
4205        // A table from each bundled module exists, so an app that only ran
4206        // builtin_migrations has everything the admin's screens need. A no-row
4207        // probe succeeds only if the table exists, portably on every backend.
4208        for table in ["backend_users", "settings"] {
4209            let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
4210                .fetch_optional(&db.pool)
4211                .await;
4212            assert!(
4213                probe.is_ok(),
4214                "{table} should exist after builtin_migrations"
4215            );
4216        }
4217    }
4218
4219    #[tokio::test]
4220    async fn brand_setting_overrides_config_and_blank_falls_back() {
4221        let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
4222        let state = AdminState {
4223            auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
4224            db: db.clone(),
4225            nav: Arc::new(Vec::new()),
4226            settings: Arc::new(Vec::new()),
4227            permissions: Arc::new(builtin_permissions()),
4228            trusted_proxies: Arc::new(Vec::new()),
4229            icons: Arc::new(laterite_core::icons::IconSet::new()),
4230            sprite_url: Arc::from(""),
4231            admin_path: Arc::from("/admin"),
4232            secure_cookie: false,
4233            origin: Arc::from(""),
4234            timezone: Tz::UTC,
4235            default_locale: Arc::from("en"),
4236            catalogs: Arc::new(CatalogStore::default()),
4237            app_name: "Configured Name".to_string(),
4238            brand_cache: Arc::new(RwLock::new(None)),
4239            field_types: Arc::new(field::builtin_registry()),
4240            column_types: Arc::new(list::builtin_column_registry()),
4241            plugin_defined: Arc::new(laterite_core::Registry::new()),
4242            assets: Arc::new(builtin_assets()),
4243            asset_urls: Arc::new(asset_urls(&builtin_assets())),
4244            pickers: Arc::new(picker::PickerRegistry::new()),
4245            overrides: Arc::new(field::NoOverrides),
4246        };
4247
4248        // With no brand setting, the configured application name is the brand.
4249        assert_eq!(state.brand().await, "Configured Name");
4250
4251        // A brand setting overrides the configured name (cache re-reads after
4252        // invalidation).
4253        settings::store::save(
4254            &db,
4255            &settings::BrandSetting {
4256                app_name: "Acme Corp".to_string(),
4257            },
4258        )
4259        .await
4260        .unwrap();
4261        state.invalidate_brand();
4262        assert_eq!(state.brand().await, "Acme Corp");
4263
4264        // A blank brand setting falls back to the configured name.
4265        settings::store::save(
4266            &db,
4267            &settings::BrandSetting {
4268                app_name: "   ".to_string(),
4269            },
4270        )
4271        .await
4272        .unwrap();
4273        state.invalidate_brand();
4274        assert_eq!(state.brand().await, "Configured Name");
4275    }
4276
4277    fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
4278        settings::SettingsItem {
4279            code: code.to_string(),
4280            label: code.into(),
4281            description: String::new().into(),
4282            category: "General".into(),
4283            hint: None,
4284            confirm_leave: true,
4285            order: 1,
4286            icon: None,
4287            permission: permission.map(str::to_string),
4288            link: None,
4289            fields: Vec::new(),
4290            route: None,
4291        }
4292    }
4293
4294    #[test]
4295    fn settings_visibility_respects_permissions() {
4296        let items = vec![
4297            settings_item("public", None),
4298            settings_item("gated", Some("backend.manage_users")),
4299        ];
4300
4301        // An operator without the grant sees only the unpermissioned item.
4302        let none = PermissionSet::new(false, Vec::<String>::new());
4303        let codes: Vec<String> = visible_settings(&items, &none)
4304            .into_iter()
4305            .map(|i| i.code)
4306            .collect();
4307        assert_eq!(codes, ["public"]);
4308
4309        // Holding the permission reveals the gated item.
4310        let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
4311        assert_eq!(visible_settings(&items, &granted).len(), 2);
4312
4313        // A superuser sees everything.
4314        let superuser = PermissionSet::new(true, Vec::<String>::new());
4315        assert_eq!(visible_settings(&items, &superuser).len(), 2);
4316    }
4317
4318    #[test]
4319    fn context_sidebar_follows_settings_links() {
4320        let items = builtin_settings();
4321        let superuser = PermissionSet::new(true, Vec::<String>::new());
4322        let tr = Translator::new("en");
4323        let sidebar = |path: &str| {
4324            resolve_nav_context(
4325                &[],
4326                &items,
4327                "/admin",
4328                &superuser,
4329                path,
4330                &tr,
4331                icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
4332            )
4333            .0
4334        };
4335        let active_path = |path: &str| -> Option<String> {
4336            sidebar(path)
4337                .into_iter()
4338                .flat_map(|g| g.items)
4339                .find(|i| i.active)
4340                .map(|i| i.path)
4341        };
4342
4343        // A linked resource, and its sub-pages, resolve to that item as active.
4344        assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
4345        assert_eq!(
4346            active_path("/admin/roles/42/edit").as_deref(),
4347            Some("/admin/roles")
4348        );
4349        // The settings index shows the sidebar, with nothing active.
4350        assert!(!sidebar("/admin/settings").is_empty());
4351        assert_eq!(active_path("/admin/settings"), None);
4352        // A settings screen of the item's own activates that item. Branding is
4353        // the framework's one form-backed setting; unresolved in this test, it
4354        // falls back to a path built from its code's segments, which is a URL
4355        // rather than the dotted storage key it used to be.
4356        assert_eq!(
4357            active_path("/admin/settings/branding").as_deref(),
4358            Some("/admin/settings/branding")
4359        );
4360        // The dotted storage key is not a path at all any more, and neither is
4361        // the vendor-qualified form: the panel's own settings carry no vendor.
4362        assert_eq!(active_path("/admin/settings/laterite.brand"), None);
4363        assert_eq!(active_path("/admin/settings/laterite/brand"), None);
4364        // The dashboard is not a settings context, so it has no sidebar.
4365        assert!(sidebar("/admin").is_empty());
4366    }
4367
4368    #[test]
4369    fn active_nav_lights_the_right_tab() {
4370        let nav = vec![
4371            NavLink {
4372                label: "Dashboard".into(),
4373                path: "/admin".to_string(),
4374                icon: Some("layout-dashboard"),
4375            },
4376            NavLink {
4377                label: "Pages".into(),
4378                path: "/admin/pages".to_string(),
4379                icon: None,
4380            },
4381            NavLink {
4382                label: "Settings".into(),
4383                path: "/admin/settings".to_string(),
4384                icon: Some("settings"),
4385            },
4386        ];
4387
4388        // Dashboard lights only on an exact match, never as a prefix of deeper paths.
4389        assert_eq!(
4390            active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
4391            Some("/admin")
4392        );
4393        // A section keeps its own tab active across its sub-pages.
4394        assert_eq!(
4395            active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
4396            Some("/admin/pages")
4397        );
4398        // A sibling section that merely shares a prefix does not steal the tab.
4399        assert_eq!(
4400            active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
4401            None
4402        );
4403        // A screen under no section (reached from the user menu) lights nothing,
4404        // rather than the root falling back to Dashboard.
4405        assert_eq!(
4406            active_nav_path(&nav, "/admin", false, "/admin/preferences"),
4407            None
4408        );
4409        // The settings context lights Settings, including for a linked resource
4410        // whose path lives outside /admin/settings.
4411        assert_eq!(
4412            active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
4413            Some("/admin/settings")
4414        );
4415        assert_eq!(
4416            active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
4417            Some("/admin/settings")
4418        );
4419    }
4420}
4421
4422#[cfg(test)]
4423mod session_cookie_tests {
4424    use super::*;
4425
4426    /// The session cookie always dies with the browser. Persistence belongs to
4427    /// the remember credential, which can be rotated and revoked on its own; a
4428    /// long-lived session cookie could be neither.
4429    #[test]
4430    fn the_session_cookie_never_outlives_the_browser() {
4431        let cookie = session_cookie("tok".into(), "/admin", false);
4432        assert_eq!(cookie.max_age(), None);
4433        assert!(cookie.http_only().unwrap_or(false));
4434        assert_eq!(cookie.path(), Some("/admin"));
4435    }
4436
4437    /// "Stay signed in" is what survives a closed browser, and it carries its
4438    /// own lifetime rather than borrowing the session's.
4439    #[test]
4440    fn the_remember_cookie_carries_its_own_lifetime() {
4441        let credential = laterite_auth::RememberCredential {
4442            cookie: "sel:ver".into(),
4443            expires_at: chrono::Utc::now() + chrono::Duration::days(14),
4444        };
4445        let cookie = remember_cookie(credential, "/admin", true);
4446        let age = cookie.max_age().expect("a remember cookie must persist");
4447        // Within a second of fourteen days, allowing for the clock read.
4448        assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
4449        assert!(cookie.http_only().unwrap_or(false));
4450        assert!(cookie.secure().unwrap_or(false));
4451    }
4452
4453    /// Clearing has to match on path or the browser keeps the original.
4454    #[test]
4455    fn the_removal_matches_the_cookie_it_clears() {
4456        let removal = remember_removal("/admin");
4457        assert_eq!(removal.name(), REMEMBER_COOKIE);
4458        assert_eq!(removal.path(), Some("/admin"));
4459    }
4460}