1pub 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#[derive(Debug, Clone, Copy)]
31pub enum SettingsWidget {
32 Text,
34 Textarea,
36 Switch,
38}
39
40#[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#[derive(Debug, Clone)]
83pub struct SettingsItem {
84 pub code: String,
86 pub label: String,
87 pub description: String,
88 pub category: String,
90 pub order: i32,
92 pub icon: Option<String>,
95 pub permission: Option<String>,
98 pub link: Option<String>,
102 pub fields: Vec<SettingsField>,
103}
104
105impl SettingsItem {
106 pub fn path(&self) -> String {
108 self.link
109 .clone()
110 .unwrap_or_else(|| format!("/admin/settings/{}", self.code))
111 }
112}
113
114pub(crate) fn index(shell: crate::Shell) -> Response {
117 render(SettingsIndexTemplate { shell })
118}
119
120pub(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
134pub(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
153fn 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
174pub(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#[derive(Clone)]
253pub(crate) struct CategoryView {
254 pub(crate) name: String,
255 min_order: i32,
256 pub(crate) items: Vec<ItemView>,
257}
258
259#[derive(Clone)]
261pub(crate) struct ItemView {
262 pub(crate) label: String,
263 pub(crate) description: String,
264 pub(crate) path: String,
265 pub(crate) icon: &'static str,
267 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 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 assert_eq!(groups[0].name, "Logs");
381 assert_eq!(groups[1].name, "System");
382 assert_eq!(groups[1].items[0].label, "Alpha");
384 assert_eq!(groups[1].items[1].label, "Beta");
385 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 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 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 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}