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