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