diff --git a/README.md b/README.md index 45895af..4535ab1 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,8 @@ explicit/mandatory DN entries, default key type/size/curve, default validity). extensions, and a server-side **Validate extensions** action. OpenSSL config text is available on export. - 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 + Metadata and CA tabs, with hash deep links. Header actions provide export, + renewal, revocation, deletion and certificate transformation. 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 1a23381..9311a61 100644 --- a/e2e/tests/core.spec.js +++ b/e2e/tests/core.spec.js @@ -101,11 +101,13 @@ test("core PKI workflow", async ({ page }) => { await page.getByRole("button", { name: "Search" }).click(); await expect(page.getByRole("link", { name: leafName, exact: true })).toBeVisible(); - // Certificate header actions replace the Export and Revoke tabs. + // Certificate header controls replace the Export, Revoke and Transform tabs. await page.getByRole("link", { name: leafName, exact: true }).click(); + const leafUrl = page.url(); 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); + await expect(certificateTabs.getByRole("tab", { name: "Transform", exact: true })).toHaveCount(0); const downloadPromise = page.waitForEvent("download"); await page.getByRole("button", { name: "Export", exact: true }).click(); @@ -117,6 +119,31 @@ test("core PKI workflow", async ({ page }) => { expect(download.suggestedFilename()).toBe(`${leafName}.crt`); await expect(exportDialog).toHaveCount(0); + const transformButton = page.getByRole("button", { name: "Transform", exact: true }); + await transformButton.click(); + for (const name of ["Extract public key", "Create request", "Create template", "Similar certificate"]) { + await expect(page.getByRole("menuitem", { name, exact: true })).toBeVisible(); + } + await page.getByRole("menuitem", { name: "Extract public key", exact: true }).click(); + await expect(page).toHaveURL(/\/keys\/[^/]+$/); + + await page.goto(leafUrl); + await transformButton.click(); + await page.getByRole("menuitem", { name: "Create request", exact: true }).click(); + await expect(page).toHaveURL(/\/requests\/[^/]+$/); + + await page.goto(leafUrl); + await transformButton.click(); + await page.getByRole("menuitem", { name: "Create template", exact: true }).click(); + await expect(page).toHaveURL(/\/templates\/[^/]+$/); + + await page.goto(leafUrl); + await transformButton.click(); + await page.getByRole("menuitem", { name: "Similar certificate", exact: true }).click(); + await expect(page).toHaveURL(`${leafUrl}/similar`); + await expect(page.getByRole("heading", { name: "New certificate" })).toBeVisible(); + await page.goto(leafUrl); + 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(); diff --git a/web/src/pages/certificate_detail.rs b/web/src/pages/certificate_detail.rs index 5535d05..69015cf 100644 --- a/web/src/pages/certificate_detail.rs +++ b/web/src/pages/certificate_detail.rs @@ -45,6 +45,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { let info = create_signal(None::); let openssl_config = create_signal(String::new()); let actions_open = create_signal(false); + let transform_open = create_signal(false); let export_open = create_signal(false); let revoke_open = create_signal(false); let can_revoke = create_signal(false); @@ -352,12 +353,16 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Back" } button(class="btn btn-outline-light", r#type="button", on:click=move |_| { actions_open.set(false); + transform_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" } + on:click=move |_| { + transform_open.set(false); + actions_open.set(!actions_open.get()); + }) { "Actions" } (if actions_open.get() { view! { div(class="dropdown-menu dropdown-menu-end show", role="menu") { @@ -370,6 +375,34 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } } else { view! {} }) } + div(class="dropdown") { + button(class="btn btn-outline-light dropdown-toggle", r#type="button", aria-haspopup="true", + aria-expanded=if transform_open.get() { "true" } else { "false" }, + on:click=move |_| { + actions_open.set(false); + transform_open.set(!transform_open.get()); + }) { "Transform" } + (if transform_open.get() { + view! { + div(class="dropdown-menu dropdown-menu-end show", role="menu") { + button(class="dropdown-item", r#type="button", role="menuitem", on:click=move |_| { + transform_open.set(false); + transform("open-key"); + }) { "Extract public key" } + button(class="dropdown-item", r#type="button", role="menuitem", on:click=move |_| { + transform_open.set(false); + transform("request"); + }) { "Create request" } + button(class="dropdown-item", r#type="button", role="menuitem", on:click=move |_| { + transform_open.set(false); + transform("template"); + }) { "Create template" } + a(class="dropdown-item", href=format!("/vaults/{id}/certificates/{cert_id_value}/similar"), role="menuitem", + on:click=move |_| transform_open.set(false)) { "Similar certificate" } + } + } + } else { view! {} }) + } }, )) div(class="card mb-3") { @@ -391,7 +424,6 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { (tab("extensions", "Extensions")) (tab("validation", "Validation")) (tab("metadata", "Metadata")) - (tab("transform", "Transform")) (if is_ca { tab("ca", "CA") } else { view! {} }) } (if active_tab.get_clone() == "overview" { @@ -472,23 +504,6 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } } } else { view! {} }) - (if active_tab.get_clone() == "transform" { - view! { - div(id="panel-transform", role="tabpanel", aria-labelledby="tab-transform", class="row g-3") { - div(class="col-12 col-lg-6") { - div(class="card") { - div(class="card-header") { "Transform" } - div(class="card-body d-grid gap-2") { - button(class="btn btn-outline-light", on:click=move |_| transform("open-key")) { "Extract public key" } - button(class="btn btn-outline-light", on:click=move |_| transform("request")) { "Create request" } - button(class="btn btn-outline-light", on:click=move |_| transform("template")) { "Create template" } - a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates/{cert_id_value}/similar")) { "Similar certificate" } - } - } - } - } - } - } else { view! {} }) (if is_ca && active_tab.get_clone() == "ca" { view! { div(id="panel-ca", role="tabpanel", aria-labelledby="tab-ca", class="card mt-3") { @@ -673,9 +688,7 @@ fn initial_tab() -> String { .unwrap_or_default(); let hash = hash.trim_start_matches('#'); match hash { - "overview" | "extensions" | "validation" | "metadata" | "transform" | "ca" => { - hash.to_string() - } + "overview" | "extensions" | "validation" | "metadata" | "ca" => hash.to_string(), _ => "overview".to_string(), } }