Skip to main content

laterite_admin/
lib.rs

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