diff --git a/README.md b/README.md index 9869d66..434fa2d 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,7 @@ explicit/mandatory DN entries, default key type/size/curve, default validity). issuer chain. - Renew with a new or kept serial, optionally revoke/replace the old certificate. Revoke with a reason; manage the CA's revocation list manually, - including revocation dates and invalidity dates. Certificate lists support - bulk revocation with a shared reason and invalidity date. CA properties + including revocation dates and invalidity dates. CA properties (CRL days and default signing template, including built-ins) are editable from the CA detail page, and the wizard offers the CA default template explicitly without overwriting edits. diff --git a/e2e/tests/bulk.spec.js b/e2e/tests/bulk.spec.js deleted file mode 100644 index 10d32e8..0000000 --- a/e2e/tests/bulk.spec.js +++ /dev/null @@ -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); -}); diff --git a/e2e/tests/core.spec.js b/e2e/tests/core.spec.js index e14ff54..302e277 100644 --- a/e2e/tests/core.spec.js +++ b/e2e/tests/core.spec.js @@ -79,12 +79,18 @@ test("core PKI workflow", async ({ page }) => { // Certificate list links to the detail route in both views. const certDetailUrl = /\/vaults\/[^/]+\/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 expect(page).toHaveURL(certDetailUrl); await expect(page.getByRole("heading", { name: leafName })).toBeVisible(); await page.goto(`${vaultUrl}/certificates`); 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 expect(page).toHaveURL(certDetailUrl); await expect(page.getByRole("heading", { name: leafName })).toBeVisible(); diff --git a/web/src/api.rs b/web/src/api.rs index eae3d75..3d31082 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -297,11 +297,6 @@ pub async fn certificate(vault_id: &str, cert_id: &str) -> Result Result { - 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 { let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/preview"), Some(payload)).await?; check(response).await diff --git a/web/src/pages/certificates.rs b/web/src/pages/certificates.rs index 267d7fd..4a5a55a 100644 --- a/web/src/pages/certificates.rs +++ b/web/src/pages/certificates.rs @@ -1,16 +1,12 @@ //! 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 { @@ -21,11 +17,6 @@ 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::::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 { @@ -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::().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(); @@ -167,11 +67,10 @@ 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, &chosen) + flat_html(&visible, id) } else { - tree_html(&visible, id, &chosen) + tree_html(&visible, id) }; view! { (page_header( @@ -191,31 +90,7 @@ pub fn CertificatesPage(vault_id: String) -> View { 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) {} + div(class="card", dangerously_set_inner_html=html) {} } }; @@ -228,17 +103,20 @@ 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 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() { return "

No certificates match.

".to_string(); } @@ -246,8 +124,7 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) - 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), @@ -262,11 +139,11 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) - )); } format!( - "
{body}
SelectNameIssuerTypeSerialExpiresStatusSHA-256
" + "
{body}
NameIssuerTypeSerialExpiresStatusSHA-256
" ) } -fn tree_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String { +fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String { if rows.is_empty() { return "

No certificates match.

".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())); let mut html = String::from("
    "); 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("
"); html @@ -297,7 +174,6 @@ 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 { @@ -306,8 +182,7 @@ fn node_html( format!("issuer: {}", cert.issuer_name) }; let mut row = format!( - "
{}{}{} {}{}expires {}{}
", - checkbox(cert, selected), + "
{}{} {}{}expires {}{}
", certificate_href(vault_id, &cert.id), escape(&cert.name), if cert.is_ca { " CA" } else { "" }, @@ -322,7 +197,7 @@ fn node_html( if !kids.is_empty() { row = format!( "
  • {row}
      {}
  • ", - kids.iter().map(|child| node_html(child, children, vault_id, selected)).collect::() + kids.iter().map(|child| node_html(child, children, vault_id)).collect::() ); } else { row = format!("
  • {row}
  • "); @@ -333,39 +208,6 @@ fn node_html( 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('&', "&") @@ -390,12 +232,14 @@ mod tests { 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", &[]); + 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\"")); + 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/cert-1\"")); + assert!(!tree.contains("type=\"checkbox\"")); } }