From e83dc42d3d3c743c713cb611e8edda7c81c535bb Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Mon, 26 Oct 2026 12:06:17 +0800 Subject: [PATCH] Add structured subject attribute editor --- e2e/tests/settings.spec.js | 57 ++++++++ web/src/pages/certificate_new.rs | 220 +++++++++++++++++++++++++------ 2 files changed, 235 insertions(+), 42 deletions(-) diff --git a/e2e/tests/settings.spec.js b/e2e/tests/settings.spec.js index 82a2295..d76399b 100644 --- a/e2e/tests/settings.spec.js +++ b/e2e/tests/settings.spec.js @@ -83,3 +83,60 @@ test("explicit DN order, mandatory DN and template apply", async ({ page }) => { // RFC 4514 renders the most specific RDN first, so CN follows O. expect(created.subject).toBe(`O=KCA E2E,CN=${certName}`); }); + +test("additional subject attributes use an XCA-style editor", async ({ page }) => { + const user = unique("subject-editor"); + const vaultName = unique("Subject Editor Vault"); + const certName = unique("attributes.example"); + + await login(page, user); + await page.getByRole("button", { name: "New vault" }).click(); + await page.getByPlaceholder("Production PKI").fill(vaultName); + await page.getByRole("button", { name: "Create vault" }).click(); + await page.getByRole("link", { name: vaultName }).click(); + const vaultUrl = page.url(); + const vaultId = vaultUrl.split("/").pop(); + + await page.goto(`${vaultUrl}/certificates/new`); + const editor = page.locator(".additional-subject-attributes"); + const add = editor.getByRole("button", { name: "Add", exact: true }); + const rows = editor.locator(".subject-extra-row"); + await expect(add).toBeEnabled(); + await expect(editor.locator("textarea")).toHaveCount(0); + + await add.click(); + await expect(rows).toHaveCount(1); + await rows.nth(0).getByRole("combobox").selectOption("serialNumber"); + await rows.nth(0).getByRole("textbox").fill("1234"); + + // A second row must not disturb the first row's nested input signals. + await add.click(); + await expect(rows).toHaveCount(2); + await rows.nth(1).getByRole("combobox").selectOption("title"); + await rows.nth(1).getByRole("textbox").fill("delete me"); + await expect(rows.nth(0).getByRole("textbox")).toHaveValue("1234"); + await rows.nth(1).getByRole("button", { name: "Delete", exact: true }).click(); + await expect(rows).toHaveCount(1); + await expect(rows.nth(0).getByRole("textbox")).toHaveValue("1234"); + + await (await field(page, "Internal name")).fill(certName); + await (await field(page, "Common name (CN)")).fill(certName); + await page.locator("#new-key").check(); + await page.getByRole("button", { name: "Create certificate" }).click(); + await expect(page.getByRole("heading", { name: certName })).toBeVisible(); + + const certId = page.url().split("/").pop(); + const detail = await api(page, "GET", `/api/vaults/${vaultId}/certificates/${certId}`); + expect(detail.status).toBe(200); + const entries = detail.json.subject_entries; + expect(entries.find((entry) => entry.name === "serialNumber")?.value).toBe("1234"); + expect(entries.some((entry) => entry.name === "title")).toBe(false); + + // Copying a certificate populates the structured editor with the extra row. + await page.goto(`${vaultUrl}/certificates/${certId}/similar`); + const copiedEditor = page.locator(".additional-subject-attributes"); + const copiedRows = copiedEditor.locator(".subject-extra-row"); + await expect(copiedRows).toHaveCount(1); + await expect(copiedRows.nth(0).getByRole("combobox")).toHaveValue("serialNumber"); + await expect(copiedRows.nth(0).getByRole("textbox")).toHaveValue("1234"); +}); diff --git a/web/src/pages/certificate_new.rs b/web/src/pages/certificate_new.rs index 5950f54..781b26c 100644 --- a/web/src/pages/certificate_new.rs +++ b/web/src/pages/certificate_new.rs @@ -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, + value: Signal, +} + #[derive(Clone, Copy)] struct CertFormSignals { explicit_dn: Signal>, - subject_extra: Signal, + subject_extra: Signal>, + next_extra_subject_id: Signal, cn: Signal, country: Signal, state: Signal, @@ -60,6 +70,7 @@ struct CertFormSignals { } pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { + let reactive_root = use_global_scope(); let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); let me = create_signal(None::); let keys = create_signal(Vec::::new()); @@ -159,10 +170,13 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option) -> View { .map(|v| v.to_string()) .collect::>(), ); - let subject_extra = create_signal(String::new()); + let subject_extra = create_signal(Vec::::new()); + let next_extra_subject_id = create_signal(0_u64); + let dn_attributes = create_signal(Vec::::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) -> 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) -> 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) -> 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) -> 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) -> 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>, + next_id: Signal, + attributes: Signal>, +) -> 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, 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>, + selected: Signal, +) -> Vec { + 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::>(); + 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, 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()); }