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
23use std::collections::HashMap;
24
25use askama::Template;
26use axum::response::{IntoResponse, Redirect, Response};
27use serde_json::{Map, Value};
28
29use crate::{render, render_error, AdminState};
30
31#[derive(Debug, Clone, Copy)]
33pub enum SettingsWidget {
34 Text,
36 Textarea,
38 Switch,
40}
41
42#[derive(Debug, Clone)]
45pub struct SettingsField {
46 pub name: String,
47 pub label: String,
48 pub widget: SettingsWidget,
49 pub help: Option<String>,
50}
51
52impl SettingsField {
53 pub fn text(name: &str, label: &str) -> Self {
54 Self {
55 name: name.to_string(),
56 label: label.to_string(),
57 widget: SettingsWidget::Text,
58 help: None,
59 }
60 }
61
62 pub fn textarea(name: &str, label: &str) -> Self {
63 Self {
64 widget: SettingsWidget::Textarea,
65 ..Self::text(name, label)
66 }
67 }
68
69 pub fn switch(name: &str, label: &str) -> Self {
70 Self {
71 widget: SettingsWidget::Switch,
72 ..Self::text(name, label)
73 }
74 }
75
76 pub fn help(mut self, text: &str) -> Self {
77 self.help = Some(text.to_string());
78 self
79 }
80}
81
82#[derive(Debug, Clone)]
85pub struct SettingsItem {
86 pub code: String,
88 pub label: String,
89 pub description: String,
90 pub category: String,
92 pub order: i32,
94 pub icon: Option<String>,
97 pub permission: Option<String>,
100 pub link: Option<String>,
104 pub fields: Vec<SettingsField>,
105}
106
107impl SettingsItem {
108 pub fn path(&self) -> String {
110 self.link
111 .clone()
112 .unwrap_or_else(|| format!("/admin/settings/{}", self.code))
113 }
114}
115
116pub(crate) fn index(shell: crate::Shell) -> Response {
119 render(SettingsIndexTemplate { shell })
120}
121
122pub(crate) async fn edit_form(
125 state: &AdminState,
126 item: &SettingsItem,
127 shell: crate::Shell,
128) -> Response {
129 let mut stored = match store::get(&state.db, &item.code).await {
130 Ok(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
131 Err(_) => return render_error(),
132 };
133 prefill_from_config(item, &mut stored, &state.app_name);
136 render(build(item, None, &stored, &shell))
137}
138
139pub(crate) async fn update(
141 state: &AdminState,
142 item: &SettingsItem,
143 data: HashMap<String, String>,
144 shell: crate::Shell,
145) -> Response {
146 let value = collect(item, &data);
147 match store::set(&state.db, &item.code, &value).await {
148 Ok(()) => {
149 if item.code == brand::BrandSetting::CODE {
152 state.invalidate_brand();
153 }
154 Redirect::to("/admin/settings").into_response()
155 }
156 Err(_) => render(build(
157 item,
158 Some("Could not save. Please try again.".to_string()),
159 &value,
160 &shell,
161 )),
162 }
163}
164
165fn collect(item: &SettingsItem, data: &HashMap<String, String>) -> Value {
169 let mut object = Map::new();
170 for field in &item.fields {
171 let value = match field.widget {
172 SettingsWidget::Text | SettingsWidget::Textarea => {
173 Value::String(data.get(&field.name).cloned().unwrap_or_default())
174 }
175 SettingsWidget::Switch => Value::Bool(is_checked(data.get(&field.name))),
176 };
177 object.insert(field.name.clone(), value);
178 }
179 Value::Object(object)
180}
181
182fn is_checked(raw: Option<&String>) -> bool {
183 matches!(raw.map(String::as_str), Some("on" | "true" | "1"))
184}
185
186pub(crate) fn sidebar_groups(
192 items: &[SettingsItem],
193 active_code: Option<&str>,
194) -> Vec<CategoryView> {
195 let mut by_category: HashMap<&str, Vec<&SettingsItem>> = HashMap::new();
196 for item in items {
197 by_category.entry(&item.category).or_default().push(item);
198 }
199 let mut groups: Vec<CategoryView> = by_category
200 .into_iter()
201 .map(|(category, mut items)| {
202 items.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.label.cmp(&b.label)));
203 CategoryView {
204 min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
205 name: category.to_string(),
206 items: items
207 .iter()
208 .map(|i| ItemView {
209 label: i.label.clone(),
210 description: i.description.clone(),
211 path: i.path(),
212 icon: crate::icons::svg(i.icon.as_deref()),
213 active: active_code == Some(i.code.as_str()),
214 })
215 .collect(),
216 }
217 })
218 .collect();
219 groups.sort_by(|a, b| {
220 a.min_order
221 .cmp(&b.min_order)
222 .then_with(|| a.name.cmp(&b.name))
223 });
224 groups
225}
226
227fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
234 if item.code != brand::BrandSetting::CODE {
235 return;
236 }
237 if let Value::Object(map) = stored {
238 let blank = map
239 .get("app_name")
240 .and_then(Value::as_str)
241 .unwrap_or("")
242 .trim()
243 .is_empty();
244 if blank {
245 map.insert("app_name".to_string(), Value::String(app_name.to_string()));
246 }
247 }
248}
249
250fn build(
251 item: &SettingsItem,
252 error: Option<String>,
253 stored: &Value,
254 shell: &crate::Shell,
255) -> SettingsFormTemplate {
256 let fields = item
257 .fields
258 .iter()
259 .map(|f| {
260 let current = stored.get(&f.name);
261 FieldView {
262 name: f.name.clone(),
263 label: f.label.clone(),
264 help: f.help.clone(),
265 textarea: matches!(f.widget, SettingsWidget::Textarea),
266 switch: matches!(f.widget, SettingsWidget::Switch),
267 checked: current.and_then(Value::as_bool).unwrap_or(false),
268 value: match current {
269 Some(Value::String(s)) => s.clone(),
270 Some(Value::Null) | None => String::new(),
271 Some(other) => other.to_string(),
272 },
273 }
274 })
275 .collect();
276 SettingsFormTemplate {
277 shell: shell.clone(),
278 title: item.label.clone(),
279 description: item.description.clone(),
280 action: item.path(),
281 error,
282 fields,
283 }
284}
285
286#[derive(Clone)]
288pub(crate) struct CategoryView {
289 pub(crate) name: String,
290 min_order: i32,
291 pub(crate) items: Vec<ItemView>,
292}
293
294#[derive(Clone)]
296pub(crate) struct ItemView {
297 pub(crate) label: String,
298 pub(crate) description: String,
299 pub(crate) path: String,
300 pub(crate) icon: &'static str,
302 pub(crate) active: bool,
304}
305
306#[derive(Template)]
307#[template(path = "settings_index.html")]
308struct SettingsIndexTemplate {
309 shell: crate::Shell,
310}
311
312struct FieldView {
313 name: String,
314 label: String,
315 help: Option<String>,
316 value: String,
317 textarea: bool,
318 switch: bool,
319 checked: bool,
320}
321
322#[derive(Template)]
323#[template(path = "settings_form.html")]
324struct SettingsFormTemplate {
325 shell: crate::Shell,
326 title: String,
327 description: String,
328 action: String,
329 error: Option<String>,
330 fields: Vec<FieldView>,
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use laterite_core::Db;
337
338 #[test]
339 fn brand_form_prefills_app_name_from_config_when_unset() {
340 let brand = brand::settings_item();
341 let mut unset = Value::Object(Map::new());
343 prefill_from_config(&brand, &mut unset, "Configured Name");
344 assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
345 let mut set = serde_json::json!({ "app_name": "Acme" });
347 prefill_from_config(&brand, &mut set, "Configured Name");
348 assert_eq!(set["app_name"], serde_json::json!("Acme"));
349 let mut other = Value::Object(Map::new());
351 prefill_from_config(&item(), &mut other, "Configured Name");
352 assert!(other.get("app_name").is_none());
353 }
354
355 fn item() -> SettingsItem {
356 SettingsItem {
357 code: "test.log".to_string(),
358 label: "Log Settings".to_string(),
359 description: "What the log records.".to_string(),
360 category: "Logs".to_string(),
361 order: 10,
362 icon: None,
363 permission: None,
364 link: None,
365 fields: vec![
366 SettingsField::switch("log_events", "Log events"),
367 SettingsField::switch("log_requests", "Log requests"),
368 SettingsField::text("retention_days", "Retention (days)"),
369 ],
370 }
371 }
372
373 fn state(db: Db) -> AdminState {
374 AdminState::new(
375 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
376 db,
377 )
378 }
379
380 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
383 laterite_core::testing::connect_test(&[migrations()]).await
384 }
385
386 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
387 pairs
388 .iter()
389 .map(|(k, v)| (k.to_string(), v.to_string()))
390 .collect()
391 }
392
393 #[test]
394 fn group_orders_categories_then_items() {
395 let items = vec![
396 SettingsItem {
397 code: "b".into(),
398 label: "Beta".into(),
399 description: String::new(),
400 category: "System".into(),
401 order: 20,
402 icon: None,
403 permission: None,
404 link: None,
405 fields: vec![],
406 },
407 SettingsItem {
408 code: "a".into(),
409 label: "Alpha".into(),
410 description: String::new(),
411 category: "System".into(),
412 order: 10,
413 icon: None,
414 permission: None,
415 link: None,
416 fields: vec![],
417 },
418 SettingsItem {
419 code: "l".into(),
420 label: "Logs".into(),
421 description: String::new(),
422 category: "Logs".into(),
423 order: 5,
424 icon: None,
425 permission: None,
426 link: None,
427 fields: vec![],
428 },
429 ];
430 let groups = sidebar_groups(&items, None);
431 assert_eq!(groups[0].name, "Logs");
433 assert_eq!(groups[1].name, "System");
434 assert_eq!(groups[1].items[0].label, "Alpha");
436 assert_eq!(groups[1].items[1].label, "Beta");
437 assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
439 }
440
441 #[test]
442 fn group_marks_only_the_active_item() {
443 let items = vec![item()];
444 let groups = sidebar_groups(&items, Some("test.log"));
445 let active: Vec<&str> = groups
446 .iter()
447 .flat_map(|g| &g.items)
448 .filter(|i| i.active)
449 .map(|i| i.label.as_str())
450 .collect();
451 assert_eq!(active, ["Log Settings"]);
452 }
453
454 #[test]
455 fn settings_model_item_path_is_its_form() {
456 assert_eq!(item().path(), "/admin/settings/test.log");
457 }
458
459 #[test]
460 fn link_item_path_follows_the_link() {
461 let admins = crate::builtin_settings()
462 .into_iter()
463 .find(|i| i.code == "backend.administrators")
464 .unwrap();
465 assert_eq!(admins.category, "Users");
466 assert!(admins.link.is_some());
467 assert!(admins.fields.is_empty());
468 assert_eq!(admins.path(), "/admin/users");
470 assert!(crate::builtin_settings()
471 .iter()
472 .any(|i| i.code == "backend.roles"));
473 }
474
475 #[tokio::test]
476 async fn update_persists_typed_values() {
477 let (db, _guard) = test_db().await;
478 let st = state(db.clone());
479
480 let it = item();
481 let resp = update(
482 &st,
483 &it,
484 data(&[("log_events", "on"), ("retention_days", "30")]),
486 crate::Shell::test(),
487 )
488 .await;
489 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
490
491 let stored = store::get(&db, "test.log").await.unwrap().unwrap();
492 assert_eq!(stored["log_events"], serde_json::json!(true));
493 assert_eq!(stored["log_requests"], serde_json::json!(false));
494 assert_eq!(stored["retention_days"], serde_json::json!("30"));
495 }
496
497 #[test]
498 fn index_renders() {
499 let resp = index(crate::Shell::test());
500 assert_eq!(resp.status(), axum::http::StatusCode::OK);
501 }
502
503 #[tokio::test]
504 async fn edit_form_renders_for_unset_item() {
505 let (db, _guard) = test_db().await;
506 let st = state(db);
507 let it = item();
509 let resp = edit_form(&st, &it, crate::Shell::test()).await;
510 assert_eq!(resp.status(), axum::http::StatusCode::OK);
511 }
512}