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