Move certificate export and revoke actions into header
This commit is contained in:
@@ -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
@@ -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\//);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
} else { view! {} })
|
}) { "Export" }
|
||||||
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
|
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") {
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user