Add structured subject attribute editor
This commit is contained in:
@@ -13,14 +13,24 @@ use crate::components::list_editor::{
|
||||
};
|
||||
use crate::components::nav::{app_nav, whoami};
|
||||
use crate::components::ui::{page_header, toast_error};
|
||||
use crate::model::{CertificateDetail, CertificateSummary, KeySummary, Me, TemplateSummary};
|
||||
use crate::model::{
|
||||
CertificateDetail, CertificateSummary, DnAttribute, KeySummary, Me, TemplateSummary,
|
||||
};
|
||||
|
||||
const DEFAULT_EXPLICIT_DN: [&str; 7] = ["C", "ST", "L", "O", "OU", "CN", "emailAddress"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ExtraSubjectRow {
|
||||
id: u64,
|
||||
name: Signal<String>,
|
||||
value: Signal<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CertFormSignals {
|
||||
explicit_dn: Signal<Vec<String>>,
|
||||
subject_extra: Signal<String>,
|
||||
subject_extra: Signal<Vec<ExtraSubjectRow>>,
|
||||
next_extra_subject_id: Signal<u64>,
|
||||
cn: Signal<String>,
|
||||
country: Signal<String>,
|
||||
state: Signal<String>,
|
||||
@@ -60,6 +70,7 @@ struct CertFormSignals {
|
||||
}
|
||||
|
||||
pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
||||
let reactive_root = use_global_scope();
|
||||
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());
|
||||
@@ -159,10 +170,13 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let subject_extra = create_signal(String::new());
|
||||
let subject_extra = create_signal(Vec::<ExtraSubjectRow>::new());
|
||||
let next_extra_subject_id = create_signal(0_u64);
|
||||
let dn_attributes = create_signal(Vec::<DnAttribute>::new());
|
||||
let form = CertFormSignals {
|
||||
explicit_dn,
|
||||
subject_extra,
|
||||
next_extra_subject_id,
|
||||
cn,
|
||||
country,
|
||||
state,
|
||||
@@ -263,6 +277,9 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(options) = api::options(&id).await {
|
||||
dn_attributes.set(options.dn_attributes);
|
||||
}
|
||||
if !request_mode {
|
||||
if let Some(cert_id) = from_cert.clone() {
|
||||
if let Ok(value) = api::certificate(&id, &cert_id).await {
|
||||
@@ -285,7 +302,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
||||
}
|
||||
let subject_json =
|
||||
serde_json::to_value(&value.subject_entries).unwrap_or(json!([]));
|
||||
apply_subject_json(&form, &subject_json);
|
||||
reactive_root.run_in(|| apply_subject_json(&form, &subject_json));
|
||||
apply_extension_spec(&form, &value.extension_spec);
|
||||
no_expiration.set(value.summary.no_expiration);
|
||||
}
|
||||
@@ -296,7 +313,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
||||
name.set(format!("{} certificate", value.summary.name));
|
||||
let subject_json =
|
||||
serde_json::to_value(&value.subject_entries).unwrap_or(json!([]));
|
||||
apply_subject_json(&form, &subject_json);
|
||||
reactive_root.run_in(|| apply_subject_json(&form, &subject_json));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -322,7 +339,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
||||
Ok(merged) => {
|
||||
if mode == "all" || mode == "subject" {
|
||||
let subject = merged.get("subject").cloned().unwrap_or_else(|| json!([]));
|
||||
apply_subject_json(&form, &subject);
|
||||
reactive_root.run_in(|| apply_subject_json(&form, &subject));
|
||||
}
|
||||
if mode == "all" || mode == "extensions" {
|
||||
let extensions = merged
|
||||
@@ -622,15 +639,11 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
||||
div(class="form-section-title") { "Subject" }
|
||||
div(class="row g-3") {
|
||||
(subject_fields(&form))
|
||||
div(class="col-12") {
|
||||
label(class="form-label") { "Additional subject attributes (JSON)" }
|
||||
textarea(class="form-control mono", rows="2",
|
||||
placeholder=r#"[{"name":"serialNumber","value":"1234"}]"#,
|
||||
bind:value=subject_extra) {}
|
||||
div(class="form-text") {
|
||||
"Used for attributes not shown above; the order above follows the vault's explicit DN setting."
|
||||
}
|
||||
}
|
||||
(extra_subject_editor(
|
||||
form.subject_extra,
|
||||
form.next_extra_subject_id,
|
||||
dn_attributes,
|
||||
))
|
||||
}
|
||||
}
|
||||
(if request_mode {
|
||||
@@ -939,6 +952,144 @@ pub fn CertificateRenewPage(vault_id: String, cert_id: String) -> View {
|
||||
}
|
||||
}
|
||||
|
||||
fn extra_subject_editor(
|
||||
rows: Signal<Vec<ExtraSubjectRow>>,
|
||||
next_id: Signal<u64>,
|
||||
attributes: Signal<Vec<DnAttribute>>,
|
||||
) -> View {
|
||||
let add_row = move |_| {
|
||||
let available = attributes.get_clone();
|
||||
let default_name = available
|
||||
.first()
|
||||
.map(|attribute| {
|
||||
if attribute.name.is_empty() {
|
||||
attribute.oid.clone()
|
||||
} else {
|
||||
attribute.name.clone()
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let row = new_extra_subject_row(next_id, default_name, String::new());
|
||||
rows.update(move |rows| rows.push(row));
|
||||
};
|
||||
|
||||
view! {
|
||||
div(class="col-12 additional-subject-attributes") {
|
||||
div(class="d-flex align-items-center justify-content-between mb-2") {
|
||||
label(class="form-label mb-0") { "Additional subject attributes" }
|
||||
button(
|
||||
class="btn btn-sm btn-outline-light",
|
||||
r#type="button",
|
||||
disabled=attributes.with(|attributes| attributes.is_empty()),
|
||||
on:click=add_row,
|
||||
) { "Add" }
|
||||
}
|
||||
div(class="table-responsive") {
|
||||
table(class="table table-sm align-middle mb-2") {
|
||||
thead() {
|
||||
tr() {
|
||||
th(scope="col") { "Type" }
|
||||
th(scope="col") { "Content" }
|
||||
th(scope="col") {}
|
||||
}
|
||||
}
|
||||
tbody() {
|
||||
Keyed(
|
||||
list=rows,
|
||||
view=move |row| {
|
||||
let row_id = row.id;
|
||||
let name = row.name;
|
||||
let value = row.value;
|
||||
on_cleanup(move || {
|
||||
name.dispose();
|
||||
value.dispose();
|
||||
});
|
||||
let remove_row = move |_| {
|
||||
rows.update(|rows| rows.retain(|row| row.id != row_id));
|
||||
};
|
||||
view! {
|
||||
tr(class="subject-extra-row") {
|
||||
td(style="width: 16rem") {
|
||||
select(
|
||||
class="form-select form-select-sm",
|
||||
aria-label="Additional subject attribute type",
|
||||
bind:value=name,
|
||||
) {
|
||||
(move || extra_subject_type_options(attributes, name))
|
||||
}
|
||||
}
|
||||
td() {
|
||||
input(
|
||||
class="form-control form-control-sm",
|
||||
aria-label="Additional subject attribute value",
|
||||
bind:value=value,
|
||||
)
|
||||
}
|
||||
td(class="table-actions") {
|
||||
button(
|
||||
class="btn btn-sm btn-outline-danger",
|
||||
r#type="button",
|
||||
on:click=remove_row,
|
||||
) { "Delete" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
key=|row| row.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
div(class="form-text") {
|
||||
"Used for attributes not shown above; the order above follows the vault's explicit DN setting."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_extra_subject_row(next_id: Signal<u64>, name: String, value: String) -> ExtraSubjectRow {
|
||||
let id = next_id.get();
|
||||
next_id.set(id + 1);
|
||||
ExtraSubjectRow {
|
||||
id,
|
||||
name: create_signal(name),
|
||||
value: create_signal(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn extra_subject_type_options(
|
||||
attributes: Signal<Vec<DnAttribute>>,
|
||||
selected: Signal<String>,
|
||||
) -> Vec<View> {
|
||||
let selected = selected.get_clone();
|
||||
let attributes = attributes.get_clone();
|
||||
let has_selected = attributes
|
||||
.iter()
|
||||
.any(|attribute| attribute.name.as_str() == selected);
|
||||
let mut options = attributes
|
||||
.into_iter()
|
||||
.map(|attribute| {
|
||||
let value = if attribute.name.is_empty() {
|
||||
attribute.oid
|
||||
} else {
|
||||
attribute.name
|
||||
};
|
||||
let label = if attribute.long_name.is_empty() || attribute.long_name == value {
|
||||
value.clone()
|
||||
} else {
|
||||
format!("{} ({})", attribute.long_name, value)
|
||||
};
|
||||
view! { option(value=value) { (label) } }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !selected.is_empty() && !has_selected {
|
||||
let value = selected.clone();
|
||||
let label = selected.clone();
|
||||
options.push(view! { option(value=value) { (label) } });
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
fn subject_label(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"CN" => Some("Common name (CN)"),
|
||||
@@ -1009,39 +1160,24 @@ fn apply_subject_json(form: &CertFormSignals, subject: &Value) {
|
||||
}
|
||||
match subject_signal(name, form) {
|
||||
Some(signal) if signal.get_clone().is_empty() => signal.set(value.to_string()),
|
||||
_ => extras.push(entry),
|
||||
_ => extras.push((name.to_string(), value.to_string())),
|
||||
}
|
||||
}
|
||||
if extras.is_empty() {
|
||||
form.subject_extra.set(String::new());
|
||||
} else {
|
||||
form.subject_extra
|
||||
.set(serde_json::to_string_pretty(&Value::Array(extras)).unwrap_or_default());
|
||||
}
|
||||
form.subject_extra.set(
|
||||
extras
|
||||
.into_iter()
|
||||
.map(|(name, value)| new_extra_subject_row(form.next_extra_subject_id, name, value))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
fn extra_subject_entries(form: &CertFormSignals) -> Result<Vec<Value>, String> {
|
||||
let text = form.subject_extra.get_clone();
|
||||
if text.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let value: Value = serde_json::from_str(&text)
|
||||
.map_err(|_| "additional subject attributes must be valid JSON".to_string())?;
|
||||
let entries = value
|
||||
.as_array()
|
||||
.ok_or_else(|| "additional subject attributes must be a JSON array".to_string())?;
|
||||
let mut result = Vec::new();
|
||||
for entry in entries {
|
||||
let name = entry
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let value = entry
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
for row in form.subject_extra.get_clone() {
|
||||
let name = row.name.get_clone();
|
||||
let value = row.value.get_clone();
|
||||
let name = name.trim();
|
||||
let value = value.trim();
|
||||
if name.is_empty() {
|
||||
return Err("each additional subject attribute needs a name".to_string());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user