Add server template apply modes, bulk revocation, explicit DN order and detail tabs

This commit is contained in:
2026-10-23 05:33:43 +00:00
parent 5eb7866f5b
commit 763268bd40
13 changed files with 1477 additions and 633 deletions
+172 -11
View File
@@ -1,12 +1,16 @@
//! 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 {
@@ -17,6 +21,11 @@ pub fn CertificatesPage(vault_id: String) -> View {
let loading = create_signal(true);
let filter = create_signal(String::new());
let plain = create_signal(false);
let selected = create_signal(Vec::<String>::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::<String>);
spawn_local(async move {
match api::me().await {
@@ -50,6 +59,97 @@ pub fn CertificatesPage(vault_id: String) -> View {
}
});
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::<web_sys::HtmlInputElement>().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::<Vec<_>>();
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();
@@ -67,10 +167,11 @@ pub fn CertificatesPage(vault_id: String) -> View {
|| cert.fingerprint_sha256.to_lowercase().contains(&query)
})
.collect();
let chosen = selected.get_clone();
let html = if plain.get() {
flat_html(&visible, id)
flat_html(&visible, id, &chosen)
} else {
tree_html(&visible, id)
tree_html(&visible, id, &chosen)
};
view! {
(page_header(
@@ -90,7 +191,31 @@ pub fn CertificatesPage(vault_id: String) -> View {
bind:value=filter)
}
}
div(class="card", dangerously_set_inner_html=html) {}
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") { (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) {}
}
};
@@ -103,12 +228,13 @@ pub fn CertificatesPage(vault_id: String) -> View {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(crate::components::toast::toast(info, true))
(content)
}
}
}
fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
fn flat_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String {
if rows.is_empty() {
return "<p class=\"empty-state\">No certificates match.</p>".to_string();
}
@@ -116,7 +242,8 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
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>",
"<tr><td>{}</td><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>",
checkbox(cert, selected),
escape(vault_id),
escape(&cert.id),
escape(&cert.name),
@@ -132,11 +259,11 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
));
}
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>"
"<div class=\"table-responsive\"><table class=\"table table-hover align-middle mb-0\"><thead><tr><th></th><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 {
fn tree_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String {
if rows.is_empty() {
return "<p class=\"empty-state\">No certificates match.</p>".to_string();
}
@@ -157,7 +284,7 @@ fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String {
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(&node_html(root, &children, vault_id, selected));
}
html.push_str("</ul></div>");
html
@@ -167,6 +294,7 @@ fn node_html(
cert: &CertificateSummary,
children: &HashMap<String, Vec<CertificateSummary>>,
vault_id: &str,
selected: &[String],
) -> String {
let (badge_class, badge_label) = status_badge(cert);
let sub = if cert.subject == cert.issuer_name {
@@ -175,7 +303,8 @@ fn node_html(
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>",
"<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>",
checkbox(cert, selected),
escape(vault_id),
escape(&cert.id),
escape(&cert.name),
@@ -183,7 +312,7 @@ fn node_html(
badge_class,
badge_label,
escape(&sub),
fmt_date(&cert.not_after),
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);
@@ -191,7 +320,7 @@ fn node_html(
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>()
kids.iter().map(|child| node_html(child, children, vault_id, selected)).collect::<String>()
);
} else {
row = format!("<li>{row}</li>");
@@ -202,6 +331,38 @@ fn node_html(
row
}
fn checkbox(cert: &CertificateSummary, selected: &[String]) -> String {
if cert.issuer_id.is_none() {
return "<input class=\"form-check-input\" type=\"checkbox\" disabled title=\"Self-signed certificates cannot be revoked\">".to_string();
}
let checked = if selected.contains(&cert.id) {
" checked"
} else {
""
};
format!(
"<input class=\"form-check-input\" type=\"checkbox\" data-cert-id=\"{}\"{}>",
escape(&cert.id),
checked
)
}
fn reason_options() -> Vec<View> {
[
"unspecified",
"keyCompromise",
"CACompromise",
"affiliationChanged",
"superseded",
"cessationOfOperation",
"certificateHold",
"privilegeWithdrawn",
]
.iter()
.map(|reason| view! { option(value=*reason) { (*reason) } })
.collect::<Vec<View>>()
}
fn escape(value: &str) -> String {
value
.replace('&', "&amp;")