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