Add advanced extension list editors
This commit is contained in:
@@ -0,0 +1,152 @@
|
|||||||
|
// XCA-style editors for advanced extension list fields.
|
||||||
|
const { test, expect } = require("@playwright/test");
|
||||||
|
|
||||||
|
function unique(prefix) {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function field(page, label) {
|
||||||
|
return page.locator("label").filter({ hasText: label }).first().locator("..").locator("input, select, textarea").first();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page, username) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByRole("link", { name: "Sign in with default" }).click();
|
||||||
|
await page.getByPlaceholder("Enter any user/subject").fill(username);
|
||||||
|
await page.getByRole("button", { name: "Sign-in" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Vaults" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditor(page, buttonId, title) {
|
||||||
|
await page.locator(buttonId).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: title, exact: true });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRow(dialog, type, value) {
|
||||||
|
const index = await dialog.locator(".list-entry").count();
|
||||||
|
await dialog.getByRole("button", { name: "Add", exact: true }).click();
|
||||||
|
const row = dialog.locator(".list-entry").nth(index);
|
||||||
|
await row.locator("select").selectOption(type);
|
||||||
|
await row.locator(".list-value").fill(value);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("advanced extension list editors", async ({ page }) => {
|
||||||
|
const user = unique("list-editor");
|
||||||
|
const vaultName = unique("List Editors");
|
||||||
|
const certName = unique("constraints.example");
|
||||||
|
const crlUrls = ["https://ca.example.com/root.crl", "https://backup.example.com/root.crl"];
|
||||||
|
const cpsUrls = ["https://example.com/cps", "https://example.com/policy"];
|
||||||
|
|
||||||
|
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 settingsResponse = page.waitForResponse(
|
||||||
|
(response) => response.url().includes("/api/vaults/") && response.url().endsWith("/settings") && response.ok()
|
||||||
|
);
|
||||||
|
await page.goto(`${vaultUrl}/certificates/new`);
|
||||||
|
await settingsResponse;
|
||||||
|
await (await field(page, "Internal name")).fill(certName);
|
||||||
|
await (await field(page, "Common name (CN)")).fill(certName);
|
||||||
|
await (await field(page, "Basic constraints")).selectOption("ca");
|
||||||
|
await page.locator("#new-key").check();
|
||||||
|
|
||||||
|
// URI-list edits can be cancelled without changing the original textarea.
|
||||||
|
let dialog = await openEditor(page, "#edit-crl-urls", "CRL distribution point URLs");
|
||||||
|
await expect(dialog.getByLabel("Critical", { exact: true })).not.toBeChecked();
|
||||||
|
await addRow(dialog, "URI", "https://discarded.example.com/root.crl");
|
||||||
|
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(page.locator("#crl-urls")).toHaveValue("");
|
||||||
|
|
||||||
|
dialog = await openEditor(page, "#edit-crl-urls", "CRL distribution point URLs");
|
||||||
|
await expect(dialog.locator(".list-entry")).toHaveCount(0);
|
||||||
|
await addRow(dialog, "URI", crlUrls[0]);
|
||||||
|
await addRow(dialog, "URI", crlUrls[1]);
|
||||||
|
await dialog.getByLabel("Critical", { exact: true }).check();
|
||||||
|
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||||
|
await expect(page.locator("#crl-urls")).toHaveValue(crlUrls.join("\n"));
|
||||||
|
|
||||||
|
dialog = await openEditor(page, "#edit-crl-urls", "CRL distribution point URLs");
|
||||||
|
await expect(dialog.locator(".list-entry")).toHaveCount(2);
|
||||||
|
await expect(dialog.locator(".list-entry").nth(1).locator(".list-value")).toHaveValue(crlUrls[1]);
|
||||||
|
await expect(dialog.getByLabel("Critical", { exact: true })).toBeChecked();
|
||||||
|
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
|
||||||
|
// Name constraints use typed rows and share one extension-level critical flag.
|
||||||
|
dialog = await openEditor(page, "#edit-nc-permitted", "Name constraints · permitted");
|
||||||
|
await expect(dialog.getByLabel("Critical", { exact: true })).toBeChecked();
|
||||||
|
await addRow(dialog, "DNS", ".example.com");
|
||||||
|
await addRow(dialog, "DNS", "example.net");
|
||||||
|
await dialog.getByLabel("Critical", { exact: true }).uncheck();
|
||||||
|
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||||
|
await expect(page.locator("#nc-permitted")).toHaveValue("DNS:.example.com, DNS:example.net");
|
||||||
|
|
||||||
|
dialog = await openEditor(page, "#edit-nc-excluded", "Name constraints · excluded");
|
||||||
|
await expect(dialog.getByLabel("Critical", { exact: true })).not.toBeChecked();
|
||||||
|
await dialog.getByLabel("Critical", { exact: true }).check();
|
||||||
|
await addRow(dialog, "DNS", ".bad.example");
|
||||||
|
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||||
|
await expect(page.locator("#nc-excluded")).toHaveValue("DNS:.bad.example");
|
||||||
|
|
||||||
|
dialog = await openEditor(page, "#edit-nc-permitted", "Name constraints · permitted");
|
||||||
|
await expect(dialog.locator(".list-entry")).toHaveCount(2);
|
||||||
|
await expect(dialog.getByLabel("Critical", { exact: true })).toBeChecked();
|
||||||
|
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
|
||||||
|
// CPS URIs use comma-separated plain URLs and support row deletion.
|
||||||
|
await (await field(page, "Certificate policy OIDs")).fill("1.3.6.1.4.1.1");
|
||||||
|
dialog = await openEditor(page, "#edit-policy-cps", "Policy CPS URIs");
|
||||||
|
await expect(dialog.getByLabel("Critical", { exact: true })).not.toBeChecked();
|
||||||
|
await dialog.getByLabel("Critical", { exact: true }).check();
|
||||||
|
await addRow(dialog, "URI", cpsUrls[0]);
|
||||||
|
await addRow(dialog, "URI", cpsUrls[1]);
|
||||||
|
const discarded = await addRow(dialog, "URI", "https://discarded.example.com/cps");
|
||||||
|
await discarded.getByRole("button", { name: "Delete", exact: true }).click();
|
||||||
|
await expect(dialog.locator(".list-entry")).toHaveCount(2);
|
||||||
|
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||||
|
await expect(page.locator("#policy-cps")).toHaveValue(cpsUrls.join(", "));
|
||||||
|
|
||||||
|
await expect(await field(page, "Common name (CN)")).toHaveValue(certName);
|
||||||
|
await expect(page.locator("#nc-excluded")).toHaveValue("DNS:.bad.example");
|
||||||
|
await page.getByRole("button", { name: "Validate extensions" }).click();
|
||||||
|
await expect(page.getByText("Extension preview")).toBeVisible();
|
||||||
|
await expect(page.locator("pre.pem")).toContainText("Name Constraints");
|
||||||
|
await expect(page.locator("pre.pem")).toContainText("Certificate Policies");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Create certificate" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: certName })).toBeVisible();
|
||||||
|
const certApiUrl = page.url().replace(/\/vaults\//, "/api/vaults/");
|
||||||
|
const detail = await page.evaluate(async (url) => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
return response.json();
|
||||||
|
}, certApiUrl);
|
||||||
|
const extensions = detail.extension_spec;
|
||||||
|
expect(extensions.crl_distribution_points).toEqual({ critical: true, urls: crlUrls });
|
||||||
|
expect(extensions.name_constraints).toEqual({
|
||||||
|
critical: true,
|
||||||
|
permitted: [
|
||||||
|
{ type: "dns", value: ".example.com" },
|
||||||
|
{ type: "dns", value: "example.net" },
|
||||||
|
],
|
||||||
|
excluded: [{ type: "dns", value: ".bad.example" }],
|
||||||
|
});
|
||||||
|
expect(extensions.certificate_policies).toEqual({
|
||||||
|
critical: true,
|
||||||
|
policies: [{ oid: "1.3.6.1.4.1.1", cps: cpsUrls }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shared advanced-extension component also works in template forms.
|
||||||
|
await page.goto(`${vaultUrl}/templates/new`);
|
||||||
|
await expect(page.getByRole("heading", { name: "New template" })).toBeVisible();
|
||||||
|
dialog = await openEditor(page, "#edit-crl-urls", "CRL distribution point URLs");
|
||||||
|
await addRow(dialog, "URI", "https://template.example.com/root.crl");
|
||||||
|
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||||
|
await expect(page.locator("#crl-urls")).toHaveValue("https://template.example.com/root.crl");
|
||||||
|
});
|
||||||
@@ -8,15 +8,23 @@
|
|||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::list_editor::{
|
||||||
|
general_name_entries_to_text, list_editor_dialog, open_list_editor, parse_other_name,
|
||||||
|
split_list, ListEditorConfig, ListEditorFormat, ListEditorSignals,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct ExtensionSignals {
|
pub struct ExtensionSignals {
|
||||||
pub crl_urls: Signal<String>,
|
pub crl_urls: Signal<String>,
|
||||||
|
pub crl_critical: Signal<bool>,
|
||||||
pub aia_ocsp: Signal<String>,
|
pub aia_ocsp: Signal<String>,
|
||||||
pub aia_ca_issuers: Signal<String>,
|
pub aia_ca_issuers: Signal<String>,
|
||||||
pub nc_permitted: Signal<String>,
|
pub nc_permitted: Signal<String>,
|
||||||
pub nc_excluded: Signal<String>,
|
pub nc_excluded: Signal<String>,
|
||||||
|
pub nc_critical: Signal<bool>,
|
||||||
pub policy_oids: Signal<String>,
|
pub policy_oids: Signal<String>,
|
||||||
pub policy_cps: Signal<String>,
|
pub policy_cps: Signal<String>,
|
||||||
|
pub policy_critical: Signal<bool>,
|
||||||
pub policy_require: Signal<String>,
|
pub policy_require: Signal<String>,
|
||||||
pub policy_inhibit: Signal<String>,
|
pub policy_inhibit: Signal<String>,
|
||||||
pub inhibit_any: Signal<String>,
|
pub inhibit_any: Signal<String>,
|
||||||
@@ -35,13 +43,57 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
|
|||||||
let policy_inhibit = s.policy_inhibit;
|
let policy_inhibit = s.policy_inhibit;
|
||||||
let inhibit_any = s.inhibit_any;
|
let inhibit_any = s.inhibit_any;
|
||||||
let advanced_json = s.advanced_json;
|
let advanced_json = s.advanced_json;
|
||||||
|
|
||||||
|
let crl_editor = ListEditorSignals::new();
|
||||||
|
let nc_permitted_editor = ListEditorSignals::new();
|
||||||
|
let nc_excluded_editor = ListEditorSignals::new();
|
||||||
|
let policy_cps_editor = ListEditorSignals::new();
|
||||||
|
let crl_config = ListEditorConfig {
|
||||||
|
id: "crl-urls",
|
||||||
|
title: "CRL distribution point URLs",
|
||||||
|
format: ListEditorFormat::UriList("\n"),
|
||||||
|
critical: Some(s.crl_critical),
|
||||||
|
copy_common_name: false,
|
||||||
|
};
|
||||||
|
let nc_permitted_config = ListEditorConfig {
|
||||||
|
id: "nc-permitted",
|
||||||
|
title: "Name constraints · permitted",
|
||||||
|
format: ListEditorFormat::GeneralNames,
|
||||||
|
critical: Some(s.nc_critical),
|
||||||
|
copy_common_name: false,
|
||||||
|
};
|
||||||
|
let nc_excluded_config = ListEditorConfig {
|
||||||
|
id: "nc-excluded",
|
||||||
|
title: "Name constraints · excluded",
|
||||||
|
format: ListEditorFormat::GeneralNames,
|
||||||
|
critical: Some(s.nc_critical),
|
||||||
|
copy_common_name: false,
|
||||||
|
};
|
||||||
|
let policy_cps_config = ListEditorConfig {
|
||||||
|
id: "policy-cps",
|
||||||
|
title: "Policy CPS URIs",
|
||||||
|
format: ListEditorFormat::UriList(", "),
|
||||||
|
critical: Some(s.policy_critical),
|
||||||
|
copy_common_name: false,
|
||||||
|
};
|
||||||
|
let edit_crl = move |_| open_list_editor(crl_editor, crl_urls, crl_config);
|
||||||
|
let edit_nc_permitted =
|
||||||
|
move |_| open_list_editor(nc_permitted_editor, nc_permitted, nc_permitted_config);
|
||||||
|
let edit_nc_excluded =
|
||||||
|
move |_| open_list_editor(nc_excluded_editor, nc_excluded, nc_excluded_config);
|
||||||
|
let edit_policy_cps =
|
||||||
|
move |_| open_list_editor(policy_cps_editor, policy_cps, policy_cps_config);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
div(class="form-section") {
|
div(class="form-section") {
|
||||||
div(class="form-section-title") { "Advanced extensions" }
|
div(class="form-section-title") { "Advanced extensions" }
|
||||||
div(class="row g-3") {
|
div(class="row g-3") {
|
||||||
div(class="col-md-6") {
|
div(class="col-md-6") {
|
||||||
label(class="form-label") { "CRL distribution point URLs" }
|
label(class="form-label") { "CRL distribution point URLs" }
|
||||||
textarea(class="form-control", rows="2", placeholder="http://example.com/ca.crl", bind:value=crl_urls) {}
|
div(class="input-group") {
|
||||||
|
textarea(id="crl-urls", class="form-control", rows="2", placeholder="http://example.com/ca.crl", bind:value=crl_urls) {}
|
||||||
|
button(id="edit-crl-urls", class="btn btn-outline-light", r#type="button", on:click=edit_crl) { "Edit" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div(class="col-md-3") {
|
div(class="col-md-3") {
|
||||||
label(class="form-label") { "AIA · OCSP" }
|
label(class="form-label") { "AIA · OCSP" }
|
||||||
@@ -53,11 +105,17 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
|
|||||||
}
|
}
|
||||||
div(class="col-md-6") {
|
div(class="col-md-6") {
|
||||||
label(class="form-label") { "Name constraints · permitted" }
|
label(class="form-label") { "Name constraints · permitted" }
|
||||||
textarea(class="form-control", rows="2", placeholder="DNS:.example.com, IP:10.0.0.0/8", bind:value=nc_permitted) {}
|
div(class="input-group") {
|
||||||
|
textarea(id="nc-permitted", class="form-control", rows="2", placeholder="DNS:.example.com, IP:10.0.0.0/8", bind:value=nc_permitted) {}
|
||||||
|
button(id="edit-nc-permitted", class="btn btn-outline-light", r#type="button", on:click=edit_nc_permitted) { "Edit" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div(class="col-md-6") {
|
div(class="col-md-6") {
|
||||||
label(class="form-label") { "Name constraints · excluded" }
|
label(class="form-label") { "Name constraints · excluded" }
|
||||||
textarea(class="form-control", rows="2", placeholder="DNS:.bad.example", bind:value=nc_excluded) {}
|
div(class="input-group") {
|
||||||
|
textarea(id="nc-excluded", class="form-control", rows="2", placeholder="DNS:.bad.example", bind:value=nc_excluded) {}
|
||||||
|
button(id="edit-nc-excluded", class="btn btn-outline-light", r#type="button", on:click=edit_nc_excluded) { "Edit" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div(class="col-md-4") {
|
div(class="col-md-4") {
|
||||||
label(class="form-label") { "Certificate policy OIDs" }
|
label(class="form-label") { "Certificate policy OIDs" }
|
||||||
@@ -65,7 +123,10 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
|
|||||||
}
|
}
|
||||||
div(class="col-md-4") {
|
div(class="col-md-4") {
|
||||||
label(class="form-label") { "Policy CPS URIs" }
|
label(class="form-label") { "Policy CPS URIs" }
|
||||||
input(class="form-control", placeholder="https://example.com/cps", bind:value=policy_cps)
|
div(class="input-group") {
|
||||||
|
input(id="policy-cps", class="form-control", placeholder="https://example.com/cps", bind:value=policy_cps)
|
||||||
|
button(id="edit-policy-cps", class="btn btn-outline-light", r#type="button", on:click=edit_policy_cps) { "Edit" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div(class="col-md-2") {
|
div(class="col-md-2") {
|
||||||
label(class="form-label") { "Require explicit policy" }
|
label(class="form-label") { "Require explicit policy" }
|
||||||
@@ -90,6 +151,22 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(list_editor_dialog(crl_editor, crl_urls, crl_config))
|
||||||
|
(list_editor_dialog(
|
||||||
|
nc_permitted_editor,
|
||||||
|
nc_permitted,
|
||||||
|
nc_permitted_config,
|
||||||
|
))
|
||||||
|
(list_editor_dialog(
|
||||||
|
nc_excluded_editor,
|
||||||
|
nc_excluded,
|
||||||
|
nc_excluded_config,
|
||||||
|
))
|
||||||
|
(list_editor_dialog(
|
||||||
|
policy_cps_editor,
|
||||||
|
policy_cps,
|
||||||
|
policy_cps_config,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +177,7 @@ pub fn build_extra_extensions(s: &ExtensionSignals) -> Result<Value, String> {
|
|||||||
if !crl_urls.is_empty() {
|
if !crl_urls.is_empty() {
|
||||||
extensions.insert(
|
extensions.insert(
|
||||||
"crl_distribution_points".to_string(),
|
"crl_distribution_points".to_string(),
|
||||||
json!({"critical": false, "urls": crl_urls}),
|
json!({"critical": s.crl_critical.get(), "urls": crl_urls}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +200,7 @@ pub fn build_extra_extensions(s: &ExtensionSignals) -> Result<Value, String> {
|
|||||||
if !permitted.is_empty() || !excluded.is_empty() {
|
if !permitted.is_empty() || !excluded.is_empty() {
|
||||||
extensions.insert(
|
extensions.insert(
|
||||||
"name_constraints".to_string(),
|
"name_constraints".to_string(),
|
||||||
json!({"critical": true, "permitted": permitted, "excluded": excluded}),
|
json!({"critical": s.nc_critical.get(), "permitted": permitted, "excluded": excluded}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +213,7 @@ pub fn build_extra_extensions(s: &ExtensionSignals) -> Result<Value, String> {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
extensions.insert(
|
extensions.insert(
|
||||||
"certificate_policies".to_string(),
|
"certificate_policies".to_string(),
|
||||||
json!({"critical": false, "policies": policies}),
|
json!({"critical": s.policy_critical.get(), "policies": policies}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +262,12 @@ pub fn load_extra_extensions(s: &ExtensionSignals, spec: &Value) {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
);
|
);
|
||||||
|
s.crl_critical.set(
|
||||||
|
spec.get("crl_distribution_points")
|
||||||
|
.and_then(|v| v.get("critical"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false),
|
||||||
|
);
|
||||||
let descriptions = spec
|
let descriptions = spec
|
||||||
.get("authority_information_access")
|
.get("authority_information_access")
|
||||||
.and_then(|v| v.get("descriptions"))
|
.and_then(|v| v.get("descriptions"))
|
||||||
@@ -215,11 +298,22 @@ pub fn load_extra_extensions(s: &ExtensionSignals, spec: &Value) {
|
|||||||
s.aia_ca_issuers.set(ca_issuers);
|
s.aia_ca_issuers.set(ca_issuers);
|
||||||
|
|
||||||
let nc = spec.get("name_constraints");
|
let nc = spec.get("name_constraints");
|
||||||
|
s.nc_critical.set(
|
||||||
|
nc.and_then(|v| v.get("critical"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true),
|
||||||
|
);
|
||||||
s.nc_permitted
|
s.nc_permitted
|
||||||
.set(names_to_text(nc.and_then(|v| v.get("permitted"))));
|
.set(names_to_text(nc.and_then(|v| v.get("permitted"))));
|
||||||
s.nc_excluded
|
s.nc_excluded
|
||||||
.set(names_to_text(nc.and_then(|v| v.get("excluded"))));
|
.set(names_to_text(nc.and_then(|v| v.get("excluded"))));
|
||||||
|
|
||||||
|
s.policy_critical.set(
|
||||||
|
spec.get("certificate_policies")
|
||||||
|
.and_then(|v| v.get("critical"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false),
|
||||||
|
);
|
||||||
let policies = spec
|
let policies = spec
|
||||||
.get("certificate_policies")
|
.get("certificate_policies")
|
||||||
.and_then(|v| v.get("policies"))
|
.and_then(|v| v.get("policies"))
|
||||||
@@ -293,13 +387,6 @@ pub fn load_extra_extensions(s: &ExtensionSignals, spec: &Value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn split_list(text: &str) -> Vec<String> {
|
|
||||||
text.split(|c| c == ',' || c == '\n')
|
|
||||||
.map(|item| item.trim().to_string())
|
|
||||||
.filter(|item| !item.is_empty())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn optional_int(text: &str, field: &str) -> Result<Option<i64>, String> {
|
fn optional_int(text: &str, field: &str) -> Result<Option<i64>, String> {
|
||||||
let text = text.trim();
|
let text = text.trim();
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
@@ -316,6 +403,9 @@ fn parse_general_names(text: &str) -> Result<Vec<Value>, String> {
|
|||||||
.map(|item| {
|
.map(|item| {
|
||||||
if let Some((prefix, value)) = item.split_once(':') {
|
if let Some((prefix, value)) = item.split_once(':') {
|
||||||
let prefix = prefix.trim().to_lowercase();
|
let prefix = prefix.trim().to_lowercase();
|
||||||
|
if prefix == "othername" || prefix == "other_name" {
|
||||||
|
return Ok(parse_other_name(value));
|
||||||
|
}
|
||||||
let kind = match prefix.as_str() {
|
let kind = match prefix.as_str() {
|
||||||
"dns" => "dns",
|
"dns" => "dns",
|
||||||
"ip" => "ip",
|
"ip" => "ip",
|
||||||
@@ -323,7 +413,6 @@ fn parse_general_names(text: &str) -> Result<Vec<Value>, String> {
|
|||||||
"email" => "email",
|
"email" => "email",
|
||||||
"rid" => "rid",
|
"rid" => "rid",
|
||||||
"upn" => "upn",
|
"upn" => "upn",
|
||||||
"othername" | "other_name" => "other_name",
|
|
||||||
_ => return Err(format!("unknown general-name type {prefix:?}")),
|
_ => return Err(format!("unknown general-name type {prefix:?}")),
|
||||||
};
|
};
|
||||||
if kind == "upn" {
|
if kind == "upn" {
|
||||||
@@ -341,20 +430,6 @@ fn parse_general_names(text: &str) -> Result<Vec<Value>, String> {
|
|||||||
fn names_to_text(value: Option<&Value>) -> String {
|
fn names_to_text(value: Option<&Value>) -> String {
|
||||||
value
|
value
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.map(|items| {
|
.map(|items| general_name_entries_to_text(items))
|
||||||
items
|
|
||||||
.iter()
|
|
||||||
.filter_map(|item| {
|
|
||||||
let kind = item.get("type").and_then(|v| v.as_str()).unwrap_or("dns");
|
|
||||||
let value = item.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
if value.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(format!("{}:{}", kind.to_uppercase(), value))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ")
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
//! XCA-style modal editor for general-name and URI-list extension fields.
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
struct ListRow {
|
||||||
|
id: usize,
|
||||||
|
kind: Signal<String>,
|
||||||
|
value: Signal<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListRow {
|
||||||
|
fn new(id: usize, kind: &str, value: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
kind: create_signal(kind.to_string()),
|
||||||
|
value: create_signal(value.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct ListEditorSignals {
|
||||||
|
open: Signal<bool>,
|
||||||
|
rows: Signal<Vec<ListRow>>,
|
||||||
|
next_id: Signal<usize>,
|
||||||
|
critical: Signal<bool>,
|
||||||
|
copy_cn: Signal<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListEditorSignals {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
open: create_signal(false),
|
||||||
|
rows: create_signal(Vec::new()),
|
||||||
|
next_id: create_signal(0),
|
||||||
|
critical: create_signal(false),
|
||||||
|
copy_cn: create_signal(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum ListEditorFormat {
|
||||||
|
GeneralNames,
|
||||||
|
UriList(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct ListEditorConfig {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub title: &'static str,
|
||||||
|
pub format: ListEditorFormat,
|
||||||
|
pub critical: Option<Signal<bool>>,
|
||||||
|
pub copy_common_name: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_list_editor(editor: ListEditorSignals, text: Signal<String>, config: ListEditorConfig) {
|
||||||
|
let mut copy_cn = false;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut next_id = 0;
|
||||||
|
match config.format {
|
||||||
|
ListEditorFormat::GeneralNames => {
|
||||||
|
for entry in parse_general_name_entries(&text.get_clone()) {
|
||||||
|
let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if config.copy_common_name
|
||||||
|
&& matches!(kind, "dns" | "dnsname")
|
||||||
|
&& value.eq_ignore_ascii_case("copycn")
|
||||||
|
{
|
||||||
|
copy_cn = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(text) = general_name_entry_to_text(&entry) {
|
||||||
|
if let Some((kind, value)) = text.split_once(':') {
|
||||||
|
rows.push(ListRow::new(next_id, kind, value));
|
||||||
|
next_id += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ListEditorFormat::UriList(_) => {
|
||||||
|
for chunk in split_list(&text.get_clone()) {
|
||||||
|
let value = uri_value(&chunk);
|
||||||
|
if !value.is_empty() {
|
||||||
|
rows.push(ListRow::new(next_id, "URI", &value));
|
||||||
|
next_id += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editor.rows.set(rows);
|
||||||
|
editor.next_id.set(next_id);
|
||||||
|
editor.critical.set(
|
||||||
|
config
|
||||||
|
.critical
|
||||||
|
.map(|critical| critical.get())
|
||||||
|
.unwrap_or(false),
|
||||||
|
);
|
||||||
|
editor.copy_cn.set(copy_cn);
|
||||||
|
editor.open.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_editor_dialog(
|
||||||
|
editor: ListEditorSignals,
|
||||||
|
text: Signal<String>,
|
||||||
|
config: ListEditorConfig,
|
||||||
|
) -> View {
|
||||||
|
let rows = editor.rows;
|
||||||
|
let critical = editor.critical;
|
||||||
|
let copy_cn = editor.copy_cn;
|
||||||
|
let title_id: &'static str = Box::leak(format!("{}-editor-title", config.id).into_boxed_str());
|
||||||
|
let critical_id: &'static str =
|
||||||
|
Box::leak(format!("{}-draft-critical", config.id).into_boxed_str());
|
||||||
|
let copy_cn_id: &'static str = Box::leak(format!("{}-copy-cn", config.id).into_boxed_str());
|
||||||
|
let has_options = config.critical.is_some() || config.copy_common_name;
|
||||||
|
let add = move |_| {
|
||||||
|
let id = editor.next_id.get();
|
||||||
|
editor.next_id.set(id + 1);
|
||||||
|
let kind = match config.format {
|
||||||
|
ListEditorFormat::GeneralNames => "DNS",
|
||||||
|
ListEditorFormat::UriList(_) => "URI",
|
||||||
|
};
|
||||||
|
rows.update(|rows| rows.push(ListRow::new(id, kind, "")));
|
||||||
|
};
|
||||||
|
let apply = move |_| {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
if config.copy_common_name && copy_cn.get() {
|
||||||
|
entries.push("DNS:copycn".to_string());
|
||||||
|
}
|
||||||
|
for row in rows.get_clone() {
|
||||||
|
let value = row.value.get_clone();
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.push(match config.format {
|
||||||
|
ListEditorFormat::GeneralNames => {
|
||||||
|
format!("{}:{}", row.kind.get_clone(), value)
|
||||||
|
}
|
||||||
|
ListEditorFormat::UriList(_) => value.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let separator = match config.format {
|
||||||
|
ListEditorFormat::GeneralNames => ", ",
|
||||||
|
ListEditorFormat::UriList(separator) => separator,
|
||||||
|
};
|
||||||
|
text.set(entries.join(separator));
|
||||||
|
if let Some(target) = config.critical {
|
||||||
|
target.set(critical.get());
|
||||||
|
}
|
||||||
|
editor.open.set(false);
|
||||||
|
};
|
||||||
|
let close = move |_| editor.open.set(false);
|
||||||
|
let cancel = move |_| editor.open.set(false);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
(if editor.open.get() {
|
||||||
|
view! {
|
||||||
|
div(class="modal fade show d-block", tabindex="-1", role="dialog", aria-modal="true", aria-labelledby=title_id) {
|
||||||
|
div(class="modal-dialog modal-lg modal-dialog-centered") {
|
||||||
|
div(class="modal-content") {
|
||||||
|
div(class="modal-header") {
|
||||||
|
h2(class="modal-title h5", id=title_id) { (config.title) }
|
||||||
|
button(class="btn-close btn-close-white", r#type="button", aria-label="Close", on:click=close)
|
||||||
|
}
|
||||||
|
div(class="modal-body") {
|
||||||
|
(if has_options {
|
||||||
|
view! {
|
||||||
|
div(class="d-flex flex-wrap gap-4 mb-3") {
|
||||||
|
(if config.critical.is_some() {
|
||||||
|
view! {
|
||||||
|
div(class="form-check") {
|
||||||
|
input(id=critical_id, class="form-check-input", r#type="checkbox", bind:checked=critical)
|
||||||
|
label(class="form-check-label", r#for=critical_id) { "Critical" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {}
|
||||||
|
})
|
||||||
|
(if config.copy_common_name {
|
||||||
|
view! {
|
||||||
|
div(class="form-check") {
|
||||||
|
input(id=copy_cn_id, class="form-check-input", r#type="checkbox", bind:checked=copy_cn)
|
||||||
|
label(class="form-check-label", r#for=copy_cn_id) { "Copy Common Name" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {}
|
||||||
|
})
|
||||||
|
div(class="table-responsive") {
|
||||||
|
table(class="table table-sm align-middle mb-0") {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th(scope="col", style="width: 11rem") { "Type" }
|
||||||
|
th(scope="col") { "Content" }
|
||||||
|
th(scope="col") { span(class="visually-hidden") { "Actions" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
Keyed(
|
||||||
|
list=rows,
|
||||||
|
view=move |row| {
|
||||||
|
let id = row.id;
|
||||||
|
let kind = row.kind;
|
||||||
|
let value = row.value;
|
||||||
|
on_cleanup(move || {
|
||||||
|
kind.dispose();
|
||||||
|
value.dispose();
|
||||||
|
});
|
||||||
|
let remove = move |_| {
|
||||||
|
rows.update(|rows| rows.retain(|row| row.id != id));
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
tr(class="san-entry list-entry") {
|
||||||
|
td {
|
||||||
|
select(class="form-select form-select-sm", aria-label="Entry type", bind:value=kind) {
|
||||||
|
(list_type_options(config.format))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
input(class="form-control form-control-sm san-value list-value", aria-label="Entry content", bind:value=value)
|
||||||
|
}
|
||||||
|
td(class="table-actions") {
|
||||||
|
button(class="btn btn-sm btn-outline-danger", r#type="button", on:click=remove) { "Delete" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
key=|row| row.id,
|
||||||
|
)
|
||||||
|
(if rows.get_clone().is_empty() {
|
||||||
|
view! {
|
||||||
|
tr {
|
||||||
|
td(colspan="3", class="text-secondary text-center py-3") { "No entries. Click Add to create one." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(if matches!(config.format, ListEditorFormat::GeneralNames) {
|
||||||
|
view! {
|
||||||
|
div(class="form-text mt-2") {
|
||||||
|
"For otherName, use <OID>;UTF8:<content> or <OID>;DER:<hex>."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {}
|
||||||
|
})
|
||||||
|
button(class="btn btn-outline-light mt-3", r#type="button", on:click=add) { "Add" }
|
||||||
|
}
|
||||||
|
div(class="modal-footer") {
|
||||||
|
button(class="btn btn-outline-light", r#type="button", on:click=cancel) { "Cancel" }
|
||||||
|
button(class="btn btn-primary", r#type="button", on:click=apply) { "Apply" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(class="modal-backdrop fade show")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_type_options(format: ListEditorFormat) -> View {
|
||||||
|
match format {
|
||||||
|
ListEditorFormat::GeneralNames => view! {
|
||||||
|
option(value="DNS") { "DNS" }
|
||||||
|
option(value="IP") { "IP" }
|
||||||
|
option(value="URI") { "URI" }
|
||||||
|
option(value="email") { "email" }
|
||||||
|
option(value="RID") { "RID" }
|
||||||
|
option(value="UPN") { "UPN" }
|
||||||
|
option(value="otherName") { "otherName" }
|
||||||
|
},
|
||||||
|
ListEditorFormat::UriList(_) => view! {
|
||||||
|
option(value="URI") { "URI" }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn split_list(text: &str) -> Vec<String> {
|
||||||
|
text.split(|c| c == ',' || c == '\n')
|
||||||
|
.map(|item| item.trim().to_string())
|
||||||
|
.filter(|item| !item.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uri_value(text: &str) -> String {
|
||||||
|
if let Some((prefix, value)) = text.split_once(':') {
|
||||||
|
if prefix.trim().eq_ignore_ascii_case("URI") {
|
||||||
|
return value.trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_general_name_entries(text: &str) -> Vec<Value> {
|
||||||
|
split_list(text)
|
||||||
|
.iter()
|
||||||
|
.map(|chunk| parse_general_name_chunk(chunk))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_general_name_chunk(chunk: &str) -> Value {
|
||||||
|
if let Some((prefix, value)) = chunk.split_once(':') {
|
||||||
|
let kind = prefix.trim().to_lowercase();
|
||||||
|
if kind == "othername" || kind == "other_name" {
|
||||||
|
return parse_other_name(value);
|
||||||
|
}
|
||||||
|
let kind = match kind.as_str() {
|
||||||
|
"ip" => "ip",
|
||||||
|
"uri" => "uri",
|
||||||
|
"email" => "email",
|
||||||
|
"rid" => "rid",
|
||||||
|
"upn" => "upn",
|
||||||
|
"dns" => "dns",
|
||||||
|
_ => "dns",
|
||||||
|
};
|
||||||
|
json!({"type": kind, "value": value.trim()})
|
||||||
|
} else {
|
||||||
|
json!({"type": "dns", "value": chunk})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_other_name(value: &str) -> Value {
|
||||||
|
let (oid, payload) = value.split_once(';').unwrap_or((value, ""));
|
||||||
|
let (encoding, content) = payload.split_once(':').unwrap_or(("UTF8", payload));
|
||||||
|
let mut entry = json!({"type": "other_name", "oid": oid.trim()});
|
||||||
|
if encoding.trim().eq_ignore_ascii_case("DER") {
|
||||||
|
entry["der_hex"] = json!(content.trim());
|
||||||
|
} else {
|
||||||
|
entry["value"] = json!(content.trim());
|
||||||
|
}
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn general_name_entry_to_text(entry: &Value) -> Option<String> {
|
||||||
|
let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns");
|
||||||
|
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if matches!(kind, "othername" | "other_name") {
|
||||||
|
let oid = entry.get("oid").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if oid.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !value.is_empty() {
|
||||||
|
return Some(format!("otherName:{oid};UTF8:{value}"));
|
||||||
|
}
|
||||||
|
let der = entry.get("der_hex").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
return (!der.is_empty()).then(|| format!("otherName:{oid};DER:{der}"));
|
||||||
|
}
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let prefix = match kind {
|
||||||
|
"dns" | "dnsname" => "DNS".to_string(),
|
||||||
|
"ip" | "ipaddress" => "IP".to_string(),
|
||||||
|
"uri" | "uniformresourceidentifier" => "URI".to_string(),
|
||||||
|
"email" | "rfc822name" => "email".to_string(),
|
||||||
|
"rid" | "registeredid" => "RID".to_string(),
|
||||||
|
"upn" => "UPN".to_string(),
|
||||||
|
other => other.to_uppercase(),
|
||||||
|
};
|
||||||
|
Some(format!("{prefix}:{value}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn general_name_entries_to_text(entries: &[Value]) -> String {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(general_name_entry_to_text)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn general_names_round_trip_xca_types() {
|
||||||
|
let text = "DNS:example.com, IP:192.0.2.1, URI:https://example.com/ca, email:pki@example.com, RID:1.2.3.4, UPN:user@example.com, otherName:1.2.3.4.5;UTF8:device";
|
||||||
|
let entries = parse_general_name_entries(text);
|
||||||
|
|
||||||
|
assert_eq!(entries.len(), 7);
|
||||||
|
assert_eq!(
|
||||||
|
entries[5],
|
||||||
|
json!({"type": "upn", "value": "user@example.com"})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
entries[6],
|
||||||
|
json!({"type": "other_name", "oid": "1.2.3.4.5", "value": "device"})
|
||||||
|
);
|
||||||
|
assert_eq!(general_name_entries_to_text(&entries), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn general_names_preserve_copycn_and_der_other_names() {
|
||||||
|
let entries = parse_general_name_entries("DNS:copycn, otherName:1.2.3.4;DER:0c03616263");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
entries[1],
|
||||||
|
json!({"type": "other_name", "oid": "1.2.3.4", "der_hex": "0c03616263"})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general_name_entries_to_text(&entries),
|
||||||
|
"DNS:copycn, otherName:1.2.3.4;DER:0c03616263"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uri_lists_split_lines_and_accept_optional_prefixes() {
|
||||||
|
let items = split_list("https://one.example/crl\nURI:https://two.example/crl");
|
||||||
|
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
assert_eq!(uri_value(&items[0]), "https://one.example/crl");
|
||||||
|
assert_eq!(uri_value(&items[1]), "https://two.example/crl");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod extension_form;
|
pub mod extension_form;
|
||||||
|
pub mod list_editor;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|||||||
@@ -7,38 +7,16 @@ use crate::api;
|
|||||||
use crate::components::extension_form::{
|
use crate::components::extension_form::{
|
||||||
build_extra_extensions, extension_fields, load_extra_extensions, ExtensionSignals,
|
build_extra_extensions, extension_fields, load_extra_extensions, ExtensionSignals,
|
||||||
};
|
};
|
||||||
|
use crate::components::list_editor::{
|
||||||
|
general_name_entries_to_text, list_editor_dialog, open_list_editor, parse_general_name_entries,
|
||||||
|
ListEditorConfig, ListEditorFormat, ListEditorSignals,
|
||||||
|
};
|
||||||
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, 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(Clone, PartialEq)]
|
|
||||||
struct SanRow {
|
|
||||||
id: usize,
|
|
||||||
kind: Signal<String>,
|
|
||||||
value: Signal<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SanRow {
|
|
||||||
fn new(id: usize, kind: &str, value: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
kind: create_signal(kind.to_string()),
|
|
||||||
value: create_signal(value.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct SanEditorSignals {
|
|
||||||
open: Signal<bool>,
|
|
||||||
rows: Signal<Vec<SanRow>>,
|
|
||||||
next_id: Signal<usize>,
|
|
||||||
critical: Signal<bool>,
|
|
||||||
copy_cn: Signal<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct CertFormSignals {
|
struct CertFormSignals {
|
||||||
explicit_dn: Signal<Vec<String>>,
|
explicit_dn: Signal<Vec<String>>,
|
||||||
@@ -137,24 +115,30 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
|||||||
let aki = create_signal(true);
|
let aki = create_signal(true);
|
||||||
let ocsp_staple = create_signal(false);
|
let ocsp_staple = create_signal(false);
|
||||||
let crl_urls = create_signal(String::new());
|
let crl_urls = create_signal(String::new());
|
||||||
|
let crl_critical = create_signal(false);
|
||||||
let aia_ocsp = create_signal(String::new());
|
let aia_ocsp = create_signal(String::new());
|
||||||
let aia_ca_issuers = create_signal(String::new());
|
let aia_ca_issuers = create_signal(String::new());
|
||||||
let nc_permitted = create_signal(String::new());
|
let nc_permitted = create_signal(String::new());
|
||||||
let nc_excluded = create_signal(String::new());
|
let nc_excluded = create_signal(String::new());
|
||||||
|
let nc_critical = create_signal(true);
|
||||||
let policy_oids = create_signal(String::new());
|
let policy_oids = create_signal(String::new());
|
||||||
let policy_cps = create_signal(String::new());
|
let policy_cps = create_signal(String::new());
|
||||||
|
let policy_critical = create_signal(false);
|
||||||
let policy_require = create_signal(String::new());
|
let policy_require = create_signal(String::new());
|
||||||
let policy_inhibit = create_signal(String::new());
|
let policy_inhibit = create_signal(String::new());
|
||||||
let inhibit_any = create_signal(String::new());
|
let inhibit_any = create_signal(String::new());
|
||||||
let advanced_json = create_signal(String::new());
|
let advanced_json = create_signal(String::new());
|
||||||
let ext = ExtensionSignals {
|
let ext = ExtensionSignals {
|
||||||
crl_urls,
|
crl_urls,
|
||||||
|
crl_critical,
|
||||||
aia_ocsp,
|
aia_ocsp,
|
||||||
aia_ca_issuers,
|
aia_ca_issuers,
|
||||||
nc_permitted,
|
nc_permitted,
|
||||||
nc_excluded,
|
nc_excluded,
|
||||||
|
nc_critical,
|
||||||
policy_oids,
|
policy_oids,
|
||||||
policy_cps,
|
policy_cps,
|
||||||
|
policy_critical,
|
||||||
policy_require,
|
policy_require,
|
||||||
policy_inhibit,
|
policy_inhibit,
|
||||||
inhibit_any,
|
inhibit_any,
|
||||||
@@ -216,14 +200,15 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
|||||||
not_after_text,
|
not_after_text,
|
||||||
no_expiration,
|
no_expiration,
|
||||||
};
|
};
|
||||||
let san_editor = SanEditorSignals {
|
let san_editor = ListEditorSignals::new();
|
||||||
open: create_signal(false),
|
let san_editor_config = ListEditorConfig {
|
||||||
rows: create_signal(Vec::new()),
|
id: "san",
|
||||||
next_id: create_signal(0),
|
title: "Subject alternative names",
|
||||||
critical: create_signal(false),
|
format: ListEditorFormat::GeneralNames,
|
||||||
copy_cn: create_signal(false),
|
critical: Some(san_critical),
|
||||||
|
copy_common_name: true,
|
||||||
};
|
};
|
||||||
let edit_san = move |_| open_san_editor(san_editor, san_text, san_critical);
|
let edit_san = move |_| open_list_editor(san_editor, san_text, san_editor_config);
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(user) = api::me().await {
|
if let Ok(user) = api::me().await {
|
||||||
@@ -818,7 +803,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(san_editor_dialog(san_editor, san_text, san_critical))
|
(list_editor_dialog(san_editor, san_text, san_editor_config))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1141,7 +1126,7 @@ fn build_extensions(form: &CertFormSignals) -> Result<Value, String> {
|
|||||||
.map(|(_, name)| json!(name))
|
.map(|(_, name)| json!(name))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
extensions["extended_key_usage"] = json!({"critical": form.eku_critical.get(), "usages": eku});
|
extensions["extended_key_usage"] = json!({"critical": form.eku_critical.get(), "usages": eku});
|
||||||
let san = parse_san(&form.san_text.get_clone());
|
let san = parse_general_name_entries(&form.san_text.get_clone());
|
||||||
if !san.is_empty() {
|
if !san.is_empty() {
|
||||||
extensions["subject_alt_name"] =
|
extensions["subject_alt_name"] =
|
||||||
json!({"critical": form.san_critical.get(), "entries": san});
|
json!({"critical": form.san_critical.get(), "entries": san});
|
||||||
@@ -1276,7 +1261,7 @@ fn apply_extension_spec(form: &CertFormSignals, spec: &Value) {
|
|||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
form.san_text.set(san_entries_to_text(&entries));
|
form.san_text.set(general_name_entries_to_text(&entries));
|
||||||
form.san_critical.set(
|
form.san_critical.set(
|
||||||
san.get("critical")
|
san.get("critical")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
@@ -1301,163 +1286,6 @@ fn apply_extension_spec(form: &CertFormSignals, spec: &Value) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_san_editor(editor: SanEditorSignals, san_text: Signal<String>, san_critical: Signal<bool>) {
|
|
||||||
let mut copy_cn = false;
|
|
||||||
let mut rows = Vec::new();
|
|
||||||
let mut next_id = 0;
|
|
||||||
for entry in parse_san(&san_text.get_clone()) {
|
|
||||||
let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
if matches!(kind, "dns" | "dnsname") && value.eq_ignore_ascii_case("copycn") {
|
|
||||||
copy_cn = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(text) = san_entry_to_text(&entry) {
|
|
||||||
if let Some((kind, value)) = text.split_once(':') {
|
|
||||||
rows.push(SanRow::new(next_id, kind, value));
|
|
||||||
next_id += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
editor.rows.set(rows);
|
|
||||||
editor.next_id.set(next_id);
|
|
||||||
editor.critical.set(san_critical.get());
|
|
||||||
editor.copy_cn.set(copy_cn);
|
|
||||||
editor.open.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn san_editor_dialog(
|
|
||||||
editor: SanEditorSignals,
|
|
||||||
san_text: Signal<String>,
|
|
||||||
san_critical: Signal<bool>,
|
|
||||||
) -> View {
|
|
||||||
let rows = editor.rows;
|
|
||||||
let critical = editor.critical;
|
|
||||||
let copy_cn = editor.copy_cn;
|
|
||||||
let add = move |_| {
|
|
||||||
let id = editor.next_id.get();
|
|
||||||
editor.next_id.set(id + 1);
|
|
||||||
rows.update(|rows| rows.push(SanRow::new(id, "DNS", "")));
|
|
||||||
};
|
|
||||||
let apply = move |_| {
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
if copy_cn.get() {
|
|
||||||
entries.push("DNS:copycn".to_string());
|
|
||||||
}
|
|
||||||
for row in rows.get_clone() {
|
|
||||||
let value = row.value.get_clone();
|
|
||||||
let value = value.trim();
|
|
||||||
if !value.is_empty() {
|
|
||||||
entries.push(format!("{}:{}", row.kind.get_clone(), value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
san_text.set(entries.join(", "));
|
|
||||||
san_critical.set(critical.get());
|
|
||||||
editor.open.set(false);
|
|
||||||
};
|
|
||||||
let close = move |_| editor.open.set(false);
|
|
||||||
let cancel = move |_| editor.open.set(false);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
(if editor.open.get() {
|
|
||||||
view! {
|
|
||||||
div(class="modal fade show d-block", tabindex="-1", role="dialog", aria-modal="true", aria-labelledby="san-editor-title") {
|
|
||||||
div(class="modal-dialog modal-lg modal-dialog-centered") {
|
|
||||||
div(class="modal-content") {
|
|
||||||
div(class="modal-header") {
|
|
||||||
h2(class="modal-title h5", id="san-editor-title") { "Subject alternative names" }
|
|
||||||
button(class="btn-close btn-close-white", r#type="button", aria-label="Close", on:click=close)
|
|
||||||
}
|
|
||||||
div(class="modal-body") {
|
|
||||||
div(class="d-flex flex-wrap gap-4 mb-3") {
|
|
||||||
div(class="form-check") {
|
|
||||||
input(id="san-draft-critical", class="form-check-input", r#type="checkbox", bind:checked=critical)
|
|
||||||
label(class="form-check-label", r#for="san-draft-critical") { "Critical" }
|
|
||||||
}
|
|
||||||
div(class="form-check") {
|
|
||||||
input(id="san-copy-cn", class="form-check-input", r#type="checkbox", bind:checked=copy_cn)
|
|
||||||
label(class="form-check-label", r#for="san-copy-cn") { "Copy Common Name" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div(class="table-responsive") {
|
|
||||||
table(class="table table-sm align-middle mb-0") {
|
|
||||||
thead {
|
|
||||||
tr {
|
|
||||||
th(scope="col", style="width: 11rem") { "Type" }
|
|
||||||
th(scope="col") { "Content" }
|
|
||||||
th(scope="col") { span(class="visually-hidden") { "Actions" } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tbody {
|
|
||||||
Keyed(
|
|
||||||
list=rows,
|
|
||||||
view=move |row| {
|
|
||||||
let id = row.id;
|
|
||||||
let kind = row.kind;
|
|
||||||
let value = row.value;
|
|
||||||
on_cleanup(move || {
|
|
||||||
kind.dispose();
|
|
||||||
value.dispose();
|
|
||||||
});
|
|
||||||
let remove = move |_| {
|
|
||||||
rows.update(|rows| rows.retain(|row| row.id != id));
|
|
||||||
};
|
|
||||||
view! {
|
|
||||||
tr(class="san-entry") {
|
|
||||||
td {
|
|
||||||
select(class="form-select form-select-sm", aria-label="SAN type", bind:value=kind) {
|
|
||||||
option(value="DNS") { "DNS" }
|
|
||||||
option(value="IP") { "IP" }
|
|
||||||
option(value="URI") { "URI" }
|
|
||||||
option(value="email") { "email" }
|
|
||||||
option(value="RID") { "RID" }
|
|
||||||
option(value="UPN") { "UPN" }
|
|
||||||
option(value="otherName") { "otherName" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
input(class="form-control form-control-sm san-value", aria-label="SAN content", bind:value=value)
|
|
||||||
}
|
|
||||||
td(class="table-actions") {
|
|
||||||
button(class="btn btn-sm btn-outline-danger", r#type="button", on:click=remove) { "Delete" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
key=|row| row.id,
|
|
||||||
)
|
|
||||||
(if rows.get_clone().is_empty() {
|
|
||||||
view! {
|
|
||||||
tr {
|
|
||||||
td(colspan="3", class="text-secondary text-center py-3") { "No entries. Click Add to create one." }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
view! {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div(class="form-text mt-2") {
|
|
||||||
"For otherName, use <OID>;UTF8:<content> or <OID>;DER:<hex>."
|
|
||||||
}
|
|
||||||
button(class="btn btn-outline-light mt-3", r#type="button", on:click=add) { "Add" }
|
|
||||||
}
|
|
||||||
div(class="modal-footer") {
|
|
||||||
button(class="btn btn-outline-light", r#type="button", on:click=cancel) { "Cancel" }
|
|
||||||
button(class="btn btn-primary", r#type="button", on:click=apply) { "Apply" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div(class="modal-backdrop fade show")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
view! {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check(label: &'static str, value: Signal<bool>) -> View {
|
fn check(label: &'static str, value: Signal<bool>) -> View {
|
||||||
view! {
|
view! {
|
||||||
div(class="form-check") {
|
div(class="form-check") {
|
||||||
@@ -1467,82 +1295,6 @@ fn check(label: &'static str, value: Signal<bool>) -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_san(text: &str) -> Vec<serde_json::Value> {
|
|
||||||
text.split(',')
|
|
||||||
.map(|chunk| chunk.trim())
|
|
||||||
.filter(|chunk| !chunk.is_empty())
|
|
||||||
.map(|chunk| {
|
|
||||||
if let Some((prefix, value)) = chunk.split_once(':') {
|
|
||||||
let kind = prefix.trim().to_lowercase();
|
|
||||||
if kind == "othername" || kind == "other_name" {
|
|
||||||
return parse_other_name(value);
|
|
||||||
}
|
|
||||||
let kind = match kind.as_str() {
|
|
||||||
"ip" => "ip",
|
|
||||||
"uri" => "uri",
|
|
||||||
"email" => "email",
|
|
||||||
"rid" => "rid",
|
|
||||||
"upn" => "upn",
|
|
||||||
"dns" => "dns",
|
|
||||||
_ => "dns",
|
|
||||||
};
|
|
||||||
json!({"type": kind, "value": value.trim()})
|
|
||||||
} else {
|
|
||||||
json!({"type": "dns", "value": chunk})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_other_name(value: &str) -> serde_json::Value {
|
|
||||||
let (oid, payload) = value.split_once(';').unwrap_or((value, ""));
|
|
||||||
let (encoding, content) = payload.split_once(':').unwrap_or(("UTF8", payload));
|
|
||||||
let mut entry = json!({"type": "other_name", "oid": oid.trim()});
|
|
||||||
if encoding.trim().eq_ignore_ascii_case("DER") {
|
|
||||||
entry["der_hex"] = json!(content.trim());
|
|
||||||
} else {
|
|
||||||
entry["value"] = json!(content.trim());
|
|
||||||
}
|
|
||||||
entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fn san_entry_to_text(entry: &serde_json::Value) -> Option<String> {
|
|
||||||
let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns");
|
|
||||||
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
if matches!(kind, "othername" | "other_name") {
|
|
||||||
let oid = entry.get("oid").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
if oid.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if !value.is_empty() {
|
|
||||||
return Some(format!("otherName:{oid};UTF8:{value}"));
|
|
||||||
}
|
|
||||||
let der = entry.get("der_hex").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
return (!der.is_empty()).then(|| format!("otherName:{oid};DER:{der}"));
|
|
||||||
}
|
|
||||||
if value.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let prefix = match kind {
|
|
||||||
"dns" | "dnsname" => "DNS".to_string(),
|
|
||||||
"ip" | "ipaddress" => "IP".to_string(),
|
|
||||||
"uri" | "uniformresourceidentifier" => "URI".to_string(),
|
|
||||||
"email" | "rfc822name" => "email".to_string(),
|
|
||||||
"rid" | "registeredid" => "RID".to_string(),
|
|
||||||
"upn" => "UPN".to_string(),
|
|
||||||
other => other.to_uppercase(),
|
|
||||||
};
|
|
||||||
Some(format!("{prefix}:{value}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn san_entries_to_text(entries: &[serde_json::Value]) -> String {
|
|
||||||
entries
|
|
||||||
.iter()
|
|
||||||
.filter_map(san_entry_to_text)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
|
fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
|
||||||
value
|
value
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
@@ -1566,39 +1318,3 @@ fn query_param(name: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn san_entries_round_trip_xca_types() {
|
|
||||||
let text = "DNS:example.com, IP:192.0.2.1, URI:https://example.com/ca, email:pki@example.com, RID:1.2.3.4, UPN:user@example.com, otherName:1.2.3.4.5;UTF8:device";
|
|
||||||
let entries = parse_san(text);
|
|
||||||
|
|
||||||
assert_eq!(entries.len(), 7);
|
|
||||||
assert_eq!(
|
|
||||||
entries[5],
|
|
||||||
json!({"type": "upn", "value": "user@example.com"})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
entries[6],
|
|
||||||
json!({"type": "other_name", "oid": "1.2.3.4.5", "value": "device"})
|
|
||||||
);
|
|
||||||
assert_eq!(san_entries_to_text(&entries), text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn san_entries_preserve_copycn_and_der_other_names() {
|
|
||||||
let entries = parse_san("DNS:copycn, otherName:1.2.3.4;DER:0c03616263");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
entries[1],
|
|
||||||
json!({"type": "other_name", "oid": "1.2.3.4", "der_hex": "0c03616263"})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
san_entries_to_text(&entries),
|
|
||||||
"DNS:copycn, otherName:1.2.3.4;DER:0c03616263"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -51,24 +51,30 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
|
|||||||
let ski = create_signal(true);
|
let ski = create_signal(true);
|
||||||
let aki = create_signal(true);
|
let aki = create_signal(true);
|
||||||
let crl_urls = create_signal(String::new());
|
let crl_urls = create_signal(String::new());
|
||||||
|
let crl_critical = create_signal(false);
|
||||||
let aia_ocsp = create_signal(String::new());
|
let aia_ocsp = create_signal(String::new());
|
||||||
let aia_ca_issuers = create_signal(String::new());
|
let aia_ca_issuers = create_signal(String::new());
|
||||||
let nc_permitted = create_signal(String::new());
|
let nc_permitted = create_signal(String::new());
|
||||||
let nc_excluded = create_signal(String::new());
|
let nc_excluded = create_signal(String::new());
|
||||||
|
let nc_critical = create_signal(true);
|
||||||
let policy_oids = create_signal(String::new());
|
let policy_oids = create_signal(String::new());
|
||||||
let policy_cps = create_signal(String::new());
|
let policy_cps = create_signal(String::new());
|
||||||
|
let policy_critical = create_signal(false);
|
||||||
let policy_require = create_signal(String::new());
|
let policy_require = create_signal(String::new());
|
||||||
let policy_inhibit = create_signal(String::new());
|
let policy_inhibit = create_signal(String::new());
|
||||||
let inhibit_any = create_signal(String::new());
|
let inhibit_any = create_signal(String::new());
|
||||||
let advanced_json = create_signal(String::new());
|
let advanced_json = create_signal(String::new());
|
||||||
let ext = ExtensionSignals {
|
let ext = ExtensionSignals {
|
||||||
crl_urls,
|
crl_urls,
|
||||||
|
crl_critical,
|
||||||
aia_ocsp,
|
aia_ocsp,
|
||||||
aia_ca_issuers,
|
aia_ca_issuers,
|
||||||
nc_permitted,
|
nc_permitted,
|
||||||
nc_excluded,
|
nc_excluded,
|
||||||
|
nc_critical,
|
||||||
policy_oids,
|
policy_oids,
|
||||||
policy_cps,
|
policy_cps,
|
||||||
|
policy_critical,
|
||||||
policy_require,
|
policy_require,
|
||||||
policy_inhibit,
|
policy_inhibit,
|
||||||
inhibit_any,
|
inhibit_any,
|
||||||
|
|||||||
Reference in New Issue
Block a user