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
This commit is contained in:
@@ -0,0 +1,727 @@
|
||||
//! Certificate creation wizard and renewal form.
|
||||
use serde_json::json;
|
||||
use sycamore::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::nav::{app_nav, whoami};
|
||||
use crate::components::ui::{page_header, toast_error};
|
||||
use crate::model::{CertificateDetail, CertificateSummary, KeySummary, Me, TemplateSummary};
|
||||
|
||||
pub fn CertificateNewPage(vault_id: String) -> View {
|
||||
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
|
||||
let me = create_signal(None::<Me>);
|
||||
let keys = create_signal(Vec::<KeySummary>::new());
|
||||
let signers = create_signal(Vec::<CertificateSummary>::new());
|
||||
let templates = create_signal(Vec::<TemplateSummary>::new());
|
||||
let error = create_signal(None::<String>);
|
||||
let saving = create_signal(false);
|
||||
|
||||
let request_id = query_param("request").unwrap_or_default();
|
||||
let request_mode = !request_id.is_empty();
|
||||
let request_id_for_load = request_id.clone();
|
||||
|
||||
let name = create_signal(String::new());
|
||||
let comment = create_signal(String::new());
|
||||
let signer_id = create_signal("self".to_string());
|
||||
let key_id = create_signal(String::new());
|
||||
let new_key = create_signal(false);
|
||||
let key_type = create_signal("rsa".to_string());
|
||||
let key_size = create_signal("2048".to_string());
|
||||
let template_id = create_signal(String::new());
|
||||
let signature_hash = create_signal("sha256".to_string());
|
||||
|
||||
let cn = create_signal(String::new());
|
||||
let country = create_signal(String::new());
|
||||
let state = create_signal(String::new());
|
||||
let locality = create_signal(String::new());
|
||||
let organization = create_signal(String::new());
|
||||
let org_unit = create_signal(String::new());
|
||||
let email = create_signal(String::new());
|
||||
|
||||
let ca_mode = create_signal("end".to_string());
|
||||
let path_length = create_signal(String::new());
|
||||
let bc_critical = create_signal(true);
|
||||
let ku_digital = create_signal(true);
|
||||
let ku_encipher = create_signal(false);
|
||||
let ku_key_agreement = create_signal(false);
|
||||
let ku_cert_sign = create_signal(false);
|
||||
let ku_crl_sign = create_signal(false);
|
||||
let ku_critical = create_signal(true);
|
||||
let eku_server = create_signal(false);
|
||||
let eku_client = create_signal(false);
|
||||
let eku_code = create_signal(false);
|
||||
let eku_email = create_signal(false);
|
||||
let eku_time = create_signal(false);
|
||||
let eku_ocsp = create_signal(false);
|
||||
let eku_critical = create_signal(false);
|
||||
let san_text = create_signal(String::new());
|
||||
let ski = create_signal(true);
|
||||
let aki = create_signal(true);
|
||||
let ocsp_staple = create_signal(false);
|
||||
|
||||
let validity_amount = create_signal("365".to_string());
|
||||
let validity_unit = create_signal("days".to_string());
|
||||
let midnight = create_signal(false);
|
||||
|
||||
|
||||
spawn_local(async move {
|
||||
if let Ok(user) = api::me().await {
|
||||
if me.is_alive() {
|
||||
me.set(Some(user));
|
||||
}
|
||||
}
|
||||
if let Ok(page) = api::keys(&id).await {
|
||||
keys.set(page.results);
|
||||
}
|
||||
if let Ok(page) = api::certificates(&id).await {
|
||||
signers.set(page.results.into_iter().filter(|cert| cert.is_ca && cert.has_private_key).collect());
|
||||
}
|
||||
if let Ok(page) = api::templates(&id).await {
|
||||
templates.set(page.results);
|
||||
}
|
||||
if request_mode {
|
||||
if let Ok(value) = api::request(&id, &request_id_for_load).await {
|
||||
name.set(format!("{} certificate", value.summary.name));
|
||||
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "CN") {
|
||||
cn.set(first.value.clone());
|
||||
}
|
||||
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "O") {
|
||||
organization.set(first.value.clone());
|
||||
}
|
||||
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "OU") {
|
||||
org_unit.set(first.value.clone());
|
||||
}
|
||||
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "C") {
|
||||
country.set(first.value.clone());
|
||||
}
|
||||
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "ST") {
|
||||
state.set(first.value.clone());
|
||||
}
|
||||
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "L") {
|
||||
locality.set(first.value.clone());
|
||||
}
|
||||
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "emailAddress") {
|
||||
email.set(first.value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let apply_template = {
|
||||
let id = id.clone();
|
||||
move |_| {
|
||||
let template = template_id.get_clone();
|
||||
if template.is_empty() {
|
||||
return;
|
||||
}
|
||||
let id = id.clone();
|
||||
spawn_local(async move {
|
||||
match api::template(&id, &template).await {
|
||||
Ok(template) => {
|
||||
if let Some(settings) = template.settings.get("extensions") {
|
||||
if let Some(bc) = settings.get("basic_constraints") {
|
||||
if bc.get("ca").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
ca_mode.set("ca".to_string());
|
||||
if let Some(len) = bc.get("path_length").and_then(|v| v.as_i64()) {
|
||||
path_length.set(len.to_string());
|
||||
}
|
||||
} else if bc.get("ca").is_some() {
|
||||
ca_mode.set("end".to_string());
|
||||
}
|
||||
bc_critical.set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
|
||||
}
|
||||
if let Some(ku) = settings.get("key_usage") {
|
||||
let usages = string_array(ku.get("usages"));
|
||||
ku_digital.set(usages.iter().any(|s| s == "digital_signature"));
|
||||
ku_encipher.set(usages.iter().any(|s| s == "key_encipherment"));
|
||||
ku_key_agreement.set(usages.iter().any(|s| s == "key_agreement"));
|
||||
ku_cert_sign.set(usages.iter().any(|s| s == "key_cert_sign"));
|
||||
ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign"));
|
||||
ku_critical.set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
|
||||
}
|
||||
if let Some(eku) = settings.get("extended_key_usage") {
|
||||
let usages = string_array(eku.get("usages"));
|
||||
eku_server.set(usages.iter().any(|s| s == "serverAuth"));
|
||||
eku_client.set(usages.iter().any(|s| s == "clientAuth"));
|
||||
eku_code.set(usages.iter().any(|s| s == "codeSigning"));
|
||||
eku_email.set(usages.iter().any(|s| s == "emailProtection"));
|
||||
eku_time.set(usages.iter().any(|s| s == "timeStamping"));
|
||||
eku_ocsp.set(usages.iter().any(|s| s == "OCSPSigning"));
|
||||
eku_critical.set(eku.get("critical").and_then(|v| v.as_bool()).unwrap_or(false));
|
||||
}
|
||||
if let Some(san) = settings.get("subject_alt_name") {
|
||||
let entries = san.get("entries").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let text = entries.iter().filter_map(|entry| {
|
||||
let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns");
|
||||
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if value.is_empty() { None } else { Some(format!("{}:{}", kind.to_uppercase(), value)) }
|
||||
}).collect::<Vec<_>>().join(", ");
|
||||
san_text.set(text);
|
||||
}
|
||||
ski.set(settings.get("subject_key_identifier").is_some());
|
||||
aki.set(settings.get("authority_key_identifier").is_some());
|
||||
}
|
||||
if let Some(subject) = template.subject.as_array() {
|
||||
for entry in subject {
|
||||
let key = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
match key {
|
||||
"CN" => cn.set(value),
|
||||
"O" => organization.set(value),
|
||||
"OU" => org_unit.set(value),
|
||||
"C" => country.set(value),
|
||||
"ST" => state.set(value),
|
||||
"L" => locality.set(value),
|
||||
"emailAddress" => email.set(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(validity) = template.settings.get("validity") {
|
||||
if let Some(amount) = validity.get("amount").and_then(|v| v.as_i64()) {
|
||||
validity_amount.set(amount.to_string());
|
||||
}
|
||||
if let Some(unit) = validity.get("unit").and_then(|v| v.as_str()) {
|
||||
validity_unit.set(unit.to_string());
|
||||
}
|
||||
midnight.set(validity.get("midnight").and_then(|v| v.as_bool()).unwrap_or(false));
|
||||
}
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let submit = {
|
||||
let id = id.clone();
|
||||
let request_id = request_id.clone();
|
||||
move |_| {
|
||||
let cert_name = name.get_clone();
|
||||
if cert_name.trim().is_empty() {
|
||||
error.set(Some("certificate name is required".to_string()));
|
||||
return;
|
||||
}
|
||||
let subject = build_subject(&[
|
||||
("C", country.get_clone()),
|
||||
("ST", state.get_clone()),
|
||||
("L", locality.get_clone()),
|
||||
("O", organization.get_clone()),
|
||||
("OU", org_unit.get_clone()),
|
||||
("CN", cn.get_clone()),
|
||||
("emailAddress", email.get_clone()),
|
||||
]);
|
||||
if subject.is_empty() {
|
||||
error.set(Some("the subject must not be empty".to_string()));
|
||||
return;
|
||||
}
|
||||
let mut extensions = json!({});
|
||||
let mode = ca_mode.get_clone();
|
||||
let crit = bc_critical.get();
|
||||
if mode == "ca" {
|
||||
extensions["basic_constraints"] = json!({
|
||||
"critical": crit,
|
||||
"ca": true,
|
||||
"path_length": path_length.get_clone().parse::<i64>().ok(),
|
||||
});
|
||||
} else if mode == "end" {
|
||||
extensions["basic_constraints"] = json!({"critical": crit, "ca": false, "path_length": null});
|
||||
}
|
||||
let usages = [
|
||||
(ku_digital.get(), "digital_signature"),
|
||||
(ku_encipher.get(), "key_encipherment"),
|
||||
(ku_key_agreement.get(), "key_agreement"),
|
||||
(ku_cert_sign.get(), "key_cert_sign"),
|
||||
(ku_crl_sign.get(), "crl_sign"),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(checked, _)| *checked)
|
||||
.map(|(_, name)| json!(name))
|
||||
.collect::<Vec<_>>();
|
||||
extensions["key_usage"] = json!({"critical": ku_critical.get(), "usages": usages});
|
||||
let eku = [
|
||||
(eku_server.get(), "serverAuth"),
|
||||
(eku_client.get(), "clientAuth"),
|
||||
(eku_code.get(), "codeSigning"),
|
||||
(eku_email.get(), "emailProtection"),
|
||||
(eku_time.get(), "timeStamping"),
|
||||
(eku_ocsp.get(), "OCSPSigning"),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(checked, _)| *checked)
|
||||
.map(|(_, name)| json!(name))
|
||||
.collect::<Vec<_>>();
|
||||
extensions["extended_key_usage"] = json!({"critical": eku_critical.get(), "usages": eku});
|
||||
let san = parse_san(&san_text.get_clone());
|
||||
if !san.is_empty() {
|
||||
extensions["subject_alt_name"] = json!({"critical": false, "entries": san});
|
||||
}
|
||||
if ski.get() {
|
||||
extensions["subject_key_identifier"] = json!({"critical": false});
|
||||
}
|
||||
if aki.get() {
|
||||
extensions["authority_key_identifier"] = json!({"critical": false});
|
||||
}
|
||||
if ocsp_staple.get() {
|
||||
extensions["tls_feature"] = json!({"critical": false, "features": ["status_request"]});
|
||||
}
|
||||
let signer = signer_id.get_clone();
|
||||
let mut payload = json!({
|
||||
"name": cert_name,
|
||||
"comment": comment.get_clone(),
|
||||
"subject": subject,
|
||||
"extensions": extensions,
|
||||
"signature_hash": signature_hash.get_clone(),
|
||||
"signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) },
|
||||
"validity": {
|
||||
"amount": validity_amount.get_clone().parse::<i64>().unwrap_or(365),
|
||||
"unit": validity_unit.get_clone(),
|
||||
"midnight": midnight.get(),
|
||||
},
|
||||
});
|
||||
if !request_id.is_empty() {
|
||||
payload["request_id"] = json!(request_id);
|
||||
payload["modify_subject"] = json!(true);
|
||||
payload["copy_request_extensions"] = json!(false);
|
||||
} else if new_key.get() {
|
||||
payload["new_key"] = json!({
|
||||
"key_type": key_type.get_clone(),
|
||||
"size": key_size.get_clone().parse::<i64>().unwrap_or(2048),
|
||||
"name": format!("{} key", name.get_clone()),
|
||||
});
|
||||
} else if !key_id.get_clone().is_empty() {
|
||||
payload["key_id"] = json!(key_id.get_clone());
|
||||
} else {
|
||||
error.set(Some("select a key or generate a new one".to_string()));
|
||||
return;
|
||||
}
|
||||
saving.set(true);
|
||||
let id = id.clone();
|
||||
spawn_local(async move {
|
||||
match api::create_certificate(&id, &payload).await {
|
||||
Ok(cert) => {
|
||||
saving.set(false);
|
||||
sycamore_router::navigate(&format!("/vaults/{}/certificates/{}", id, cert.summary.id));
|
||||
return;
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
if saving.is_alive() { saving.set(false); }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let signer_options = move || {
|
||||
signers.get_clone().into_iter().map(|cert| view! {
|
||||
option(value=cert.id.clone()) { (cert.name.clone()) }
|
||||
}).collect::<Vec<View>>()
|
||||
};
|
||||
let key_options = move || {
|
||||
keys.get_clone().into_iter().filter(|key| key.has_private).map(|key| view! {
|
||||
option(value=key.id.clone()) { (key.name.clone()) }
|
||||
}).collect::<Vec<View>>()
|
||||
};
|
||||
let template_options = move || {
|
||||
templates.get_clone().into_iter().map(|template| view! {
|
||||
option(value=template.id.clone()) { (if template.builtin { format!("[built-in] {}", template.name) } else { template.name.clone() }) }
|
||||
}).collect::<Vec<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))
|
||||
(page_header(
|
||||
if request_mode { "Sign certificate request" } else { "New certificate" },
|
||||
"Certificates can be self-signed or issued by a CA with a private key in this vault.",
|
||||
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Cancel" } },
|
||||
))
|
||||
(if request_mode {
|
||||
view! { div(class="alert alert-info") { "The subject and public key come from the selected request; the fields below override the subject." } }
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
div(class="card") {
|
||||
div(class="card-body") {
|
||||
div(class="form-section") {
|
||||
div(class="form-section-title") { "Source" }
|
||||
div(class="row g-3") {
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { "Issuer" }
|
||||
select(class="form-select", bind:value=signer_id) {
|
||||
option(value="self") { "Self-signed" }
|
||||
(signer_options())
|
||||
}
|
||||
}
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { "Signature hash" }
|
||||
select(class="form-select", bind:value=signature_hash) {
|
||||
option(value="sha256") { "SHA-256" }
|
||||
option(value="sha384") { "SHA-384" }
|
||||
option(value="sha512") { "SHA-512" }
|
||||
option(value="sha1") { "SHA-1" }
|
||||
}
|
||||
}
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { "Template" }
|
||||
div(class="input-group") {
|
||||
select(class="form-select", bind:value=template_id) {
|
||||
option(value="") { "— no template —" }
|
||||
(template_options())
|
||||
}
|
||||
button(class="btn btn-outline-light", on:click=apply_template) { "Apply" }
|
||||
}
|
||||
}
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { "Internal name" }
|
||||
input(class="form-control", bind:value=name)
|
||||
}
|
||||
div(class="col-12") {
|
||||
label(class="form-label") { "Comment" }
|
||||
textarea(class="form-control", rows="2", bind:value=comment) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="form-section") {
|
||||
div(class="form-section-title") { "Subject" }
|
||||
div(class="row g-3") {
|
||||
(subject_input("Common name (CN)", cn))
|
||||
(subject_input("Organization (O)", organization))
|
||||
(subject_input("Organizational unit (OU)", org_unit))
|
||||
(subject_input("Country (C)", country))
|
||||
(subject_input("State / province (ST)", state))
|
||||
(subject_input("Locality (L)", locality))
|
||||
(subject_input("E-mail address", email))
|
||||
}
|
||||
}
|
||||
(if request_mode {
|
||||
view! {}
|
||||
} else {
|
||||
view! {
|
||||
div(class="form-section") {
|
||||
div(class="form-section-title") { "Private key" }
|
||||
div(class="form-check mb-2") {
|
||||
input(class="form-check-input", r#type="checkbox", id="new-key", bind:checked=new_key)
|
||||
label(class="form-check-label", r#for="new-key") { "Generate a new key" }
|
||||
}
|
||||
(if new_key.get() {
|
||||
view! {
|
||||
div(class="row g-3") {
|
||||
div(class="col-md-4") {
|
||||
label(class="form-label") { "Key type" }
|
||||
select(class="form-select", bind:value=key_type) {
|
||||
option(value="rsa") { "RSA" }
|
||||
option(value="ec") { "EC" }
|
||||
option(value="ed25519") { "ED25519" }
|
||||
}
|
||||
}
|
||||
div(class="col-md-4") {
|
||||
label(class="form-label") { "Key size" }
|
||||
input(class="form-control", bind:value=key_size)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
label(class="form-label") { "Existing key" }
|
||||
select(class="form-select", bind:value=key_id) {
|
||||
option(value="") { "— select a key —" }
|
||||
(key_options())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
div(class="form-section") {
|
||||
div(class="form-section-title") { "Extensions" }
|
||||
div(class="row g-3" ) {
|
||||
div(class="col-md-4") {
|
||||
label(class="form-label") { "Basic constraints" }
|
||||
select(class="form-select", bind:value=ca_mode) {
|
||||
option(value="end") { "End entity (CA:FALSE)" }
|
||||
option(value="ca") { "Certification authority (CA:TRUE)" }
|
||||
option(value="none") { "Not defined" }
|
||||
}
|
||||
}
|
||||
div(class="col-md-3") {
|
||||
label(class="form-label") { "Path length" }
|
||||
input(class="form-control", placeholder="unlimited", bind:value=path_length)
|
||||
}
|
||||
div(class="col-md-3 d-flex align-items-end") {
|
||||
div(class="form-check") {
|
||||
input(class="form-check-input", r#type="checkbox", bind:checked=bc_critical)
|
||||
label(class="form-check-label") { "Critical" }
|
||||
}
|
||||
}
|
||||
div(class="col-md-2 d-flex align-items-end") {
|
||||
div(class="form-check") {
|
||||
input(class="form-check-input", r#type="checkbox", bind:checked=ski)
|
||||
label(class="form-check-label") { "SKI" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="row g-3 mt-1") {
|
||||
div(class="col-md-6") {
|
||||
div(class="form-section-title") { "Key usage" }
|
||||
(check("Digital signature", ku_digital))
|
||||
(check("Key encipherment", ku_encipher))
|
||||
(check("Key agreement", ku_key_agreement))
|
||||
(check("Certificate sign", ku_cert_sign))
|
||||
(check("CRL sign", ku_crl_sign))
|
||||
(check("Critical", ku_critical))
|
||||
}
|
||||
div(class="col-md-6") {
|
||||
div(class="form-section-title") { "Extended key usage" }
|
||||
(check("TLS server", eku_server))
|
||||
(check("TLS client", eku_client))
|
||||
(check("Code signing", eku_code))
|
||||
(check("E-mail protection", eku_email))
|
||||
(check("Time stamping", eku_time))
|
||||
(check("OCSP signing", eku_ocsp))
|
||||
(check("Critical", eku_critical))
|
||||
}
|
||||
div(class="col-md-6") {
|
||||
div(class="form-section-title") { "Subject alternative names" }
|
||||
input(class="form-control", placeholder="DNS:example.com, IP:192.0.2.1", bind:value=san_text)
|
||||
}
|
||||
div(class="col-md-6") {
|
||||
div(class="form-section-title") { "Key identifier / OCSP" }
|
||||
(check("Authority key identifier", aki))
|
||||
(check("OCSP stapling", ocsp_staple))
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="form-section") {
|
||||
div(class="form-section-title") { "Validity" }
|
||||
div(class="row g-3") {
|
||||
div(class="col-md-3") {
|
||||
label(class="form-label") { "Duration" }
|
||||
input(class="form-control", bind:value=validity_amount)
|
||||
}
|
||||
div(class="col-md-3") {
|
||||
label(class="form-label") { "Unit" }
|
||||
select(class="form-select", bind:value=validity_unit) {
|
||||
option(value="days") { "Days" }
|
||||
option(value="months") { "Months" }
|
||||
option(value="years") { "Years" }
|
||||
}
|
||||
}
|
||||
div(class="col-md-3 d-flex align-items-end") {
|
||||
div(class="form-check") {
|
||||
input(class="form-check-input", r#type="checkbox", bind:checked=midnight)
|
||||
label(class="form-check-label") { "Round to midnight" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
button(class="btn btn-primary btn-lg", disabled=saving.get(), on:click=submit) {
|
||||
(if saving.get() { "Issuing…" } else if request_mode { "Issue certificate" } else { "Create certificate" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn CertificateRenewPage(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::<Me>);
|
||||
let cert = create_signal(None::<CertificateDetail>);
|
||||
let error = create_signal(None::<String>);
|
||||
let loading = create_signal(true);
|
||||
let saving = create_signal(false);
|
||||
let name = create_signal(String::new());
|
||||
let amount = create_signal("365".to_string());
|
||||
let unit = create_signal("days".to_string());
|
||||
let keep_serial = create_signal(false);
|
||||
let revoke_old = create_signal(false);
|
||||
let replace_old = create_signal(false);
|
||||
|
||||
spawn_local(async move {
|
||||
match api::me().await {
|
||||
Ok(user) => {
|
||||
me.set(Some(user));
|
||||
match api::certificate(&id, &cert_id_value).await {
|
||||
Ok(value) => {
|
||||
name.set(value.summary.name.clone());
|
||||
cert.set(Some(value));
|
||||
}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
|
||||
let submit = {
|
||||
let id = id.clone();
|
||||
let cert_id_value = cert_id_value.clone();
|
||||
move |_| {
|
||||
let id = id.clone();
|
||||
let cert_id_value = cert_id_value.clone();
|
||||
let payload = json!({
|
||||
"name": name.get_clone(),
|
||||
"validity": {"amount": amount.get_clone().parse::<i64>().unwrap_or(365), "unit": unit.get_clone()},
|
||||
"keep_serial": keep_serial.get(),
|
||||
"revoke_old": revoke_old.get(),
|
||||
"replace_old": replace_old.get(),
|
||||
});
|
||||
saving.set(true);
|
||||
spawn_local(async move {
|
||||
match api::renew_certificate(&id, &cert_id_value, &payload).await {
|
||||
Ok(value) => {
|
||||
saving.set(false);
|
||||
sycamore_router::navigate(&format!("/vaults/{}/certificates/{}", id, value.summary.id));
|
||||
return;
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
if saving.is_alive() { saving.set(false); }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let content = move || {
|
||||
if loading.get() {
|
||||
return crate::components::ui::spinner();
|
||||
}
|
||||
let Some(value) = cert.get_clone() else {
|
||||
return crate::components::ui::empty_state("Certificate not found.");
|
||||
};
|
||||
view! {
|
||||
(page_header("Renew certificate", &format!("Issued by {}", value.issuer_name), view! {
|
||||
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates/{cert_id_value}")) { "Cancel" }
|
||||
}))
|
||||
div(class="card") {
|
||||
div(class="card-body") {
|
||||
div(class="row g-3") {
|
||||
div(class="col-md-8") {
|
||||
label(class="form-label") { "New internal name" }
|
||||
input(class="form-control", bind:value=name)
|
||||
}
|
||||
div(class="col-md-2") {
|
||||
label(class="form-label") { "Duration" }
|
||||
input(class="form-control", bind:value=amount)
|
||||
}
|
||||
div(class="col-md-2") {
|
||||
label(class="form-label") { "Unit" }
|
||||
select(class="form-select", bind:value=unit) {
|
||||
option(value="days") { "Days" }
|
||||
option(value="months") { "Months" }
|
||||
option(value="years") { "Years" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="form-check mt-3") {
|
||||
input(class="form-check-input", r#type="checkbox", id="keep-serial", bind:checked=keep_serial)
|
||||
label(class="form-check-label", r#for="keep-serial") { "Keep serial number" }
|
||||
}
|
||||
div(class="form-check") {
|
||||
input(class="form-check-input", r#type="checkbox", id="revoke-old", bind:checked=revoke_old)
|
||||
label(class="form-check-label", r#for="revoke-old") { "Revoke old certificate" }
|
||||
}
|
||||
div(class="form-check mb-3") {
|
||||
input(class="form-check-input", r#type="checkbox", id="replace-old", bind:checked=replace_old)
|
||||
label(class="form-check-label", r#for="replace-old") { "Replace old certificate" }
|
||||
}
|
||||
button(class="btn btn-primary", disabled=saving.get(), on:click=submit) {
|
||||
(if saving.get() { "Renewing…" } else { "Renew certificate" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 subject_input(label: &'static str, value: Signal<String>) -> View {
|
||||
view! {
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { (label) }
|
||||
input(class="form-control", bind:value=value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check(label: &'static str, value: Signal<bool>) -> View {
|
||||
view! {
|
||||
div(class="form-check") {
|
||||
input(class="form-check-input", r#type="checkbox", bind:checked=value)
|
||||
label(class="form-check-label") { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_subject(fields: &[(&str, String)]) -> Vec<serde_json::Value> {
|
||||
fields
|
||||
.iter()
|
||||
.filter(|(_, value)| !value.trim().is_empty())
|
||||
.map(|(name, value)| json!({"name": name, "value": value.trim()}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_san(text: &str) -> Vec<serde_json::Value> {
|
||||
text.split(',')
|
||||
.map(|chunk| chunk.trim())
|
||||
.filter(|chunk| !chunk.is_empty())
|
||||
.map(|chunk| {
|
||||
if let Some((prefix, value)) = chunk.split_once(':') {
|
||||
let kind = match prefix.trim().to_lowercase().as_str() {
|
||||
"ip" => "ip",
|
||||
"uri" => "uri",
|
||||
"email" => "email",
|
||||
"rid" => "rid",
|
||||
"dns" => "dns",
|
||||
_ => "dns",
|
||||
};
|
||||
json!({"type": kind, "value": value.trim()})
|
||||
} else {
|
||||
json!({"type": "dns", "value": chunk})
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
|
||||
value
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|items| items.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(name: &str) -> Option<String> {
|
||||
let search = web_sys::window()?.location().search().ok()?;
|
||||
let search = search.trim_start_matches('?');
|
||||
for pair in search.split('&') {
|
||||
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
if key == name {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Reference in New Issue
Block a user