1pub mod brand;
16pub mod migrations;
17pub mod store;
18
19pub use brand::BrandSetting;
20pub use migrations::{migrations, MODULE_ID};
21pub use store::{
22 get, get_for, load, load_for, save, save_for, set, set_for, PreferencesError, PreferencesModel,
23 SettingsError, SettingsModel,
24};
25
26pub struct SettingsModule;
28
29impl laterite_core::Module for SettingsModule {
30 fn id(&self) -> laterite_core::ModuleId {
31 laterite_core::ModuleId::new(MODULE_ID)
32 }
33 fn migrations(&self) -> laterite_core::MigrationSet {
34 migrations()
35 }
36}
37
38use crate::form::ValueFieldView;
39use std::collections::HashMap;
40
41use askama::Template;
42use axum::response::{IntoResponse, Redirect, Response};
43use laterite_core::{t, Text, Translator};
44use serde::Serialize;
45use serde_json::{Map, Value};
46
47use crate::{render, render_error, AdminState};
48
49pub use crate::form::FormField;
56
57#[derive(Debug, Clone, Serialize)]
60#[non_exhaustive]
61pub struct SettingsItem {
62 pub code: String,
70 pub label: Text,
72 pub description: Text,
73 pub category: Text,
75 pub hint: Option<Text>,
79 pub confirm_leave: bool,
81 pub slot: i32,
84 pub order: i32,
86 pub icon: Option<String>,
89 pub permission: Option<String>,
92 pub link: Option<String>,
96 pub fields: Vec<FormField>,
97 pub(crate) route: Option<String>,
100 pub per_operator: bool,
103}
104
105pub(crate) const MAX_DESCRIPTION: usize = 72;
110
111impl SettingsItem {
112 pub fn new(code: impl Into<String>, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
118 Self {
119 code: code.into(),
120 label: label.into(),
121 description: Text::from(""),
122 category: Text::from(""),
123 hint: None,
124 confirm_leave: true,
125 slot: laterite_core::arrange::slot::APPLICATION,
126 order: 0,
127 icon: None,
128 permission: None,
129 link: None,
130 fields,
131 route: None,
132 per_operator: false,
133 }
134 }
135
136 pub fn per_operator(mut self) -> Self {
139 self.per_operator = true;
140 self
141 }
142
143 pub fn description(mut self, text: impl Into<Text>) -> Self {
150 let text = text.into();
151 debug_assert!(
152 text.source().chars().count() <= MAX_DESCRIPTION,
153 "settings description is {} characters, over the {MAX_DESCRIPTION} that \
154 fit two sidebar lines; shorten it or move the detail to `hint`: {:?}",
155 text.source().chars().count(),
156 text.source(),
157 );
158 self.description = text;
159 self
160 }
161
162 pub fn hint(mut self, text: impl Into<Text>) -> Self {
165 self.hint = Some(text.into());
166 self
167 }
168
169 pub fn confirm_leave(mut self, ask: bool) -> Self {
172 self.confirm_leave = ask;
173 self
174 }
175
176 pub fn category(mut self, text: impl Into<Text>) -> Self {
178 self.category = text.into();
179 self
180 }
181
182 pub fn order(mut self, order: i32) -> Self {
184 self.order = order;
185 self
186 }
187
188 pub fn slot(mut self, slot: i32) -> Self {
190 self.slot = slot;
191 self
192 }
193
194 pub fn icon(mut self, icon: impl Into<String>) -> Self {
196 self.icon = Some(icon.into());
197 self
198 }
199
200 pub fn permission(mut self, permission: impl Into<String>) -> Self {
202 self.permission = Some(permission.into());
203 self
204 }
205
206 pub fn link(mut self, link: impl Into<String>) -> Self {
209 self.link = Some(link.into());
210 self
211 }
212
213 pub(crate) fn mount_at(&mut self, route: impl Into<String>) {
216 self.route = Some(route.into());
217 }
218
219 pub(crate) fn at(mut self, route: impl Into<String>) -> Self {
226 self.route = Some(route.into());
227 self
228 }
229
230 pub(crate) fn route(&self) -> String {
237 match &self.route {
238 Some(route) => route.clone(),
239 None if self.per_operator => format!("/preferences/{}", self.code.replace('.', "/")),
240 None => format!("/settings/{}", self.code.replace('.', "/")),
241 }
242 }
243
244 pub fn path(&self, admin_path: &str) -> String {
248 match &self.link {
249 Some(link) => format!("{admin_path}{link}"),
250 None => format!("{admin_path}{}", self.route()),
251 }
252 }
253}
254
255pub(crate) fn index(shell: crate::Shell) -> Response {
258 render(SettingsIndexTemplate { shell })
259}
260
261pub(crate) async fn edit_form(
264 state: &AdminState,
265 item: &SettingsItem,
266 shell: crate::Shell,
267 user: &laterite_auth::AuthenticatedUser,
268) -> Response {
269 let mut stored = match stored_value(state, item, user).await {
270 Some(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
271 None => return render_error(),
272 };
273 prefill_from_config(item, &mut stored, &state.app_name);
276 render(build(
277 item,
278 &state.field_types,
279 state.overrides.as_ref(),
280 None,
281 &stored,
282 &shell,
283 ))
284}
285
286pub(crate) async fn update(
288 state: &AdminState,
289 item: &SettingsItem,
290 data: HashMap<String, String>,
291 shell: crate::Shell,
292 session: &crate::session::SessionHandle,
293 user: &laterite_auth::AuthenticatedUser,
294) -> Response {
295 let value = match collect(item, &state.field_types, &data) {
298 Ok(value) => value,
299 Err(message) => {
300 let stored = stored_value(state, item, user)
301 .await
302 .flatten()
303 .unwrap_or(Value::Null);
304 return render(build(
305 item,
306 &state.field_types,
307 state.overrides.as_ref(),
308 Some(message),
309 &stored,
310 &shell,
311 ));
312 }
313 };
314 let written = if item.per_operator {
315 store::set_for(&state.db, user.user.id, &item.code, &value)
316 .await
317 .is_ok()
318 } else {
319 store::set(&state.db, &item.code, &value).await.is_ok()
320 };
321 match written {
322 true => {
323 if item.code == brand::BrandSetting::CODE {
326 state.invalidate_brand();
327 }
328 if !item.per_operator {
332 crate::audit::record(
333 state,
334 user,
335 "backend.settings.update",
336 Some("settings"),
337 Some(item.code.as_str()),
338 None,
339 None,
340 )
341 .await;
342 }
343 session.push_flash(
344 crate::session::FlashLevel::Success,
345 if item.per_operator {
346 t!("Preferences saved.")
347 } else {
348 t!("Settings saved.")
349 },
350 );
351 Redirect::to(&item.path(&state.admin_path)).into_response()
355 }
356 false => render(build(
357 item,
358 &state.field_types,
359 state.overrides.as_ref(),
360 Some(t!("Could not save. Please try again.")),
361 &value,
362 &shell,
363 )),
364 }
365}
366
367async fn stored_value(
370 state: &AdminState,
371 item: &SettingsItem,
372 user: &laterite_auth::AuthenticatedUser,
373) -> Option<Option<Value>> {
374 if item.per_operator {
375 store::get_for(&state.db, user.user.id, &item.code)
376 .await
377 .ok()
378 } else {
379 store::get(&state.db, &item.code).await.ok()
380 }
381}
382
383fn collect(
391 item: &SettingsItem,
392 types: &crate::field::FieldRegistry,
393 data: &HashMap<String, String>,
394) -> Result<Value, Text> {
395 crate::form::value_collect(&item.fields, types, data)
396}
397
398pub const HIERARCHY: &str = "backend.settings";
401
402pub(crate) fn hierarchy() -> laterite_core::arrange::Hierarchy {
403 laterite_core::arrange::Hierarchy::new(HIERARCHY).operator_adjustable()
404}
405
406struct Placed<'a> {
407 item: &'a SettingsItem,
408 position: laterite_core::arrange::Position,
409}
410
411impl laterite_core::arrange::Arranged for Placed<'_> {
412 fn key(&self) -> &str {
413 &self.item.code
414 }
415 fn position(&self) -> &laterite_core::arrange::Position {
416 &self.position
417 }
418 fn label(&self) -> &str {
419 self.item.label.source()
420 }
421}
422
423pub(crate) fn log_arrangement(items: &[SettingsItem], order: Option<&[String]>) {
426 let placed: Vec<Placed<'_>> = items
427 .iter()
428 .map(|item| Placed {
429 item,
430 position: laterite_core::arrange::Position::new()
431 .slot(item.slot)
432 .order(item.order),
433 })
434 .collect();
435 let arranged = laterite_core::arrange::arrange(
436 &hierarchy(),
437 placed,
438 laterite_core::arrange::Overrides {
439 deployment: order,
440 operator: None,
441 },
442 )
443 .unwrap_or_default();
444 let keys: Vec<String> = arranged
445 .iter()
446 .map(|p| {
447 format!(
448 "{} ({})",
449 p.item.code,
450 laterite_core::arrange::slot::name(p.item.slot)
451 )
452 })
453 .collect();
454 tracing::info!(hierarchy = HIERARCHY, arrangement = %keys.join(" > "));
455}
456
457pub(crate) fn sidebar_groups(
462 items: &[SettingsItem],
463 admin_path: &str,
464 active_code: Option<&str>,
465 tr: &Translator,
466 icons: crate::icons::Icons<'_>,
467 order: Option<&[String]>,
468 own_order: Option<&[String]>,
469) -> Vec<CategoryView> {
470 let placed: Vec<Placed<'_>> = items
471 .iter()
472 .map(|item| Placed {
473 item,
474 position: laterite_core::arrange::Position::new()
475 .slot(item.slot)
476 .order(item.order),
477 })
478 .collect();
479 let arranged = laterite_core::arrange::arrange(
480 &hierarchy(),
481 placed,
482 laterite_core::arrange::Overrides {
483 deployment: order,
484 operator: own_order,
485 },
486 )
487 .unwrap_or_default();
488 let mut groups: Vec<CategoryView> = Vec::new();
489 for placed in arranged {
490 let i = placed.item;
491 let category = tr.t(&i.category);
492 let view = ItemView {
493 key: i.code.clone(),
494 label: tr.t(&i.label),
495 description: tr.t(&i.description),
496 path: i.path(admin_path),
497 icon: icons.render(i.icon.as_deref()),
498 active: active_code == Some(i.code.as_str()),
499 };
500 match groups.iter_mut().find(|g| g.name == category) {
501 Some(group) => group.items.push(view),
502 None => groups.push(CategoryView {
503 name: category,
504 items: vec![view],
505 }),
506 }
507 }
508 groups
509}
510
511fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
518 if item.code != brand::BrandSetting::CODE {
519 return;
520 }
521 if let Value::Object(map) = stored {
522 let blank = map
523 .get("app_name")
524 .and_then(Value::as_str)
525 .unwrap_or("")
526 .trim()
527 .is_empty();
528 if blank {
529 map.insert("app_name".to_string(), Value::String(app_name.to_string()));
530 }
531 }
532}
533
534fn build(
535 item: &SettingsItem,
536 types: &crate::field::FieldRegistry,
537 overrides: &dyn crate::field::OverrideResolver,
538 error: Option<Text>,
539 stored: &Value,
540 shell: &crate::Shell,
541) -> SettingsFormTemplate {
542 let fields =
543 crate::form::value_views(&item.fields, types, overrides, stored, shell, &item.code);
544 SettingsFormTemplate {
545 title: shell.tt(&item.label),
546 description: shell.tt(&item.description),
547 hint: item.hint.as_ref().map(|h| shell.tt(h)),
548 action: item.path(&shell.base),
549 error: error.map(|e| shell.tt(&e)),
550 fields,
551 leave_off: !item.confirm_leave,
552 shell: shell.clone(),
553 }
554}
555
556#[derive(Clone)]
558pub(crate) struct CategoryView {
559 pub(crate) name: String,
560 pub(crate) items: Vec<ItemView>,
561}
562
563impl CategoryView {
564 pub(crate) fn new(name: String, items: Vec<ItemView>) -> Self {
566 Self { name, items }
567 }
568}
569
570#[derive(Clone)]
572pub(crate) struct ItemView {
573 pub(crate) key: String,
575 pub(crate) label: String,
576 pub(crate) description: String,
577 pub(crate) path: String,
578 pub(crate) icon: String,
580 pub(crate) active: bool,
582}
583
584#[derive(Template)]
585#[template(path = "settings_index.html")]
586struct SettingsIndexTemplate {
587 shell: crate::Shell,
588}
589
590#[derive(Template)]
591#[template(path = "settings_form.html")]
592struct SettingsFormTemplate {
593 shell: crate::Shell,
594 title: String,
595 description: String,
596 hint: Option<String>,
599 action: String,
600 error: Option<String>,
601 fields: Vec<ValueFieldView>,
602 leave_off: bool,
604}
605
606#[cfg(test)]
607mod field_system_tests {
608 use super::*;
609
610 #[test]
611 #[should_panic(expected = "over the 72")]
612 fn a_lecture_of_a_description_is_refused() {
613 SettingsItem::new("acme.test", "Test", Vec::new()).description(
616 "What search engines and other crawlers may visit. The admin panel \
617 is always excluded, whatever you put here.",
618 );
619 }
620
621 #[test]
622 fn a_label_sized_description_passes_and_a_hint_carries_the_rest() {
623 let item = SettingsItem::new("acme.test", "Test", Vec::new())
624 .description("Which crawlers may visit, and what they may not.")
625 .hint(
626 "The admin panel is always excluded, whatever you put here, and a \
627 crawler obeys only the block that names it.",
628 );
629 assert!(item.description.source().chars().count() <= MAX_DESCRIPTION);
630 assert!(item.hint.is_some());
632 }
633
634 fn item(fields: Vec<FormField>) -> SettingsItem {
635 SettingsItem {
636 code: "acme.test".to_string(),
637 label: Text::new(""),
638 description: Text::new(""),
639 category: Text::new(""),
640 hint: None,
641 confirm_leave: true,
642 slot: laterite_core::arrange::slot::APPLICATION,
643 order: 0,
644 icon: None,
645 permission: None,
646 link: None,
647 fields,
648 route: None,
649 per_operator: false,
650 }
651 }
652
653 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
654 pairs
655 .iter()
656 .map(|(k, v)| (k.to_string(), v.to_string()))
657 .collect()
658 }
659
660 #[test]
663 fn settings_store_what_the_field_type_says() {
664 let cfg = item(vec![
665 FormField::text("name", "Name"),
666 FormField::switch("enabled", "Enabled"),
667 FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
668 FormField::date("expires", "Expires"),
669 ]);
670 let stored = collect(
671 &cfg,
672 &crate::field::builtin_registry(),
673 &submitted(&[
674 ("name", "Acme"),
675 ("enabled", "on"),
676 ("freq", "daily"),
677 ("expires", "2026-12-31"),
678 ]),
679 )
680 .unwrap();
681
682 assert_eq!(stored["name"], "Acme");
683 assert_eq!(stored["enabled"], serde_json::json!(true));
685 assert_eq!(stored["freq"], "daily");
686 assert_eq!(stored["expires"], "2026-12-31");
687 }
688
689 #[test]
692 fn an_unticked_switch_clears() {
693 let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
694 let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
695 assert_eq!(stored["enabled"], serde_json::json!(false));
696 }
697
698 #[test]
701 fn a_repeater_works_in_settings() {
702 let cfg = item(vec![FormField::repeater(
703 "rules",
704 "Rules",
705 vec![
706 FormField::text("path", "Path"),
707 FormField::switch("allow", "Allow"),
708 ],
709 )]);
710 let stored = collect(
711 &cfg,
712 &crate::field::builtin_registry(),
713 &submitted(&[
714 ("rules[0][path]", "/private"),
715 ("rules[1][path]", "/public"),
716 ("rules[1][allow]", "on"),
717 ]),
718 )
719 .unwrap();
720
721 let rows = stored["rules"].as_array().unwrap();
722 assert_eq!(rows.len(), 2);
723 assert_eq!(rows[0]["path"], "/private");
724 assert_eq!(rows[0]["allow"], serde_json::json!(false));
725 assert_eq!(rows[1]["allow"], serde_json::json!(true));
726 }
727
728 #[test]
729 fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
730 let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
731 assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
732 }
733
734 #[test]
735 fn the_form_renders_each_control_through_its_type() {
736 let cfg = item(vec![
737 FormField::switch("enabled", "Enabled"),
738 FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
739 ]);
740 let html = build(
741 &cfg,
742 &crate::field::builtin_registry(),
743 &crate::field::NoOverrides,
744 None,
745 &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
746 &crate::Shell::test(),
747 )
748 .render()
749 .unwrap();
750
751 assert!(html.contains(r#"type="checkbox""#));
752 assert!(html.contains(r#"name="rules[0][path]""#));
753 assert!(html.contains(r#"value="/a""#));
754 assert!(html.contains("lat-repeater__blank"));
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761 use laterite_core::Db;
762
763 #[test]
764 fn brand_form_prefills_app_name_from_config_when_unset() {
765 let brand = brand::settings_item();
766 let mut unset = Value::Object(Map::new());
768 prefill_from_config(&brand, &mut unset, "Configured Name");
769 assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
770 let mut set = serde_json::json!({ "app_name": "Acme" });
772 prefill_from_config(&brand, &mut set, "Configured Name");
773 assert_eq!(set["app_name"], serde_json::json!("Acme"));
774 let mut other = Value::Object(Map::new());
776 prefill_from_config(&item(), &mut other, "Configured Name");
777 assert!(other.get("app_name").is_none());
778 }
779
780 fn item() -> SettingsItem {
781 SettingsItem {
782 code: "test.log".to_string(),
783 label: "Log Settings".into(),
784 description: "What the log records.".into(),
785 category: "Logs".into(),
786 hint: None,
787 confirm_leave: true,
788 slot: laterite_core::arrange::slot::APPLICATION,
789 order: 10,
790 icon: None,
791 permission: None,
792 link: None,
793 fields: vec![
794 FormField::switch("log_events", "Log events"),
795 FormField::switch("log_requests", "Log requests"),
796 FormField::text("retention_days", "Retention (days)"),
797 ],
798 route: None,
799 per_operator: false,
800 }
801 }
802
803 fn state(db: Db) -> AdminState {
804 AdminState::new(
805 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
806 db,
807 )
808 }
809
810 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
813 laterite_core::testing::connect_test(&[migrations()]).await
814 }
815
816 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
817 pairs
818 .iter()
819 .map(|(k, v)| (k.to_string(), v.to_string()))
820 .collect()
821 }
822
823 #[test]
824 fn group_orders_categories_then_items() {
825 let items = vec![
826 SettingsItem {
827 code: "b".into(),
828 label: "Beta".into(),
829 description: String::new().into(),
830 category: "System".into(),
831 hint: None,
832 confirm_leave: true,
833 slot: laterite_core::arrange::slot::APPLICATION,
834 order: 20,
835 icon: None,
836 permission: None,
837 link: None,
838 fields: vec![],
839 route: None,
840 per_operator: false,
841 },
842 SettingsItem {
843 code: "a".into(),
844 label: "Alpha".into(),
845 description: String::new().into(),
846 category: "System".into(),
847 hint: None,
848 confirm_leave: true,
849 slot: laterite_core::arrange::slot::APPLICATION,
850 order: 10,
851 icon: None,
852 permission: None,
853 link: None,
854 fields: vec![],
855 route: None,
856 per_operator: false,
857 },
858 SettingsItem {
859 code: "l".into(),
860 label: "Logs".into(),
861 description: String::new().into(),
862 category: "Logs".into(),
863 hint: None,
864 confirm_leave: true,
865 slot: laterite_core::arrange::slot::APPLICATION,
866 order: 5,
867 icon: None,
868 permission: None,
869 link: None,
870 fields: vec![],
871 route: None,
872 per_operator: false,
873 },
874 ];
875 let groups = sidebar_groups(
876 &items,
877 "/admin",
878 None,
879 &Translator::new("en"),
880 crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
881 None,
882 None,
883 );
884 assert_eq!(groups[0].name, "Logs");
886 assert_eq!(groups[1].name, "System");
887 assert_eq!(groups[1].items[0].label, "Alpha");
889 assert_eq!(groups[1].items[1].label, "Beta");
890 assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
892 }
893
894 #[test]
895 fn group_marks_only_the_active_item() {
896 let items = vec![item()];
897 let groups = sidebar_groups(
898 &items,
899 "/admin",
900 Some("test.log"),
901 &Translator::new("en"),
902 crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
903 None,
904 None,
905 );
906 let active: Vec<&str> = groups
907 .iter()
908 .flat_map(|g| &g.items)
909 .filter(|i| i.active)
910 .map(|i| i.label.as_str())
911 .collect();
912 assert_eq!(active, ["Log Settings"]);
913 }
914
915 #[test]
916 fn settings_model_item_path_is_its_form() {
917 assert_eq!(item().path("/admin"), "/admin/settings/test/log");
920 assert_eq!(item().path("/manage"), "/manage/settings/test/log");
922
923 let mut resolved = item();
925 resolved.mount_at("/acme/logging/log");
926 assert_eq!(resolved.path("/admin"), "/admin/acme/logging/log");
927 }
928
929 #[test]
930 fn link_item_path_follows_the_link() {
931 let admins = crate::builtin_settings()
932 .into_iter()
933 .find(|i| i.code == "backend.administrators")
934 .unwrap();
935 assert_eq!(admins.category.source(), "Users");
936 assert!(admins.link.is_some());
937 assert!(admins.fields.is_empty());
938 assert_eq!(admins.path("/admin"), "/admin/users");
940 assert!(crate::builtin_settings()
941 .iter()
942 .any(|i| i.code == "backend.roles"));
943 }
944
945 #[tokio::test]
946 async fn update_persists_typed_values() {
947 let (db, _guard) = test_db().await;
948 let st = state(db.clone());
949
950 let it = item();
951 let resp = update(
952 &st,
953 &it,
954 data(&[("log_events", "on"), ("retention_days", "30")]),
956 crate::Shell::test(),
957 &crate::session::SessionHandle::from_blob(None),
958 &crate::audit::test_actor(),
959 )
960 .await;
961 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
962
963 let stored = store::get(&db, "test.log").await.unwrap().unwrap();
964 assert_eq!(stored["log_events"], serde_json::json!(true));
965 assert_eq!(stored["log_requests"], serde_json::json!(false));
966 assert_eq!(stored["retention_days"], serde_json::json!("30"));
967 }
968
969 #[test]
970 fn index_renders() {
971 let resp = index(crate::Shell::test());
972 assert_eq!(resp.status(), axum::http::StatusCode::OK);
973 }
974
975 #[tokio::test]
976 async fn edit_form_renders_for_unset_item() {
977 let (db, _guard) = test_db().await;
978 let st = state(db);
979 let it = item();
981 let resp = edit_form(&st, &it, crate::Shell::test(), &crate::audit::test_actor()).await;
982 assert_eq!(resp.status(), axum::http::StatusCode::OK);
983 }
984}