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 order: i32,
83 pub icon: Option<String>,
86 pub permission: Option<String>,
89 pub link: Option<String>,
93 pub fields: Vec<FormField>,
94 pub(crate) route: Option<String>,
97 pub per_operator: bool,
100}
101
102pub(crate) const MAX_DESCRIPTION: usize = 72;
107
108impl SettingsItem {
109 pub fn new(code: impl Into<String>, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
115 Self {
116 code: code.into(),
117 label: label.into(),
118 description: Text::from(""),
119 category: Text::from(""),
120 hint: None,
121 confirm_leave: true,
122 order: 0,
123 icon: None,
124 permission: None,
125 link: None,
126 fields,
127 route: None,
128 per_operator: false,
129 }
130 }
131
132 pub fn per_operator(mut self) -> Self {
135 self.per_operator = true;
136 self
137 }
138
139 pub fn description(mut self, text: impl Into<Text>) -> Self {
146 let text = text.into();
147 debug_assert!(
148 text.source().chars().count() <= MAX_DESCRIPTION,
149 "settings description is {} characters, over the {MAX_DESCRIPTION} that \
150 fit two sidebar lines; shorten it or move the detail to `hint`: {:?}",
151 text.source().chars().count(),
152 text.source(),
153 );
154 self.description = text;
155 self
156 }
157
158 pub fn hint(mut self, text: impl Into<Text>) -> Self {
161 self.hint = Some(text.into());
162 self
163 }
164
165 pub fn confirm_leave(mut self, ask: bool) -> Self {
168 self.confirm_leave = ask;
169 self
170 }
171
172 pub fn category(mut self, text: impl Into<Text>) -> Self {
174 self.category = text.into();
175 self
176 }
177
178 pub fn order(mut self, order: i32) -> Self {
180 self.order = order;
181 self
182 }
183
184 pub fn icon(mut self, icon: impl Into<String>) -> Self {
186 self.icon = Some(icon.into());
187 self
188 }
189
190 pub fn permission(mut self, permission: impl Into<String>) -> Self {
192 self.permission = Some(permission.into());
193 self
194 }
195
196 pub fn link(mut self, link: impl Into<String>) -> Self {
199 self.link = Some(link.into());
200 self
201 }
202
203 pub(crate) fn mount_at(&mut self, route: impl Into<String>) {
206 self.route = Some(route.into());
207 }
208
209 pub(crate) fn at(mut self, route: impl Into<String>) -> Self {
216 self.route = Some(route.into());
217 self
218 }
219
220 pub(crate) fn route(&self) -> String {
227 match &self.route {
228 Some(route) => route.clone(),
229 None if self.per_operator => format!("/preferences/{}", self.code.replace('.', "/")),
230 None => format!("/settings/{}", self.code.replace('.', "/")),
231 }
232 }
233
234 pub fn path(&self, admin_path: &str) -> String {
238 match &self.link {
239 Some(link) => format!("{admin_path}{link}"),
240 None => format!("{admin_path}{}", self.route()),
241 }
242 }
243}
244
245pub(crate) fn index(shell: crate::Shell) -> Response {
248 render(SettingsIndexTemplate { shell })
249}
250
251pub(crate) async fn edit_form(
254 state: &AdminState,
255 item: &SettingsItem,
256 shell: crate::Shell,
257 user: &laterite_auth::AuthenticatedUser,
258) -> Response {
259 let mut stored = match stored_value(state, item, user).await {
260 Some(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
261 None => return render_error(),
262 };
263 prefill_from_config(item, &mut stored, &state.app_name);
266 render(build(
267 item,
268 &state.field_types,
269 state.overrides.as_ref(),
270 None,
271 &stored,
272 &shell,
273 ))
274}
275
276pub(crate) async fn update(
278 state: &AdminState,
279 item: &SettingsItem,
280 data: HashMap<String, String>,
281 shell: crate::Shell,
282 session: &crate::session::SessionHandle,
283 user: &laterite_auth::AuthenticatedUser,
284) -> Response {
285 let value = match collect(item, &state.field_types, &data) {
288 Ok(value) => value,
289 Err(message) => {
290 let stored = stored_value(state, item, user)
291 .await
292 .flatten()
293 .unwrap_or(Value::Null);
294 return render(build(
295 item,
296 &state.field_types,
297 state.overrides.as_ref(),
298 Some(message),
299 &stored,
300 &shell,
301 ));
302 }
303 };
304 let written = if item.per_operator {
305 store::set_for(&state.db, user.user.id, &item.code, &value)
306 .await
307 .is_ok()
308 } else {
309 store::set(&state.db, &item.code, &value).await.is_ok()
310 };
311 match written {
312 true => {
313 if item.code == brand::BrandSetting::CODE {
316 state.invalidate_brand();
317 }
318 if !item.per_operator {
322 crate::audit::record(
323 state,
324 user,
325 "backend.settings.update",
326 Some("settings"),
327 Some(item.code.as_str()),
328 None,
329 None,
330 )
331 .await;
332 }
333 session.push_flash(
334 crate::session::FlashLevel::Success,
335 if item.per_operator {
336 t!("Preferences saved.")
337 } else {
338 t!("Settings saved.")
339 },
340 );
341 Redirect::to(&item.path(&state.admin_path)).into_response()
345 }
346 false => render(build(
347 item,
348 &state.field_types,
349 state.overrides.as_ref(),
350 Some(t!("Could not save. Please try again.")),
351 &value,
352 &shell,
353 )),
354 }
355}
356
357async fn stored_value(
360 state: &AdminState,
361 item: &SettingsItem,
362 user: &laterite_auth::AuthenticatedUser,
363) -> Option<Option<Value>> {
364 if item.per_operator {
365 store::get_for(&state.db, user.user.id, &item.code)
366 .await
367 .ok()
368 } else {
369 store::get(&state.db, &item.code).await.ok()
370 }
371}
372
373fn collect(
381 item: &SettingsItem,
382 types: &crate::field::FieldRegistry,
383 data: &HashMap<String, String>,
384) -> Result<Value, Text> {
385 crate::form::value_collect(&item.fields, types, data)
386}
387
388pub(crate) fn sidebar_groups(
394 items: &[SettingsItem],
395 admin_path: &str,
396 active_code: Option<&str>,
397 tr: &Translator,
398 icons: crate::icons::Icons<'_>,
399) -> Vec<CategoryView> {
400 let mut by_category: HashMap<String, Vec<&SettingsItem>> = HashMap::new();
402 for item in items {
403 by_category
404 .entry(tr.t(&item.category))
405 .or_default()
406 .push(item);
407 }
408 let mut groups: Vec<CategoryView> = by_category
409 .into_iter()
410 .map(|(category, mut items)| {
411 items.sort_by(|a, b| {
413 a.order
414 .cmp(&b.order)
415 .then_with(|| a.label.source().cmp(b.label.source()))
416 });
417 CategoryView {
418 min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
419 name: category,
420 items: items
421 .iter()
422 .map(|i| ItemView {
423 label: tr.t(&i.label),
424 description: tr.t(&i.description),
425 path: i.path(admin_path),
426 icon: icons.render(i.icon.as_deref()),
427 active: active_code == Some(i.code.as_str()),
428 })
429 .collect(),
430 }
431 })
432 .collect();
433 groups.sort_by(|a, b| {
434 a.min_order
435 .cmp(&b.min_order)
436 .then_with(|| a.name.cmp(&b.name))
437 });
438 groups
439}
440
441fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
448 if item.code != brand::BrandSetting::CODE {
449 return;
450 }
451 if let Value::Object(map) = stored {
452 let blank = map
453 .get("app_name")
454 .and_then(Value::as_str)
455 .unwrap_or("")
456 .trim()
457 .is_empty();
458 if blank {
459 map.insert("app_name".to_string(), Value::String(app_name.to_string()));
460 }
461 }
462}
463
464fn build(
465 item: &SettingsItem,
466 types: &crate::field::FieldRegistry,
467 overrides: &dyn crate::field::OverrideResolver,
468 error: Option<Text>,
469 stored: &Value,
470 shell: &crate::Shell,
471) -> SettingsFormTemplate {
472 let fields =
473 crate::form::value_views(&item.fields, types, overrides, stored, shell, &item.code);
474 SettingsFormTemplate {
475 title: shell.tt(&item.label),
476 description: shell.tt(&item.description),
477 hint: item.hint.as_ref().map(|h| shell.tt(h)),
478 action: item.path(&shell.base),
479 error: error.map(|e| shell.tt(&e)),
480 fields,
481 leave_off: !item.confirm_leave,
482 shell: shell.clone(),
483 }
484}
485
486#[derive(Clone)]
488pub(crate) struct CategoryView {
489 pub(crate) name: String,
490 min_order: i32,
491 pub(crate) items: Vec<ItemView>,
492}
493
494impl CategoryView {
495 pub(crate) fn new(name: String, items: Vec<ItemView>) -> Self {
497 Self {
498 name,
499 min_order: 0,
500 items,
501 }
502 }
503}
504
505#[derive(Clone)]
507pub(crate) struct ItemView {
508 pub(crate) label: String,
509 pub(crate) description: String,
510 pub(crate) path: String,
511 pub(crate) icon: String,
513 pub(crate) active: bool,
515}
516
517#[derive(Template)]
518#[template(path = "settings_index.html")]
519struct SettingsIndexTemplate {
520 shell: crate::Shell,
521}
522
523#[derive(Template)]
524#[template(path = "settings_form.html")]
525struct SettingsFormTemplate {
526 shell: crate::Shell,
527 title: String,
528 description: String,
529 hint: Option<String>,
532 action: String,
533 error: Option<String>,
534 fields: Vec<ValueFieldView>,
535 leave_off: bool,
537}
538
539#[cfg(test)]
540mod field_system_tests {
541 use super::*;
542
543 #[test]
544 #[should_panic(expected = "over the 72")]
545 fn a_lecture_of_a_description_is_refused() {
546 SettingsItem::new("acme.test", "Test", Vec::new()).description(
549 "What search engines and other crawlers may visit. The admin panel \
550 is always excluded, whatever you put here.",
551 );
552 }
553
554 #[test]
555 fn a_label_sized_description_passes_and_a_hint_carries_the_rest() {
556 let item = SettingsItem::new("acme.test", "Test", Vec::new())
557 .description("Which crawlers may visit, and what they may not.")
558 .hint(
559 "The admin panel is always excluded, whatever you put here, and a \
560 crawler obeys only the block that names it.",
561 );
562 assert!(item.description.source().chars().count() <= MAX_DESCRIPTION);
563 assert!(item.hint.is_some());
565 }
566
567 fn item(fields: Vec<FormField>) -> SettingsItem {
568 SettingsItem {
569 code: "acme.test".to_string(),
570 label: Text::new(""),
571 description: Text::new(""),
572 category: Text::new(""),
573 hint: None,
574 confirm_leave: true,
575 order: 0,
576 icon: None,
577 permission: None,
578 link: None,
579 fields,
580 route: None,
581 per_operator: false,
582 }
583 }
584
585 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
586 pairs
587 .iter()
588 .map(|(k, v)| (k.to_string(), v.to_string()))
589 .collect()
590 }
591
592 #[test]
595 fn settings_store_what_the_field_type_says() {
596 let cfg = item(vec![
597 FormField::text("name", "Name"),
598 FormField::switch("enabled", "Enabled"),
599 FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
600 FormField::date("expires", "Expires"),
601 ]);
602 let stored = collect(
603 &cfg,
604 &crate::field::builtin_registry(),
605 &submitted(&[
606 ("name", "Acme"),
607 ("enabled", "on"),
608 ("freq", "daily"),
609 ("expires", "2026-12-31"),
610 ]),
611 )
612 .unwrap();
613
614 assert_eq!(stored["name"], "Acme");
615 assert_eq!(stored["enabled"], serde_json::json!(true));
617 assert_eq!(stored["freq"], "daily");
618 assert_eq!(stored["expires"], "2026-12-31");
619 }
620
621 #[test]
624 fn an_unticked_switch_clears() {
625 let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
626 let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
627 assert_eq!(stored["enabled"], serde_json::json!(false));
628 }
629
630 #[test]
633 fn a_repeater_works_in_settings() {
634 let cfg = item(vec![FormField::repeater(
635 "rules",
636 "Rules",
637 vec![
638 FormField::text("path", "Path"),
639 FormField::switch("allow", "Allow"),
640 ],
641 )]);
642 let stored = collect(
643 &cfg,
644 &crate::field::builtin_registry(),
645 &submitted(&[
646 ("rules[0][path]", "/private"),
647 ("rules[1][path]", "/public"),
648 ("rules[1][allow]", "on"),
649 ]),
650 )
651 .unwrap();
652
653 let rows = stored["rules"].as_array().unwrap();
654 assert_eq!(rows.len(), 2);
655 assert_eq!(rows[0]["path"], "/private");
656 assert_eq!(rows[0]["allow"], serde_json::json!(false));
657 assert_eq!(rows[1]["allow"], serde_json::json!(true));
658 }
659
660 #[test]
661 fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
662 let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
663 assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
664 }
665
666 #[test]
667 fn the_form_renders_each_control_through_its_type() {
668 let cfg = item(vec![
669 FormField::switch("enabled", "Enabled"),
670 FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
671 ]);
672 let html = build(
673 &cfg,
674 &crate::field::builtin_registry(),
675 &crate::field::NoOverrides,
676 None,
677 &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
678 &crate::Shell::test(),
679 )
680 .render()
681 .unwrap();
682
683 assert!(html.contains(r#"type="checkbox""#));
684 assert!(html.contains(r#"name="rules[0][path]""#));
685 assert!(html.contains(r#"value="/a""#));
686 assert!(html.contains("lat-repeater__blank"));
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use laterite_core::Db;
694
695 #[test]
696 fn brand_form_prefills_app_name_from_config_when_unset() {
697 let brand = brand::settings_item();
698 let mut unset = Value::Object(Map::new());
700 prefill_from_config(&brand, &mut unset, "Configured Name");
701 assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
702 let mut set = serde_json::json!({ "app_name": "Acme" });
704 prefill_from_config(&brand, &mut set, "Configured Name");
705 assert_eq!(set["app_name"], serde_json::json!("Acme"));
706 let mut other = Value::Object(Map::new());
708 prefill_from_config(&item(), &mut other, "Configured Name");
709 assert!(other.get("app_name").is_none());
710 }
711
712 fn item() -> SettingsItem {
713 SettingsItem {
714 code: "test.log".to_string(),
715 label: "Log Settings".into(),
716 description: "What the log records.".into(),
717 category: "Logs".into(),
718 hint: None,
719 confirm_leave: true,
720 order: 10,
721 icon: None,
722 permission: None,
723 link: None,
724 fields: vec![
725 FormField::switch("log_events", "Log events"),
726 FormField::switch("log_requests", "Log requests"),
727 FormField::text("retention_days", "Retention (days)"),
728 ],
729 route: None,
730 per_operator: false,
731 }
732 }
733
734 fn state(db: Db) -> AdminState {
735 AdminState::new(
736 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
737 db,
738 )
739 }
740
741 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
744 laterite_core::testing::connect_test(&[migrations()]).await
745 }
746
747 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
748 pairs
749 .iter()
750 .map(|(k, v)| (k.to_string(), v.to_string()))
751 .collect()
752 }
753
754 #[test]
755 fn group_orders_categories_then_items() {
756 let items = vec![
757 SettingsItem {
758 code: "b".into(),
759 label: "Beta".into(),
760 description: String::new().into(),
761 category: "System".into(),
762 hint: None,
763 confirm_leave: true,
764 order: 20,
765 icon: None,
766 permission: None,
767 link: None,
768 fields: vec![],
769 route: None,
770 per_operator: false,
771 },
772 SettingsItem {
773 code: "a".into(),
774 label: "Alpha".into(),
775 description: String::new().into(),
776 category: "System".into(),
777 hint: None,
778 confirm_leave: true,
779 order: 10,
780 icon: None,
781 permission: None,
782 link: None,
783 fields: vec![],
784 route: None,
785 per_operator: false,
786 },
787 SettingsItem {
788 code: "l".into(),
789 label: "Logs".into(),
790 description: String::new().into(),
791 category: "Logs".into(),
792 hint: None,
793 confirm_leave: true,
794 order: 5,
795 icon: None,
796 permission: None,
797 link: None,
798 fields: vec![],
799 route: None,
800 per_operator: false,
801 },
802 ];
803 let groups = sidebar_groups(
804 &items,
805 "/admin",
806 None,
807 &Translator::new("en"),
808 crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
809 );
810 assert_eq!(groups[0].name, "Logs");
812 assert_eq!(groups[1].name, "System");
813 assert_eq!(groups[1].items[0].label, "Alpha");
815 assert_eq!(groups[1].items[1].label, "Beta");
816 assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
818 }
819
820 #[test]
821 fn group_marks_only_the_active_item() {
822 let items = vec![item()];
823 let groups = sidebar_groups(
824 &items,
825 "/admin",
826 Some("test.log"),
827 &Translator::new("en"),
828 crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
829 );
830 let active: Vec<&str> = groups
831 .iter()
832 .flat_map(|g| &g.items)
833 .filter(|i| i.active)
834 .map(|i| i.label.as_str())
835 .collect();
836 assert_eq!(active, ["Log Settings"]);
837 }
838
839 #[test]
840 fn settings_model_item_path_is_its_form() {
841 assert_eq!(item().path("/admin"), "/admin/settings/test/log");
844 assert_eq!(item().path("/manage"), "/manage/settings/test/log");
846
847 let mut resolved = item();
849 resolved.mount_at("/acme/logging/log");
850 assert_eq!(resolved.path("/admin"), "/admin/acme/logging/log");
851 }
852
853 #[test]
854 fn link_item_path_follows_the_link() {
855 let admins = crate::builtin_settings()
856 .into_iter()
857 .find(|i| i.code == "backend.administrators")
858 .unwrap();
859 assert_eq!(admins.category.source(), "Users");
860 assert!(admins.link.is_some());
861 assert!(admins.fields.is_empty());
862 assert_eq!(admins.path("/admin"), "/admin/users");
864 assert!(crate::builtin_settings()
865 .iter()
866 .any(|i| i.code == "backend.roles"));
867 }
868
869 #[tokio::test]
870 async fn update_persists_typed_values() {
871 let (db, _guard) = test_db().await;
872 let st = state(db.clone());
873
874 let it = item();
875 let resp = update(
876 &st,
877 &it,
878 data(&[("log_events", "on"), ("retention_days", "30")]),
880 crate::Shell::test(),
881 &crate::session::SessionHandle::from_blob(None),
882 &crate::audit::test_actor(),
883 )
884 .await;
885 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
886
887 let stored = store::get(&db, "test.log").await.unwrap().unwrap();
888 assert_eq!(stored["log_events"], serde_json::json!(true));
889 assert_eq!(stored["log_requests"], serde_json::json!(false));
890 assert_eq!(stored["retention_days"], serde_json::json!("30"));
891 }
892
893 #[test]
894 fn index_renders() {
895 let resp = index(crate::Shell::test());
896 assert_eq!(resp.status(), axum::http::StatusCode::OK);
897 }
898
899 #[tokio::test]
900 async fn edit_form_renders_for_unset_item() {
901 let (db, _guard) = test_db().await;
902 let st = state(db);
903 let it = item();
905 let resp = edit_form(&st, &it, crate::Shell::test(), &crate::audit::test_actor()).await;
906 assert_eq!(resp.status(), axum::http::StatusCode::OK);
907 }
908}