Remove bulk actions from certificate list

This commit is contained in:
Walter Oggioni authored and woggioni committed 2026-10-27 00:14:32 +08:00
1 parent 555f3f28f7
commit 73e6a11463
5 files changed
+26 -297

No files matched your search

+1 -2
View File
@@ -103,8 +103,7 @@ explicit/mandatory DN entries, default key type/size/curve, default validity).
issuer chain. issuer chain.
- Renew with a new or kept serial, optionally revoke/replace the old - Renew with a new or kept serial, optionally revoke/replace the old
certificate. Revoke with a reason; manage the CA's revocation list manually, certificate. Revoke with a reason; manage the CA's revocation list manually,
including revocation dates and invalidity dates. Certificate lists support including revocation dates and invalidity dates. CA properties
bulk revocation with a shared reason and invalidity date. CA properties
(CRL days and default signing template, including built-ins) are editable (CRL days and default signing template, including built-ins) are editable
from the CA detail page, and the wizard offers the CA default template from the CA detail page, and the wizard offers the CA default template
explicitly without overwriting edits. explicitly without overwriting edits.
-115
View File
@@ -1,115 +0,0 @@
// Bulk revocation through the certificate list.
const { test, expect } = require("@playwright/test");
function unique(prefix) {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
async function login(page, username) {
await page.goto("/login");
await page.getByRole("link", { name: "Sign in with default" }).click();
await page.getByPlaceholder("Enter any user/subject").fill(username);
await page.getByRole("button", { name: "Sign-in" }).click();
await expect(page.getByRole("heading", { name: "Vaults" })).toBeVisible();
}
async function api(page, method, url, body) {
return page.evaluate(
async ({ method, url, body }) => {
const response = await fetch(url, {
method,
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
return { status: response.status, json: text ? JSON.parse(text) : null };
},
{ method, url, body }
);
}
test("bulk revocation from the certificate list", async ({ page }) => {
const user = unique("bulk");
const vaultName = unique("Bulk Vault");
await login(page, user);
const vault = await api(page, "POST", "/api/vaults", { name: vaultName });
expect(vault.status).toBe(201);
const vaultId = vault.json.id;
const caKey = await api(page, "POST", `/api/vaults/${vaultId}/keys`, {
name: "Bulk CA key",
key_type: "rsa",
size: 2048,
});
expect(caKey.status).toBe(201);
const now = new Date();
const later = new Date(now.getTime() + 365 * 24 * 3600 * 1000);
const ca = await api(page, "POST", `/api/vaults/${vaultId}/certificates`, {
name: "Bulk CA",
key_id: caKey.json.id,
subject: [
{ name: "C", value: "DE" },
{ name: "O", value: "KCA E2E" },
{ name: "CN", value: "Bulk CA" },
],
not_before: now.toISOString(),
not_after: later.toISOString(),
extensions: {
basic_constraints: { critical: true, ca: true, path_length: null },
key_usage: { critical: true, usages: ["key_cert_sign", "crl_sign"] },
subject_key_identifier: { critical: false },
authority_key_identifier: { critical: false },
},
});
expect(ca.status).toBe(201);
const caId = ca.json.id;
async function leaf(name) {
const key = await api(page, "POST", `/api/vaults/${vaultId}/keys`, {
name: `${name} key`,
key_type: "rsa",
size: 2048,
});
expect(key.status).toBe(201);
const cert = await api(page, "POST", `/api/vaults/${vaultId}/certificates`, {
name,
key_id: key.json.id,
signer_id: caId,
subject: [
{ name: "C", value: "DE" },
{ name: "O", value: "KCA E2E" },
{ name: "CN", value: name },
],
not_before: now.toISOString(),
not_after: later.toISOString(),
extensions: {
basic_constraints: { critical: true, ca: false, path_length: null },
key_usage: { critical: true, usages: ["digital_signature"] },
subject_alt_name: { critical: false, entries: [{ type: "dns", value: name }] },
subject_key_identifier: { critical: false },
authority_key_identifier: { critical: false },
},
});
expect(cert.status).toBe(201);
return cert.json;
}
await leaf("bulk-one.example");
await leaf("bulk-two.example");
await page.goto(`/vaults/${vaultId}/certificates`);
await expect(page.getByRole("heading", { name: "Certificates" })).toBeVisible();
await page.evaluate(() => {
window.confirm = () => true;
});
await page.getByRole("button", { name: "Select all revocable" }).click();
await expect(page.getByText("2 selected")).toBeVisible();
await page.locator("select").first().selectOption("keyCompromise");
await page.getByRole("button", { name: "Revoke selected" }).click();
await expect(page.getByText(/Revoked 2 certificate\(s\); 0 already revoked; 0 error\(s\)/)).toBeVisible();
const revocations = await api(page, "GET", `/api/vaults/${vaultId}/certificates`);
const revoked = revocations.json.results.filter((cert) => cert.revoked);
expect(revoked).toHaveLength(2);
});
+6
View File
@@ -79,12 +79,18 @@ test("core PKI workflow", async ({ page }) => {
// Certificate list links to the detail route in both views. // Certificate list links to the detail route in both views.
const certDetailUrl = /\/vaults\/[^/]+\/certificates\/[^/]+$/; const certDetailUrl = /\/vaults\/[^/]+\/certificates\/[^/]+$/;
await page.goto(`${vaultUrl}/certificates`); await page.goto(`${vaultUrl}/certificates`);
await expect(page.getByRole("button", { name: "Select all revocable" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Revoke selected" })).toHaveCount(0);
await expect(page.locator("input[data-cert-id]")).toHaveCount(0);
await page.getByRole("link", { name: leafName, exact: true }).click(); await page.getByRole("link", { name: leafName, exact: true }).click();
await expect(page).toHaveURL(certDetailUrl); await expect(page).toHaveURL(certDetailUrl);
await expect(page.getByRole("heading", { name: leafName })).toBeVisible(); await expect(page.getByRole("heading", { name: leafName })).toBeVisible();
await page.goto(`${vaultUrl}/certificates`); await page.goto(`${vaultUrl}/certificates`);
await page.getByRole("button", { name: "Plain view" }).click(); await page.getByRole("button", { name: "Plain view" }).click();
await expect(page.getByRole("button", { name: "Select all revocable" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Revoke selected" })).toHaveCount(0);
await expect(page.locator("input[data-cert-id]")).toHaveCount(0);
await page.getByRole("link", { name: leafName, exact: true }).click(); await page.getByRole("link", { name: leafName, exact: true }).click();
await expect(page).toHaveURL(certDetailUrl); await expect(page).toHaveURL(certDetailUrl);
await expect(page.getByRole("heading", { name: leafName })).toBeVisible(); await expect(page.getByRole("heading", { name: leafName })).toBeVisible();
-5
View File
@@ -297,11 +297,6 @@ pub async fn certificate(vault_id: &str, cert_id: &str) -> Result<CertificateDet
check(response).await check(response).await
} }
pub async fn bulk_revoke_certificates(vault_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/bulk-revoke"), Some(payload)).await?;
check(response).await
}
pub async fn preview_certificate(vault_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> { pub async fn preview_certificate(vault_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/preview"), Some(payload)).await?; let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/preview"), Some(payload)).await?;
check(response).await check(response).await
+19 -175
View File
@@ -1,16 +1,12 @@
//! Certificate list, flat or as an issuer tree. //! Certificate list, flat or as an issuer tree.
use std::collections::HashMap; use std::collections::HashMap;
use serde_json::json;
use sycamore::prelude::*; use sycamore::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use crate::api; use crate::api;
use crate::components::nav::{app_nav, spinner, whoami}; use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{page_header, toast_error}; use crate::components::ui::{page_header, toast_error};
use crate::dom;
use crate::model::{fmt_date, fmt_fp, status_badge, CertificateSummary, Me}; use crate::model::{fmt_date, fmt_fp, status_badge, CertificateSummary, Me};
pub fn CertificatesPage(vault_id: String) -> View { pub fn CertificatesPage(vault_id: String) -> View {
@@ -21,11 +17,6 @@ pub fn CertificatesPage(vault_id: String) -> View {
let loading = create_signal(true); let loading = create_signal(true);
let filter = create_signal(String::new()); let filter = create_signal(String::new());
let plain = create_signal(false); 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 { spawn_local(async move {
match api::me().await { match api::me().await {
@@ -59,97 +50,6 @@ 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 || { let content = move || {
if loading.get() { if loading.get() {
return spinner(); return spinner();
@@ -167,11 +67,10 @@ pub fn CertificatesPage(vault_id: String) -> View {
|| cert.fingerprint_sha256.to_lowercase().contains(&query) || cert.fingerprint_sha256.to_lowercase().contains(&query)
}) })
.collect(); .collect();
let chosen = selected.get_clone();
let html = if plain.get() { let html = if plain.get() {
flat_html(&visible, id, &chosen) flat_html(&visible, id)
} else { } else {
tree_html(&visible, id, &chosen) tree_html(&visible, id)
}; };
view! { view! {
(page_header( (page_header(
@@ -191,31 +90,7 @@ pub fn CertificatesPage(vault_id: String) -> View {
aria-label="Filter certificates", bind:value=filter) aria-label="Filter certificates", bind:value=filter)
} }
} }
div(class="card mb-3") { div(class="card", dangerously_set_inner_html=html) {}
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) {}
} }
}; };
@@ -228,17 +103,20 @@ pub fn CertificatesPage(vault_id: String) -> View {
(app_nav("/", Some(id), nav)) (app_nav("/", Some(id), nav))
main(class="container page-shell py-4") { main(class="container page-shell py-4") {
(toast_error(error)) (toast_error(error))
(crate::components::toast::toast(info, true))
(content) (content)
} }
} }
} }
fn certificate_href(vault_id: &str, cert_id: &str) -> String { fn certificate_href(vault_id: &str, cert_id: &str) -> String {
format!("/vaults/{}/certificates/{}", escape(vault_id), escape(cert_id)) format!(
"/vaults/{}/certificates/{}",
escape(vault_id),
escape(cert_id)
)
} }
fn flat_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String { fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
if rows.is_empty() { if rows.is_empty() {
return "<p class=\"empty-state\">No certificates match.</p>".to_string(); return "<p class=\"empty-state\">No certificates match.</p>".to_string();
} }
@@ -246,8 +124,7 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -
for cert in rows { for cert in rows {
let (badge_class, badge_label) = status_badge(cert); let (badge_class, badge_label) = status_badge(cert);
body.push_str(&format!( body.push_str(&format!(
"<tr><td>{}</td><td><a href=\"{}\">{}</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><a href=\"{}\">{}</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),
certificate_href(vault_id, &cert.id), certificate_href(vault_id, &cert.id),
escape(&cert.name), escape(&cert.name),
escape(&cert.subject), escape(&cert.subject),
@@ -262,11 +139,11 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -
)); ));
} }
format!( format!(
"<div class=\"table-responsive\"><table class=\"table table-hover align-middle mb-0\"><thead><tr><th scope=\"col\"><span class=\"visually-hidden\">Select</span></th><th scope=\"col\">Name</th><th scope=\"col\">Issuer</th><th scope=\"col\">Type</th><th scope=\"col\">Serial</th><th scope=\"col\">Expires</th><th scope=\"col\">Status</th><th scope=\"col\">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 scope=\"col\">Name</th><th scope=\"col\">Issuer</th><th scope=\"col\">Type</th><th scope=\"col\">Serial</th><th scope=\"col\">Expires</th><th scope=\"col\">Status</th><th scope=\"col\">SHA-256</th></tr></thead><tbody>{body}</tbody></table></div>"
) )
} }
fn tree_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String { fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String {
if rows.is_empty() { if rows.is_empty() {
return "<p class=\"empty-state\">No certificates match.</p>".to_string(); return "<p class=\"empty-state\">No certificates match.</p>".to_string();
} }
@@ -287,7 +164,7 @@ fn tree_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -
roots.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); 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\">"); let mut html = String::from("<div class=\"card-body\"><ul class=\"cert-tree\">");
for root in &roots { for root in &roots {
html.push_str(&node_html(root, &children, vault_id, selected)); html.push_str(&node_html(root, &children, vault_id));
} }
html.push_str("</ul></div>"); html.push_str("</ul></div>");
html html
@@ -297,7 +174,6 @@ fn node_html(
cert: &CertificateSummary, cert: &CertificateSummary,
children: &HashMap<String, Vec<CertificateSummary>>, children: &HashMap<String, Vec<CertificateSummary>>,
vault_id: &str, vault_id: &str,
selected: &[String],
) -> String { ) -> String {
let (badge_class, badge_label) = status_badge(cert); let (badge_class, badge_label) = status_badge(cert);
let sub = if cert.subject == cert.issuer_name { let sub = if cert.subject == cert.issuer_name {
@@ -306,8 +182,7 @@ fn node_html(
format!("issuer: {}", cert.issuer_name) format!("issuer: {}", cert.issuer_name)
}; };
let mut row = format!( let mut row = format!(
"<div class=\"cert-row\">{}<a class=\"item-name\" href=\"{}\">{}</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=\"{}\">{}</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),
certificate_href(vault_id, &cert.id), certificate_href(vault_id, &cert.id),
escape(&cert.name), escape(&cert.name),
if cert.is_ca { " <span class=\"badge badge-ca\">CA</span>" } else { "" }, if cert.is_ca { " <span class=\"badge badge-ca\">CA</span>" } else { "" },
@@ -322,7 +197,7 @@ fn node_html(
if !kids.is_empty() { if !kids.is_empty() {
row = format!( row = format!(
"<li><details class=\"cert-node\" open><summary>{row}</summary><ul class=\"cert-tree\">{}</ul></details></li>", "<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, selected)).collect::<String>() kids.iter().map(|child| node_html(child, children, vault_id)).collect::<String>()
); );
} else { } else {
row = format!("<li>{row}</li>"); row = format!("<li>{row}</li>");
@@ -333,39 +208,6 @@ fn node_html(
row row
} }
fn checkbox(cert: &CertificateSummary, selected: &[String]) -> String {
if cert.issuer_id.is_none() {
return "<input class=\"form-check-input\" type=\"checkbox\" disabled aria-label=\"Self-signed certificates cannot be revoked\" 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=\"{}\" aria-label=\"Select {} for revocation\"{}>",
escape(&cert.id),
escape(&cert.name),
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 { fn escape(value: &str) -> String {
value value
.replace('&', "&amp;") .replace('&', "&amp;")
@@ -390,12 +232,14 @@ mod tests {
fn certificate_links_point_at_the_detail_route() { fn certificate_links_point_at_the_detail_route() {
let cert = cert("cert-1", "Leaf"); let cert = cert("cert-1", "Leaf");
let flat = flat_html(std::slice::from_ref(&cert), "vault-1", &[]); 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/certificates/cert-1\""));
assert!(!flat.contains("href=\"/vaults/vault-1/cert-1\"")); assert!(!flat.contains("href=\"/vaults/vault-1/cert-1\""));
assert!(!flat.contains("type=\"checkbox\""));
let tree = tree_html(std::slice::from_ref(&cert), "vault-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/certificates/cert-1\""));
assert!(!tree.contains("href=\"/vaults/vault-1/cert-1\"")); assert!(!tree.contains("href=\"/vaults/vault-1/cert-1\""));
assert!(!tree.contains("type=\"checkbox\""));
} }
} }