pub mod brand;
pub mod migrations;
pub mod store;
pub use brand::BrandSetting;
pub use migrations::{migrations, MODULE_ID};
pub use store::{
get, get_for, load, load_for, save, save_for, set, set_for, PreferencesError, PreferencesModel,
SettingsError, SettingsModel,
};
pub struct SettingsModule;
impl laterite_core::Module for SettingsModule {
fn id(&self) -> laterite_core::ModuleId {
laterite_core::ModuleId::new(MODULE_ID)
}
fn migrations(&self) -> laterite_core::MigrationSet {
migrations()
}
}
use std::collections::HashMap;
use askama::Template;
use axum::response::{IntoResponse, Redirect, Response};
use laterite_core::validation::Mode;
use laterite_core::{t, Text, Translator};
use serde::Serialize;
use serde_json::{Map, Value};
use crate::{render, render_error, AdminState};
pub use crate::form::FormField;
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct SettingsItem {
pub code: String,
pub label: Text,
pub description: Text,
pub category: Text,
pub hint: Option<Text>,
pub confirm_leave: bool,
pub order: i32,
pub icon: Option<String>,
pub permission: Option<String>,
pub link: Option<String>,
pub fields: Vec<FormField>,
pub(crate) route: Option<String>,
pub per_operator: bool,
}
pub(crate) const MAX_DESCRIPTION: usize = 72;
impl SettingsItem {
pub fn new(code: impl Into<String>, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
Self {
code: code.into(),
label: label.into(),
description: Text::from(""),
category: Text::from(""),
hint: None,
confirm_leave: true,
order: 0,
icon: None,
permission: None,
link: None,
fields,
route: None,
per_operator: false,
}
}
pub fn per_operator(mut self) -> Self {
self.per_operator = true;
self
}
pub fn description(mut self, text: impl Into<Text>) -> Self {
let text = text.into();
debug_assert!(
text.source().chars().count() <= MAX_DESCRIPTION,
"settings description is {} characters, over the {MAX_DESCRIPTION} that \
fit two sidebar lines; shorten it or move the detail to `hint`: {:?}",
text.source().chars().count(),
text.source(),
);
self.description = text;
self
}
pub fn hint(mut self, text: impl Into<Text>) -> Self {
self.hint = Some(text.into());
self
}
pub fn confirm_leave(mut self, ask: bool) -> Self {
self.confirm_leave = ask;
self
}
pub fn category(mut self, text: impl Into<Text>) -> Self {
self.category = text.into();
self
}
pub fn order(mut self, order: i32) -> Self {
self.order = order;
self
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn permission(mut self, permission: impl Into<String>) -> Self {
self.permission = Some(permission.into());
self
}
pub fn link(mut self, link: impl Into<String>) -> Self {
self.link = Some(link.into());
self
}
pub(crate) fn mount_at(&mut self, route: impl Into<String>) {
self.route = Some(route.into());
}
pub(crate) fn at(mut self, route: impl Into<String>) -> Self {
self.route = Some(route.into());
self
}
pub(crate) fn route(&self) -> String {
match &self.route {
Some(route) => route.clone(),
None if self.per_operator => format!("/preferences/{}", self.code.replace('.', "/")),
None => format!("/settings/{}", self.code.replace('.', "/")),
}
}
pub fn path(&self, admin_path: &str) -> String {
match &self.link {
Some(link) => format!("{admin_path}{link}"),
None => format!("{admin_path}{}", self.route()),
}
}
}
pub(crate) fn index(shell: crate::Shell) -> Response {
render(SettingsIndexTemplate { shell })
}
pub(crate) async fn edit_form(
state: &AdminState,
item: &SettingsItem,
shell: crate::Shell,
user: &laterite_auth::AuthenticatedUser,
) -> Response {
let mut stored = match stored_value(state, item, user).await {
Some(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
None => return render_error(),
};
prefill_from_config(item, &mut stored, &state.app_name);
render(build(
item,
&state.field_types,
state.overrides.as_ref(),
None,
&stored,
&shell,
))
}
pub(crate) async fn update(
state: &AdminState,
item: &SettingsItem,
data: HashMap<String, String>,
shell: crate::Shell,
session: &crate::session::SessionHandle,
user: &laterite_auth::AuthenticatedUser,
) -> Response {
let value = match collect(item, &state.field_types, &data) {
Ok(value) => value,
Err(message) => {
let stored = stored_value(state, item, user)
.await
.flatten()
.unwrap_or(Value::Null);
return render(build(
item,
&state.field_types,
state.overrides.as_ref(),
Some(message),
&stored,
&shell,
));
}
};
let written = if item.per_operator {
store::set_for(&state.db, user.user.id, &item.code, &value)
.await
.is_ok()
} else {
store::set(&state.db, &item.code, &value).await.is_ok()
};
match written {
true => {
if item.code == brand::BrandSetting::CODE {
state.invalidate_brand();
}
if !item.per_operator {
crate::audit::record(
state,
user,
"backend.settings.update",
Some("settings"),
Some(item.code.as_str()),
None,
None,
)
.await;
}
session.push_flash(
crate::session::FlashLevel::Success,
if item.per_operator {
t!("Preferences saved.")
} else {
t!("Settings saved.")
},
);
Redirect::to(&item.path(&state.admin_path)).into_response()
}
false => render(build(
item,
&state.field_types,
state.overrides.as_ref(),
Some(t!("Could not save. Please try again.")),
&value,
&shell,
)),
}
}
async fn stored_value(
state: &AdminState,
item: &SettingsItem,
user: &laterite_auth::AuthenticatedUser,
) -> Option<Option<Value>> {
if item.per_operator {
store::get_for(&state.db, user.user.id, &item.code)
.await
.ok()
} else {
store::get(&state.db, &item.code).await.ok()
}
}
fn collect(
item: &SettingsItem,
types: &crate::field::FieldRegistry,
data: &HashMap<String, String>,
) -> Result<Value, Text> {
let mut object = Map::new();
for field in &item.fields {
let Some(field_type) = types.get(&field.field_type) else {
return Err(t!("This screen uses a field type that is not registered."));
};
let opts = field_type
.resolve_options(&field.options, types)
.map_err(|_| t!("This screen has a malformed field."))?;
let submitted = crate::field::SubmittedField::new(&field.name, data);
let value = match field_type.to_attr(&submitted, &opts, Mode::Update) {
Ok(Some(value)) => attr_to_json(value),
Ok(None) => Value::String(String::new()),
Err(_) => return Err(t!("Check the values and try again.")),
};
object.insert(field.name.clone(), value);
}
Ok(Value::Object(object))
}
fn attr_to_json(value: laterite_core::AttrValue) -> Value {
use laterite_core::AttrValue;
match value {
AttrValue::Null => Value::String(String::new()),
AttrValue::Bool(b) => Value::Bool(b),
AttrValue::Int(i) => Value::from(i),
AttrValue::Float(f) => Value::from(f),
AttrValue::Json(j) => j,
other => Value::String(other.to_text().unwrap_or_default()),
}
}
pub(crate) fn sidebar_groups(
items: &[SettingsItem],
admin_path: &str,
active_code: Option<&str>,
tr: &Translator,
icons: crate::icons::Icons<'_>,
) -> Vec<CategoryView> {
let mut by_category: HashMap<String, Vec<&SettingsItem>> = HashMap::new();
for item in items {
by_category
.entry(tr.t(&item.category))
.or_default()
.push(item);
}
let mut groups: Vec<CategoryView> = by_category
.into_iter()
.map(|(category, mut items)| {
items.sort_by(|a, b| {
a.order
.cmp(&b.order)
.then_with(|| a.label.source().cmp(b.label.source()))
});
CategoryView {
min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
name: category,
items: items
.iter()
.map(|i| ItemView {
label: tr.t(&i.label),
description: tr.t(&i.description),
path: i.path(admin_path),
icon: icons.render(i.icon.as_deref()),
active: active_code == Some(i.code.as_str()),
})
.collect(),
}
})
.collect();
groups.sort_by(|a, b| {
a.min_order
.cmp(&b.min_order)
.then_with(|| a.name.cmp(&b.name))
});
groups
}
fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
if item.code != brand::BrandSetting::CODE {
return;
}
if let Value::Object(map) = stored {
let blank = map
.get("app_name")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.is_empty();
if blank {
map.insert("app_name".to_string(), Value::String(app_name.to_string()));
}
}
}
fn build(
item: &SettingsItem,
types: &crate::field::FieldRegistry,
overrides: &dyn crate::field::OverrideResolver,
error: Option<Text>,
stored: &Value,
shell: &crate::Shell,
) -> SettingsFormTemplate {
let fields = item
.fields
.iter()
.map(|f| {
let label = shell.tt(&f.label);
let control = match types.get(&f.field_type) {
Some(ft) => {
let opts = ft
.resolve_options(&f.options, types)
.unwrap_or_else(|_| crate::field::ResolvedOptions::none());
let value = match stored.get(&f.name) {
Some(Value::Array(rows)) => {
crate::field::FieldValue::Json(Value::Array(rows.clone()))
}
Some(Value::String(text)) => {
crate::field::FieldValue::Text(ft.to_control(Some(text), &opts))
}
Some(Value::Null) | None => {
crate::field::FieldValue::Text(ft.to_control(None, &opts))
}
Some(other) => crate::field::FieldValue::Text(
ft.to_control(Some(&other.to_string()), &opts),
),
};
let cx = crate::field::FieldCx {
name: &f.name,
id: &f.name,
label: &label,
value: &value,
required: false,
max_length: crate::form::max_length(&f.rules),
opts: &opts,
base: &shell.base,
i18n: shell.i18n(),
};
let scope = crate::field::OverrideScope {
surface: crate::field::Surface::Field,
view_key: &f.field_type,
resource: Some(&item.code),
field: Some(&f.name),
};
crate::field::render_field(ft.as_ref(), overrides, &scope, &cx).into_string()
}
None => String::new(),
};
FieldView {
label,
help: f.help.as_ref().map(|h| shell.tt(h)),
control,
layout: crate::form::layout_classes(f.span, f.break_row),
attrs: crate::form::behaviour_attrs(f),
}
})
.collect();
SettingsFormTemplate {
title: shell.tt(&item.label),
description: shell.tt(&item.description),
hint: item.hint.as_ref().map(|h| shell.tt(h)),
action: item.path(&shell.base),
error: error.map(|e| shell.tt(&e)),
fields,
leave_off: !item.confirm_leave,
shell: shell.clone(),
}
}
#[derive(Clone)]
pub(crate) struct CategoryView {
pub(crate) name: String,
min_order: i32,
pub(crate) items: Vec<ItemView>,
}
impl CategoryView {
pub(crate) fn new(name: String, items: Vec<ItemView>) -> Self {
Self {
name,
min_order: 0,
items,
}
}
}
#[derive(Clone)]
pub(crate) struct ItemView {
pub(crate) label: String,
pub(crate) description: String,
pub(crate) path: String,
pub(crate) icon: String,
pub(crate) active: bool,
}
#[derive(Template)]
#[template(path = "settings_index.html")]
struct SettingsIndexTemplate {
shell: crate::Shell,
}
struct FieldView {
label: String,
help: Option<String>,
control: String,
layout: String,
attrs: String,
}
#[derive(Template)]
#[template(path = "settings_form.html")]
struct SettingsFormTemplate {
shell: crate::Shell,
title: String,
description: String,
hint: Option<String>,
action: String,
error: Option<String>,
fields: Vec<FieldView>,
leave_off: bool,
}
#[cfg(test)]
mod field_system_tests {
use super::*;
#[test]
#[should_panic(expected = "over the 72")]
fn a_lecture_of_a_description_is_refused() {
SettingsItem::new("acme.test", "Test", Vec::new()).description(
"What search engines and other crawlers may visit. The admin panel \
is always excluded, whatever you put here.",
);
}
#[test]
fn a_label_sized_description_passes_and_a_hint_carries_the_rest() {
let item = SettingsItem::new("acme.test", "Test", Vec::new())
.description("Which crawlers may visit, and what they may not.")
.hint(
"The admin panel is always excluded, whatever you put here, and a \
crawler obeys only the block that names it.",
);
assert!(item.description.source().chars().count() <= MAX_DESCRIPTION);
assert!(item.hint.is_some());
}
fn item(fields: Vec<FormField>) -> SettingsItem {
SettingsItem {
code: "acme.test".to_string(),
label: Text::new(""),
description: Text::new(""),
category: Text::new(""),
hint: None,
confirm_leave: true,
order: 0,
icon: None,
permission: None,
link: None,
fields,
route: None,
per_operator: false,
}
}
fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn settings_store_what_the_field_type_says() {
let cfg = item(vec![
FormField::text("name", "Name"),
FormField::switch("enabled", "Enabled"),
FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
FormField::date("expires", "Expires"),
]);
let stored = collect(
&cfg,
&crate::field::builtin_registry(),
&submitted(&[
("name", "Acme"),
("enabled", "on"),
("freq", "daily"),
("expires", "2026-12-31"),
]),
)
.unwrap();
assert_eq!(stored["name"], "Acme");
assert_eq!(stored["enabled"], serde_json::json!(true));
assert_eq!(stored["freq"], "daily");
assert_eq!(stored["expires"], "2026-12-31");
}
#[test]
fn an_unticked_switch_clears() {
let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
assert_eq!(stored["enabled"], serde_json::json!(false));
}
#[test]
fn a_repeater_works_in_settings() {
let cfg = item(vec![FormField::repeater(
"rules",
"Rules",
vec![
FormField::text("path", "Path"),
FormField::switch("allow", "Allow"),
],
)]);
let stored = collect(
&cfg,
&crate::field::builtin_registry(),
&submitted(&[
("rules[0][path]", "/private"),
("rules[1][path]", "/public"),
("rules[1][allow]", "on"),
]),
)
.unwrap();
let rows = stored["rules"].as_array().unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["path"], "/private");
assert_eq!(rows[0]["allow"], serde_json::json!(false));
assert_eq!(rows[1]["allow"], serde_json::json!(true));
}
#[test]
fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
}
#[test]
fn the_form_renders_each_control_through_its_type() {
let cfg = item(vec![
FormField::switch("enabled", "Enabled"),
FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
]);
let html = build(
&cfg,
&crate::field::builtin_registry(),
&crate::field::NoOverrides,
None,
&serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
&crate::Shell::test(),
)
.render()
.unwrap();
assert!(html.contains(r#"type="checkbox""#));
assert!(html.contains(r#"name="rules[0][path]""#));
assert!(html.contains(r#"value="/a""#));
assert!(html.contains("lat-repeater__blank"));
}
}
#[cfg(test)]
mod tests {
use super::*;
use laterite_core::Db;
#[test]
fn brand_form_prefills_app_name_from_config_when_unset() {
let brand = brand::settings_item();
let mut unset = Value::Object(Map::new());
prefill_from_config(&brand, &mut unset, "Configured Name");
assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
let mut set = serde_json::json!({ "app_name": "Acme" });
prefill_from_config(&brand, &mut set, "Configured Name");
assert_eq!(set["app_name"], serde_json::json!("Acme"));
let mut other = Value::Object(Map::new());
prefill_from_config(&item(), &mut other, "Configured Name");
assert!(other.get("app_name").is_none());
}
fn item() -> SettingsItem {
SettingsItem {
code: "test.log".to_string(),
label: "Log Settings".into(),
description: "What the log records.".into(),
category: "Logs".into(),
hint: None,
confirm_leave: true,
order: 10,
icon: None,
permission: None,
link: None,
fields: vec![
FormField::switch("log_events", "Log events"),
FormField::switch("log_requests", "Log requests"),
FormField::text("retention_days", "Retention (days)"),
],
route: None,
per_operator: false,
}
}
fn state(db: Db) -> AdminState {
AdminState::new(
laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
db,
)
}
async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
laterite_core::testing::connect_test(&[migrations()]).await
}
fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn group_orders_categories_then_items() {
let items = vec![
SettingsItem {
code: "b".into(),
label: "Beta".into(),
description: String::new().into(),
category: "System".into(),
hint: None,
confirm_leave: true,
order: 20,
icon: None,
permission: None,
link: None,
fields: vec![],
route: None,
per_operator: false,
},
SettingsItem {
code: "a".into(),
label: "Alpha".into(),
description: String::new().into(),
category: "System".into(),
hint: None,
confirm_leave: true,
order: 10,
icon: None,
permission: None,
link: None,
fields: vec![],
route: None,
per_operator: false,
},
SettingsItem {
code: "l".into(),
label: "Logs".into(),
description: String::new().into(),
category: "Logs".into(),
hint: None,
confirm_leave: true,
order: 5,
icon: None,
permission: None,
link: None,
fields: vec![],
route: None,
per_operator: false,
},
];
let groups = sidebar_groups(
&items,
"/admin",
None,
&Translator::new("en"),
crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
);
assert_eq!(groups[0].name, "Logs");
assert_eq!(groups[1].name, "System");
assert_eq!(groups[1].items[0].label, "Alpha");
assert_eq!(groups[1].items[1].label, "Beta");
assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
}
#[test]
fn group_marks_only_the_active_item() {
let items = vec![item()];
let groups = sidebar_groups(
&items,
"/admin",
Some("test.log"),
&Translator::new("en"),
crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
);
let active: Vec<&str> = groups
.iter()
.flat_map(|g| &g.items)
.filter(|i| i.active)
.map(|i| i.label.as_str())
.collect();
assert_eq!(active, ["Log Settings"]);
}
#[test]
fn settings_model_item_path_is_its_form() {
assert_eq!(item().path("/admin"), "/admin/settings/test/log");
assert_eq!(item().path("/manage"), "/manage/settings/test/log");
let mut resolved = item();
resolved.mount_at("/acme/logging/log");
assert_eq!(resolved.path("/admin"), "/admin/acme/logging/log");
}
#[test]
fn link_item_path_follows_the_link() {
let admins = crate::builtin_settings()
.into_iter()
.find(|i| i.code == "backend.administrators")
.unwrap();
assert_eq!(admins.category.source(), "Users");
assert!(admins.link.is_some());
assert!(admins.fields.is_empty());
assert_eq!(admins.path("/admin"), "/admin/users");
assert!(crate::builtin_settings()
.iter()
.any(|i| i.code == "backend.roles"));
}
#[tokio::test]
async fn update_persists_typed_values() {
let (db, _guard) = test_db().await;
let st = state(db.clone());
let it = item();
let resp = update(
&st,
&it,
data(&[("log_events", "on"), ("retention_days", "30")]),
crate::Shell::test(),
&crate::session::SessionHandle::from_blob(None),
&crate::audit::test_actor(),
)
.await;
assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
let stored = store::get(&db, "test.log").await.unwrap().unwrap();
assert_eq!(stored["log_events"], serde_json::json!(true));
assert_eq!(stored["log_requests"], serde_json::json!(false));
assert_eq!(stored["retention_days"], serde_json::json!("30"));
}
#[test]
fn index_renders() {
let resp = index(crate::Shell::test());
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn edit_form_renders_for_unset_item() {
let (db, _guard) = test_db().await;
let st = state(db);
let it = item();
let resp = edit_form(&st, &it, crate::Shell::test(), &crate::audit::test_actor()).await;
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
}