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