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