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