Skip to main content

laterite_admin/
lib.rs

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