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