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,220 @@
|
||||
//! Per-vault options, mirroring XCA's database settings.
|
||||
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::{page_header, toast_error};
|
||||
use crate::model::Me;
|
||||
|
||||
pub fn SettingsPage(vault_id: String) -> View {
|
||||
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
|
||||
let me = create_signal(None::<Me>);
|
||||
let error = create_signal(None::<String>);
|
||||
let info = create_signal(None::<String>);
|
||||
let loading = create_signal(true);
|
||||
let saving = create_signal(false);
|
||||
let version = create_signal(0i64);
|
||||
|
||||
let default_hash = create_signal("sha256".to_string());
|
||||
let serial_len = create_signal("64".to_string());
|
||||
let explicit_dn = create_signal("C,ST,L,O,OU,CN,emailAddress".to_string());
|
||||
let mandatory_dn = create_signal(String::new());
|
||||
let disable_netscape = create_signal(false);
|
||||
let hide_unusable = create_signal(false);
|
||||
let default_key_type = create_signal("rsa".to_string());
|
||||
let default_key_size = create_signal("2048".to_string());
|
||||
let default_validity = create_signal("365".to_string());
|
||||
let default_validity_unit = create_signal("days".to_string());
|
||||
|
||||
spawn_local(async move {
|
||||
if let Ok(user) = api::me().await {
|
||||
me.set(Some(user));
|
||||
}
|
||||
match api::vault_settings(&id).await {
|
||||
Ok(value) => {
|
||||
version.set(value.get("version").and_then(|v| v.as_i64()).unwrap_or(0));
|
||||
if let Some(settings) = value.get("settings") {
|
||||
if let Some(v) = settings.get("default_hash").and_then(|v| v.as_str()) {
|
||||
default_hash.set(v.to_string());
|
||||
}
|
||||
if let Some(v) = settings.get("serial_len_bits").and_then(|v| v.as_i64()) {
|
||||
serial_len.set(v.to_string());
|
||||
}
|
||||
if let Some(items) = settings.get("explicit_dn").and_then(|v| v.as_array()) {
|
||||
explicit_dn.set(items.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(","));
|
||||
}
|
||||
if let Some(items) = settings.get("mandatory_dn").and_then(|v| v.as_array()) {
|
||||
mandatory_dn.set(items.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(","));
|
||||
}
|
||||
disable_netscape.set(settings.get("disable_netscape").and_then(|v| v.as_bool()).unwrap_or(false));
|
||||
hide_unusable.set(settings.get("hide_unusable").and_then(|v| v.as_bool()).unwrap_or(false));
|
||||
if let Some(key) = settings.get("default_key") {
|
||||
if let Some(v) = key.get("type").and_then(|v| v.as_str()) {
|
||||
default_key_type.set(v.to_string());
|
||||
}
|
||||
if let Some(v) = key.get("size").and_then(|v| v.as_i64()) {
|
||||
default_key_size.set(v.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(validity) = settings.get("default_validity") {
|
||||
if let Some(v) = validity.get("amount").and_then(|v| v.as_i64()) {
|
||||
default_validity.set(v.to_string());
|
||||
}
|
||||
if let Some(v) = validity.get("unit").and_then(|v| v.as_str()) {
|
||||
default_validity_unit.set(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
|
||||
let save = {
|
||||
let id = id.clone();
|
||||
move |_| {
|
||||
let id = id.clone();
|
||||
let settings = json!({
|
||||
"default_hash": default_hash.get_clone(),
|
||||
"serial_len_bits": serial_len.get_clone().parse::<i64>().unwrap_or(64),
|
||||
"explicit_dn": explicit_dn.get_clone().split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect::<Vec<_>>(),
|
||||
"mandatory_dn": mandatory_dn.get_clone().split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect::<Vec<_>>(),
|
||||
"disable_netscape": disable_netscape.get(),
|
||||
"hide_unusable": hide_unusable.get(),
|
||||
"default_key": {
|
||||
"type": default_key_type.get_clone(),
|
||||
"size": default_key_size.get_clone().parse::<i64>().unwrap_or(2048),
|
||||
"curve": null,
|
||||
},
|
||||
"default_validity": {
|
||||
"amount": default_validity.get_clone().parse::<i64>().unwrap_or(365),
|
||||
"unit": default_validity_unit.get_clone(),
|
||||
},
|
||||
});
|
||||
saving.set(true);
|
||||
spawn_local(async move {
|
||||
match api::update_vault_settings(&id, &settings, version.get()).await {
|
||||
Ok(value) => {
|
||||
version.set(value.get("version").and_then(|v| v.as_i64()).unwrap_or(version.get()));
|
||||
info.set(Some("Settings saved.".to_string()));
|
||||
}
|
||||
Err(message) if message == api::LOGIN_REQUIRED => {}
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
saving.set(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let content = move || {
|
||||
if loading.get() {
|
||||
return spinner();
|
||||
}
|
||||
view! {
|
||||
(page_header(
|
||||
"Vault settings",
|
||||
"These options are stored with the vault and mirror XCA's database preferences.",
|
||||
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}")) { "Back" } },
|
||||
))
|
||||
(if let Some(message) = info.get_clone() {
|
||||
view! { div(class="alert alert-success") { (message) } }
|
||||
} else {
|
||||
view! {}
|
||||
})
|
||||
div(class="card") {
|
||||
div(class="card-body") {
|
||||
div(class="form-section") {
|
||||
div(class="form-section-title") { "Cryptography" }
|
||||
div(class="row g-3") {
|
||||
div(class="col-md-4") {
|
||||
label(class="form-label") { "Default signature hash" }
|
||||
select(class="form-select", bind:value=default_hash) {
|
||||
option(value="sha256") { "SHA-256" }
|
||||
option(value="sha384") { "SHA-384" }
|
||||
option(value="sha512") { "SHA-512" }
|
||||
option(value="sha224") { "SHA-224" }
|
||||
option(value="sha1") { "SHA-1" }
|
||||
}
|
||||
}
|
||||
div(class="col-md-4") {
|
||||
label(class="form-label") { "Serial number length (bits)" }
|
||||
input(class="form-control", bind:value=serial_len)
|
||||
}
|
||||
div(class="col-md-4") {
|
||||
label(class="form-label") { "Default validity" }
|
||||
div(class="input-group") {
|
||||
input(class="form-control", bind:value=default_validity)
|
||||
select(class="form-select", bind:value=default_validity_unit) {
|
||||
option(value="days") { "Days" }
|
||||
option(value="months") { "Months" }
|
||||
option(value="years") { "Years" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="form-section") {
|
||||
div(class="form-section-title") { "Distinguished name" }
|
||||
div(class="row g-3") {
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { "Explicit subject entries" }
|
||||
input(class="form-control", bind:value=explicit_dn)
|
||||
div(class="form-text") { "Comma-separated short names, e.g. C,ST,L,O,OU,CN,emailAddress." }
|
||||
}
|
||||
div(class="col-md-6") {
|
||||
label(class="form-label") { "Mandatory subject entries" }
|
||||
input(class="form-control", bind:value=mandatory_dn)
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="form-section") {
|
||||
div(class="form-section-title") { "Editor defaults" }
|
||||
div(class="row g-3") {
|
||||
div(class="col-md-4") {
|
||||
label(class="form-label") { "Default key type" }
|
||||
select(class="form-select", bind:value=default_key_type) {
|
||||
option(value="rsa") { "RSA" }
|
||||
option(value="ec") { "EC" }
|
||||
option(value="ed25519") { "ED25519" }
|
||||
}
|
||||
}
|
||||
div(class="col-md-4") {
|
||||
label(class="form-label") { "Default key size" }
|
||||
input(class="form-control", bind:value=default_key_size)
|
||||
}
|
||||
div(class="col-md-4 d-flex align-items-end") {
|
||||
button(class="btn btn-primary", disabled=saving.get(), on:click=save) {
|
||||
(if saving.get() { "Saving…" } else { "Save settings" })
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="form-check mt-3") {
|
||||
input(class="form-check-input", r#type="checkbox", bind:checked=disable_netscape)
|
||||
label(class="form-check-label") { "Disable legacy Netscape extensions" }
|
||||
}
|
||||
div(class="form-check") {
|
||||
input(class="form-check-input", r#type="checkbox", bind:checked=hide_unusable)
|
||||
label(class="form-check-label") { "Hide expired and revoked certificates by default" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user