Add server template apply modes, bulk revocation, explicit DN order and detail tabs

This commit is contained in:
2026-10-23 05:33:43 +00:00
parent 5eb7866f5b
commit 763268bd40
13 changed files with 1477 additions and 633 deletions
+18 -11
View File
@@ -78,8 +78,10 @@ explicit/mandatory DN entries, default key type/size, default validity).
- Issue self-signed certificates or certificates signed by a CA in the same
vault, from scratch or from a request. Imported CSRs are marked **signed**
once a certificate is issued from them.
- The certificate wizard covers subject entries, validity, basic constraints,
key usage, extended key usage, SAN/IAN, subject/authority key identifiers,
- The certificate wizard covers subject entries in the vault's explicit DN
order, additional subject attributes, mandatory DN enforcement, validity,
basic constraints, key usage, extended key usage, SAN/IAN,
subject/authority key identifiers,
CRL distribution points, authority information access, name constraints,
certificate policies, policy constraints, inhibit-any-policy, OCSP no-check,
TLS feature (OCSP stapling), Netscape legacy extensions and raw
@@ -87,14 +89,18 @@ explicit/mandatory DN entries, default key type/size, default validity).
advanced extensions plus an advanced JSON editor for raw/less common
extensions, and a server-side **Validate extensions** action. OpenSSL config
text is available on export.
- Certificate details show subject/issuer, serial, validity, signatures,
MD5/SHA-1/SHA-256 fingerprints, parsed extensions, OpenSSL validation
errors, purposes and the issuer chain.
- Certificate details are split into Overview, Extensions, Validation, Export,
Metadata, Revoke, Transform and CA tabs, with hash deep links. They show
subject/issuer, serial, validity, signatures, MD5/SHA-1/SHA-256
fingerprints, parsed extensions, OpenSSL validation errors, purposes and the
issuer chain.
- Renew with a new or kept serial, optionally revoke/replace the old
certificate. Revoke with a reason; manage the CA's revocation list manually,
including revocation dates and invalidity dates. CA properties (CRL days and
default signing template, including built-ins) are editable from the CA
detail page.
including revocation dates and invalidity dates. Certificate lists support
bulk revocation with a shared reason and invalidity date. CA properties
(CRL days and default signing template, including built-ins) are editable
from the CA detail page, and the wizard offers the CA default template
explicitly without overwriting edits.
- Absolute validity dates and XCA's "no well-defined expiration" mode are
supported alongside duration-based validity.
- Transform certificates to a public key, a CSR or a template.
@@ -114,8 +120,9 @@ DER or vCalendar.
Built-in **CA**, **TLS server** and **TLS client** templates mirror XCA's
presets. User templates store subject defaults, extension defaults and a
relative validity range. XCA's binary template format is supported for import
and export, and templates can be applied to a certificate/request spec
server-side.
and export, and templates are applied to a certificate/request spec
server-side with **Apply all**, **Subject only**, and **Extensions + validity**
modes.
### Import and migration
@@ -148,7 +155,7 @@ OpenAPI JSON is at `/api/openapi.json`; Swagger UI at `/api/docs`.
| Vaults | `GET/POST /api/vaults`, `GET/PATCH/DELETE /api/vaults/{id}`, `GET/PATCH /api/vaults/{id}/settings`, `GET /api/vaults/{id}/summary`, `GET /api/vaults/{id}/options`, `GET /api/vaults/{id}/search`, `POST /api/vaults/{id}/export` |
| Keys | `GET/POST /api/vaults/{id}/keys`, `GET/PATCH/DELETE .../keys/{key_id}`, `POST .../keys/{key_id}/export` |
| Requests | `GET/POST /api/vaults/{id}/requests`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/transform/open-key`, `POST .../{id}/transform/template` |
| Certificates | `GET/POST /api/vaults/{id}/certificates`, `POST /api/vaults/{id}/certificates/preview`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/renew`, `POST .../{id}/revoke`, `GET/PATCH .../{id}/ca`, `GET/POST .../{id}/revocations`, `PATCH/DELETE .../revocations/{rev_id}`, `POST .../{id}/crl`, `POST .../{id}/tls-auth`, transforms |
| Certificates | `GET/POST /api/vaults/{id}/certificates`, `POST /api/vaults/{id}/certificates/preview`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/renew`, `POST .../{id}/revoke`, `POST /api/vaults/{id}/certificates/bulk-revoke`, `GET/PATCH .../{id}/ca`, `GET/POST .../{id}/revocations`, `PATCH/DELETE .../revocations/{rev_id}`, `POST .../{id}/crl`, `POST .../{id}/tls-auth`, transforms |
| CRLs | `GET /api/vaults/{id}/crls`, `GET/DELETE .../{crl_id}`, `POST .../{crl_id}/export` |
| Templates | `GET/POST /api/vaults/{id}/templates`, `GET/PATCH/DELETE .../{template_id}`, `POST .../{template_id}/apply`, `POST .../{template_id}/export` |
| Import | `POST /api/vaults/{id}/import` (`dry_run`, `selected`, `xca_db`) |
+23
View File
@@ -49,6 +49,9 @@ test("advanced extensions, validation and vault export", async ({ page }) => {
await page.getByRole("button", { name: "Create certificate" }).click();
await expect(page.getByRole("heading", { name: certName })).toBeVisible();
const certUrl = page.url();
const certApiUrl = certUrl.replace(/\/vaults\//, "/api/vaults/");
await page.locator(".nav-tabs").getByRole("button", { name: "Extensions", exact: true }).click();
await expect(page.getByText("CRL Distribution Points", { exact: false })).toBeVisible();
await expect(page.getByText("1.2.3.4", { exact: false })).toBeVisible();
@@ -59,4 +62,24 @@ test("advanced extensions, validation and vault export", async ({ page }) => {
await page.getByRole("button", { name: "Export ZIP" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.zip$/);
// Import preview selection: a duplicate is deselected by default, but can
// be selected explicitly and then reported as skipped by the commit.
await page.goto(`${vaultUrl}/import`);
await expect(page.getByRole("heading", { name: "Import" })).toBeVisible();
const pem = await page.evaluate(async (url) => {
const response = await fetch(`${url}/export`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ format: "pem" }),
});
const payload = await response.json();
return atob(payload.content_base64);
}, certApiUrl);
await page.locator("textarea[placeholder='-----BEGIN CERTIFICATE-----']").fill(pem);
await page.getByRole("button", { name: "Preview", exact: true }).click();
await expect(page.getByText("duplicate", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "All", exact: true }).click();
await page.getByRole("button", { name: "Import", exact: true }).click();
await expect(page.getByText(/skipped 1/)).toBeVisible();
});
+115
View File
@@ -0,0 +1,115 @@
// Bulk revocation through the certificate list.
const { test, expect } = require("@playwright/test");
function unique(prefix) {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
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 api(page, method, url, body) {
return page.evaluate(
async ({ method, url, body }) => {
const response = await fetch(url, {
method,
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
return { status: response.status, json: text ? JSON.parse(text) : null };
},
{ method, url, body }
);
}
test("bulk revocation from the certificate list", async ({ page }) => {
const user = unique("bulk");
const vaultName = unique("Bulk Vault");
await login(page, user);
const vault = await api(page, "POST", "/api/vaults", { name: vaultName });
expect(vault.status).toBe(201);
const vaultId = vault.json.id;
const caKey = await api(page, "POST", `/api/vaults/${vaultId}/keys`, {
name: "Bulk CA key",
key_type: "rsa",
size: 2048,
});
expect(caKey.status).toBe(201);
const now = new Date();
const later = new Date(now.getTime() + 365 * 24 * 3600 * 1000);
const ca = await api(page, "POST", `/api/vaults/${vaultId}/certificates`, {
name: "Bulk CA",
key_id: caKey.json.id,
subject: [
{ name: "C", value: "DE" },
{ name: "O", value: "KCA E2E" },
{ name: "CN", value: "Bulk CA" },
],
not_before: now.toISOString(),
not_after: later.toISOString(),
extensions: {
basic_constraints: { critical: true, ca: true, path_length: null },
key_usage: { critical: true, usages: ["key_cert_sign", "crl_sign"] },
subject_key_identifier: { critical: false },
authority_key_identifier: { critical: false },
},
});
expect(ca.status).toBe(201);
const caId = ca.json.id;
async function leaf(name) {
const key = await api(page, "POST", `/api/vaults/${vaultId}/keys`, {
name: `${name} key`,
key_type: "rsa",
size: 2048,
});
expect(key.status).toBe(201);
const cert = await api(page, "POST", `/api/vaults/${vaultId}/certificates`, {
name,
key_id: key.json.id,
signer_id: caId,
subject: [
{ name: "C", value: "DE" },
{ name: "O", value: "KCA E2E" },
{ name: "CN", value: name },
],
not_before: now.toISOString(),
not_after: later.toISOString(),
extensions: {
basic_constraints: { critical: true, ca: false, path_length: null },
key_usage: { critical: true, usages: ["digital_signature"] },
subject_alt_name: { critical: false, entries: [{ type: "dns", value: name }] },
subject_key_identifier: { critical: false },
authority_key_identifier: { critical: false },
},
});
expect(cert.status).toBe(201);
return cert.json;
}
await leaf("bulk-one.example");
await leaf("bulk-two.example");
await page.goto(`/vaults/${vaultId}/certificates`);
await expect(page.getByRole("heading", { name: "Certificates" })).toBeVisible();
await page.evaluate(() => {
window.confirm = () => true;
});
await page.getByRole("button", { name: "Select all revocable" }).click();
await expect(page.getByText("2 selected")).toBeVisible();
await page.locator("select").first().selectOption("keyCompromise");
await page.getByRole("button", { name: "Revoke selected" }).click();
await expect(page.getByText(/Revoked 2 certificate\(s\); 0 already revoked; 0 error\(s\)/)).toBeVisible();
const revocations = await api(page, "GET", `/api/vaults/${vaultId}/certificates`);
const revoked = revocations.json.results.filter((cert) => cert.revoked);
expect(revoked).toHaveLength(2);
});
+10 -1
View File
@@ -54,6 +54,10 @@ test("core PKI workflow", async ({ page }) => {
await page.getByRole("button", { name: "Create certificate" }).click();
await expect(page.getByText("CA certificate", { exact: false })).toBeVisible();
const caUrl = page.url();
await page.locator(".nav-tabs").getByRole("button", { name: "CA", exact: true }).click();
await (await field(page, "CA · default signing template")).selectOption("builtin:tls-server");
await page.getByRole("button", { name: "Save CA" }).click();
await expect(page.getByText("CA properties saved.")).toBeVisible();
// Leaf signed by the CA
await page.goto(`${vaultUrl}/certificates/new`);
@@ -63,10 +67,13 @@ test("core PKI workflow", async ({ page }) => {
await (await field(page, "Organization (O)")).fill("KCA E2E");
await (await field(page, "Country (C)")).fill("DE");
await page.locator("#new-key").check();
await expect(page.getByText("CA default template: TLS server")).toBeVisible();
await page.getByRole("button", { name: "Apply CA default" }).click();
await expect(page.locator("label").filter({ hasText: "TLS server" }).locator("..").locator("input[type=checkbox]")).toBeChecked();
await page.locator("input[placeholder*='DNS:example.com']").fill(`DNS:${leafName}`);
await page.locator("label").filter({ hasText: "TLS server" }).locator("..").locator("input[type=checkbox]").check();
await page.getByRole("button", { name: "Create certificate" }).click();
await expect(page.getByRole("heading", { name: leafName })).toBeVisible();
await page.locator(".nav-tabs").getByRole("button", { name: "Extensions", exact: true }).click();
await expect(page.getByText("serverAuth", { exact: false })).toBeVisible();
// Search
@@ -77,12 +84,14 @@ test("core PKI workflow", async ({ page }) => {
// Revoke
await page.getByRole("link", { name: leafName, exact: true }).click();
await page.locator(".nav-tabs").getByRole("button", { name: "Revoke", exact: true }).click();
await page.getByRole("button", { name: "Revoke certificate" }).click();
await expect(page.getByText("revoked", { exact: true })).toBeVisible();
// CRL from the CA
await page.goto(caUrl);
await expect(page.getByRole("heading", { name: caName })).toBeVisible();
await page.locator(".nav-tabs").getByRole("button", { name: "CA", exact: true }).click();
await page.getByRole("button", { name: "Generate CRL" }).click();
await expect(page).toHaveURL(/\/crls\//);
await expect(page.getByText(/CRL number \d+ · 1 revoked/)).toBeVisible();
+85
View File
@@ -0,0 +1,85 @@
// Vault settings: explicit DN order, mandatory DN and template application.
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 api(page, method, url, body) {
return page.evaluate(
async ({ method, url, body }) => {
const response = await fetch(url, {
method,
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
return { status: response.status, json: text ? JSON.parse(text) : null };
},
{ method, url, body }
);
}
test("explicit DN order, mandatory DN and template apply", async ({ page }) => {
const user = unique("settings");
const vaultName = unique("Settings Vault");
const certName = unique("ordered.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();
const settings = await api(page, "GET", `/api/vaults/${vaultId}/settings`);
const updated = await api(page, "PATCH", `/api/vaults/${vaultId}/settings`, {
version: settings.json.version,
settings: { explicit_dn: ["CN", "O"], mandatory_dn: ["CN"] },
});
expect(updated.status).toBe(200);
await page.goto(`${vaultUrl}/certificates/new`);
await expect(page.getByText("Additional subject attributes")).toBeVisible();
const labels = await page.locator("label").allTextContents();
const cnIndex = labels.findIndex((label) => label.trim() === "Common name (CN)");
const oIndex = labels.findIndex((label) => label.trim() === "Organization (O)");
expect(cnIndex).toBeGreaterThan(-1);
expect(oIndex).toBeGreaterThan(cnIndex);
await expect(page.locator("label").filter({ hasText: "Country (C)" })).toHaveCount(0);
// Applying a template uses the server merge endpoint and updates typed fields.
await page.locator("select").nth(2).selectOption("builtin:tls-server");
await page.locator("select").nth(3).selectOption("extensions");
await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(page.locator("label").filter({ hasText: "TLS server" }).locator("..").locator("input[type=checkbox]")).toBeChecked();
await (await field(page, "Internal name")).fill(certName);
await (await field(page, "Organization (O)")).fill("KCA E2E");
await page.locator("#new-key").check();
await page.getByRole("button", { name: "Create certificate" }).click();
await expect(page.getByText("missing mandatory subject entries: CN")).toBeVisible();
await (await field(page, "Common name (CN)")).fill(certName);
await page.locator("label").filter({ hasText: "No well-defined expiration" }).locator("..").locator("input[type=checkbox]").check();
await page.getByRole("button", { name: "Create certificate" }).click();
await expect(page.getByRole("heading", { name: certName })).toBeVisible();
await expect(page.getByText("No expiration", { exact: true })).toBeVisible();
const cert = await api(page, "GET", `/api/vaults/${vaultId}/certificates`);
const created = cert.json.results.find((item) => item.name === certName);
// RFC 4514 renders the most specific RDN first, so CN follows O.
expect(created.subject).toBe(`O=KCA E2E,CN=${certName}`);
});
@@ -26,6 +26,7 @@ from ..service import (
chain_for_certificate,
delete_certificate_cascade,
generate_crl,
bulk_revoke_certificates,
issue_certificate,
load_private_key,
preview_certificate,
@@ -97,6 +98,25 @@ def register(app: KayaApp, kca: "Kca") -> None:
return
await send_json(ctx, 201, await serialize_certificate_detail(cert, vault))
@app.POST("/api/vaults/${vault_id}/certificates/bulk-revoke")
@operation(summary="Revoke several certificates", tags=["certificates"])
@require_auth(kca.oidc)
async def bulk_revoke(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
result = await bulk_revoke_certificates(vault, body)
except (ValidationError, ValueError) as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, result)
@app.POST("/api/vaults/${vault_id}/certificates/preview")
@operation(summary="Preview/validate a certificate payload", tags=["certificates"])
@require_auth(kca.oidc)
@@ -73,6 +73,17 @@ def merge_settings(current: Mapping[str, Any], patch: Mapping[str, Any]) -> Dict
if not (32 <= bits <= 160):
raise ValidationError("serial_len_bits must be between 32 and 160")
merged["serial_len_bits"] = bits
for key in ("explicit_dn", "mandatory_dn"):
entries = merged.get(key) or []
if not isinstance(entries, list):
raise ValidationError(f"{key} must be a list")
for entry in entries:
if not isinstance(entry, str) or not entry.strip():
raise ValidationError(f"{key} entries must be non-empty strings")
try:
name_core.resolve_oid(entry)
except ValueError as exc:
raise ValidationError(f"unknown subject attribute {entry!r}") from exc
return merged
@@ -965,6 +976,63 @@ async def revoke_certificate(vault: Vault, cert: Certificate, payload: Mapping[s
return row
async def bulk_revoke_certificates(vault: Vault, payload: Mapping[str, Any]) -> Dict[str, Any]:
"""Revoke several certificates in one request, reporting per-item failures."""
raw_ids = payload.get("certificate_ids") or payload.get("ids") or []
if not isinstance(raw_ids, list):
raise ValidationError("certificate_ids must be a list")
seen: set[str] = set()
ids: list[str] = []
for raw in raw_ids:
text = str(raw)
if text in seen:
continue
seen.add(text)
ids.append(text)
if not ids:
raise ValidationError("select at least one certificate")
if len(ids) > 200:
raise ValidationError("at most 200 certificates can be revoked at once")
reason = str(payload.get("reason") or "unspecified")
if reason not in {item["name"] for item in crl_core.CRL_REASONS}:
raise ValidationError("unknown revocation reason")
invalidity = payload.get("invalidity_date")
if payload.get("revoked_at"):
parse_datetime(payload["revoked_at"], "revoked_at")
if invalidity:
parse_datetime(invalidity, "invalidity_date")
revocation_payload = {
"reason": reason,
"revoked_at": payload.get("revoked_at"),
"invalidity_date": invalidity,
}
revoked: list[Dict[str, Any]] = []
skipped: list[Dict[str, Any]] = []
errors: list[Dict[str, Any]] = []
for raw_id in ids:
parsed_id = parse_uuid(raw_id)
if parsed_id is None:
errors.append({"id": raw_id, "error": "invalid certificate id"})
continue
cert = await Certificate.get_or_none(id=parsed_id, vault_id=vault.id)
if cert is None:
errors.append({"id": raw_id, "error": "certificate not found"})
continue
existing = None
if cert.issuer_id is not None:
existing = await CertificateRevocation.get_or_none(ca_id=cert.issuer_id, serial_hex=cert.serial_hex)
if existing is not None:
skipped.append({"id": str(cert.id), "serial_hex": cert.serial_hex, "reason": "already revoked"})
continue
try:
row = await revoke_certificate(vault, cert, revocation_payload)
except (ValidationError, ValueError) as exc:
errors.append({"id": str(cert.id), "serial_hex": cert.serial_hex, "error": str(exc)})
continue
revoked.append({"id": str(cert.id), "serial_hex": cert.serial_hex, "revocation_id": str(row.id)})
return {"revoked": revoked, "skipped": skipped, "errors": errors}
async def generate_crl(vault: Vault, ca: Certificate, payload: Mapping[str, Any]) -> Crl:
if not ca.is_ca:
raise ValidationError("the selected certificate is not a CA")
@@ -1323,6 +1391,7 @@ def _reason_from_bit(bit: int) -> str:
__all__ = [
"DEFAULT_VAULT_SETTINGS",
"ValidationError",
"bulk_revoke_certificates",
"chain_for_certificate",
"extension_search_text",
"delete_certificate_cascade",
+74 -1
View File
@@ -152,6 +152,30 @@ class RouteExtraTests(unittest.TestCase):
)
assert response.status_code == 200
assert response.json()["extensions"]["basic_constraints"]["ca"] is True
response = await client.post(
f"/api/vaults/{vault_id}/templates/builtin:tls-server/apply",
json={
"target": {
"subject": [{"name": "CN", "value": "keep.example.com"}],
"extensions": {},
},
"apply_subject": False,
"apply_extensions": True,
},
)
assert response.status_code == 200
merged = response.json()
assert merged["subject"] == [{"name": "CN", "value": "keep.example.com"}]
assert "serverAuth" in merged["extensions"]["extended_key_usage"]["usages"]
settings_response = await client.get(f"/api/vaults/{vault_id}/settings")
response = await client.patch(
f"/api/vaults/{vault_id}/settings",
json={
"settings": {"explicit_dn": ["CN", "not-a-real-attribute"]},
"version": settings_response.json()["version"],
},
)
assert response.status_code == 400
@async_test
async def test_revocation_and_crl(self) -> None:
@@ -303,6 +327,55 @@ class VaultExportTests(unittest.TestCase):
names = archive.namelist()
assert "manifest.json" in names
assert any(name.startswith("certificates/") for name in names)
assert any(name.startswith("keys/") for name in names)
key_files = [name for name in names if name.startswith("keys/") and name.endswith(".key")]
assert key_files
assert b"BEGIN ENCRYPTED PRIVATE KEY" in archive.read(key_files[0])
manifest = archive.read("manifest.json")
assert b"Zip Cert" in manifest
class BulkRevocationTests(unittest.TestCase):
@async_test
async def test_bulk_revoke_reports_skips_and_errors(self) -> None:
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
ca_key = await _key(client, vault_id, "bulk ca")
ca = await _cert(client, vault_id, ca_key["id"], "Bulk CA", ca=True)
leaf1_key = await _key(client, vault_id, "leaf1")
leaf1 = await _cert(client, vault_id, leaf1_key["id"], "Leaf 1", signer_id=ca["id"])
leaf2_key = await _key(client, vault_id, "leaf2")
leaf2 = await _cert(client, vault_id, leaf2_key["id"], "Leaf 2", signer_id=ca["id"])
self_key = await _key(client, vault_id, "self")
self_signed = await _cert(client, vault_id, self_key["id"], "Self")
response = await client.post(
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
json={
"certificate_ids": [leaf1["id"], leaf2["id"], self_signed["id"]],
"reason": "keyCompromise",
},
)
assert response.status_code == 200, response.text
result = response.json()
assert len(result["revoked"]) == 2
assert len(result["errors"]) == 1
assert result["errors"][0]["id"] == self_signed["id"]
response = await client.get(f"/api/vaults/{vault_id}/certificates/{leaf1['id']}")
assert response.json()["revoked"] is True
response = await client.post(
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
json={"certificate_ids": [leaf1["id"], leaf2["id"]], "reason": "superseded"},
)
assert response.status_code == 200, response.text
assert len(response.json()["skipped"]) == 2
response = await client.post(
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
json={"certificate_ids": [], "reason": "superseded"},
)
assert response.status_code == 400
response = await client.post(
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
json={"certificate_ids": ["not-a-uuid"], "reason": "superseded"},
)
assert response.status_code == 200
assert response.json()["errors"][0]["error"] == "invalid certificate id"
+5
View File
@@ -297,6 +297,11 @@ pub async fn certificate(vault_id: &str, cert_id: &str) -> Result<CertificateDet
check(response).await
}
pub async fn bulk_revoke_certificates(vault_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/bulk-revoke"), Some(payload)).await?;
check(response).await
}
pub async fn preview_certificate(vault_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/preview"), Some(payload)).await?;
check(response).await
+2 -2
View File
@@ -1,6 +1,6 @@
//! JSON models mirroring the KCA API and small display helpers.
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
@@ -265,7 +265,7 @@ pub struct CertificateDetail {
pub cert_spec: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
pub struct DnEntry {
#[serde(default)]
pub oid: String,
+298 -212
View File
@@ -37,6 +37,9 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let tls_auth_text = create_signal(String::new());
let new_name = create_signal(String::new());
let new_comment = create_signal(String::new());
let active_tab = create_signal(initial_tab());
let info = create_signal(None::<String>);
let openssl_config = create_signal(String::new());
let load = move || {
spawn_local(async move {
@@ -62,6 +65,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
validation_errors.set(value.validation_errors.clone());
purposes.set(value.purposes.clone());
chain.set(value.chain.clone());
openssl_config.set(value.openssl_config.clone());
cert.set(Some(value));
if is_ca {
if let Ok(page) = api::templates(id).await {
@@ -111,7 +115,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let payload = json!({"reason": rev_reason.get_clone()});
spawn_local(async move {
match api::revoke_certificate(id, cert_id_value, &payload).await {
Ok(_) => load(),
Ok(_) => {
info.set(Some("Certificate revoked.".to_string()));
load();
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
@@ -137,7 +144,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let payload = json!({"name": new_name.get_clone(), "comment": new_comment.get_clone(), "version": value.summary.version});
spawn_local(async move {
match api::update_certificate(id, cert_id_value, &payload).await {
Ok(updated) => cert.set(Some(updated)),
Ok(updated) => {
cert.set(Some(updated));
info.set(Some("Certificate metadata saved.".to_string()));
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
@@ -171,7 +181,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
});
spawn_local(async move {
match api::update_ca(id, cert_id_value, &payload).await {
Ok(_) => {}
Ok(_) => info.set(Some("CA properties saved.".to_string())),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
@@ -217,6 +227,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
rev_serial.set(String::new());
rev_invalidity.set(String::new());
editing_rev.set(String::new());
info.set(Some("Revocation list saved.".to_string()));
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
@@ -264,6 +275,20 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
});
};
let tab = move |key: &'static str, label: &'static str| -> View {
view! {
li(class="nav-item") {
button(class=if active_tab.get_clone() == key { "nav-link active" } else { "nav-link" },
on:click=move |_| {
active_tab.set(key.to_string());
if let Some(window) = web_sys::window() {
let _ = window.location().set_hash(key);
}
}) { (label) }
}
}
};
let content = move || {
if loading.get() {
return spinner();
@@ -275,233 +300,281 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let is_ca = summary.is_ca;
let header_serial = summary.serial_hex.clone();
view! {
(page_header(
summary.name.clone(),
format!("{} · serial {}", if is_ca { "CA certificate" } else { "end-entity certificate" }, header_serial),
view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Back" }
a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/{cert_id_value}/renew")) { "Renew" }
(if !summary.revoked && !is_ca {
view! { button(class="btn btn-warning", on:click=revoke) { "Revoke" } }
} else { view! {} })
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
},
))
div(class="card mb-3") {
div(class="card-body") {
div(class="row g-2") {
div(class="col-md-6") { div(class="small text-secondary") { "Subject" } div(class="mono") { (value.subject_rfc4514.clone()) } }
div(class="col-md-6") { div(class="small text-secondary") { "Issuer" } div(class="mono") { (value.issuer_rfc4514.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Serial" } div(class="mono") { (summary.serial_hex.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Signature" } div() { (value.signature_algorithm.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Not before" } div() { (fmt_date(&summary.not_before)) } }
div(class="col-md-3") { div(class="small text-secondary") { "Not after" } div() { (fmt_date(&summary.not_after)) } }
div(class="col-md-3") { div(class="small text-secondary") { "Key" } div() { (value.key_name.clone().unwrap_or_else(|| "not in vault".to_string())) } }
div(class="col-md-3") { div(class="small text-secondary") { "Status" } (if summary.revoked { view! { span(class="badge badge-danger") { "revoked" } } } else { view! { span(class="badge badge-soft") { "valid" } } }) }
}
}
}
div(class="row g-3 mb-3") {
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header") { "Subject" }
table(class="table table-sm dn-table mb-0") {
tbody() { Indexed(list=subject_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) }
}
}
}
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header") { "Issuer" }
table(class="table table-sm dn-table mb-0") {
tbody() { Indexed(list=issuer_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) }
}
}
}
}
div(class="card mb-3") {
div(class="card-header d-flex justify-content-between align-items-center") {
span() { "Extensions" }
(copy_button(value.openssl_config.clone(), "Copy OpenSSL config"))
}
div(class="table-responsive") {
table(class="table table-sm ext-table mb-0") {
thead() { tr() { th() { "Extension" } th() { "Value" } } }
tbody() { Indexed(list=extensions, view=|ext| view! { tr() { td() { (ext.name) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) } td(class="extension-value") { (ext.value) } } }) }
}
}
}
div(class="card mb-3") {
div(class="card-header") { "Validation" }
div(class="card-body") {
(if validation_errors.get_clone().is_empty() {
view! { div(class="alert alert-success mb-2") { "No verification errors found." } }
} else {
view! { ul(class="text-danger") { Indexed(list=validation_errors, view=|err| view! { li() { (err.code.map(|c| format!("[{c}] ")).unwrap_or_default()) (err.message) } }) } }
})
div(class="mt-2") { Indexed(list=purposes, view=|p| view! { span(class="badge badge-soft me-1") { (p) } }) }
}
}
div(class="row g-3") {
div(class="col-12 col-lg-6") {
(page_header(
summary.name.clone(),
format!("{} · serial {}", if is_ca { "CA certificate" } else { "end-entity certificate" }, header_serial),
view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Back" }
a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/{cert_id_value}/renew")) { "Renew" }
(if !summary.revoked && !is_ca {
view! { button(class="btn btn-warning", on:click=revoke) { "Revoke" } }
} else { view! {} })
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
},
))
div(class="card mb-3") {
div(class="card-header") { "Export" }
div(class="card-body") {
select(class="form-select mb-2", bind:value=export_format) {
option(value="pem") { "PEM" }
option(value="pem-chain") { "PEM chain" }
option(value="pem-key") { "PEM + key" }
option(value="pem-pkcs8") { "PEM + encrypted PKCS#8" }
option(value="pkcs7") { "PKCS#7" }
option(value="pkcs7-chain") { "PKCS#7 chain" }
option(value="der") { "DER" }
option(value="pkcs12") { "PKCS#12" }
option(value="pkcs12-chain") { "PKCS#12 chain" }
option(value="openvpn") { "OpenVPN" }
option(value="openssl-config") { "OpenSSL config" }
option(value="index") { "Certificate index" }
option(value="vcalendar") { "vCalendar" }
option(value="jwk") { "JWK" }
option(value="jwk-chain") { "JWK chain" }
option(value="tls-auth") { "OpenVPN tls-auth key" }
div(class="row g-2") {
div(class="col-md-6") { div(class="small text-secondary") { "Subject" } div(class="mono") { (value.subject_rfc4514.clone()) } }
div(class="col-md-6") { div(class="small text-secondary") { "Issuer" } div(class="mono") { (value.issuer_rfc4514.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Serial" } div(class="mono") { (summary.serial_hex.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Signature" } div() { (value.signature_algorithm.clone()) } }
div(class="col-md-3") { div(class="small text-secondary") { "Not before" } div() { (fmt_date(&summary.not_before)) } }
div(class="col-md-3") { div(class="small text-secondary") { "Not after" } div() { (if summary.no_expiration { "No expiration".to_string() } else { fmt_date(&summary.not_after) }) } }
div(class="col-md-3") { div(class="small text-secondary") { "Key" } div() { (value.key_name.clone().unwrap_or_else(|| "not in vault".to_string())) } }
div(class="col-md-3") { div(class="small text-secondary") { "Status" } (if summary.revoked { view! { span(class="badge badge-danger") { "revoked" } } } else { view! { span(class="badge badge-soft") { "valid" } } }) }
}
div(class="mb-2") {
label(class="form-label") { "Password (encrypted formats)" }
input(class="form-control", r#type="password", bind:value=export_password)
}
button(class="btn btn-primary", on:click=do_export) { "Download" }
}
}
div(class="card") {
div(class="card-header") { "Metadata" }
div(class="card-body") {
div(class="mb-2") {
label(class="form-label") { "Name" }
input(class="form-control", bind:value=new_name)
ul(class="nav nav-tabs mb-3") {
(tab("overview", "Overview"))
(tab("extensions", "Extensions"))
(tab("validation", "Validation"))
(tab("export", "Export"))
(tab("metadata", "Metadata"))
(tab("revoke", "Revoke"))
(tab("transform", "Transform"))
(if is_ca { tab("ca", "CA") } else { view! {} })
}
(if active_tab.get_clone() == "overview" {
view! {
div(class="row g-3 mb-3") {
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header") { "Subject" }
table(class="table table-sm dn-table mb-0") {
tbody() { Indexed(list=subject_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) }
}
}
div(class="mb-2") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="3", bind:value=new_comment) {}
}
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header") { "Issuer" }
table(class="table table-sm dn-table mb-0") {
tbody() { Indexed(list=issuer_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) }
}
}
button(class="btn btn-primary", on:click=save_meta) { "Save" }
}
}
}
div(class="col-12 col-lg-6") {
div(class="card mb-3") {
div(class="card-header") { "Revoke" }
div(class="card-body") {
select(class="form-select mb-2", bind:value=rev_reason) { (reason_options()) }
button(class="btn btn-warning", disabled=summary.revoked, on:click=revoke) { "Revoke certificate" }
}
} else { view! {} }) (if active_tab.get_clone() == "extensions" {
view! {
div(class="card mb-3") {
div(class="card-header d-flex justify-content-between align-items-center") {
span() { "Extensions" }
(copy_button(openssl_config.get_clone(), "Copy OpenSSL config"))
}
div(class="table-responsive") {
table(class="table table-sm ext-table mb-0") {
thead() { tr() { th() { "Extension" } th() { "Value" } } }
tbody() { Indexed(list=extensions, view=|ext| view! { tr() { td() { (ext.name) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) } td(class="extension-value") { (ext.value) } } }) }
}
}
}
div(class="card") {
div(class="card-header") { "Transform" }
div(class="card-body d-grid gap-2") {
button(class="btn btn-outline-light", on:click=move |_| transform("open-key")) { "Extract public key" }
button(class="btn btn-outline-light", on:click=move |_| transform("request")) { "Create request" }
button(class="btn btn-outline-light", on:click=move |_| transform("template")) { "Create template" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates/{cert_id_value}/similar")) { "Similar certificate" }
}
}
}
}
(if is_ca {
view! {
div(class="card mt-3") {
div(class="card-header") { "CA operations" }
} else { view! {} }) (if active_tab.get_clone() == "validation" {
view! {
div(class="card mb-3") {
div(class="card-header") { "Validation" }
div(class="card-body") {
div(class="row g-2 align-items-end mb-3") {
div(class="col-md-3") {
label(class="form-label") { "CA · CRL days" }
input(class="form-control", bind:value=ca_days)
}
div(class="col-md-5") {
label(class="form-label") { "CA · default signing template" }
select(class="form-select", bind:value=ca_template) {
option(value="") { "— none —" }
(templates.get_clone().into_iter().map(|template| view! {
option(value=template.id.clone()) { (template.name.clone()) }
}).collect::<Vec<View>>())
}
}
div(class="col-md-2") {
button(class="btn btn-outline-light w-100", on:click=save_ca) { "Save CA" }
}
}
div(class="row g-2 align-items-end mb-3") {
div(class="col-md-3") {
label(class="form-label") { "CRL days" }
input(class="form-control", bind:value=crl_days)
}
div(class="col-md-3") {
label(class="form-label") { "Hash" }
select(class="form-select", bind:value=crl_hash) {
option(value="sha256") { "SHA-256" }
option(value="sha384") { "SHA-384" }
option(value="sha512") { "SHA-512" }
}
}
div(class="col-md-3") {
button(class="btn btn-primary", on:click=generate_crl) { "Generate CRL" }
}
div(class="col-md-3") {
button(class="btn btn-outline-light", on:click=make_tls_auth) { "tls-auth key" }
}
}
(if tls_auth_text.get_clone().is_empty() {
view! {}
(if validation_errors.get_clone().is_empty() {
view! { div(class="alert alert-success mb-2") { "No verification errors found." } }
} else {
view! { pre(class="pem") { (tls_auth_text.get_clone()) } }
view! { ul(class="text-danger") { Indexed(list=validation_errors, view=|err| view! { li() { (err.code.map(|c| format!("[{c}] ")).unwrap_or_default()) (err.message) } }) } }
})
h3(class="h6 mt-3") { "Revocations" }
div(class="row g-2 align-items-end mb-2") {
div(class="col-md-3") {
input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial)
}
div(class="col-md-3") {
select(class="form-select", bind:value=rev_reason) { (reason_options()) }
}
div(class="col-md-3") {
input(class="form-control", r#type="datetime-local", bind:value=rev_invalidity)
}
div(class="col-md-3 d-flex gap-2") {
button(class="btn btn-outline-light", on:click=save_revocation) {
(if editing_rev.get_clone().is_empty() { "Add revocation" } else { "Save revocation" })
}
(if !editing_rev.get_clone().is_empty() {
view! {
button(class="btn btn-outline-secondary", on:click=move |_| {
editing_rev.set(String::new());
rev_serial.set(String::new());
rev_invalidity.set(String::new());
}) { "Cancel" }
}
} else { view! {} })
}
}
table(class="table table-sm mb-0") {
thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() { "Invalid since" } th() {} } }
tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); let rev_for_edit = rev.clone(); view! {
tr() {
td(class="fingerprint") { (rev.serial_hex) }
td() { (fmt_date(&rev.revoked_at)) }
td() { (rev.reason) }
td() { (rev.invalidity_date.clone().map(|v| fmt_date(&Some(v))).unwrap_or_else(|| "—".to_string())) }
td(class="table-actions") {
button(class="btn btn-sm btn-outline-light me-1", on:click=move |_| edit_revocation(rev_for_edit.clone())) { "Edit" }
button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" }
}
}
} }) }
}
div(class="mt-2") { Indexed(list=purposes, view=|p| view! { span(class="badge badge-soft me-1") { (p) } }) }
}
}
}
} else { view! {} }) (if active_tab.get_clone() == "export" {
view! {
div(class="row g-3") {
div(class="col-12 col-lg-6") {
div(class="card mb-3") {
div(class="card-header") { "Export" }
div(class="card-body") {
select(class="form-select mb-2", bind:value=export_format) {
option(value="pem") { "PEM" }
option(value="pem-chain") { "PEM chain" }
option(value="pem-key") { "PEM + key" }
option(value="pem-pkcs8") { "PEM + encrypted PKCS#8" }
option(value="pkcs7") { "PKCS#7" }
option(value="pkcs7-chain") { "PKCS#7 chain" }
option(value="der") { "DER" }
option(value="pkcs12") { "PKCS#12" }
option(value="pkcs12-chain") { "PKCS#12 chain" }
option(value="openvpn") { "OpenVPN" }
option(value="openssl-config") { "OpenSSL config" }
option(value="index") { "Certificate index" }
option(value="vcalendar") { "vCalendar" }
option(value="jwk") { "JWK" }
option(value="jwk-chain") { "JWK chain" }
option(value="tls-auth") { "OpenVPN tls-auth key" }
}
div(class="mb-2") {
label(class="form-label") { "Password (encrypted formats)" }
input(class="form-control", r#type="password", bind:value=export_password)
}
button(class="btn btn-primary", on:click=do_export) { "Download" }
}
}
}
}
}
} else { view! {} })
(if active_tab.get_clone() == "metadata" {
view! {
div(class="row g-3") {
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header") { "Metadata" }
div(class="card-body") {
div(class="mb-2") {
label(class="form-label") { "Name" }
input(class="form-control", bind:value=new_name)
}
div(class="mb-2") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="3", bind:value=new_comment) {}
}
button(class="btn btn-primary", on:click=save_meta) { "Save" }
}
}
}
}
}
} else { view! {} })
(if active_tab.get_clone() == "revoke" {
view! {
div(class="row g-3") {
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header") { "Revoke" }
div(class="card-body") {
select(class="form-select mb-2", bind:value=rev_reason) { (reason_options()) }
button(class="btn btn-warning", disabled=summary.revoked, on:click=revoke) { "Revoke certificate" }
}
}
}
}
}
} else { view! {} })
(if active_tab.get_clone() == "transform" {
view! {
div(class="row g-3") {
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header") { "Transform" }
div(class="card-body d-grid gap-2") {
button(class="btn btn-outline-light", on:click=move |_| transform("open-key")) { "Extract public key" }
button(class="btn btn-outline-light", on:click=move |_| transform("request")) { "Create request" }
button(class="btn btn-outline-light", on:click=move |_| transform("template")) { "Create template" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates/{cert_id_value}/similar")) { "Similar certificate" }
}
}
}
}
}
} else { view! {} })
(if is_ca && active_tab.get_clone() == "ca" {
view! {
div(class="card mt-3") {
div(class="card-header") { "CA operations" }
div(class="card-body") {
div(class="row g-2 align-items-end mb-3") {
div(class="col-md-3") {
label(class="form-label") { "CA · CRL days" }
input(class="form-control", bind:value=ca_days)
}
div(class="col-md-5") {
label(class="form-label") { "CA · default signing template" }
select(class="form-select", bind:value=ca_template) {
option(value="") { "— none —" }
(templates.get_clone().into_iter().map(|template| view! {
option(value=template.id.clone()) { (template.name.clone()) }
}).collect::<Vec<View>>())
}
}
div(class="col-md-2") {
button(class="btn btn-outline-light w-100", on:click=save_ca) { "Save CA" }
}
}
div(class="row g-2 align-items-end mb-3") {
div(class="col-md-3") {
label(class="form-label") { "CRL days" }
input(class="form-control", bind:value=crl_days)
}
div(class="col-md-3") {
label(class="form-label") { "Hash" }
select(class="form-select", bind:value=crl_hash) {
option(value="sha256") { "SHA-256" }
option(value="sha384") { "SHA-384" }
option(value="sha512") { "SHA-512" }
}
}
div(class="col-md-3") {
button(class="btn btn-primary", on:click=generate_crl) { "Generate CRL" }
}
div(class="col-md-3") {
button(class="btn btn-outline-light", on:click=make_tls_auth) { "tls-auth key" }
}
}
(if tls_auth_text.get_clone().is_empty() {
view! {}
} else {
view! { pre(class="pem") { (tls_auth_text.get_clone()) } }
})
h3(class="h6 mt-3") { "Revocations" }
div(class="row g-2 align-items-end mb-2") {
div(class="col-md-3") {
input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial)
}
div(class="col-md-3") {
select(class="form-select", bind:value=rev_reason) { (reason_options()) }
}
div(class="col-md-3") {
input(class="form-control", r#type="datetime-local", bind:value=rev_invalidity)
}
div(class="col-md-3 d-flex gap-2") {
button(class="btn btn-outline-light", on:click=save_revocation) {
(if editing_rev.get_clone().is_empty() { "Add revocation" } else { "Save revocation" })
}
(if !editing_rev.get_clone().is_empty() {
view! {
button(class="btn btn-outline-secondary", on:click=move |_| {
editing_rev.set(String::new());
rev_serial.set(String::new());
rev_invalidity.set(String::new());
}) { "Cancel" }
}
} else { view! {} })
}
}
table(class="table table-sm mb-0") {
thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() { "Invalid since" } th() {} } }
tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); let rev_for_edit = rev.clone(); view! {
tr() {
td(class="fingerprint") { (rev.serial_hex) }
td() { (fmt_date(&rev.revoked_at)) }
td() { (rev.reason) }
td() { (rev.invalidity_date.clone().map(|v| fmt_date(&Some(v))).unwrap_or_else(|| "—".to_string())) }
td(class="table-actions") {
button(class="btn btn-sm btn-outline-light me-1", on:click=move |_| edit_revocation(rev_for_edit.clone())) { "Edit" }
button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" }
}
}
} }) }
}
}
}
}
} else {
view! {}
})
}
} else {
view! {}
})
}
};
let nav = move || match me.get_clone() {
@@ -513,11 +586,24 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(crate::components::toast::toast(info, true))
(content)
}
}
}
fn initial_tab() -> String {
let hash = web_sys::window()
.and_then(|window| window.location().hash().ok())
.unwrap_or_default();
let hash = hash.trim_start_matches('#');
match hash {
"overview" | "extensions" | "validation" | "export" | "metadata" | "revoke"
| "transform" | "ca" => hash.to_string(),
_ => "overview".to_string(),
}
}
fn reason_options() -> Vec<View> {
[
"unspecified",
File diff suppressed because it is too large Load Diff
+172 -11
View File
@@ -1,12 +1,16 @@
//! Certificate list, flat or as an issuer tree.
use std::collections::HashMap;
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{page_header, toast_error};
use crate::dom;
use crate::model::{fmt_date, fmt_fp, status_badge, CertificateSummary, Me};
pub fn CertificatesPage(vault_id: String) -> View {
@@ -17,6 +21,11 @@ pub fn CertificatesPage(vault_id: String) -> View {
let loading = create_signal(true);
let filter = create_signal(String::new());
let plain = create_signal(false);
let selected = create_signal(Vec::<String>::new());
let bulk_reason = create_signal("unspecified".to_string());
let bulk_invalidity = create_signal(String::new());
let bulk_busy = create_signal(false);
let info = create_signal(None::<String>);
spawn_local(async move {
match api::me().await {
@@ -50,6 +59,97 @@ pub fn CertificatesPage(vault_id: String) -> View {
}
});
let refresh = move || {
spawn_local(async move {
if let Ok(page) = api::certificates(id).await {
if certificates.is_alive() {
certificates.set(page.results);
}
}
});
};
let toggle = move |event: web_sys::Event| {
if let Some(target) = event
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
if let Some(cert_id) = target.get_attribute("data-cert-id") {
let mut current = selected.get_clone();
if target.checked() {
if !current.contains(&cert_id) {
current.push(cert_id);
}
} else {
current.retain(|value| value != &cert_id);
}
selected.set(current);
}
}
};
let select_all = move |_| {
let all = certificates
.get_clone()
.into_iter()
.filter(|cert| cert.issuer_id.is_some())
.map(|cert| cert.id)
.collect::<Vec<_>>();
selected.set(all);
};
let clear_selection = move |_| selected.set(Vec::new());
let bulk_revoke = move |_| {
let ids = selected.get_clone();
if ids.is_empty() {
error.set(Some("select at least one certificate".to_string()));
return;
}
if !dom::confirm(&format!("Revoke {} selected certificate(s)?", ids.len())) {
return;
}
let payload = json!({
"certificate_ids": ids,
"reason": bulk_reason.get_clone(),
"invalidity_date": if bulk_invalidity.get_clone().is_empty() {
serde_json::Value::Null
} else {
json!(bulk_invalidity.get_clone())
},
});
bulk_busy.set(true);
spawn_local(async move {
match api::bulk_revoke_certificates(id, &payload).await {
Ok(value) => {
let revoked = value
.get("revoked")
.and_then(|v| v.as_array())
.map(|v| v.len())
.unwrap_or(0);
let skipped = value
.get("skipped")
.and_then(|v| v.as_array())
.map(|v| v.len())
.unwrap_or(0);
let errors = value
.get("errors")
.and_then(|v| v.as_array())
.map(|v| v.len())
.unwrap_or(0);
info.set(Some(format!(
"Revoked {revoked} certificate(s); {skipped} already revoked; {errors} error(s)."
)));
selected.set(Vec::new());
bulk_invalidity.set(String::new());
refresh();
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if bulk_busy.is_alive() {
bulk_busy.set(false);
}
});
};
let content = move || {
if loading.get() {
return spinner();
@@ -67,10 +167,11 @@ pub fn CertificatesPage(vault_id: String) -> View {
|| cert.fingerprint_sha256.to_lowercase().contains(&query)
})
.collect();
let chosen = selected.get_clone();
let html = if plain.get() {
flat_html(&visible, id)
flat_html(&visible, id, &chosen)
} else {
tree_html(&visible, id)
tree_html(&visible, id, &chosen)
};
view! {
(page_header(
@@ -90,7 +191,31 @@ pub fn CertificatesPage(vault_id: String) -> View {
bind:value=filter)
}
}
div(class="card", dangerously_set_inner_html=html) {}
div(class="card mb-3") {
div(class="card-body") {
div(class="d-flex flex-wrap gap-2 align-items-end") {
div(class="me-auto") {
div(class="small text-secondary") { (format!("{} selected", selected.get_clone().len())) }
div(class="d-flex gap-2 mt-1") {
button(class="btn btn-sm btn-outline-light", on:click=select_all) { "Select all revocable" }
button(class="btn btn-sm btn-outline-light", on:click=clear_selection) { "Clear" }
}
}
div() {
label(class="form-label small mb-1") { "Reason" }
select(class="form-select form-select-sm", bind:value=bulk_reason) { (reason_options()) }
}
div() {
label(class="form-label small mb-1") { "Invalid since (optional)" }
input(class="form-control form-control-sm", r#type="datetime-local", bind:value=bulk_invalidity)
}
button(class="btn btn-warning", disabled=bulk_busy.get() || selected.get_clone().is_empty(), on:click=bulk_revoke) {
(if bulk_busy.get() { "Revoking…" } else { "Revoke selected" })
}
}
}
}
div(class="card", on:change=toggle, dangerously_set_inner_html=html) {}
}
};
@@ -103,12 +228,13 @@ pub fn CertificatesPage(vault_id: String) -> View {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(crate::components::toast::toast(info, true))
(content)
}
}
}
fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
fn flat_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String {
if rows.is_empty() {
return "<p class=\"empty-state\">No certificates match.</p>".to_string();
}
@@ -116,7 +242,8 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
for cert in rows {
let (badge_class, badge_label) = status_badge(cert);
body.push_str(&format!(
"<tr><td><a href=\"/vaults/{}/{}\">{}</a><div class=\"item-sub\">{}</div></td><td>{}</td><td>{}</td><td class=\"fingerprint\">{}</td><td>{}</td><td><span class=\"badge {}\">{}</span>{}</td><td class=\"fingerprint\">{}</td></tr>",
"<tr><td>{}</td><td><a href=\"/vaults/{}/{}\">{}</a><div class=\"item-sub\">{}</div></td><td>{}</td><td>{}</td><td class=\"fingerprint\">{}</td><td>{}</td><td><span class=\"badge {}\">{}</span>{}</td><td class=\"fingerprint\">{}</td></tr>",
checkbox(cert, selected),
escape(vault_id),
escape(&cert.id),
escape(&cert.name),
@@ -132,11 +259,11 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
));
}
format!(
"<div class=\"table-responsive\"><table class=\"table table-hover align-middle mb-0\"><thead><tr><th>Name</th><th>Issuer</th><th>Type</th><th>Serial</th><th>Expires</th><th>Status</th><th>SHA-256</th></tr></thead><tbody>{body}</tbody></table></div>"
"<div class=\"table-responsive\"><table class=\"table table-hover align-middle mb-0\"><thead><tr><th></th><th>Name</th><th>Issuer</th><th>Type</th><th>Serial</th><th>Expires</th><th>Status</th><th>SHA-256</th></tr></thead><tbody>{body}</tbody></table></div>"
)
}
fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String {
fn tree_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) -> String {
if rows.is_empty() {
return "<p class=\"empty-state\">No certificates match.</p>".to_string();
}
@@ -157,7 +284,7 @@ fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String {
roots.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
let mut html = String::from("<div class=\"card-body\"><ul class=\"cert-tree\">");
for root in &roots {
html.push_str(&node_html(root, &children, vault_id));
html.push_str(&node_html(root, &children, vault_id, selected));
}
html.push_str("</ul></div>");
html
@@ -167,6 +294,7 @@ fn node_html(
cert: &CertificateSummary,
children: &HashMap<String, Vec<CertificateSummary>>,
vault_id: &str,
selected: &[String],
) -> String {
let (badge_class, badge_label) = status_badge(cert);
let sub = if cert.subject == cert.issuer_name {
@@ -175,7 +303,8 @@ fn node_html(
format!("issuer: {}", cert.issuer_name)
};
let mut row = format!(
"<div class=\"cert-row\"><a class=\"item-name\" href=\"/vaults/{}/{}\">{}</a>{} <span class=\"badge {}\">{}</span><span class=\"item-sub\">{}</span><span class=\"item-sub\">expires {}</span><span class=\"item-sub fingerprint\">{}</span></div>",
"<div class=\"cert-row\">{}<a class=\"item-name\" href=\"/vaults/{}/{}\">{}</a>{} <span class=\"badge {}\">{}</span><span class=\"item-sub\">{}</span><span class=\"item-sub\">expires {}</span><span class=\"item-sub fingerprint\">{}</span></div>",
checkbox(cert, selected),
escape(vault_id),
escape(&cert.id),
escape(&cert.name),
@@ -183,7 +312,7 @@ fn node_html(
badge_class,
badge_label,
escape(&sub),
fmt_date(&cert.not_after),
if cert.no_expiration { "No expiration".to_string() } else { fmt_date(&cert.not_after) },
fmt_fp(&cert.fingerprint_sha256),
);
let kids = children.get(&cert.id);
@@ -191,7 +320,7 @@ fn node_html(
if !kids.is_empty() {
row = format!(
"<li><details class=\"cert-node\" open><summary>{row}</summary><ul class=\"cert-tree\">{}</ul></details></li>",
kids.iter().map(|child| node_html(child, children, vault_id)).collect::<String>()
kids.iter().map(|child| node_html(child, children, vault_id, selected)).collect::<String>()
);
} else {
row = format!("<li>{row}</li>");
@@ -202,6 +331,38 @@ fn node_html(
row
}
fn checkbox(cert: &CertificateSummary, selected: &[String]) -> String {
if cert.issuer_id.is_none() {
return "<input class=\"form-check-input\" type=\"checkbox\" disabled title=\"Self-signed certificates cannot be revoked\">".to_string();
}
let checked = if selected.contains(&cert.id) {
" checked"
} else {
""
};
format!(
"<input class=\"form-check-input\" type=\"checkbox\" data-cert-id=\"{}\"{}>",
escape(&cert.id),
checked
)
}
fn reason_options() -> Vec<View> {
[
"unspecified",
"keyCompromise",
"CACompromise",
"affiliationChanged",
"superseded",
"cessationOfOperation",
"certificateHold",
"privilegeWithdrawn",
]
.iter()
.map(|reason| view! { option(value=*reason) { (*reason) } })
.collect::<Vec<View>>()
}
fn escape(value: &str) -> String {
value
.replace('&', "&amp;")