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::{get, load, save, set, SettingsError, SettingsModel};
22
23/// The `laterite.settings` module: the framework's settings store table.
24pub struct SettingsModule;
25
26impl laterite_core::Module for SettingsModule {
27    fn id(&self) -> laterite_core::ModuleId {
28        laterite_core::ModuleId::new(MODULE_ID)
29    }
30    fn migrations(&self) -> laterite_core::MigrationSet {
31        migrations()
32    }
33}
34
35use std::collections::HashMap;
36
37use askama::Template;
38use axum::response::{IntoResponse, Redirect, Response};
39use laterite_core::validation::Mode;
40use laterite_core::{t, Text, Translator};
41use serde::Serialize;
42use serde_json::{Map, Value};
43
44use crate::{render, render_error, AdminState};
45
46/// One editable field of a settings model.
47///
48/// A settings screen renders [`FormField`](crate::form::FormField)s, the same
49/// descriptors a list or form screen uses: the module declares which fields its
50/// settings model has, and the framework renders and stores them through the
51/// field-type registry. There is no settings-specific field vocabulary.
52pub use crate::form::FormField;
53
54/// A settings model surfaced in the admin: a storage `code`, a `category` and
55/// `order` that place it in the index, and the fields to edit.
56#[derive(Debug, Clone, Serialize)]
57pub struct SettingsItem {
58    /// Storage key. Matches the model's `SettingsModel::CODE`.
59    pub code: String,
60    /// The item's label, description, and category heading, localized at render.
61    pub label: Text,
62    pub description: Text,
63    /// Group heading in the index.
64    pub category: Text,
65    /// Weight within the category (lower sorts first).
66    pub order: i32,
67    /// Icon name shown beside the item in the context sidebar (a Lucide name
68    /// such as `users` or `shield`). `None` falls back to a generic glyph.
69    pub icon: Option<String>,
70    /// Permission required to edit, enforced by middleware. `None` means any
71    /// authenticated operator.
72    pub permission: Option<String>,
73    /// When set, the item links to this route (e.g. a resource list) instead of
74    /// its settings form. Used to place list/form screens (like Administrators)
75    /// in the settings menu rather than the main menu.
76    pub link: Option<String>,
77    pub fields: Vec<FormField>,
78}
79
80impl SettingsItem {
81    /// Where this item leads, resolved under the admin mount (`admin_path`): its
82    /// `link` target if set, else its own settings form. Both `link` and the form
83    /// path are authored relative to the admin root, so this prepends the mount.
84    pub fn path(&self, admin_path: &str) -> String {
85        match &self.link {
86            Some(link) => format!("{admin_path}{link}"),
87            None => format!("{admin_path}/settings/{}", self.code),
88        }
89    }
90}
91
92/// Renders the settings index: a prompt to pick a section. The context sidebar
93/// itself is resolved by the auth guard and rendered by the shell.
94pub(crate) fn index(shell: crate::Shell) -> Response {
95    render(SettingsIndexTemplate { shell })
96}
97
98/// Renders the edit form for one item, populated from its stored value. The
99/// context sidebar (with this item active) comes from the shell.
100pub(crate) async fn edit_form(
101    state: &AdminState,
102    item: &SettingsItem,
103    shell: crate::Shell,
104) -> Response {
105    let mut stored = match store::get(&state.db, &item.code).await {
106        Ok(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
107        Err(_) => return render_error(),
108    };
109    // Prefill unset fields from config so they show the current effective value
110    // rather than opening blank. Display only; nothing is written.
111    prefill_from_config(item, &mut stored, &state.app_name);
112    render(build(
113        item,
114        &state.field_types,
115        state.overrides.as_ref(),
116        None,
117        &stored,
118        &shell,
119    ))
120}
121
122/// Persists submitted values as the item's JSON object, then returns to the index.
123pub(crate) async fn update(
124    state: &AdminState,
125    item: &SettingsItem,
126    data: HashMap<String, String>,
127    shell: crate::Shell,
128    session: &crate::session::SessionHandle,
129    user: &laterite_auth::AuthenticatedUser,
130) -> Response {
131    // A field type that refuses its input refuses the save: the screen says so
132    // rather than storing a value every later read has to defend against.
133    let value = match collect(item, &state.field_types, &data) {
134        Ok(value) => value,
135        Err(message) => {
136            let stored = store::get(&state.db, &item.code)
137                .await
138                .ok()
139                .flatten()
140                .unwrap_or(Value::Null);
141            return render(build(
142                item,
143                &state.field_types,
144                state.overrides.as_ref(),
145                Some(message),
146                &stored,
147                &shell,
148            ));
149        }
150    };
151    match store::set(&state.db, &item.code, &value).await {
152        Ok(()) => {
153            // The brand is cached for display; a save to it must invalidate the
154            // cache so the next page reflects the new name.
155            if item.code == brand::BrandSetting::CODE {
156                state.invalidate_brand();
157            }
158            // The stored value can hold secrets, so the audit records which
159            // settings model changed, not the new contents.
160            crate::audit::record(
161                state,
162                user,
163                "backend.settings.update",
164                Some("settings"),
165                Some(item.code.as_str()),
166                None,
167            )
168            .await;
169            session.push_flash(crate::session::FlashLevel::Success, t!("Settings saved."));
170            Redirect::to(&format!("{}/settings", state.admin_path)).into_response()
171        }
172        Err(_) => render(build(
173            item,
174            &state.field_types,
175            state.overrides.as_ref(),
176            Some(t!("Could not save. Please try again.")),
177            &value,
178            &shell,
179        )),
180    }
181}
182
183/// Builds the JSON object to store from the submitted form data, typing each
184/// field through its field type.
185///
186/// The same `to_attr` a descriptor form writes through, so a switch stores a
187/// bool, a repeater stores an array of objects, and a module's own field type
188/// behaves identically here and on a form. A type that refuses its input refuses
189/// the save, naming the field.
190fn collect(
191    item: &SettingsItem,
192    types: &crate::field::FieldRegistry,
193    data: &HashMap<String, String>,
194) -> Result<Value, Text> {
195    let mut object = Map::new();
196    for field in &item.fields {
197        let Some(field_type) = types.get(&field.field_type) else {
198            return Err(t!("This screen uses a field type that is not registered."));
199        };
200        let opts = field_type
201            .resolve_options(&field.options, types)
202            .map_err(|_| t!("This screen has a malformed field."))?;
203        let submitted = crate::field::SubmittedField::new(&field.name, data);
204        // Settings always write every declared key: a settings blob has no
205        // previous row to fall back to the way a table column does.
206        let value = match field_type.to_attr(&submitted, &opts, Mode::Update) {
207            Ok(Some(value)) => attr_to_json(value),
208            Ok(None) => Value::String(String::new()),
209            Err(_) => return Err(t!("Check the values and try again.")),
210        };
211        object.insert(field.name.clone(), value);
212    }
213    Ok(Value::Object(object))
214}
215
216/// A stored attribute as the JSON a settings blob holds.
217fn attr_to_json(value: laterite_core::AttrValue) -> Value {
218    use laterite_core::AttrValue;
219    match value {
220        AttrValue::Null => Value::String(String::new()),
221        AttrValue::Bool(b) => Value::Bool(b),
222        AttrValue::Int(i) => Value::from(i),
223        AttrValue::Float(f) => Value::from(f),
224        AttrValue::Json(j) => j,
225        other => Value::String(other.to_text().unwrap_or_default()),
226    }
227}
228
229/// Builds the context-sidebar groups: items grouped by category and ordered
230/// deterministically, with `active_code` (if any) marked. Categories sort by
231/// their lowest item `order`, then name; items by `order`, then label. This is
232/// the simple-weight stage; relative-anchor ordering with an operator override
233/// is a later refinement.
234pub(crate) fn sidebar_groups(
235    items: &[SettingsItem],
236    admin_path: &str,
237    active_code: Option<&str>,
238    tr: &Translator,
239) -> Vec<CategoryView> {
240    // Group by the localized category heading (a shared source localizes identically).
241    let mut by_category: HashMap<String, Vec<&SettingsItem>> = HashMap::new();
242    for item in items {
243        by_category
244            .entry(tr.t(&item.category))
245            .or_default()
246            .push(item);
247    }
248    let mut groups: Vec<CategoryView> = by_category
249        .into_iter()
250        .map(|(category, mut items)| {
251            // Order by weight, then the label's source for a stable tie-break.
252            items.sort_by(|a, b| {
253                a.order
254                    .cmp(&b.order)
255                    .then_with(|| a.label.source().cmp(b.label.source()))
256            });
257            CategoryView {
258                min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
259                name: category,
260                items: items
261                    .iter()
262                    .map(|i| ItemView {
263                        label: tr.t(&i.label),
264                        description: tr.t(&i.description),
265                        path: i.path(admin_path),
266                        icon: crate::icons::svg(i.icon.as_deref()),
267                        active: active_code == Some(i.code.as_str()),
268                    })
269                    .collect(),
270            }
271        })
272        .collect();
273    groups.sort_by(|a, b| {
274        a.min_order
275            .cmp(&b.min_order)
276            .then_with(|| a.name.cmp(&b.name))
277    });
278    groups
279}
280
281/// Prefills unset display fields from configuration before the form renders, so
282/// a field shows the current effective value rather than opening blank. The
283/// brand's application name prefills from the configured `app.name` when no brand
284/// setting is saved. This is display only: it writes nothing, so a later config
285/// change still propagates (persisting it would freeze the value). It is not a
286/// database seeder; that is a separate, deferred capability.
287fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
288    if item.code != brand::BrandSetting::CODE {
289        return;
290    }
291    if let Value::Object(map) = stored {
292        let blank = map
293            .get("app_name")
294            .and_then(Value::as_str)
295            .unwrap_or("")
296            .trim()
297            .is_empty();
298        if blank {
299            map.insert("app_name".to_string(), Value::String(app_name.to_string()));
300        }
301    }
302}
303
304fn build(
305    item: &SettingsItem,
306    types: &crate::field::FieldRegistry,
307    overrides: &dyn crate::field::OverrideResolver,
308    error: Option<Text>,
309    stored: &Value,
310    shell: &crate::Shell,
311) -> SettingsFormTemplate {
312    let fields = item
313        .fields
314        .iter()
315        .map(|f| {
316            let label = shell.tt(&f.label);
317            let control = match types.get(&f.field_type) {
318                Some(ft) => {
319                    let opts = ft
320                        .resolve_options(&f.options, types)
321                        .unwrap_or_else(|_| crate::field::ResolvedOptions::none());
322                    let value = match stored.get(&f.name) {
323                        Some(Value::Array(rows)) => {
324                            crate::field::FieldValue::Json(Value::Array(rows.clone()))
325                        }
326                        Some(Value::String(text)) => {
327                            crate::field::FieldValue::Text(ft.to_control(Some(text), &opts))
328                        }
329                        Some(Value::Null) | None => {
330                            crate::field::FieldValue::Text(ft.to_control(None, &opts))
331                        }
332                        Some(other) => crate::field::FieldValue::Text(
333                            ft.to_control(Some(&other.to_string()), &opts),
334                        ),
335                    };
336                    let cx = crate::field::FieldCx {
337                        name: &f.name,
338                        id: &f.name,
339                        label: &label,
340                        value: &value,
341                        required: false,
342                        opts: &opts,
343                        base: &shell.base,
344                    };
345                    let scope = crate::field::OverrideScope {
346                        surface: crate::field::Surface::Field,
347                        view_key: &f.field_type,
348                        resource: Some(&item.code),
349                        field: Some(&f.name),
350                    };
351                    crate::field::render_field(ft.as_ref(), overrides, &scope, &cx).into_string()
352                }
353                None => String::new(),
354            };
355            FieldView {
356                label,
357                help: f.help.as_ref().map(|h| shell.tt(h)),
358                control,
359            }
360        })
361        .collect();
362    SettingsFormTemplate {
363        title: shell.tt(&item.label),
364        description: shell.tt(&item.description),
365        action: item.path(&shell.base),
366        error: error.map(|e| shell.tt(&e)),
367        fields,
368        shell: shell.clone(),
369    }
370}
371
372/// One category block in the context sidebar. Rendered by the shell.
373#[derive(Clone)]
374pub(crate) struct CategoryView {
375    pub(crate) name: String,
376    min_order: i32,
377    pub(crate) items: Vec<ItemView>,
378}
379
380/// One item in the context sidebar.
381#[derive(Clone)]
382pub(crate) struct ItemView {
383    pub(crate) label: String,
384    pub(crate) description: String,
385    pub(crate) path: String,
386    /// Inline SVG markup for the item's icon, rendered raw in the template.
387    pub(crate) icon: &'static str,
388    /// Whether this is the item currently open, so the sidebar highlights it.
389    pub(crate) active: bool,
390}
391
392#[derive(Template)]
393#[template(path = "settings_index.html")]
394struct SettingsIndexTemplate {
395    shell: crate::Shell,
396}
397
398struct FieldView {
399    label: String,
400    help: Option<String>,
401    /// The control, rendered by the field's own type.
402    control: String,
403}
404
405#[derive(Template)]
406#[template(path = "settings_form.html")]
407struct SettingsFormTemplate {
408    shell: crate::Shell,
409    title: String,
410    description: String,
411    action: String,
412    error: Option<String>,
413    fields: Vec<FieldView>,
414}
415
416#[cfg(test)]
417mod field_system_tests {
418    use super::*;
419
420    fn item(fields: Vec<FormField>) -> SettingsItem {
421        SettingsItem {
422            code: "acme.test".to_string(),
423            label: Text::new(""),
424            description: Text::new(""),
425            category: Text::new(""),
426            order: 0,
427            icon: None,
428            permission: None,
429            link: None,
430            fields,
431        }
432    }
433
434    fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
435        pairs
436            .iter()
437            .map(|(k, v)| (k.to_string(), v.to_string()))
438            .collect()
439    }
440
441    /// Settings and forms now share one field system, so a settings screen gets
442    /// every registered type and stores what that type says it stores.
443    #[test]
444    fn settings_store_what_the_field_type_says() {
445        let cfg = item(vec![
446            FormField::text("name", "Name"),
447            FormField::switch("enabled", "Enabled"),
448            FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
449            FormField::date("expires", "Expires"),
450        ]);
451        let stored = collect(
452            &cfg,
453            &crate::field::builtin_registry(),
454            &submitted(&[
455                ("name", "Acme"),
456                ("enabled", "on"),
457                ("freq", "daily"),
458                ("expires", "2026-12-31"),
459            ]),
460        )
461        .unwrap();
462
463        assert_eq!(stored["name"], "Acme");
464        // A bool, not the string "on": the switch field type decided that.
465        assert_eq!(stored["enabled"], serde_json::json!(true));
466        assert_eq!(stored["freq"], "daily");
467        assert_eq!(stored["expires"], "2026-12-31");
468    }
469
470    /// An unticked switch is absent from the submission and must store false,
471    /// or the box could never be cleared.
472    #[test]
473    fn an_unticked_switch_clears() {
474        let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
475        let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
476        assert_eq!(stored["enabled"], serde_json::json!(false));
477    }
478
479    /// The repeater works in settings because it is a field type, not a
480    /// settings-only widget: this is the whole point of the unification.
481    #[test]
482    fn a_repeater_works_in_settings() {
483        let cfg = item(vec![FormField::repeater(
484            "rules",
485            "Rules",
486            vec![
487                FormField::text("path", "Path"),
488                FormField::switch("allow", "Allow"),
489            ],
490        )]);
491        let stored = collect(
492            &cfg,
493            &crate::field::builtin_registry(),
494            &submitted(&[
495                ("rules[0][path]", "/private"),
496                ("rules[1][path]", "/public"),
497                ("rules[1][allow]", "on"),
498            ]),
499        )
500        .unwrap();
501
502        let rows = stored["rules"].as_array().unwrap();
503        assert_eq!(rows.len(), 2);
504        assert_eq!(rows[0]["path"], "/private");
505        assert_eq!(rows[0]["allow"], serde_json::json!(false));
506        assert_eq!(rows[1]["allow"], serde_json::json!(true));
507    }
508
509    #[test]
510    fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
511        let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
512        assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
513    }
514
515    #[test]
516    fn the_form_renders_each_control_through_its_type() {
517        let cfg = item(vec![
518            FormField::switch("enabled", "Enabled"),
519            FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
520        ]);
521        let html = build(
522            &cfg,
523            &crate::field::builtin_registry(),
524            &crate::field::NoOverrides,
525            None,
526            &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
527            &crate::Shell::test(),
528        )
529        .render()
530        .unwrap();
531
532        assert!(html.contains(r#"type="checkbox""#));
533        assert!(html.contains(r#"name="rules[0][path]""#));
534        assert!(html.contains(r#"value="/a""#));
535        assert!(html.contains("lat-repeater__blank"));
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use laterite_core::Db;
543
544    #[test]
545    fn brand_form_prefills_app_name_from_config_when_unset() {
546        let brand = brand::settings_item();
547        // Unset: the field prefills from the configured application name.
548        let mut unset = Value::Object(Map::new());
549        prefill_from_config(&brand, &mut unset, "Configured Name");
550        assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
551        // Already set: the stored value is left untouched.
552        let mut set = serde_json::json!({ "app_name": "Acme" });
553        prefill_from_config(&brand, &mut set, "Configured Name");
554        assert_eq!(set["app_name"], serde_json::json!("Acme"));
555        // A non-brand item is not prefilled.
556        let mut other = Value::Object(Map::new());
557        prefill_from_config(&item(), &mut other, "Configured Name");
558        assert!(other.get("app_name").is_none());
559    }
560
561    fn item() -> SettingsItem {
562        SettingsItem {
563            code: "test.log".to_string(),
564            label: "Log Settings".into(),
565            description: "What the log records.".into(),
566            category: "Logs".into(),
567            order: 10,
568            icon: None,
569            permission: None,
570            link: None,
571            fields: vec![
572                FormField::switch("log_events", "Log events"),
573                FormField::switch("log_requests", "Log requests"),
574                FormField::text("retention_days", "Retention (days)"),
575            ],
576        }
577    }
578
579    fn state(db: Db) -> AdminState {
580        AdminState::new(
581            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
582            db,
583        )
584    }
585
586    /// A fresh test database with the settings table migrated in, on whichever
587    /// backend the run targets. Hold the returned guard for the test's lifetime.
588    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
589        laterite_core::testing::connect_test(&[migrations()]).await
590    }
591
592    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
593        pairs
594            .iter()
595            .map(|(k, v)| (k.to_string(), v.to_string()))
596            .collect()
597    }
598
599    #[test]
600    fn group_orders_categories_then_items() {
601        let items = vec![
602            SettingsItem {
603                code: "b".into(),
604                label: "Beta".into(),
605                description: String::new().into(),
606                category: "System".into(),
607                order: 20,
608                icon: None,
609                permission: None,
610                link: None,
611                fields: vec![],
612            },
613            SettingsItem {
614                code: "a".into(),
615                label: "Alpha".into(),
616                description: String::new().into(),
617                category: "System".into(),
618                order: 10,
619                icon: None,
620                permission: None,
621                link: None,
622                fields: vec![],
623            },
624            SettingsItem {
625                code: "l".into(),
626                label: "Logs".into(),
627                description: String::new().into(),
628                category: "Logs".into(),
629                order: 5,
630                icon: None,
631                permission: None,
632                link: None,
633                fields: vec![],
634            },
635        ];
636        let groups = sidebar_groups(&items, "/admin", None, &Translator::new("en"));
637        // "Logs" (min order 5) comes before "System" (min order 10).
638        assert_eq!(groups[0].name, "Logs");
639        assert_eq!(groups[1].name, "System");
640        // Within "System", Alpha (10) before Beta (20).
641        assert_eq!(groups[1].items[0].label, "Alpha");
642        assert_eq!(groups[1].items[1].label, "Beta");
643        // Nothing is active when no code is given.
644        assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
645    }
646
647    #[test]
648    fn group_marks_only_the_active_item() {
649        let items = vec![item()];
650        let groups = sidebar_groups(&items, "/admin", Some("test.log"), &Translator::new("en"));
651        let active: Vec<&str> = groups
652            .iter()
653            .flat_map(|g| &g.items)
654            .filter(|i| i.active)
655            .map(|i| i.label.as_str())
656            .collect();
657        assert_eq!(active, ["Log Settings"]);
658    }
659
660    #[test]
661    fn settings_model_item_path_is_its_form() {
662        assert_eq!(item().path("/admin"), "/admin/settings/test.log");
663        // The mount is honoured, so a relocated panel keeps consistent links.
664        assert_eq!(item().path("/manage"), "/manage/settings/test.log");
665    }
666
667    #[test]
668    fn link_item_path_follows_the_link() {
669        let admins = crate::builtin_settings()
670            .into_iter()
671            .find(|i| i.code == "backend.administrators")
672            .unwrap();
673        assert_eq!(admins.category.source(), "Users");
674        assert!(admins.link.is_some());
675        assert!(admins.fields.is_empty());
676        // links to the resource list, not a settings form
677        assert_eq!(admins.path("/admin"), "/admin/users");
678        assert!(crate::builtin_settings()
679            .iter()
680            .any(|i| i.code == "backend.roles"));
681    }
682
683    #[tokio::test]
684    async fn update_persists_typed_values() {
685        let (db, _guard) = test_db().await;
686        let st = state(db.clone());
687
688        let it = item();
689        let resp = update(
690            &st,
691            &it,
692            // log_requests is absent, as an unchecked checkbox would be.
693            data(&[("log_events", "on"), ("retention_days", "30")]),
694            crate::Shell::test(),
695            &crate::session::SessionHandle::from_blob(None),
696            &crate::audit::test_actor(),
697        )
698        .await;
699        assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
700
701        let stored = store::get(&db, "test.log").await.unwrap().unwrap();
702        assert_eq!(stored["log_events"], serde_json::json!(true));
703        assert_eq!(stored["log_requests"], serde_json::json!(false));
704        assert_eq!(stored["retention_days"], serde_json::json!("30"));
705    }
706
707    #[test]
708    fn index_renders() {
709        let resp = index(crate::Shell::test());
710        assert_eq!(resp.status(), axum::http::StatusCode::OK);
711    }
712
713    #[tokio::test]
714    async fn edit_form_renders_for_unset_item() {
715        let (db, _guard) = test_db().await;
716        let st = state(db);
717        // No stored value yet: the form still renders (fields fall back to defaults).
718        let it = item();
719        let resp = edit_form(&st, &it, crate::Shell::test()).await;
720        assert_eq!(resp.status(), axum::http::StatusCode::OK);
721    }
722}