Compare commits

...
2 Commits
Author SHA1 Message Date
Walter Oggioni a2457ab1f4 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
2026-10-27 07:39:13 +08:00
Walter Oggioni 917e0c1d7c Move certificate export and revoke actions into header 2026-10-27 07:39:00 +08:00
4 changed files with 213 additions and 85 deletions

No files matched your search

+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 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 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.
+59 -3
View File
@@ -101,15 +101,71 @@ 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 controls replace the Export, Revoke and Transform 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 leafUrl = page.url();
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);
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();
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);
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();
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();
+147 -80
View File
@@ -44,8 +44,14 @@ 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 transform_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 +76,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 +120,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 +136,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 +153,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 +351,58 @@ 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" } } transform_open.set(false);
} else { view! {} }) export_open.set(true);
button(class="btn btn-outline-danger", on:click=remove) { "Delete" } }) { "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 |_| {
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") {
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="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") { div(class="card mb-3") {
@@ -349,10 +423,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"))
(if is_ca { tab("ca", "CA") } else { view! {} }) (if is_ca { tab("ca", "CA") } else { view! {} })
} }
(if active_tab.get_clone() == "overview" { (if active_tab.get_clone() == "overview" {
@@ -411,42 +482,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,38 +504,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" {
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" { (if is_ca && active_tab.get_clone() == "ca" {
view! { view! {
div(id="panel-ca", role="tabpanel", aria-labelledby="tab-ca", class="card mt-3") { div(id="panel-ca", role="tabpanel", aria-labelledby="tab-ca", class="card mt-3") {
@@ -596,6 +599,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 +688,7 @@ 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" | "ca" => hash.to_string(),
| "transform" | "ca" => hash.to_string(),
_ => "overview".to_string(), _ => "overview".to_string(),
} }
} }