Move certificate transforms into header dropdown
CI / Build and push docker image (push) Successful in 1m56s
CI / Build and push OCSP image (push) Successful in 33s

This commit is contained in:
Walter Oggioni
2026-10-27 07:39:13 +08:00
committed by woggioni
parent 917e0c1d7c
commit a2457ab1f4
3 changed files with 65 additions and 25 deletions
+2 -2
View File
@@ -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.
+28 -1
View File
@@ -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();
+35 -22
View File
@@ -45,6 +45,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let info = create_signal(None::<String>);
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(),
}
}