Files
kca/web/src/pages/request_detail.rs
T
woggioni 4f3a632f4b Implement KCA: web reimplementation of XCA PKI workflows
- kaya backend with OIDC sessions, API tokens, Postgres/Tortoise models
- AES-256-GCM private key storage and full certificate/CSR/CRL domain logic
- Sycamore/WASM SPA for vaults, keys, requests, certificates, CRLs and templates
- imports/exports, XCA .xdb migration, Docker Compose stack and tests
2026-10-23 02:06:55 +00:00

233 lines
10 KiB
Rust

//! Certificate signing request detail.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{empty_state, page_header, toast_error};
use crate::dom;
use crate::model::{fmt_fp, Me, RequestDetail};
pub fn RequestDetailPage(vault_id: String, request_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let req_id: &'static str = Box::leak(request_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let request = create_signal(None::<RequestDetail>);
let error = create_signal(None::<String>);
let loading = create_signal(true);
let name = create_signal(String::new());
let comment = create_signal(String::new());
let request_name = create_signal(String::new());
let request_subject = create_signal(String::new());
let signature_label = create_signal(String::new());
let export_format = create_signal("pem".to_string());
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
match api::request(id, req_id).await {
Ok(value) => {
name.set(value.summary.name.clone());
comment.set(value.summary.comment.clone());
request_name.set(value.summary.name.clone());
request_subject.set(value.summary.subject.clone());
signature_label.set(if value.signature_valid { "PKCS#10 signature valid".to_string() } else { "PKCS#10 signature INVALID".to_string() });
request.set(Some(value));
}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
let save = move |_| {
let value = request.get_clone();
let Some(value) = value else { return };
let payload = json!({"name": name.get_clone(), "comment": comment.get_clone(), "version": value.summary.version});
spawn_local(async move {
match api::update_request(id, req_id, &payload).await {
Ok(updated) => {
request_name.set(updated.summary.name.clone());
request.set(Some(updated));
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let export = move |_| {
let fmt = export_format.get_clone();
spawn_local(async move {
match api::export_request(id, req_id, &json!({"format": fmt})).await {
Ok(payload) => {
if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) {
error.set(Some(message));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let remove = move |_| {
if !dom::confirm("Delete this certificate signing request?") {
return;
}
spawn_local(async move {
match api::delete_request(id, req_id).await {
Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/requests")),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let extract = move |_| {
spawn_local(async move {
match api::transform_request(id, req_id, "open-key").await {
Ok(value) => {
if let Some(key_id) = value.get("id").and_then(|v| v.as_str()) {
sycamore_router::navigate(&format!("/vaults/{id}/keys/{key_id}"));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let to_template = move |_| {
spawn_local(async move {
match api::transform_request(id, req_id, "template").await {
Ok(value) => {
if let Some(template_id) = value.get("id").and_then(|v| v.as_str()) {
sycamore_router::navigate(&format!("/vaults/{id}/templates/{template_id}"));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let content = move || {
if loading.get() {
return spinner();
}
let Some(value) = request.get_clone() else {
return empty_state("Request not found.");
};
let mut html = String::new();
html.push_str("<table class=\"table table-sm mb-0\"><tbody>");
html.push_str(&format!("<tr><td class=\"text-secondary\">Subject</td><td class=\"mono\">{}</td></tr>", escape(&value.subject_rfc4514)));
html.push_str(&format!("<tr><td class=\"text-secondary\">Signature</td><td>{}</td></tr>", escape(&value.signature_algorithm)));
html.push_str(&format!("<tr><td class=\"text-secondary\">Fingerprint</td><td class=\"fingerprint\">{}</td></tr>", fmt_fp(&value.summary.fingerprint_sha256)));
html.push_str(&format!("<tr><td class=\"text-secondary\">Certificates issued</td><td>{}</td></tr>", value.summary.certificate_count));
html.push_str("</tbody></table>");
let mut subject_html = String::new();
for entry in &value.subject_entries {
subject_html.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>", escape(&entry.name), escape(&entry.value)));
}
let mut ext_html = String::new();
for ext in &value.extensions {
ext_html.push_str(&format!(
"<tr><td>{}{}</td><td class=\"extension-value\">{}</td></tr>",
escape(&ext.name),
if ext.critical { " <span class=\"badge badge-critical\">critical</span>" } else { "" },
escape(&ext.value),
));
}
let mut attr_html = String::new();
for attr in &value.attributes {
attr_html.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>", escape(&attr.name), escape(&attr.values.join(", "))));
}
view! {
(page_header(request_name.get_clone(), request_subject.get_clone(), view! {
a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/new?request={req_id}")) { "Sign request" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/requests")) { "Back" }
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
}))
div(class="alert alert-info") { (signature_label.get_clone()) }
div(class="row g-3") {
div(class="col-12 col-lg-7") {
div(class="card mb-3") {
div(class="card-header") { "Request" }
div(class="card-body", dangerously_set_inner_html=html) {}
}
div(class="card mb-3") {
div(class="card-header") { "Subject" }
table(class="table table-sm mb-0") { tbody(dangerously_set_inner_html=subject_html) {} }
}
div(class="card mb-3") {
div(class="card-header") { "Requested extensions" }
div(class="table-responsive") {
table(class="table table-sm ext-table mb-0") {
thead() { tr() { th() { "Extension" } th() { "Value" } } }
tbody(dangerously_set_inner_html=ext_html) {}
}
}
}
div(class="card") {
div(class="card-header") { "Attributes" }
table(class="table table-sm mb-0") { tbody(dangerously_set_inner_html=attr_html) {} }
}
}
div(class="col-12 col-lg-5") {
div(class="card mb-3") {
div(class="card-header") { "Edit" }
div(class="card-body") {
div(class="mb-2") {
label(class="form-label") { "Name" }
input(class="form-control", bind:value=name)
}
div(class="mb-2") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="3", bind:value=comment) {}
}
button(class="btn btn-primary", on:click=save) { "Save" }
}
}
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="der") { "DER" }
option(value="openssl-config") { "OpenSSL config" }
}
button(class="btn btn-primary", on:click=export) { "Download" }
}
}
div(class="card") {
div(class="card-header") { "Transform" }
div(class="card-body d-grid gap-2") {
button(class="btn btn-outline-light", on:click=extract) { "Extract public key" }
button(class="btn btn-outline-light", on:click=to_template) { "Create template" }
}
}
}
}
}
};
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))
(content)
}
}
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}