Skip to main content

laterite_admin/
settings.rs

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