Skip to main content

laterite_admin/settings/
brand.rs

1//! Brand settings: the application name, mark, palette and colour mode shown
2//! across the admin, and how the sign-in page looks.
3//!
4//! This is the settings layer over the configured application name
5//! ([`laterite_core::config::AppMeta`]). The configured name is the baseline;
6//! the values here override it when set, and a blank value falls back. The
7//! resolved [`Brand`] is cached per process until the setting is saved.
8
9use serde::{Deserialize, Serialize};
10
11use super::store::SettingsModel;
12use super::{FormField, SettingsItem};
13use laterite_core::Db;
14
15/// Brand settings stored under [`BrandSetting::CODE`]. An admin edits these;
16/// the values override the configured application name for display.
17#[derive(Debug, Default, Serialize, Deserialize)]
18pub struct BrandSetting {
19    /// The application name shown as the admin brand. Blank falls back to the
20    /// configured application name.
21    #[serde(default)]
22    pub app_name: String,
23    /// A library image shown in place of the framework's mark (its record id).
24    #[serde(default)]
25    pub logo: String,
26    /// A library image used as the browser tab icon (its record id).
27    #[serde(default)]
28    pub favicon: String,
29    /// The accent colour as `#rrggbb`; blank keeps the palette's own.
30    #[serde(default)]
31    pub accent: String,
32    /// The colour mode an operator starts in: `auto`, `light` or `dark`.
33    #[serde(default)]
34    pub mode: String,
35    /// The line above the sign-in form; blank keeps the framework's.
36    #[serde(default)]
37    pub login_prompt: String,
38    /// The line on the sign-in page's hero panel; blank keeps the framework's.
39    #[serde(default)]
40    pub login_tagline: String,
41    /// A library image behind the hero panel (its record id).
42    #[serde(default)]
43    pub login_background: String,
44    /// Whether the hero panel shows beside the form on wide screens.
45    #[serde(default = "yes")]
46    pub login_hero: bool,
47    /// Stylesheet rules added to every admin page, cleaned on display.
48    #[serde(default)]
49    pub custom_css: String,
50}
51
52fn yes() -> bool {
53    true
54}
55
56impl SettingsModel for BrandSetting {
57    const CODE: &'static str = "laterite.brand";
58}
59
60/// The settings item that surfaces [`BrandSetting`] in the admin, under a
61/// "System" category.
62pub(crate) fn settings_item() -> SettingsItem {
63    SettingsItem::new(
64        BrandSetting::CODE,
65        "Branding",
66        vec![
67            FormField::text("app_name", "Application name").help(
68                "Shown as the admin brand. Clearing it falls back to the configured application name.",
69            ),
70            FormField::of("logo", "Logo", "media")
71                .options(serde_json::json!({ "mode": "image" }))
72                .help("Shown in the top bar and on the sign-in page in place of the mark. Keep it on a public disk."),
73            FormField::of("favicon", "Browser icon", "media")
74                .options(serde_json::json!({ "mode": "image" }))
75                .help("The tab icon; a square PNG or SVG."),
76            FormField::of("accent", "Accent colour", "text")
77                .options(serde_json::json!({ "input": "color" }))
78                .help("Buttons, links and the active section take this colour."),
79            FormField::select(
80                "mode",
81                "Colour mode",
82                vec![
83                    ("auto", "Follow the system"),
84                    ("light", "Light"),
85                    ("dark", "Dark"),
86                ],
87            )
88            .help("What an operator sees before choosing their own."),
89            FormField::text("login_prompt", "Sign-in prompt")
90                .help("The line above the sign-in form."),
91            FormField::text("login_tagline", "Sign-in tagline")
92                .help("The line on the panel beside the form."),
93            FormField::of("login_background", "Sign-in background", "media")
94                .options(serde_json::json!({ "mode": "image" }))
95                .help("An image behind the panel beside the form."),
96            FormField::switch("login_hero", "Show the panel beside the sign-in form"),
97            FormField::of("custom_css", "Custom stylesheet", "codeeditor")
98                .options(serde_json::json!({ "language": "css", "rows": 8 }))
99                .help("Rules added to every admin page. Imports and scripts are removed."),
100        ],
101    )
102    .description("The name, mark, colours and sign-in page of the admin.")
103    .category("System")
104    .slot(laterite_core::arrange::slot::SYSTEM)
105    .order(10)
106    .icon("palette")
107    .permission("backend.manage_branding")
108    // The panel's own setting, belonging to no vendor.
109    .at("/settings/branding")
110}
111
112/// The brand as every page shows it: the setting resolved against the
113/// configured name, its images against the library, its colour into
114/// stylesheet variables, its stylesheet cleaned.
115#[derive(Debug, Clone, Default)]
116pub struct Brand {
117    pub name: String,
118    pub logo_url: Option<String>,
119    pub favicon_url: Option<String>,
120    /// `auto`, `light` or `dark`.
121    pub mode: String,
122    pub login_prompt: Option<String>,
123    pub login_tagline: Option<String>,
124    pub login_background_url: Option<String>,
125    pub login_hero: bool,
126    /// The `<style>` body for every page: the accent's variables plus the
127    /// cleaned custom stylesheet. Empty when nothing is set.
128    pub css: String,
129}
130
131impl Brand {
132    /// The configured name alone, for a process with no setting saved.
133    pub fn of_name(name: &str) -> Self {
134        Self {
135            name: name.to_string(),
136            mode: "auto".to_string(),
137            login_hero: true,
138            ..Self::default()
139        }
140    }
141
142    /// Reads the setting and resolves it.
143    pub async fn resolve(db: &Db, app_name: &str) -> Self {
144        let setting = super::store::load::<BrandSetting>(db)
145            .await
146            .unwrap_or_default();
147        let name = if setting.app_name.trim().is_empty() {
148            app_name.to_string()
149        } else {
150            setting.app_name.trim().to_string()
151        };
152        let mode = match setting.mode.as_str() {
153            "light" | "dark" => setting.mode.clone(),
154            _ => "auto".to_string(),
155        };
156        let mut css = accent_css(&setting.accent).unwrap_or_default();
157        let custom = clean_css(&setting.custom_css);
158        if !custom.is_empty() {
159            css.push('\n');
160            css.push_str(&custom);
161        }
162        let some = |s: &str| {
163            let t = s.trim();
164            (!t.is_empty()).then(|| t.to_string())
165        };
166        Self {
167            name,
168            logo_url: media_url(db, &setting.logo).await,
169            favicon_url: media_url(db, &setting.favicon).await,
170            mode,
171            login_prompt: some(&setting.login_prompt),
172            login_tagline: some(&setting.login_tagline),
173            login_background_url: media_url(db, &setting.login_background).await,
174            login_hero: setting.login_hero,
175            css,
176        }
177    }
178}
179
180/// A library file's public URL from the id a media field stored, or nothing
181/// when the field is blank or the file is gone.
182async fn media_url(db: &Db, id: &str) -> Option<String> {
183    let id: i64 = id.trim().parse().ok()?;
184    let record = laterite_media::record::find(db, id).await.ok().flatten()?;
185    Some(crate::medialib::file_url(&record))
186}
187
188/// The variables an accent colour sets, for both modes: the colour, its
189/// pressed shades, and its two tints. `None` for anything but `#rrggbb`.
190pub(crate) fn accent_css(hex: &str) -> Option<String> {
191    let (r, g, b) = parse_hex(hex)?;
192    let shade = |k: f32| {
193        format!(
194            "#{:02X}{:02X}{:02X}",
195            (r as f32 * k) as u8,
196            (g as f32 * k) as u8,
197            (b as f32 * k) as u8
198        )
199    };
200    let tint = |a: f32| format!("rgba({r},{g},{b},{a})");
201    Some(format!(
202        ":root{{--accent:{hex};--accent-hover:{};--accent-active:{};--accent-subtle:{};--accent-quiet:{};--text-link:{hex};--text-link-hover:{}}}",
203        shade(0.88),
204        shade(0.76),
205        tint(0.14),
206        tint(0.09),
207        shade(0.88),
208    ))
209}
210
211fn parse_hex(hex: &str) -> Option<(u8, u8, u8)> {
212    let h = hex.trim().strip_prefix('#')?;
213    if h.len() != 6 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
214        return None;
215    }
216    let v = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
217    Some((v(0)?, v(2)?, v(4)?))
218}
219
220/// A custom stylesheet cleaned of what cannot be allowed in a `<style>`. See
221/// [`laterite_media::sanitize::css`].
222pub(crate) fn clean_css(css: &str) -> String {
223    laterite_media::sanitize::css(css)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn an_accent_sets_the_variables_and_rejects_anything_but_a_hex() {
232        let css = accent_css("#C44E2F").unwrap();
233        assert!(css.starts_with(":root{--accent:#C44E2F;"));
234        assert!(css.contains("--accent-subtle:rgba(196,78,47,0.14)"));
235        assert!(css.contains("--accent-hover:#AC4429"));
236        assert!(accent_css("red").is_none());
237        assert!(accent_css("#fff").is_none());
238        assert!(accent_css("").is_none());
239    }
240
241    #[test]
242    fn a_custom_stylesheet_loses_imports_scripts_and_closing_tags() {
243        let dirty = ".lat-topnav{background:#123}\n@import url(evil.css);\na{behavior:url(x.htc)}\n</style><script>1</script>\n.x{background:url(javascript:alert(1))}\n.ok{color:red}";
244        assert_eq!(
245            clean_css(dirty),
246            ".lat-topnav{background:#123}\n.ok{color:red}"
247        );
248    }
249}