diff --git a/README.md b/README.md index 434fa2d..45895af 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,9 @@ explicit/mandatory DN entries, default key type/size/curve, default validity). advanced extensions plus an advanced JSON editor for raw/less common extensions, and a server-side **Validate extensions** action. OpenSSL config text is available on export. -- Certificate details are split into Overview, Extensions, Validation, Export, - Metadata, Revoke, Transform and CA tabs, with hash deep links. They show +- Certificate details are split into Overview, Extensions, Validation, + Metadata, Transform and CA tabs, with hash deep links. Header actions provide + export, renewal, revocation and deletion. They show subject/issuer, serial, validity, signatures, MD5/SHA-1/SHA-256 fingerprints, parsed extensions, OpenSSL validation errors, purposes and the issuer chain. diff --git a/e2e/tests/core.spec.js b/e2e/tests/core.spec.js index 302e277..1a23381 100644 --- a/e2e/tests/core.spec.js +++ b/e2e/tests/core.spec.js @@ -101,15 +101,44 @@ test("core PKI workflow", async ({ page }) => { await page.getByRole("button", { name: "Search" }).click(); await expect(page.getByRole("link", { name: leafName, exact: true })).toBeVisible(); - // Revoke + // Certificate header actions replace the Export and Revoke tabs. await page.getByRole("link", { name: leafName, exact: true }).click(); - await page.locator(".nav-tabs").getByRole("tab", { name: "Revoke", exact: true }).click(); - await page.getByRole("button", { name: "Revoke certificate" }).click(); + const certificateTabs = page.locator(".nav-tabs"); + await expect(certificateTabs.getByRole("tab", { name: "Export", exact: true })).toHaveCount(0); + await expect(certificateTabs.getByRole("tab", { name: "Revoke", exact: true })).toHaveCount(0); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export", exact: true }).click(); + const exportDialog = page.getByRole("dialog", { name: "Export certificate", exact: true }); + await expect(exportDialog).toBeVisible(); + await exportDialog.getByLabel("Format", { exact: true }).selectOption("pem"); + await exportDialog.getByRole("button", { name: "Download", exact: true }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${leafName}.crt`); + await expect(exportDialog).toHaveCount(0); + + await page.getByRole("button", { name: "Actions", exact: true }).click(); + await expect(page.getByRole("menuitem", { name: "Renew", exact: true })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Delete", exact: true })).toBeVisible(); + const revokeAction = page.getByRole("menuitem", { name: "Revoke", exact: true }); + await expect(revokeAction).toBeEnabled(); + await revokeAction.click(); + const revokeDialog = page.getByRole("dialog", { name: "Revoke certificate", exact: true }); + await expect(revokeDialog).toBeVisible(); + await revokeDialog.getByLabel("Reason", { exact: true }).selectOption("keyCompromise"); + await revokeDialog.getByRole("button", { name: "Revoke certificate", exact: true }).click(); + await expect(revokeDialog).toHaveCount(0); await expect(page.getByText("revoked", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Actions", exact: true }).click(); + await expect(page.getByRole("menuitem", { name: "Revoke", exact: true })).toBeDisabled(); + // CRL from the CA await page.goto(caUrl); await expect(page.getByRole("heading", { name: caName })).toBeVisible(); + await page.getByRole("button", { name: "Actions", exact: true }).click(); + await expect(page.getByRole("menuitem", { name: "Revoke", exact: true })).toBeDisabled(); + await page.getByRole("button", { name: "Actions", exact: true }).click(); await page.locator(".nav-tabs").getByRole("tab", { name: "CA", exact: true }).click(); await page.getByRole("button", { name: "Generate CRL" }).click(); await expect(page).toHaveURL(/\/crls\//); diff --git a/e2e/tests/san.spec.js b/e2e/tests/san.spec.js index 26cb027..70329a1 100644 --- a/e2e/tests/san.spec.js +++ b/e2e/tests/san.spec.js @@ -29,7 +29,11 @@ test("XCA-style subject alternative name editor", async ({ page }) => { await page.getByRole("link", { name: vaultName }).click(); const vaultUrl = page.url(); + const settingsResponse = page.waitForResponse( + (response) => response.url().includes("/api/vaults/") && response.url().endsWith("/settings") && response.ok() + ); await page.goto(`${vaultUrl}/certificates/new`); + await settingsResponse; await (await field(page, "Internal name")).fill(certName); await (await field(page, "Common name (CN)")).fill(certName); await page.locator("#new-key").check(); diff --git a/web/src/pages/certificate_detail.rs b/web/src/pages/certificate_detail.rs index 1c14347..5535d05 100644 --- a/web/src/pages/certificate_detail.rs +++ b/web/src/pages/certificate_detail.rs @@ -44,8 +44,13 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { let active_tab = create_signal(initial_tab()); let info = create_signal(None::); let openssl_config = create_signal(String::new()); + let actions_open = create_signal(false); + let export_open = create_signal(false); + let revoke_open = create_signal(false); + let can_revoke = create_signal(false); let load = move || { + can_revoke.set(false); spawn_local(async move { match api::certificate(id, cert_id_value).await { Ok(value) => { @@ -70,7 +75,17 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { purposes.set(value.purposes.clone()); chain.set(value.chain.clone()); openssl_config.set(value.openssl_config.clone()); + let issuer_id = if value.summary.revoked { + None + } else { + value.summary.issuer_id.clone() + }; cert.set(Some(value)); + if let Some(issuer_id) = issuer_id { + if let Ok(issuer) = api::certificate(id, &issuer_id).await { + can_revoke.set(issuer.summary.has_private_key); + } + } if is_ca { if let Ok(page) = api::templates(id).await { templates.set(page.results); @@ -104,12 +119,13 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { spawn_local(async move { match api::export_certificate(id, cert_id_value, &payload).await { Ok(payload) => { - if let Err(message) = dom::download_base64( + match dom::download_base64( &payload.filename, &payload.content_type, &payload.content_base64, ) { - error.set(Some(message)); + Ok(()) => export_open.set(false), + Err(message) => error.set(Some(message)), } } Err(message) if message == api::LOGIN_REQUIRED => {} @@ -119,11 +135,15 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { }; let revoke = move |_| { + if !can_revoke.get() { + return; + } let payload = json!({"reason": rev_reason.get_clone()}); spawn_local(async move { match api::revoke_certificate(id, cert_id_value, &payload).await { Ok(_) => { info.set(Some("Certificate revoked.".to_string())); + revoke_open.set(false); load(); } Err(message) if message == api::LOGIN_REQUIRED => {} @@ -132,7 +152,13 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { }); }; + let open_revoke = move |_| { + actions_open.set(false); + revoke_open.set(true); + }; + let remove = move |_| { + actions_open.set(false); if !dom::confirm("Delete this certificate?") { return; } @@ -324,11 +350,26 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { format!("{} ยท serial {}", if is_ca { "CA certificate" } else { "end-entity certificate" }, header_serial), view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Back" } - a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/{cert_id_value}/renew")) { "Renew" } - (if !summary.revoked && !is_ca { - view! { button(class="btn btn-warning", on:click=revoke) { "Revoke" } } - } else { view! {} }) - button(class="btn btn-outline-danger", on:click=remove) { "Delete" } + button(class="btn btn-outline-light", r#type="button", on:click=move |_| { + actions_open.set(false); + export_open.set(true); + }) { "Export" } + div(class="dropdown") { + button(class="btn btn-primary dropdown-toggle", r#type="button", aria-haspopup="true", + aria-expanded=if actions_open.get() { "true" } else { "false" }, + on:click=move |_| actions_open.set(!actions_open.get())) { "Actions" } + (if actions_open.get() { + view! { + div(class="dropdown-menu dropdown-menu-end show", role="menu") { + a(class="dropdown-item", href=format!("/vaults/{id}/certificates/{cert_id_value}/renew"), role="menuitem", + on:click=move |_| actions_open.set(false)) { "Renew" } + button(class="dropdown-item text-warning", r#type="button", role="menuitem", + disabled=!can_revoke.get(), on:click=open_revoke) { "Revoke" } + button(class="dropdown-item text-danger", r#type="button", role="menuitem", on:click=remove) { "Delete" } + } + } + } else { view! {} }) + } }, )) div(class="card mb-3") { @@ -349,9 +390,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { (tab("overview", "Overview")) (tab("extensions", "Extensions")) (tab("validation", "Validation")) - (tab("export", "Export")) (tab("metadata", "Metadata")) - (tab("revoke", "Revoke")) (tab("transform", "Transform")) (if is_ca { tab("ca", "CA") } else { view! {} }) } @@ -411,42 +450,6 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } } else { view! {} }) - (if active_tab.get_clone() == "export" { - view! { - div(id="panel-export", role="tabpanel", aria-labelledby="tab-export", class="row g-3") { - div(class="col-12 col-lg-6") { - div(class="card mb-3") { - div(class="card-header") { "Export" } - div(class="card-body") { - select(class="form-select mb-2", bind:value=export_format) { - option(value="pem") { "PEM" } - option(value="pem-chain") { "PEM chain" } - option(value="pem-key") { "PEM + key" } - option(value="pem-pkcs8") { "PEM + encrypted PKCS#8" } - option(value="pkcs7") { "PKCS#7" } - option(value="pkcs7-chain") { "PKCS#7 chain" } - option(value="der") { "DER" } - option(value="pkcs12") { "PKCS#12" } - option(value="pkcs12-chain") { "PKCS#12 chain" } - option(value="openvpn") { "OpenVPN" } - option(value="openssl-config") { "OpenSSL config" } - option(value="index") { "Certificate index" } - option(value="vcalendar") { "vCalendar" } - option(value="jwk") { "JWK" } - option(value="jwk-chain") { "JWK chain" } - option(value="tls-auth") { "OpenVPN tls-auth key" } - } - div(class="mb-2") { - label(class="form-label") { "Password (encrypted formats)" } - input(class="form-control", r#type="password", bind:value=export_password) - } - button(class="btn btn-primary", on:click=do_export) { "Download" } - } - } - } - } - } - } else { view! {} }) (if active_tab.get_clone() == "metadata" { view! { div(id="panel-metadata", role="tabpanel", aria-labelledby="tab-metadata", class="row g-3") { @@ -469,21 +472,6 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } } } else { view! {} }) - (if active_tab.get_clone() == "revoke" { - view! { - div(id="panel-revoke", role="tabpanel", aria-labelledby="tab-revoke", class="row g-3") { - div(class="col-12 col-lg-6") { - div(class="card") { - div(class="card-header") { "Revoke" } - div(class="card-body") { - select(class="form-select mb-2", bind:value=rev_reason) { (reason_options()) } - button(class="btn btn-warning", disabled=summary.revoked, on:click=revoke) { "Revoke certificate" } - } - } - } - } - } - } else { view! {} }) (if active_tab.get_clone() == "transform" { view! { div(id="panel-transform", role="tabpanel", aria-labelledby="tab-transform", class="row g-3") { @@ -596,6 +584,71 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } else { view! {} }) + (if export_open.get() { + view! { + div(class="modal fade show d-block", tabindex="-1", role="dialog", aria-modal="true", aria-labelledby="export-dialog-title") { + div(class="modal-dialog modal-dialog-centered") { + div(class="modal-content") { + div(class="modal-header") { + h2(class="modal-title h5", id="export-dialog-title") { "Export certificate" } + button(class="btn-close btn-close-white", r#type="button", aria-label="Close", on:click=move |_| export_open.set(false)) + } + div(class="modal-body") { + label(class="form-label", r#for="export-format") { "Format" } + select(id="export-format", class="form-select mb-3", bind:value=export_format) { + option(value="pem") { "PEM" } + option(value="pem-chain") { "PEM chain" } + option(value="pem-key") { "PEM + key" } + option(value="pem-pkcs8") { "PEM + encrypted PKCS#8" } + option(value="pkcs7") { "PKCS#7" } + option(value="pkcs7-chain") { "PKCS#7 chain" } + option(value="der") { "DER" } + option(value="pkcs12") { "PKCS#12" } + option(value="pkcs12-chain") { "PKCS#12 chain" } + option(value="openvpn") { "OpenVPN" } + option(value="openssl-config") { "OpenSSL config" } + option(value="index") { "Certificate index" } + option(value="vcalendar") { "vCalendar" } + option(value="jwk") { "JWK" } + option(value="jwk-chain") { "JWK chain" } + option(value="tls-auth") { "OpenVPN tls-auth key" } + } + label(class="form-label", r#for="export-password") { "Password (encrypted formats)" } + input(id="export-password", class="form-control", r#type="password", bind:value=export_password) + } + div(class="modal-footer") { + button(class="btn btn-outline-light", r#type="button", on:click=move |_| export_open.set(false)) { "Cancel" } + button(class="btn btn-primary", r#type="button", on:click=do_export) { "Download" } + } + } + } + } + div(class="modal-backdrop fade show") + } + } else { view! {} }) + (if revoke_open.get() { + view! { + div(class="modal fade show d-block", tabindex="-1", role="dialog", aria-modal="true", aria-labelledby="revoke-dialog-title") { + div(class="modal-dialog modal-dialog-centered") { + div(class="modal-content") { + div(class="modal-header") { + h2(class="modal-title h5", id="revoke-dialog-title") { "Revoke certificate" } + button(class="btn-close btn-close-white", r#type="button", aria-label="Close", on:click=move |_| revoke_open.set(false)) + } + div(class="modal-body") { + label(class="form-label", r#for="revoke-reason") { "Reason" } + select(id="revoke-reason", class="form-select", bind:value=rev_reason) { (reason_options()) } + } + div(class="modal-footer") { + button(class="btn btn-outline-light", r#type="button", on:click=move |_| revoke_open.set(false)) { "Cancel" } + button(class="btn btn-warning", r#type="button", disabled=!can_revoke.get(), on:click=revoke) { "Revoke certificate" } + } + } + } + } + div(class="modal-backdrop fade show") + } + } else { view! {} }) } }; @@ -620,8 +673,9 @@ fn initial_tab() -> String { .unwrap_or_default(); let hash = hash.trim_start_matches('#'); match hash { - "overview" | "extensions" | "validation" | "export" | "metadata" | "revoke" - | "transform" | "ca" => hash.to_string(), + "overview" | "extensions" | "validation" | "metadata" | "transform" | "ca" => { + hash.to_string() + } _ => "overview".to_string(), } }