1use std::collections::HashMap;
16
17use askama::Template;
18use axum::response::{IntoResponse, Redirect, Response};
19use serde_json::{Map, Value};
20
21use crate::{render, render_error, AdminState};
22
23#[derive(Debug, Clone, Copy)]
25pub enum SettingsWidget {
26 Text,
28 Textarea,
30 Switch,
32}
33
34#[derive(Debug, Clone)]
37pub struct SettingsField {
38 pub name: String,
39 pub label: String,
40 pub widget: SettingsWidget,
41 pub help: Option<String>,
42}
43
44impl SettingsField {
45 pub fn text(name: &str, label: &str) -> Self {
46 Self {
47 name: name.to_string(),
48 label: label.to_string(),
49 widget: SettingsWidget::Text,
50 help: None,
51 }
52 }
53
54 pub fn textarea(name: &str, label: &str) -> Self {
55 Self {
56 widget: SettingsWidget::Textarea,
57 ..Self::text(name, label)
58 }
59 }
60
61 pub fn switch(name: &str, label: &str) -> Self {
62 Self {
63 widget: SettingsWidget::Switch,
64 ..Self::text(name, label)
65 }
66 }
67
68 pub fn help(mut self, text: &str) -> Self {
69 self.help = Some(text.to_string());
70 self
71 }
72}
73
74#[derive(Debug, Clone)]
77pub struct SettingsItem {
78 pub code: String,
80 pub label: String,
81 pub description: String,
82 pub category: String,
84 pub order: i32,
86 pub icon: Option<String>,
89 pub permission: Option<String>,
92 pub link: Option<String>,
96 pub fields: Vec<SettingsField>,
97}
98
99impl SettingsItem {
100 pub fn path(&self) -> String {
102 self.link
103 .clone()
104 .unwrap_or_else(|| format!("/admin/settings/{}", self.code))
105 }
106}
107
108pub(crate) fn index(shell: crate::Shell) -> Response {
111 render(SettingsIndexTemplate { shell })
112}
113
114pub(crate) async fn edit_form(
117 state: &AdminState,
118 item: &SettingsItem,
119 shell: crate::Shell,
120) -> Response {
121 let stored = match laterite_settings::get(&state.db, &item.code).await {
122 Ok(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
123 Err(_) => return render_error(),
124 };
125 render(build(item, None, &stored, &shell))
126}
127
128pub(crate) async fn update(
130 state: &AdminState,
131 item: &SettingsItem,
132 data: HashMap<String, String>,
133 shell: crate::Shell,
134) -> Response {
135 let value = collect(item, &data);
136 match laterite_settings::set(&state.db, &item.code, &value).await {
137 Ok(()) => Redirect::to("/admin/settings").into_response(),
138 Err(_) => render(build(
139 item,
140 Some("Could not save. Please try again.".to_string()),
141 &value,
142 &shell,
143 )),
144 }
145}
146
147fn collect(item: &SettingsItem, data: &HashMap<String, String>) -> Value {
151 let mut object = Map::new();
152 for field in &item.fields {
153 let value = match field.widget {
154 SettingsWidget::Text | SettingsWidget::Textarea => {
155 Value::String(data.get(&field.name).cloned().unwrap_or_default())
156 }
157 SettingsWidget::Switch => Value::Bool(is_checked(data.get(&field.name))),
158 };
159 object.insert(field.name.clone(), value);
160 }
161 Value::Object(object)
162}
163
164fn is_checked(raw: Option<&String>) -> bool {
165 matches!(raw.map(String::as_str), Some("on" | "true" | "1"))
166}
167
168pub(crate) fn sidebar_groups(
174 items: &[SettingsItem],
175 active_code: Option<&str>,
176) -> Vec<CategoryView> {
177 let mut by_category: HashMap<&str, Vec<&SettingsItem>> = HashMap::new();
178 for item in items {
179 by_category.entry(&item.category).or_default().push(item);
180 }
181 let mut groups: Vec<CategoryView> = by_category
182 .into_iter()
183 .map(|(category, mut items)| {
184 items.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.label.cmp(&b.label)));
185 CategoryView {
186 min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
187 name: category.to_string(),
188 items: items
189 .iter()
190 .map(|i| ItemView {
191 label: i.label.clone(),
192 description: i.description.clone(),
193 path: i.path(),
194 icon: crate::icons::svg(i.icon.as_deref()),
195 active: active_code == Some(i.code.as_str()),
196 })
197 .collect(),
198 }
199 })
200 .collect();
201 groups.sort_by(|a, b| {
202 a.min_order
203 .cmp(&b.min_order)
204 .then_with(|| a.name.cmp(&b.name))
205 });
206 groups
207}
208
209fn build(
210 item: &SettingsItem,
211 error: Option<String>,
212 stored: &Value,
213 shell: &crate::Shell,
214) -> SettingsFormTemplate {
215 let fields = item
216 .fields
217 .iter()
218 .map(|f| {
219 let current = stored.get(&f.name);
220 FieldView {
221 name: f.name.clone(),
222 label: f.label.clone(),
223 help: f.help.clone(),
224 textarea: matches!(f.widget, SettingsWidget::Textarea),
225 switch: matches!(f.widget, SettingsWidget::Switch),
226 checked: current.and_then(Value::as_bool).unwrap_or(false),
227 value: match current {
228 Some(Value::String(s)) => s.clone(),
229 Some(Value::Null) | None => String::new(),
230 Some(other) => other.to_string(),
231 },
232 }
233 })
234 .collect();
235 SettingsFormTemplate {
236 shell: shell.clone(),
237 title: item.label.clone(),
238 description: item.description.clone(),
239 action: item.path(),
240 error,
241 fields,
242 }
243}
244
245#[derive(Clone)]
247pub(crate) struct CategoryView {
248 pub(crate) name: String,
249 min_order: i32,
250 pub(crate) items: Vec<ItemView>,
251}
252
253#[derive(Clone)]
255pub(crate) struct ItemView {
256 pub(crate) label: String,
257 pub(crate) description: String,
258 pub(crate) path: String,
259 pub(crate) icon: &'static str,
261 pub(crate) active: bool,
263}
264
265#[derive(Template)]
266#[template(path = "settings_index.html")]
267struct SettingsIndexTemplate {
268 shell: crate::Shell,
269}
270
271struct FieldView {
272 name: String,
273 label: String,
274 help: Option<String>,
275 value: String,
276 textarea: bool,
277 switch: bool,
278 checked: bool,
279}
280
281#[derive(Template)]
282#[template(path = "settings_form.html")]
283struct SettingsFormTemplate {
284 shell: crate::Shell,
285 title: String,
286 description: String,
287 action: String,
288 error: Option<String>,
289 fields: Vec<FieldView>,
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use laterite_core::Db;
296
297 fn item() -> SettingsItem {
298 SettingsItem {
299 code: "test.log".to_string(),
300 label: "Log Settings".to_string(),
301 description: "What the log records.".to_string(),
302 category: "Logs".to_string(),
303 order: 10,
304 icon: None,
305 permission: None,
306 link: None,
307 fields: vec![
308 SettingsField::switch("log_events", "Log events"),
309 SettingsField::switch("log_requests", "Log requests"),
310 SettingsField::text("retention_days", "Retention (days)"),
311 ],
312 }
313 }
314
315 fn state(db: Db) -> AdminState {
316 AdminState::new(
317 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
318 db,
319 )
320 }
321
322 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
325 laterite_core::testing::connect_test(&[laterite_settings::migrations()]).await
326 }
327
328 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
329 pairs
330 .iter()
331 .map(|(k, v)| (k.to_string(), v.to_string()))
332 .collect()
333 }
334
335 #[test]
336 fn group_orders_categories_then_items() {
337 let items = vec![
338 SettingsItem {
339 code: "b".into(),
340 label: "Beta".into(),
341 description: String::new(),
342 category: "System".into(),
343 order: 20,
344 icon: None,
345 permission: None,
346 link: None,
347 fields: vec![],
348 },
349 SettingsItem {
350 code: "a".into(),
351 label: "Alpha".into(),
352 description: String::new(),
353 category: "System".into(),
354 order: 10,
355 icon: None,
356 permission: None,
357 link: None,
358 fields: vec![],
359 },
360 SettingsItem {
361 code: "l".into(),
362 label: "Logs".into(),
363 description: String::new(),
364 category: "Logs".into(),
365 order: 5,
366 icon: None,
367 permission: None,
368 link: None,
369 fields: vec![],
370 },
371 ];
372 let groups = sidebar_groups(&items, None);
373 assert_eq!(groups[0].name, "Logs");
375 assert_eq!(groups[1].name, "System");
376 assert_eq!(groups[1].items[0].label, "Alpha");
378 assert_eq!(groups[1].items[1].label, "Beta");
379 assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
381 }
382
383 #[test]
384 fn group_marks_only_the_active_item() {
385 let items = vec![item()];
386 let groups = sidebar_groups(&items, Some("test.log"));
387 let active: Vec<&str> = groups
388 .iter()
389 .flat_map(|g| &g.items)
390 .filter(|i| i.active)
391 .map(|i| i.label.as_str())
392 .collect();
393 assert_eq!(active, ["Log Settings"]);
394 }
395
396 #[test]
397 fn settings_model_item_path_is_its_form() {
398 assert_eq!(item().path(), "/admin/settings/test.log");
399 }
400
401 #[test]
402 fn link_item_path_follows_the_link() {
403 let admins = crate::builtin_settings()
404 .into_iter()
405 .find(|i| i.code == "backend.administrators")
406 .unwrap();
407 assert_eq!(admins.category, "Users");
408 assert!(admins.link.is_some());
409 assert!(admins.fields.is_empty());
410 assert_eq!(admins.path(), "/admin/users");
412 assert!(crate::builtin_settings()
413 .iter()
414 .any(|i| i.code == "backend.roles"));
415 }
416
417 #[tokio::test]
418 async fn update_persists_typed_values() {
419 let (db, _guard) = test_db().await;
420 let st = state(db.clone());
421
422 let it = item();
423 let resp = update(
424 &st,
425 &it,
426 data(&[("log_events", "on"), ("retention_days", "30")]),
428 crate::Shell::test(),
429 )
430 .await;
431 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
432
433 let stored = laterite_settings::get(&db, "test.log")
434 .await
435 .unwrap()
436 .unwrap();
437 assert_eq!(stored["log_events"], serde_json::json!(true));
438 assert_eq!(stored["log_requests"], serde_json::json!(false));
439 assert_eq!(stored["retention_days"], serde_json::json!("30"));
440 }
441
442 #[test]
443 fn index_renders() {
444 let resp = index(crate::Shell::test());
445 assert_eq!(resp.status(), axum::http::StatusCode::OK);
446 }
447
448 #[tokio::test]
449 async fn edit_form_renders_for_unset_item() {
450 let (db, _guard) = test_db().await;
451 let st = state(db);
452 let it = item();
454 let resp = edit_form(&st, &it, crate::Shell::test()).await;
455 assert_eq!(resp.status(), axum::http::StatusCode::OK);
456 }
457}