Skip to main content

laterite_admin/
lib.rs

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