Skip to main content

acme_proxy_admin/webadmin/pages/
mod.rs

1//! `/ui` — the HTML the operator actually looks at.
2//!
3//! ## Why this is separate from `handlers/`
4//!
5//! `handlers/` answers JSON at `/api`; this answers HTML at `/ui`. Both are
6//! thin layers over the same `crates/admin/src/admin/` operations, which is the whole point:
7//! neither re-derives data, and neither is where a rule lives. Two small
8//! handlers over one operation beat one handler content-negotiating itself into
9//! two representations — htmx swaps markup, and a `text/html` branch inside a
10//! JSON handler is where the two would start disagreeing.
11//!
12//! A write goes one step further: a page calls the **same** `apply_*` function
13//! its `/api` twin does (see `handlers/mod.rs`), which owns the validation, the
14//! audit rows and the log line. A page decides only which refusals are a banner
15//! beside the card and which replace the page.
16//! `every_shared_write_leaves_the_same_audit_rows_on_both_surfaces`
17//! (`tests/admin_pages.rs`) compares the two.
18//!
19//! ## Pages and fragments
20//!
21//! Every list and detail route serves both. A normal navigation gets the full
22//! document — the layout, the navigation, the page's content — and an htmx
23//! request gets the bare partial it is going to swap in. The choice is made off
24//! the `HX-Request` header ([`auth::is_htmx`]), not off the route, so there is
25//! one URL per resource and it is bookmarkable.
26//!
27//! ## What makes a write safe here
28//!
29//! Exactly what makes it safe on the API: [`PageSessionWrite`] wraps
30//! [`crate::webadmin::session::AuthenticatedWrite`], so the origin gate, the
31//! session lookup and the CSRF check all run before a mutating handler can see
32//! a session. The token reaches the browser through `hx-headers` on `<body>` in
33//! `layout.html` and comes back as `X-CSRF-Token` — the same header the JSON
34//! API uses, and the reason `check_csrf` needed no second code path.
35
36pub mod account;
37pub mod accounts;
38pub mod assets;
39pub mod audit;
40pub mod auth;
41pub mod eab;
42pub mod error;
43pub mod expiring;
44pub mod filter;
45pub mod jobs;
46pub mod misc;
47pub mod operators;
48pub mod orders;
49pub mod session;
50pub mod templates;
51pub mod upstream_orders;
52
53pub use auth::{
54    PageAdminRead, PageAdminWrite, PageAuth, PageSelfServiceWrite, PageSession, PageSessionWrite,
55};
56pub use error::PageError;
57
58use axum::Router;
59use axum::response::{Html, IntoResponse, Response};
60use axum::routing::{get, post};
61use serde_json::{Map, Value, json};
62
63use crate::webadmin::AdminState;
64use crate::webadmin::error::AdminError;
65use crate::webadmin::handlers::paging::Page;
66
67/// Everything under `/ui`, including the assets and the two unauthenticated
68/// sign-in routes.
69///
70/// Full paths rather than a `nest`: the admin listener's fallback is HTML (a
71/// browser-facing listener should answer a browser), and the JSON fallback the
72/// API needs is already scoped inside its own nest. One fewer nesting level is
73/// one fewer place for a path to be assembled twice.
74pub(crate) fn pages_router() -> Router<AdminState> {
75    Router::new()
76        // No session: the sign-in page, and the assets it needs to render.
77        .route(
78            "/ui/login",
79            get(session::get_login).post(session::post_login),
80        )
81        // The second half of signing in. It takes a `pending_mfa` cookie, which
82        // is the one thing `PageSession` refuses -- so it belongs here, above
83        // the line, and not below it.
84        .route(
85            "/ui/login/mfa",
86            get(session::get_login_mfa).post(session::post_login_mfa),
87        )
88        .route("/ui/static/{file}", get(assets::get_asset))
89        // Everything below needs one.
90        .route("/ui/", get(misc::get_index))
91        // The operator's own page. No id in any of these paths: there is
92        // exactly one account they can be about, and the session names it.
93        .route("/ui/account", get(account::get_account))
94        .route("/ui/account/mfa/totp", post(account::begin_totp))
95        .route("/ui/account/mfa/totp/confirm", post(account::confirm_totp))
96        .route("/ui/account/mfa/totp/disable", post(account::disable_totp))
97        .route(
98            "/ui/account/mfa/recovery-codes",
99            post(account::regenerate_recovery_codes),
100        )
101        .route("/ui/account/password", post(account::change_password))
102        .route("/ui/account/contact", post(account::change_contact))
103        .route(
104            "/ui/account/sessions/{id}/revoke",
105            post(account::revoke_own_session),
106        )
107        .route("/ui/logout", post(session::post_logout))
108        // The operators surface: every operator this process has, and acting
109        // on one *other* than the caller — see `pages::operators`. Every
110        // mutating route here sits behind `verify_current_password`, unlike the
111        // `/ui/account/*` routes just above.
112        .route("/ui/operators", get(operators::list_operators))
113        .route("/ui/operators/{username}", get(operators::get_operator))
114        .route(
115            "/ui/operators/{username}/disable",
116            post(operators::disable_operator),
117        )
118        .route(
119            "/ui/operators/{username}/enable",
120            post(operators::enable_operator),
121        )
122        .route(
123            "/ui/operators/{username}/totp/reset",
124            post(operators::reset_operator_totp),
125        )
126        .route(
127            "/ui/operators/{username}/contact",
128            post(operators::set_operator_contact),
129        )
130        .route(
131            "/ui/operators/{username}/role",
132            post(operators::set_operator_role),
133        )
134        .route(
135            "/ui/operators/{username}/sessions/{id}/revoke",
136            post(operators::revoke_operator_session),
137        )
138        .route("/ui/accounts", get(accounts::list_accounts))
139        .route(
140            "/ui/accounts/{id}",
141            get(accounts::get_account).delete(accounts::delete_account),
142        )
143        .route(
144            "/ui/accounts/{id}/orders",
145            get(accounts::list_account_orders),
146        )
147        .route(
148            "/ui/accounts/{id}/contact",
149            post(accounts::post_account_contact),
150        )
151        .route(
152            "/ui/accounts/{id}/deactivate",
153            post(accounts::deactivate_account),
154        )
155        // Read-only, and the only list here with no control in its rows.
156        .route("/ui/expiring", get(expiring::list_expiring))
157        .route("/ui/audit", get(audit::list_audit))
158        .route("/ui/audit/{id}", get(audit::get_audit))
159        .route("/ui/orders", get(orders::list_orders))
160        .route(
161            "/ui/orders/{id}",
162            get(orders::get_order).delete(orders::delete_order),
163        )
164        .route("/ui/orders/{id}/revoke", post(orders::revoke_order))
165        // A `GET`, so it is absent from `mutating_page_endpoints()` by right
166        // rather than by omission.
167        .route("/ui/orders/{id}/chain.pem", get(orders::download_chain))
168        .route("/ui/jobs", get(jobs::list_jobs))
169        .route("/ui/jobs/{id}", get(jobs::get_job))
170        .route("/ui/jobs/{id}/cancel", post(jobs::cancel_job))
171        .route("/ui/jobs/{id}/run", post(jobs::run_job))
172        // Read-only, absent from `mutating_page_endpoints()` by design.
173        .route(
174            "/ui/upstream-orders",
175            get(upstream_orders::list_upstream_orders),
176        )
177        .route(
178            "/ui/upstream-orders/{id}",
179            get(upstream_orders::get_upstream_order),
180        )
181        .route("/ui/eab", get(eab::list_eab).post(eab::create_eab))
182        .route("/ui/eab/{kid}", get(eab::get_eab).delete(eab::delete_eab))
183        .route("/ui/eab/{kid}/revoke", post(eab::revoke_eab))
184        .route("/ui/nonces", get(misc::get_nonces))
185        .route("/ui/nonces/cleanup", post(misc::cleanup_nonces))
186        .route("/ui/profiles", get(misc::list_profiles))
187        // Read-only: a policy is configuration, and `filter explain` -- the
188        // one that would need a form -- has no web surface at all. Absent from
189        // `mutating_page_endpoints()` accordingly.
190        .route(
191            "/ui/profiles/{name}/filter",
192            get(filter::get_profile_filter),
193        )
194}
195
196/// The context every full page needs on top of its own data.
197///
198/// `csrf_token` is the load-bearing member: `layout.html` puts it on `<body>`
199/// as an `hx-headers` attribute, and every mutating request htmx issues carries
200/// it back. A page rendered without it loses every write at once, which is the
201/// intended failure mode — a partial loss would be worse.
202pub(crate) fn chrome<S: PageAuth>(
203    session: &S,
204    nav: &'static str,
205    title: &str,
206) -> Map<String, Value> {
207    let mut context = fragment_context(session.auth());
208    context.insert("nav".to_string(), Value::String(nav.to_string()));
209    context.insert("title".to_string(), Value::String(title.to_string()));
210    context
211}
212
213/// The context every rendering for a signed-in caller starts from: the half of
214/// [`chrome`] that a mutation's fragment needs too.
215///
216/// `can_write` is what templates gate their controls on. Six of them used to
217/// spell it `not user is defined or user.role != "viewer"`, on the reasoning
218/// that a fragment rendered without `user` must be answering a mutation whose
219/// caller had already passed a write extractor. That held only while every
220/// fragment was assembled by hand the same way: a read path rendering one
221/// without `user` would have shown a `viewer` every control. Now the decision
222/// is computed once, from the role, and travels with every rendering — and a
223/// template that meets no `can_write` at all hides the control.
224///
225/// `csrf_token` rides along for the forms that carry it explicitly: a fragment
226/// rendered standalone is not yet inside the `<body>` whose `hx-headers` it
227/// would otherwise inherit.
228pub(crate) fn fragment_context(
229    auth: &crate::webadmin::session::Authenticated,
230) -> Map<String, Value> {
231    let mut context = Map::new();
232    context.insert(
233        "csrf_token".to_string(),
234        Value::String(auth.session.csrf_token.clone()),
235    );
236    context.insert(
237        "user".to_string(),
238        crate::admin::render_admin_user_json(&auth.user),
239    );
240    context.insert(
241        "can_write".to_string(),
242        Value::Bool(auth.user.role() >= acme_proxy_store::admin_user::AdminRole::Operator),
243    );
244    context
245}
246
247/// Renders `page` for a navigation and `fragment` for an htmx swap.
248///
249/// The single place the page/fragment choice is expressed, so a route cannot
250/// accidentally serve a bare partial to the address bar (a document with no
251/// `<html>`) or a whole document into a `<div>`.
252pub(crate) fn respond(
253    state: &AdminState,
254    hx: bool,
255    page: &str,
256    fragment: &str,
257    context: Map<String, Value>,
258) -> Result<Html<String>, PageError> {
259    let name = if hx { fragment } else { page };
260    templates::render(
261        &state.templates,
262        name,
263        minijinja::Value::from_serialize(Value::Object(context)),
264    )
265}
266
267/// Renders a fragment only — the answer to every mutation, which is always a
268/// swap and never a navigation.
269pub(crate) fn respond_fragment(
270    state: &AdminState,
271    fragment: &str,
272    context: Map<String, Value>,
273) -> Result<Html<String>, PageError> {
274    templates::render(
275        &state.templates,
276        fragment,
277        minijinja::Value::from_serialize(Value::Object(context)),
278    )
279}
280
281/// A banner for a partial to render: `kind` is `ok`, `error` or `warn`.
282#[must_use]
283pub(crate) fn flash(kind: &str, message: impl Into<String>) -> Value {
284    json!({ "kind": kind, "message": message.into() })
285}
286
287/// The banner form of a refusal that is worth showing rather than replacing the
288/// page with.
289///
290/// A `409 already_revoked` is the motivating case: the operator asked for
291/// something the row's state does not allow, the answer belongs next to the
292/// button they pressed, and the code is the same string the JSON API returns.
293#[must_use]
294pub(crate) fn flash_error(code: &str, message: impl Into<String>) -> Value {
295    json!({ "kind": "error", "message": message.into(), "code": code })
296}
297
298/// A refusal rendered as a card's own banner, keeping the refusal's status and
299/// its headers.
300///
301/// The shape four handlers had each written out for themselves: an
302/// [`AdminError`] the caller judged worth *showing beside* the control rather
303/// than replacing the page with (a wrong password on a step-up, a `409` on a
304/// row whose state moved), rendered into the same fragment the success path
305/// re-renders.
306///
307/// One place rather than four, and the reason is the thing three of them
308/// dropped: `AdminError::rate_limited` sets a `Retry-After` header, and a
309/// hand-rebuilt `(status, body)` tuple loses it. Building from
310/// `error.into_response()` and swapping the body keeps every header the error
311/// carries, whatever a later constructor adds.
312///
313/// A `401` is reworded: on this path it means "that password is not correct",
314/// not "sign in again" — the session making the request is perfectly live, and
315/// the API's own wording would read as a bounce.
316pub(crate) fn refuse_with_card(
317    state: &AdminState,
318    fragment: &str,
319    mut context: Map<String, Value>,
320    error: &AdminError,
321) -> Result<Response, PageError> {
322    let message = if error.status == axum::http::StatusCode::UNAUTHORIZED {
323        "That password is not correct.".to_string()
324    } else {
325        error.message.clone()
326    };
327    context.insert("flash".to_string(), flash_error(error.code, message));
328    let body = respond_fragment(state, fragment, context)?;
329
330    let mut response = error.clone().into_response();
331    *response.body_mut() = body.into_response().into_body();
332    response.headers_mut().insert(
333        axum::http::header::CONTENT_TYPE,
334        axum::http::HeaderValue::from_static("text/html; charset=utf-8"),
335    );
336    Ok(response)
337}
338
339/// The `{items, total}` half of a list context; the window itself is [`pager`].
340///
341/// Every paged fragment template reads `page.items` and the pager reads the
342/// window, so the two halves are built separately and land in the context under
343/// their own names.
344#[must_use]
345pub(crate) fn page_value(items: Vec<Value>, total: i64) -> Value {
346    json!({ "items": items, "total": total })
347}
348
349/// A closed vocabulary as the filter `<select>` offers it: every value of the
350/// enum the matching `?status=` is parsed against, so a status added there is
351/// offered here rather than being filterable only by a hand-typed URL.
352#[must_use]
353pub(crate) fn vocabulary<T: Copy>(all: &[T], name: impl Fn(T) -> &'static str) -> Value {
354    Value::Array(all.iter().map(|value| Value::from(name(*value))).collect())
355}
356
357/// The filters a list page is showing, stated once.
358///
359/// Each list handler used to spell its filter set three times — the
360/// `unwrap_or_default` locals, the slice handed to [`pager`] and the `filters`
361/// object the form echoes — and `/ui/audit` let two of the three drift: it
362/// filtered on `orderId`/`certSerial` and carried neither to the next page, so
363/// a deep link lost its filter on page two. One list, two views of it.
364#[derive(Debug, Default)]
365pub(crate) struct ListFilters(Vec<(&'static str, String)>);
366
367impl ListFilters {
368    #[must_use]
369    pub(crate) fn new() -> Self {
370        Self::default()
371    }
372
373    /// One filter under its query-string name; absent is the empty string, the
374    /// "any" option every control on this listener sends.
375    #[must_use]
376    pub(crate) fn with(mut self, key: &'static str, value: Option<&str>) -> Self {
377        self.0.push((key, value.unwrap_or_default().to_string()));
378        self
379    }
380
381    /// The `(key, value)` pairs [`pager`] carries forward, blank ones included
382    /// (it drops those itself).
383    #[must_use]
384    pub(crate) fn pairs(&self) -> Vec<(&str, &str)> {
385        self.0
386            .iter()
387            .map(|(key, value)| (*key, value.as_str()))
388            .collect()
389    }
390
391    /// The `filters` object a list template echoes into its form controls.
392    #[must_use]
393    pub(crate) fn to_value(&self) -> Value {
394        Value::Object(
395            self.0
396                .iter()
397                .map(|(key, value)| ((*key).to_string(), Value::String(value.clone())))
398                .collect(),
399        )
400    }
401}
402
403/// The offset-pagination controls for a list fragment.
404///
405/// Built here rather than in the template because the previous and next URLs
406/// have to carry whatever filters the list was already showing — assembling a
407/// query string is not a template's job, and getting it wrong silently drops a
408/// filter on the second page.
409pub(crate) fn pager(
410    page: Page,
411    total: i64,
412    path: &str,
413    filters: &[(&str, &str)],
414    target: &str,
415) -> Value {
416    let url = |offset: i64| {
417        let mut query = url::form_urlencoded::Serializer::new(String::new());
418        query.append_pair("limit", &page.limit.to_string());
419        query.append_pair("offset", &offset.to_string());
420        for (key, value) in filters {
421            if !value.is_empty() {
422                query.append_pair(key, value);
423            }
424        }
425        format!("{path}?{}", query.finish())
426    };
427
428    // Saturating throughout. `PageParams::resolve` already clamps `offset` so
429    // this cannot overflow from the query string, but `limit`'s ceiling is
430    // `admin.page_size_max`, an operator-set `i64` with no upper bound of its
431    // own — and a page control is not worth a panicking arithmetic path.
432    let next = page.offset.saturating_add(page.limit);
433    json!({
434        "total": total,
435        "limit": page.limit,
436        "offset": page.offset,
437        // Human numbering: "1–50 of 312", and an empty page says 0–0.
438        "from": if total == 0 { 0 } else { page.offset.saturating_add(1) },
439        "to": next.min(total).max(0),
440        "prev": (page.offset > 0).then(|| url(page.offset.saturating_sub(page.limit).max(0))),
441        "next": (next < total).then(|| url(next)),
442        "target": target,
443        // Whether a page step rewrites the address bar. On for every list that
444        // owns its page; off for a table embedded in another resource's page,
445        // whose URL belongs to that resource.
446        "push": true,
447    })
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    fn page(limit: i64, offset: i64) -> Page {
455        Page { limit, offset }
456    }
457
458    #[test]
459    fn the_first_page_of_several_offers_next_and_not_previous() {
460        let value = pager(page(50, 0), 312, "/ui/accounts", &[], "#accounts-table");
461        assert_eq!(value["from"], 1);
462        assert_eq!(value["to"], 50);
463        assert_eq!(value["total"], 312);
464        assert!(value["prev"].is_null());
465        assert_eq!(value["next"], "/ui/accounts?limit=50&offset=50");
466    }
467
468    #[test]
469    fn the_last_page_offers_previous_and_not_next() {
470        let value = pager(page(50, 300), 312, "/ui/accounts", &[], "#accounts-table");
471        assert_eq!(value["from"], 301);
472        // The final page is short, and the count must say so rather than
473        // running past the total.
474        assert_eq!(value["to"], 312);
475        assert_eq!(value["prev"], "/ui/accounts?limit=50&offset=250");
476        assert!(value["next"].is_null());
477    }
478
479    /// The bug this function exists to prevent: paging away from page one must
480    /// not drop the filter the operator was looking through.
481    #[test]
482    fn the_filters_survive_a_page_step_and_are_encoded() {
483        let value = pager(
484            page(10, 0),
485            40,
486            "/ui/orders",
487            &[("profile", "le"), ("status", ""), ("accountId", "a b&c")],
488            "#orders-table",
489        );
490        let next = value["next"].as_str().unwrap();
491        assert!(next.contains("profile=le"));
492        // An empty filter is absent, not `status=`.
493        assert!(!next.contains("status="));
494        assert!(next.contains("accountId=a+b%26c"));
495    }
496
497    #[test]
498    fn an_empty_result_set_renders_no_controls() {
499        let value = pager(page(50, 0), 0, "/ui/eab", &[], "#eab-table");
500        assert_eq!(value["total"], 0);
501        assert_eq!(value["from"], 0);
502        assert_eq!(value["to"], 0);
503        assert!(value["prev"].is_null());
504        assert!(value["next"].is_null());
505    }
506
507    /// `PageParams::resolve` clamps the offset, but `limit`'s own ceiling is
508    /// `admin.page_size_max`, an operator-set `i64`. Neither end may panic.
509    #[test]
510    fn an_extreme_window_saturates_instead_of_overflowing() {
511        let value = pager(page(i64::MAX, i64::MAX / 2), 10, "/ui/orders", &[], "#t");
512        assert_eq!(value["to"], 10);
513        assert!(value["next"].is_null());
514        assert!(value["prev"].is_string());
515    }
516
517    /// Both views come from the one list, so a filter the page echoes is a
518    /// filter the next page keeps — the `/ui/audit` regression, structurally.
519    #[test]
520    fn list_filters_echo_every_key_and_the_pager_carries_the_set_ones() {
521        let filters = ListFilters::new()
522            .with("certSerial", Some("0a0b"))
523            .with("orderId", None);
524
525        let echoed = filters.to_value();
526        assert_eq!(echoed["certSerial"], "0a0b");
527        // Absent is the empty string, which is what the form's "any" sends.
528        assert_eq!(echoed["orderId"], "");
529
530        let value = pager(page(1, 0), 5, "/ui/audit", &filters.pairs(), "#t");
531        let next = value["next"].as_str().unwrap();
532        assert!(next.contains("certSerial=0a0b"), "{next}");
533        assert!(!next.contains("orderId"), "{next}");
534        assert_eq!(value["push"], true);
535    }
536
537    #[test]
538    fn a_single_full_page_offers_neither_control() {
539        let value = pager(page(50, 0), 50, "/ui/accounts", &[], "#accounts-table");
540        assert!(value["prev"].is_null());
541        assert!(value["next"].is_null());
542    }
543
544    #[test]
545    fn flashes_carry_their_kind_and_a_code_only_when_there_was_one() {
546        let ok = flash("ok", "saved");
547        assert_eq!(ok["kind"], "ok");
548        assert!(ok.get("code").is_none());
549
550        let error = flash_error("already_revoked", "already revoked");
551        assert_eq!(error["kind"], "error");
552        assert_eq!(error["code"], "already_revoked");
553    }
554}