- 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
233 lines
10 KiB
Rust
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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
|
}
|