Skip to main content

laterite_admin/
lib.rs

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