Skip to main content

cratefield_ui/
render.rs

1//! The markup contract (ADR 0010, `docs/UI.md`). Every element carries a
2//! `cf-*` class and, where it maps to the surface, a `data-cf-*`
3//! attribute. No shadow DOM, no inline styles, no ids except the ones
4//! that tie a `<label>` to its control. Changing a class name here is a
5//! breaking change of the UI contract; the snapshot test guards it.
6
7use maud::{DOCTYPE, Markup, PreEscaped, html};
8
9use crate::fields::{Field, Values, Widget, humanize};
10
11/// Cloudflare Turnstile's script and iframe origin, allowed by the page
12/// CSP only when a captcha widget is rendered.
13pub const TURNSTILE_ORIGIN: &str = "https://challenges.cloudflare.com";
14/// Turnstile's loader. Shared with `auth-core`'s hosted pages so both
15/// render the same script, from the same origin the CSP allows.
16pub const TURNSTILE_SCRIPT: &str = "https://challenges.cloudflare.com/turnstile/v0/api.js";
17
18/// What a form needs to render, independent of where it came from.
19pub struct FormSpec<'a> {
20    pub module: &'a str,
21    pub action: &'a str,
22    /// `POST` target; the `/ui` route, never `/v1`.
23    pub post_to: &'a str,
24    /// A paragraph above the fields (from the `UiSpec`).
25    pub intro: Option<&'a str>,
26    pub fields: &'a [Field],
27    pub values: &'a Values,
28    /// Field name to message; a key of `""` is the form-level error.
29    pub errors: &'a [(String, String)],
30    /// Turnstile site key when the action wants a captcha and one is
31    /// configured.
32    pub captcha_site_key: Option<&'a str>,
33    pub submit_label: &'a str,
34}
35
36/// The form fragment.
37#[must_use]
38pub fn form(spec: &FormSpec<'_>) -> Markup {
39    let form_error = spec.errors.iter().find(|(name, _)| name.is_empty());
40    html! {
41        form class="cf-form" data-cf-module=(spec.module) data-cf-action=(spec.action)
42            method="post" action=(spec.post_to) novalidate {
43            @if let Some(intro) = spec.intro {
44                p class="cf-intro" { (intro) }
45            }
46            @if let Some((_, message)) = form_error {
47                p class="cf-error cf-error--form" role="alert" { (message) }
48            }
49            @for field in spec.fields {
50                (field_markup(spec, field))
51            }
52            @if let Some(site_key) = spec.captcha_site_key {
53                div class="cf-field cf-field--captcha" data-cf-field="captchaToken" {
54                    div class="cf-turnstile" data-sitekey=(site_key) {}
55                }
56            }
57            div class="cf-actions" {
58                button class="cf-submit" type="submit" { (spec.submit_label) }
59            }
60        }
61    }
62}
63
64fn field_markup(spec: &FormSpec<'_>, field: &Field) -> Markup {
65    let value = spec.values.get(&field.name).map(String::as_str);
66    if field.widget == Widget::Hidden {
67        return html! {
68            @if let Some(value) = value {
69                input type="hidden" name=(field.name) value=(value);
70            }
71        };
72    }
73    let error = spec
74        .errors
75        .iter()
76        .find(|(name, _)| name == &field.name)
77        .map(|(_, message)| message.as_str());
78    let id = format!("cf-{}-{}-{}", spec.module, spec.action, field.name);
79    let classes = if error.is_some() {
80        "cf-field cf-field--invalid"
81    } else {
82        "cf-field"
83    };
84    html! {
85        div class=(classes) data-cf-field=(field.name) {
86            @if field.widget == Widget::Checkbox {
87                label class="cf-label cf-label--checkbox" for=(id) {
88                    input class="cf-input cf-input--checkbox" type="checkbox" id=(id)
89                        name=(field.name) required[field.required]
90                        checked[value.is_some_and(|v| matches!(v, "on" | "true" | "1"))];
91                    span { (field.label) }
92                }
93            } @else {
94                label class="cf-label" for=(id) {
95                    (field.label)
96                    @if field.required { span class="cf-required" aria-hidden="true" { "*" } }
97                }
98                (control(field, &id, value))
99            }
100            @if let Some(help) = &field.help {
101                p class="cf-help" { (help) }
102            }
103            @if let Some(message) = error {
104                p class="cf-error" role="alert" { (message) }
105            }
106        }
107    }
108}
109
110fn control(field: &Field, id: &str, value: Option<&str>) -> Markup {
111    match field.widget {
112        Widget::Select => html! {
113            select class="cf-input cf-input--select" id=(id) name=(field.name)
114                required[field.required] {
115                @if !field.required || value.is_none() {
116                    option value="" { (field.placeholder.as_deref().unwrap_or("Choose")) }
117                }
118                @for option in &field.options {
119                    option value=(option.value) selected[value == Some(option.value.as_str())] {
120                        (option.label)
121                    }
122                }
123            }
124        },
125        Widget::Textarea => html! {
126            textarea class="cf-input cf-input--textarea" id=(id) name=(field.name)
127                required[field.required] placeholder=[field.placeholder.as_deref()] {
128                @if let Some(value) = value { (value) }
129            }
130        },
131        Widget::Email
132        | Widget::Number
133        | Widget::Text
134        | Widget::Password
135        | Widget::Checkbox
136        | Widget::Hidden => {
137            let kind = match field.widget {
138                Widget::Email => "email",
139                Widget::Number => "number",
140                Widget::Password => "password",
141                _ => "text",
142            };
143            let value = (field.widget != Widget::Password)
144                .then_some(value)
145                .flatten();
146            let autocomplete = match field.widget {
147                Widget::Email => Some("email"),
148                Widget::Password => Some("off"),
149                _ => None,
150            };
151            html! {
152                input class="cf-input" type=(kind) id=(id) name=(field.name)
153                    required[field.required] placeholder=[field.placeholder.as_deref()]
154                    value=[value] autocomplete=[autocomplete];
155            }
156        }
157    }
158}
159
160/// A notice: the outcome of a successful action, or a landing page.
161#[must_use]
162pub fn notice(module: &str, action: &str, tone: &str, title: &str, message: &str) -> Markup {
163    let class = format!("cf-notice cf-notice--{tone}");
164    html! {
165        div class=(class) data-cf-module=(module) data-cf-action=(action) role="status" {
166            h2 class="cf-notice-title" { (title) }
167            p class="cf-notice-text" { (message) }
168        }
169    }
170}
171
172/// A `Json` outcome rendered as a definition list, one row per top-level
173/// key. Nested values are shown as JSON text.
174#[must_use]
175pub fn status(module: &str, action: &str, value: &serde_json::Value) -> Markup {
176    html! {
177        dl class="cf-status" data-cf-module=(module) data-cf-action=(action) {
178            @if let Some(object) = value.as_object() {
179                @for (key, item) in object {
180                    div class="cf-status-row" data-cf-field=(key) {
181                        dt class="cf-status-key" { (humanize(key)) }
182                        dd class="cf-status-value" { (scalar(item)) }
183                    }
184                }
185            } @else {
186                div class="cf-status-row" {
187                    dd class="cf-status-value" { (scalar(value)) }
188                }
189            }
190        }
191    }
192}
193
194fn scalar(value: &serde_json::Value) -> String {
195    match value {
196        serde_json::Value::String(s) => s.clone(),
197        serde_json::Value::Null => String::new(),
198        other => other.to_string(),
199    }
200}
201
202/// What a full page needs around a fragment.
203pub struct PageSpec<'a> {
204    pub venture: &'a str,
205    pub title: &'a str,
206    /// `/ui/cf.css` and `/ui/cf.js` relative to the API origin; the
207    /// venture's own theme stylesheet, if configured, is linked after.
208    /// Link `/ui/theme.css` (the spec's `--cf-*` tokens) after `cf.css`.
209    pub theme_tokens: bool,
210    pub theme_css: Option<&'a str>,
211    pub turnstile: bool,
212    /// Render the admin navigation (home link, log-out form).
213    pub admin: bool,
214}
215
216/// Wraps a fragment in the page shell. Scripts: none of ours; Turnstile's
217/// only when a captcha widget is on the page.
218#[must_use]
219pub fn page(spec: &PageSpec<'_>, body: &Markup) -> Markup {
220    html! {
221        (DOCTYPE)
222        html lang="en" {
223            head {
224                meta charset="utf-8";
225                meta name="viewport" content="width=device-width, initial-scale=1";
226                meta name="robots" content="noindex";
227                title { (spec.title) " ยท " (spec.venture) }
228                link rel="stylesheet" href="/ui/cf.css";
229                @if spec.theme_tokens {
230                    link rel="stylesheet" href="/ui/theme.css";
231                }
232                @if let Some(theme) = spec.theme_css {
233                    link rel="stylesheet" href=(theme);
234                }
235                @if spec.turnstile {
236                    script src=(TURNSTILE_SCRIPT) async defer {}
237                }
238            }
239            body class="cf-page" {
240                main class="cf-main" {
241                    header class="cf-header" {
242                        p class="cf-venture" { (spec.venture) }
243                        h1 class="cf-title" { (spec.title) }
244                        @if spec.admin {
245                            nav class="cf-nav" {
246                                a class="cf-nav-link" href="/ui/admin" { "Admin" }
247                                form class="cf-logout" method="post" action="/ui/admin/logout" {
248                                    button class="cf-nav-link cf-logout-button" type="submit" { "Log out" }
249                                }
250                            }
251                        }
252                    }
253                    (body)
254                }
255            }
256        }
257    }
258}
259
260/// One row of a table: cells in column order, plus the per-row forms
261/// (a delete button whose confirm step is a normal post).
262pub struct TableRow<'a> {
263    pub cells: Vec<&'a str>,
264    /// `(action, hidden fields)` for each row action.
265    pub actions: Vec<(&'a str, Vec<(&'a str, &'a str)>)>,
266}
267
268/// An admin `Table` view.
269#[must_use]
270pub fn table(module: &str, source: &str, columns: &[&str], rows: &[TableRow<'_>]) -> Markup {
271    html! {
272        div class="cf-table-wrap" {
273            table class="cf-table" data-cf-module=(module) data-cf-action=(source) {
274                thead {
275                    tr {
276                        @for column in columns { th class="cf-table-head" scope="col" { (column) } }
277                        @if rows.iter().any(|r| !r.actions.is_empty()) { th class="cf-table-head" scope="col" { "" } }
278                    }
279                }
280                tbody {
281                    @if rows.is_empty() {
282                        tr { td class="cf-table-empty" colspan=(columns.len()) { "No rows." } }
283                    }
284                    @for row in rows {
285                        tr class="cf-table-row" {
286                            @for cell in &row.cells { td class="cf-table-cell" { (cell) } }
287                            @if !row.actions.is_empty() {
288                                td class="cf-table-cell cf-table-actions" {
289                                    @for (action, hidden) in &row.actions {
290                                        form class="cf-row-action" method="post"
291                                            action=(format!("/ui/admin/{module}/{action}")) {
292                                            @for (name, value) in hidden {
293                                                input type="hidden" name=(name) value=(value);
294                                            }
295                                            button class="cf-submit cf-submit--row" type="submit" { (humanize(action)) }
296                                        }
297                                    }
298                                }
299                            }
300                        }
301                    }
302                }
303            }
304        }
305    }
306}
307
308/// The confirm step of a destructive row action: the same hidden fields
309/// plus `confirm=1`, and a way back.
310#[must_use]
311pub fn confirm(
312    module: &str,
313    action: &str,
314    what: &str,
315    hidden: &[(String, String)],
316    back: &str,
317) -> Markup {
318    html! {
319        div class="cf-notice cf-notice--warning" data-cf-module=(module) data-cf-action=(action) role="status" {
320            h2 class="cf-notice-title" { (humanize(action)) " " (what) "?" }
321            p class="cf-notice-text" { "This cannot be undone." }
322            form class="cf-form cf-form--confirm" method="post" action=(format!("/ui/admin/{module}/{action}")) {
323                @for (name, value) in hidden { input type="hidden" name=(name) value=(value); }
324                input type="hidden" name="confirm" value="1";
325                div class="cf-actions" {
326                    button class="cf-submit cf-submit--danger" type="submit" { (humanize(action)) }
327                    a class="cf-cancel" href=(back) { "Cancel" }
328                }
329            }
330        }
331    }
332}
333
334/// The admin index: per module, its tables and its form actions.
335#[must_use]
336pub fn admin_index(entries: &[(String, Vec<(String, String)>)]) -> Markup {
337    html! {
338        div class="cf-admin-index" {
339            @if entries.is_empty() { p class="cf-help" { "No module declares an admin surface." } }
340            @for (module, links) in entries {
341                section class="cf-admin-module" data-cf-module=(module) {
342                    h2 class="cf-admin-module-title" { (humanize(module)) }
343                    ul class="cf-admin-links" {
344                        @for (href, label) in links { li { a class="cf-admin-link" href=(href) { (label) } } }
345                    }
346                }
347            }
348        }
349    }
350}
351
352/// Raw markup from a trusted source (the base stylesheet, the embed
353/// script), for the tests that snapshot them.
354#[must_use]
355pub fn raw(text: &str) -> Markup {
356    PreEscaped(text.to_owned())
357}