Skip to main content

laterite_admin/settings/
mod.rs

1//! Descriptor-driven settings screens, and the settings store behind them.
2//!
3//! A module registers a [`SettingsItem`] for each settings model it wants an
4//! operator to edit: its storage `code` (the [`store::SettingsModel`] `CODE`), a
5//! `category` it groups under, and the fields to render. The framework mounts one
6//! index that lists every registered item grouped by category, and one generic
7//! form per item that reads and writes the model's JSON value through the
8//! [`store`]. No per-model controller is needed, exactly as a single settings
9//! controller serves every settings model.
10//!
11//! Values are stored as one JSON object per code. Field names are JSON keys, not
12//! SQL identifiers, and the value is written through a parameterized upsert, so
13//! nothing here builds SQL from user input.
14
15pub mod brand;
16pub mod mail;
17pub mod mail_brand;
18pub mod migrations;
19pub mod store;
20
21pub use brand::BrandSetting;
22pub use migrations::{migrations, MODULE_ID};
23pub use store::{
24    get, get_for, load, load_for, save, save_for, set, set_for, PreferencesError, PreferencesModel,
25    SettingsError, SettingsModel,
26};
27
28/// The `laterite.settings` module: the framework's settings store table.
29pub struct SettingsModule;
30
31impl laterite_core::Module for SettingsModule {
32    fn id(&self) -> laterite_core::ModuleId {
33        laterite_core::ModuleId::new(MODULE_ID)
34    }
35    fn migrations(&self) -> laterite_core::MigrationSet {
36        migrations()
37    }
38    fn register(&self, registry: &mut laterite_core::Registry) {
39        registry.add(laterite_core::cache::CacheNamespace::new("values"));
40    }
41}
42
43/// The cache namespace every cached reading of a setting depends on; a
44/// write through [`Settings`] bumps it.
45pub const NAMESPACE: &str = "laterite.settings::values";
46
47/// The settings store writing through the cache: a save moves
48/// [`NAMESPACE`] on, so every value built from a setting is read afresh in
49/// every process. [`RouteCtx::settings`](crate::routes::RouteCtx::settings)
50/// hands one out. Reads are the free functions of [`store`].
51#[derive(Clone)]
52pub struct Settings {
53    db: laterite_core::Db,
54    cache: laterite_core::Cache,
55}
56
57impl Settings {
58    pub fn new(db: laterite_core::Db, cache: laterite_core::Cache) -> Self {
59        cache.allow(NAMESPACE);
60        Self { db, cache }
61    }
62
63    /// Saves a typed model under its code.
64    pub async fn save<T: SettingsModel>(&self, model: &T) -> Result<(), SettingsError> {
65        store::save(&self.db, model).await?;
66        let _ = self.cache.bump(NAMESPACE).await;
67        Ok(())
68    }
69
70    /// Upserts the raw JSON value for a code.
71    pub async fn set(&self, code: &str, value: &serde_json::Value) -> Result<(), SettingsError> {
72        store::set(&self.db, code, value).await?;
73        let _ = self.cache.bump(NAMESPACE).await;
74        Ok(())
75    }
76}
77
78#[cfg(test)]
79mod namespace_tests {
80    use super::*;
81    use laterite_core::{Module, ModuleId};
82
83    #[test]
84    fn the_registered_namespace_is_the_constant() {
85        let owner = ModuleId::new(MODULE_ID);
86        assert_eq!(SettingsModule.id(), owner);
87        assert_eq!(
88            laterite_core::cache::CacheNamespace::new("values").qualified(owner),
89            NAMESPACE
90        );
91    }
92}
93
94use crate::form::ValueFieldView;
95use std::collections::HashMap;
96
97use askama::Template;
98use axum::response::{IntoResponse, Redirect, Response};
99use laterite_core::{t, Text, Translator};
100use serde::Serialize;
101use serde_json::{Map, Value};
102
103use crate::{render, render_error, AdminState};
104
105/// One editable field of a settings model.
106///
107/// A settings screen renders [`FormField`](crate::form::FormField)s, the same
108/// descriptors a list or form screen uses: the module declares which fields its
109/// settings model has, and the framework renders and stores them through the
110/// field-type registry. There is no settings-specific field vocabulary.
111pub use crate::form::FormField;
112
113/// A settings model surfaced in the admin: a storage `code`, a `category` and
114/// `order` that place it in the index, and the fields to edit.
115#[derive(Debug, Clone, Serialize)]
116#[non_exhaustive]
117pub struct SettingsItem {
118    /// Storage key. Matches the model's `SettingsModel::CODE`.
119    ///
120    /// This is a storage key and never a URL. It is dotted, like the module
121    /// identity it derives from, and a dotted identifier in a path is how these
122    /// screens spent months at `/settings/rainmill.discovery.robots`. Where the
123    /// screen mounts is [`SettingsItem::route`], resolved at boot through the
124    /// module's namespace like every other contributed screen.
125    pub code: String,
126    /// The item's label, description, and category heading, localized at render.
127    pub label: Text,
128    pub description: Text,
129    /// Group heading in the index.
130    pub category: Text,
131    /// A fuller explanation, shown as a note at the top of this screen and
132    /// nowhere else. Where [`SettingsItem::description`] must stay short enough
133    /// to read in a sidebar, this has room to say why the screen exists.
134    pub hint: Option<Text>,
135    /// Ask before leaving this screen with changes unsaved. Default `true`.
136    pub confirm_leave: bool,
137    /// The band the item belongs to; see [`laterite_core::arrange::slot`].
138    /// The application band unless the item says otherwise.
139    pub slot: i32,
140    /// Weight within the band (lower sorts first).
141    pub order: i32,
142    /// Icon name shown beside the item in the context sidebar (a Lucide name
143    /// such as `users` or `shield`). `None` falls back to a generic glyph.
144    pub icon: Option<String>,
145    /// Permission required to edit, enforced by middleware. `None` means any
146    /// authenticated operator.
147    pub permission: Option<String>,
148    /// When set, the item links to this route (e.g. a resource list) instead of
149    /// its settings form. Used to place list/form screens (like Administrators)
150    /// in the settings menu rather than the main menu.
151    pub link: Option<String>,
152    pub fields: Vec<FormField>,
153    /// Where this screen mounts, relative to the admin root, resolved at boot
154    /// from the contributing module's namespace. `None` until then.
155    pub(crate) route: Option<String>,
156    /// Stored once per operator, among their preferences, and edited from
157    /// the Preferences page rather than Settings. See [`store::PreferencesModel`].
158    pub per_operator: bool,
159    /// Buttons beside Save that post to their own route under the admin
160    /// root: a Send test, a Clear.
161    pub actions: Vec<SettingsAction>,
162}
163
164/// A secondary action on a settings screen.
165#[derive(Debug, Clone, Serialize)]
166pub struct SettingsAction {
167    pub label: Text,
168    /// The path the button posts to, under the admin root (`/settings/mail/test`).
169    pub path: String,
170}
171
172/// The longest a settings description may be: two lines of the context sidebar,
173/// which renders at 211 pixels and 12px type, so roughly 38 characters a line.
174/// The reference system's own descriptions run 27 to 90 characters with a median
175/// of 53, so this sits inside the range a mature panel actually uses.
176pub(crate) const MAX_DESCRIPTION: usize = 72;
177
178impl SettingsItem {
179    /// A settings screen for `code` (the model's `SettingsModel::CODE`), titled
180    /// `label`, editing `fields`.
181    ///
182    /// Everything else takes a default and is opted into through a builder
183    /// method, so a field added later costs existing callers nothing.
184    pub fn new(code: impl Into<String>, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
185        Self {
186            code: code.into(),
187            label: label.into(),
188            description: Text::from(""),
189            category: Text::from(""),
190            hint: None,
191            confirm_leave: true,
192            slot: laterite_core::arrange::slot::APPLICATION,
193            order: 0,
194            icon: None,
195            permission: None,
196            link: None,
197            fields,
198            route: None,
199            per_operator: false,
200            actions: Vec::new(),
201        }
202    }
203
204    /// A button beside Save posting to `path` under the admin root.
205    pub fn action(mut self, label: impl Into<Text>, path: impl Into<String>) -> Self {
206        self.actions.push(SettingsAction {
207            label: label.into(),
208            path: path.into(),
209        });
210        self
211    }
212
213    /// Keeps a value per operator, edited from their Preferences page. The
214    /// permission still applies; the category is not used.
215    pub fn per_operator(mut self) -> Self {
216        self.per_operator = true;
217        self
218    }
219
220    /// The sentence under the item's name in the index and sidebar.
221    ///
222    /// Keep it to two lines in the sidebar, which is 72 characters at the
223    /// panel's width. A description longer than that stops being a label and
224    /// starts being a lecture in a column 211 pixels wide; put the explanation
225    /// in [`SettingsItem::hint`], where the reader has already chosen to look.
226    pub fn description(mut self, text: impl Into<Text>) -> Self {
227        let text = text.into();
228        debug_assert!(
229            text.source().chars().count() <= MAX_DESCRIPTION,
230            "settings description is {} characters, over the {MAX_DESCRIPTION} that \
231             fit two sidebar lines; shorten it or move the detail to `hint`: {:?}",
232            text.source().chars().count(),
233            text.source(),
234        );
235        self.description = text;
236        self
237    }
238
239    /// A fuller explanation for the top of this screen. Unlike the description,
240    /// it has no length limit: the reader has opened the screen.
241    pub fn hint(mut self, text: impl Into<Text>) -> Self {
242        self.hint = Some(text.into());
243        self
244    }
245
246    /// Whether leaving this screen with changes unsaved asks first. Default
247    /// `true`.
248    pub fn confirm_leave(mut self, ask: bool) -> Self {
249        self.confirm_leave = ask;
250        self
251    }
252
253    /// The group heading this item sorts under.
254    pub fn category(mut self, text: impl Into<Text>) -> Self {
255        self.category = text.into();
256        self
257    }
258
259    /// Weight within the band; lower sorts first.
260    pub fn order(mut self, order: i32) -> Self {
261        self.order = order;
262        self
263    }
264
265    /// The band the item belongs to; see [`laterite_core::arrange::slot`].
266    pub fn slot(mut self, slot: i32) -> Self {
267        self.slot = slot;
268        self
269    }
270
271    /// A Lucide icon name shown beside the item.
272    pub fn icon(mut self, icon: impl Into<String>) -> Self {
273        self.icon = Some(icon.into());
274        self
275    }
276
277    /// The permission required to edit, enforced by middleware.
278    pub fn permission(mut self, permission: impl Into<String>) -> Self {
279        self.permission = Some(permission.into());
280        self
281    }
282
283    /// Point the item at an existing route instead of its own settings form, so
284    /// a list screen can sit in the settings menu.
285    pub fn link(mut self, link: impl Into<String>) -> Self {
286        self.link = Some(link.into());
287        self
288    }
289
290    /// Records where this screen mounts. Called once at boot, after the
291    /// contributing module's namespace has been resolved.
292    pub(crate) fn mount_at(&mut self, route: impl Into<String>) {
293        self.route = Some(route.into());
294    }
295
296    /// Mounts this screen at a fixed path rather than a module namespace.
297    ///
298    /// For the panel's own settings, which belong to no vendor: they configure
299    /// the admin itself, so they sit under `/settings` with a plain name
300    /// (`/settings/branding`) rather than carrying `laterite` through a URL an
301    /// operator reads. A module's settings live in the module's namespace.
302    pub(crate) fn at(mut self, route: impl Into<String>) -> Self {
303        self.route = Some(route.into());
304        self
305    }
306
307    /// The item's own screen, relative to the admin root, ignoring any `link`.
308    ///
309    /// Before boot resolves it, this falls back to a path under `/settings`
310    /// built from the code's segments rather than the dotted code itself, so
311    /// even the fallback is a URL rather than an identifier. See
312    /// [`SettingsItem::at`] for why the panel's own settings pin theirs.
313    pub(crate) fn route(&self) -> String {
314        match &self.route {
315            Some(route) => route.clone(),
316            None if self.per_operator => format!("/preferences/{}", self.code.replace('.', "/")),
317            None => format!("/settings/{}", self.code.replace('.', "/")),
318        }
319    }
320
321    /// Where this item leads, resolved under the admin mount (`admin_path`): its
322    /// `link` target if set, else its own settings screen. Both are authored
323    /// relative to the admin root, so this prepends the mount.
324    pub fn path(&self, admin_path: &str) -> String {
325        match &self.link {
326            Some(link) => format!("{admin_path}{link}"),
327            None => format!("{admin_path}{}", self.route()),
328        }
329    }
330}
331
332/// Renders the settings index: a prompt to pick a section. The context sidebar
333/// itself is resolved by the auth guard and rendered by the shell.
334pub(crate) fn index(shell: crate::Shell) -> Response {
335    render(SettingsIndexTemplate { shell })
336}
337
338/// Renders the edit form for one item, populated from its stored value. The
339/// context sidebar (with this item active) comes from the shell.
340pub(crate) async fn edit_form(
341    state: &AdminState,
342    item: &SettingsItem,
343    shell: crate::Shell,
344    user: &laterite_auth::AuthenticatedUser,
345) -> Response {
346    let mut stored = match stored_value(state, item, user).await {
347        Some(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
348        None => return render_error(),
349    };
350    // Prefill unset fields from config so they show the current effective value
351    // rather than opening blank. Display only; nothing is written.
352    prefill_from_config(item, &mut stored, &state.app_name);
353    render(build(
354        item,
355        &state.field_types,
356        state.overrides.as_ref(),
357        None,
358        &stored,
359        &shell,
360    ))
361}
362
363/// Persists submitted values as the item's JSON object, then returns to the index.
364pub(crate) async fn update(
365    state: &AdminState,
366    item: &SettingsItem,
367    data: HashMap<String, String>,
368    shell: crate::Shell,
369    session: &crate::session::SessionHandle,
370    user: &laterite_auth::AuthenticatedUser,
371) -> Response {
372    // A field type that refuses its input refuses the save: the screen says so
373    // rather than storing a value every later read has to defend against.
374    let value = match collect(item, &state.field_types, &data) {
375        // A field that supplies no value (a sealed secret shown as its mask)
376        // keeps what was stored, as a record's column does.
377        Ok(mut value) => {
378            if let Some(Some(Value::Object(previous))) = stored_value(state, item, user).await {
379                if let Value::Object(map) = &mut value {
380                    for (key, old) in previous {
381                        map.entry(key).or_insert(old);
382                    }
383                }
384            }
385            value
386        }
387        Err(message) => {
388            let stored = stored_value(state, item, user)
389                .await
390                .flatten()
391                .unwrap_or(Value::Null);
392            return render(build(
393                item,
394                &state.field_types,
395                state.overrides.as_ref(),
396                Some(message),
397                &stored,
398                &shell,
399            ));
400        }
401    };
402    let written = if item.per_operator {
403        store::set_for(&state.db, user.user.id, &item.code, &value)
404            .await
405            .is_ok()
406    } else {
407        state.settings_store().set(&item.code, &value).await.is_ok()
408    };
409    match written {
410        true => {
411            // The mail brand reaches every layout at once.
412            if item.code == mail_brand::MailBrandSetting::CODE {
413                let brand: mail_brand::MailBrandSetting =
414                    serde_json::from_value(value.clone()).unwrap_or_default();
415                state.mail.templates().set_brand_css(&brand.css()).await;
416            }
417            // The mail transport follows its settings at once.
418            if item.code == mail::MailSetting::CODE {
419                let setting: mail::MailSetting =
420                    serde_json::from_value(value.clone()).unwrap_or_default();
421                let config = mail::effective(&state.mail_config, &setting, &state.crypt);
422                if let Err(e) = state.mail.reconfigure(&config) {
423                    session.push_flash(
424                        crate::session::FlashLevel::Error,
425                        t!(
426                            "Saved, but the transport could not be set up: {error}",
427                            error = e.to_string()
428                        ),
429                    );
430                }
431            }
432            // The stored value can hold secrets, so the audit records which
433            // settings model changed, not the new contents. An operator's own
434            // preference is theirs alone and goes unrecorded, like their timezone.
435            if !item.per_operator {
436                crate::audit::record(
437                    state,
438                    user,
439                    "backend.settings.update",
440                    Some("settings"),
441                    Some(item.code.as_str()),
442                    None,
443                    None,
444                )
445                .await;
446            }
447            session.push_flash(
448                crate::session::FlashLevel::Success,
449                if item.per_operator {
450                    t!("Preferences saved.")
451                } else {
452                    t!("Settings saved.")
453                },
454            );
455            // Back to the screen that was saved, not the index. Saving is not
456            // leaving: an operator adjusting one setting usually adjusts the
457            // next, and being thrown back to the menu loses their place.
458            Redirect::to(&item.path(&state.admin_path)).into_response()
459        }
460        false => render(build(
461            item,
462            &state.field_types,
463            state.overrides.as_ref(),
464            Some(t!("Could not save. Please try again.")),
465            &value,
466            &shell,
467        )),
468    }
469}
470
471/// The item's stored value: the deployment's, or this operator's own for a
472/// per-operator item. `None` when the read failed.
473async fn stored_value(
474    state: &AdminState,
475    item: &SettingsItem,
476    user: &laterite_auth::AuthenticatedUser,
477) -> Option<Option<Value>> {
478    if item.per_operator {
479        store::get_for(&state.db, user.user.id, &item.code)
480            .await
481            .ok()
482    } else {
483        store::get(&state.db, &item.code).await.ok()
484    }
485}
486
487/// Builds the JSON object to store from the submitted form data, typing each
488/// field through its field type.
489///
490/// The same `to_attr` a descriptor form writes through, so a switch stores a
491/// bool, a repeater stores an array of objects, and a module's own field type
492/// behaves identically here and on a form. A type that refuses its input refuses
493/// the save, naming the field.
494fn collect(
495    item: &SettingsItem,
496    types: &crate::field::FieldRegistry,
497    data: &HashMap<String, String>,
498) -> Result<Value, Text> {
499    crate::form::value_collect(&item.fields, types, data)
500}
501
502/// The settings index as a hierarchy: a deployment may rearrange it, and so
503/// may an operator for themselves.
504pub const HIERARCHY: &str = "backend.settings";
505
506pub(crate) fn hierarchy() -> laterite_core::arrange::Hierarchy {
507    laterite_core::arrange::Hierarchy::new(HIERARCHY).operator_adjustable()
508}
509
510struct Placed<'a> {
511    item: &'a SettingsItem,
512    position: laterite_core::arrange::Position,
513}
514
515impl laterite_core::arrange::Arranged for Placed<'_> {
516    fn key(&self) -> &str {
517        &self.item.code
518    }
519    fn position(&self) -> &laterite_core::arrange::Position {
520        &self.position
521    }
522    fn label(&self) -> &str {
523        self.item.label.source()
524    }
525}
526
527/// One boot log line for the settings index, as [`sidebar_groups`] will
528/// arrange it.
529pub(crate) fn log_arrangement(items: &[SettingsItem], order: Option<&[String]>) {
530    let placed: Vec<Placed<'_>> = items
531        .iter()
532        .map(|item| Placed {
533            item,
534            position: laterite_core::arrange::Position::new()
535                .slot(item.slot)
536                .order(item.order),
537        })
538        .collect();
539    let arranged = laterite_core::arrange::arrange(
540        &hierarchy(),
541        placed,
542        laterite_core::arrange::Overrides {
543            deployment: order,
544            operator: None,
545        },
546    )
547    .unwrap_or_default();
548    let keys: Vec<String> = arranged
549        .iter()
550        .map(|p| {
551            format!(
552                "{} ({})",
553                p.item.code,
554                laterite_core::arrange::slot::name(p.item.slot)
555            )
556        })
557        .collect();
558    tracing::info!(hierarchy = HIERARCHY, arrangement = %keys.join(" > "));
559}
560
561/// Builds the context-sidebar groups: the items arranged by the engine (slot,
562/// order, label, then the deployment's `[backend.order]` for
563/// `backend.settings`), grouped by category in order of first appearance,
564/// with `active_code` (if any) marked.
565pub(crate) fn sidebar_groups(
566    items: &[SettingsItem],
567    admin_path: &str,
568    active_code: Option<&str>,
569    tr: &Translator,
570    icons: crate::icons::Icons<'_>,
571    order: Option<&[String]>,
572    own_order: Option<&[String]>,
573) -> Vec<CategoryView> {
574    let placed: Vec<Placed<'_>> = items
575        .iter()
576        .map(|item| Placed {
577            item,
578            position: laterite_core::arrange::Position::new()
579                .slot(item.slot)
580                .order(item.order),
581        })
582        .collect();
583    let arranged = laterite_core::arrange::arrange(
584        &hierarchy(),
585        placed,
586        laterite_core::arrange::Overrides {
587            deployment: order,
588            operator: own_order,
589        },
590    )
591    .unwrap_or_default();
592    let mut groups: Vec<CategoryView> = Vec::new();
593    for placed in arranged {
594        let i = placed.item;
595        let category = tr.t(&i.category);
596        let view = ItemView {
597            key: i.code.clone(),
598            label: tr.t(&i.label),
599            description: tr.t(&i.description),
600            path: i.path(admin_path),
601            icon: icons.render(i.icon.as_deref()),
602            active: active_code == Some(i.code.as_str()),
603        };
604        match groups.iter_mut().find(|g| g.name == category) {
605            Some(group) => group.items.push(view),
606            None => groups.push(CategoryView {
607                name: category,
608                items: vec![view],
609            }),
610        }
611    }
612    groups
613}
614
615/// Prefills unset display fields from configuration before the form renders, so
616/// a field shows the current effective value rather than opening blank. The
617/// brand's application name prefills from the configured `app.name` when no brand
618/// setting is saved. This is display only: it writes nothing, so a later config
619/// change still propagates (persisting it would freeze the value). It is not a
620/// database seeder; that is a separate, deferred capability.
621fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
622    if item.code != brand::BrandSetting::CODE {
623        return;
624    }
625    if let Value::Object(map) = stored {
626        let blank = map
627            .get("app_name")
628            .and_then(Value::as_str)
629            .unwrap_or("")
630            .trim()
631            .is_empty();
632        if blank {
633            map.insert("app_name".to_string(), Value::String(app_name.to_string()));
634        }
635    }
636}
637
638fn build(
639    item: &SettingsItem,
640    types: &crate::field::FieldRegistry,
641    overrides: &dyn crate::field::OverrideResolver,
642    error: Option<Text>,
643    stored: &Value,
644    shell: &crate::Shell,
645) -> SettingsFormTemplate {
646    let fields =
647        crate::form::value_views(&item.fields, types, overrides, stored, shell, &item.code);
648    SettingsFormTemplate {
649        title: shell.tt(&item.label),
650        description: shell.tt(&item.description),
651        hint: item.hint.as_ref().map(|h| shell.tt(h)),
652        action: item.path(&shell.base),
653        error: error.map(|e| shell.tt(&e)),
654        fields,
655        leave_off: !item.confirm_leave,
656        actions: item
657            .actions
658            .iter()
659            .map(|a| ActionView {
660                label: shell.tt(&a.label),
661                path: format!("{}{}", shell.base, a.path),
662            })
663            .collect(),
664        shell: shell.clone(),
665    }
666}
667
668/// One category block in the context sidebar. Rendered by the shell.
669#[derive(Clone)]
670pub(crate) struct CategoryView {
671    pub(crate) name: String,
672    pub(crate) items: Vec<ItemView>,
673}
674
675impl CategoryView {
676    /// One group of entries, as a menu section renders in the sidebar.
677    pub(crate) fn new(name: String, items: Vec<ItemView>) -> Self {
678        Self { name, items }
679    }
680}
681
682/// One item in the context sidebar.
683#[derive(Clone)]
684pub(crate) struct ItemView {
685    /// The item's code, which an operator's own order names.
686    pub(crate) key: String,
687    pub(crate) label: String,
688    pub(crate) description: String,
689    pub(crate) path: String,
690    /// Inline SVG markup for the item's icon, rendered raw in the template.
691    pub(crate) icon: String,
692    /// Whether this is the item currently open, so the sidebar highlights it.
693    pub(crate) active: bool,
694}
695
696#[derive(Template)]
697#[template(path = "settings_index.html")]
698struct SettingsIndexTemplate {
699    shell: crate::Shell,
700}
701
702#[derive(Template)]
703#[template(path = "settings_form.html")]
704struct SettingsFormTemplate {
705    shell: crate::Shell,
706    title: String,
707    description: String,
708    /// The item's fuller explanation, localized. Supersedes `description` on
709    /// this screen, which is written for a sidebar.
710    hint: Option<String>,
711    action: String,
712    error: Option<String>,
713    fields: Vec<ValueFieldView>,
714    /// Leaving with changes unsaved does not ask.
715    leave_off: bool,
716    actions: Vec<ActionView>,
717}
718
719struct ActionView {
720    label: String,
721    path: String,
722}
723
724#[cfg(test)]
725mod field_system_tests {
726    use super::*;
727
728    #[test]
729    #[should_panic(expected = "over the 72")]
730    fn a_lecture_of_a_description_is_refused() {
731        // The shape the discovery plugin shipped with: 108 characters, four
732        // lines in a 211-pixel sidebar.
733        SettingsItem::new("acme.test", "Test", Vec::new()).description(
734            "What search engines and other crawlers may visit. The admin panel \
735             is always excluded, whatever you put here.",
736        );
737    }
738
739    #[test]
740    fn a_label_sized_description_passes_and_a_hint_carries_the_rest() {
741        let item = SettingsItem::new("acme.test", "Test", Vec::new())
742            .description("Which crawlers may visit, and what they may not.")
743            .hint(
744                "The admin panel is always excluded, whatever you put here, and a \
745                   crawler obeys only the block that names it.",
746            );
747        assert!(item.description.source().chars().count() <= MAX_DESCRIPTION);
748        // The hint has no ceiling: the reader has opened the screen.
749        assert!(item.hint.is_some());
750    }
751
752    fn item(fields: Vec<FormField>) -> SettingsItem {
753        SettingsItem {
754            code: "acme.test".to_string(),
755            label: Text::new(""),
756            description: Text::new(""),
757            category: Text::new(""),
758            hint: None,
759            confirm_leave: true,
760            slot: laterite_core::arrange::slot::APPLICATION,
761            order: 0,
762            icon: None,
763            permission: None,
764            link: None,
765            fields,
766            route: None,
767            per_operator: false,
768            actions: Vec::new(),
769        }
770    }
771
772    fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
773        pairs
774            .iter()
775            .map(|(k, v)| (k.to_string(), v.to_string()))
776            .collect()
777    }
778
779    /// Settings and forms now share one field system, so a settings screen gets
780    /// every registered type and stores what that type says it stores.
781    #[test]
782    fn settings_store_what_the_field_type_says() {
783        let cfg = item(vec![
784            FormField::text("name", "Name"),
785            FormField::switch("enabled", "Enabled"),
786            FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
787            FormField::date("expires", "Expires"),
788        ]);
789        let stored = collect(
790            &cfg,
791            &crate::field::builtin_registry(),
792            &submitted(&[
793                ("name", "Acme"),
794                ("enabled", "on"),
795                ("freq", "daily"),
796                ("expires", "2026-12-31"),
797            ]),
798        )
799        .unwrap();
800
801        assert_eq!(stored["name"], "Acme");
802        // A bool, not the string "on": the switch field type decided that.
803        assert_eq!(stored["enabled"], serde_json::json!(true));
804        assert_eq!(stored["freq"], "daily");
805        assert_eq!(stored["expires"], "2026-12-31");
806    }
807
808    /// An unticked switch is absent from the submission and must store false,
809    /// or the box could never be cleared.
810    #[test]
811    fn an_unticked_switch_clears() {
812        let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
813        let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
814        assert_eq!(stored["enabled"], serde_json::json!(false));
815    }
816
817    /// The repeater works in settings because it is a field type, not a
818    /// settings-only widget: this is the whole point of the unification.
819    #[test]
820    fn a_repeater_works_in_settings() {
821        let cfg = item(vec![FormField::repeater(
822            "rules",
823            "Rules",
824            vec![
825                FormField::text("path", "Path"),
826                FormField::switch("allow", "Allow"),
827            ],
828        )]);
829        let stored = collect(
830            &cfg,
831            &crate::field::builtin_registry(),
832            &submitted(&[
833                ("rules[0][path]", "/private"),
834                ("rules[1][path]", "/public"),
835                ("rules[1][allow]", "on"),
836            ]),
837        )
838        .unwrap();
839
840        let rows = stored["rules"].as_array().unwrap();
841        assert_eq!(rows.len(), 2);
842        assert_eq!(rows[0]["path"], "/private");
843        assert_eq!(rows[0]["allow"], serde_json::json!(false));
844        assert_eq!(rows[1]["allow"], serde_json::json!(true));
845    }
846
847    #[test]
848    fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
849        let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
850        assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
851    }
852
853    #[test]
854    fn the_form_renders_each_control_through_its_type() {
855        let cfg = item(vec![
856            FormField::switch("enabled", "Enabled"),
857            FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
858        ]);
859        let html = build(
860            &cfg,
861            &crate::field::builtin_registry(),
862            &crate::field::NoOverrides,
863            None,
864            &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
865            &crate::Shell::test(),
866        )
867        .render()
868        .unwrap();
869
870        assert!(html.contains(r#"type="checkbox""#));
871        assert!(html.contains(r#"name="rules[0][path]""#));
872        assert!(html.contains(r#"value="/a""#));
873        assert!(html.contains("lat-repeater__blank"));
874    }
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880    use laterite_core::Db;
881
882    #[test]
883    fn brand_form_prefills_app_name_from_config_when_unset() {
884        let brand = brand::settings_item();
885        // Unset: the field prefills from the configured application name.
886        let mut unset = Value::Object(Map::new());
887        prefill_from_config(&brand, &mut unset, "Configured Name");
888        assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
889        // Already set: the stored value is left untouched.
890        let mut set = serde_json::json!({ "app_name": "Acme" });
891        prefill_from_config(&brand, &mut set, "Configured Name");
892        assert_eq!(set["app_name"], serde_json::json!("Acme"));
893        // A non-brand item is not prefilled.
894        let mut other = Value::Object(Map::new());
895        prefill_from_config(&item(), &mut other, "Configured Name");
896        assert!(other.get("app_name").is_none());
897    }
898
899    fn item() -> SettingsItem {
900        SettingsItem {
901            code: "test.log".to_string(),
902            label: "Log Settings".into(),
903            description: "What the log records.".into(),
904            category: "Logs".into(),
905            hint: None,
906            confirm_leave: true,
907            slot: laterite_core::arrange::slot::APPLICATION,
908            order: 10,
909            icon: None,
910            permission: None,
911            link: None,
912            fields: vec![
913                FormField::switch("log_events", "Log events"),
914                FormField::switch("log_requests", "Log requests"),
915                FormField::text("retention_days", "Retention (days)"),
916            ],
917            route: None,
918            per_operator: false,
919            actions: Vec::new(),
920        }
921    }
922
923    fn state(db: Db) -> AdminState {
924        AdminState::new(
925            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
926            db,
927        )
928    }
929
930    /// A fresh test database with the settings table migrated in, on whichever
931    /// backend the run targets. Hold the returned guard for the test's lifetime.
932    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
933        laterite_core::testing::connect_test(&[migrations()]).await
934    }
935
936    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
937        pairs
938            .iter()
939            .map(|(k, v)| (k.to_string(), v.to_string()))
940            .collect()
941    }
942
943    #[test]
944    fn group_orders_categories_then_items() {
945        let items = vec![
946            SettingsItem {
947                code: "b".into(),
948                label: "Beta".into(),
949                description: String::new().into(),
950                category: "System".into(),
951                hint: None,
952                confirm_leave: true,
953                slot: laterite_core::arrange::slot::APPLICATION,
954                order: 20,
955                icon: None,
956                permission: None,
957                link: None,
958                fields: vec![],
959                route: None,
960                per_operator: false,
961                actions: Vec::new(),
962            },
963            SettingsItem {
964                code: "a".into(),
965                label: "Alpha".into(),
966                description: String::new().into(),
967                category: "System".into(),
968                hint: None,
969                confirm_leave: true,
970                slot: laterite_core::arrange::slot::APPLICATION,
971                order: 10,
972                icon: None,
973                permission: None,
974                link: None,
975                fields: vec![],
976                route: None,
977                per_operator: false,
978                actions: Vec::new(),
979            },
980            SettingsItem {
981                code: "l".into(),
982                label: "Logs".into(),
983                description: String::new().into(),
984                category: "Logs".into(),
985                hint: None,
986                confirm_leave: true,
987                slot: laterite_core::arrange::slot::APPLICATION,
988                order: 5,
989                icon: None,
990                permission: None,
991                link: None,
992                fields: vec![],
993                route: None,
994                per_operator: false,
995                actions: Vec::new(),
996            },
997        ];
998        let groups = sidebar_groups(
999            &items,
1000            "/admin",
1001            None,
1002            &Translator::new("en"),
1003            crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
1004            None,
1005            None,
1006        );
1007        // "Logs" holds the first-arranged item, so it comes before "System".
1008        assert_eq!(groups[0].name, "Logs");
1009        assert_eq!(groups[1].name, "System");
1010        // Within "System", Alpha (10) before Beta (20).
1011        assert_eq!(groups[1].items[0].label, "Alpha");
1012        assert_eq!(groups[1].items[1].label, "Beta");
1013        // Nothing is active when no code is given.
1014        assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
1015    }
1016
1017    #[test]
1018    fn group_marks_only_the_active_item() {
1019        let items = vec![item()];
1020        let groups = sidebar_groups(
1021            &items,
1022            "/admin",
1023            Some("test.log"),
1024            &Translator::new("en"),
1025            crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
1026            None,
1027            None,
1028        );
1029        let active: Vec<&str> = groups
1030            .iter()
1031            .flat_map(|g| &g.items)
1032            .filter(|i| i.active)
1033            .map(|i| i.label.as_str())
1034            .collect();
1035        assert_eq!(active, ["Log Settings"]);
1036    }
1037
1038    #[test]
1039    fn settings_model_item_path_is_its_form() {
1040        // Unresolved (no module namespace yet), the fallback still builds a
1041        // path from the code's segments rather than using the dotted key.
1042        assert_eq!(item().path("/admin"), "/admin/settings/test/log");
1043        // The mount is honoured, so a relocated panel keeps consistent links.
1044        assert_eq!(item().path("/manage"), "/manage/settings/test/log");
1045
1046        // Once boot resolves it into the module's namespace, that wins.
1047        let mut resolved = item();
1048        resolved.mount_at("/acme/logging/log");
1049        assert_eq!(resolved.path("/admin"), "/admin/acme/logging/log");
1050    }
1051
1052    #[test]
1053    fn link_item_path_follows_the_link() {
1054        let admins = crate::builtin_settings()
1055            .into_iter()
1056            .find(|i| i.code == "backend.administrators")
1057            .unwrap();
1058        assert_eq!(admins.category.source(), "Users");
1059        assert!(admins.link.is_some());
1060        assert!(admins.fields.is_empty());
1061        // links to the resource list, not a settings form
1062        assert_eq!(admins.path("/admin"), "/admin/users");
1063        assert!(crate::builtin_settings()
1064            .iter()
1065            .any(|i| i.code == "backend.roles"));
1066    }
1067
1068    #[tokio::test]
1069    async fn update_persists_typed_values() {
1070        let (db, _guard) = test_db().await;
1071        let st = state(db.clone());
1072
1073        let it = item();
1074        let resp = update(
1075            &st,
1076            &it,
1077            // log_requests is absent, as an unchecked checkbox would be.
1078            data(&[("log_events", "on"), ("retention_days", "30")]),
1079            crate::Shell::test(),
1080            &crate::session::SessionHandle::from_blob(None),
1081            &crate::audit::test_actor(),
1082        )
1083        .await;
1084        assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
1085
1086        let stored = store::get(&db, "test.log").await.unwrap().unwrap();
1087        assert_eq!(stored["log_events"], serde_json::json!(true));
1088        assert_eq!(stored["log_requests"], serde_json::json!(false));
1089        assert_eq!(stored["retention_days"], serde_json::json!("30"));
1090    }
1091
1092    #[test]
1093    fn index_renders() {
1094        let resp = index(crate::Shell::test());
1095        assert_eq!(resp.status(), axum::http::StatusCode::OK);
1096    }
1097
1098    #[tokio::test]
1099    async fn edit_form_renders_for_unset_item() {
1100        let (db, _guard) = test_db().await;
1101        let st = state(db);
1102        // No stored value yet: the form still renders (fields fall back to defaults).
1103        let it = item();
1104        let resp = edit_form(&st, &it, crate::Shell::test(), &crate::audit::test_actor()).await;
1105        assert_eq!(resp.status(), axum::http::StatusCode::OK);
1106    }
1107}