711 lines
38 KiB
Rust
711 lines
38 KiB
Rust
//! 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::<Me>);
|
|
let options = create_signal(None::<OptionsPage>);
|
|
let cert = create_signal(None::<CertificateDetail>);
|
|
let error = create_signal(None::<String>);
|
|
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::<Revocation>::new());
|
|
let subject_entries = create_signal(Vec::<crate::model::DnEntry>::new());
|
|
let issuer_entries = create_signal(Vec::<crate::model::DnEntry>::new());
|
|
let extensions = create_signal(Vec::<crate::model::ExtensionRow>::new());
|
|
let validation_errors = create_signal(Vec::<crate::model::ValidationError>::new());
|
|
let purposes = create_signal(Vec::<String>::new());
|
|
let chain = create_signal(Vec::<crate::model::ChainItem>::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::<TemplateSummary>::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::<String>);
|
|
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::<i64>().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::<i64>().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::<String>())
|
|
.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::<Vec<View>>())
|
|
}
|
|
}
|
|
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<View> {
|
|
[
|
|
"unspecified",
|
|
"keyCompromise",
|
|
"CACompromise",
|
|
"affiliationChanged",
|
|
"superseded",
|
|
"cessationOfOperation",
|
|
"certificateHold",
|
|
"privilegeWithdrawn",
|
|
]
|
|
.iter()
|
|
.map(|reason| view! { option(value=*reason) { (*reason) } })
|
|
.collect::<Vec<View>>()
|
|
}
|