//! Certificate detail, validation and CA actions. use serde_json::json; use sycamore::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api; use crate::components::crypto::{apply_signature_hash, signature_hash_input}; 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, OptionsPage, Revocation, TemplateSummary, }; pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let cert_id_value: &'static str = Box::leak(cert_id.clone().into_boxed_str()); let me = create_signal(None::); let options = create_signal(None::); let cert = create_signal(None::); let error = create_signal(None::); let loading = create_signal(true); let export_format = create_signal("pem".to_string()); let export_password = create_signal(String::new()); let revocations = create_signal(Vec::::new()); let subject_entries = create_signal(Vec::::new()); let issuer_entries = create_signal(Vec::::new()); let extensions = create_signal(Vec::::new()); let validation_errors = create_signal(Vec::::new()); let purposes = create_signal(Vec::::new()); let chain = create_signal(Vec::::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::::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()); 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::); 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 || { can_revoke.set(false); spawn_local(async move { match api::certificate(id, cert_id_value).await { Ok(value) => { new_name.set(value.summary.name.clone()); new_comment.set(value.summary.comment.clone()); if let Some(days) = value.authority.as_ref().and_then(|a| a.crl_days) { 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()); validation_errors.set(value.validation_errors.clone()); purposes.set(value.purposes.clone()); chain.set(value.chain.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)); 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 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); } } Err(message) => error.set(Some(message)), } }); }; spawn_local(async move { if let Ok(user) = api::me().await { me.set(Some(user)); } if let Ok(page) = api::options(id).await { options.set(Some(page)); } loading.set(false); }); load(); let do_export = move |_| { let mut payload = json!({"format": export_format.get_clone()}); if !export_password.get_clone().is_empty() { payload["password"] = json!(export_password.get_clone()); } spawn_local(async move { match api::export_certificate(id, cert_id_value, &payload).await { Ok(payload) => { match dom::download_base64( &payload.filename, &payload.content_type, &payload.content_base64, ) { Ok(()) => export_open.set(false), Err(message) => error.set(Some(message)), } } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); }; let revoke = move |_| { if !can_revoke.get() { return; } let payload = json!({"reason": rev_reason.get_clone()}); spawn_local(async move { match api::revoke_certificate(id, cert_id_value, &payload).await { Ok(_) => { info.set(Some("Certificate revoked.".to_string())); revoke_open.set(false); load(); } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); }; let open_revoke = move |_| { actions_open.set(false); revoke_open.set(true); }; let remove = move |_| { actions_open.set(false); if !dom::confirm("Delete this certificate?") { return; } spawn_local(async move { match api::delete_certificate(id, cert_id_value).await { Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/certificates")), Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); }; let save_meta = move |_| { let value = cert.get_clone(); let Some(value) = value else { return }; 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)); info.set(Some("Certificate metadata saved.".to_string())); } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); }; let transform = move |action: &'static str| { spawn_local(async move { match api::transform_certificate(id, cert_id_value, action).await { Ok(value) => { if let Some(item_id) = value.get("id").and_then(|v| v.as_str()) { let route = match action { "open-key" => "keys", "request" => "requests", "template" => "templates", _ => "certificates", }; sycamore_router::navigate(&format!("/vaults/{id}/{route}/{item_id}")); } } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); }; let save_ca = move |_| { let payload = json!({ "crl_days": ca_days.get_clone().parse::().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(_) => info.set(Some("CA properties saved.".to_string())), Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); }; let ca_key_type = move || { cert.get_clone() .and_then(|certificate| certificate.key_type.clone()) }; let generate_crl = move |_| { let mut payload = json!({ "days": crl_days.get_clone().parse::().unwrap_or(30), "with_reason": true, "include_aki": true, }); apply_signature_hash(&mut payload, ca_key_type().as_deref(), &crl_hash.get_clone()); 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)) } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); }; 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 { 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()); info.set(Some("Revocation list saved.".to_string())); } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); }; 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::()) .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 { Ok(()) => { if let Ok(page) = api::revocations(id, cert_id_value).await { revocations.set(page.results); } } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); }; let make_tls_auth = move |_| { spawn_local(async move { match api::tls_auth(id, cert_id_value, &json!({})).await { Ok(value) => { if let Some(key) = value.get("key").and_then(|v| v.as_str()) { tls_auth_text.set(key.to_string()); } } Err(message) if message == api::LOGIN_REQUIRED => {} Err(message) => error.set(Some(message)), } }); }; let tab = move |key: &'static str, label: &'static str| -> View { let panel_id = format!("panel-{key}"); let tab_id = format!("tab-{key}"); view! { li(class="nav-item", role="presentation") { button(class=if active_tab.get_clone() == key { "nav-link active" } else { "nav-link" }, id=tab_id, r#type="button", role="tab", aria-controls=panel_id, aria-selected=if active_tab.get_clone() == key { "true" } else { "false" }, 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(); } let Some(value) = cert.get_clone() else { return empty_state("Certificate not found."); }; let summary = value.summary.clone(); 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" } button(class="btn btn-outline-light", r#type="button", on:click=move |_| { actions_open.set(false); transform_open.set(false); export_open.set(true); }) { "Export" } div(class="dropdown") { button(class="btn btn-primary dropdown-toggle", r#type="button", aria-haspopup="true", aria-expanded=if actions_open.get() { "true" } else { "false" }, on:click=move |_| { 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-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() { (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" } } }) } } } } ul(class="nav nav-tabs mb-3", role="tablist", aria-label="Certificate sections") { (tab("overview", "Overview")) (tab("extensions", "Extensions")) (tab("validation", "Validation")) (tab("metadata", "Metadata")) (if is_ca { tab("ca", "CA") } else { view! {} }) } (if active_tab.get_clone() == "overview" { view! { div(id="panel-overview", role="tabpanel", aria-labelledby="tab-overview", 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) } } }) } } } } } } } else { view! {} }) (if active_tab.get_clone() == "extensions" { view! { div(id="panel-extensions", role="tabpanel", aria-labelledby="tab-extensions", 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(scope="col") { "Extension" } th(scope="col") { "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) } } }) } } } } } } else { view! {} }) (if active_tab.get_clone() == "validation" { view! { div(id="panel-validation", role="tabpanel", aria-labelledby="tab-validation", 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) } }) } } } } } else { view! {} }) (if active_tab.get_clone() == "metadata" { view! { div(id="panel-metadata", role="tabpanel", aria-labelledby="tab-metadata", 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 is_ca && active_tab.get_clone() == "ca" { view! { div(id="panel-ca", role="tabpanel", aria-labelledby="tab-ca", 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::>()) } } 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") { (signature_hash_input( "Hash", options, ca_key_type, crl_hash, )) } 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(scope="col") { "Serial" } th(scope="col") { "Revoked" } th(scope="col") { "Reason" } th(scope="col") { "Invalid since" } th(scope="col") {} } } 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! {} }) (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! {} }) } }; let nav = move || match me.get_clone() { Some(user) => whoami(&user), None => view! {}, }; 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" | "metadata" | "ca" => hash.to_string(), _ => "overview".to_string(), } } fn reason_options() -> Vec { [ "unspecified", "keyCompromise", "CACompromise", "affiliationChanged", "superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn", ] .iter() .map(|reason| view! { option(value=*reason) { (*reason) } }) .collect::>() }