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