Skip to main content

laterite_admin/
lib.rs

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