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