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