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