pub mod account;
pub mod accounts;
pub mod assets;
pub mod audit;
pub mod auth;
pub mod eab;
pub mod error;
pub mod misc;
pub mod orders;
pub mod session;
pub mod templates;
pub use auth::{PageSession, PageSessionWrite};
pub use error::PageError;
use axum::Router;
use axum::response::Html;
use axum::routing::{get, post};
use serde_json::{Map, Value, json};
use crate::webadmin::AdminState;
use crate::webadmin::handlers::paging::Page;
pub(crate) fn pages_router() -> Router<AdminState> {
Router::new()
.route(
"/ui/login",
get(session::get_login).post(session::post_login),
)
.route(
"/ui/login/mfa",
get(session::get_login_mfa).post(session::post_login_mfa),
)
.route("/ui/static/{file}", get(assets::get_asset))
.route("/ui/", get(misc::get_index))
.route("/ui/account", get(account::get_account))
.route("/ui/account/mfa/totp", post(account::begin_totp))
.route("/ui/account/mfa/totp/confirm", post(account::confirm_totp))
.route("/ui/account/mfa/totp/disable", post(account::disable_totp))
.route(
"/ui/account/mfa/recovery-codes",
post(account::regenerate_recovery_codes),
)
.route("/ui/logout", post(session::post_logout))
.route("/ui/accounts", get(accounts::list_accounts))
.route(
"/ui/accounts/{id}",
get(accounts::get_account).delete(accounts::delete_account),
)
.route(
"/ui/accounts/{id}/contact",
post(accounts::post_account_contact),
)
.route(
"/ui/accounts/{id}/deactivate",
post(accounts::deactivate_account),
)
.route("/ui/audit", get(audit::list_audit))
.route("/ui/audit/{id}", get(audit::get_audit))
.route("/ui/orders", get(orders::list_orders))
.route(
"/ui/orders/{id}",
get(orders::get_order).delete(orders::delete_order),
)
.route("/ui/orders/{id}/revoke", post(orders::revoke_order))
.route("/ui/orders/{id}/chain.pem", get(orders::download_chain))
.route("/ui/eab", get(eab::list_eab).post(eab::create_eab))
.route("/ui/eab/{kid}", get(eab::get_eab))
.route("/ui/eab/{kid}/revoke", post(eab::revoke_eab))
.route("/ui/nonces", get(misc::get_nonces))
.route("/ui/nonces/cleanup", post(misc::cleanup_nonces))
.route("/ui/profiles", get(misc::list_profiles))
}
pub(crate) fn chrome(session: &PageSession, nav: &'static str, title: &str) -> Map<String, Value> {
let mut context = Map::new();
context.insert(
"csrf_token".to_string(),
Value::String(session.auth.session.csrf_token.clone()),
);
context.insert(
"user".to_string(),
crate::admin::render_admin_user_json(&session.auth.user),
);
context.insert("nav".to_string(), Value::String(nav.to_string()));
context.insert("title".to_string(), Value::String(title.to_string()));
context
}
pub(crate) fn respond(
state: &AdminState,
hx: bool,
page: &str,
fragment: &str,
context: Map<String, Value>,
) -> Result<Html<String>, PageError> {
let name = if hx { fragment } else { page };
templates::render(
&state.templates,
name,
minijinja::Value::from_serialize(Value::Object(context)),
)
}
pub(crate) fn respond_fragment(
state: &AdminState,
fragment: &str,
context: Map<String, Value>,
) -> Result<Html<String>, PageError> {
templates::render(
&state.templates,
fragment,
minijinja::Value::from_serialize(Value::Object(context)),
)
}
#[must_use]
pub(crate) fn flash(kind: &str, message: impl Into<String>) -> Value {
json!({ "kind": kind, "message": message.into() })
}
#[must_use]
pub(crate) fn flash_error(code: &str, message: impl Into<String>) -> Value {
json!({ "kind": "error", "message": message.into(), "code": code })
}
pub(crate) fn pager(
page: Page,
total: i64,
path: &str,
filters: &[(&str, &str)],
target: &str,
) -> Value {
let url = |offset: i64| {
let mut query = url::form_urlencoded::Serializer::new(String::new());
query.append_pair("limit", &page.limit.to_string());
query.append_pair("offset", &offset.to_string());
for (key, value) in filters {
if !value.is_empty() {
query.append_pair(key, value);
}
}
format!("{path}?{}", query.finish())
};
let next = page.offset.saturating_add(page.limit);
json!({
"total": total,
"limit": page.limit,
"offset": page.offset,
"from": if total == 0 { 0 } else { page.offset.saturating_add(1) },
"to": next.min(total).max(0),
"prev": (page.offset > 0).then(|| url(page.offset.saturating_sub(page.limit).max(0))),
"next": (next < total).then(|| url(next)),
"target": target,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn page(limit: i64, offset: i64) -> Page {
Page { limit, offset }
}
#[test]
fn the_first_page_of_several_offers_next_and_not_previous() {
let value = pager(page(50, 0), 312, "/ui/accounts", &[], "#accounts-table");
assert_eq!(value["from"], 1);
assert_eq!(value["to"], 50);
assert_eq!(value["total"], 312);
assert!(value["prev"].is_null());
assert_eq!(value["next"], "/ui/accounts?limit=50&offset=50");
}
#[test]
fn the_last_page_offers_previous_and_not_next() {
let value = pager(page(50, 300), 312, "/ui/accounts", &[], "#accounts-table");
assert_eq!(value["from"], 301);
assert_eq!(value["to"], 312);
assert_eq!(value["prev"], "/ui/accounts?limit=50&offset=250");
assert!(value["next"].is_null());
}
#[test]
fn the_filters_survive_a_page_step_and_are_encoded() {
let value = pager(
page(10, 0),
40,
"/ui/orders",
&[("profile", "le"), ("status", ""), ("accountId", "a b&c")],
"#orders-table",
);
let next = value["next"].as_str().unwrap();
assert!(next.contains("profile=le"));
assert!(!next.contains("status="));
assert!(next.contains("accountId=a+b%26c"));
}
#[test]
fn an_empty_result_set_renders_no_controls() {
let value = pager(page(50, 0), 0, "/ui/eab", &[], "#eab-table");
assert_eq!(value["total"], 0);
assert_eq!(value["from"], 0);
assert_eq!(value["to"], 0);
assert!(value["prev"].is_null());
assert!(value["next"].is_null());
}
#[test]
fn an_extreme_window_saturates_instead_of_overflowing() {
let value = pager(page(i64::MAX, i64::MAX / 2), 10, "/ui/orders", &[], "#t");
assert_eq!(value["to"], 10);
assert!(value["next"].is_null());
assert!(value["prev"].is_string());
}
#[test]
fn a_single_full_page_offers_neither_control() {
let value = pager(page(50, 0), 50, "/ui/accounts", &[], "#accounts-table");
assert!(value["prev"].is_null());
assert!(value["next"].is_null());
}
#[test]
fn flashes_carry_their_kind_and_a_code_only_when_there_was_one() {
let ok = flash("ok", "saved");
assert_eq!(ok["kind"], "ok");
assert!(ok.get("code").is_none());
let error = flash_error("already_revoked", "already revoked");
assert_eq!(error["kind"], "error");
assert_eq!(error["code"], "already_revoked");
}
}