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