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