1pub 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
23pub 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::validation::Mode;
40use laterite_core::{t, Text, Translator};
41use serde::Serialize;
42use serde_json::{Map, Value};
43
44use crate::{render, render_error, AdminState};
45
46pub use crate::form::FormField;
53
54#[derive(Debug, Clone, Serialize)]
57pub struct SettingsItem {
58 pub code: String,
60 pub label: Text,
62 pub description: Text,
63 pub category: Text,
65 pub order: i32,
67 pub icon: Option<String>,
70 pub permission: Option<String>,
73 pub link: Option<String>,
77 pub fields: Vec<FormField>,
78}
79
80impl SettingsItem {
81 pub fn path(&self, admin_path: &str) -> String {
85 match &self.link {
86 Some(link) => format!("{admin_path}{link}"),
87 None => format!("{admin_path}/settings/{}", self.code),
88 }
89 }
90}
91
92pub(crate) fn index(shell: crate::Shell) -> Response {
95 render(SettingsIndexTemplate { shell })
96}
97
98pub(crate) async fn edit_form(
101 state: &AdminState,
102 item: &SettingsItem,
103 shell: crate::Shell,
104) -> Response {
105 let mut stored = match store::get(&state.db, &item.code).await {
106 Ok(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
107 Err(_) => return render_error(),
108 };
109 prefill_from_config(item, &mut stored, &state.app_name);
112 render(build(
113 item,
114 &state.field_types,
115 state.overrides.as_ref(),
116 None,
117 &stored,
118 &shell,
119 ))
120}
121
122pub(crate) async fn update(
124 state: &AdminState,
125 item: &SettingsItem,
126 data: HashMap<String, String>,
127 shell: crate::Shell,
128 session: &crate::session::SessionHandle,
129 user: &laterite_auth::AuthenticatedUser,
130) -> Response {
131 let value = match collect(item, &state.field_types, &data) {
134 Ok(value) => value,
135 Err(message) => {
136 let stored = store::get(&state.db, &item.code)
137 .await
138 .ok()
139 .flatten()
140 .unwrap_or(Value::Null);
141 return render(build(
142 item,
143 &state.field_types,
144 state.overrides.as_ref(),
145 Some(message),
146 &stored,
147 &shell,
148 ));
149 }
150 };
151 match store::set(&state.db, &item.code, &value).await {
152 Ok(()) => {
153 if item.code == brand::BrandSetting::CODE {
156 state.invalidate_brand();
157 }
158 crate::audit::record(
161 state,
162 user,
163 "backend.settings.update",
164 Some("settings"),
165 Some(item.code.as_str()),
166 None,
167 )
168 .await;
169 session.push_flash(crate::session::FlashLevel::Success, t!("Settings saved."));
170 Redirect::to(&format!("{}/settings", state.admin_path)).into_response()
171 }
172 Err(_) => render(build(
173 item,
174 &state.field_types,
175 state.overrides.as_ref(),
176 Some(t!("Could not save. Please try again.")),
177 &value,
178 &shell,
179 )),
180 }
181}
182
183fn collect(
191 item: &SettingsItem,
192 types: &crate::field::FieldRegistry,
193 data: &HashMap<String, String>,
194) -> Result<Value, Text> {
195 let mut object = Map::new();
196 for field in &item.fields {
197 let Some(field_type) = types.get(&field.field_type) else {
198 return Err(t!("This screen uses a field type that is not registered."));
199 };
200 let opts = field_type
201 .resolve_options(&field.options, types)
202 .map_err(|_| t!("This screen has a malformed field."))?;
203 let submitted = crate::field::SubmittedField::new(&field.name, data);
204 let value = match field_type.to_attr(&submitted, &opts, Mode::Update) {
207 Ok(Some(value)) => attr_to_json(value),
208 Ok(None) => Value::String(String::new()),
209 Err(_) => return Err(t!("Check the values and try again.")),
210 };
211 object.insert(field.name.clone(), value);
212 }
213 Ok(Value::Object(object))
214}
215
216fn attr_to_json(value: laterite_core::AttrValue) -> Value {
218 use laterite_core::AttrValue;
219 match value {
220 AttrValue::Null => Value::String(String::new()),
221 AttrValue::Bool(b) => Value::Bool(b),
222 AttrValue::Int(i) => Value::from(i),
223 AttrValue::Float(f) => Value::from(f),
224 AttrValue::Json(j) => j,
225 other => Value::String(other.to_text().unwrap_or_default()),
226 }
227}
228
229pub(crate) fn sidebar_groups(
235 items: &[SettingsItem],
236 admin_path: &str,
237 active_code: Option<&str>,
238 tr: &Translator,
239) -> Vec<CategoryView> {
240 let mut by_category: HashMap<String, Vec<&SettingsItem>> = HashMap::new();
242 for item in items {
243 by_category
244 .entry(tr.t(&item.category))
245 .or_default()
246 .push(item);
247 }
248 let mut groups: Vec<CategoryView> = by_category
249 .into_iter()
250 .map(|(category, mut items)| {
251 items.sort_by(|a, b| {
253 a.order
254 .cmp(&b.order)
255 .then_with(|| a.label.source().cmp(b.label.source()))
256 });
257 CategoryView {
258 min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
259 name: category,
260 items: items
261 .iter()
262 .map(|i| ItemView {
263 label: tr.t(&i.label),
264 description: tr.t(&i.description),
265 path: i.path(admin_path),
266 icon: crate::icons::svg(i.icon.as_deref()),
267 active: active_code == Some(i.code.as_str()),
268 })
269 .collect(),
270 }
271 })
272 .collect();
273 groups.sort_by(|a, b| {
274 a.min_order
275 .cmp(&b.min_order)
276 .then_with(|| a.name.cmp(&b.name))
277 });
278 groups
279}
280
281fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
288 if item.code != brand::BrandSetting::CODE {
289 return;
290 }
291 if let Value::Object(map) = stored {
292 let blank = map
293 .get("app_name")
294 .and_then(Value::as_str)
295 .unwrap_or("")
296 .trim()
297 .is_empty();
298 if blank {
299 map.insert("app_name".to_string(), Value::String(app_name.to_string()));
300 }
301 }
302}
303
304fn build(
305 item: &SettingsItem,
306 types: &crate::field::FieldRegistry,
307 overrides: &dyn crate::field::OverrideResolver,
308 error: Option<Text>,
309 stored: &Value,
310 shell: &crate::Shell,
311) -> SettingsFormTemplate {
312 let fields = item
313 .fields
314 .iter()
315 .map(|f| {
316 let label = shell.tt(&f.label);
317 let control = match types.get(&f.field_type) {
318 Some(ft) => {
319 let opts = ft
320 .resolve_options(&f.options, types)
321 .unwrap_or_else(|_| crate::field::ResolvedOptions::none());
322 let value = match stored.get(&f.name) {
323 Some(Value::Array(rows)) => {
324 crate::field::FieldValue::Json(Value::Array(rows.clone()))
325 }
326 Some(Value::String(text)) => {
327 crate::field::FieldValue::Text(ft.to_control(Some(text), &opts))
328 }
329 Some(Value::Null) | None => {
330 crate::field::FieldValue::Text(ft.to_control(None, &opts))
331 }
332 Some(other) => crate::field::FieldValue::Text(
333 ft.to_control(Some(&other.to_string()), &opts),
334 ),
335 };
336 let cx = crate::field::FieldCx {
337 name: &f.name,
338 id: &f.name,
339 label: &label,
340 value: &value,
341 required: false,
342 opts: &opts,
343 base: &shell.base,
344 };
345 let scope = crate::field::OverrideScope {
346 surface: crate::field::Surface::Field,
347 view_key: &f.field_type,
348 resource: Some(&item.code),
349 field: Some(&f.name),
350 };
351 crate::field::render_field(ft.as_ref(), overrides, &scope, &cx).into_string()
352 }
353 None => String::new(),
354 };
355 FieldView {
356 label,
357 help: f.help.as_ref().map(|h| shell.tt(h)),
358 control,
359 }
360 })
361 .collect();
362 SettingsFormTemplate {
363 title: shell.tt(&item.label),
364 description: shell.tt(&item.description),
365 action: item.path(&shell.base),
366 error: error.map(|e| shell.tt(&e)),
367 fields,
368 shell: shell.clone(),
369 }
370}
371
372#[derive(Clone)]
374pub(crate) struct CategoryView {
375 pub(crate) name: String,
376 min_order: i32,
377 pub(crate) items: Vec<ItemView>,
378}
379
380#[derive(Clone)]
382pub(crate) struct ItemView {
383 pub(crate) label: String,
384 pub(crate) description: String,
385 pub(crate) path: String,
386 pub(crate) icon: &'static str,
388 pub(crate) active: bool,
390}
391
392#[derive(Template)]
393#[template(path = "settings_index.html")]
394struct SettingsIndexTemplate {
395 shell: crate::Shell,
396}
397
398struct FieldView {
399 label: String,
400 help: Option<String>,
401 control: String,
403}
404
405#[derive(Template)]
406#[template(path = "settings_form.html")]
407struct SettingsFormTemplate {
408 shell: crate::Shell,
409 title: String,
410 description: String,
411 action: String,
412 error: Option<String>,
413 fields: Vec<FieldView>,
414}
415
416#[cfg(test)]
417mod field_system_tests {
418 use super::*;
419
420 fn item(fields: Vec<FormField>) -> SettingsItem {
421 SettingsItem {
422 code: "acme.test".to_string(),
423 label: Text::new(""),
424 description: Text::new(""),
425 category: Text::new(""),
426 order: 0,
427 icon: None,
428 permission: None,
429 link: None,
430 fields,
431 }
432 }
433
434 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
435 pairs
436 .iter()
437 .map(|(k, v)| (k.to_string(), v.to_string()))
438 .collect()
439 }
440
441 #[test]
444 fn settings_store_what_the_field_type_says() {
445 let cfg = item(vec![
446 FormField::text("name", "Name"),
447 FormField::switch("enabled", "Enabled"),
448 FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
449 FormField::date("expires", "Expires"),
450 ]);
451 let stored = collect(
452 &cfg,
453 &crate::field::builtin_registry(),
454 &submitted(&[
455 ("name", "Acme"),
456 ("enabled", "on"),
457 ("freq", "daily"),
458 ("expires", "2026-12-31"),
459 ]),
460 )
461 .unwrap();
462
463 assert_eq!(stored["name"], "Acme");
464 assert_eq!(stored["enabled"], serde_json::json!(true));
466 assert_eq!(stored["freq"], "daily");
467 assert_eq!(stored["expires"], "2026-12-31");
468 }
469
470 #[test]
473 fn an_unticked_switch_clears() {
474 let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
475 let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
476 assert_eq!(stored["enabled"], serde_json::json!(false));
477 }
478
479 #[test]
482 fn a_repeater_works_in_settings() {
483 let cfg = item(vec![FormField::repeater(
484 "rules",
485 "Rules",
486 vec![
487 FormField::text("path", "Path"),
488 FormField::switch("allow", "Allow"),
489 ],
490 )]);
491 let stored = collect(
492 &cfg,
493 &crate::field::builtin_registry(),
494 &submitted(&[
495 ("rules[0][path]", "/private"),
496 ("rules[1][path]", "/public"),
497 ("rules[1][allow]", "on"),
498 ]),
499 )
500 .unwrap();
501
502 let rows = stored["rules"].as_array().unwrap();
503 assert_eq!(rows.len(), 2);
504 assert_eq!(rows[0]["path"], "/private");
505 assert_eq!(rows[0]["allow"], serde_json::json!(false));
506 assert_eq!(rows[1]["allow"], serde_json::json!(true));
507 }
508
509 #[test]
510 fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
511 let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
512 assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
513 }
514
515 #[test]
516 fn the_form_renders_each_control_through_its_type() {
517 let cfg = item(vec![
518 FormField::switch("enabled", "Enabled"),
519 FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
520 ]);
521 let html = build(
522 &cfg,
523 &crate::field::builtin_registry(),
524 &crate::field::NoOverrides,
525 None,
526 &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
527 &crate::Shell::test(),
528 )
529 .render()
530 .unwrap();
531
532 assert!(html.contains(r#"type="checkbox""#));
533 assert!(html.contains(r#"name="rules[0][path]""#));
534 assert!(html.contains(r#"value="/a""#));
535 assert!(html.contains("lat-repeater__blank"));
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use laterite_core::Db;
543
544 #[test]
545 fn brand_form_prefills_app_name_from_config_when_unset() {
546 let brand = brand::settings_item();
547 let mut unset = Value::Object(Map::new());
549 prefill_from_config(&brand, &mut unset, "Configured Name");
550 assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
551 let mut set = serde_json::json!({ "app_name": "Acme" });
553 prefill_from_config(&brand, &mut set, "Configured Name");
554 assert_eq!(set["app_name"], serde_json::json!("Acme"));
555 let mut other = Value::Object(Map::new());
557 prefill_from_config(&item(), &mut other, "Configured Name");
558 assert!(other.get("app_name").is_none());
559 }
560
561 fn item() -> SettingsItem {
562 SettingsItem {
563 code: "test.log".to_string(),
564 label: "Log Settings".into(),
565 description: "What the log records.".into(),
566 category: "Logs".into(),
567 order: 10,
568 icon: None,
569 permission: None,
570 link: None,
571 fields: vec![
572 FormField::switch("log_events", "Log events"),
573 FormField::switch("log_requests", "Log requests"),
574 FormField::text("retention_days", "Retention (days)"),
575 ],
576 }
577 }
578
579 fn state(db: Db) -> AdminState {
580 AdminState::new(
581 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
582 db,
583 )
584 }
585
586 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
589 laterite_core::testing::connect_test(&[migrations()]).await
590 }
591
592 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
593 pairs
594 .iter()
595 .map(|(k, v)| (k.to_string(), v.to_string()))
596 .collect()
597 }
598
599 #[test]
600 fn group_orders_categories_then_items() {
601 let items = vec![
602 SettingsItem {
603 code: "b".into(),
604 label: "Beta".into(),
605 description: String::new().into(),
606 category: "System".into(),
607 order: 20,
608 icon: None,
609 permission: None,
610 link: None,
611 fields: vec![],
612 },
613 SettingsItem {
614 code: "a".into(),
615 label: "Alpha".into(),
616 description: String::new().into(),
617 category: "System".into(),
618 order: 10,
619 icon: None,
620 permission: None,
621 link: None,
622 fields: vec![],
623 },
624 SettingsItem {
625 code: "l".into(),
626 label: "Logs".into(),
627 description: String::new().into(),
628 category: "Logs".into(),
629 order: 5,
630 icon: None,
631 permission: None,
632 link: None,
633 fields: vec![],
634 },
635 ];
636 let groups = sidebar_groups(&items, "/admin", None, &Translator::new("en"));
637 assert_eq!(groups[0].name, "Logs");
639 assert_eq!(groups[1].name, "System");
640 assert_eq!(groups[1].items[0].label, "Alpha");
642 assert_eq!(groups[1].items[1].label, "Beta");
643 assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
645 }
646
647 #[test]
648 fn group_marks_only_the_active_item() {
649 let items = vec![item()];
650 let groups = sidebar_groups(&items, "/admin", Some("test.log"), &Translator::new("en"));
651 let active: Vec<&str> = groups
652 .iter()
653 .flat_map(|g| &g.items)
654 .filter(|i| i.active)
655 .map(|i| i.label.as_str())
656 .collect();
657 assert_eq!(active, ["Log Settings"]);
658 }
659
660 #[test]
661 fn settings_model_item_path_is_its_form() {
662 assert_eq!(item().path("/admin"), "/admin/settings/test.log");
663 assert_eq!(item().path("/manage"), "/manage/settings/test.log");
665 }
666
667 #[test]
668 fn link_item_path_follows_the_link() {
669 let admins = crate::builtin_settings()
670 .into_iter()
671 .find(|i| i.code == "backend.administrators")
672 .unwrap();
673 assert_eq!(admins.category.source(), "Users");
674 assert!(admins.link.is_some());
675 assert!(admins.fields.is_empty());
676 assert_eq!(admins.path("/admin"), "/admin/users");
678 assert!(crate::builtin_settings()
679 .iter()
680 .any(|i| i.code == "backend.roles"));
681 }
682
683 #[tokio::test]
684 async fn update_persists_typed_values() {
685 let (db, _guard) = test_db().await;
686 let st = state(db.clone());
687
688 let it = item();
689 let resp = update(
690 &st,
691 &it,
692 data(&[("log_events", "on"), ("retention_days", "30")]),
694 crate::Shell::test(),
695 &crate::session::SessionHandle::from_blob(None),
696 &crate::audit::test_actor(),
697 )
698 .await;
699 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
700
701 let stored = store::get(&db, "test.log").await.unwrap().unwrap();
702 assert_eq!(stored["log_events"], serde_json::json!(true));
703 assert_eq!(stored["log_requests"], serde_json::json!(false));
704 assert_eq!(stored["retention_days"], serde_json::json!("30"));
705 }
706
707 #[test]
708 fn index_renders() {
709 let resp = index(crate::Shell::test());
710 assert_eq!(resp.status(), axum::http::StatusCode::OK);
711 }
712
713 #[tokio::test]
714 async fn edit_form_renders_for_unset_item() {
715 let (db, _guard) = test_db().await;
716 let st = state(db);
717 let it = item();
719 let resp = edit_form(&st, &it, crate::Shell::test()).await;
720 assert_eq!(resp.status(), axum::http::StatusCode::OK);
721 }
722}