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