Add structured subject attribute editor

This commit is contained in:
2026-10-26 12:06:17 +08:00
parent f9e8915b77
commit e83dc42d3d
2 changed files with 235 additions and 42 deletions
+178 -42
View File
@@ -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());
}