//! Certificate list, flat or as an issuer tree. use std::collections::HashMap; use serde_json::json; use sycamore::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::spawn_local; use crate::api; use crate::components::nav::{app_nav, spinner, whoami}; use crate::components::ui::{page_header, toast_error}; use crate::dom; use crate::model::{fmt_date, fmt_fp, status_badge, CertificateSummary, Me}; pub fn CertificatesPage(vault_id: String) -> View { let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let me = create_signal(None::); let certificates = create_signal(Vec::::new()); let error = create_signal(None::); let loading = create_signal(true); let filter = create_signal(String::new()); let plain = create_signal(false); let selected = create_signal(Vec::::new()); let bulk_reason = create_signal("unspecified".to_string()); let bulk_invalidity = create_signal(String::new()); let bulk_busy = create_signal(false); let info = create_signal(None::); spawn_local(async move { match api::me().await { Ok(user) => { if me.is_alive() { me.set(Some(user)); } match api::certificates(id).await { Ok(page) => { if certificates.is_alive() { certificates.set(page.results); } } Err(message) if message != api::LOGIN_REQUIRED => { if error.is_alive() { error.set(Some(message)); } } _ => {} } } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => { if error.is_alive() { error.set(Some(message)); } } } if loading.is_alive() { loading.set(false); } }); let refresh = move || { spawn_local(async move { if let Ok(page) = api::certificates(id).await { if certificates.is_alive() { certificates.set(page.results); } } }); }; let toggle = move |event: web_sys::Event| { if let Some(target) = event .target() .and_then(|t| t.dyn_into::().ok()) { if let Some(cert_id) = target.get_attribute("data-cert-id") { let mut current = selected.get_clone(); if target.checked() { if !current.contains(&cert_id) { current.push(cert_id); } } else { current.retain(|value| value != &cert_id); } selected.set(current); } } }; let select_all = move |_| { let all = certificates .get_clone() .into_iter() .filter(|cert| cert.issuer_id.is_some()) .map(|cert| cert.id) .collect::>(); selected.set(all); }; let clear_selection = move |_| selected.set(Vec::new()); let bulk_revoke = move |_| { let ids = selected.get_clone(); if ids.is_empty() { error.set(Some("select at least one certificate".to_string())); return; } if !dom::confirm(&format!("Revoke {} selected certificate(s)?", ids.len())) { return; } let payload = json!({ "certificate_ids": ids, "reason": bulk_reason.get_clone(), "invalidity_date": if bulk_invalidity.get_clone().is_empty() { serde_json::Value::Null } else { json!(bulk_invalidity.get_clone()) }, }); bulk_busy.set(true); spawn_local(async move { match api::bulk_revoke_certificates(id, &payload).await { Ok(value) => { let revoked = value .get("revoked") .and_then(|v| v.as_array()) .map(|v| v.len()) .unwrap_or(0); let skipped = value .get("skipped") .and_then(|v| v.as_array()) .map(|v| v.len()) .unwrap_or(0); let errors = value .get("errors") .and_then(|v| v.as_array()) .map(|v| v.len()) .unwrap_or(0); info.set(Some(format!( "Revoked {revoked} certificate(s); {skipped} already revoked; {errors} error(s)." ))); selected.set(Vec::new()); bulk_invalidity.set(String::new()); refresh(); } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } if bulk_busy.is_alive() { bulk_busy.set(false); } }); }; let content = move || { if loading.get() { return spinner(); } let rows = certificates.get_clone(); let query = filter.get_clone().to_lowercase(); let visible: Vec = rows .into_iter() .filter(|cert| { query.is_empty() || cert.name.to_lowercase().contains(&query) || cert.subject.to_lowercase().contains(&query) || cert.issuer_name.to_lowercase().contains(&query) || cert.serial_hex.to_lowercase().contains(&query) || cert.fingerprint_sha256.to_lowercase().contains(&query) }) .collect(); let chosen = selected.get_clone(); let html = if plain.get() { flat_html(&visible, id, &chosen) } else { tree_html(&visible, id, &chosen) }; view! { (page_header( "Certificates", "The tree follows issuer links; use the plain view for a flat table.", view! { a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/new")) { "New certificate" } a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" } button(class="btn btn-outline-light", on:click=move |_| plain.set(!plain.get())) { (if plain.get() { "Tree view" } else { "Plain view" }) } }, )) div(class="card mb-3") { div(class="card-body py-2") { input(class="form-control", placeholder="Filter by name, subject, issuer, serial or fingerprint…", aria-label="Filter certificates", bind:value=filter) } } div(class="card mb-3") { div(class="card-body") { div(class="d-flex flex-wrap gap-2 align-items-end") { div(class="me-auto") { div(class="small text-secondary", aria-live="polite") { (format!("{} selected", selected.get_clone().len())) } div(class="d-flex gap-2 mt-1") { button(class="btn btn-sm btn-outline-light", on:click=select_all) { "Select all revocable" } button(class="btn btn-sm btn-outline-light", on:click=clear_selection) { "Clear" } } } div() { label(class="form-label small mb-1") { "Reason" } select(class="form-select form-select-sm", bind:value=bulk_reason) { (reason_options()) } } div() { label(class="form-label small mb-1") { "Invalid since (optional)" } input(class="form-control form-control-sm", r#type="datetime-local", bind:value=bulk_invalidity) } button(class="btn btn-warning", disabled=bulk_busy.get() || selected.get_clone().is_empty(), on:click=bulk_revoke) { (if bulk_busy.get() { "Revoking…" } else { "Revoke selected" }) } } } } div(class="card", on:change=toggle, dangerously_set_inner_html=html) {} } }; let nav = move || match me.get_clone() { Some(user) => whoami(&user), None => view! {}, }; view! { (app_nav("/", Some(id), nav)) main(class="container page-shell py-4") { (toast_error(error)) (crate::components::toast::toast(info, true)) (content) } } } fn certificate_href(vault_id: &str, cert_id: &str) -> String { format!("/vaults/{}/certificates/{}", escape(vault_id), escape(cert_id)) } fn flat_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String { if rows.is_empty() { return "

No certificates match.

".to_string(); } let mut body = String::new(); for cert in rows { let (badge_class, badge_label) = status_badge(cert); body.push_str(&format!( "{}{}
{}
{}{}{}{}{}{}{}", checkbox(cert, selected), certificate_href(vault_id, &cert.id), escape(&cert.name), escape(&cert.subject), escape(&cert.issuer_name), if cert.is_ca { "CA" } else { "end entity" }, escape(&cert.serial_hex), if cert.no_expiration { "No expiration".to_string() } else { fmt_date(&cert.not_after) }, badge_class, badge_label, if cert.has_private_key { " key" } else { "" }, fmt_fp(&cert.fingerprint_sha256), )); } format!( "
{body}
SelectNameIssuerTypeSerialExpiresStatusSHA-256
" ) } fn tree_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String { if rows.is_empty() { return "

No certificates match.

".to_string(); } let mut children: HashMap> = HashMap::new(); let mut roots: Vec = Vec::new(); let ids: Vec = rows.iter().map(|cert| cert.id.clone()).collect(); for cert in rows { match &cert.issuer_id { Some(issuer) if ids.contains(issuer) && issuer != &cert.id => { children .entry(issuer.clone()) .or_default() .push(cert.clone()); } _ => roots.push(cert.clone()), } } roots.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); let mut html = String::from("
    "); for root in &roots { html.push_str(&node_html(root, &children, vault_id, selected)); } html.push_str("
"); html } fn node_html( cert: &CertificateSummary, children: &HashMap>, vault_id: &str, selected: &[String], ) -> String { let (badge_class, badge_label) = status_badge(cert); let sub = if cert.subject == cert.issuer_name { "self-signed".to_string() } else { format!("issuer: {}", cert.issuer_name) }; let mut row = format!( "
{}{}{} {}{}expires {}{}
", checkbox(cert, selected), certificate_href(vault_id, &cert.id), escape(&cert.name), if cert.is_ca { " CA" } else { "" }, badge_class, badge_label, escape(&sub), if cert.no_expiration { "No expiration".to_string() } else { fmt_date(&cert.not_after) }, fmt_fp(&cert.fingerprint_sha256), ); let kids = children.get(&cert.id); if let Some(kids) = kids { if !kids.is_empty() { row = format!( "
  • {row}
      {}
  • ", kids.iter().map(|child| node_html(child, children, vault_id, selected)).collect::() ); } else { row = format!("
  • {row}
  • "); } } else { row = format!("
  • {row}
  • "); } row } fn checkbox(cert: &CertificateSummary, selected: &[String]) -> String { if cert.issuer_id.is_none() { return "".to_string(); } let checked = if selected.contains(&cert.id) { " checked" } else { "" }; format!( "", escape(&cert.id), escape(&cert.name), checked ) } fn reason_options() -> Vec { [ "unspecified", "keyCompromise", "CACompromise", "affiliationChanged", "superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn", ] .iter() .map(|reason| view! { option(value=*reason) { (*reason) } }) .collect::>() } fn escape(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } #[cfg(test)] mod tests { use super::*; fn cert(id: &str, name: &str) -> CertificateSummary { CertificateSummary { id: id.to_string(), name: name.to_string(), ..CertificateSummary::default() } } #[test] fn certificate_links_point_at_the_detail_route() { let cert = cert("cert-1", "Leaf"); let flat = flat_html(std::slice::from_ref(&cert), "vault-1", &[]); assert!(flat.contains("href=\"/vaults/vault-1/certificates/cert-1\"")); assert!(!flat.contains("href=\"/vaults/vault-1/cert-1\"")); let tree = tree_html(std::slice::from_ref(&cert), "vault-1", &[]); assert!(tree.contains("href=\"/vaults/vault-1/certificates/cert-1\"")); assert!(!tree.contains("href=\"/vaults/vault-1/cert-1\"")); } }