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)]
57#[non_exhaustive]
58pub struct SettingsItem {
59 pub code: String,
67 pub label: Text,
69 pub description: Text,
70 pub category: Text,
72 pub order: i32,
74 pub icon: Option<String>,
77 pub permission: Option<String>,
80 pub link: Option<String>,
84 pub fields: Vec<FormField>,
85 pub(crate) route: Option<String>,
88}
89
90impl SettingsItem {
91 pub fn new(code: impl Into<String>, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
97 Self {
98 code: code.into(),
99 label: label.into(),
100 description: Text::from(""),
101 category: Text::from(""),
102 order: 0,
103 icon: None,
104 permission: None,
105 link: None,
106 fields,
107 route: None,
108 }
109 }
110
111 pub fn description(mut self, text: impl Into<Text>) -> Self {
113 self.description = text.into();
114 self
115 }
116
117 pub fn category(mut self, text: impl Into<Text>) -> Self {
119 self.category = text.into();
120 self
121 }
122
123 pub fn order(mut self, order: i32) -> Self {
125 self.order = order;
126 self
127 }
128
129 pub fn icon(mut self, icon: impl Into<String>) -> Self {
131 self.icon = Some(icon.into());
132 self
133 }
134
135 pub fn permission(mut self, permission: impl Into<String>) -> Self {
137 self.permission = Some(permission.into());
138 self
139 }
140
141 pub fn link(mut self, link: impl Into<String>) -> Self {
144 self.link = Some(link.into());
145 self
146 }
147
148 pub(crate) fn mount_at(&mut self, route: impl Into<String>) {
151 self.route = Some(route.into());
152 }
153
154 pub(crate) fn at(mut self, route: impl Into<String>) -> Self {
161 self.route = Some(route.into());
162 self
163 }
164
165 pub(crate) fn route(&self) -> String {
172 match &self.route {
173 Some(route) => route.clone(),
174 None => format!("/settings/{}", self.code.replace('.', "/")),
175 }
176 }
177
178 pub fn path(&self, admin_path: &str) -> String {
182 match &self.link {
183 Some(link) => format!("{admin_path}{link}"),
184 None => format!("{admin_path}{}", self.route()),
185 }
186 }
187}
188
189pub(crate) fn index(shell: crate::Shell) -> Response {
192 render(SettingsIndexTemplate { shell })
193}
194
195pub(crate) async fn edit_form(
198 state: &AdminState,
199 item: &SettingsItem,
200 shell: crate::Shell,
201) -> Response {
202 let mut stored = match store::get(&state.db, &item.code).await {
203 Ok(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
204 Err(_) => return render_error(),
205 };
206 prefill_from_config(item, &mut stored, &state.app_name);
209 render(build(
210 item,
211 &state.field_types,
212 state.overrides.as_ref(),
213 None,
214 &stored,
215 &shell,
216 ))
217}
218
219pub(crate) async fn update(
221 state: &AdminState,
222 item: &SettingsItem,
223 data: HashMap<String, String>,
224 shell: crate::Shell,
225 session: &crate::session::SessionHandle,
226 user: &laterite_auth::AuthenticatedUser,
227) -> Response {
228 let value = match collect(item, &state.field_types, &data) {
231 Ok(value) => value,
232 Err(message) => {
233 let stored = store::get(&state.db, &item.code)
234 .await
235 .ok()
236 .flatten()
237 .unwrap_or(Value::Null);
238 return render(build(
239 item,
240 &state.field_types,
241 state.overrides.as_ref(),
242 Some(message),
243 &stored,
244 &shell,
245 ));
246 }
247 };
248 match store::set(&state.db, &item.code, &value).await {
249 Ok(()) => {
250 if item.code == brand::BrandSetting::CODE {
253 state.invalidate_brand();
254 }
255 crate::audit::record(
258 state,
259 user,
260 "backend.settings.update",
261 Some("settings"),
262 Some(item.code.as_str()),
263 None,
264 )
265 .await;
266 session.push_flash(crate::session::FlashLevel::Success, t!("Settings saved."));
267 Redirect::to(&format!("{}/settings", state.admin_path)).into_response()
268 }
269 Err(_) => render(build(
270 item,
271 &state.field_types,
272 state.overrides.as_ref(),
273 Some(t!("Could not save. Please try again.")),
274 &value,
275 &shell,
276 )),
277 }
278}
279
280fn collect(
288 item: &SettingsItem,
289 types: &crate::field::FieldRegistry,
290 data: &HashMap<String, String>,
291) -> Result<Value, Text> {
292 let mut object = Map::new();
293 for field in &item.fields {
294 let Some(field_type) = types.get(&field.field_type) else {
295 return Err(t!("This screen uses a field type that is not registered."));
296 };
297 let opts = field_type
298 .resolve_options(&field.options, types)
299 .map_err(|_| t!("This screen has a malformed field."))?;
300 let submitted = crate::field::SubmittedField::new(&field.name, data);
301 let value = match field_type.to_attr(&submitted, &opts, Mode::Update) {
304 Ok(Some(value)) => attr_to_json(value),
305 Ok(None) => Value::String(String::new()),
306 Err(_) => return Err(t!("Check the values and try again.")),
307 };
308 object.insert(field.name.clone(), value);
309 }
310 Ok(Value::Object(object))
311}
312
313fn attr_to_json(value: laterite_core::AttrValue) -> Value {
315 use laterite_core::AttrValue;
316 match value {
317 AttrValue::Null => Value::String(String::new()),
318 AttrValue::Bool(b) => Value::Bool(b),
319 AttrValue::Int(i) => Value::from(i),
320 AttrValue::Float(f) => Value::from(f),
321 AttrValue::Json(j) => j,
322 other => Value::String(other.to_text().unwrap_or_default()),
323 }
324}
325
326pub(crate) fn sidebar_groups(
332 items: &[SettingsItem],
333 admin_path: &str,
334 active_code: Option<&str>,
335 tr: &Translator,
336) -> Vec<CategoryView> {
337 let mut by_category: HashMap<String, Vec<&SettingsItem>> = HashMap::new();
339 for item in items {
340 by_category
341 .entry(tr.t(&item.category))
342 .or_default()
343 .push(item);
344 }
345 let mut groups: Vec<CategoryView> = by_category
346 .into_iter()
347 .map(|(category, mut items)| {
348 items.sort_by(|a, b| {
350 a.order
351 .cmp(&b.order)
352 .then_with(|| a.label.source().cmp(b.label.source()))
353 });
354 CategoryView {
355 min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
356 name: category,
357 items: items
358 .iter()
359 .map(|i| ItemView {
360 label: tr.t(&i.label),
361 description: tr.t(&i.description),
362 path: i.path(admin_path),
363 icon: crate::icons::svg(i.icon.as_deref()),
364 active: active_code == Some(i.code.as_str()),
365 })
366 .collect(),
367 }
368 })
369 .collect();
370 groups.sort_by(|a, b| {
371 a.min_order
372 .cmp(&b.min_order)
373 .then_with(|| a.name.cmp(&b.name))
374 });
375 groups
376}
377
378fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
385 if item.code != brand::BrandSetting::CODE {
386 return;
387 }
388 if let Value::Object(map) = stored {
389 let blank = map
390 .get("app_name")
391 .and_then(Value::as_str)
392 .unwrap_or("")
393 .trim()
394 .is_empty();
395 if blank {
396 map.insert("app_name".to_string(), Value::String(app_name.to_string()));
397 }
398 }
399}
400
401fn build(
402 item: &SettingsItem,
403 types: &crate::field::FieldRegistry,
404 overrides: &dyn crate::field::OverrideResolver,
405 error: Option<Text>,
406 stored: &Value,
407 shell: &crate::Shell,
408) -> SettingsFormTemplate {
409 let fields = item
410 .fields
411 .iter()
412 .map(|f| {
413 let label = shell.tt(&f.label);
414 let control = match types.get(&f.field_type) {
415 Some(ft) => {
416 let opts = ft
417 .resolve_options(&f.options, types)
418 .unwrap_or_else(|_| crate::field::ResolvedOptions::none());
419 let value = match stored.get(&f.name) {
420 Some(Value::Array(rows)) => {
421 crate::field::FieldValue::Json(Value::Array(rows.clone()))
422 }
423 Some(Value::String(text)) => {
424 crate::field::FieldValue::Text(ft.to_control(Some(text), &opts))
425 }
426 Some(Value::Null) | None => {
427 crate::field::FieldValue::Text(ft.to_control(None, &opts))
428 }
429 Some(other) => crate::field::FieldValue::Text(
430 ft.to_control(Some(&other.to_string()), &opts),
431 ),
432 };
433 let cx = crate::field::FieldCx {
434 name: &f.name,
435 id: &f.name,
436 label: &label,
437 value: &value,
438 required: false,
439 opts: &opts,
440 base: &shell.base,
441 i18n: shell.i18n(),
442 };
443 let scope = crate::field::OverrideScope {
444 surface: crate::field::Surface::Field,
445 view_key: &f.field_type,
446 resource: Some(&item.code),
447 field: Some(&f.name),
448 };
449 crate::field::render_field(ft.as_ref(), overrides, &scope, &cx).into_string()
450 }
451 None => String::new(),
452 };
453 FieldView {
454 label,
455 help: f.help.as_ref().map(|h| shell.tt(h)),
456 control,
457 }
458 })
459 .collect();
460 SettingsFormTemplate {
461 title: shell.tt(&item.label),
462 description: shell.tt(&item.description),
463 action: item.path(&shell.base),
464 error: error.map(|e| shell.tt(&e)),
465 fields,
466 shell: shell.clone(),
467 }
468}
469
470#[derive(Clone)]
472pub(crate) struct CategoryView {
473 pub(crate) name: String,
474 min_order: i32,
475 pub(crate) items: Vec<ItemView>,
476}
477
478#[derive(Clone)]
480pub(crate) struct ItemView {
481 pub(crate) label: String,
482 pub(crate) description: String,
483 pub(crate) path: String,
484 pub(crate) icon: &'static str,
486 pub(crate) active: bool,
488}
489
490#[derive(Template)]
491#[template(path = "settings_index.html")]
492struct SettingsIndexTemplate {
493 shell: crate::Shell,
494}
495
496struct FieldView {
497 label: String,
498 help: Option<String>,
499 control: String,
501}
502
503#[derive(Template)]
504#[template(path = "settings_form.html")]
505struct SettingsFormTemplate {
506 shell: crate::Shell,
507 title: String,
508 description: String,
509 action: String,
510 error: Option<String>,
511 fields: Vec<FieldView>,
512}
513
514#[cfg(test)]
515mod field_system_tests {
516 use super::*;
517
518 fn item(fields: Vec<FormField>) -> SettingsItem {
519 SettingsItem {
520 code: "acme.test".to_string(),
521 label: Text::new(""),
522 description: Text::new(""),
523 category: Text::new(""),
524 order: 0,
525 icon: None,
526 permission: None,
527 link: None,
528 fields,
529 route: None,
530 }
531 }
532
533 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
534 pairs
535 .iter()
536 .map(|(k, v)| (k.to_string(), v.to_string()))
537 .collect()
538 }
539
540 #[test]
543 fn settings_store_what_the_field_type_says() {
544 let cfg = item(vec![
545 FormField::text("name", "Name"),
546 FormField::switch("enabled", "Enabled"),
547 FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
548 FormField::date("expires", "Expires"),
549 ]);
550 let stored = collect(
551 &cfg,
552 &crate::field::builtin_registry(),
553 &submitted(&[
554 ("name", "Acme"),
555 ("enabled", "on"),
556 ("freq", "daily"),
557 ("expires", "2026-12-31"),
558 ]),
559 )
560 .unwrap();
561
562 assert_eq!(stored["name"], "Acme");
563 assert_eq!(stored["enabled"], serde_json::json!(true));
565 assert_eq!(stored["freq"], "daily");
566 assert_eq!(stored["expires"], "2026-12-31");
567 }
568
569 #[test]
572 fn an_unticked_switch_clears() {
573 let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
574 let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
575 assert_eq!(stored["enabled"], serde_json::json!(false));
576 }
577
578 #[test]
581 fn a_repeater_works_in_settings() {
582 let cfg = item(vec![FormField::repeater(
583 "rules",
584 "Rules",
585 vec![
586 FormField::text("path", "Path"),
587 FormField::switch("allow", "Allow"),
588 ],
589 )]);
590 let stored = collect(
591 &cfg,
592 &crate::field::builtin_registry(),
593 &submitted(&[
594 ("rules[0][path]", "/private"),
595 ("rules[1][path]", "/public"),
596 ("rules[1][allow]", "on"),
597 ]),
598 )
599 .unwrap();
600
601 let rows = stored["rules"].as_array().unwrap();
602 assert_eq!(rows.len(), 2);
603 assert_eq!(rows[0]["path"], "/private");
604 assert_eq!(rows[0]["allow"], serde_json::json!(false));
605 assert_eq!(rows[1]["allow"], serde_json::json!(true));
606 }
607
608 #[test]
609 fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
610 let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
611 assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
612 }
613
614 #[test]
615 fn the_form_renders_each_control_through_its_type() {
616 let cfg = item(vec![
617 FormField::switch("enabled", "Enabled"),
618 FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
619 ]);
620 let html = build(
621 &cfg,
622 &crate::field::builtin_registry(),
623 &crate::field::NoOverrides,
624 None,
625 &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
626 &crate::Shell::test(),
627 )
628 .render()
629 .unwrap();
630
631 assert!(html.contains(r#"type="checkbox""#));
632 assert!(html.contains(r#"name="rules[0][path]""#));
633 assert!(html.contains(r#"value="/a""#));
634 assert!(html.contains("lat-repeater__blank"));
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use laterite_core::Db;
642
643 #[test]
644 fn brand_form_prefills_app_name_from_config_when_unset() {
645 let brand = brand::settings_item();
646 let mut unset = Value::Object(Map::new());
648 prefill_from_config(&brand, &mut unset, "Configured Name");
649 assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
650 let mut set = serde_json::json!({ "app_name": "Acme" });
652 prefill_from_config(&brand, &mut set, "Configured Name");
653 assert_eq!(set["app_name"], serde_json::json!("Acme"));
654 let mut other = Value::Object(Map::new());
656 prefill_from_config(&item(), &mut other, "Configured Name");
657 assert!(other.get("app_name").is_none());
658 }
659
660 fn item() -> SettingsItem {
661 SettingsItem {
662 code: "test.log".to_string(),
663 label: "Log Settings".into(),
664 description: "What the log records.".into(),
665 category: "Logs".into(),
666 order: 10,
667 icon: None,
668 permission: None,
669 link: None,
670 fields: vec![
671 FormField::switch("log_events", "Log events"),
672 FormField::switch("log_requests", "Log requests"),
673 FormField::text("retention_days", "Retention (days)"),
674 ],
675 route: None,
676 }
677 }
678
679 fn state(db: Db) -> AdminState {
680 AdminState::new(
681 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
682 db,
683 )
684 }
685
686 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
689 laterite_core::testing::connect_test(&[migrations()]).await
690 }
691
692 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
693 pairs
694 .iter()
695 .map(|(k, v)| (k.to_string(), v.to_string()))
696 .collect()
697 }
698
699 #[test]
700 fn group_orders_categories_then_items() {
701 let items = vec![
702 SettingsItem {
703 code: "b".into(),
704 label: "Beta".into(),
705 description: String::new().into(),
706 category: "System".into(),
707 order: 20,
708 icon: None,
709 permission: None,
710 link: None,
711 fields: vec![],
712 route: None,
713 },
714 SettingsItem {
715 code: "a".into(),
716 label: "Alpha".into(),
717 description: String::new().into(),
718 category: "System".into(),
719 order: 10,
720 icon: None,
721 permission: None,
722 link: None,
723 fields: vec![],
724 route: None,
725 },
726 SettingsItem {
727 code: "l".into(),
728 label: "Logs".into(),
729 description: String::new().into(),
730 category: "Logs".into(),
731 order: 5,
732 icon: None,
733 permission: None,
734 link: None,
735 fields: vec![],
736 route: None,
737 },
738 ];
739 let groups = sidebar_groups(&items, "/admin", None, &Translator::new("en"));
740 assert_eq!(groups[0].name, "Logs");
742 assert_eq!(groups[1].name, "System");
743 assert_eq!(groups[1].items[0].label, "Alpha");
745 assert_eq!(groups[1].items[1].label, "Beta");
746 assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
748 }
749
750 #[test]
751 fn group_marks_only_the_active_item() {
752 let items = vec![item()];
753 let groups = sidebar_groups(&items, "/admin", Some("test.log"), &Translator::new("en"));
754 let active: Vec<&str> = groups
755 .iter()
756 .flat_map(|g| &g.items)
757 .filter(|i| i.active)
758 .map(|i| i.label.as_str())
759 .collect();
760 assert_eq!(active, ["Log Settings"]);
761 }
762
763 #[test]
764 fn settings_model_item_path_is_its_form() {
765 assert_eq!(item().path("/admin"), "/admin/settings/test/log");
768 assert_eq!(item().path("/manage"), "/manage/settings/test/log");
770
771 let mut resolved = item();
773 resolved.mount_at("/acme/logging/log");
774 assert_eq!(resolved.path("/admin"), "/admin/acme/logging/log");
775 }
776
777 #[test]
778 fn link_item_path_follows_the_link() {
779 let admins = crate::builtin_settings()
780 .into_iter()
781 .find(|i| i.code == "backend.administrators")
782 .unwrap();
783 assert_eq!(admins.category.source(), "Users");
784 assert!(admins.link.is_some());
785 assert!(admins.fields.is_empty());
786 assert_eq!(admins.path("/admin"), "/admin/users");
788 assert!(crate::builtin_settings()
789 .iter()
790 .any(|i| i.code == "backend.roles"));
791 }
792
793 #[tokio::test]
794 async fn update_persists_typed_values() {
795 let (db, _guard) = test_db().await;
796 let st = state(db.clone());
797
798 let it = item();
799 let resp = update(
800 &st,
801 &it,
802 data(&[("log_events", "on"), ("retention_days", "30")]),
804 crate::Shell::test(),
805 &crate::session::SessionHandle::from_blob(None),
806 &crate::audit::test_actor(),
807 )
808 .await;
809 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
810
811 let stored = store::get(&db, "test.log").await.unwrap().unwrap();
812 assert_eq!(stored["log_events"], serde_json::json!(true));
813 assert_eq!(stored["log_requests"], serde_json::json!(false));
814 assert_eq!(stored["retention_days"], serde_json::json!("30"));
815 }
816
817 #[test]
818 fn index_renders() {
819 let resp = index(crate::Shell::test());
820 assert_eq!(resp.status(), axum::http::StatusCode::OK);
821 }
822
823 #[tokio::test]
824 async fn edit_form_renders_for_unset_item() {
825 let (db, _guard) = test_db().await;
826 let st = state(db);
827 let it = item();
829 let resp = edit_form(&st, &it, crate::Shell::test()).await;
830 assert_eq!(resp.status(), axum::http::StatusCode::OK);
831 }
832}