Add structured subject attribute editor
This commit is contained in:
@@ -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.
|
// RFC 4514 renders the most specific RDN first, so CN follows O.
|
||||||
expect(created.subject).toBe(`O=KCA E2E,CN=${certName}`);
|
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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,14 +13,24 @@ use crate::components::list_editor::{
|
|||||||
};
|
};
|
||||||
use crate::components::nav::{app_nav, whoami};
|
use crate::components::nav::{app_nav, whoami};
|
||||||
use crate::components::ui::{page_header, toast_error};
|
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"];
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
struct CertFormSignals {
|
struct CertFormSignals {
|
||||||
explicit_dn: Signal<Vec<String>>,
|
explicit_dn: Signal<Vec<String>>,
|
||||||
subject_extra: Signal<String>,
|
subject_extra: Signal<Vec<ExtraSubjectRow>>,
|
||||||
|
next_extra_subject_id: Signal<u64>,
|
||||||
cn: Signal<String>,
|
cn: Signal<String>,
|
||||||
country: Signal<String>,
|
country: Signal<String>,
|
||||||
state: Signal<String>,
|
state: Signal<String>,
|
||||||
@@ -60,6 +70,7 @@ struct CertFormSignals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
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 id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
|
||||||
let me = create_signal(None::<Me>);
|
let me = create_signal(None::<Me>);
|
||||||
let keys = create_signal(Vec::<KeySummary>::new());
|
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())
|
.map(|v| v.to_string())
|
||||||
.collect::<Vec<_>>(),
|
.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 {
|
let form = CertFormSignals {
|
||||||
explicit_dn,
|
explicit_dn,
|
||||||
subject_extra,
|
subject_extra,
|
||||||
|
next_extra_subject_id,
|
||||||
cn,
|
cn,
|
||||||
country,
|
country,
|
||||||
state,
|
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 !request_mode {
|
||||||
if let Some(cert_id) = from_cert.clone() {
|
if let Some(cert_id) = from_cert.clone() {
|
||||||
if let Ok(value) = api::certificate(&id, &cert_id).await {
|
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 =
|
let subject_json =
|
||||||
serde_json::to_value(&value.subject_entries).unwrap_or(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);
|
apply_extension_spec(&form, &value.extension_spec);
|
||||||
no_expiration.set(value.summary.no_expiration);
|
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));
|
name.set(format!("{} certificate", value.summary.name));
|
||||||
let subject_json =
|
let subject_json =
|
||||||
serde_json::to_value(&value.subject_entries).unwrap_or(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) => {
|
Ok(merged) => {
|
||||||
if mode == "all" || mode == "subject" {
|
if mode == "all" || mode == "subject" {
|
||||||
let subject = merged.get("subject").cloned().unwrap_or_else(|| json!([]));
|
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" {
|
if mode == "all" || mode == "extensions" {
|
||||||
let extensions = merged
|
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="form-section-title") { "Subject" }
|
||||||
div(class="row g-3") {
|
div(class="row g-3") {
|
||||||
(subject_fields(&form))
|
(subject_fields(&form))
|
||||||
div(class="col-12") {
|
(extra_subject_editor(
|
||||||
label(class="form-label") { "Additional subject attributes (JSON)" }
|
form.subject_extra,
|
||||||
textarea(class="form-control mono", rows="2",
|
form.next_extra_subject_id,
|
||||||
placeholder=r#"[{"name":"serialNumber","value":"1234"}]"#,
|
dn_attributes,
|
||||||
bind:value=subject_extra) {}
|
))
|
||||||
div(class="form-text") {
|
|
||||||
"Used for attributes not shown above; the order above follows the vault's explicit DN setting."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(if request_mode {
|
(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> {
|
fn subject_label(name: &str) -> Option<&'static str> {
|
||||||
match name {
|
match name {
|
||||||
"CN" => Some("Common name (CN)"),
|
"CN" => Some("Common name (CN)"),
|
||||||
@@ -1009,39 +1160,24 @@ fn apply_subject_json(form: &CertFormSignals, subject: &Value) {
|
|||||||
}
|
}
|
||||||
match subject_signal(name, form) {
|
match subject_signal(name, form) {
|
||||||
Some(signal) if signal.get_clone().is_empty() => signal.set(value.to_string()),
|
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(
|
||||||
form.subject_extra.set(String::new());
|
extras
|
||||||
} else {
|
.into_iter()
|
||||||
form.subject_extra
|
.map(|(name, value)| new_extra_subject_row(form.next_extra_subject_id, name, value))
|
||||||
.set(serde_json::to_string_pretty(&Value::Array(extras)).unwrap_or_default());
|
.collect(),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extra_subject_entries(form: &CertFormSignals) -> Result<Vec<Value>, String> {
|
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();
|
let mut result = Vec::new();
|
||||||
for entry in entries {
|
for row in form.subject_extra.get_clone() {
|
||||||
let name = entry
|
let name = row.name.get_clone();
|
||||||
.get("name")
|
let value = row.value.get_clone();
|
||||||
.and_then(|v| v.as_str())
|
let name = name.trim();
|
||||||
.unwrap_or("")
|
let value = value.trim();
|
||||||
.trim();
|
|
||||||
let value = entry
|
|
||||||
.get("value")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err("each additional subject attribute needs a name".to_string());
|
return Err("each additional subject attribute needs a name".to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user