1pub 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
67pub(crate) fn pages_router() -> Router<AdminState> {
75 Router::new()
76 .route(
78 "/ui/login",
79 get(session::get_login).post(session::post_login),
80 )
81 .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 .route("/ui/", get(misc::get_index))
91 .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 .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 .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 .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 .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 .route(
191 "/ui/profiles/{name}/filter",
192 get(filter::get_profile_filter),
193 )
194}
195
196pub(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
213pub(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
247pub(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
267pub(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#[must_use]
283pub(crate) fn flash(kind: &str, message: impl Into<String>) -> Value {
284 json!({ "kind": kind, "message": message.into() })
285}
286
287#[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
298pub(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#[must_use]
345pub(crate) fn page_value(items: Vec<Value>, total: i64) -> Value {
346 json!({ "items": items, "total": total })
347}
348
349#[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#[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 #[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 #[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 #[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
403pub(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 let next = page.offset.saturating_add(page.limit);
433 json!({
434 "total": total,
435 "limit": page.limit,
436 "offset": page.offset,
437 "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 "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 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 #[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 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 #[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 #[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 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}