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 error;
20pub mod field;
21pub mod form;
22pub mod html;
23mod icons;
24pub mod list;
25pub mod persist;
26pub mod picker;
27pub mod plugins;
28mod roles;
29mod session;
30pub mod settings;
31mod sql;
32mod users;
33
34pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
35pub use error::AdminError;
36pub use session::{FlashLevel, SessionHandle};
37
38use std::collections::HashMap;
39use std::sync::{Arc, RwLock};
40
41use askama::Template;
42use axum::body::Body;
43use axum::extract::{Path, Query, Request, State};
44use axum::http::{header, HeaderMap};
45use axum::middleware::{self, Next};
46use axum::response::{Html, IntoResponse, Redirect, Response};
47use axum::routing::{any, get, post};
48use axum::{Extension, Form, Router};
49use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
50use chrono_tz::{Tz, TZ_VARIANTS};
51use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
52use laterite_core::{t, CatalogStore, Db, Text, Translator};
53use serde::{Deserialize, Serialize};
54
55/// Typed contribution channels for the framework's admin surfaces, as an
56/// extension trait over the generic [`laterite_core::Registry`]. A module
57/// contributes its admin screens, permissions, and settings from its `register`;
58/// a wrong-type contribution is a compile error, not one silently ignored. The
59/// generic `add`/`items` underneath remains for open, plugin-defined points.
60pub trait AdminRegistry {
61    /// Adds a list/form resource (an admin screen).
62    fn add_resource(&mut self, resource: Resource);
63    /// Adds a permission, offered in the role editor.
64    fn add_permission(&mut self, permission: Permission);
65    /// Adds a settings model.
66    fn add_settings(&mut self, item: settings::SettingsItem);
67    /// Adds a reference-picker source, callable by a picker field's endpoints.
68    fn add_picker_source(&mut self, source: picker::PickerSourceReg);
69    /// Adds a form persister, selectable by a form descriptor's `persist` key.
70    fn add_persister(&mut self, persister: persist::PersisterReg);
71}
72
73impl AdminRegistry for laterite_core::Registry {
74    fn add_resource(&mut self, resource: Resource) {
75        self.add(resource);
76    }
77    fn add_permission(&mut self, permission: Permission) {
78        self.add(permission);
79    }
80    fn add_settings(&mut self, item: settings::SettingsItem) {
81        self.add(item);
82    }
83    fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
84        self.add(source);
85    }
86    fn add_persister(&mut self, persister: persist::PersisterReg) {
87        self.add(persister);
88    }
89}
90
91const SESSION_COOKIE: &str = "laterite_session";
92
93/// Shared state for the admin router. Constructed by [`router`].
94#[derive(Clone)]
95pub(crate) struct AdminState {
96    auth: AuthService,
97    db: Db,
98    nav: Arc<Vec<NavLink>>,
99    settings: Arc<Vec<settings::SettingsItem>>,
100    permissions: Arc<Vec<Permission>>,
101    /// The URL path the panel is mounted under, without a trailing slash (e.g.
102    /// `/admin`). Every route, redirect, link, and the session-cookie scope is
103    /// built from it, so one config value moves the whole panel.
104    admin_path: Arc<str>,
105    secure_cookie: bool,
106    /// The public origin for the CSRF origin check (see [`AdminConfig::origin`]).
107    origin: Arc<str>,
108    timezone: Tz,
109    /// The deployment default UI locale (a base tag like `en`), the last stop in a
110    /// request's locale chain before the English source. An operator's own
111    /// preference overrides it.
112    default_locale: Arc<str>,
113    /// The shared message catalogs, resolved once at boot and looked up per request
114    /// through the [`Translator`]. Empty until module catalogs are loaded.
115    catalogs: Arc<CatalogStore>,
116    /// The configured application name (the baseline brand). A brand setting
117    /// overrides it; see [`AdminState::brand`].
118    app_name: String,
119    /// The resolved brand name, cached across requests so the brand setting is
120    /// not read from the database on every page. Invalidated when the setting is
121    /// saved. `None` means "not resolved yet".
122    brand_cache: Arc<RwLock<Option<String>>>,
123    /// The field-type registry (built-ins plus contributions): resolves a form
124    /// descriptor's type key to its rendering + behaviour. See [`field`].
125    field_types: Arc<field::FieldRegistry>,
126    /// The column-type registry: resolves a list column's type key to its cell
127    /// rendering. Sibling to `field_types`; shares the override resolver.
128    column_types: Arc<list::ColumnRegistry>,
129    /// Embedded admin assets served under `{admin}/assets/` (see [`AdminAsset`]).
130    assets: Arc<AssetRegistry>,
131    /// The reference-picker source registry: resolves a picker field's `source`
132    /// key to the domain service its endpoints call. See [`picker`].
133    pickers: Arc<picker::PickerRegistry>,
134    /// The admin view-override resolver, default [`field::NoOverrides`] (the
135    /// compiled path). A theme layer injects a runtime-template-backed resolver
136    /// so users can override a field's presentation from outside the plugin.
137    overrides: Arc<dyn field::OverrideResolver>,
138}
139
140impl AdminState {
141    #[cfg(test)]
142    pub(crate) fn new(auth: AuthService, db: Db) -> Self {
143        Self {
144            auth,
145            db,
146            nav: Arc::new(Vec::new()),
147            settings: Arc::new(Vec::new()),
148            permissions: Arc::new(builtin_permissions()),
149            admin_path: Arc::from("/admin"),
150            secure_cookie: false,
151            origin: Arc::from(""),
152            timezone: Tz::UTC,
153            default_locale: Arc::from("en"),
154            catalogs: Arc::new(CatalogStore::default()),
155            app_name: "Laterite".to_string(),
156            brand_cache: Arc::new(RwLock::new(None)),
157            field_types: Arc::new(field::builtin_registry()),
158            column_types: Arc::new(list::builtin_column_registry()),
159            assets: Arc::new(builtin_assets()),
160            pickers: Arc::new(picker::PickerRegistry::new()),
161            overrides: Arc::new(field::NoOverrides),
162        }
163    }
164
165    /// The brand name shown across the admin: the [`settings::BrandSetting`]
166    /// `app_name` when set, otherwise the configured application name. The
167    /// resolved value is cached until [`AdminState::invalidate_brand`] clears it.
168    async fn brand(&self) -> String {
169        {
170            let cached = self.brand_cache.read().unwrap().clone();
171            if let Some(name) = cached {
172                return name;
173            }
174        }
175        let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
176            Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
177            _ => self.app_name.clone(),
178        };
179        *self.brand_cache.write().unwrap() = Some(resolved.clone());
180        resolved
181    }
182
183    /// Clears the cached brand so the next resolution re-reads the setting.
184    fn invalidate_brand(&self) {
185        *self.brand_cache.write().unwrap() = None;
186    }
187}
188
189/// Deployment-level admin settings passed to [`router`]. Per-install brand and
190/// per-operator preferences are settings/preferences, not deployment config.
191///
192/// Non-exhaustive: build one with [`Default`] and set fields, so a field added
193/// in a later release is additive.
194#[derive(Clone)]
195#[non_exhaustive]
196pub struct AdminConfig {
197    /// Set the `Secure` attribute on the session cookie. Enable behind HTTPS in
198    /// production; leave off for plain-HTTP local development.
199    pub secure_cookie: bool,
200    /// Default display timezone for the admin (an IANA name like `Asia/Kolkata`).
201    /// Storage is UTC; this only affects rendering. Invalid or empty falls back
202    /// to UTC. An operator's own preference overrides it (later).
203    pub timezone: String,
204    /// Default UI locale for the admin (a base tag like `en` or `de`). An
205    /// operator's own preference and the request's `Accept-Language` override it;
206    /// a locale with no loaded catalog, or an empty value, falls back to `en`.
207    pub locale: String,
208    /// The application name, shown as the admin brand. This is the baseline; a
209    /// `BrandSetting` in the admin overrides it. Typically the configured
210    /// `app.name`. Empty falls back to `Laterite`.
211    pub app_name: String,
212    /// The URL path the panel is mounted under (typically the configured
213    /// `backend.path`). A leading slash is added if missing and a trailing slash
214    /// is stripped; empty falls back to `/admin`.
215    pub path: String,
216    /// The public origin (scheme://host[:port], no trailing slash) the panel is
217    /// served from, for the CSRF origin check. Typically the configured
218    /// `app.url`; empty falls back to the request `Host` (dev only), so set it in
219    /// production.
220    pub origin: String,
221}
222
223impl Default for AdminConfig {
224    fn default() -> Self {
225        Self {
226            secure_cookie: false,
227            timezone: "UTC".to_string(),
228            locale: "en".to_string(),
229            app_name: "Laterite".to_string(),
230            path: "/admin".to_string(),
231            origin: String::new(),
232        }
233    }
234}
235
236#[derive(Clone)]
237struct NavLink {
238    label: Text,
239    path: String,
240    /// An icon name (a Lucide subset, see [`icons`]), or `None` for a text-only
241    /// tab. The built-in Dashboard and Settings entries set one.
242    icon: Option<&'static str>,
243}
244
245/// The chrome shared by every authenticated page: the top-nav links and the
246/// signed-in operator. Built once by the auth guard and injected into request
247/// extensions, so page handlers render inside the same shell without each
248/// rebuilding it. Templates embed it as `shell` and `base.html` renders it.
249#[derive(Clone)]
250pub(crate) struct Shell {
251    /// The admin mount path (e.g. `/admin`), so shared chrome (asset links, the
252    /// brand link, sign-out and preferences) builds URLs under the configured
253    /// panel path rather than a hardcoded `/admin`.
254    pub(crate) base: String,
255    /// The brand name shown in the top nav and drawer, resolved once per request
256    /// (the brand setting, or the configured application name). See
257    /// [`AdminState::brand`].
258    brand: String,
259    nav: Vec<NavView>,
260    full_name: String,
261    initial: String,
262    /// The timezone this operator's timestamps render in, resolved once per
263    /// request: the operator's own preference if set and valid, else the
264    /// deployment default. List and detail screens format dates in it.
265    tz: Tz,
266    /// The translator for this request's locale (resolved like `tz`), used by
267    /// `shell.t` / `shell.tt` in templates. A source with no catalog entry falls
268    /// back to itself.
269    i18n: Translator,
270    /// The context sidebar for the current section, resolved once per request
271    /// from the path (see [`resolve_nav_context`]). Empty means no sidebar.
272    /// `base.html` renders it, so any screen in a settings context shows it.
273    sidebar: Vec<settings::CategoryView>,
274    /// The current session's CSRF token, auto-injected into every rendered form
275    /// (a hidden field) and into HTMX requests (a header), so a mutating request
276    /// carries it without the handler doing anything. See [`session`].
277    pub(crate) csrf_token: String,
278    /// Flash messages to show once on this render, taken from the session on a
279    /// full-page GET (redirect-after-POST delivers them here) and localized in this
280    /// request's locale. See [`session`].
281    pub(crate) flash: Vec<FlashLine>,
282    /// Per-page widget assets (field/column scripts and styles) for this render,
283    /// deduped and emitted in the head after core `laterite.js`. See
284    /// [`page_assets`].
285    pub(crate) assets: Vec<PageAsset>,
286}
287
288/// One per-page asset to load: a resolved URL and whether it is a stylesheet.
289#[derive(Clone)]
290pub(crate) struct PageAsset {
291    pub(crate) url: String,
292    pub(crate) css: bool,
293}
294
295/// A flash message localized for this render: the session's stored `Text` resolved
296/// to a display string in the operator's locale. `base.html` renders it.
297#[derive(Clone)]
298pub(crate) struct FlashLine {
299    pub(crate) level: session::FlashLevel,
300    pub(crate) text: String,
301}
302
303impl Shell {
304    #[allow(clippy::too_many_arguments)]
305    fn new(
306        base: &str,
307        brand: String,
308        nav: &[NavLink],
309        user: &AuthenticatedUser,
310        default_tz: Tz,
311        sidebar: Vec<settings::CategoryView>,
312        active_nav: Option<&str>,
313        csrf_token: String,
314        flash: Vec<session::Flash>,
315        i18n: Translator,
316    ) -> Self {
317        let full_name = user.user.full_name();
318        let initial = full_name
319            .chars()
320            .next()
321            .map(|c| c.to_uppercase().to_string())
322            .unwrap_or_else(|| "?".to_string());
323        // Localize the flashes and nav labels here, where the request translator is
324        // in hand, before it is moved into the shell.
325        let flash = flash
326            .into_iter()
327            .map(|f| FlashLine {
328                level: f.level,
329                text: i18n.t(&f.text),
330            })
331            .collect();
332        let nav = nav
333            .iter()
334            .map(|n| NavView {
335                label: i18n.t(&n.label),
336                path: n.path.clone(),
337                active: active_nav == Some(n.path.as_str()),
338                icon: n.icon.map(|name| icons::svg(Some(name))).unwrap_or(""),
339            })
340            .collect();
341        Shell {
342            base: base.to_string(),
343            brand,
344            nav,
345            full_name,
346            initial,
347            tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
348            i18n,
349            sidebar,
350            csrf_token,
351            flash,
352            assets: Vec::new(),
353        }
354    }
355
356    /// Localizes a source string in this request's locale, falling back to the
357    /// source itself. Templates call `{{ shell.t("Save") }}`.
358    pub(crate) fn t(&self, source: &str) -> String {
359        self.i18n.t(&Text::dynamic(source))
360    }
361
362    /// Localizes a prebuilt message (a `t!`/`tn!` value, a descriptor label, a
363    /// flash) into this request's locale.
364    pub(crate) fn tt(&self, text: &Text) -> String {
365        self.i18n.t(text)
366    }
367
368    /// The active locale (most specific in the chain), for `<html lang>`.
369    pub(crate) fn locale(&self) -> &str {
370        self.i18n.locale()
371    }
372
373    /// Localizes a source string with integer `{name}` arguments, for a template
374    /// (`{{ shell.tf("Page {n} of {m}", [("n", page), ("m", pages)]) }}`). Nested-`Text`
375    /// arguments are built in Rust with `t!` and rendered with `tt`.
376    pub(crate) fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
377        let mut text = Text::dynamic(source);
378        for (name, value) in args {
379            text = text.arg(*name, *value);
380        }
381        self.i18n.t(&text)
382    }
383
384    /// Like [`Shell::tf`], but with string `{name}` arguments, for a template
385    /// (`{{ shell.tfs("Signed in as {name}", [("name", user)]) }}`).
386    pub(crate) fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
387        let mut text = Text::dynamic(source);
388        for (name, value) in args {
389            text = text.arg(*name, *value);
390        }
391        self.i18n.t(&text)
392    }
393
394    #[cfg(test)]
395    pub(crate) fn test() -> Self {
396        Shell {
397            base: "/admin".to_string(),
398            brand: "Laterite".to_string(),
399            nav: Vec::new(),
400            full_name: "Test Operator".to_string(),
401            initial: "T".to_string(),
402            tz: Tz::UTC,
403            i18n: Translator::new("en"),
404            sidebar: Vec::new(),
405            csrf_token: "test-csrf-token".to_string(),
406            flash: Vec::new(),
407            assets: Vec::new(),
408        }
409    }
410}
411
412/// Whether `path` sits in the settings context, and if so which item code is
413/// active. A screen is in the settings context when it is the settings index or
414/// a settings form, or when its path falls under a settings item's `link` (its
415/// list, forms and sub-pages). The matching item is returned so the sidebar can
416/// highlight it. `visible` is the operator's permitted items, so a linked
417/// resource they cannot see never claims the context.
418fn settings_context(
419    visible: &[settings::SettingsItem],
420    admin_path: &str,
421    path: &str,
422) -> (bool, Option<String>) {
423    let settings_root = format!("{admin_path}/settings");
424    if path == settings_root {
425        (true, None)
426    } else if let Some(code) = path.strip_prefix(&format!("{settings_root}/")) {
427        (true, Some(code.to_string()))
428    } else {
429        // A linked resource: the settings item whose resolved link is the longest
430        // prefix of this path owns the context (so /admin/roles/5/edit resolves).
431        // Links are stored relative to the admin root, so resolve each here.
432        let active = visible
433            .iter()
434            .filter_map(|i| {
435                i.link
436                    .as_deref()
437                    .map(|link| (format!("{admin_path}{link}"), &i.code))
438            })
439            .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
440            .max_by_key(|(link, _)| link.len())
441            .map(|(_, code)| code.clone());
442        (active.is_some(), active)
443    }
444}
445
446/// The top-nav item to highlight for `path`. A screen in the settings context
447/// lights the Settings tab (so a linked resource such as the users list keeps
448/// Settings active); otherwise a section owns its own path subtree and stays
449/// active across its sub-pages, with the longest matching prefix winning. The
450/// `/admin` root is every path's ancestor, so it lights the Dashboard tab only
451/// on an exact match: a screen belonging to no section (Preferences, say) lights
452/// nothing rather than falling back to Dashboard.
453fn active_nav_path(
454    nav: &[NavLink],
455    admin_path: &str,
456    in_settings_context: bool,
457    path: &str,
458) -> Option<String> {
459    if in_settings_context {
460        return Some(format!("{admin_path}/settings"));
461    }
462    nav.iter()
463        .filter(|n| {
464            path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
465        })
466        .max_by_key(|n| n.path.len())
467        .map(|n| n.path.clone())
468}
469
470/// Resolves the per-request navigation context from the descriptors: the
471/// settings sidebar (empty when the screen sits outside any settings context)
472/// and the top-nav item to highlight. The auth guard runs this once per request
473/// and hands both to the [`Shell`].
474fn resolve_nav_context(
475    nav: &[NavLink],
476    items: &[settings::SettingsItem],
477    admin_path: &str,
478    perms: &PermissionSet,
479    path: &str,
480    tr: &Translator,
481) -> (Vec<settings::CategoryView>, Option<String>) {
482    let visible = visible_settings(items, perms);
483    let (in_context, active) = settings_context(&visible, admin_path, path);
484    let sidebar = if in_context {
485        settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr)
486    } else {
487        Vec::new()
488    };
489    let active_nav = active_nav_path(nav, admin_path, in_context, path);
490    (sidebar, active_nav)
491}
492
493/// Resolves the timezone an operator's timestamps render in: their own
494/// preference when it is set and a valid IANA name, otherwise the deployment
495/// default. An unparseable stored value falls back rather than erroring.
496fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
497    preference
498        .and_then(|name| name.parse::<Tz>().ok())
499        .unwrap_or(default_tz)
500}
501
502/// Whether a timezone is offered in the picker's zone list.
503fn zone_offered(name: &str) -> bool {
504    !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
505}
506
507/// The base language of a tag (`kn-IN` -> `kn`), lowercased. Catalogs are keyed by
508/// base language.
509fn base_lang(tag: &str) -> String {
510    tag.split(['-', '_'])
511        .next()
512        .unwrap_or(tag)
513        .to_ascii_lowercase()
514}
515
516/// Whether resolution can serve `base`: English (the source, always reachable), a
517/// locale with a loaded catalog, or the QA pseudo-locale (selectable via config or
518/// `Accept-Language`, though never shown in the picker). The serveable set is the
519/// deployment's loaded catalogs, so shipping a `de.po` makes `de` resolve with no
520/// code change.
521fn is_serveable(base: &str, serveable: &[String]) -> bool {
522    base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
523}
524
525/// The deployment default locale from config, normalized to a base tag the running
526/// catalogs can serve, else `en`.
527fn default_locale(configured: &str, serveable: &[String]) -> String {
528    let base = base_lang(configured);
529    if is_serveable(&base, serveable) {
530        base
531    } else {
532        "en".to_string()
533    }
534}
535
536/// The locales the language picker offers: English (the source, always) then every
537/// locale with a loaded catalog, sorted. The pseudo-locale is resolvable but never
538/// offered here.
539fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
540    let mut out = vec!["en".to_string()];
541    out.extend(catalogs.locales());
542    out
543}
544
545/// A locale's display name for the picker. The framework names only its own source
546/// language (English); every other locale shows by its tag. A locale's own name
547/// (endonym) is data that belongs with the catalog that provides the locale, not a
548/// table of specific languages the framework would otherwise have to hold for all.
549fn locale_name(code: &str) -> String {
550    if code == "en" {
551        "English".to_string()
552    } else {
553        code.to_string()
554    }
555}
556
557/// The tags in an `Accept-Language` header, most-preferred first. Each entry's
558/// optional `;q=` weight orders them (absent means `1.0`); `*` is dropped.
559fn parse_accept_language(header: &str) -> Vec<String> {
560    let mut items: Vec<(f32, String)> = header
561        .split(',')
562        .filter_map(|part| {
563            let mut bits = part.split(';');
564            let tag = bits.next()?.trim();
565            if tag.is_empty() || tag == "*" {
566                return None;
567            }
568            let q = bits
569                .find_map(|p| p.trim().strip_prefix("q="))
570                .and_then(|q| q.parse::<f32>().ok())
571                .unwrap_or(1.0);
572            Some((q, tag.to_string()))
573        })
574        .collect();
575    // Stable sort by weight descending, so equal weights keep header order.
576    items.sort_by(|a, b| b.0.total_cmp(&a.0));
577    items.into_iter().map(|(_, tag)| tag).collect()
578}
579
580/// Builds this request's locale fallback chain, most specific first and always
581/// ending at `en`. Candidates, in order: the operator's stored locale, the
582/// `Accept-Language` tags, the deployment default. Each is normalized to a base
583/// language the running catalogs can serve; unserveable tags are skipped and none
584/// repeats.
585fn resolve_locale_chain(
586    preference: Option<&str>,
587    accept_language: Option<&str>,
588    default: &str,
589    serveable: &[String],
590) -> Vec<String> {
591    let mut chain: Vec<String> = Vec::new();
592    let consider = |tag: &str, chain: &mut Vec<String>| {
593        let base = base_lang(tag);
594        if is_serveable(&base, serveable) && !chain.contains(&base) {
595            chain.push(base);
596        }
597    };
598    if let Some(pref) = preference {
599        consider(pref, &mut chain);
600    }
601    if let Some(header) = accept_language {
602        for tag in parse_accept_language(header) {
603            consider(&tag, &mut chain);
604        }
605    }
606    consider(default, &mut chain);
607    // Guarantee the source language is reachable, appended only if not already
608    // signaled earlier (so an explicit en preference keeps its position).
609    consider("en", &mut chain);
610    chain
611}
612
613/// A translator for a pre-auth screen (login, setup): no operator preference exists
614/// yet, so the chain is the request's `Accept-Language`, then the deployment default,
615/// then `en`, over the shared catalogs.
616fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
617    let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
618    let serveable = state.catalogs.locales();
619    let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
620    Translator::with_chain(chain, state.catalogs.clone())
621}
622
623/// An admin resource: a list screen, optionally with a create/edit form, mounted
624/// under `base_path` and shown in the menu as `nav_label`.
625#[derive(Serialize)]
626pub struct Resource {
627    /// The path the resource mounts at, relative to the admin root and starting
628    /// with a slash (e.g. `/products`). The framework prepends the configured
629    /// admin mount, so a resource never hardcodes it and survives a moved panel.
630    /// The list's `edit_base` and the form's `base_path` are resolved the same
631    /// way.
632    pub base_path: String,
633    /// The menu label, localized at render. Serde stays a plain string.
634    pub nav_label: Text,
635    pub list: list::ListConfig,
636    pub form: Option<form::FormConfig>,
637    /// The permission an operator must hold to reach any of the resource's
638    /// routes. A dotted string gates every route the resource mounts, and an
639    /// operator who lacks it receives `403 Forbidden`; superusers pass regardless.
640    /// `None` leaves the resource open to any signed-in operator and is allowed
641    /// only for a read-only resource: a resource with a create/edit `form` must
642    /// declare a permission, or boot aborts (see [`mount_resource`]).
643    pub permission: Option<String>,
644}
645
646/// A permission an operator can be granted: a dotted `code`, a human `label`,
647/// and a `group` heading it sorts under in the role editor. The framework
648/// registers its own (see the built-in grants), and an application registers
649/// its permissions through [`router`] so they appear in the editor alongside.
650#[derive(Clone, Serialize)]
651pub struct Permission {
652    pub code: String,
653    /// The permission's display label and group heading, localized at render.
654    pub label: Text,
655    pub group: Text,
656}
657
658/// The framework's own permissions, offered in the role editor under a
659/// "Backend" group. These gate the built-in Users and Roles screens.
660fn builtin_permissions() -> Vec<Permission> {
661    vec![
662        Permission {
663            code: "backend.manage_users".to_string(),
664            label: "Manage backend users".into(),
665            group: "Backend".into(),
666        },
667        Permission {
668            code: "backend.manage_roles".to_string(),
669            label: "Manage roles".into(),
670            group: "Backend".into(),
671        },
672        Permission {
673            code: "backend.manage_branding".to_string(),
674            label: "Manage branding".into(),
675            group: "Backend".into(),
676        },
677        Permission {
678            code: plugins::MANAGE_PERMISSION.to_string(),
679            label: "Manage plugins".into(),
680            group: "Backend".into(),
681        },
682        Permission {
683            code: "backend.view_audit_log".to_string(),
684            label: "View the audit log".into(),
685            group: "Backend".into(),
686        },
687    ]
688}
689
690/// Every source string in the framework's built-in admin descriptors: resource nav
691/// labels and their list titles and column headers, settings items and their fields,
692/// and permission labels and groups. `lat i18n extract` folds these into the admin
693/// catalog. Collected through the serde source walk, so it never drifts as new label
694/// fields are added.
695pub fn descriptor_sources() -> Vec<String> {
696    let mut out = Vec::new();
697    out.extend(laterite_core::collect_sources(&builtin_resources()));
698    out.extend(laterite_core::collect_sources(&builtin_settings()));
699    out.extend(laterite_core::collect_sources(&builtin_permissions()));
700    out.sort();
701    out.dedup();
702    out
703}
704
705/// The migration sets for every module the admin mounts: the auth schema
706/// (users, roles, sessions, access log) and the settings store. Run these
707/// before serving [`router`] so its built-in screens have their tables, so an
708/// application never has to know which framework modules the admin pulls in.
709///
710/// An application with its own modules appends their sets:
711///
712/// ```no_run
713/// # async fn f(db: laterite_core::Db) -> Result<(), Box<dyn std::error::Error>> {
714/// let mut migrations = laterite_admin::builtin_migrations();
715/// // migrations.extend([my_module::migrations()]);
716/// laterite_core::migration::run(&db.pool, db.backend, &migrations).await?;
717/// # Ok(()) }
718/// ```
719pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
720    builtin_modules().iter().map(|m| m.migrations()).collect()
721}
722
723/// The framework's built-in modules, in registration order. `Bootstrap`
724/// registers these before an app's own modules, so the admin's tables migrate
725/// first.
726pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
727    vec![
728        Box::new(laterite_auth::AuthModule),
729        Box::new(settings::SettingsModule),
730        Box::new(plugins::PluginsModule),
731    ]
732}
733
734/// Builds the admin router. `app_resources` are the application's own list/form
735/// screens; `app_settings` are its settings models; `app_permissions` are the
736/// permissions it defines, offered in the role editor alongside the framework's.
737/// All are mounted alongside the framework's built-in equivalents.
738// The admin entry point collects each contribution kind as its own vec; it has
739// grown past clippy's arg ceiling and will keep growing as registries are added
740// (a `Contributions` bundle is the eventual tidy-up).
741#[allow(clippy::too_many_arguments)]
742pub fn router(
743    auth: AuthService,
744    db: Db,
745    app_resources: Vec<Resource>,
746    app_settings: Vec<settings::SettingsItem>,
747    app_permissions: Vec<Permission>,
748    app_picker_sources: Vec<picker::PickerSourceReg>,
749    app_persisters: Vec<persist::PersisterReg>,
750    config: AdminConfig,
751    catalogs: Arc<CatalogStore>,
752) -> Router {
753    let admin_path = normalize_path(&config.path);
754
755    let mut resources = builtin_resources();
756    let mut app_resources = app_resources;
757    // Descriptor paths are authored relative to the admin root; resolve them to
758    // full paths under the configured mount so routes, the menu, and every link
759    // built from them agree.
760    for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
761        prefix_resource(&admin_path, resource);
762    }
763
764    let mut settings = builtin_settings();
765    settings.extend(app_settings);
766    let mut permissions = builtin_permissions();
767    permissions.extend(app_permissions);
768
769    // Main menu (top nav): Dashboard, the application's own sections, then
770    // Settings. Built-in Users and Roles are settings items (see the settings
771    // menu), not main-menu tabs.
772    let mut nav = vec![NavLink {
773        label: "Dashboard".into(),
774        path: admin_path.clone(),
775        icon: Some("layout-dashboard"),
776    }];
777    for resource in &app_resources {
778        nav.push(NavLink {
779            label: resource.nav_label.clone(),
780            path: resource.base_path.clone(),
781            icon: None,
782        });
783    }
784    nav.push(NavLink {
785        label: "Settings".into(),
786        path: format!("{admin_path}/settings"),
787        icon: Some("settings"),
788    });
789    resources.extend(app_resources);
790
791    let app_name = if config.app_name.trim().is_empty() {
792        "Laterite".to_string()
793    } else {
794        config.app_name.clone()
795    };
796
797    // The picker-source registry, keyed by dotted name. A bad name or a duplicate
798    // is a wiring bug, so it aborts boot.
799    let mut pickers = picker::PickerRegistry::new();
800    for reg in app_picker_sources {
801        let name = reg.name.clone();
802        if !field::is_name(&name, true) {
803            panic!("invalid picker source name `{name}`");
804        }
805        if pickers.insert(name.clone(), reg).is_some() {
806            panic!("duplicate picker source `{name}`");
807        }
808    }
809
810    // The reference field validates its `source` against the picker registry at
811    // boot, so build the field registry here (over the arg-free built-ins) with
812    // the picker field added and the registry injected.
813    let pickers = Arc::new(pickers);
814    let mut field_types = field::builtin_registry();
815    field_types.insert(
816        "reference".to_string(),
817        Arc::new(field::RefPickerField::new(pickers.clone())),
818    );
819
820    // The form-persister registry: a bad or duplicate name is a wiring bug, so it
821    // aborts boot. It is not on AdminState; `prepare` resolves it per form.
822    let mut persisters = persist::PersisterRegistry::new();
823    for reg in app_persisters {
824        let name = reg.name.clone();
825        if !field::is_name(&name, true) {
826            panic!("invalid persister name `{name}`");
827        }
828        if persisters.insert(name.clone(), reg.persister).is_some() {
829            panic!("duplicate persister `{name}`");
830        }
831    }
832
833    let state = AdminState {
834        auth,
835        db,
836        nav: Arc::new(nav),
837        settings: Arc::new(settings),
838        permissions: Arc::new(permissions),
839        admin_path: Arc::from(admin_path.as_str()),
840        secure_cookie: config.secure_cookie,
841        origin: Arc::from(config.origin.trim_end_matches('/')),
842        timezone: config.timezone.parse().unwrap_or(Tz::UTC),
843        default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
844        catalogs,
845        app_name,
846        brand_cache: Arc::new(RwLock::new(None)),
847        field_types: Arc::new(field_types),
848        column_types: Arc::new(list::builtin_column_registry()),
849        assets: Arc::new(builtin_assets()),
850        pickers,
851        overrides: Arc::new(field::NoOverrides),
852    };
853
854    let mut protected = Router::new().route(&admin_path, get(dashboard));
855    for resource in &resources {
856        protected = protected.merge(mount_resource(resource, &state.field_types, &persisters));
857    }
858    // The roles screen has a dedicated create/edit form (the permission editor),
859    // gated by the same permission as its list.
860    protected = protected.merge(guard_with_permission(
861        Router::new()
862            .route(
863                &format!("{admin_path}/roles/new"),
864                get(roles::new_form).post(roles::create),
865            )
866            .route(
867                &format!("{admin_path}/roles/{{id}}/edit"),
868                get(roles::edit_form).post(roles::update),
869            ),
870        "backend.manage_roles",
871    ));
872    // The backend users screen edits a user's per-permission overrides, gated by
873    // the same permission as its list.
874    protected = protected.merge(guard_with_permission(
875        Router::new().route(
876            &format!("{admin_path}/users/{{id}}/edit"),
877            get(users::edit_form).post(users::update),
878        ),
879        "backend.manage_users",
880    ));
881    // The plugins screen lists the installed plugins and toggles each on or off
882    // (an intent applied on the next boot), gated by its own permission.
883    protected = protected.merge(guard_with_permission(
884        Router::new()
885            .route(&format!("{admin_path}/plugins"), get(plugins::index))
886            .route(
887                &format!("{admin_path}/plugins/toggle"),
888                post(plugins::toggle),
889            ),
890        plugins::MANAGE_PERMISSION,
891    ));
892    protected = protected
893        .route(&format!("{admin_path}/settings"), get(settings_index))
894        .route(
895            &format!("{admin_path}/settings/{{code}}"),
896            get(settings_edit).post(settings_update),
897        )
898        .route(
899            &format!("{admin_path}/preferences"),
900            get(preferences_form).post(preferences_update),
901        )
902        // Picker endpoints, served with the QUERY method (a read; the guard in the
903        // handler answers 405 for any other method).
904        .route(
905            &format!("{admin_path}/pickers/{{source}}/search"),
906            any(picker::search),
907        )
908        .route(
909            &format!("{admin_path}/pickers/{{source}}/resolve"),
910            any(picker::resolve),
911        )
912        .route(&format!("{admin_path}/logout"), post(logout));
913
914    protected
915        .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
916        // Public routes (not covered by the guard above): the login and first-run
917        // setup screens and the embedded stylesheet and fonts (needed before
918        // authentication).
919        .route(
920            &format!("{admin_path}/login"),
921            get(login_form).post(login_submit),
922        )
923        .route(
924            &format!("{admin_path}/setup"),
925            get(setup_form).post(setup_submit),
926        )
927        .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
928        // Unmatched URLs render the styled 404; a handler panic renders the 500.
929        .fallback(not_found_fallback)
930        // The CSRF origin gate wraps every route (login and setup included); the
931        // panic layer is outermost so it catches everything.
932        .layer(middleware::from_fn_with_state(
933            state.clone(),
934            enforce_origin,
935        ))
936        .layer(tower_http::catch_panic::CatchPanicLayer::custom(
937            handle_panic,
938        ))
939        .with_state(state)
940}
941
942/// Fallback for unmatched admin URLs: a styled 404.
943async fn not_found_fallback() -> AdminError {
944    AdminError::NotFound
945}
946
947/// Turns a handler panic into a logged, styled 500.
948fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
949    let cause = err
950        .downcast_ref::<&str>()
951        .map(|s| s.to_string())
952        .or_else(|| err.downcast_ref::<String>().cloned())
953        .unwrap_or_else(|| "unknown panic".to_string());
954    tracing::error!(panic = cause, "admin handler panicked");
955    error::masked_500()
956}
957
958/// Normalises a configured admin path: a single leading slash, no trailing
959/// slash, falling back to `/admin` when empty. `manage`, `/manage`, and
960/// `/manage/` all become `/manage`. Shared by [`router`] and any caller building
961/// the panel's URL (for example a startup banner), so the two never drift.
962pub fn normalize_path(path: &str) -> String {
963    let trimmed = path.trim().trim_matches('/');
964    if trimmed.is_empty() {
965        "/admin".to_string()
966    } else {
967        format!("/{trimmed}")
968    }
969}
970
971/// Resolves a resource's authored-relative paths (`base_path`, the list's
972/// `edit_base`, the form's `base_path`) to full paths under the admin mount.
973fn prefix_resource(admin_path: &str, resource: &mut Resource) {
974    resource.base_path = format!("{admin_path}{}", resource.base_path);
975    if let Some(edit_base) = &mut resource.list.edit_base {
976        *edit_base = format!("{admin_path}{edit_base}");
977    }
978    if let Some(form) = &mut resource.form {
979        form.base_path = format!("{admin_path}{}", form.base_path);
980    }
981}
982
983/// One embedded admin asset served by [`serve_asset`].
984pub(crate) struct AdminAsset {
985    pub mime: &'static str,
986    pub cache: &'static str,
987    pub bytes: &'static [u8],
988}
989
990/// Admin assets served under `{admin}/assets/`, keyed by path. An open registry:
991/// field types and plugins contribute their own (wired when the first one does).
992pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
993
994/// Resolves widget asset keys to per-page assets for the shell: an order-
995/// preserving dedup, a single URL builder (`{base}/assets/{key}`), and
996/// stylesheet-vs-script by the registry's mime. A key absent from the registry is
997/// skipped and trips a debug assertion, since a declared asset must be registered.
998/// The URL is stamped as `data-lat-asset` in the head so a later htmx fragment's
999/// `lat.assets.ensure` of the same key is a no-op.
1000pub(crate) fn page_assets(keys: &[&str], base: &str, registry: &AssetRegistry) -> Vec<PageAsset> {
1001    let mut seen = std::collections::HashSet::new();
1002    let mut out = Vec::new();
1003    for &key in keys {
1004        if !seen.insert(key) {
1005            continue;
1006        }
1007        match registry.get(key) {
1008            Some(asset) => out.push(PageAsset {
1009                url: format!("{base}/assets/{key}"),
1010                css: asset.mime.contains("css"),
1011            }),
1012            None => debug_assert!(false, "asset key `{key}` is not registered"),
1013        }
1014    }
1015    out
1016}
1017
1018const ASSET_IMMUTABLE: &str = "public, max-age=31536000, immutable";
1019
1020/// The framework's built-in assets: the stylesheet, brand marks, and webfonts.
1021/// The stylesheet is `no-cache` (it changes with the binary and is referenced at
1022/// a stable URL); fonts and marks are content-stable, so immutable.
1023pub(crate) fn builtin_assets() -> AssetRegistry {
1024    let font = |bytes: &'static [u8]| AdminAsset {
1025        mime: "font/woff2",
1026        cache: ASSET_IMMUTABLE,
1027        bytes,
1028    };
1029    HashMap::from([
1030        (
1031            "laterite.css",
1032            AdminAsset {
1033                mime: "text/css; charset=utf-8",
1034                cache: "no-cache",
1035                bytes: include_bytes!("../assets/laterite.css"),
1036            },
1037        ),
1038        (
1039            "laterite.js",
1040            AdminAsset {
1041                mime: "text/javascript; charset=utf-8",
1042                cache: "no-cache",
1043                bytes: include_bytes!("../assets/laterite.js"),
1044            },
1045        ),
1046        (
1047            "vendor/htmx.min.js",
1048            AdminAsset {
1049                mime: "text/javascript; charset=utf-8",
1050                cache: "no-cache",
1051                bytes: include_bytes!("../assets/vendor/htmx.min.js"),
1052            },
1053        ),
1054        (
1055            "fields/ref-picker.js",
1056            AdminAsset {
1057                mime: "text/javascript; charset=utf-8",
1058                cache: "no-cache",
1059                bytes: include_bytes!("../assets/fields/ref-picker.js"),
1060            },
1061        ),
1062        (
1063            "fields/ref-picker.css",
1064            AdminAsset {
1065                mime: "text/css; charset=utf-8",
1066                cache: "no-cache",
1067                bytes: include_bytes!("../assets/fields/ref-picker.css"),
1068            },
1069        ),
1070        (
1071            "mark.svg",
1072            AdminAsset {
1073                mime: "image/svg+xml",
1074                cache: ASSET_IMMUTABLE,
1075                bytes: include_bytes!("../assets/mark.svg"),
1076            },
1077        ),
1078        (
1079            "mark.png",
1080            AdminAsset {
1081                mime: "image/png",
1082                cache: ASSET_IMMUTABLE,
1083                bytes: include_bytes!("../assets/mark.png"),
1084            },
1085        ),
1086        (
1087            "fonts/space-grotesk-500.woff2",
1088            font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1089        ),
1090        (
1091            "fonts/space-grotesk-600.woff2",
1092            font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1093        ),
1094        (
1095            "fonts/space-grotesk-700.woff2",
1096            font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1097        ),
1098        (
1099            "fonts/ibm-plex-sans-400.woff2",
1100            font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1101        ),
1102        (
1103            "fonts/ibm-plex-sans-600.woff2",
1104            font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1105        ),
1106        (
1107            "fonts/ibm-plex-mono-400.woff2",
1108            font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1109        ),
1110        (
1111            "fonts/ibm-plex-mono-600.woff2",
1112            font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1113        ),
1114    ])
1115}
1116
1117/// Serves an embedded admin asset by path (public; no auth).
1118async fn serve_asset(State(state): State<AdminState>, Path(path): Path<String>) -> Response {
1119    match state.assets.get(path.as_str()) {
1120        Some(asset) => (
1121            [
1122                (header::CONTENT_TYPE, asset.mime),
1123                (header::CACHE_CONTROL, asset.cache),
1124            ],
1125            asset.bytes,
1126        )
1127            .into_response(),
1128        None => not_found(),
1129    }
1130}
1131
1132/// Builds a resource's list, create, and edit routes as generic handlers that
1133/// carry the resource's descriptors. When the resource sets a `permission`, every
1134/// route it mounts is wrapped in a guard that answers `403 Forbidden` for an
1135/// operator who lacks it; the caller merges the result into the protected router.
1136fn mount_resource(
1137    resource: &Resource,
1138    field_types: &field::FieldRegistry,
1139    persisters: &persist::PersisterRegistry,
1140) -> Router<AdminState> {
1141    // A write resource must be permission-gated. A create/edit form with no
1142    // permission would expose its mutations to every signed-in operator, so an
1143    // unguarded form is a wiring bug that aborts boot (a read-only list may omit a
1144    // permission). If a public write resource is ever wanted, it opts in explicitly.
1145    if resource.form.is_some() && resource.permission.is_none() {
1146        panic!(
1147            "admin resource `{}` has a create/edit form but no permission; a write \
1148             resource must declare a permission",
1149            resource.base_path
1150        );
1151    }
1152    let base = resource.base_path.clone();
1153    let list_cfg = resource.list.clone();
1154    let mut router = Router::new().route(
1155        &base,
1156        get(
1157            move |State(state): State<AdminState>,
1158                  Extension(shell): Extension<Shell>,
1159                  Query(params): Query<list::ListParams>| {
1160                let cfg = list_cfg.clone();
1161                async move { list::handle(&state, &cfg, params, shell).await }
1162            },
1163        ),
1164    );
1165
1166    if let Some(form_cfg) = resource.form.clone() {
1167        // Resolve the form's field options once, here at router build. A malformed
1168        // option or unregistered type aborts boot naming the resource.
1169        let prepared = Arc::new(
1170            form::PreparedForm::prepare(form_cfg, field_types, persisters)
1171                .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
1172        );
1173        let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
1174        router = router.route(
1175            &format!("{base}/new"),
1176            get(
1177                move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
1178                    let pf = new_pf.clone();
1179                    async move { form::new_form(&state, &pf, shell) }
1180                },
1181            )
1182            .post(
1183                move |State(state): State<AdminState>,
1184                      Extension(shell): Extension<Shell>,
1185                      Extension(user): Extension<AuthenticatedUser>,
1186                      Form(data): Form<HashMap<String, String>>| {
1187                    let pf = create_pf.clone();
1188                    async move { form::create(&state, &pf, data, shell, &user).await }
1189                },
1190            ),
1191        );
1192
1193        let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
1194        router = router.route(
1195            &format!("{base}/{{id}}/edit"),
1196            get(
1197                move |State(state): State<AdminState>,
1198                      Extension(shell): Extension<Shell>,
1199                      Path(id): Path<String>| {
1200                    let pf = edit_pf.clone();
1201                    async move { form::edit_form(&state, &pf, id, shell).await }
1202                },
1203            )
1204            .post(
1205                move |State(state): State<AdminState>,
1206                      Extension(shell): Extension<Shell>,
1207                      Extension(user): Extension<AuthenticatedUser>,
1208                      Path(id): Path<String>,
1209                      Form(data): Form<HashMap<String, String>>| {
1210                    let pf = update_pf.clone();
1211                    async move { form::update(&state, &pf, id, data, shell, &user).await }
1212                },
1213            ),
1214        );
1215    }
1216
1217    // Gate every route the resource mounts on its permission.
1218    if let Some(permission) = &resource.permission {
1219        router = guard_with_permission(router, permission);
1220    }
1221    router
1222}
1223
1224/// Wraps every route currently in `router` in a permission guard: the auth guard
1225/// runs first and injects the identity, so this reads it from the request
1226/// extensions and answers `403 Forbidden` for an operator who lacks `permission`.
1227/// Shared by resource mounting and the roles permission editor.
1228fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
1229    let needed: Arc<str> = Arc::from(permission);
1230    router.route_layer(middleware::from_fn(
1231        move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
1232            let needed = needed.clone();
1233            async move {
1234                if user.allows(&needed) {
1235                    next.run(req).await
1236                } else {
1237                    forbidden()
1238                }
1239            }
1240        },
1241    ))
1242}
1243
1244/// The gate for authenticated admin routes. Resolves the session (identity plus
1245/// its opaque blob), verifies CSRF on state-changing requests, injects the
1246/// identity, shell, and a [`session::SessionHandle`] for handlers, and persists
1247/// the session blob afterwards when a handler changed it. Unauthenticated
1248/// requests redirect to the login screen.
1249async fn require_auth(
1250    State(state): State<AdminState>,
1251    jar: CookieJar,
1252    request: Request,
1253    next: Next,
1254) -> Response {
1255    let login = format!("{}/login", state.admin_path);
1256    let token = match jar.get(SESSION_COOKIE) {
1257        Some(cookie) => cookie.value().to_string(),
1258        None => return Redirect::to(&login).into_response(),
1259    };
1260    let resolved = match state.auth.resolve_session(&token).await {
1261        Ok(resolved) => resolved,
1262        Err(_) => return Redirect::to(&login).into_response(),
1263    };
1264    let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
1265
1266    // CSRF token check (the origin gate already ran on the whole router). A
1267    // state-changing method must carry the session token in a header or the
1268    // `_csrf` field; buffering the body serves plain forms and HTMX alike and
1269    // fails closed when a form omitted the field.
1270    let mut request = request;
1271    let safe = session::is_safe_method(request.method());
1272    if !safe {
1273        let (parts, body) = request.into_parts();
1274        let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
1275            .await
1276            .unwrap_or_default();
1277        let submitted = session::submitted_token(&parts.headers, &bytes);
1278        if !session::token_matches(&handle.csrf_token(), submitted.as_deref()) {
1279            tracing::warn!(
1280                user_id = resolved.identity.user.id,
1281                "admin CSRF check failed"
1282            );
1283            return error::csrf_rejected();
1284        }
1285        request = Request::from_parts(parts, Body::from(bytes));
1286    }
1287
1288    // Deliver and clear any queued flash on a full-page render; a mutating
1289    // request leaves it for the redirect target's GET.
1290    let flash = if safe {
1291        handle.take_flash()
1292    } else {
1293        Vec::new()
1294    };
1295    let user = resolved.identity;
1296    let path = request.uri().path().to_string();
1297    // Resolve the locale chain for this request (operator preference, then the
1298    // browser's Accept-Language, then the deployment default) over the shared
1299    // catalogs, before the nav context, which localizes the settings sidebar.
1300    let accept_language = request
1301        .headers()
1302        .get("accept-language")
1303        .and_then(|v| v.to_str().ok());
1304    let serveable = state.catalogs.locales();
1305    let chain = resolve_locale_chain(
1306        user.user.locale.as_deref(),
1307        accept_language,
1308        &state.default_locale,
1309        &serveable,
1310    );
1311    let i18n = Translator::with_chain(chain, state.catalogs.clone());
1312    // A clone kept past `next` (the shell's copy moves into the request) to localize
1313    // an error response the handler renders English (the `ErrorMeta` seam).
1314    let translator = i18n.clone();
1315    let (sidebar, active_nav) = resolve_nav_context(
1316        &state.nav,
1317        &state.settings,
1318        &state.admin_path,
1319        &user.permissions,
1320        &path,
1321        &i18n,
1322    );
1323    let brand = state.brand().await;
1324    let shell = Shell::new(
1325        &state.admin_path,
1326        brand,
1327        &state.nav,
1328        &user,
1329        state.timezone,
1330        sidebar,
1331        active_nav.as_deref(),
1332        handle.csrf_token(),
1333        flash,
1334        i18n,
1335    );
1336    request.extensions_mut().insert(user);
1337    request.extensions_mut().insert(shell);
1338    request.extensions_mut().insert(handle.clone());
1339    let mut response = next.run(request).await;
1340
1341    // The `ErrorMeta` seam: an `AdminError` renders its page in English from
1342    // `IntoResponse` (no request context there); re-render it localized now that a
1343    // translator is in hand. Status and kind are preserved.
1344    if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
1345        response = error::localized_error(meta.kind, &translator);
1346    }
1347
1348    // Persist the blob only when a handler changed it (flash set or consumed,
1349    // token rotated, or a token freshly minted this request).
1350    if let Some(blob) = handle.dirty_blob() {
1351        if let Err(e) = state.auth.set_session_data(&token, &blob).await {
1352            tracing::error!(error = %e, "persisting admin session failed");
1353        }
1354    }
1355    response
1356}
1357
1358/// Ceiling on a buffered admin form body for the CSRF check. Admin forms are
1359/// small; a larger body is rejected rather than buffered.
1360const MAX_FORM_BYTES: usize = 1024 * 1024;
1361
1362/// The primary CSRF gate, layered on the whole admin router: a state-changing
1363/// request must come from our own origin (see [`session::origin_ok`]). This
1364/// covers the login and setup screens, which are deliberately token-less (no
1365/// session exists yet), so the origin check is their sole CSRF defense.
1366async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
1367    if !session::is_safe_method(request.method())
1368        && !session::origin_ok(request.headers(), &state.origin)
1369    {
1370        tracing::warn!(
1371            path = request.uri().path(),
1372            "admin CSRF origin check failed"
1373        );
1374        return error::csrf_rejected();
1375    }
1376    next.run(request).await
1377}
1378
1379async fn login_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
1380    // A fresh install with no operators goes to first-run setup instead.
1381    match state.auth.has_any_operator().await {
1382        Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
1383        Ok(true) => render(LoginTemplate {
1384            base: state.admin_path.to_string(),
1385            brand: state.brand().await,
1386            error: None,
1387            i18n: pre_auth_translator(&state, &headers),
1388        }),
1389        Err(_) => render_error(),
1390    }
1391}
1392
1393#[derive(Deserialize)]
1394struct LoginForm {
1395    username: String,
1396    password: String,
1397}
1398
1399async fn login_submit(
1400    State(state): State<AdminState>,
1401    jar: CookieJar,
1402    headers: HeaderMap,
1403    Form(form): Form<LoginForm>,
1404) -> Response {
1405    match state
1406        .auth
1407        .authenticate(&form.username, &form.password, &RequestContext::default())
1408        .await
1409    {
1410        Ok(session) => {
1411            let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
1412            (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
1413        }
1414        Err(_) => {
1415            let i18n = pre_auth_translator(&state, &headers);
1416            render(LoginTemplate {
1417                base: state.admin_path.to_string(),
1418                brand: state.brand().await,
1419                error: Some(i18n.t(&t!("Invalid username or password."))),
1420                i18n,
1421            })
1422        }
1423    }
1424}
1425
1426/// Builds the session cookie, scoped to the admin mount and flagged `Secure`
1427/// behind HTTPS. Shared by login and first-run setup.
1428fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
1429    Cookie::build((SESSION_COOKIE, token))
1430        .path(admin_path.to_string())
1431        .http_only(true)
1432        .secure(secure)
1433        .same_site(SameSite::Lax)
1434        .build()
1435}
1436
1437#[derive(Deserialize)]
1438struct SetupForm {
1439    username: String,
1440    first_name: String,
1441    last_name: String,
1442    email: String,
1443    password: String,
1444    timezone: String,
1445}
1446
1447/// The first-run setup screen: shown only while no operator exists, so a fresh
1448/// install can create its first administrator without the CLI.
1449async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
1450    match state.auth.has_any_operator().await {
1451        Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
1452        Ok(false) => render(setup_view(
1453            &state.admin_path,
1454            state.brand().await,
1455            state.timezone,
1456            None,
1457            pre_auth_translator(&state, &headers),
1458        )),
1459        Err(_) => render_error(),
1460    }
1461}
1462
1463async fn setup_submit(
1464    State(state): State<AdminState>,
1465    jar: CookieJar,
1466    headers: HeaderMap,
1467    Form(form): Form<SetupForm>,
1468) -> Response {
1469    // Setup only ever creates the first operator; once one exists it is closed.
1470    match state.auth.has_any_operator().await {
1471        Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
1472        Ok(false) => {}
1473        Err(_) => return render_error(),
1474    }
1475
1476    let username = form.username.trim();
1477    let email = form.email.trim();
1478    let first_name = form.first_name.trim();
1479    let last_name = form.last_name.trim();
1480    let tz = form.timezone.trim();
1481    if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
1482    {
1483        return render(setup_view(
1484            &state.admin_path,
1485            state.brand().await,
1486            state.timezone,
1487            Some(t!(
1488                "Username, first name, email, and password are all required."
1489            )),
1490            pre_auth_translator(&state, &headers),
1491        ));
1492    }
1493    // The setup select always carries a value, but guard against a bad one.
1494    if tz.parse::<Tz>().is_err() {
1495        return render(setup_view(
1496            &state.admin_path,
1497            state.brand().await,
1498            state.timezone,
1499            Some(t!("That is not a recognised timezone.")),
1500            pre_auth_translator(&state, &headers),
1501        ));
1502    }
1503
1504    let new = NewOperator {
1505        username,
1506        email,
1507        first_name,
1508        last_name: (!last_name.is_empty()).then_some(last_name),
1509        password: &form.password,
1510        timezone: Some(tz),
1511    };
1512    if state.auth.create_superuser(new).await.is_err() {
1513        return render(setup_view(
1514            &state.admin_path,
1515            state.brand().await,
1516            state.timezone,
1517            Some(t!(
1518                "Could not create the account. The username or email may already be taken."
1519            )),
1520            pre_auth_translator(&state, &headers),
1521        ));
1522    }
1523
1524    // Sign the new administrator straight in through the normal login path.
1525    match state
1526        .auth
1527        .authenticate(username, &form.password, &RequestContext::default())
1528        .await
1529    {
1530        Ok(session) => {
1531            let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
1532            (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
1533        }
1534        Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
1535    }
1536}
1537
1538/// Builds the setup view, its timezone select defaulting to the deployment
1539/// default so the first administrator can accept or change it.
1540fn setup_view(
1541    admin_path: &str,
1542    brand: String,
1543    default_tz: Tz,
1544    error: Option<Text>,
1545    i18n: Translator,
1546) -> SetupTemplate {
1547    let default_name = default_tz.name();
1548    let zones = TZ_VARIANTS
1549        .iter()
1550        .filter(|tz| zone_offered(tz.name()))
1551        .map(|tz| TzOption {
1552            name: tz.name().to_string(),
1553            selected: tz.name() == default_name,
1554        })
1555        .collect();
1556    SetupTemplate {
1557        base: admin_path.to_string(),
1558        brand,
1559        zones,
1560        error: error.map(|e| i18n.t(&e)),
1561        i18n,
1562    }
1563}
1564
1565async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
1566    if let Some(cookie) = jar.get(SESSION_COOKIE) {
1567        let _ = state.auth.logout(cookie.value()).await;
1568    }
1569    let removal = Cookie::build((SESSION_COOKIE, ""))
1570        .path(state.admin_path.to_string())
1571        .build();
1572    (
1573        jar.remove(removal),
1574        Redirect::to(&format!("{}/login", state.admin_path)),
1575    )
1576        .into_response()
1577}
1578
1579async fn dashboard(
1580    Extension(shell): Extension<Shell>,
1581    Extension(user): Extension<AuthenticatedUser>,
1582) -> Response {
1583    render(DashboardTemplate {
1584        username: user.user.username,
1585        shell,
1586    })
1587}
1588
1589/// Whether an operator may see a settings item: items with no permission are
1590/// public, otherwise the operator must hold the item's permission.
1591fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
1592    match &item.permission {
1593        None => true,
1594        Some(p) => perms.allows(p),
1595    }
1596}
1597
1598/// The settings items this operator may see, in registry order. Both the index
1599/// and the form use this set, so an operator never sees or edits an item their
1600/// permissions do not allow.
1601fn visible_settings(
1602    items: &[settings::SettingsItem],
1603    perms: &PermissionSet,
1604) -> Vec<settings::SettingsItem> {
1605    items
1606        .iter()
1607        .filter(|item| operator_can_see(item, perms))
1608        .cloned()
1609        .collect()
1610}
1611
1612async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
1613    settings::index(shell)
1614}
1615
1616async fn settings_edit(
1617    State(state): State<AdminState>,
1618    Extension(shell): Extension<Shell>,
1619    Extension(user): Extension<AuthenticatedUser>,
1620    Path(code): Path<String>,
1621) -> Response {
1622    // Filter first, so an operator cannot open a settings form they lack the
1623    // permission to see.
1624    let items = visible_settings(&state.settings, &user.permissions);
1625    match items
1626        .iter()
1627        .find(|item| item.code == code && item.link.is_none())
1628    {
1629        Some(item) => settings::edit_form(&state, item, shell).await,
1630        None => not_found(),
1631    }
1632}
1633
1634async fn settings_update(
1635    State(state): State<AdminState>,
1636    Extension(shell): Extension<Shell>,
1637    Extension(user): Extension<AuthenticatedUser>,
1638    Extension(session): Extension<session::SessionHandle>,
1639    Path(code): Path<String>,
1640    Form(data): Form<HashMap<String, String>>,
1641) -> Response {
1642    let items = visible_settings(&state.settings, &user.permissions);
1643    match items
1644        .iter()
1645        .find(|item| item.code == code && item.link.is_none())
1646    {
1647        Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
1648        None => not_found(),
1649    }
1650}
1651
1652/// The self-service Preferences screen for the signed-in operator.
1653async fn preferences_form(
1654    State(state): State<AdminState>,
1655    Extension(shell): Extension<Shell>,
1656    Extension(user): Extension<AuthenticatedUser>,
1657) -> Response {
1658    render(preferences_view(
1659        &shell,
1660        &user,
1661        state.timezone,
1662        &state.default_locale,
1663        &offered_locales(&state.catalogs),
1664        None,
1665    ))
1666}
1667
1668#[derive(Deserialize)]
1669struct PreferencesForm {
1670    timezone: String,
1671    /// Omitted means no locale choice was submitted, treated as inherit.
1672    #[serde(default)]
1673    locale: String,
1674}
1675
1676async fn preferences_update(
1677    State(state): State<AdminState>,
1678    Extension(shell): Extension<Shell>,
1679    Extension(user): Extension<AuthenticatedUser>,
1680    Extension(session): Extension<session::SessionHandle>,
1681    Form(form): Form<PreferencesForm>,
1682) -> Response {
1683    let offered = offered_locales(&state.catalogs);
1684    // An empty choice clears a preference so the operator inherits the default.
1685    let tz = form.timezone.trim();
1686    let tz_stored = if tz.is_empty() {
1687        None
1688    } else if tz.parse::<Tz>().is_ok() {
1689        Some(tz)
1690    } else {
1691        return render(preferences_view(
1692            &shell,
1693            &user,
1694            state.timezone,
1695            &state.default_locale,
1696            &offered,
1697            Some(t!("That is not a recognised timezone.")),
1698        ));
1699    };
1700    let loc = form.locale.trim();
1701    let loc_stored = if loc.is_empty() {
1702        None
1703    } else if offered.iter().any(|l| l == loc) {
1704        Some(loc)
1705    } else {
1706        return render(preferences_view(
1707            &shell,
1708            &user,
1709            state.timezone,
1710            &state.default_locale,
1711            &offered,
1712            Some(t!("That is not a supported language.")),
1713        ));
1714    };
1715    if state
1716        .auth
1717        .set_user_timezone(user.user.id, tz_stored)
1718        .await
1719        .is_err()
1720    {
1721        return render_error();
1722    }
1723    match state.auth.set_user_locale(user.user.id, loc_stored).await {
1724        Ok(()) => {
1725            session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
1726            Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
1727        }
1728        Err(_) => render_error(),
1729    }
1730}
1731
1732/// Builds the Preferences view. `shell.tz` is the timezone currently in force
1733/// (the operator's preference or the default); the operator's stored preference
1734/// selects the matching option, or the inherit option when unset.
1735fn preferences_view(
1736    shell: &Shell,
1737    user: &AuthenticatedUser,
1738    default_tz: Tz,
1739    default_locale: &str,
1740    offered: &[String],
1741    error: Option<Text>,
1742) -> PreferencesTemplate {
1743    let current = user.user.timezone.as_deref();
1744    let zones = TZ_VARIANTS
1745        .iter()
1746        .filter(|tz| zone_offered(tz.name()))
1747        .map(|tz| TzOption {
1748            name: tz.name().to_string(),
1749            selected: current == Some(tz.name()),
1750        })
1751        .collect();
1752    let current_locale = user.user.locale.as_deref();
1753    let locales = offered
1754        .iter()
1755        .map(|code| LocaleOption {
1756            name: locale_name(code),
1757            selected: current_locale == Some(code.as_str()),
1758            code: code.clone(),
1759        })
1760        .collect();
1761    PreferencesTemplate {
1762        shell: shell.clone(),
1763        zones,
1764        effective_tz: shell.tz.name().to_string(),
1765        default_tz: default_tz.name().to_string(),
1766        inherits: current.is_none(),
1767        locales,
1768        default_locale: locale_name(default_locale),
1769        inherits_locale: current_locale.is_none(),
1770        error: error.map(|e| shell.tt(&e)),
1771    }
1772}
1773
1774/// The framework's own admin screens.
1775fn builtin_resources() -> Vec<Resource> {
1776    vec![
1777        Resource {
1778            base_path: "/users".to_string(),
1779            nav_label: "Backend Users".into(),
1780            list: backend_users_list_config(),
1781            form: None,
1782            permission: Some("backend.manage_users".to_string()),
1783        },
1784        Resource {
1785            base_path: "/roles".to_string(),
1786            nav_label: "Roles".into(),
1787            list: roles_list_config(),
1788            // The create/edit form is the dedicated permission editor (see the
1789            // `roles` module), mounted separately, not the generic form.
1790            form: None,
1791            permission: Some("backend.manage_roles".to_string()),
1792        },
1793        Resource {
1794            base_path: "/audit-log".to_string(),
1795            nav_label: "Audit Log".into(),
1796            list: audit_log_list_config(),
1797            // Append-only: read-only list, no form, no edit or create links.
1798            form: None,
1799            permission: Some("backend.view_audit_log".to_string()),
1800        },
1801    ]
1802}
1803
1804/// The framework's own settings items. The built-in Users and Roles resources
1805/// appear in the settings menu under a Users category (linking to their list
1806/// screens), rather than as main-menu tabs.
1807fn builtin_settings() -> Vec<settings::SettingsItem> {
1808    vec![
1809        settings::SettingsItem {
1810            code: "backend.administrators".to_string(),
1811            label: "Administrators".into(),
1812            description: "Manage backend administrator accounts.".into(),
1813            category: "Users".into(),
1814            order: 10,
1815            icon: Some("users".to_string()),
1816            permission: Some("backend.manage_users".to_string()),
1817            link: Some("/users".to_string()),
1818            fields: Vec::new(),
1819        },
1820        settings::SettingsItem {
1821            code: "backend.roles".to_string(),
1822            label: "Roles".into(),
1823            description: "Manage roles and their permissions.".into(),
1824            category: "Users".into(),
1825            order: 20,
1826            icon: Some("shield".to_string()),
1827            permission: Some("backend.manage_roles".to_string()),
1828            link: Some("/roles".to_string()),
1829            fields: Vec::new(),
1830        },
1831        settings::brand::settings_item(),
1832        settings::SettingsItem {
1833            code: "backend.plugins".to_string(),
1834            label: "Plugins".into(),
1835            description: "Enable or disable installed plugins.".into(),
1836            category: "System".into(),
1837            order: 10,
1838            icon: Some("plug".to_string()),
1839            permission: Some(plugins::MANAGE_PERMISSION.to_string()),
1840            link: Some("/plugins".to_string()),
1841            fields: Vec::new(),
1842        },
1843        settings::SettingsItem {
1844            code: "backend.audit_log".to_string(),
1845            label: "Audit Log".into(),
1846            description: "Review the record of administrative changes.".into(),
1847            category: "System".into(),
1848            order: 20,
1849            icon: Some("history".to_string()),
1850            permission: Some("backend.view_audit_log".to_string()),
1851            link: Some("/audit-log".to_string()),
1852            fields: Vec::new(),
1853        },
1854    ]
1855}
1856
1857fn backend_users_list_config() -> list::ListConfig {
1858    list::ListConfig {
1859        entity: "backend_users".to_string(),
1860        title: "Backend Users".into(),
1861        columns: vec![
1862            list::ListColumn::new("username", "Username"),
1863            list::ListColumn::new("email", "Email"),
1864            list::ListColumn::new("first_name", "First name"),
1865            list::ListColumn::new("last_name", "Last name"),
1866            list::ListColumn::new("is_superuser", "Superuser").yes_no(),
1867            list::ListColumn::new("is_active", "Active").yes_no(),
1868            list::ListColumn::new("created_at", "Created").datetime(),
1869        ],
1870        order_by: "created_at".to_string(),
1871        order_dir: list::SortDir::Desc,
1872        per_page: 25,
1873        id_field: "id".to_string(),
1874        // Rows link to the per-user permission editor; users are created from the
1875        // CLI or first-run setup, so no "New" screen here.
1876        edit_base: Some("/users".to_string()),
1877        creatable: false,
1878    }
1879}
1880
1881fn roles_list_config() -> list::ListConfig {
1882    list::ListConfig {
1883        entity: "backend_roles".to_string(),
1884        title: "Roles".into(),
1885        columns: vec![
1886            list::ListColumn::new("code", "Code"),
1887            list::ListColumn::new("name", "Name"),
1888            list::ListColumn::new("created_at", "Created").datetime(),
1889        ],
1890        order_by: "created_at".to_string(),
1891        order_dir: list::SortDir::Desc,
1892        per_page: 25,
1893        id_field: "id".to_string(),
1894        edit_base: Some("/roles".to_string()),
1895        creatable: true,
1896    }
1897}
1898
1899/// The read-only audit-log view: the recorded administrative changes, newest
1900/// first. Append-only, so no create or edit path (`edit_base`/`creatable` off).
1901fn audit_log_list_config() -> list::ListConfig {
1902    list::ListConfig {
1903        entity: "backend_audit_log".to_string(),
1904        title: "Audit Log".into(),
1905        columns: vec![
1906            list::ListColumn::new("created_at", "When").datetime(),
1907            list::ListColumn::new("actor_username", "Operator"),
1908            list::ListColumn::new("action", "Action"),
1909            list::ListColumn::new("target_type", "Target"),
1910            list::ListColumn::new("target_id", "Target ID"),
1911        ],
1912        order_by: "created_at".to_string(),
1913        order_dir: list::SortDir::Desc,
1914        per_page: 50,
1915        id_field: "id".to_string(),
1916        edit_base: None,
1917        creatable: false,
1918    }
1919}
1920
1921#[derive(Template)]
1922#[template(path = "login.html")]
1923struct LoginTemplate {
1924    /// The admin mount path, so pre-auth asset and form URLs match the panel.
1925    base: String,
1926    brand: String,
1927    error: Option<String>,
1928    /// The pre-auth translator (config locale, then `Accept-Language`); no operator
1929    /// preference exists yet. `self.t` and `self.locale` localize this screen.
1930    i18n: Translator,
1931}
1932
1933impl LoginTemplate {
1934    fn t(&self, source: &str) -> String {
1935        self.i18n.t(&Text::dynamic(source))
1936    }
1937    fn locale(&self) -> &str {
1938        self.i18n.locale()
1939    }
1940}
1941
1942#[derive(Template)]
1943#[template(path = "dashboard.html")]
1944struct DashboardTemplate {
1945    shell: Shell,
1946    username: String,
1947}
1948
1949#[derive(Template)]
1950#[template(path = "setup.html")]
1951struct SetupTemplate {
1952    /// The admin mount path, so pre-auth asset and form URLs match the panel.
1953    base: String,
1954    brand: String,
1955    zones: Vec<TzOption>,
1956    error: Option<String>,
1957    /// The pre-auth translator, as on [`LoginTemplate`].
1958    i18n: Translator,
1959}
1960
1961impl SetupTemplate {
1962    fn t(&self, source: &str) -> String {
1963        self.i18n.t(&Text::dynamic(source))
1964    }
1965    fn locale(&self) -> &str {
1966        self.i18n.locale()
1967    }
1968}
1969
1970#[derive(Template)]
1971#[template(path = "preferences.html")]
1972struct PreferencesTemplate {
1973    shell: Shell,
1974    zones: Vec<TzOption>,
1975    /// The timezone dates currently render in for this operator.
1976    effective_tz: String,
1977    /// The deployment default, named in the inherit option.
1978    default_tz: String,
1979    /// Whether the operator currently inherits the default (no preference set).
1980    inherits: bool,
1981    locales: Vec<LocaleOption>,
1982    /// The deployment default locale, named in the inherit option.
1983    default_locale: String,
1984    /// Whether the operator currently inherits the default locale.
1985    inherits_locale: bool,
1986    error: Option<String>,
1987}
1988
1989struct TzOption {
1990    name: String,
1991    selected: bool,
1992}
1993
1994struct LocaleOption {
1995    /// The base language tag (`en`, `kn`).
1996    code: String,
1997    /// The language's own name, shown untranslated.
1998    name: String,
1999    selected: bool,
2000}
2001
2002#[derive(Clone)]
2003struct NavView {
2004    label: String,
2005    path: String,
2006    active: bool,
2007    /// Inline SVG for the tab's icon, or empty for a text-only tab. Rendered raw
2008    /// with `|safe`.
2009    icon: &'static str,
2010}
2011
2012/// Renders a template to an HTML response, mapping a render failure to a 500.
2013pub(crate) fn render<T: Template>(template: T) -> Response {
2014    match template.render() {
2015        Ok(html) => Html(html).into_response(),
2016        // A render failure is unexpected: log the cause via AdminError, then mask.
2017        Err(e) => AdminError::from(e).into_response(),
2018    }
2019}
2020
2021pub(crate) fn render_error() -> Response {
2022    error::masked_500()
2023}
2024
2025pub(crate) fn not_found() -> Response {
2026    AdminError::NotFound.into_response()
2027}
2028
2029pub(crate) fn forbidden() -> Response {
2030    AdminError::Forbidden.into_response()
2031}
2032
2033#[cfg(test)]
2034mod tests {
2035    use super::*;
2036
2037    fn write_form() -> form::FormConfig {
2038        form::FormConfig {
2039            entity: "widgets".to_string(),
2040            title: "Widgets".into(),
2041            base_path: "/widgets".to_string(),
2042            id_field: "id".to_string(),
2043            fields: vec![],
2044            persist: None,
2045        }
2046    }
2047
2048    fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
2049        Resource {
2050            base_path: "/widgets".to_string(),
2051            nav_label: "Widgets".into(),
2052            list: backend_users_list_config(),
2053            form,
2054            permission: permission.map(str::to_string),
2055        }
2056    }
2057
2058    #[test]
2059    #[should_panic(expected = "must declare a permission")]
2060    fn write_resource_without_permission_aborts_boot() {
2061        // A create/edit form with no permission would expose its mutations to any
2062        // signed-in operator; mounting it aborts boot.
2063        let resource = resource_with(Some(write_form()), None);
2064        let _ = mount_resource(
2065            &resource,
2066            &field::builtin_registry(),
2067            &persist::PersisterRegistry::new(),
2068        );
2069    }
2070
2071    #[test]
2072    fn read_only_resource_may_omit_permission() {
2073        // No write path, so no permission is required: this mounts without panicking.
2074        let resource = resource_with(None, None);
2075        let _ = mount_resource(
2076            &resource,
2077            &field::builtin_registry(),
2078            &persist::PersisterRegistry::new(),
2079        );
2080    }
2081
2082    #[test]
2083    fn shell_localizes_through_the_translator_and_falls_back() {
2084        // The default translator has no catalog, so each source returns itself.
2085        let shell = Shell::test();
2086        assert_eq!(shell.t("Save"), "Save");
2087        assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
2088    }
2089
2090    #[test]
2091    fn shell_tf_interpolates_integer_arguments() {
2092        let shell = Shell::test();
2093        assert_eq!(
2094            shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
2095            "Page 2 of 5"
2096        );
2097    }
2098
2099    #[test]
2100    fn display_tz_prefers_a_valid_operator_preference() {
2101        assert_eq!(
2102            resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
2103            Tz::Asia__Kolkata
2104        );
2105    }
2106
2107    #[test]
2108    fn display_tz_falls_back_when_unset_or_invalid() {
2109        let default = Tz::Europe__London;
2110        // No preference: use the deployment default.
2111        assert_eq!(resolve_display_tz(None, default), default);
2112        // Junk stored value: fall back rather than error.
2113        assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
2114        assert_eq!(resolve_display_tz(Some(""), default), default);
2115    }
2116
2117    #[test]
2118    fn accept_language_orders_by_weight_and_drops_wildcard() {
2119        assert_eq!(
2120            parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
2121            vec!["en-US", "en", "kn"]
2122        );
2123        // A higher-weight later tag wins; the wildcard is dropped.
2124        assert_eq!(
2125            parse_accept_language("en;q=0.5,kn,*;q=0.1"),
2126            vec!["kn", "en"]
2127        );
2128    }
2129
2130    #[test]
2131    fn locale_chain_prefers_operator_then_header_then_default() {
2132        // A deployment with hi/kn/ta catalogs loaded (en is always serveable).
2133        let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
2134        // Operator preference leads, then header order, then the default; en is
2135        // appended last as the source fallback, and nothing repeats.
2136        assert_eq!(
2137            resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
2138            vec!["kn", "ta", "hi", "en"]
2139        );
2140        // An explicit en in the header keeps its signaled position, not forced last.
2141        assert_eq!(
2142            resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
2143            vec!["kn", "en", "ta"]
2144        );
2145        // A regional tag normalizes to its base language.
2146        assert_eq!(
2147            resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
2148            vec!["kn", "en"]
2149        );
2150    }
2151
2152    #[test]
2153    fn locale_chain_skips_unserveable_and_defaults_to_en() {
2154        // Only hi has a catalog here; en is always serveable.
2155        let loaded = ["hi".to_string()];
2156        // Tags with no loaded catalog (and not en) are skipped; an unserveable
2157        // default falls to en, leaving just the source.
2158        assert_eq!(
2159            resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
2160            vec!["en"]
2161        );
2162        // No signal at all is still a valid en chain.
2163        assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
2164        // A loaded catalog makes its locale serveable with no other change.
2165        assert_eq!(
2166            resolve_locale_chain(Some("hi"), None, "en", &loaded),
2167            vec!["hi", "en"]
2168        );
2169        // The pseudo-locale resolves without a catalog (QA selects it explicitly).
2170        assert_eq!(
2171            resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
2172            vec![laterite_core::PSEUDO_LOCALE, "en"]
2173        );
2174    }
2175
2176    #[test]
2177    fn page_assets_dedupe_preserve_order_and_classify() {
2178        let mut reg = AssetRegistry::new();
2179        reg.insert(
2180            "a.js",
2181            AdminAsset {
2182                mime: "text/javascript; charset=utf-8",
2183                cache: "no-cache",
2184                bytes: b"",
2185            },
2186        );
2187        reg.insert(
2188            "b.css",
2189            AdminAsset {
2190                mime: "text/css; charset=utf-8",
2191                cache: "no-cache",
2192                bytes: b"",
2193            },
2194        );
2195        // A repeated key collapses to one, keeping first-seen order; the URL is
2196        // built from the base; css-vs-js follows the registry mime.
2197        let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", &reg);
2198        assert_eq!(assets.len(), 2);
2199        assert_eq!(assets[0].url, "/admin/assets/a.js");
2200        assert!(!assets[0].css);
2201        assert_eq!(assets[1].url, "/admin/assets/b.css");
2202        assert!(assets[1].css);
2203    }
2204
2205    /// A fresh test database with no migrations applied, the blank slate an
2206    /// application starts from before it runs its migration set. Hold the guard
2207    /// for the test's lifetime.
2208    async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
2209        laterite_core::testing::connect_test(&[]).await
2210    }
2211
2212    #[tokio::test]
2213    async fn builtin_migrations_create_the_admin_tables() {
2214        let (db, _guard) = empty_db().await;
2215        laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
2216            .await
2217            .unwrap();
2218        // A table from each bundled module exists, so an app that only ran
2219        // builtin_migrations has everything the admin's screens need. A no-row
2220        // probe succeeds only if the table exists, portably on every backend.
2221        for table in ["backend_users", "settings"] {
2222            let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
2223                .fetch_optional(&db.pool)
2224                .await;
2225            assert!(
2226                probe.is_ok(),
2227                "{table} should exist after builtin_migrations"
2228            );
2229        }
2230    }
2231
2232    #[tokio::test]
2233    async fn brand_setting_overrides_config_and_blank_falls_back() {
2234        let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
2235        let state = AdminState {
2236            auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2237            db: db.clone(),
2238            nav: Arc::new(Vec::new()),
2239            settings: Arc::new(Vec::new()),
2240            permissions: Arc::new(builtin_permissions()),
2241            admin_path: Arc::from("/admin"),
2242            secure_cookie: false,
2243            origin: Arc::from(""),
2244            timezone: Tz::UTC,
2245            default_locale: Arc::from("en"),
2246            catalogs: Arc::new(CatalogStore::default()),
2247            app_name: "Configured Name".to_string(),
2248            brand_cache: Arc::new(RwLock::new(None)),
2249            field_types: Arc::new(field::builtin_registry()),
2250            column_types: Arc::new(list::builtin_column_registry()),
2251            assets: Arc::new(builtin_assets()),
2252            pickers: Arc::new(picker::PickerRegistry::new()),
2253            overrides: Arc::new(field::NoOverrides),
2254        };
2255
2256        // With no brand setting, the configured application name is the brand.
2257        assert_eq!(state.brand().await, "Configured Name");
2258
2259        // A brand setting overrides the configured name (cache re-reads after
2260        // invalidation).
2261        settings::store::save(
2262            &db,
2263            &settings::BrandSetting {
2264                app_name: "Acme Corp".to_string(),
2265            },
2266        )
2267        .await
2268        .unwrap();
2269        state.invalidate_brand();
2270        assert_eq!(state.brand().await, "Acme Corp");
2271
2272        // A blank brand setting falls back to the configured name.
2273        settings::store::save(
2274            &db,
2275            &settings::BrandSetting {
2276                app_name: "   ".to_string(),
2277            },
2278        )
2279        .await
2280        .unwrap();
2281        state.invalidate_brand();
2282        assert_eq!(state.brand().await, "Configured Name");
2283    }
2284
2285    fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
2286        settings::SettingsItem {
2287            code: code.to_string(),
2288            label: code.into(),
2289            description: String::new().into(),
2290            category: "General".into(),
2291            order: 1,
2292            icon: None,
2293            permission: permission.map(str::to_string),
2294            link: None,
2295            fields: Vec::new(),
2296        }
2297    }
2298
2299    #[test]
2300    fn settings_visibility_respects_permissions() {
2301        let items = vec![
2302            settings_item("public", None),
2303            settings_item("gated", Some("backend.manage_users")),
2304        ];
2305
2306        // An operator without the grant sees only the unpermissioned item.
2307        let none = PermissionSet::new(false, Vec::<String>::new());
2308        let codes: Vec<String> = visible_settings(&items, &none)
2309            .into_iter()
2310            .map(|i| i.code)
2311            .collect();
2312        assert_eq!(codes, ["public"]);
2313
2314        // Holding the permission reveals the gated item.
2315        let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
2316        assert_eq!(visible_settings(&items, &granted).len(), 2);
2317
2318        // A superuser sees everything.
2319        let superuser = PermissionSet::new(true, Vec::<String>::new());
2320        assert_eq!(visible_settings(&items, &superuser).len(), 2);
2321    }
2322
2323    #[test]
2324    fn context_sidebar_follows_settings_links() {
2325        let items = builtin_settings();
2326        let superuser = PermissionSet::new(true, Vec::<String>::new());
2327        let tr = Translator::new("en");
2328        let sidebar =
2329            |path: &str| resolve_nav_context(&[], &items, "/admin", &superuser, path, &tr).0;
2330        let active_path = |path: &str| -> Option<String> {
2331            sidebar(path)
2332                .into_iter()
2333                .flat_map(|g| g.items)
2334                .find(|i| i.active)
2335                .map(|i| i.path)
2336        };
2337
2338        // A linked resource, and its sub-pages, resolve to that item as active.
2339        assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
2340        assert_eq!(
2341            active_path("/admin/roles/42/edit").as_deref(),
2342            Some("/admin/roles")
2343        );
2344        // The settings index shows the sidebar, with nothing active.
2345        assert!(!sidebar("/admin/settings").is_empty());
2346        assert_eq!(active_path("/admin/settings"), None);
2347        // A settings form activates its own item.
2348        assert_eq!(
2349            active_path("/admin/settings/backend.roles").as_deref(),
2350            Some("/admin/roles")
2351        );
2352        // The dashboard is not a settings context, so it has no sidebar.
2353        assert!(sidebar("/admin").is_empty());
2354    }
2355
2356    #[test]
2357    fn active_nav_lights_the_right_tab() {
2358        let nav = vec![
2359            NavLink {
2360                label: "Dashboard".into(),
2361                path: "/admin".to_string(),
2362                icon: Some("layout-dashboard"),
2363            },
2364            NavLink {
2365                label: "Pages".into(),
2366                path: "/admin/pages".to_string(),
2367                icon: None,
2368            },
2369            NavLink {
2370                label: "Settings".into(),
2371                path: "/admin/settings".to_string(),
2372                icon: Some("settings"),
2373            },
2374        ];
2375
2376        // Dashboard lights only on an exact match, never as a prefix of deeper paths.
2377        assert_eq!(
2378            active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
2379            Some("/admin")
2380        );
2381        // A section keeps its own tab active across its sub-pages.
2382        assert_eq!(
2383            active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
2384            Some("/admin/pages")
2385        );
2386        // A sibling section that merely shares a prefix does not steal the tab.
2387        assert_eq!(
2388            active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
2389            None
2390        );
2391        // A screen under no section (reached from the user menu) lights nothing,
2392        // rather than the root falling back to Dashboard.
2393        assert_eq!(
2394            active_nav_path(&nav, "/admin", false, "/admin/preferences"),
2395            None
2396        );
2397        // The settings context lights Settings, including for a linked resource
2398        // whose path lives outside /admin/settings.
2399        assert_eq!(
2400            active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
2401            Some("/admin/settings")
2402        );
2403        assert_eq!(
2404            active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
2405            Some("/admin/settings")
2406        );
2407    }
2408}