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