Implement KCA: web reimplementation of XCA PKI workflows
- kaya backend with OIDC sessions, API tokens, Postgres/Tortoise models - AES-256-GCM private key storage and full certificate/CSR/CRL domain logic - Sycamore/WASM SPA for vaults, keys, requests, certificates, CRLs and templates - imports/exports, XCA .xdb migration, Docker Compose stack and tests
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
//! 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::<Me>);
|
||||
let certificates = create_signal(Vec::<CertificateSummary>::new());
|
||||
let error = create_signal(None::<String>);
|
||||
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<CertificateSummary> = 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 "<p class=\"empty-state\">No certificates match.</p>".to_string();
|
||||
}
|
||||
let mut body = String::new();
|
||||
for cert in rows {
|
||||
let (badge_class, badge_label) = status_badge(cert);
|
||||
body.push_str(&format!(
|
||||
"<tr><td><a href=\"/vaults/{}/{}\">{}</a><div class=\"item-sub\">{}</div></td><td>{}</td><td>{}</td><td class=\"fingerprint\">{}</td><td>{}</td><td><span class=\"badge {}\">{}</span>{}</td><td class=\"fingerprint\">{}</td></tr>",
|
||||
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),
|
||||
fmt_date(&cert.not_after),
|
||||
badge_class,
|
||||
badge_label,
|
||||
if cert.has_private_key { " <span class=\"badge badge-soft\">key</span>" } else { "" },
|
||||
fmt_fp(&cert.fingerprint_sha256),
|
||||
));
|
||||
}
|
||||
format!(
|
||||
"<div class=\"table-responsive\"><table class=\"table table-hover align-middle mb-0\"><thead><tr><th>Name</th><th>Issuer</th><th>Type</th><th>Serial</th><th>Expires</th><th>Status</th><th>SHA-256</th></tr></thead><tbody>{body}</tbody></table></div>"
|
||||
)
|
||||
}
|
||||
|
||||
fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String {
|
||||
if rows.is_empty() {
|
||||
return "<p class=\"empty-state\">No certificates match.</p>".to_string();
|
||||
}
|
||||
let mut children: HashMap<String, Vec<CertificateSummary>> = HashMap::new();
|
||||
let mut roots: Vec<CertificateSummary> = Vec::new();
|
||||
let ids: Vec<String> = 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("<div class=\"card-body\"><ul class=\"cert-tree\">");
|
||||
for root in &roots {
|
||||
html.push_str(&node_html(root, &children, vault_id));
|
||||
}
|
||||
html.push_str("</ul></div>");
|
||||
html
|
||||
}
|
||||
|
||||
fn node_html(
|
||||
cert: &CertificateSummary,
|
||||
children: &HashMap<String, Vec<CertificateSummary>>,
|
||||
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!(
|
||||
"<div class=\"cert-row\"><a class=\"item-name\" href=\"/vaults/{}/{}\">{}</a>{} <span class=\"badge {}\">{}</span><span class=\"item-sub\">{}</span><span class=\"item-sub\">expires {}</span><span class=\"item-sub fingerprint\">{}</span></div>",
|
||||
escape(vault_id),
|
||||
escape(&cert.id),
|
||||
escape(&cert.name),
|
||||
if cert.is_ca { " <span class=\"badge badge-ca\">CA</span>" } 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!(
|
||||
"<li><details class=\"cert-node\" open><summary>{row}</summary><ul class=\"cert-tree\">{}</ul></details></li>",
|
||||
kids.iter().map(|child| node_html(child, children, vault_id)).collect::<String>()
|
||||
);
|
||||
} else {
|
||||
row = format!("<li>{row}</li>");
|
||||
}
|
||||
} else {
|
||||
row = format!("<li>{row}</li>");
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
fn escape(value: &str) -> String {
|
||||
value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
Reference in New Issue
Block a user