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            ("login.html", include_str!("templates/login.html.tera")),
54            ("mfa_setup.html", include_str!("templates/mfa_setup.html.tera")),
55            ("mfa_backup.html", include_str!("templates/mfa_backup.html.tera")),
56            ("mfa_verify.html", include_str!("templates/mfa_verify.html.tera")),
57        ])
58        .expect("adminx: failed to parse embedded templates");
59        tera.autoescape_on(vec![".html"]);
60        tera
61    };
62}
63
64/// Render a named template into an HTML `ApiResponse`.
65pub fn render(name: &str, ctx: &Context) -> ApiResponse {
66    match TEMPLATES.render(name, ctx) {
67        Ok(html) => ApiResponse::html(200, html),
68        Err(e) => {
69            tracing::error!("adminx template render error [{name}]: {e}");
70            ApiResponse::error(CoreError::Internal(format!("template error: {e}")))
71        }
72    }
73}
74
75/// Render a page that contains a POST form, putting a CSRF token in scope as
76/// `csrf_token` and setting the cookie when a fresh one had to be minted. Every
77/// template rendered through this must echo the token into a hidden `_csrf`
78/// field, or its form will be rejected by [`crate::csrf::verify`] on submit.
79pub fn render_with_csrf(ctx: &ReqCtx, mut context: Context, template: &str) -> ApiResponse {
80    let (token, cookie) = crate::csrf::ensure(ctx);
81    context.insert("csrf_token", &token);
82    let resp = render(template, &context);
83    match cookie {
84        Some(v) => resp.with_header("Set-Cookie", v),
85        None => resp,
86    }
87}
88
89/// Base template context: title, mount prefix, and the navigation menus.
90pub fn base_context(ctx: &ReqCtx, title: &str) -> Context {
91    let mut c = Context::new();
92    c.insert("title", title);
93    c.insert("mount", &ctx.mount);
94    c.insert("menus", &get_registered_menus());
95    c.insert("is_authenticated", &ctx.claims.is_some());
96    c.insert("tailwind_src", &tailwind_src());
97    c
98}
99
100/// Render the top-level dashboard.
101pub fn dashboard(ctx: &ReqCtx) -> ApiResponse {
102    let c = base_context(ctx, "Dashboard");
103    render("dashboard.html", &c)
104}
105
106/// Derive table column headers from a set of rows: the primary key first, then
107/// the remaining keys of the first row in their natural order.
108pub fn derive_headers(rows: &[Value], pk: &str) -> Vec<String> {
109    let mut headers: Vec<String> = Vec::new();
110    if let Some(Value::Object(first)) = rows.first() {
111        if first.contains_key(pk) {
112            headers.push(pk.to_string());
113        }
114        for k in first.keys() {
115            if k != pk {
116                headers.push(k.clone());
117            }
118        }
119    }
120    headers
121}
122
123/// Build a default field list (for create/edit forms) from a set of column
124/// names, when a resource provides no explicit `form_structure`.
125pub fn default_fields(columns: &[&str]) -> Vec<Value> {
126    columns
127        .iter()
128        .map(|name| {
129            let field_type = if *name == "deleted" { "checkbox" } else { "text" };
130            json!({
131                "name": name,
132                "label": humanize(name),
133                "field_type": field_type,
134            })
135        })
136        .collect()
137}
138
139/// Extract a flat `fields` array from an explicit `form_structure` value.
140/// Supports `{ "groups": [ { "fields": [...] } ] }` and `{ "fields": [...] }`.
141pub fn fields_from_structure(structure: &Value) -> Vec<Value> {
142    if let Some(groups) = structure.get("groups").and_then(|g| g.as_array()) {
143        return groups
144            .iter()
145            .filter_map(|g| g.get("fields").and_then(|f| f.as_array()))
146            .flatten()
147            .cloned()
148            .collect();
149    }
150    if let Some(fields) = structure.get("fields").and_then(|f| f.as_array()) {
151        return fields.clone();
152    }
153    Vec::new()
154}
155
156/// Turn a submitted HTML form (all string values) into a typed JSON object:
157/// `"true"/"false"` → bool, integer/float text → number, everything else stays
158/// a string.
159pub fn form_to_json(form: HashMap<String, String>) -> Value {
160    let mut map = Map::new();
161    for (k, v) in form {
162        let value = if v == "true" {
163            Value::Bool(true)
164        } else if v == "false" {
165            Value::Bool(false)
166        } else if let Ok(i) = v.parse::<i64>() {
167            Value::from(i)
168        } else if let Ok(f) = v.parse::<f64>() {
169            Value::from(f)
170        } else {
171            Value::String(v)
172        };
173        map.insert(k, value);
174    }
175    Value::Object(map)
176}
177
178/// `created_at` → `Created At`.
179pub fn humanize(name: &str) -> String {
180    name.split('_')
181        .map(|w| {
182            let mut chars = w.chars();
183            match chars.next() {
184                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
185                None => String::new(),
186            }
187        })
188        .collect::<Vec<_>>()
189        .join(" ")
190}