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 hint: Option<Text>,
76 pub confirm_leave: bool,
78 pub order: i32,
80 pub icon: Option<String>,
83 pub permission: Option<String>,
86 pub link: Option<String>,
90 pub fields: Vec<FormField>,
91 pub(crate) route: Option<String>,
94}
95
96pub(crate) const MAX_DESCRIPTION: usize = 72;
101
102impl SettingsItem {
103 pub fn new(code: impl Into<String>, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
109 Self {
110 code: code.into(),
111 label: label.into(),
112 description: Text::from(""),
113 category: Text::from(""),
114 hint: None,
115 confirm_leave: true,
116 order: 0,
117 icon: None,
118 permission: None,
119 link: None,
120 fields,
121 route: None,
122 }
123 }
124
125 pub fn description(mut self, text: impl Into<Text>) -> Self {
132 let text = text.into();
133 debug_assert!(
134 text.source().chars().count() <= MAX_DESCRIPTION,
135 "settings description is {} characters, over the {MAX_DESCRIPTION} that \
136 fit two sidebar lines; shorten it or move the detail to `hint`: {:?}",
137 text.source().chars().count(),
138 text.source(),
139 );
140 self.description = text;
141 self
142 }
143
144 pub fn hint(mut self, text: impl Into<Text>) -> Self {
147 self.hint = Some(text.into());
148 self
149 }
150
151 pub fn confirm_leave(mut self, ask: bool) -> Self {
154 self.confirm_leave = ask;
155 self
156 }
157
158 pub fn category(mut self, text: impl Into<Text>) -> Self {
160 self.category = text.into();
161 self
162 }
163
164 pub fn order(mut self, order: i32) -> Self {
166 self.order = order;
167 self
168 }
169
170 pub fn icon(mut self, icon: impl Into<String>) -> Self {
172 self.icon = Some(icon.into());
173 self
174 }
175
176 pub fn permission(mut self, permission: impl Into<String>) -> Self {
178 self.permission = Some(permission.into());
179 self
180 }
181
182 pub fn link(mut self, link: impl Into<String>) -> Self {
185 self.link = Some(link.into());
186 self
187 }
188
189 pub(crate) fn mount_at(&mut self, route: impl Into<String>) {
192 self.route = Some(route.into());
193 }
194
195 pub(crate) fn at(mut self, route: impl Into<String>) -> Self {
202 self.route = Some(route.into());
203 self
204 }
205
206 pub(crate) fn route(&self) -> String {
213 match &self.route {
214 Some(route) => route.clone(),
215 None => format!("/settings/{}", self.code.replace('.', "/")),
216 }
217 }
218
219 pub fn path(&self, admin_path: &str) -> String {
223 match &self.link {
224 Some(link) => format!("{admin_path}{link}"),
225 None => format!("{admin_path}{}", self.route()),
226 }
227 }
228}
229
230pub(crate) fn index(shell: crate::Shell) -> Response {
233 render(SettingsIndexTemplate { shell })
234}
235
236pub(crate) async fn edit_form(
239 state: &AdminState,
240 item: &SettingsItem,
241 shell: crate::Shell,
242) -> Response {
243 let mut stored = match store::get(&state.db, &item.code).await {
244 Ok(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
245 Err(_) => return render_error(),
246 };
247 prefill_from_config(item, &mut stored, &state.app_name);
250 render(build(
251 item,
252 &state.field_types,
253 state.overrides.as_ref(),
254 None,
255 &stored,
256 &shell,
257 ))
258}
259
260pub(crate) async fn update(
262 state: &AdminState,
263 item: &SettingsItem,
264 data: HashMap<String, String>,
265 shell: crate::Shell,
266 session: &crate::session::SessionHandle,
267 user: &laterite_auth::AuthenticatedUser,
268) -> Response {
269 let value = match collect(item, &state.field_types, &data) {
272 Ok(value) => value,
273 Err(message) => {
274 let stored = store::get(&state.db, &item.code)
275 .await
276 .ok()
277 .flatten()
278 .unwrap_or(Value::Null);
279 return render(build(
280 item,
281 &state.field_types,
282 state.overrides.as_ref(),
283 Some(message),
284 &stored,
285 &shell,
286 ));
287 }
288 };
289 match store::set(&state.db, &item.code, &value).await {
290 Ok(()) => {
291 if item.code == brand::BrandSetting::CODE {
294 state.invalidate_brand();
295 }
296 crate::audit::record(
299 state,
300 user,
301 "backend.settings.update",
302 Some("settings"),
303 Some(item.code.as_str()),
304 None,
305 None,
306 )
307 .await;
308 session.push_flash(crate::session::FlashLevel::Success, t!("Settings saved."));
309 Redirect::to(&item.path(&state.admin_path)).into_response()
313 }
314 Err(_) => render(build(
315 item,
316 &state.field_types,
317 state.overrides.as_ref(),
318 Some(t!("Could not save. Please try again.")),
319 &value,
320 &shell,
321 )),
322 }
323}
324
325fn collect(
333 item: &SettingsItem,
334 types: &crate::field::FieldRegistry,
335 data: &HashMap<String, String>,
336) -> Result<Value, Text> {
337 let mut object = Map::new();
338 for field in &item.fields {
339 let Some(field_type) = types.get(&field.field_type) else {
340 return Err(t!("This screen uses a field type that is not registered."));
341 };
342 let opts = field_type
343 .resolve_options(&field.options, types)
344 .map_err(|_| t!("This screen has a malformed field."))?;
345 let submitted = crate::field::SubmittedField::new(&field.name, data);
346 let value = match field_type.to_attr(&submitted, &opts, Mode::Update) {
349 Ok(Some(value)) => attr_to_json(value),
350 Ok(None) => Value::String(String::new()),
351 Err(_) => return Err(t!("Check the values and try again.")),
352 };
353 object.insert(field.name.clone(), value);
354 }
355 Ok(Value::Object(object))
356}
357
358fn attr_to_json(value: laterite_core::AttrValue) -> Value {
360 use laterite_core::AttrValue;
361 match value {
362 AttrValue::Null => Value::String(String::new()),
363 AttrValue::Bool(b) => Value::Bool(b),
364 AttrValue::Int(i) => Value::from(i),
365 AttrValue::Float(f) => Value::from(f),
366 AttrValue::Json(j) => j,
367 other => Value::String(other.to_text().unwrap_or_default()),
368 }
369}
370
371pub(crate) fn sidebar_groups(
377 items: &[SettingsItem],
378 admin_path: &str,
379 active_code: Option<&str>,
380 tr: &Translator,
381 icons: crate::icons::Icons<'_>,
382) -> Vec<CategoryView> {
383 let mut by_category: HashMap<String, Vec<&SettingsItem>> = HashMap::new();
385 for item in items {
386 by_category
387 .entry(tr.t(&item.category))
388 .or_default()
389 .push(item);
390 }
391 let mut groups: Vec<CategoryView> = by_category
392 .into_iter()
393 .map(|(category, mut items)| {
394 items.sort_by(|a, b| {
396 a.order
397 .cmp(&b.order)
398 .then_with(|| a.label.source().cmp(b.label.source()))
399 });
400 CategoryView {
401 min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
402 name: category,
403 items: items
404 .iter()
405 .map(|i| ItemView {
406 label: tr.t(&i.label),
407 description: tr.t(&i.description),
408 path: i.path(admin_path),
409 icon: icons.render(i.icon.as_deref()),
410 active: active_code == Some(i.code.as_str()),
411 })
412 .collect(),
413 }
414 })
415 .collect();
416 groups.sort_by(|a, b| {
417 a.min_order
418 .cmp(&b.min_order)
419 .then_with(|| a.name.cmp(&b.name))
420 });
421 groups
422}
423
424fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
431 if item.code != brand::BrandSetting::CODE {
432 return;
433 }
434 if let Value::Object(map) = stored {
435 let blank = map
436 .get("app_name")
437 .and_then(Value::as_str)
438 .unwrap_or("")
439 .trim()
440 .is_empty();
441 if blank {
442 map.insert("app_name".to_string(), Value::String(app_name.to_string()));
443 }
444 }
445}
446
447fn build(
448 item: &SettingsItem,
449 types: &crate::field::FieldRegistry,
450 overrides: &dyn crate::field::OverrideResolver,
451 error: Option<Text>,
452 stored: &Value,
453 shell: &crate::Shell,
454) -> SettingsFormTemplate {
455 let fields = item
456 .fields
457 .iter()
458 .map(|f| {
459 let label = shell.tt(&f.label);
460 let control = match types.get(&f.field_type) {
461 Some(ft) => {
462 let opts = ft
463 .resolve_options(&f.options, types)
464 .unwrap_or_else(|_| crate::field::ResolvedOptions::none());
465 let value = match stored.get(&f.name) {
466 Some(Value::Array(rows)) => {
467 crate::field::FieldValue::Json(Value::Array(rows.clone()))
468 }
469 Some(Value::String(text)) => {
470 crate::field::FieldValue::Text(ft.to_control(Some(text), &opts))
471 }
472 Some(Value::Null) | None => {
473 crate::field::FieldValue::Text(ft.to_control(None, &opts))
474 }
475 Some(other) => crate::field::FieldValue::Text(
476 ft.to_control(Some(&other.to_string()), &opts),
477 ),
478 };
479 let cx = crate::field::FieldCx {
480 name: &f.name,
481 id: &f.name,
482 label: &label,
483 value: &value,
484 required: false,
485 max_length: crate::form::max_length(&f.rules),
486 opts: &opts,
487 base: &shell.base,
488 i18n: shell.i18n(),
489 };
490 let scope = crate::field::OverrideScope {
491 surface: crate::field::Surface::Field,
492 view_key: &f.field_type,
493 resource: Some(&item.code),
494 field: Some(&f.name),
495 };
496 crate::field::render_field(ft.as_ref(), overrides, &scope, &cx).into_string()
497 }
498 None => String::new(),
499 };
500 FieldView {
501 label,
502 help: f.help.as_ref().map(|h| shell.tt(h)),
503 control,
504 layout: crate::form::layout_classes(f.span, f.break_row),
505 attrs: crate::form::behaviour_attrs(f),
506 }
507 })
508 .collect();
509 SettingsFormTemplate {
510 title: shell.tt(&item.label),
511 description: shell.tt(&item.description),
512 hint: item.hint.as_ref().map(|h| shell.tt(h)),
513 action: item.path(&shell.base),
514 error: error.map(|e| shell.tt(&e)),
515 fields,
516 leave_off: !item.confirm_leave,
517 shell: shell.clone(),
518 }
519}
520
521#[derive(Clone)]
523pub(crate) struct CategoryView {
524 pub(crate) name: String,
525 min_order: i32,
526 pub(crate) items: Vec<ItemView>,
527}
528
529#[derive(Clone)]
531pub(crate) struct ItemView {
532 pub(crate) label: String,
533 pub(crate) description: String,
534 pub(crate) path: String,
535 pub(crate) icon: String,
537 pub(crate) active: bool,
539}
540
541#[derive(Template)]
542#[template(path = "settings_index.html")]
543struct SettingsIndexTemplate {
544 shell: crate::Shell,
545}
546
547struct FieldView {
548 label: String,
549 help: Option<String>,
550 control: String,
552 layout: String,
554 attrs: String,
556}
557
558#[derive(Template)]
559#[template(path = "settings_form.html")]
560struct SettingsFormTemplate {
561 shell: crate::Shell,
562 title: String,
563 description: String,
564 hint: Option<String>,
567 action: String,
568 error: Option<String>,
569 fields: Vec<FieldView>,
570 leave_off: bool,
572}
573
574#[cfg(test)]
575mod field_system_tests {
576 use super::*;
577
578 #[test]
579 #[should_panic(expected = "over the 72")]
580 fn a_lecture_of_a_description_is_refused() {
581 SettingsItem::new("acme.test", "Test", Vec::new()).description(
584 "What search engines and other crawlers may visit. The admin panel \
585 is always excluded, whatever you put here.",
586 );
587 }
588
589 #[test]
590 fn a_label_sized_description_passes_and_a_hint_carries_the_rest() {
591 let item = SettingsItem::new("acme.test", "Test", Vec::new())
592 .description("Which crawlers may visit, and what they may not.")
593 .hint(
594 "The admin panel is always excluded, whatever you put here, and a \
595 crawler obeys only the block that names it.",
596 );
597 assert!(item.description.source().chars().count() <= MAX_DESCRIPTION);
598 assert!(item.hint.is_some());
600 }
601
602 fn item(fields: Vec<FormField>) -> SettingsItem {
603 SettingsItem {
604 code: "acme.test".to_string(),
605 label: Text::new(""),
606 description: Text::new(""),
607 category: Text::new(""),
608 hint: None,
609 confirm_leave: true,
610 order: 0,
611 icon: None,
612 permission: None,
613 link: None,
614 fields,
615 route: None,
616 }
617 }
618
619 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
620 pairs
621 .iter()
622 .map(|(k, v)| (k.to_string(), v.to_string()))
623 .collect()
624 }
625
626 #[test]
629 fn settings_store_what_the_field_type_says() {
630 let cfg = item(vec![
631 FormField::text("name", "Name"),
632 FormField::switch("enabled", "Enabled"),
633 FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
634 FormField::date("expires", "Expires"),
635 ]);
636 let stored = collect(
637 &cfg,
638 &crate::field::builtin_registry(),
639 &submitted(&[
640 ("name", "Acme"),
641 ("enabled", "on"),
642 ("freq", "daily"),
643 ("expires", "2026-12-31"),
644 ]),
645 )
646 .unwrap();
647
648 assert_eq!(stored["name"], "Acme");
649 assert_eq!(stored["enabled"], serde_json::json!(true));
651 assert_eq!(stored["freq"], "daily");
652 assert_eq!(stored["expires"], "2026-12-31");
653 }
654
655 #[test]
658 fn an_unticked_switch_clears() {
659 let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
660 let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
661 assert_eq!(stored["enabled"], serde_json::json!(false));
662 }
663
664 #[test]
667 fn a_repeater_works_in_settings() {
668 let cfg = item(vec![FormField::repeater(
669 "rules",
670 "Rules",
671 vec![
672 FormField::text("path", "Path"),
673 FormField::switch("allow", "Allow"),
674 ],
675 )]);
676 let stored = collect(
677 &cfg,
678 &crate::field::builtin_registry(),
679 &submitted(&[
680 ("rules[0][path]", "/private"),
681 ("rules[1][path]", "/public"),
682 ("rules[1][allow]", "on"),
683 ]),
684 )
685 .unwrap();
686
687 let rows = stored["rules"].as_array().unwrap();
688 assert_eq!(rows.len(), 2);
689 assert_eq!(rows[0]["path"], "/private");
690 assert_eq!(rows[0]["allow"], serde_json::json!(false));
691 assert_eq!(rows[1]["allow"], serde_json::json!(true));
692 }
693
694 #[test]
695 fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
696 let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
697 assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
698 }
699
700 #[test]
701 fn the_form_renders_each_control_through_its_type() {
702 let cfg = item(vec![
703 FormField::switch("enabled", "Enabled"),
704 FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
705 ]);
706 let html = build(
707 &cfg,
708 &crate::field::builtin_registry(),
709 &crate::field::NoOverrides,
710 None,
711 &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
712 &crate::Shell::test(),
713 )
714 .render()
715 .unwrap();
716
717 assert!(html.contains(r#"type="checkbox""#));
718 assert!(html.contains(r#"name="rules[0][path]""#));
719 assert!(html.contains(r#"value="/a""#));
720 assert!(html.contains("lat-repeater__blank"));
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727 use laterite_core::Db;
728
729 #[test]
730 fn brand_form_prefills_app_name_from_config_when_unset() {
731 let brand = brand::settings_item();
732 let mut unset = Value::Object(Map::new());
734 prefill_from_config(&brand, &mut unset, "Configured Name");
735 assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
736 let mut set = serde_json::json!({ "app_name": "Acme" });
738 prefill_from_config(&brand, &mut set, "Configured Name");
739 assert_eq!(set["app_name"], serde_json::json!("Acme"));
740 let mut other = Value::Object(Map::new());
742 prefill_from_config(&item(), &mut other, "Configured Name");
743 assert!(other.get("app_name").is_none());
744 }
745
746 fn item() -> SettingsItem {
747 SettingsItem {
748 code: "test.log".to_string(),
749 label: "Log Settings".into(),
750 description: "What the log records.".into(),
751 category: "Logs".into(),
752 hint: None,
753 confirm_leave: true,
754 order: 10,
755 icon: None,
756 permission: None,
757 link: None,
758 fields: vec![
759 FormField::switch("log_events", "Log events"),
760 FormField::switch("log_requests", "Log requests"),
761 FormField::text("retention_days", "Retention (days)"),
762 ],
763 route: None,
764 }
765 }
766
767 fn state(db: Db) -> AdminState {
768 AdminState::new(
769 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
770 db,
771 )
772 }
773
774 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
777 laterite_core::testing::connect_test(&[migrations()]).await
778 }
779
780 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
781 pairs
782 .iter()
783 .map(|(k, v)| (k.to_string(), v.to_string()))
784 .collect()
785 }
786
787 #[test]
788 fn group_orders_categories_then_items() {
789 let items = vec![
790 SettingsItem {
791 code: "b".into(),
792 label: "Beta".into(),
793 description: String::new().into(),
794 category: "System".into(),
795 hint: None,
796 confirm_leave: true,
797 order: 20,
798 icon: None,
799 permission: None,
800 link: None,
801 fields: vec![],
802 route: None,
803 },
804 SettingsItem {
805 code: "a".into(),
806 label: "Alpha".into(),
807 description: String::new().into(),
808 category: "System".into(),
809 hint: None,
810 confirm_leave: true,
811 order: 10,
812 icon: None,
813 permission: None,
814 link: None,
815 fields: vec![],
816 route: None,
817 },
818 SettingsItem {
819 code: "l".into(),
820 label: "Logs".into(),
821 description: String::new().into(),
822 category: "Logs".into(),
823 hint: None,
824 confirm_leave: true,
825 order: 5,
826 icon: None,
827 permission: None,
828 link: None,
829 fields: vec![],
830 route: None,
831 },
832 ];
833 let groups = sidebar_groups(
834 &items,
835 "/admin",
836 None,
837 &Translator::new("en"),
838 crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
839 );
840 assert_eq!(groups[0].name, "Logs");
842 assert_eq!(groups[1].name, "System");
843 assert_eq!(groups[1].items[0].label, "Alpha");
845 assert_eq!(groups[1].items[1].label, "Beta");
846 assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
848 }
849
850 #[test]
851 fn group_marks_only_the_active_item() {
852 let items = vec![item()];
853 let groups = sidebar_groups(
854 &items,
855 "/admin",
856 Some("test.log"),
857 &Translator::new("en"),
858 crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
859 );
860 let active: Vec<&str> = groups
861 .iter()
862 .flat_map(|g| &g.items)
863 .filter(|i| i.active)
864 .map(|i| i.label.as_str())
865 .collect();
866 assert_eq!(active, ["Log Settings"]);
867 }
868
869 #[test]
870 fn settings_model_item_path_is_its_form() {
871 assert_eq!(item().path("/admin"), "/admin/settings/test/log");
874 assert_eq!(item().path("/manage"), "/manage/settings/test/log");
876
877 let mut resolved = item();
879 resolved.mount_at("/acme/logging/log");
880 assert_eq!(resolved.path("/admin"), "/admin/acme/logging/log");
881 }
882
883 #[test]
884 fn link_item_path_follows_the_link() {
885 let admins = crate::builtin_settings()
886 .into_iter()
887 .find(|i| i.code == "backend.administrators")
888 .unwrap();
889 assert_eq!(admins.category.source(), "Users");
890 assert!(admins.link.is_some());
891 assert!(admins.fields.is_empty());
892 assert_eq!(admins.path("/admin"), "/admin/users");
894 assert!(crate::builtin_settings()
895 .iter()
896 .any(|i| i.code == "backend.roles"));
897 }
898
899 #[tokio::test]
900 async fn update_persists_typed_values() {
901 let (db, _guard) = test_db().await;
902 let st = state(db.clone());
903
904 let it = item();
905 let resp = update(
906 &st,
907 &it,
908 data(&[("log_events", "on"), ("retention_days", "30")]),
910 crate::Shell::test(),
911 &crate::session::SessionHandle::from_blob(None),
912 &crate::audit::test_actor(),
913 )
914 .await;
915 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
916
917 let stored = store::get(&db, "test.log").await.unwrap().unwrap();
918 assert_eq!(stored["log_events"], serde_json::json!(true));
919 assert_eq!(stored["log_requests"], serde_json::json!(false));
920 assert_eq!(stored["retention_days"], serde_json::json!("30"));
921 }
922
923 #[test]
924 fn index_renders() {
925 let resp = index(crate::Shell::test());
926 assert_eq!(resp.status(), axum::http::StatusCode::OK);
927 }
928
929 #[tokio::test]
930 async fn edit_form_renders_for_unset_item() {
931 let (db, _guard) = test_db().await;
932 let st = state(db);
933 let it = item();
935 let resp = edit_form(&st, &it, crate::Shell::test()).await;
936 assert_eq!(resp.status(), axum::http::StatusCode::OK);
937 }
938}