Skip to main content

laterite_admin/settings/
mod.rs

1//! Descriptor-driven settings screens, and the settings store behind them.
2//!
3//! A module registers a [`SettingsItem`] for each settings model it wants an
4//! operator to edit: its storage `code` (the [`store::SettingsModel`] `CODE`), a
5//! `category` it groups under, and the fields to render. The framework mounts one
6//! index that lists every registered item grouped by category, and one generic
7//! form per item that reads and writes the model's JSON value through the
8//! [`store`]. No per-model controller is needed, exactly as a single settings
9//! controller serves every settings model.
10//!
11//! Values are stored as one JSON object per code. Field names are JSON keys, not
12//! SQL identifiers, and the value is written through a parameterized upsert, so
13//! nothing here builds SQL from user input.
14
15pub mod brand;
16pub mod migrations;
17pub mod store;
18
19pub use brand::BrandSetting;
20pub use migrations::{migrations, MODULE_ID};
21pub use store::{
22    get, get_for, load, load_for, save, save_for, set, set_for, PreferencesError, PreferencesModel,
23    SettingsError, SettingsModel,
24};
25
26/// The `laterite.settings` module: the framework's settings store table.
27pub struct SettingsModule;
28
29impl laterite_core::Module for SettingsModule {
30    fn id(&self) -> laterite_core::ModuleId {
31        laterite_core::ModuleId::new(MODULE_ID)
32    }
33    fn migrations(&self) -> laterite_core::MigrationSet {
34        migrations()
35    }
36    fn register(&self, registry: &mut laterite_core::Registry) {
37        registry.add(laterite_core::cache::CacheNamespace::new("values"));
38    }
39}
40
41/// The cache namespace every cached reading of a setting depends on; a
42/// write through [`Settings`] bumps it.
43pub const NAMESPACE: &str = "laterite.settings::values";
44
45/// The settings store writing through the cache: a save moves
46/// [`NAMESPACE`] on, so every value built from a setting is read afresh in
47/// every process. [`RouteCtx::settings`](crate::routes::RouteCtx::settings)
48/// hands one out. Reads are the free functions of [`store`].
49#[derive(Clone)]
50pub struct Settings {
51    db: laterite_core::Db,
52    cache: laterite_core::Cache,
53}
54
55impl Settings {
56    pub fn new(db: laterite_core::Db, cache: laterite_core::Cache) -> Self {
57        cache.allow(NAMESPACE);
58        Self { db, cache }
59    }
60
61    /// Saves a typed model under its code.
62    pub async fn save<T: SettingsModel>(&self, model: &T) -> Result<(), SettingsError> {
63        store::save(&self.db, model).await?;
64        let _ = self.cache.bump(NAMESPACE).await;
65        Ok(())
66    }
67
68    /// Upserts the raw JSON value for a code.
69    pub async fn set(&self, code: &str, value: &serde_json::Value) -> Result<(), SettingsError> {
70        store::set(&self.db, code, value).await?;
71        let _ = self.cache.bump(NAMESPACE).await;
72        Ok(())
73    }
74}
75
76#[cfg(test)]
77mod namespace_tests {
78    use super::*;
79    use laterite_core::{Module, ModuleId};
80
81    #[test]
82    fn the_registered_namespace_is_the_constant() {
83        let owner = ModuleId::new(MODULE_ID);
84        assert_eq!(SettingsModule.id(), owner);
85        assert_eq!(
86            laterite_core::cache::CacheNamespace::new("values").qualified(owner),
87            NAMESPACE
88        );
89    }
90}
91
92use crate::form::ValueFieldView;
93use std::collections::HashMap;
94
95use askama::Template;
96use axum::response::{IntoResponse, Redirect, Response};
97use laterite_core::{t, Text, Translator};
98use serde::Serialize;
99use serde_json::{Map, Value};
100
101use crate::{render, render_error, AdminState};
102
103/// One editable field of a settings model.
104///
105/// A settings screen renders [`FormField`](crate::form::FormField)s, the same
106/// descriptors a list or form screen uses: the module declares which fields its
107/// settings model has, and the framework renders and stores them through the
108/// field-type registry. There is no settings-specific field vocabulary.
109pub use crate::form::FormField;
110
111/// A settings model surfaced in the admin: a storage `code`, a `category` and
112/// `order` that place it in the index, and the fields to edit.
113#[derive(Debug, Clone, Serialize)]
114#[non_exhaustive]
115pub struct SettingsItem {
116    /// Storage key. Matches the model's `SettingsModel::CODE`.
117    ///
118    /// This is a storage key and never a URL. It is dotted, like the module
119    /// identity it derives from, and a dotted identifier in a path is how these
120    /// screens spent months at `/settings/rainmill.discovery.robots`. Where the
121    /// screen mounts is [`SettingsItem::route`], resolved at boot through the
122    /// module's namespace like every other contributed screen.
123    pub code: String,
124    /// The item's label, description, and category heading, localized at render.
125    pub label: Text,
126    pub description: Text,
127    /// Group heading in the index.
128    pub category: Text,
129    /// A fuller explanation, shown as a note at the top of this screen and
130    /// nowhere else. Where [`SettingsItem::description`] must stay short enough
131    /// to read in a sidebar, this has room to say why the screen exists.
132    pub hint: Option<Text>,
133    /// Ask before leaving this screen with changes unsaved. Default `true`.
134    pub confirm_leave: bool,
135    /// The band the item belongs to; see [`laterite_core::arrange::slot`].
136    /// The application band unless the item says otherwise.
137    pub slot: i32,
138    /// Weight within the band (lower sorts first).
139    pub order: i32,
140    /// Icon name shown beside the item in the context sidebar (a Lucide name
141    /// such as `users` or `shield`). `None` falls back to a generic glyph.
142    pub icon: Option<String>,
143    /// Permission required to edit, enforced by middleware. `None` means any
144    /// authenticated operator.
145    pub permission: Option<String>,
146    /// When set, the item links to this route (e.g. a resource list) instead of
147    /// its settings form. Used to place list/form screens (like Administrators)
148    /// in the settings menu rather than the main menu.
149    pub link: Option<String>,
150    pub fields: Vec<FormField>,
151    /// Where this screen mounts, relative to the admin root, resolved at boot
152    /// from the contributing module's namespace. `None` until then.
153    pub(crate) route: Option<String>,
154    /// Stored once per operator, among their preferences, and edited from
155    /// the Preferences page rather than Settings. See [`store::PreferencesModel`].
156    pub per_operator: bool,
157}
158
159/// The longest a settings description may be: two lines of the context sidebar,
160/// which renders at 211 pixels and 12px type, so roughly 38 characters a line.
161/// The reference system's own descriptions run 27 to 90 characters with a median
162/// of 53, so this sits inside the range a mature panel actually uses.
163pub(crate) const MAX_DESCRIPTION: usize = 72;
164
165impl SettingsItem {
166    /// A settings screen for `code` (the model's `SettingsModel::CODE`), titled
167    /// `label`, editing `fields`.
168    ///
169    /// Everything else takes a default and is opted into through a builder
170    /// method, so a field added later costs existing callers nothing.
171    pub fn new(code: impl Into<String>, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
172        Self {
173            code: code.into(),
174            label: label.into(),
175            description: Text::from(""),
176            category: Text::from(""),
177            hint: None,
178            confirm_leave: true,
179            slot: laterite_core::arrange::slot::APPLICATION,
180            order: 0,
181            icon: None,
182            permission: None,
183            link: None,
184            fields,
185            route: None,
186            per_operator: false,
187        }
188    }
189
190    /// Keeps a value per operator, edited from their Preferences page. The
191    /// permission still applies; the category is not used.
192    pub fn per_operator(mut self) -> Self {
193        self.per_operator = true;
194        self
195    }
196
197    /// The sentence under the item's name in the index and sidebar.
198    ///
199    /// Keep it to two lines in the sidebar, which is 72 characters at the
200    /// panel's width. A description longer than that stops being a label and
201    /// starts being a lecture in a column 211 pixels wide; put the explanation
202    /// in [`SettingsItem::hint`], where the reader has already chosen to look.
203    pub fn description(mut self, text: impl Into<Text>) -> Self {
204        let text = text.into();
205        debug_assert!(
206            text.source().chars().count() <= MAX_DESCRIPTION,
207            "settings description is {} characters, over the {MAX_DESCRIPTION} that \
208             fit two sidebar lines; shorten it or move the detail to `hint`: {:?}",
209            text.source().chars().count(),
210            text.source(),
211        );
212        self.description = text;
213        self
214    }
215
216    /// A fuller explanation for the top of this screen. Unlike the description,
217    /// it has no length limit: the reader has opened the screen.
218    pub fn hint(mut self, text: impl Into<Text>) -> Self {
219        self.hint = Some(text.into());
220        self
221    }
222
223    /// Whether leaving this screen with changes unsaved asks first. Default
224    /// `true`.
225    pub fn confirm_leave(mut self, ask: bool) -> Self {
226        self.confirm_leave = ask;
227        self
228    }
229
230    /// The group heading this item sorts under.
231    pub fn category(mut self, text: impl Into<Text>) -> Self {
232        self.category = text.into();
233        self
234    }
235
236    /// Weight within the band; lower sorts first.
237    pub fn order(mut self, order: i32) -> Self {
238        self.order = order;
239        self
240    }
241
242    /// The band the item belongs to; see [`laterite_core::arrange::slot`].
243    pub fn slot(mut self, slot: i32) -> Self {
244        self.slot = slot;
245        self
246    }
247
248    /// A Lucide icon name shown beside the item.
249    pub fn icon(mut self, icon: impl Into<String>) -> Self {
250        self.icon = Some(icon.into());
251        self
252    }
253
254    /// The permission required to edit, enforced by middleware.
255    pub fn permission(mut self, permission: impl Into<String>) -> Self {
256        self.permission = Some(permission.into());
257        self
258    }
259
260    /// Point the item at an existing route instead of its own settings form, so
261    /// a list screen can sit in the settings menu.
262    pub fn link(mut self, link: impl Into<String>) -> Self {
263        self.link = Some(link.into());
264        self
265    }
266
267    /// Records where this screen mounts. Called once at boot, after the
268    /// contributing module's namespace has been resolved.
269    pub(crate) fn mount_at(&mut self, route: impl Into<String>) {
270        self.route = Some(route.into());
271    }
272
273    /// Mounts this screen at a fixed path rather than a module namespace.
274    ///
275    /// For the panel's own settings, which belong to no vendor: they configure
276    /// the admin itself, so they sit under `/settings` with a plain name
277    /// (`/settings/branding`) rather than carrying `laterite` through a URL an
278    /// operator reads. A module's settings live in the module's namespace.
279    pub(crate) fn at(mut self, route: impl Into<String>) -> Self {
280        self.route = Some(route.into());
281        self
282    }
283
284    /// The item's own screen, relative to the admin root, ignoring any `link`.
285    ///
286    /// Before boot resolves it, this falls back to a path under `/settings`
287    /// built from the code's segments rather than the dotted code itself, so
288    /// even the fallback is a URL rather than an identifier. See
289    /// [`SettingsItem::at`] for why the panel's own settings pin theirs.
290    pub(crate) fn route(&self) -> String {
291        match &self.route {
292            Some(route) => route.clone(),
293            None if self.per_operator => format!("/preferences/{}", self.code.replace('.', "/")),
294            None => format!("/settings/{}", self.code.replace('.', "/")),
295        }
296    }
297
298    /// Where this item leads, resolved under the admin mount (`admin_path`): its
299    /// `link` target if set, else its own settings screen. Both are authored
300    /// relative to the admin root, so this prepends the mount.
301    pub fn path(&self, admin_path: &str) -> String {
302        match &self.link {
303            Some(link) => format!("{admin_path}{link}"),
304            None => format!("{admin_path}{}", self.route()),
305        }
306    }
307}
308
309/// Renders the settings index: a prompt to pick a section. The context sidebar
310/// itself is resolved by the auth guard and rendered by the shell.
311pub(crate) fn index(shell: crate::Shell) -> Response {
312    render(SettingsIndexTemplate { shell })
313}
314
315/// Renders the edit form for one item, populated from its stored value. The
316/// context sidebar (with this item active) comes from the shell.
317pub(crate) async fn edit_form(
318    state: &AdminState,
319    item: &SettingsItem,
320    shell: crate::Shell,
321    user: &laterite_auth::AuthenticatedUser,
322) -> Response {
323    let mut stored = match stored_value(state, item, user).await {
324        Some(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
325        None => return render_error(),
326    };
327    // Prefill unset fields from config so they show the current effective value
328    // rather than opening blank. Display only; nothing is written.
329    prefill_from_config(item, &mut stored, &state.app_name);
330    render(build(
331        item,
332        &state.field_types,
333        state.overrides.as_ref(),
334        None,
335        &stored,
336        &shell,
337    ))
338}
339
340/// Persists submitted values as the item's JSON object, then returns to the index.
341pub(crate) async fn update(
342    state: &AdminState,
343    item: &SettingsItem,
344    data: HashMap<String, String>,
345    shell: crate::Shell,
346    session: &crate::session::SessionHandle,
347    user: &laterite_auth::AuthenticatedUser,
348) -> Response {
349    // A field type that refuses its input refuses the save: the screen says so
350    // rather than storing a value every later read has to defend against.
351    let value = match collect(item, &state.field_types, &data) {
352        Ok(value) => value,
353        Err(message) => {
354            let stored = stored_value(state, item, user)
355                .await
356                .flatten()
357                .unwrap_or(Value::Null);
358            return render(build(
359                item,
360                &state.field_types,
361                state.overrides.as_ref(),
362                Some(message),
363                &stored,
364                &shell,
365            ));
366        }
367    };
368    let written = if item.per_operator {
369        store::set_for(&state.db, user.user.id, &item.code, &value)
370            .await
371            .is_ok()
372    } else {
373        state.settings_store().set(&item.code, &value).await.is_ok()
374    };
375    match written {
376        true => {
377            // The stored value can hold secrets, so the audit records which
378            // settings model changed, not the new contents. An operator's own
379            // preference is theirs alone and goes unrecorded, like their timezone.
380            if !item.per_operator {
381                crate::audit::record(
382                    state,
383                    user,
384                    "backend.settings.update",
385                    Some("settings"),
386                    Some(item.code.as_str()),
387                    None,
388                    None,
389                )
390                .await;
391            }
392            session.push_flash(
393                crate::session::FlashLevel::Success,
394                if item.per_operator {
395                    t!("Preferences saved.")
396                } else {
397                    t!("Settings saved.")
398                },
399            );
400            // Back to the screen that was saved, not the index. Saving is not
401            // leaving: an operator adjusting one setting usually adjusts the
402            // next, and being thrown back to the menu loses their place.
403            Redirect::to(&item.path(&state.admin_path)).into_response()
404        }
405        false => render(build(
406            item,
407            &state.field_types,
408            state.overrides.as_ref(),
409            Some(t!("Could not save. Please try again.")),
410            &value,
411            &shell,
412        )),
413    }
414}
415
416/// The item's stored value: the deployment's, or this operator's own for a
417/// per-operator item. `None` when the read failed.
418async fn stored_value(
419    state: &AdminState,
420    item: &SettingsItem,
421    user: &laterite_auth::AuthenticatedUser,
422) -> Option<Option<Value>> {
423    if item.per_operator {
424        store::get_for(&state.db, user.user.id, &item.code)
425            .await
426            .ok()
427    } else {
428        store::get(&state.db, &item.code).await.ok()
429    }
430}
431
432/// Builds the JSON object to store from the submitted form data, typing each
433/// field through its field type.
434///
435/// The same `to_attr` a descriptor form writes through, so a switch stores a
436/// bool, a repeater stores an array of objects, and a module's own field type
437/// behaves identically here and on a form. A type that refuses its input refuses
438/// the save, naming the field.
439fn collect(
440    item: &SettingsItem,
441    types: &crate::field::FieldRegistry,
442    data: &HashMap<String, String>,
443) -> Result<Value, Text> {
444    crate::form::value_collect(&item.fields, types, data)
445}
446
447/// The settings index as a hierarchy: a deployment may rearrange it, and so
448/// may an operator for themselves.
449pub const HIERARCHY: &str = "backend.settings";
450
451pub(crate) fn hierarchy() -> laterite_core::arrange::Hierarchy {
452    laterite_core::arrange::Hierarchy::new(HIERARCHY).operator_adjustable()
453}
454
455struct Placed<'a> {
456    item: &'a SettingsItem,
457    position: laterite_core::arrange::Position,
458}
459
460impl laterite_core::arrange::Arranged for Placed<'_> {
461    fn key(&self) -> &str {
462        &self.item.code
463    }
464    fn position(&self) -> &laterite_core::arrange::Position {
465        &self.position
466    }
467    fn label(&self) -> &str {
468        self.item.label.source()
469    }
470}
471
472/// One boot log line for the settings index, as [`sidebar_groups`] will
473/// arrange it.
474pub(crate) fn log_arrangement(items: &[SettingsItem], order: Option<&[String]>) {
475    let placed: Vec<Placed<'_>> = items
476        .iter()
477        .map(|item| Placed {
478            item,
479            position: laterite_core::arrange::Position::new()
480                .slot(item.slot)
481                .order(item.order),
482        })
483        .collect();
484    let arranged = laterite_core::arrange::arrange(
485        &hierarchy(),
486        placed,
487        laterite_core::arrange::Overrides {
488            deployment: order,
489            operator: None,
490        },
491    )
492    .unwrap_or_default();
493    let keys: Vec<String> = arranged
494        .iter()
495        .map(|p| {
496            format!(
497                "{} ({})",
498                p.item.code,
499                laterite_core::arrange::slot::name(p.item.slot)
500            )
501        })
502        .collect();
503    tracing::info!(hierarchy = HIERARCHY, arrangement = %keys.join(" > "));
504}
505
506/// Builds the context-sidebar groups: the items arranged by the engine (slot,
507/// order, label, then the deployment's `[backend.order]` for
508/// `backend.settings`), grouped by category in order of first appearance,
509/// with `active_code` (if any) marked.
510pub(crate) fn sidebar_groups(
511    items: &[SettingsItem],
512    admin_path: &str,
513    active_code: Option<&str>,
514    tr: &Translator,
515    icons: crate::icons::Icons<'_>,
516    order: Option<&[String]>,
517    own_order: Option<&[String]>,
518) -> Vec<CategoryView> {
519    let placed: Vec<Placed<'_>> = items
520        .iter()
521        .map(|item| Placed {
522            item,
523            position: laterite_core::arrange::Position::new()
524                .slot(item.slot)
525                .order(item.order),
526        })
527        .collect();
528    let arranged = laterite_core::arrange::arrange(
529        &hierarchy(),
530        placed,
531        laterite_core::arrange::Overrides {
532            deployment: order,
533            operator: own_order,
534        },
535    )
536    .unwrap_or_default();
537    let mut groups: Vec<CategoryView> = Vec::new();
538    for placed in arranged {
539        let i = placed.item;
540        let category = tr.t(&i.category);
541        let view = ItemView {
542            key: i.code.clone(),
543            label: tr.t(&i.label),
544            description: tr.t(&i.description),
545            path: i.path(admin_path),
546            icon: icons.render(i.icon.as_deref()),
547            active: active_code == Some(i.code.as_str()),
548        };
549        match groups.iter_mut().find(|g| g.name == category) {
550            Some(group) => group.items.push(view),
551            None => groups.push(CategoryView {
552                name: category,
553                items: vec![view],
554            }),
555        }
556    }
557    groups
558}
559
560/// Prefills unset display fields from configuration before the form renders, so
561/// a field shows the current effective value rather than opening blank. The
562/// brand's application name prefills from the configured `app.name` when no brand
563/// setting is saved. This is display only: it writes nothing, so a later config
564/// change still propagates (persisting it would freeze the value). It is not a
565/// database seeder; that is a separate, deferred capability.
566fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
567    if item.code != brand::BrandSetting::CODE {
568        return;
569    }
570    if let Value::Object(map) = stored {
571        let blank = map
572            .get("app_name")
573            .and_then(Value::as_str)
574            .unwrap_or("")
575            .trim()
576            .is_empty();
577        if blank {
578            map.insert("app_name".to_string(), Value::String(app_name.to_string()));
579        }
580    }
581}
582
583fn build(
584    item: &SettingsItem,
585    types: &crate::field::FieldRegistry,
586    overrides: &dyn crate::field::OverrideResolver,
587    error: Option<Text>,
588    stored: &Value,
589    shell: &crate::Shell,
590) -> SettingsFormTemplate {
591    let fields =
592        crate::form::value_views(&item.fields, types, overrides, stored, shell, &item.code);
593    SettingsFormTemplate {
594        title: shell.tt(&item.label),
595        description: shell.tt(&item.description),
596        hint: item.hint.as_ref().map(|h| shell.tt(h)),
597        action: item.path(&shell.base),
598        error: error.map(|e| shell.tt(&e)),
599        fields,
600        leave_off: !item.confirm_leave,
601        shell: shell.clone(),
602    }
603}
604
605/// One category block in the context sidebar. Rendered by the shell.
606#[derive(Clone)]
607pub(crate) struct CategoryView {
608    pub(crate) name: String,
609    pub(crate) items: Vec<ItemView>,
610}
611
612impl CategoryView {
613    /// One group of entries, as a menu section renders in the sidebar.
614    pub(crate) fn new(name: String, items: Vec<ItemView>) -> Self {
615        Self { name, items }
616    }
617}
618
619/// One item in the context sidebar.
620#[derive(Clone)]
621pub(crate) struct ItemView {
622    /// The item's code, which an operator's own order names.
623    pub(crate) key: String,
624    pub(crate) label: String,
625    pub(crate) description: String,
626    pub(crate) path: String,
627    /// Inline SVG markup for the item's icon, rendered raw in the template.
628    pub(crate) icon: String,
629    /// Whether this is the item currently open, so the sidebar highlights it.
630    pub(crate) active: bool,
631}
632
633#[derive(Template)]
634#[template(path = "settings_index.html")]
635struct SettingsIndexTemplate {
636    shell: crate::Shell,
637}
638
639#[derive(Template)]
640#[template(path = "settings_form.html")]
641struct SettingsFormTemplate {
642    shell: crate::Shell,
643    title: String,
644    description: String,
645    /// The item's fuller explanation, localized. Supersedes `description` on
646    /// this screen, which is written for a sidebar.
647    hint: Option<String>,
648    action: String,
649    error: Option<String>,
650    fields: Vec<ValueFieldView>,
651    /// Leaving with changes unsaved does not ask.
652    leave_off: bool,
653}
654
655#[cfg(test)]
656mod field_system_tests {
657    use super::*;
658
659    #[test]
660    #[should_panic(expected = "over the 72")]
661    fn a_lecture_of_a_description_is_refused() {
662        // The shape the discovery plugin shipped with: 108 characters, four
663        // lines in a 211-pixel sidebar.
664        SettingsItem::new("acme.test", "Test", Vec::new()).description(
665            "What search engines and other crawlers may visit. The admin panel \
666             is always excluded, whatever you put here.",
667        );
668    }
669
670    #[test]
671    fn a_label_sized_description_passes_and_a_hint_carries_the_rest() {
672        let item = SettingsItem::new("acme.test", "Test", Vec::new())
673            .description("Which crawlers may visit, and what they may not.")
674            .hint(
675                "The admin panel is always excluded, whatever you put here, and a \
676                   crawler obeys only the block that names it.",
677            );
678        assert!(item.description.source().chars().count() <= MAX_DESCRIPTION);
679        // The hint has no ceiling: the reader has opened the screen.
680        assert!(item.hint.is_some());
681    }
682
683    fn item(fields: Vec<FormField>) -> SettingsItem {
684        SettingsItem {
685            code: "acme.test".to_string(),
686            label: Text::new(""),
687            description: Text::new(""),
688            category: Text::new(""),
689            hint: None,
690            confirm_leave: true,
691            slot: laterite_core::arrange::slot::APPLICATION,
692            order: 0,
693            icon: None,
694            permission: None,
695            link: None,
696            fields,
697            route: None,
698            per_operator: false,
699        }
700    }
701
702    fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
703        pairs
704            .iter()
705            .map(|(k, v)| (k.to_string(), v.to_string()))
706            .collect()
707    }
708
709    /// Settings and forms now share one field system, so a settings screen gets
710    /// every registered type and stores what that type says it stores.
711    #[test]
712    fn settings_store_what_the_field_type_says() {
713        let cfg = item(vec![
714            FormField::text("name", "Name"),
715            FormField::switch("enabled", "Enabled"),
716            FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
717            FormField::date("expires", "Expires"),
718        ]);
719        let stored = collect(
720            &cfg,
721            &crate::field::builtin_registry(),
722            &submitted(&[
723                ("name", "Acme"),
724                ("enabled", "on"),
725                ("freq", "daily"),
726                ("expires", "2026-12-31"),
727            ]),
728        )
729        .unwrap();
730
731        assert_eq!(stored["name"], "Acme");
732        // A bool, not the string "on": the switch field type decided that.
733        assert_eq!(stored["enabled"], serde_json::json!(true));
734        assert_eq!(stored["freq"], "daily");
735        assert_eq!(stored["expires"], "2026-12-31");
736    }
737
738    /// An unticked switch is absent from the submission and must store false,
739    /// or the box could never be cleared.
740    #[test]
741    fn an_unticked_switch_clears() {
742        let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
743        let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
744        assert_eq!(stored["enabled"], serde_json::json!(false));
745    }
746
747    /// The repeater works in settings because it is a field type, not a
748    /// settings-only widget: this is the whole point of the unification.
749    #[test]
750    fn a_repeater_works_in_settings() {
751        let cfg = item(vec![FormField::repeater(
752            "rules",
753            "Rules",
754            vec![
755                FormField::text("path", "Path"),
756                FormField::switch("allow", "Allow"),
757            ],
758        )]);
759        let stored = collect(
760            &cfg,
761            &crate::field::builtin_registry(),
762            &submitted(&[
763                ("rules[0][path]", "/private"),
764                ("rules[1][path]", "/public"),
765                ("rules[1][allow]", "on"),
766            ]),
767        )
768        .unwrap();
769
770        let rows = stored["rules"].as_array().unwrap();
771        assert_eq!(rows.len(), 2);
772        assert_eq!(rows[0]["path"], "/private");
773        assert_eq!(rows[0]["allow"], serde_json::json!(false));
774        assert_eq!(rows[1]["allow"], serde_json::json!(true));
775    }
776
777    #[test]
778    fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
779        let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
780        assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
781    }
782
783    #[test]
784    fn the_form_renders_each_control_through_its_type() {
785        let cfg = item(vec![
786            FormField::switch("enabled", "Enabled"),
787            FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
788        ]);
789        let html = build(
790            &cfg,
791            &crate::field::builtin_registry(),
792            &crate::field::NoOverrides,
793            None,
794            &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
795            &crate::Shell::test(),
796        )
797        .render()
798        .unwrap();
799
800        assert!(html.contains(r#"type="checkbox""#));
801        assert!(html.contains(r#"name="rules[0][path]""#));
802        assert!(html.contains(r#"value="/a""#));
803        assert!(html.contains("lat-repeater__blank"));
804    }
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810    use laterite_core::Db;
811
812    #[test]
813    fn brand_form_prefills_app_name_from_config_when_unset() {
814        let brand = brand::settings_item();
815        // Unset: the field prefills from the configured application name.
816        let mut unset = Value::Object(Map::new());
817        prefill_from_config(&brand, &mut unset, "Configured Name");
818        assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
819        // Already set: the stored value is left untouched.
820        let mut set = serde_json::json!({ "app_name": "Acme" });
821        prefill_from_config(&brand, &mut set, "Configured Name");
822        assert_eq!(set["app_name"], serde_json::json!("Acme"));
823        // A non-brand item is not prefilled.
824        let mut other = Value::Object(Map::new());
825        prefill_from_config(&item(), &mut other, "Configured Name");
826        assert!(other.get("app_name").is_none());
827    }
828
829    fn item() -> SettingsItem {
830        SettingsItem {
831            code: "test.log".to_string(),
832            label: "Log Settings".into(),
833            description: "What the log records.".into(),
834            category: "Logs".into(),
835            hint: None,
836            confirm_leave: true,
837            slot: laterite_core::arrange::slot::APPLICATION,
838            order: 10,
839            icon: None,
840            permission: None,
841            link: None,
842            fields: vec![
843                FormField::switch("log_events", "Log events"),
844                FormField::switch("log_requests", "Log requests"),
845                FormField::text("retention_days", "Retention (days)"),
846            ],
847            route: None,
848            per_operator: false,
849        }
850    }
851
852    fn state(db: Db) -> AdminState {
853        AdminState::new(
854            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
855            db,
856        )
857    }
858
859    /// A fresh test database with the settings table migrated in, on whichever
860    /// backend the run targets. Hold the returned guard for the test's lifetime.
861    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
862        laterite_core::testing::connect_test(&[migrations()]).await
863    }
864
865    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
866        pairs
867            .iter()
868            .map(|(k, v)| (k.to_string(), v.to_string()))
869            .collect()
870    }
871
872    #[test]
873    fn group_orders_categories_then_items() {
874        let items = vec![
875            SettingsItem {
876                code: "b".into(),
877                label: "Beta".into(),
878                description: String::new().into(),
879                category: "System".into(),
880                hint: None,
881                confirm_leave: true,
882                slot: laterite_core::arrange::slot::APPLICATION,
883                order: 20,
884                icon: None,
885                permission: None,
886                link: None,
887                fields: vec![],
888                route: None,
889                per_operator: false,
890            },
891            SettingsItem {
892                code: "a".into(),
893                label: "Alpha".into(),
894                description: String::new().into(),
895                category: "System".into(),
896                hint: None,
897                confirm_leave: true,
898                slot: laterite_core::arrange::slot::APPLICATION,
899                order: 10,
900                icon: None,
901                permission: None,
902                link: None,
903                fields: vec![],
904                route: None,
905                per_operator: false,
906            },
907            SettingsItem {
908                code: "l".into(),
909                label: "Logs".into(),
910                description: String::new().into(),
911                category: "Logs".into(),
912                hint: None,
913                confirm_leave: true,
914                slot: laterite_core::arrange::slot::APPLICATION,
915                order: 5,
916                icon: None,
917                permission: None,
918                link: None,
919                fields: vec![],
920                route: None,
921                per_operator: false,
922            },
923        ];
924        let groups = sidebar_groups(
925            &items,
926            "/admin",
927            None,
928            &Translator::new("en"),
929            crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
930            None,
931            None,
932        );
933        // "Logs" holds the first-arranged item, so it comes before "System".
934        assert_eq!(groups[0].name, "Logs");
935        assert_eq!(groups[1].name, "System");
936        // Within "System", Alpha (10) before Beta (20).
937        assert_eq!(groups[1].items[0].label, "Alpha");
938        assert_eq!(groups[1].items[1].label, "Beta");
939        // Nothing is active when no code is given.
940        assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
941    }
942
943    #[test]
944    fn group_marks_only_the_active_item() {
945        let items = vec![item()];
946        let groups = sidebar_groups(
947            &items,
948            "/admin",
949            Some("test.log"),
950            &Translator::new("en"),
951            crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
952            None,
953            None,
954        );
955        let active: Vec<&str> = groups
956            .iter()
957            .flat_map(|g| &g.items)
958            .filter(|i| i.active)
959            .map(|i| i.label.as_str())
960            .collect();
961        assert_eq!(active, ["Log Settings"]);
962    }
963
964    #[test]
965    fn settings_model_item_path_is_its_form() {
966        // Unresolved (no module namespace yet), the fallback still builds a
967        // path from the code's segments rather than using the dotted key.
968        assert_eq!(item().path("/admin"), "/admin/settings/test/log");
969        // The mount is honoured, so a relocated panel keeps consistent links.
970        assert_eq!(item().path("/manage"), "/manage/settings/test/log");
971
972        // Once boot resolves it into the module's namespace, that wins.
973        let mut resolved = item();
974        resolved.mount_at("/acme/logging/log");
975        assert_eq!(resolved.path("/admin"), "/admin/acme/logging/log");
976    }
977
978    #[test]
979    fn link_item_path_follows_the_link() {
980        let admins = crate::builtin_settings()
981            .into_iter()
982            .find(|i| i.code == "backend.administrators")
983            .unwrap();
984        assert_eq!(admins.category.source(), "Users");
985        assert!(admins.link.is_some());
986        assert!(admins.fields.is_empty());
987        // links to the resource list, not a settings form
988        assert_eq!(admins.path("/admin"), "/admin/users");
989        assert!(crate::builtin_settings()
990            .iter()
991            .any(|i| i.code == "backend.roles"));
992    }
993
994    #[tokio::test]
995    async fn update_persists_typed_values() {
996        let (db, _guard) = test_db().await;
997        let st = state(db.clone());
998
999        let it = item();
1000        let resp = update(
1001            &st,
1002            &it,
1003            // log_requests is absent, as an unchecked checkbox would be.
1004            data(&[("log_events", "on"), ("retention_days", "30")]),
1005            crate::Shell::test(),
1006            &crate::session::SessionHandle::from_blob(None),
1007            &crate::audit::test_actor(),
1008        )
1009        .await;
1010        assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
1011
1012        let stored = store::get(&db, "test.log").await.unwrap().unwrap();
1013        assert_eq!(stored["log_events"], serde_json::json!(true));
1014        assert_eq!(stored["log_requests"], serde_json::json!(false));
1015        assert_eq!(stored["retention_days"], serde_json::json!("30"));
1016    }
1017
1018    #[test]
1019    fn index_renders() {
1020        let resp = index(crate::Shell::test());
1021        assert_eq!(resp.status(), axum::http::StatusCode::OK);
1022    }
1023
1024    #[tokio::test]
1025    async fn edit_form_renders_for_unset_item() {
1026        let (db, _guard) = test_db().await;
1027        let st = state(db);
1028        // No stored value yet: the form still renders (fields fall back to defaults).
1029        let it = item();
1030        let resp = edit_form(&st, &it, crate::Shell::test(), &crate::audit::test_actor()).await;
1031        assert_eq!(resp.status(), axum::http::StatusCode::OK);
1032    }
1033}