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