Close XCA parity gaps: advanced extensions, CA/revocation UI, imports, ZIP export, E2E
This commit is contained in:
1 parent
4f3a632f4b
commit
5eb7866f5b
27 files changed
+2054
-134
No files matched your search
@@ -7,7 +7,7 @@ use crate::api;
|
||||
use crate::components::nav::{app_nav, spinner, whoami};
|
||||
use crate::components::ui::{copy_button, empty_state, page_header, toast_error};
|
||||
use crate::dom;
|
||||
use crate::model::{fmt_date, CertificateDetail, Me, Revocation};
|
||||
use crate::model::{fmt_date, CertificateDetail, Me, Revocation, TemplateSummary};
|
||||
|
||||
pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
|
||||
@@ -27,6 +27,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
let chain = create_signal(Vec::<crate::model::ChainItem>::new());
|
||||
let rev_reason = create_signal("unspecified".to_string());
|
||||
let rev_serial = create_signal(String::new());
|
||||
let rev_invalidity = create_signal(String::new());
|
||||
let editing_rev = create_signal(String::new());
|
||||
let ca_template = create_signal(String::new());
|
||||
let templates = create_signal(Vec::<TemplateSummary>::new());
|
||||
let ca_days = create_signal("30".to_string());
|
||||
let crl_days = create_signal("30".to_string());
|
||||
let crl_hash = create_signal("sha256".to_string());
|
||||
@@ -44,6 +48,14 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
ca_days.set(days.to_string());
|
||||
crl_days.set(days.to_string());
|
||||
}
|
||||
if let Some(template) = value
|
||||
.authority
|
||||
.as_ref()
|
||||
.and_then(|a| a.default_template.clone())
|
||||
{
|
||||
ca_template.set(template);
|
||||
}
|
||||
let is_ca = value.summary.is_ca;
|
||||
subject_entries.set(value.subject_entries.clone());
|
||||
issuer_entries.set(value.issuer_entries.clone());
|
||||
extensions.set(value.extensions.clone());
|
||||
@@ -51,6 +63,11 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
purposes.set(value.purposes.clone());
|
||||
chain.set(value.chain.clone());
|
||||
cert.set(Some(value));
|
||||
if is_ca {
|
||||
if let Ok(page) = api::templates(id).await {
|
||||
templates.set(page.results);
|
||||
}
|
||||
}
|
||||
if let Ok(page) = api::revocations(id, cert_id_value).await {
|
||||
revocations.set(page.results);
|
||||
}
|
||||
@@ -76,7 +93,11 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
spawn_local(async move {
|
||||
match api::export_certificate(id, cert_id_value, &payload).await {
|
||||
Ok(payload) => {
|
||||
if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) {
|
||||
if let Err(message) = dom::download_base64(
|
||||
&payload.filename,
|
||||
&payload.content_type,
|
||||
&payload.content_base64,
|
||||
) {
|
||||
error.set(Some(message));
|
||||
}
|
||||
}
|
||||
@@ -143,6 +164,20 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
});
|
||||
};
|
||||
|
||||
let save_ca = move |_| {
|
||||
let payload = json!({
|
||||
"crl_days": ca_days.get_clone().parse::<i64>().unwrap_or(30),
|
||||
"template_id": if ca_template.get_clone().is_empty() { serde_json::Value::Null } else { json!(ca_template.get_clone()) },
|
||||
});
|
||||
spawn_local(async move {
|
||||
match api::update_ca(id, cert_id_value, &payload).await {
|
||||
Ok(_) => {}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let generate_crl = move |_| {
|
||||
let payload = json!({
|
||||
"days": crl_days.get_clone().parse::<i64>().unwrap_or(30),
|
||||
@@ -152,22 +187,36 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
});
|
||||
spawn_local(async move {
|
||||
match api::generate_crl(id, cert_id_value, &payload).await {
|
||||
Ok(crl) => sycamore_router::navigate(&format!("/vaults/{id}/crls/{}", crl.summary.id)),
|
||||
Ok(crl) => {
|
||||
sycamore_router::navigate(&format!("/vaults/{id}/crls/{}", crl.summary.id))
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let add_revocation = move |_| {
|
||||
let payload = json!({"reason": rev_reason.get_clone(), "serial_hex": rev_serial.get_clone()});
|
||||
let save_revocation = move |_| {
|
||||
let payload = json!({
|
||||
"reason": rev_reason.get_clone(),
|
||||
"serial_hex": rev_serial.get_clone(),
|
||||
"invalidity_date": if rev_invalidity.get_clone().is_empty() { serde_json::Value::Null } else { json!(rev_invalidity.get_clone()) },
|
||||
});
|
||||
let editing = editing_rev.get_clone();
|
||||
spawn_local(async move {
|
||||
match api::add_revocation(id, cert_id_value, &payload).await {
|
||||
let result = if editing.is_empty() {
|
||||
api::add_revocation(id, cert_id_value, &payload).await
|
||||
} else {
|
||||
api::update_revocation(id, cert_id_value, &editing, &payload).await
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
if let Ok(page) = api::revocations(id, cert_id_value).await {
|
||||
revocations.set(page.results);
|
||||
}
|
||||
rev_serial.set(String::new());
|
||||
rev_invalidity.set(String::new());
|
||||
editing_rev.set(String::new());
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
@@ -175,6 +224,18 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
});
|
||||
};
|
||||
|
||||
let edit_revocation = move |rev: Revocation| {
|
||||
editing_rev.set(rev.id.clone());
|
||||
rev_serial.set(rev.serial_hex.clone());
|
||||
rev_reason.set(rev.reason.clone());
|
||||
rev_invalidity.set(
|
||||
rev.invalidity_date
|
||||
.clone()
|
||||
.map(|value| value.chars().take(16).collect::<String>())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
};
|
||||
|
||||
let delete_revocation = move |rev_id: String| {
|
||||
spawn_local(async move {
|
||||
match api::delete_revocation(id, cert_id_value, &rev_id).await {
|
||||
@@ -340,6 +401,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,6 +411,24 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> 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" }
|
||||
@@ -376,19 +456,44 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
})
|
||||
h3(class="h6 mt-3") { "Revocations" }
|
||||
div(class="row g-2 align-items-end mb-2") {
|
||||
div(class="col-md-4") {
|
||||
div(class="col-md-3") {
|
||||
input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial)
|
||||
}
|
||||
div(class="col-md-4") {
|
||||
div(class="col-md-3") {
|
||||
select(class="form-select", bind:value=rev_reason) { (reason_options()) }
|
||||
}
|
||||
div(class="col-md-4") {
|
||||
button(class="btn btn-outline-light", on:click=add_revocation) { "Add revocation" }
|
||||
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() {} } }
|
||||
tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); view! { tr() { td(class="fingerprint") { (rev.serial_hex) } td() { (fmt_date(&rev.revoked_at)) } td() { (rev.reason) } td() { button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" } } } } }) }
|
||||
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" }
|
||||
}
|
||||
}
|
||||
} }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,8 +519,17 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
|
||||
}
|
||||
|
||||
fn reason_options() -> Vec<View> {
|
||||
["unspecified", "keyCompromise", "CACompromise", "affiliationChanged", "superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn"]
|
||||
.iter()
|
||||
.map(|reason| view! { option(value=*reason) { (*reason) } })
|
||||
.collect::<Vec<View>>()
|
||||
[
|
||||
"unspecified",
|
||||
"keyCompromise",
|
||||
"CACompromise",
|
||||
"affiliationChanged",
|
||||
"superseded",
|
||||
"cessationOfOperation",
|
||||
"certificateHold",
|
||||
"privilegeWithdrawn",
|
||||
]
|
||||
.iter()
|
||||
.map(|reason| view! { option(value=*reason) { (*reason) } })
|
||||
.collect::<Vec<View>>()
|
||||
}
|
||||
Reference in new issue
Block a user