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