Skip to main content

laterite_admin/
lib.rs

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