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::{t, Text, Translator};
40use serde::Serialize;
41use serde_json::{Map, Value};
42
43use crate::{render, render_error, AdminState};
44
45#[derive(Debug, Clone, Copy, Serialize)]
47pub enum SettingsWidget {
48 Text,
50 Textarea,
52 Switch,
54}
55
56#[derive(Debug, Clone, Serialize)]
59pub struct SettingsField {
60 pub name: String,
61 pub label: Text,
63 pub widget: SettingsWidget,
64 pub help: Option<Text>,
65}
66
67impl SettingsField {
68 pub fn text(name: &str, label: impl Into<Text>) -> Self {
69 Self {
70 name: name.to_string(),
71 label: label.into(),
72 widget: SettingsWidget::Text,
73 help: None,
74 }
75 }
76
77 pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
78 Self {
79 widget: SettingsWidget::Textarea,
80 ..Self::text(name, label)
81 }
82 }
83
84 pub fn switch(name: &str, label: impl Into<Text>) -> Self {
85 Self {
86 widget: SettingsWidget::Switch,
87 ..Self::text(name, label)
88 }
89 }
90
91 pub fn help(mut self, text: impl Into<Text>) -> Self {
92 self.help = Some(text.into());
93 self
94 }
95}
96
97#[derive(Debug, Clone, Serialize)]
100pub struct SettingsItem {
101 pub code: String,
103 pub label: Text,
105 pub description: Text,
106 pub category: Text,
108 pub order: i32,
110 pub icon: Option<String>,
113 pub permission: Option<String>,
116 pub link: Option<String>,
120 pub fields: Vec<SettingsField>,
121}
122
123impl SettingsItem {
124 pub fn path(&self, admin_path: &str) -> String {
128 match &self.link {
129 Some(link) => format!("{admin_path}{link}"),
130 None => format!("{admin_path}/settings/{}", self.code),
131 }
132 }
133}
134
135pub(crate) fn index(shell: crate::Shell) -> Response {
138 render(SettingsIndexTemplate { shell })
139}
140
141pub(crate) async fn edit_form(
144 state: &AdminState,
145 item: &SettingsItem,
146 shell: crate::Shell,
147) -> Response {
148 let mut stored = match store::get(&state.db, &item.code).await {
149 Ok(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
150 Err(_) => return render_error(),
151 };
152 prefill_from_config(item, &mut stored, &state.app_name);
155 render(build(item, None, &stored, &shell))
156}
157
158pub(crate) async fn update(
160 state: &AdminState,
161 item: &SettingsItem,
162 data: HashMap<String, String>,
163 shell: crate::Shell,
164 session: &crate::session::SessionHandle,
165 user: &laterite_auth::AuthenticatedUser,
166) -> Response {
167 let value = collect(item, &data);
168 match store::set(&state.db, &item.code, &value).await {
169 Ok(()) => {
170 if item.code == brand::BrandSetting::CODE {
173 state.invalidate_brand();
174 }
175 crate::audit::record(
178 state,
179 user,
180 "backend.settings.update",
181 Some("settings"),
182 Some(item.code.as_str()),
183 None,
184 )
185 .await;
186 session.push_flash(crate::session::FlashLevel::Success, t!("Settings saved."));
187 Redirect::to(&format!("{}/settings", state.admin_path)).into_response()
188 }
189 Err(_) => render(build(
190 item,
191 Some(t!("Could not save. Please try again.")),
192 &value,
193 &shell,
194 )),
195 }
196}
197
198fn collect(item: &SettingsItem, data: &HashMap<String, String>) -> Value {
202 let mut object = Map::new();
203 for field in &item.fields {
204 let value = match field.widget {
205 SettingsWidget::Text | SettingsWidget::Textarea => {
206 Value::String(data.get(&field.name).cloned().unwrap_or_default())
207 }
208 SettingsWidget::Switch => Value::Bool(is_checked(data.get(&field.name))),
209 };
210 object.insert(field.name.clone(), value);
211 }
212 Value::Object(object)
213}
214
215fn is_checked(raw: Option<&String>) -> bool {
216 matches!(raw.map(String::as_str), Some("on" | "true" | "1"))
217}
218
219pub(crate) fn sidebar_groups(
225 items: &[SettingsItem],
226 admin_path: &str,
227 active_code: Option<&str>,
228 tr: &Translator,
229) -> Vec<CategoryView> {
230 let mut by_category: HashMap<String, Vec<&SettingsItem>> = HashMap::new();
232 for item in items {
233 by_category
234 .entry(tr.t(&item.category))
235 .or_default()
236 .push(item);
237 }
238 let mut groups: Vec<CategoryView> = by_category
239 .into_iter()
240 .map(|(category, mut items)| {
241 items.sort_by(|a, b| {
243 a.order
244 .cmp(&b.order)
245 .then_with(|| a.label.source().cmp(b.label.source()))
246 });
247 CategoryView {
248 min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
249 name: category,
250 items: items
251 .iter()
252 .map(|i| ItemView {
253 label: tr.t(&i.label),
254 description: tr.t(&i.description),
255 path: i.path(admin_path),
256 icon: crate::icons::svg(i.icon.as_deref()),
257 active: active_code == Some(i.code.as_str()),
258 })
259 .collect(),
260 }
261 })
262 .collect();
263 groups.sort_by(|a, b| {
264 a.min_order
265 .cmp(&b.min_order)
266 .then_with(|| a.name.cmp(&b.name))
267 });
268 groups
269}
270
271fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
278 if item.code != brand::BrandSetting::CODE {
279 return;
280 }
281 if let Value::Object(map) = stored {
282 let blank = map
283 .get("app_name")
284 .and_then(Value::as_str)
285 .unwrap_or("")
286 .trim()
287 .is_empty();
288 if blank {
289 map.insert("app_name".to_string(), Value::String(app_name.to_string()));
290 }
291 }
292}
293
294fn build(
295 item: &SettingsItem,
296 error: Option<Text>,
297 stored: &Value,
298 shell: &crate::Shell,
299) -> SettingsFormTemplate {
300 let fields = item
301 .fields
302 .iter()
303 .map(|f| {
304 let current = stored.get(&f.name);
305 FieldView {
306 name: f.name.clone(),
307 label: shell.tt(&f.label),
308 help: f.help.as_ref().map(|h| shell.tt(h)),
309 textarea: matches!(f.widget, SettingsWidget::Textarea),
310 switch: matches!(f.widget, SettingsWidget::Switch),
311 checked: current.and_then(Value::as_bool).unwrap_or(false),
312 value: match current {
313 Some(Value::String(s)) => s.clone(),
314 Some(Value::Null) | None => String::new(),
315 Some(other) => other.to_string(),
316 },
317 }
318 })
319 .collect();
320 SettingsFormTemplate {
321 title: shell.tt(&item.label),
322 description: shell.tt(&item.description),
323 action: item.path(&shell.base),
324 error: error.map(|e| shell.tt(&e)),
325 fields,
326 shell: shell.clone(),
327 }
328}
329
330#[derive(Clone)]
332pub(crate) struct CategoryView {
333 pub(crate) name: String,
334 min_order: i32,
335 pub(crate) items: Vec<ItemView>,
336}
337
338#[derive(Clone)]
340pub(crate) struct ItemView {
341 pub(crate) label: String,
342 pub(crate) description: String,
343 pub(crate) path: String,
344 pub(crate) icon: &'static str,
346 pub(crate) active: bool,
348}
349
350#[derive(Template)]
351#[template(path = "settings_index.html")]
352struct SettingsIndexTemplate {
353 shell: crate::Shell,
354}
355
356struct FieldView {
357 name: String,
358 label: String,
359 help: Option<String>,
360 value: String,
361 textarea: bool,
362 switch: bool,
363 checked: bool,
364}
365
366#[derive(Template)]
367#[template(path = "settings_form.html")]
368struct SettingsFormTemplate {
369 shell: crate::Shell,
370 title: String,
371 description: String,
372 action: String,
373 error: Option<String>,
374 fields: Vec<FieldView>,
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use laterite_core::Db;
381
382 #[test]
383 fn brand_form_prefills_app_name_from_config_when_unset() {
384 let brand = brand::settings_item();
385 let mut unset = Value::Object(Map::new());
387 prefill_from_config(&brand, &mut unset, "Configured Name");
388 assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
389 let mut set = serde_json::json!({ "app_name": "Acme" });
391 prefill_from_config(&brand, &mut set, "Configured Name");
392 assert_eq!(set["app_name"], serde_json::json!("Acme"));
393 let mut other = Value::Object(Map::new());
395 prefill_from_config(&item(), &mut other, "Configured Name");
396 assert!(other.get("app_name").is_none());
397 }
398
399 fn item() -> SettingsItem {
400 SettingsItem {
401 code: "test.log".to_string(),
402 label: "Log Settings".into(),
403 description: "What the log records.".into(),
404 category: "Logs".into(),
405 order: 10,
406 icon: None,
407 permission: None,
408 link: None,
409 fields: vec![
410 SettingsField::switch("log_events", "Log events"),
411 SettingsField::switch("log_requests", "Log requests"),
412 SettingsField::text("retention_days", "Retention (days)"),
413 ],
414 }
415 }
416
417 fn state(db: Db) -> AdminState {
418 AdminState::new(
419 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
420 db,
421 )
422 }
423
424 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
427 laterite_core::testing::connect_test(&[migrations()]).await
428 }
429
430 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
431 pairs
432 .iter()
433 .map(|(k, v)| (k.to_string(), v.to_string()))
434 .collect()
435 }
436
437 #[test]
438 fn group_orders_categories_then_items() {
439 let items = vec![
440 SettingsItem {
441 code: "b".into(),
442 label: "Beta".into(),
443 description: String::new().into(),
444 category: "System".into(),
445 order: 20,
446 icon: None,
447 permission: None,
448 link: None,
449 fields: vec![],
450 },
451 SettingsItem {
452 code: "a".into(),
453 label: "Alpha".into(),
454 description: String::new().into(),
455 category: "System".into(),
456 order: 10,
457 icon: None,
458 permission: None,
459 link: None,
460 fields: vec![],
461 },
462 SettingsItem {
463 code: "l".into(),
464 label: "Logs".into(),
465 description: String::new().into(),
466 category: "Logs".into(),
467 order: 5,
468 icon: None,
469 permission: None,
470 link: None,
471 fields: vec![],
472 },
473 ];
474 let groups = sidebar_groups(&items, "/admin", None, &Translator::new("en"));
475 assert_eq!(groups[0].name, "Logs");
477 assert_eq!(groups[1].name, "System");
478 assert_eq!(groups[1].items[0].label, "Alpha");
480 assert_eq!(groups[1].items[1].label, "Beta");
481 assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
483 }
484
485 #[test]
486 fn group_marks_only_the_active_item() {
487 let items = vec![item()];
488 let groups = sidebar_groups(&items, "/admin", Some("test.log"), &Translator::new("en"));
489 let active: Vec<&str> = groups
490 .iter()
491 .flat_map(|g| &g.items)
492 .filter(|i| i.active)
493 .map(|i| i.label.as_str())
494 .collect();
495 assert_eq!(active, ["Log Settings"]);
496 }
497
498 #[test]
499 fn settings_model_item_path_is_its_form() {
500 assert_eq!(item().path("/admin"), "/admin/settings/test.log");
501 assert_eq!(item().path("/manage"), "/manage/settings/test.log");
503 }
504
505 #[test]
506 fn link_item_path_follows_the_link() {
507 let admins = crate::builtin_settings()
508 .into_iter()
509 .find(|i| i.code == "backend.administrators")
510 .unwrap();
511 assert_eq!(admins.category.source(), "Users");
512 assert!(admins.link.is_some());
513 assert!(admins.fields.is_empty());
514 assert_eq!(admins.path("/admin"), "/admin/users");
516 assert!(crate::builtin_settings()
517 .iter()
518 .any(|i| i.code == "backend.roles"));
519 }
520
521 #[tokio::test]
522 async fn update_persists_typed_values() {
523 let (db, _guard) = test_db().await;
524 let st = state(db.clone());
525
526 let it = item();
527 let resp = update(
528 &st,
529 &it,
530 data(&[("log_events", "on"), ("retention_days", "30")]),
532 crate::Shell::test(),
533 &crate::session::SessionHandle::from_blob(None),
534 &crate::audit::test_actor(),
535 )
536 .await;
537 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
538
539 let stored = store::get(&db, "test.log").await.unwrap().unwrap();
540 assert_eq!(stored["log_events"], serde_json::json!(true));
541 assert_eq!(stored["log_requests"], serde_json::json!(false));
542 assert_eq!(stored["retention_days"], serde_json::json!("30"));
543 }
544
545 #[test]
546 fn index_renders() {
547 let resp = index(crate::Shell::test());
548 assert_eq!(resp.status(), axum::http::StatusCode::OK);
549 }
550
551 #[tokio::test]
552 async fn edit_form_renders_for_unset_item() {
553 let (db, _guard) = test_db().await;
554 let st = state(db);
555 let it = item();
557 let resp = edit_form(&st, &it, crate::Shell::test()).await;
558 assert_eq!(resp.status(), axum::http::StatusCode::OK);
559 }
560}