Move certificate export and revoke actions into header

This commit is contained in:
Walter Oggioni
2026-10-27 07:39:00 +08:00
committed by woggioni
parent 73e6a11463
commit 917e0c1d7c
4 changed files with 155 additions and 67 deletions
+3 -2
View File
@@ -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 advanced extensions plus an advanced JSON editor for raw/less common
extensions, and a server-side **Validate extensions** action. OpenSSL config extensions, and a server-side **Validate extensions** action. OpenSSL config
text is available on export. text is available on export.
- Certificate details are split into Overview, Extensions, Validation, Export, - Certificate details are split into Overview, Extensions, Validation,
Metadata, Revoke, Transform and CA tabs, with hash deep links. They show 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 subject/issuer, serial, validity, signatures, MD5/SHA-1/SHA-256
fingerprints, parsed extensions, OpenSSL validation errors, purposes and the fingerprints, parsed extensions, OpenSSL validation errors, purposes and the
issuer chain. issuer chain.
+32 -3
View File
@@ -101,15 +101,44 @@ test("core PKI workflow", async ({ page }) => {
await page.getByRole("button", { name: "Search" }).click(); await page.getByRole("button", { name: "Search" }).click();
await expect(page.getByRole("link", { name: leafName, exact: true })).toBeVisible(); 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.getByRole("link", { name: leafName, exact: true }).click();
await page.locator(".nav-tabs").getByRole("tab", { name: "Revoke", exact: true }).click(); const certificateTabs = page.locator(".nav-tabs");
await page.getByRole("button", { name: "Revoke certificate" }).click(); 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 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 // CRL from the CA
await page.goto(caUrl); await page.goto(caUrl);
await expect(page.getByRole("heading", { name: caName })).toBeVisible(); 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.locator(".nav-tabs").getByRole("tab", { name: "CA", exact: true }).click();
await page.getByRole("button", { name: "Generate CRL" }).click(); await page.getByRole("button", { name: "Generate CRL" }).click();
await expect(page).toHaveURL(/\/crls\//); await expect(page).toHaveURL(/\/crls\//);
+4
View File
@@ -29,7 +29,11 @@ test("XCA-style subject alternative name editor", async ({ page }) => {
await page.getByRole("link", { name: vaultName }).click(); await page.getByRole("link", { name: vaultName }).click();
const vaultUrl = page.url(); 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 page.goto(`${vaultUrl}/certificates/new`);
await settingsResponse;
await (await field(page, "Internal name")).fill(certName); await (await field(page, "Internal name")).fill(certName);
await (await field(page, "Common name (CN)")).fill(certName); await (await field(page, "Common name (CN)")).fill(certName);
await page.locator("#new-key").check(); await page.locator("#new-key").check();
+115 -61
View File
@@ -44,8 +44,13 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let active_tab = create_signal(initial_tab()); let active_tab = create_signal(initial_tab());
let info = create_signal(None::<String>); let info = create_signal(None::<String>);
let openssl_config = create_signal(String::new()); 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 || { let load = move || {
can_revoke.set(false);
spawn_local(async move { spawn_local(async move {
match api::certificate(id, cert_id_value).await { match api::certificate(id, cert_id_value).await {
Ok(value) => { Ok(value) => {
@@ -70,7 +75,17 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
purposes.set(value.purposes.clone()); purposes.set(value.purposes.clone());
chain.set(value.chain.clone()); chain.set(value.chain.clone());
openssl_config.set(value.openssl_config.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)); 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 is_ca {
if let Ok(page) = api::templates(id).await { if let Ok(page) = api::templates(id).await {
templates.set(page.results); templates.set(page.results);
@@ -104,12 +119,13 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
spawn_local(async move { spawn_local(async move {
match api::export_certificate(id, cert_id_value, &payload).await { match api::export_certificate(id, cert_id_value, &payload).await {
Ok(payload) => { Ok(payload) => {
if let Err(message) = dom::download_base64( match dom::download_base64(
&payload.filename, &payload.filename,
&payload.content_type, &payload.content_type,
&payload.content_base64, &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 => {} Err(message) if message == api::LOGIN_REQUIRED => {}
@@ -119,11 +135,15 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
}; };
let revoke = move |_| { let revoke = move |_| {
if !can_revoke.get() {
return;
}
let payload = json!({"reason": rev_reason.get_clone()}); let payload = json!({"reason": rev_reason.get_clone()});
spawn_local(async move { spawn_local(async move {
match api::revoke_certificate(id, cert_id_value, &payload).await { match api::revoke_certificate(id, cert_id_value, &payload).await {
Ok(_) => { Ok(_) => {
info.set(Some("Certificate revoked.".to_string())); info.set(Some("Certificate revoked.".to_string()));
revoke_open.set(false);
load(); load();
} }
Err(message) if message == api::LOGIN_REQUIRED => {} 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 |_| { let remove = move |_| {
actions_open.set(false);
if !dom::confirm("Delete this certificate?") { if !dom::confirm("Delete this certificate?") {
return; 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), format!("{} · serial {}", if is_ca { "CA certificate" } else { "end-entity certificate" }, header_serial),
view! { view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Back" } 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" } button(class="btn btn-outline-light", r#type="button", on:click=move |_| {
(if !summary.revoked && !is_ca { actions_open.set(false);
view! { button(class="btn btn-warning", on:click=revoke) { "Revoke" } } 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! {} }) } else { view! {} })
button(class="btn btn-outline-danger", on:click=remove) { "Delete" } }
}, },
)) ))
div(class="card mb-3") { div(class="card mb-3") {
@@ -349,9 +390,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
(tab("overview", "Overview")) (tab("overview", "Overview"))
(tab("extensions", "Extensions")) (tab("extensions", "Extensions"))
(tab("validation", "Validation")) (tab("validation", "Validation"))
(tab("export", "Export"))
(tab("metadata", "Metadata")) (tab("metadata", "Metadata"))
(tab("revoke", "Revoke"))
(tab("transform", "Transform")) (tab("transform", "Transform"))
(if is_ca { tab("ca", "CA") } else { view! {} }) (if is_ca { tab("ca", "CA") } else { view! {} })
} }
@@ -411,42 +450,6 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
} }
} else { 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" { (if active_tab.get_clone() == "metadata" {
view! { view! {
div(id="panel-metadata", role="tabpanel", aria-labelledby="tab-metadata", class="row g-3") { 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! {} }) } 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" { (if active_tab.get_clone() == "transform" {
view! { view! {
div(id="panel-transform", role="tabpanel", aria-labelledby="tab-transform", class="row g-3") { 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 { } else {
view! {} 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(); .unwrap_or_default();
let hash = hash.trim_start_matches('#'); let hash = hash.trim_start_matches('#');
match hash { match hash {
"overview" | "extensions" | "validation" | "export" | "metadata" | "revoke" "overview" | "extensions" | "validation" | "metadata" | "transform" | "ca" => {
| "transform" | "ca" => hash.to_string(), hash.to_string()
}
_ => "overview".to_string(), _ => "overview".to_string(),
} }
} }