Skip to main content

adminx_core/
ui.rs

1// adminx-core/src/ui.rs
2//
3// HTML rendering for the admin UI. The core renders full HTML pages (via Tera)
4// and returns them as `ApiResponse` byte bodies, so both the Actix and Axum
5// adapters serve the identical UI without templating logic of their own.
6
7use crate::error::CoreError;
8use crate::registry::get_registered_menus;
9use crate::request::ReqCtx;
10use crate::response::ApiResponse;
11use lazy_static::lazy_static;
12use once_cell::sync::OnceCell;
13use serde_json::{json, Map, Value};
14use std::collections::HashMap;
15use tera::{Context, Tera};
16
17/// Default stylesheet: Tailwind's Play CDN. Convenient, but it's a dev-mode build
18/// that needs network access and is blocked by a strict Content-Security-Policy.
19/// Override it (see [`set_tailwind_src`]) with a self-hosted build for production.
20const DEFAULT_TAILWIND_SRC: &str = "https://cdn.tailwindcss.com";
21
22static TAILWIND_SRC: OnceCell<String> = OnceCell::new();
23
24/// Point the admin UI at a different Tailwind/CSS source (e.g. a self-hosted
25/// stylesheet you serve yourself) instead of the CDN default. Set once, before
26/// serving; the `ADMINX_TAILWIND_SRC` env var is honoured as a fallback.
27pub fn set_tailwind_src(src: impl Into<String>) {
28    let _ = TAILWIND_SRC.set(src.into());
29}
30
31/// Resolve the stylesheet source: explicit config, else the env var, else the CDN.
32fn tailwind_src() -> String {
33    if let Some(s) = TAILWIND_SRC.get() {
34        return s.clone();
35    }
36    match std::env::var("ADMINX_TAILWIND_SRC") {
37        Ok(s) if !s.is_empty() => s,
38        _ => DEFAULT_TAILWIND_SRC.to_string(),
39    }
40}
41
42lazy_static! {
43    static ref TEMPLATES: Tera = {
44        let mut tera = Tera::default();
45        tera.add_raw_templates(vec![
46            ("layout.html", include_str!("templates/layout.html.tera")),
47            ("header.html", include_str!("templates/header.html.tera")),
48            ("footer.html", include_str!("templates/footer.html.tera")),
49            ("dashboard.html", include_str!("templates/dashboard.html.tera")),
50            ("list.html", include_str!("templates/list.html.tera")),
51            ("form.html", include_str!("templates/form.html.tera")),
52            ("view.html", include_str!("templates/view.html.tera")),
53            ("history.html", include_str!("templates/history.html.tera")),
54            ("login.html", include_str!("templates/login.html.tera")),
55            ("mfa_setup.html", include_str!("templates/mfa_setup.html.tera")),
56            ("mfa_backup.html", include_str!("templates/mfa_backup.html.tera")),
57            ("mfa_verify.html", include_str!("templates/mfa_verify.html.tera")),
58        ])
59        .expect("adminx: failed to parse embedded templates");
60        tera.autoescape_on(vec![".html"]);
61        tera
62    };
63}
64
65/// Render a named template into an HTML `ApiResponse`.
66pub fn render(name: &str, ctx: &Context) -> ApiResponse {
67    match TEMPLATES.render(name, ctx) {
68        Ok(html) => ApiResponse::html(200, html),
69        Err(e) => {
70            tracing::error!("adminx template render error [{name}]: {e}");
71            ApiResponse::error(CoreError::Internal(format!("template error: {e}")))
72        }
73    }
74}
75
76/// Render a page that contains a POST form, putting a CSRF token in scope as
77/// `csrf_token` and setting the cookie when a fresh one had to be minted. Every
78/// template rendered through this must echo the token into a hidden `_csrf`
79/// field, or its form will be rejected by [`crate::csrf::verify`] on submit.
80pub fn render_with_csrf(ctx: &ReqCtx, mut context: Context, template: &str) -> ApiResponse {
81    let (token, cookie) = crate::csrf::ensure(ctx);
82    context.insert("csrf_token", &token);
83    let resp = render(template, &context);
84    match cookie {
85        Some(v) => resp.with_header("Set-Cookie", v),
86        None => resp,
87    }
88}
89
90/// Base template context: title, mount prefix, and the navigation menus.
91pub fn base_context(ctx: &ReqCtx, title: &str) -> Context {
92    let mut c = Context::new();
93    c.insert("title", title);
94    c.insert("mount", &ctx.mount);
95    c.insert("menus", &get_registered_menus());
96    c.insert("is_authenticated", &ctx.claims.is_some());
97    c.insert("tailwind_src", &tailwind_src());
98    c
99}
100
101/// Render the top-level dashboard.
102pub fn dashboard(ctx: &ReqCtx) -> ApiResponse {
103    let c = base_context(ctx, "Dashboard");
104    render("dashboard.html", &c)
105}
106
107/// Derive table column headers from a set of rows: the primary key first, then
108/// the remaining keys of the first row in their natural order.
109pub fn derive_headers(rows: &[Value], pk: &str) -> Vec<String> {
110    let mut headers: Vec<String> = Vec::new();
111    if let Some(Value::Object(first)) = rows.first() {
112        if first.contains_key(pk) {
113            headers.push(pk.to_string());
114        }
115        for k in first.keys() {
116            if k != pk {
117                headers.push(k.clone());
118            }
119        }
120    }
121    headers
122}
123
124/// Build a default field list (for create/edit forms) from a set of column
125/// names, when a resource provides no explicit `form_structure`.
126pub fn default_fields(columns: &[&str]) -> Vec<Value> {
127    columns
128        .iter()
129        .map(|name| {
130            let field_type = if *name == "deleted" { "checkbox" } else { "text" };
131            json!({
132                "name": name,
133                "label": humanize(name),
134                "field_type": field_type,
135            })
136        })
137        .collect()
138}
139
140/// Extract a flat `fields` array from an explicit `form_structure` value.
141/// Supports `{ "groups": [ { "fields": [...] } ] }` and `{ "fields": [...] }`.
142pub fn fields_from_structure(structure: &Value) -> Vec<Value> {
143    if let Some(groups) = structure.get("groups").and_then(|g| g.as_array()) {
144        return groups
145            .iter()
146            .filter_map(|g| g.get("fields").and_then(|f| f.as_array()))
147            .flatten()
148            .cloned()
149            .collect();
150    }
151    if let Some(fields) = structure.get("fields").and_then(|f| f.as_array()) {
152        return fields.clone();
153    }
154    Vec::new()
155}
156
157/// Turn a submitted HTML form (all string values) into a typed JSON object:
158/// `"true"/"false"` → bool, integer/float text → number, everything else stays
159/// a string.
160pub fn form_to_json(form: HashMap<String, String>) -> Value {
161    let mut map = Map::new();
162    for (k, v) in form {
163        let value = if v == "true" {
164            Value::Bool(true)
165        } else if v == "false" {
166            Value::Bool(false)
167        } else if let Ok(i) = v.parse::<i64>() {
168            Value::from(i)
169        } else if let Ok(f) = v.parse::<f64>() {
170            Value::from(f)
171        } else {
172            Value::String(v)
173        };
174        map.insert(k, value);
175    }
176    Value::Object(map)
177}
178
179/// `created_at` → `Created At`.
180pub fn humanize(name: &str) -> String {
181    name.split('_')
182        .map(|w| {
183            let mut chars = w.chars();
184            match chars.next() {
185                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
186                None => String::new(),
187            }
188        })
189        .collect::<Vec<_>>()
190        .join(" ")
191}