//! Certificate list, flat or as an issuer tree. use std::collections::HashMap; use sycamore::prelude::*; 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::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); 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 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 html = if plain.get() { flat_html(&visible, id) } else { tree_html(&visible, id) }; 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…", bind:value=filter) } } div(class="card", 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)) (content) } } } fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> 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!( "{}
{}
{}{}{}{}{}{}{}", escape(vault_id), escape(&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}
NameIssuerTypeSerialExpiresStatusSHA-256
" ) } fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> 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)); } html.push_str("
"); html } fn node_html( cert: &CertificateSummary, children: &HashMap>, vault_id: &str, ) -> 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 {}{}
", escape(vault_id), escape(&cert.id), escape(&cert.name), if cert.is_ca { " CA" } else { "" }, badge_class, badge_label, escape(&sub), 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)).collect::() ); } else { row = format!("
  • {row}
  • "); } } else { row = format!("
  • {row}
  • "); } row } fn escape(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) }