Add server template apply modes, bulk revocation, explicit DN order and detail tabs
This commit is contained in:
@@ -297,6 +297,11 @@ pub async fn certificate(vault_id: &str, cert_id: &str) -> Result<CertificateDet
|
||||
check(response).await
|
||||
}
|
||||
|
||||
pub async fn bulk_revoke_certificates(vault_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/bulk-revoke"), Some(payload)).await?;
|
||||
check(response).await
|
||||
}
|
||||
|
||||
pub async fn preview_certificate(vault_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/preview"), Some(payload)).await?;
|
||||
check(response).await
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
//! JSON models mirroring the KCA API and small display helpers.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -265,7 +265,7 @@ pub struct CertificateDetail {
|
||||
pub cert_spec: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
|
||||
pub struct DnEntry {
|
||||
#[serde(default)]
|
||||
pub oid: String,
|
||||
|
||||
+298
-212
@@ -37,6 +37,9 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
let tls_auth_text = create_signal(String::new());
|
||||
let new_name = create_signal(String::new());
|
||||
let new_comment = create_signal(String::new());
|
||||
let active_tab = create_signal(initial_tab());
|
||||
let info = create_signal(None::<String>);
|
||||
let openssl_config = create_signal(String::new());
|
||||
|
||||
let load = move || {
|
||||
spawn_local(async move {
|
||||
@@ -62,6 +65,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
validation_errors.set(value.validation_errors.clone());
|
||||
purposes.set(value.purposes.clone());
|
||||
chain.set(value.chain.clone());
|
||||
openssl_config.set(value.openssl_config.clone());
|
||||
cert.set(Some(value));
|
||||
if is_ca {
|
||||
if let Ok(page) = api::templates(id).await {
|
||||
@@ -111,7 +115,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
let payload = json!({"reason": rev_reason.get_clone()});
|
||||
spawn_local(async move {
|
||||
match api::revoke_certificate(id, cert_id_value, &payload).await {
|
||||
Ok(_) => load(),
|
||||
Ok(_) => {
|
||||
info.set(Some("Certificate revoked.".to_string()));
|
||||
load();
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
@@ -137,7 +144,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
let payload = json!({"name": new_name.get_clone(), "comment": new_comment.get_clone(), "version": value.summary.version});
|
||||
spawn_local(async move {
|
||||
match api::update_certificate(id, cert_id_value, &payload).await {
|
||||
Ok(updated) => cert.set(Some(updated)),
|
||||
Ok(updated) => {
|
||||
cert.set(Some(updated));
|
||||
info.set(Some("Certificate metadata saved.".to_string()));
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
@@ -171,7 +181,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
});
|
||||
spawn_local(async move {
|
||||
match api::update_ca(id, cert_id_value, &payload).await {
|
||||
Ok(_) => {}
|
||||
Ok(_) => info.set(Some("CA properties saved.".to_string())),
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
@@ -217,6 +227,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
rev_serial.set(String::new());
|
||||
rev_invalidity.set(String::new());
|
||||
editing_rev.set(String::new());
|
||||
info.set(Some("Revocation list saved.".to_string()));
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
@@ -264,6 +275,20 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
});
|
||||
};
|
||||
|
||||
let tab = move |key: &'static str, label: &'static str| -> View {
|
||||
view! {
|
||||
li(class="nav-item") {
|
||||
button(class=if active_tab.get_clone() == key { "nav-link active" } else { "nav-link" },
|
||||
on:click=move |_| {
|
||||
active_tab.set(key.to_string());
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.location().set_hash(key);
|
||||
}
|
||||
}) { (label) }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let content = move || {
|
||||
if loading.get() {
|
||||
return spinner();
|
||||
@@ -275,233 +300,281 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
let is_ca = summary.is_ca;
|
||||
let header_serial = summary.serial_hex.clone();
|
||||
view! {
|
||||
(page_header(
|
||||
summary.name.clone(),
|
||||
format!("{} · serial {}", if is_ca { "CA certificate" } else { "end-entity certificate" }, header_serial),
|
||||
view! {
|
||||
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" }
|
||||
(if !summary.revoked && !is_ca {
|
||||
view! { button(class="btn btn-warning", on:click=revoke) { "Revoke" } }
|
||||
} else { view! {} })
|
||||
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
|
||||
},
|
||||
))
|
||||
div(class="card mb-3") {
|
||||
div(class="card-body") {
|
||||
div(class="row g-2") {
|
||||
div(class="col-md-6") { div(class="small text-secondary") { "Subject" } div(class="mono") { (value.subject_rfc4514.clone()) } }
|
||||
div(class="col-md-6") { div(class="small text-secondary") { "Issuer" } div(class="mono") { (value.issuer_rfc4514.clone()) } }
|
||||
div(class="col-md-3") { div(class="small text-secondary") { "Serial" } div(class="mono") { (summary.serial_hex.clone()) } }
|
||||
div(class="col-md-3") { div(class="small text-secondary") { "Signature" } div() { (value.signature_algorithm.clone()) } }
|
||||
div(class="col-md-3") { div(class="small text-secondary") { "Not before" } div() { (fmt_date(&summary.not_before)) } }
|
||||
div(class="col-md-3") { div(class="small text-secondary") { "Not after" } div() { (fmt_date(&summary.not_after)) } }
|
||||
div(class="col-md-3") { div(class="small text-secondary") { "Key" } div() { (value.key_name.clone().unwrap_or_else(|| "not in vault".to_string())) } }
|
||||
div(class="col-md-3") { div(class="small text-secondary") { "Status" } (if summary.revoked { view! { span(class="badge badge-danger") { "revoked" } } } else { view! { span(class="badge badge-soft") { "valid" } } }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="row g-3 mb-3") {
|
||||
div(class="col-12 col-lg-6") {
|
||||
div(class="card") {
|
||||
div(class="card-header") { "Subject" }
|
||||
table(class="table table-sm dn-table mb-0") {
|
||||
tbody() { Indexed(list=subject_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="col-12 col-lg-6") {
|
||||
div(class="card") {
|
||||
div(class="card-header") { "Issuer" }
|
||||
table(class="table table-sm dn-table mb-0") {
|
||||
tbody() { Indexed(list=issuer_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="card mb-3") {
|
||||
div(class="card-header d-flex justify-content-between align-items-center") {
|
||||
span() { "Extensions" }
|
||||
(copy_button(value.openssl_config.clone(), "Copy OpenSSL config"))
|
||||
}
|
||||
div(class="table-responsive") {
|
||||
table(class="table table-sm ext-table mb-0") {
|
||||
thead() { tr() { th() { "Extension" } th() { "Value" } } }
|
||||
tbody() { Indexed(list=extensions, view=|ext| view! { tr() { td() { (ext.name) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) } td(class="extension-value") { (ext.value) } } }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="card mb-3") {
|
||||
div(class="card-header") { "Validation" }
|
||||
div(class="card-body") {
|
||||
(if validation_errors.get_clone().is_empty() {
|
||||
view! { div(class="alert alert-success mb-2") { "No verification errors found." } }
|
||||
} else {
|
||||
view! { ul(class="text-danger") { Indexed(list=validation_errors, view=|err| view! { li() { (err.code.map(|c| format!("[{c}] ")).unwrap_or_default()) (err.message) } }) } }
|
||||
})
|
||||
div(class="mt-2") { Indexed(list=purposes, view=|p| view! { span(class="badge badge-soft me-1") { (p) } }) }
|
||||
}
|
||||
}
|
||||
div(class="row g-3") {
|
||||
div(class="col-12 col-lg-6") {
|
||||
(page_header(
|
||||
summary.name.clone(),
|
||||
format!("{} · serial {}", if is_ca { "CA certificate" } else { "end-entity certificate" }, header_serial),
|
||||
view! {
|
||||
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" }
|
||||
(if !summary.revoked && !is_ca {
|
||||
view! { button(class="btn btn-warning", on:click=revoke) { "Revoke" } }
|
||||
} else { view! {} })
|
||||
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
|
||||
},
|
||||
))
|
||||
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="row g-2") {
|
||||
div(class="col-md-6") { div(class="small text-secondary") { "Subject" } div(class="mono") { (value.subject_rfc4514.clone()) } }
|
||||
div(class="col-md-6") { div(class="small text-secondary") { "Issuer" } div(class="mono") { (value.issuer_rfc4514.clone()) } }
|
||||
div(class="col-md-3") { div(class="small text-secondary") { "Serial" } div(class="mono") { (summary.serial_hex.clone()) } }
|
||||
div(class="col-md-3") { div(class="small text-secondary") { "Signature" } div() { (value.signature_algorithm.clone()) } }
|
||||
div(class="col-md-3") { div(class="small text-secondary") { "Not before" } div() { (fmt_date(&summary.not_before)) } }
|
||||
div(class="col-md-3") { div(class="small text-secondary") { "Not after" } div() { (if summary.no_expiration { "No expiration".to_string() } else { fmt_date(&summary.not_after) }) } }
|
||||
div(class="col-md-3") { div(class="small text-secondary") { "Key" } div() { (value.key_name.clone().unwrap_or_else(|| "not in vault".to_string())) } }
|
||||
div(class="col-md-3") { div(class="small text-secondary") { "Status" } (if summary.revoked { view! { span(class="badge badge-danger") { "revoked" } } } else { view! { span(class="badge badge-soft") { "valid" } } }) }
|
||||
}
|
||||
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" }
|
||||
}
|
||||
}
|
||||
div(class="card") {
|
||||
div(class="card-header") { "Metadata" }
|
||||
div(class="card-body") {
|
||||
div(class="mb-2") {
|
||||
label(class="form-label") { "Name" }
|
||||
input(class="form-control", bind:value=new_name)
|
||||
ul(class="nav nav-tabs mb-3") {
|
||||
(tab("overview", "Overview"))
|
||||
(tab("extensions", "Extensions"))
|
||||
(tab("validation", "Validation"))
|
||||
(tab("export", "Export"))
|
||||
(tab("metadata", "Metadata"))
|
||||
(tab("revoke", "Revoke"))
|
||||
(tab("transform", "Transform"))
|
||||
(if is_ca { tab("ca", "CA") } else { view! {} })
|
||||
}
|
||||
(if active_tab.get_clone() == "overview" {
|
||||
view! {
|
||||
div(class="row g-3 mb-3") {
|
||||
div(class="col-12 col-lg-6") {
|
||||
div(class="card") {
|
||||
div(class="card-header") { "Subject" }
|
||||
table(class="table table-sm dn-table mb-0") {
|
||||
tbody() { Indexed(list=subject_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) }
|
||||
}
|
||||
}
|
||||
div(class="mb-2") {
|
||||
label(class="form-label") { "Comment" }
|
||||
textarea(class="form-control", rows="3", bind:value=new_comment) {}
|
||||
}
|
||||
div(class="col-12 col-lg-6") {
|
||||
div(class="card") {
|
||||
div(class="card-header") { "Issuer" }
|
||||
table(class="table table-sm dn-table mb-0") {
|
||||
tbody() { Indexed(list=issuer_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) }
|
||||
}
|
||||
}
|
||||
button(class="btn btn-primary", on:click=save_meta) { "Save" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="col-12 col-lg-6") {
|
||||
div(class="card mb-3") {
|
||||
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() == "extensions" {
|
||||
view! {
|
||||
div(class="card mb-3") {
|
||||
div(class="card-header d-flex justify-content-between align-items-center") {
|
||||
span() { "Extensions" }
|
||||
(copy_button(openssl_config.get_clone(), "Copy OpenSSL config"))
|
||||
}
|
||||
div(class="table-responsive") {
|
||||
table(class="table table-sm ext-table mb-0") {
|
||||
thead() { tr() { th() { "Extension" } th() { "Value" } } }
|
||||
tbody() { Indexed(list=extensions, view=|ext| view! { tr() { td() { (ext.name) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) } td(class="extension-value") { (ext.value) } } }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
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" }
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(if is_ca {
|
||||
view! {
|
||||
div(class="card mt-3") {
|
||||
div(class="card-header") { "CA operations" }
|
||||
} else { view! {} }) (if active_tab.get_clone() == "validation" {
|
||||
view! {
|
||||
div(class="card mb-3") {
|
||||
div(class="card-header") { "Validation" }
|
||||
div(class="card-body") {
|
||||
div(class="row g-2 align-items-end mb-3") {
|
||||
div(class="col-md-3") {
|
||||
label(class="form-label") { "CA · CRL days" }
|
||||
input(class="form-control", bind:value=ca_days)
|
||||
}
|
||||
div(class="col-md-5") {
|
||||
label(class="form-label") { "CA · default signing template" }
|
||||
select(class="form-select", bind:value=ca_template) {
|
||||
option(value="") { "— none —" }
|
||||
(templates.get_clone().into_iter().map(|template| view! {
|
||||
option(value=template.id.clone()) { (template.name.clone()) }
|
||||
}).collect::<Vec<View>>())
|
||||
}
|
||||
}
|
||||
div(class="col-md-2") {
|
||||
button(class="btn btn-outline-light w-100", on:click=save_ca) { "Save CA" }
|
||||
}
|
||||
}
|
||||
div(class="row g-2 align-items-end mb-3") {
|
||||
div(class="col-md-3") {
|
||||
label(class="form-label") { "CRL days" }
|
||||
input(class="form-control", bind:value=crl_days)
|
||||
}
|
||||
div(class="col-md-3") {
|
||||
label(class="form-label") { "Hash" }
|
||||
select(class="form-select", bind:value=crl_hash) {
|
||||
option(value="sha256") { "SHA-256" }
|
||||
option(value="sha384") { "SHA-384" }
|
||||
option(value="sha512") { "SHA-512" }
|
||||
}
|
||||
}
|
||||
div(class="col-md-3") {
|
||||
button(class="btn btn-primary", on:click=generate_crl) { "Generate CRL" }
|
||||
}
|
||||
div(class="col-md-3") {
|
||||
button(class="btn btn-outline-light", on:click=make_tls_auth) { "tls-auth key" }
|
||||
}
|
||||
}
|
||||
(if tls_auth_text.get_clone().is_empty() {
|
||||
view! {}
|
||||
(if validation_errors.get_clone().is_empty() {
|
||||
view! { div(class="alert alert-success mb-2") { "No verification errors found." } }
|
||||
} else {
|
||||
view! { pre(class="pem") { (tls_auth_text.get_clone()) } }
|
||||
view! { ul(class="text-danger") { Indexed(list=validation_errors, view=|err| view! { li() { (err.code.map(|c| format!("[{c}] ")).unwrap_or_default()) (err.message) } }) } }
|
||||
})
|
||||
h3(class="h6 mt-3") { "Revocations" }
|
||||
div(class="row g-2 align-items-end mb-2") {
|
||||
div(class="col-md-3") {
|
||||
input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial)
|
||||
}
|
||||
div(class="col-md-3") {
|
||||
select(class="form-select", bind:value=rev_reason) { (reason_options()) }
|
||||
}
|
||||
div(class="col-md-3") {
|
||||
input(class="form-control", r#type="datetime-local", bind:value=rev_invalidity)
|
||||
}
|
||||
div(class="col-md-3 d-flex gap-2") {
|
||||
button(class="btn btn-outline-light", on:click=save_revocation) {
|
||||
(if editing_rev.get_clone().is_empty() { "Add revocation" } else { "Save revocation" })
|
||||
}
|
||||
(if !editing_rev.get_clone().is_empty() {
|
||||
view! {
|
||||
button(class="btn btn-outline-secondary", on:click=move |_| {
|
||||
editing_rev.set(String::new());
|
||||
rev_serial.set(String::new());
|
||||
rev_invalidity.set(String::new());
|
||||
}) { "Cancel" }
|
||||
}
|
||||
} else { view! {} })
|
||||
}
|
||||
}
|
||||
table(class="table table-sm mb-0") {
|
||||
thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() { "Invalid since" } th() {} } }
|
||||
tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); let rev_for_edit = rev.clone(); view! {
|
||||
tr() {
|
||||
td(class="fingerprint") { (rev.serial_hex) }
|
||||
td() { (fmt_date(&rev.revoked_at)) }
|
||||
td() { (rev.reason) }
|
||||
td() { (rev.invalidity_date.clone().map(|v| fmt_date(&Some(v))).unwrap_or_else(|| "—".to_string())) }
|
||||
td(class="table-actions") {
|
||||
button(class="btn btn-sm btn-outline-light me-1", on:click=move |_| edit_revocation(rev_for_edit.clone())) { "Edit" }
|
||||
button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" }
|
||||
}
|
||||
}
|
||||
} }) }
|
||||
}
|
||||
div(class="mt-2") { Indexed(list=purposes, view=|p| view! { span(class="badge badge-soft me-1") { (p) } }) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
} else { view! {} }) (if active_tab.get_clone() == "export" {
|
||||
view! {
|
||||
div(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" {
|
||||
view! {
|
||||
div(class="row g-3") {
|
||||
div(class="col-12 col-lg-6") {
|
||||
div(class="card") {
|
||||
div(class="card-header") { "Metadata" }
|
||||
div(class="card-body") {
|
||||
div(class="mb-2") {
|
||||
label(class="form-label") { "Name" }
|
||||
input(class="form-control", bind:value=new_name)
|
||||
}
|
||||
div(class="mb-2") {
|
||||
label(class="form-label") { "Comment" }
|
||||
textarea(class="form-control", rows="3", bind:value=new_comment) {}
|
||||
}
|
||||
button(class="btn btn-primary", on:click=save_meta) { "Save" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else { view! {} })
|
||||
(if active_tab.get_clone() == "revoke" {
|
||||
view! {
|
||||
div(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(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(class="card mt-3") {
|
||||
div(class="card-header") { "CA operations" }
|
||||
div(class="card-body") {
|
||||
div(class="row g-2 align-items-end mb-3") {
|
||||
div(class="col-md-3") {
|
||||
label(class="form-label") { "CA · CRL days" }
|
||||
input(class="form-control", bind:value=ca_days)
|
||||
}
|
||||
div(class="col-md-5") {
|
||||
label(class="form-label") { "CA · default signing template" }
|
||||
select(class="form-select", bind:value=ca_template) {
|
||||
option(value="") { "— none —" }
|
||||
(templates.get_clone().into_iter().map(|template| view! {
|
||||
option(value=template.id.clone()) { (template.name.clone()) }
|
||||
}).collect::<Vec<View>>())
|
||||
}
|
||||
}
|
||||
div(class="col-md-2") {
|
||||
button(class="btn btn-outline-light w-100", on:click=save_ca) { "Save CA" }
|
||||
}
|
||||
}
|
||||
div(class="row g-2 align-items-end mb-3") {
|
||||
div(class="col-md-3") {
|
||||
label(class="form-label") { "CRL days" }
|
||||
input(class="form-control", bind:value=crl_days)
|
||||
}
|
||||
div(class="col-md-3") {
|
||||
label(class="form-label") { "Hash" }
|
||||
select(class="form-select", bind:value=crl_hash) {
|
||||
option(value="sha256") { "SHA-256" }
|
||||
option(value="sha384") { "SHA-384" }
|
||||
option(value="sha512") { "SHA-512" }
|
||||
}
|
||||
}
|
||||
div(class="col-md-3") {
|
||||
button(class="btn btn-primary", on:click=generate_crl) { "Generate CRL" }
|
||||
}
|
||||
div(class="col-md-3") {
|
||||
button(class="btn btn-outline-light", on:click=make_tls_auth) { "tls-auth key" }
|
||||
}
|
||||
}
|
||||
(if tls_auth_text.get_clone().is_empty() {
|
||||
view! {}
|
||||
} else {
|
||||
view! { pre(class="pem") { (tls_auth_text.get_clone()) } }
|
||||
})
|
||||
h3(class="h6 mt-3") { "Revocations" }
|
||||
div(class="row g-2 align-items-end mb-2") {
|
||||
div(class="col-md-3") {
|
||||
input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial)
|
||||
}
|
||||
div(class="col-md-3") {
|
||||
select(class="form-select", bind:value=rev_reason) { (reason_options()) }
|
||||
}
|
||||
div(class="col-md-3") {
|
||||
input(class="form-control", r#type="datetime-local", bind:value=rev_invalidity)
|
||||
}
|
||||
div(class="col-md-3 d-flex gap-2") {
|
||||
button(class="btn btn-outline-light", on:click=save_revocation) {
|
||||
(if editing_rev.get_clone().is_empty() { "Add revocation" } else { "Save revocation" })
|
||||
}
|
||||
(if !editing_rev.get_clone().is_empty() {
|
||||
view! {
|
||||
button(class="btn btn-outline-secondary", on:click=move |_| {
|
||||
editing_rev.set(String::new());
|
||||
rev_serial.set(String::new());
|
||||
rev_invalidity.set(String::new());
|
||||
}) { "Cancel" }
|
||||
}
|
||||
} else { view! {} })
|
||||
}
|
||||
}
|
||||
table(class="table table-sm mb-0") {
|
||||
thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() { "Invalid since" } th() {} } }
|
||||
tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); let rev_for_edit = rev.clone(); view! {
|
||||
tr() {
|
||||
td(class="fingerprint") { (rev.serial_hex) }
|
||||
td() { (fmt_date(&rev.revoked_at)) }
|
||||
td() { (rev.reason) }
|
||||
td() { (rev.invalidity_date.clone().map(|v| fmt_date(&Some(v))).unwrap_or_else(|| "—".to_string())) }
|
||||
td(class="table-actions") {
|
||||
button(class="btn btn-sm btn-outline-light me-1", on:click=move |_| edit_revocation(rev_for_edit.clone())) { "Edit" }
|
||||
button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" }
|
||||
}
|
||||
}
|
||||
} }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let nav = move || match me.get_clone() {
|
||||
@@ -513,11 +586,24 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
(app_nav("/", Some(id), nav))
|
||||
main(class="container page-shell py-4") {
|
||||
(toast_error(error))
|
||||
(crate::components::toast::toast(info, true))
|
||||
(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn initial_tab() -> String {
|
||||
let hash = web_sys::window()
|
||||
.and_then(|window| window.location().hash().ok())
|
||||
.unwrap_or_default();
|
||||
let hash = hash.trim_start_matches('#');
|
||||
match hash {
|
||||
"overview" | "extensions" | "validation" | "export" | "metadata" | "revoke"
|
||||
| "transform" | "ca" => hash.to_string(),
|
||||
_ => "overview".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn reason_options() -> Vec<View> {
|
||||
[
|
||||
"unspecified",
|
||||
|
||||
+586
-395
File diff suppressed because it is too large
Load Diff
+172
-11
@@ -1,12 +1,16 @@
|
||||
//! Certificate list, flat or as an issuer tree.
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::nav::{app_nav, spinner, whoami};
|
||||
use crate::components::ui::{page_header, toast_error};
|
||||
use crate::dom;
|
||||
use crate::model::{fmt_date, fmt_fp, status_badge, CertificateSummary, Me};
|
||||
|
||||
pub fn CertificatesPage(vault_id: String) -> View {
|
||||
@@ -17,6 +21,11 @@ pub fn CertificatesPage(vault_id: String) -> View {
|
||||
let loading = create_signal(true);
|
||||
let filter = create_signal(String::new());
|
||||
let plain = create_signal(false);
|
||||
let selected = create_signal(Vec::<String>::new());
|
||||
let bulk_reason = create_signal("unspecified".to_string());
|
||||
let bulk_invalidity = create_signal(String::new());
|
||||
let bulk_busy = create_signal(false);
|
||||
let info = create_signal(None::<String>);
|
||||
|
||||
spawn_local(async move {
|
||||
match api::me().await {
|
||||
@@ -50,6 +59,97 @@ pub fn CertificatesPage(vault_id: String) -> View {
|
||||
}
|
||||
});
|
||||
|
||||
let refresh = move || {
|
||||
spawn_local(async move {
|
||||
if let Ok(page) = api::certificates(id).await {
|
||||
if certificates.is_alive() {
|
||||
certificates.set(page.results);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let toggle = move |event: web_sys::Event| {
|
||||
if let Some(target) = event
|
||||
.target()
|
||||
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
|
||||
{
|
||||
if let Some(cert_id) = target.get_attribute("data-cert-id") {
|
||||
let mut current = selected.get_clone();
|
||||
if target.checked() {
|
||||
if !current.contains(&cert_id) {
|
||||
current.push(cert_id);
|
||||
}
|
||||
} else {
|
||||
current.retain(|value| value != &cert_id);
|
||||
}
|
||||
selected.set(current);
|
||||
}
|
||||
}
|
||||
};
|
||||
let select_all = move |_| {
|
||||
let all = certificates
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.filter(|cert| cert.issuer_id.is_some())
|
||||
.map(|cert| cert.id)
|
||||
.collect::<Vec<_>>();
|
||||
selected.set(all);
|
||||
};
|
||||
let clear_selection = move |_| selected.set(Vec::new());
|
||||
let bulk_revoke = move |_| {
|
||||
let ids = selected.get_clone();
|
||||
if ids.is_empty() {
|
||||
error.set(Some("select at least one certificate".to_string()));
|
||||
return;
|
||||
}
|
||||
if !dom::confirm(&format!("Revoke {} selected certificate(s)?", ids.len())) {
|
||||
return;
|
||||
}
|
||||
let payload = json!({
|
||||
"certificate_ids": ids,
|
||||
"reason": bulk_reason.get_clone(),
|
||||
"invalidity_date": if bulk_invalidity.get_clone().is_empty() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
json!(bulk_invalidity.get_clone())
|
||||
},
|
||||
});
|
||||
bulk_busy.set(true);
|
||||
spawn_local(async move {
|
||||
match api::bulk_revoke_certificates(id, &payload).await {
|
||||
Ok(value) => {
|
||||
let revoked = value
|
||||
.get("revoked")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(0);
|
||||
let skipped = value
|
||||
.get("skipped")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(0);
|
||||
let errors = value
|
||||
.get("errors")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(0);
|
||||
info.set(Some(format!(
|
||||
"Revoked {revoked} certificate(s); {skipped} already revoked; {errors} error(s)."
|
||||
)));
|
||||
selected.set(Vec::new());
|
||||
bulk_invalidity.set(String::new());
|
||||
refresh();
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
if bulk_busy.is_alive() {
|
||||
bulk_busy.set(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let content = move || {
|
||||
if loading.get() {
|
||||
return spinner();
|
||||
@@ -67,10 +167,11 @@ pub fn CertificatesPage(vault_id: String) -> View {
|
||||
|| cert.fingerprint_sha256.to_lowercase().contains(&query)
|
||||
})
|
||||
.collect();
|
||||
let chosen = selected.get_clone();
|
||||
let html = if plain.get() {
|
||||
flat_html(&visible, id)
|
||||
flat_html(&visible, id, &chosen)
|
||||
} else {
|
||||
tree_html(&visible, id)
|
||||
tree_html(&visible, id, &chosen)
|
||||
};
|
||||
view! {
|
||||
(page_header(
|
||||
@@ -90,7 +191,31 @@ pub fn CertificatesPage(vault_id: String) -> View {
|
||||
bind:value=filter)
|
||||
}
|
||||
}
|
||||
div(class="card", dangerously_set_inner_html=html) {}
|
||||
div(class="card mb-3") {
|
||||
div(class="card-body") {
|
||||
div(class="d-flex flex-wrap gap-2 align-items-end") {
|
||||
div(class="me-auto") {
|
||||
div(class="small text-secondary") { (format!("{} selected", selected.get_clone().len())) }
|
||||
div(class="d-flex gap-2 mt-1") {
|
||||
button(class="btn btn-sm btn-outline-light", on:click=select_all) { "Select all revocable" }
|
||||
button(class="btn btn-sm btn-outline-light", on:click=clear_selection) { "Clear" }
|
||||
}
|
||||
}
|
||||
div() {
|
||||
label(class="form-label small mb-1") { "Reason" }
|
||||
select(class="form-select form-select-sm", bind:value=bulk_reason) { (reason_options()) }
|
||||
}
|
||||
div() {
|
||||
label(class="form-label small mb-1") { "Invalid since (optional)" }
|
||||
input(class="form-control form-control-sm", r#type="datetime-local", bind:value=bulk_invalidity)
|
||||
}
|
||||
button(class="btn btn-warning", disabled=bulk_busy.get() || selected.get_clone().is_empty(), on:click=bulk_revoke) {
|
||||
(if bulk_busy.get() { "Revoking…" } else { "Revoke selected" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="card", on:change=toggle, dangerously_set_inner_html=html) {}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,12 +228,13 @@ pub fn CertificatesPage(vault_id: String) -> View {
|
||||
(app_nav("/", Some(id), nav))
|
||||
main(class="container page-shell py-4") {
|
||||
(toast_error(error))
|
||||
(crate::components::toast::toast(info, true))
|
||||
(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
|
||||
fn flat_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String {
|
||||
if rows.is_empty() {
|
||||
return "<p class=\"empty-state\">No certificates match.</p>".to_string();
|
||||
}
|
||||
@@ -116,7 +242,8 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
|
||||
for cert in rows {
|
||||
let (badge_class, badge_label) = status_badge(cert);
|
||||
body.push_str(&format!(
|
||||
"<tr><td><a href=\"/vaults/{}/{}\">{}</a><div class=\"item-sub\">{}</div></td><td>{}</td><td>{}</td><td class=\"fingerprint\">{}</td><td>{}</td><td><span class=\"badge {}\">{}</span>{}</td><td class=\"fingerprint\">{}</td></tr>",
|
||||
"<tr><td>{}</td><td><a href=\"/vaults/{}/{}\">{}</a><div class=\"item-sub\">{}</div></td><td>{}</td><td>{}</td><td class=\"fingerprint\">{}</td><td>{}</td><td><span class=\"badge {}\">{}</span>{}</td><td class=\"fingerprint\">{}</td></tr>",
|
||||
checkbox(cert, selected),
|
||||
escape(vault_id),
|
||||
escape(&cert.id),
|
||||
escape(&cert.name),
|
||||
@@ -132,11 +259,11 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
|
||||
));
|
||||
}
|
||||
format!(
|
||||
"<div class=\"table-responsive\"><table class=\"table table-hover align-middle mb-0\"><thead><tr><th>Name</th><th>Issuer</th><th>Type</th><th>Serial</th><th>Expires</th><th>Status</th><th>SHA-256</th></tr></thead><tbody>{body}</tbody></table></div>"
|
||||
"<div class=\"table-responsive\"><table class=\"table table-hover align-middle mb-0\"><thead><tr><th></th><th>Name</th><th>Issuer</th><th>Type</th><th>Serial</th><th>Expires</th><th>Status</th><th>SHA-256</th></tr></thead><tbody>{body}</tbody></table></div>"
|
||||
)
|
||||
}
|
||||
|
||||
fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String {
|
||||
fn tree_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String {
|
||||
if rows.is_empty() {
|
||||
return "<p class=\"empty-state\">No certificates match.</p>".to_string();
|
||||
}
|
||||
@@ -157,7 +284,7 @@ fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String {
|
||||
roots.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
let mut html = String::from("<div class=\"card-body\"><ul class=\"cert-tree\">");
|
||||
for root in &roots {
|
||||
html.push_str(&node_html(root, &children, vault_id));
|
||||
html.push_str(&node_html(root, &children, vault_id, selected));
|
||||
}
|
||||
html.push_str("</ul></div>");
|
||||
html
|
||||
@@ -167,6 +294,7 @@ fn node_html(
|
||||
cert: &CertificateSummary,
|
||||
children: &HashMap<String, Vec<CertificateSummary>>,
|
||||
vault_id: &str,
|
||||
selected: &[String],
|
||||
) -> String {
|
||||
let (badge_class, badge_label) = status_badge(cert);
|
||||
let sub = if cert.subject == cert.issuer_name {
|
||||
@@ -175,7 +303,8 @@ fn node_html(
|
||||
format!("issuer: {}", cert.issuer_name)
|
||||
};
|
||||
let mut row = format!(
|
||||
"<div class=\"cert-row\"><a class=\"item-name\" href=\"/vaults/{}/{}\">{}</a>{} <span class=\"badge {}\">{}</span><span class=\"item-sub\">{}</span><span class=\"item-sub\">expires {}</span><span class=\"item-sub fingerprint\">{}</span></div>",
|
||||
"<div class=\"cert-row\">{}<a class=\"item-name\" href=\"/vaults/{}/{}\">{}</a>{} <span class=\"badge {}\">{}</span><span class=\"item-sub\">{}</span><span class=\"item-sub\">expires {}</span><span class=\"item-sub fingerprint\">{}</span></div>",
|
||||
checkbox(cert, selected),
|
||||
escape(vault_id),
|
||||
escape(&cert.id),
|
||||
escape(&cert.name),
|
||||
@@ -183,7 +312,7 @@ fn node_html(
|
||||
badge_class,
|
||||
badge_label,
|
||||
escape(&sub),
|
||||
fmt_date(&cert.not_after),
|
||||
if cert.no_expiration { "No expiration".to_string() } else { fmt_date(&cert.not_after) },
|
||||
fmt_fp(&cert.fingerprint_sha256),
|
||||
);
|
||||
let kids = children.get(&cert.id);
|
||||
@@ -191,7 +320,7 @@ fn node_html(
|
||||
if !kids.is_empty() {
|
||||
row = format!(
|
||||
"<li><details class=\"cert-node\" open><summary>{row}</summary><ul class=\"cert-tree\">{}</ul></details></li>",
|
||||
kids.iter().map(|child| node_html(child, children, vault_id)).collect::<String>()
|
||||
kids.iter().map(|child| node_html(child, children, vault_id, selected)).collect::<String>()
|
||||
);
|
||||
} else {
|
||||
row = format!("<li>{row}</li>");
|
||||
@@ -202,6 +331,38 @@ fn node_html(
|
||||
row
|
||||
}
|
||||
|
||||
fn checkbox(cert: &CertificateSummary, selected: &[String]) -> String {
|
||||
if cert.issuer_id.is_none() {
|
||||
return "<input class=\"form-check-input\" type=\"checkbox\" disabled title=\"Self-signed certificates cannot be revoked\">".to_string();
|
||||
}
|
||||
let checked = if selected.contains(&cert.id) {
|
||||
" checked"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"<input class=\"form-check-input\" type=\"checkbox\" data-cert-id=\"{}\"{}>",
|
||||
escape(&cert.id),
|
||||
checked
|
||||
)
|
||||
}
|
||||
|
||||
fn reason_options() -> Vec<View> {
|
||||
[
|
||||
"unspecified",
|
||||
"keyCompromise",
|
||||
"CACompromise",
|
||||
"affiliationChanged",
|
||||
"superseded",
|
||||
"cessationOfOperation",
|
||||
"certificateHold",
|
||||
"privilegeWithdrawn",
|
||||
]
|
||||
.iter()
|
||||
.map(|reason| view! { option(value=*reason) { (*reason) } })
|
||||
.collect::<Vec<View>>()
|
||||
}
|
||||
|
||||
fn escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
|
||||
Reference in New Issue
Block a user