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::{t, Text, Translator};
40use serde::Serialize;
41use serde_json::{Map, Value};
42
43use crate::{render, render_error, AdminState};
44
45/// How a settings field is rendered and typed in the stored JSON.
46#[derive(Debug, Clone, Copy, Serialize)]
47pub enum SettingsWidget {
48    /// A single-line string value.
49    Text,
50    /// A multi-line string value.
51    Textarea,
52    /// A boolean, rendered as a checkbox and stored as a JSON bool.
53    Switch,
54}
55
56/// One editable field of a settings model: the JSON key, its label, its widget,
57/// and optional help text shown beneath the control.
58#[derive(Debug, Clone, Serialize)]
59pub struct SettingsField {
60    pub name: String,
61    /// The field label and optional help, localized at render.
62    pub label: Text,
63    pub widget: SettingsWidget,
64    pub help: Option<Text>,
65}
66
67impl SettingsField {
68    pub fn text(name: &str, label: impl Into<Text>) -> Self {
69        Self {
70            name: name.to_string(),
71            label: label.into(),
72            widget: SettingsWidget::Text,
73            help: None,
74        }
75    }
76
77    pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
78        Self {
79            widget: SettingsWidget::Textarea,
80            ..Self::text(name, label)
81        }
82    }
83
84    pub fn switch(name: &str, label: impl Into<Text>) -> Self {
85        Self {
86            widget: SettingsWidget::Switch,
87            ..Self::text(name, label)
88        }
89    }
90
91    pub fn help(mut self, text: impl Into<Text>) -> Self {
92        self.help = Some(text.into());
93        self
94    }
95}
96
97/// A settings model surfaced in the admin: a storage `code`, a `category` and
98/// `order` that place it in the index, and the fields to edit.
99#[derive(Debug, Clone, Serialize)]
100pub struct SettingsItem {
101    /// Storage key. Matches the model's `SettingsModel::CODE`.
102    pub code: String,
103    /// The item's label, description, and category heading, localized at render.
104    pub label: Text,
105    pub description: Text,
106    /// Group heading in the index.
107    pub category: Text,
108    /// Weight within the category (lower sorts first).
109    pub order: i32,
110    /// Icon name shown beside the item in the context sidebar (a Lucide name
111    /// such as `users` or `shield`). `None` falls back to a generic glyph.
112    pub icon: Option<String>,
113    /// Permission required to edit, enforced by middleware. `None` means any
114    /// authenticated operator.
115    pub permission: Option<String>,
116    /// When set, the item links to this route (e.g. a resource list) instead of
117    /// its settings form. Used to place list/form screens (like Administrators)
118    /// in the settings menu rather than the main menu.
119    pub link: Option<String>,
120    pub fields: Vec<SettingsField>,
121}
122
123impl SettingsItem {
124    /// Where this item leads, resolved under the admin mount (`admin_path`): its
125    /// `link` target if set, else its own settings form. Both `link` and the form
126    /// path are authored relative to the admin root, so this prepends the mount.
127    pub fn path(&self, admin_path: &str) -> String {
128        match &self.link {
129            Some(link) => format!("{admin_path}{link}"),
130            None => format!("{admin_path}/settings/{}", self.code),
131        }
132    }
133}
134
135/// Renders the settings index: a prompt to pick a section. The context sidebar
136/// itself is resolved by the auth guard and rendered by the shell.
137pub(crate) fn index(shell: crate::Shell) -> Response {
138    render(SettingsIndexTemplate { shell })
139}
140
141/// Renders the edit form for one item, populated from its stored value. The
142/// context sidebar (with this item active) comes from the shell.
143pub(crate) async fn edit_form(
144    state: &AdminState,
145    item: &SettingsItem,
146    shell: crate::Shell,
147) -> Response {
148    let mut stored = match store::get(&state.db, &item.code).await {
149        Ok(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
150        Err(_) => return render_error(),
151    };
152    // Prefill unset fields from config so they show the current effective value
153    // rather than opening blank. Display only; nothing is written.
154    prefill_from_config(item, &mut stored, &state.app_name);
155    render(build(item, None, &stored, &shell))
156}
157
158/// Persists submitted values as the item's JSON object, then returns to the index.
159pub(crate) async fn update(
160    state: &AdminState,
161    item: &SettingsItem,
162    data: HashMap<String, String>,
163    shell: crate::Shell,
164    session: &crate::session::SessionHandle,
165    user: &laterite_auth::AuthenticatedUser,
166) -> Response {
167    let value = collect(item, &data);
168    match store::set(&state.db, &item.code, &value).await {
169        Ok(()) => {
170            // The brand is cached for display; a save to it must invalidate the
171            // cache so the next page reflects the new name.
172            if item.code == brand::BrandSetting::CODE {
173                state.invalidate_brand();
174            }
175            // The stored value can hold secrets, so the audit records which
176            // settings model changed, not the new contents.
177            crate::audit::record(
178                state,
179                user,
180                "backend.settings.update",
181                Some("settings"),
182                Some(item.code.as_str()),
183                None,
184            )
185            .await;
186            session.push_flash(crate::session::FlashLevel::Success, t!("Settings saved."));
187            Redirect::to(&format!("{}/settings", state.admin_path)).into_response()
188        }
189        Err(_) => render(build(
190            item,
191            Some(t!("Could not save. Please try again.")),
192            &value,
193            &shell,
194        )),
195    }
196}
197
198/// Builds the JSON object to store from the submitted form data, typing each
199/// field by its widget. An unchecked switch is absent from the form, so it
200/// stores `false`.
201fn collect(item: &SettingsItem, data: &HashMap<String, String>) -> Value {
202    let mut object = Map::new();
203    for field in &item.fields {
204        let value = match field.widget {
205            SettingsWidget::Text | SettingsWidget::Textarea => {
206                Value::String(data.get(&field.name).cloned().unwrap_or_default())
207            }
208            SettingsWidget::Switch => Value::Bool(is_checked(data.get(&field.name))),
209        };
210        object.insert(field.name.clone(), value);
211    }
212    Value::Object(object)
213}
214
215fn is_checked(raw: Option<&String>) -> bool {
216    matches!(raw.map(String::as_str), Some("on" | "true" | "1"))
217}
218
219/// Builds the context-sidebar groups: items grouped by category and ordered
220/// deterministically, with `active_code` (if any) marked. Categories sort by
221/// their lowest item `order`, then name; items by `order`, then label. This is
222/// the simple-weight stage; relative-anchor ordering with an operator override
223/// is a later refinement.
224pub(crate) fn sidebar_groups(
225    items: &[SettingsItem],
226    admin_path: &str,
227    active_code: Option<&str>,
228    tr: &Translator,
229) -> Vec<CategoryView> {
230    // Group by the localized category heading (a shared source localizes identically).
231    let mut by_category: HashMap<String, Vec<&SettingsItem>> = HashMap::new();
232    for item in items {
233        by_category
234            .entry(tr.t(&item.category))
235            .or_default()
236            .push(item);
237    }
238    let mut groups: Vec<CategoryView> = by_category
239        .into_iter()
240        .map(|(category, mut items)| {
241            // Order by weight, then the label's source for a stable tie-break.
242            items.sort_by(|a, b| {
243                a.order
244                    .cmp(&b.order)
245                    .then_with(|| a.label.source().cmp(b.label.source()))
246            });
247            CategoryView {
248                min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
249                name: category,
250                items: items
251                    .iter()
252                    .map(|i| ItemView {
253                        label: tr.t(&i.label),
254                        description: tr.t(&i.description),
255                        path: i.path(admin_path),
256                        icon: crate::icons::svg(i.icon.as_deref()),
257                        active: active_code == Some(i.code.as_str()),
258                    })
259                    .collect(),
260            }
261        })
262        .collect();
263    groups.sort_by(|a, b| {
264        a.min_order
265            .cmp(&b.min_order)
266            .then_with(|| a.name.cmp(&b.name))
267    });
268    groups
269}
270
271/// Prefills unset display fields from configuration before the form renders, so
272/// a field shows the current effective value rather than opening blank. The
273/// brand's application name prefills from the configured `app.name` when no brand
274/// setting is saved. This is display only: it writes nothing, so a later config
275/// change still propagates (persisting it would freeze the value). It is not a
276/// database seeder; that is a separate, deferred capability.
277fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
278    if item.code != brand::BrandSetting::CODE {
279        return;
280    }
281    if let Value::Object(map) = stored {
282        let blank = map
283            .get("app_name")
284            .and_then(Value::as_str)
285            .unwrap_or("")
286            .trim()
287            .is_empty();
288        if blank {
289            map.insert("app_name".to_string(), Value::String(app_name.to_string()));
290        }
291    }
292}
293
294fn build(
295    item: &SettingsItem,
296    error: Option<Text>,
297    stored: &Value,
298    shell: &crate::Shell,
299) -> SettingsFormTemplate {
300    let fields = item
301        .fields
302        .iter()
303        .map(|f| {
304            let current = stored.get(&f.name);
305            FieldView {
306                name: f.name.clone(),
307                label: shell.tt(&f.label),
308                help: f.help.as_ref().map(|h| shell.tt(h)),
309                textarea: matches!(f.widget, SettingsWidget::Textarea),
310                switch: matches!(f.widget, SettingsWidget::Switch),
311                checked: current.and_then(Value::as_bool).unwrap_or(false),
312                value: match current {
313                    Some(Value::String(s)) => s.clone(),
314                    Some(Value::Null) | None => String::new(),
315                    Some(other) => other.to_string(),
316                },
317            }
318        })
319        .collect();
320    SettingsFormTemplate {
321        title: shell.tt(&item.label),
322        description: shell.tt(&item.description),
323        action: item.path(&shell.base),
324        error: error.map(|e| shell.tt(&e)),
325        fields,
326        shell: shell.clone(),
327    }
328}
329
330/// One category block in the context sidebar. Rendered by the shell.
331#[derive(Clone)]
332pub(crate) struct CategoryView {
333    pub(crate) name: String,
334    min_order: i32,
335    pub(crate) items: Vec<ItemView>,
336}
337
338/// One item in the context sidebar.
339#[derive(Clone)]
340pub(crate) struct ItemView {
341    pub(crate) label: String,
342    pub(crate) description: String,
343    pub(crate) path: String,
344    /// Inline SVG markup for the item's icon, rendered raw in the template.
345    pub(crate) icon: &'static str,
346    /// Whether this is the item currently open, so the sidebar highlights it.
347    pub(crate) active: bool,
348}
349
350#[derive(Template)]
351#[template(path = "settings_index.html")]
352struct SettingsIndexTemplate {
353    shell: crate::Shell,
354}
355
356struct FieldView {
357    name: String,
358    label: String,
359    help: Option<String>,
360    value: String,
361    textarea: bool,
362    switch: bool,
363    checked: bool,
364}
365
366#[derive(Template)]
367#[template(path = "settings_form.html")]
368struct SettingsFormTemplate {
369    shell: crate::Shell,
370    title: String,
371    description: String,
372    action: String,
373    error: Option<String>,
374    fields: Vec<FieldView>,
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use laterite_core::Db;
381
382    #[test]
383    fn brand_form_prefills_app_name_from_config_when_unset() {
384        let brand = brand::settings_item();
385        // Unset: the field prefills from the configured application name.
386        let mut unset = Value::Object(Map::new());
387        prefill_from_config(&brand, &mut unset, "Configured Name");
388        assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
389        // Already set: the stored value is left untouched.
390        let mut set = serde_json::json!({ "app_name": "Acme" });
391        prefill_from_config(&brand, &mut set, "Configured Name");
392        assert_eq!(set["app_name"], serde_json::json!("Acme"));
393        // A non-brand item is not prefilled.
394        let mut other = Value::Object(Map::new());
395        prefill_from_config(&item(), &mut other, "Configured Name");
396        assert!(other.get("app_name").is_none());
397    }
398
399    fn item() -> SettingsItem {
400        SettingsItem {
401            code: "test.log".to_string(),
402            label: "Log Settings".into(),
403            description: "What the log records.".into(),
404            category: "Logs".into(),
405            order: 10,
406            icon: None,
407            permission: None,
408            link: None,
409            fields: vec![
410                SettingsField::switch("log_events", "Log events"),
411                SettingsField::switch("log_requests", "Log requests"),
412                SettingsField::text("retention_days", "Retention (days)"),
413            ],
414        }
415    }
416
417    fn state(db: Db) -> AdminState {
418        AdminState::new(
419            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
420            db,
421        )
422    }
423
424    /// A fresh test database with the settings table migrated in, on whichever
425    /// backend the run targets. Hold the returned guard for the test's lifetime.
426    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
427        laterite_core::testing::connect_test(&[migrations()]).await
428    }
429
430    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
431        pairs
432            .iter()
433            .map(|(k, v)| (k.to_string(), v.to_string()))
434            .collect()
435    }
436
437    #[test]
438    fn group_orders_categories_then_items() {
439        let items = vec![
440            SettingsItem {
441                code: "b".into(),
442                label: "Beta".into(),
443                description: String::new().into(),
444                category: "System".into(),
445                order: 20,
446                icon: None,
447                permission: None,
448                link: None,
449                fields: vec![],
450            },
451            SettingsItem {
452                code: "a".into(),
453                label: "Alpha".into(),
454                description: String::new().into(),
455                category: "System".into(),
456                order: 10,
457                icon: None,
458                permission: None,
459                link: None,
460                fields: vec![],
461            },
462            SettingsItem {
463                code: "l".into(),
464                label: "Logs".into(),
465                description: String::new().into(),
466                category: "Logs".into(),
467                order: 5,
468                icon: None,
469                permission: None,
470                link: None,
471                fields: vec![],
472            },
473        ];
474        let groups = sidebar_groups(&items, "/admin", None, &Translator::new("en"));
475        // "Logs" (min order 5) comes before "System" (min order 10).
476        assert_eq!(groups[0].name, "Logs");
477        assert_eq!(groups[1].name, "System");
478        // Within "System", Alpha (10) before Beta (20).
479        assert_eq!(groups[1].items[0].label, "Alpha");
480        assert_eq!(groups[1].items[1].label, "Beta");
481        // Nothing is active when no code is given.
482        assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
483    }
484
485    #[test]
486    fn group_marks_only_the_active_item() {
487        let items = vec![item()];
488        let groups = sidebar_groups(&items, "/admin", Some("test.log"), &Translator::new("en"));
489        let active: Vec<&str> = groups
490            .iter()
491            .flat_map(|g| &g.items)
492            .filter(|i| i.active)
493            .map(|i| i.label.as_str())
494            .collect();
495        assert_eq!(active, ["Log Settings"]);
496    }
497
498    #[test]
499    fn settings_model_item_path_is_its_form() {
500        assert_eq!(item().path("/admin"), "/admin/settings/test.log");
501        // The mount is honoured, so a relocated panel keeps consistent links.
502        assert_eq!(item().path("/manage"), "/manage/settings/test.log");
503    }
504
505    #[test]
506    fn link_item_path_follows_the_link() {
507        let admins = crate::builtin_settings()
508            .into_iter()
509            .find(|i| i.code == "backend.administrators")
510            .unwrap();
511        assert_eq!(admins.category.source(), "Users");
512        assert!(admins.link.is_some());
513        assert!(admins.fields.is_empty());
514        // links to the resource list, not a settings form
515        assert_eq!(admins.path("/admin"), "/admin/users");
516        assert!(crate::builtin_settings()
517            .iter()
518            .any(|i| i.code == "backend.roles"));
519    }
520
521    #[tokio::test]
522    async fn update_persists_typed_values() {
523        let (db, _guard) = test_db().await;
524        let st = state(db.clone());
525
526        let it = item();
527        let resp = update(
528            &st,
529            &it,
530            // log_requests is absent, as an unchecked checkbox would be.
531            data(&[("log_events", "on"), ("retention_days", "30")]),
532            crate::Shell::test(),
533            &crate::session::SessionHandle::from_blob(None),
534            &crate::audit::test_actor(),
535        )
536        .await;
537        assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
538
539        let stored = store::get(&db, "test.log").await.unwrap().unwrap();
540        assert_eq!(stored["log_events"], serde_json::json!(true));
541        assert_eq!(stored["log_requests"], serde_json::json!(false));
542        assert_eq!(stored["retention_days"], serde_json::json!("30"));
543    }
544
545    #[test]
546    fn index_renders() {
547        let resp = index(crate::Shell::test());
548        assert_eq!(resp.status(), axum::http::StatusCode::OK);
549    }
550
551    #[tokio::test]
552    async fn edit_form_renders_for_unset_item() {
553        let (db, _guard) = test_db().await;
554        let st = state(db);
555        // No stored value yet: the form still renders (fields fall back to defaults).
556        let it = item();
557        let resp = edit_form(&st, &it, crate::Shell::test()).await;
558        assert_eq!(resp.status(), axum::http::StatusCode::OK);
559    }
560}