Implement KCA: web reimplementation of XCA PKI workflows

- kaya backend with OIDC sessions, API tokens, Postgres/Tortoise models
- AES-256-GCM private key storage and full certificate/CSR/CRL domain logic
- Sycamore/WASM SPA for vaults, keys, requests, certificates, CRLs and templates
- imports/exports, XCA .xdb migration, Docker Compose stack and tests
This commit is contained in:
2026-10-23 02:06:55 +00:00
commit 4f3a632f4b
109 changed files with 21048 additions and 0 deletions
+568
View File
@@ -0,0 +1,568 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "gloo-net"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580"
dependencies = [
"futures-channel",
"futures-core",
"futures-sink",
"gloo-utils",
"http",
"js-sys",
"pin-project",
"serde",
"serde_json",
"thiserror",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "gloo-utils"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "html-escape"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "kca-web"
version = "0.1.0"
dependencies = [
"base64",
"console_error_panic_hook",
"gloo-net",
"gloo-timers",
"js-sys",
"serde",
"serde_json",
"sycamore",
"sycamore-router",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]]
name = "smallvec"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
[[package]]
name = "sycamore"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa76a5ba796aa25357d70adf027a171b9da18478cf1ab64a9ed0c06f8fca0e49"
dependencies = [
"sycamore-core",
"sycamore-macro",
"sycamore-reactive",
"sycamore-web",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "sycamore-core"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fce3012a86a7d1ae7ca06db4d2172747a574904eed10808286071cef469f975"
dependencies = [
"hashbrown",
"paste",
"sycamore-reactive",
]
[[package]]
name = "sycamore-macro"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8dc13361313005ba633fbf1f973c2f2a2e9f31cda659f102eb362361065b450b"
dependencies = [
"proc-macro2",
"quote",
"sycamore-view-parser",
"syn 3.0.9",
]
[[package]]
name = "sycamore-reactive"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aca468d1209c2d1ae3059fb93aa005e67bbe1f17375a3c0a65135e98a1b58829"
dependencies = [
"paste",
"slotmap",
"smallvec",
"wasm-bindgen",
]
[[package]]
name = "sycamore-router"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6b53bacb0dfbb0665613b758ae24caf8ca8b221387077aee34bd76fefd7b77f"
dependencies = [
"sycamore",
"sycamore-router-macro",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "sycamore-router-macro"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4912026c5892851b743a601d7d52275b325e0f2cd245f856f646259fbef06730"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "sycamore-view-parser"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11fcefa71d9fe1100c90903c837224848441712cd90f34570bb44327a5598ca"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "sycamore-web"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af7fdad6ed1ec34d7e6a2699b4bf76059b3c56f97b9b94f1ab69edc9b04696f4"
dependencies = [
"html-escape",
"js-sys",
"once_cell",
"paste",
"smallvec",
"sycamore-core",
"sycamore-macro",
"sycamore-reactive",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tokio"
version = "1.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
dependencies = [
"pin-project-lite",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.9",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+46
View File
@@ -0,0 +1,46 @@
[package]
name = "kca-web"
version = "0.1.0"
edition = "2021"
description = "Sycamore/WASM frontend for KCA"
[dependencies]
sycamore = "0.9"
sycamore-router = "0.9"
gloo-net = "0.6"
gloo-timers = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"Window",
"Document",
"Location",
"Storage",
"HtmlInputElement",
"HtmlSelectElement",
"HtmlTextAreaElement",
"HtmlAnchorElement",
"HtmlElement",
"Element",
"Event",
"EventTarget",
"File",
"FileList",
"Blob",
"BlobPropertyBag",
"Url",
"Navigator",
"Clipboard",
"HtmlDetailsElement",
"HtmlFormElement",
"DomTokenList",
] }
js-sys = "0.3"
base64 = "0.22"
console_error_panic_hook = "0.1"
[profile.release]
opt-level = "z"
lto = true
+12
View File
@@ -0,0 +1,12 @@
# Development server for the KCA SPA. Proxies the backend prefixes to the
# app on :8080 so requests are same-origin and the session cookie works.
[serve]
port = 8000
[[proxy]]
rewrite = "/api"
backend = "http://127.0.0.1:8080/api"
[[proxy]]
rewrite = "/auth"
backend = "http://127.0.0.1:8080/auth"
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KCA — Key &amp; Certificate Authority</title>
<meta name="description" content="Web PKI management: keys, CSRs, certificates, CRLs and templates.">
<meta name="theme-color" content="#0a1720">
<meta name="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="KCA">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg">
<link data-trunk rel="copy-file" href="pwa/manifest.webmanifest" data-target-path=".">
<link data-trunk rel="copy-file" href="pwa/sw.js" data-target-path=".">
<link data-trunk rel="copy-file" href="pwa/icons/icon.svg" data-target-path="icons">
<link data-trunk rel="rust" href="Cargo.toml">
<link data-trunk rel="css" href="vendor/bootstrap.min.css">
<link data-trunk rel="css" href="theme.css">
<link data-trunk rel="css" href="style.css">
</head>
<body></body>
</html>
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#0a1720"/>
<path d="M256 72l150 58v112c0 92-62 166-150 198-88-32-150-106-150-198V130z" fill="none" stroke="#5eead4" stroke-width="26"/>
<path d="M198 206h116v34H198zm0 66h116v34H198z" fill="#fbbf24"/>
<circle cx="256" cy="300" r="18" fill="#fbbf24"/>
</svg>

After

Width:  |  Height:  |  Size: 374 B

+18
View File
@@ -0,0 +1,18 @@
{
"name": "KCA — Key & Certificate Authority",
"short_name": "KCA",
"description": "Web PKI management: keys, CSRs, certificates, CRLs and templates.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0a1720",
"theme_color": "#0a1720",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+26
View File
@@ -0,0 +1,26 @@
/* KCA app-shell service worker. It caches only the static shell, never API
* responses: certificate and key material must not be served stale. */
const CACHE = "kca-shell-v1";
const SHELL = ["/", "/manifest.webmanifest", "/icons/icon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) return;
event.respondWith(
caches.match(request).then((cached) => cached || fetch(request).catch(() => caches.match("/")))
);
});
+445
View File
@@ -0,0 +1,445 @@
//! REST client for the KCA API.
use gloo_net::http::{Request, Response};
use serde::de::DeserializeOwned;
use serde_json::json;
use crate::auth;
use crate::model::{
ApiTokensPage, AuthProvidersPage, CertificateDetail, CertificatesPage, CreatedApiToken,
CrlDetail, CrlsPage, ExportPayload, ImportPreview, ImportResult, KeyDetail, KeysPage, Me,
OptionsPage, RequestDetail, RequestsPage, RevocationsPage, SearchPage, SummaryPage,
TemplateSummary, TemplatesPage, VaultDetail, VaultsPage,
};
#[derive(serde::Deserialize)]
struct ErrorEnvelope {
#[serde(default)]
error: String,
}
fn server_error(status: u16) -> String {
format!("server returned {status}")
}
async fn error_message(response: Response) -> String {
let status = response.status();
match response.json::<ErrorEnvelope>().await {
Ok(envelope) if !envelope.error.is_empty() => envelope.error,
_ => server_error(status),
}
}
async fn check<T: DeserializeOwned>(response: Response) -> Result<T, String> {
if response.status() == 401 {
auth::redirect_to_login();
return Err(LOGIN_REQUIRED.to_string());
}
if !response.ok() {
return Err(error_message(response).await);
}
response.json().await.map_err(|e| e.to_string())
}
async fn send(method: &str, url: &str, body: Option<&serde_json::Value>) -> Result<Response, String> {
let builder = match method {
"POST" => Request::post(url),
"PUT" => Request::put(url),
"PATCH" => Request::patch(url),
"DELETE" => Request::delete(url),
_ => Request::get(url),
};
let request = match body {
Some(payload) => builder.json(payload).map_err(|e| e.to_string())?,
None => builder.build().map_err(|e| e.to_string())?,
};
request.send().await.map_err(|e| e.to_string())
}
async fn expect_empty(response: Response) -> Result<(), String> {
if response.status() == 401 {
auth::redirect_to_login();
return Err(LOGIN_REQUIRED.to_string());
}
if !response.ok() {
return Err(error_message(response).await);
}
Ok(())
}
pub const LOGIN_REQUIRED: &str = "login required";
fn url_encode(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
// ---------------------------------------------------------------------------
// Auth / identity
// ---------------------------------------------------------------------------
pub async fn me_optional() -> Result<Option<Me>, String> {
let response = Request::get("/api/me").send().await.map_err(|e| e.to_string())?;
if response.status() == 401 {
return Ok(None);
}
if !response.ok() {
return Err(server_error(response.status()));
}
response.json().await.map(Some).map_err(|e| e.to_string())
}
pub async fn me() -> Result<Me, String> {
match me_optional().await? {
Some(user) => Ok(user),
None => {
auth::redirect_to_login();
Err(LOGIN_REQUIRED.to_string())
}
}
}
pub async fn auth_providers() -> Result<AuthProvidersPage, String> {
let response = Request::get("/api/auth/providers").send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn tokens() -> Result<ApiTokensPage, String> {
let response = Request::get("/api/tokens").send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn create_token(name: &str, expires_at: Option<&str>) -> Result<CreatedApiToken, String> {
let response = send("POST", "/api/tokens", Some(&json!({"name": name, "expires_at": expires_at}))).await?;
check(response).await
}
pub async fn delete_token(id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/tokens/{id}"), None).await?;
expect_empty(response).await
}
// ---------------------------------------------------------------------------
// Vaults
// ---------------------------------------------------------------------------
pub async fn vaults() -> Result<VaultsPage, String> {
let response = Request::get("/api/vaults").send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn vault(id: &str) -> Result<VaultDetail, String> {
let response = Request::get(&format!("/api/vaults/{id}")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn create_vault(name: &str, description: &str) -> Result<VaultDetail, String> {
let response = send("POST", "/api/vaults", Some(&json!({"name": name, "description": description}))).await?;
check(response).await
}
pub async fn update_vault(id: &str, name: &str, description: &str, version: i64) -> Result<VaultDetail, String> {
let response = send(
"PATCH",
&format!("/api/vaults/{id}"),
Some(&json!({"name": name, "description": description, "version": version})),
)
.await?;
check(response).await
}
pub async fn delete_vault(id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{id}"), None).await?;
expect_empty(response).await
}
pub async fn vault_settings(id: &str) -> Result<serde_json::Value, String> {
let response = Request::get(&format!("/api/vaults/{id}/settings")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn update_vault_settings(id: &str, settings: &serde_json::Value, version: i64) -> Result<serde_json::Value, String> {
let response = send(
"PATCH",
&format!("/api/vaults/{id}/settings"),
Some(&json!({"settings": settings, "version": version})),
)
.await?;
check(response).await
}
pub async fn vault_summary(id: &str) -> Result<SummaryPage, String> {
let response = Request::get(&format!("/api/vaults/{id}/summary")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn options(id: &str) -> Result<OptionsPage, String> {
let response = Request::get(&format!("/api/vaults/{id}/options")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn search(id: &str, query: &str, kind: &str) -> Result<SearchPage, String> {
let url = format!(
"/api/vaults/{id}/search?q={}&type={}",
url_encode(query),
url_encode(kind)
);
let response = Request::get(&url).send().await.map_err(|e| e.to_string())?;
check(response).await
}
// ---------------------------------------------------------------------------
// Keys
// ---------------------------------------------------------------------------
pub async fn keys(vault_id: &str) -> Result<KeysPage, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/keys")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn key(vault_id: &str, key_id: &str) -> Result<KeyDetail, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/keys/{key_id}")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn generate_key(vault_id: &str, payload: &serde_json::Value) -> Result<KeyDetail, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/keys"), Some(payload)).await?;
check(response).await
}
pub async fn update_key(vault_id: &str, key_id: &str, payload: &serde_json::Value) -> Result<KeyDetail, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/keys/{key_id}"), Some(payload)).await?;
check(response).await
}
pub async fn delete_key(vault_id: &str, key_id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{vault_id}/keys/{key_id}"), None).await?;
expect_empty(response).await
}
pub async fn export_key(vault_id: &str, key_id: &str, payload: &serde_json::Value) -> Result<ExportPayload, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/keys/{key_id}/export"), Some(payload)).await?;
check(response).await
}
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
pub async fn requests(vault_id: &str) -> Result<RequestsPage, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/requests")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn request(vault_id: &str, request_id: &str) -> Result<RequestDetail, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/requests/{request_id}")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn create_request(vault_id: &str, payload: &serde_json::Value) -> Result<RequestDetail, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/requests"), Some(payload)).await?;
check(response).await
}
pub async fn update_request(vault_id: &str, request_id: &str, payload: &serde_json::Value) -> Result<RequestDetail, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/requests/{request_id}"), Some(payload)).await?;
check(response).await
}
pub async fn delete_request(vault_id: &str, request_id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{vault_id}/requests/{request_id}"), None).await?;
expect_empty(response).await
}
pub async fn export_request(vault_id: &str, request_id: &str, payload: &serde_json::Value) -> Result<ExportPayload, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/requests/{request_id}/export"), Some(payload)).await?;
check(response).await
}
pub async fn transform_request(vault_id: &str, request_id: &str, action: &str) -> Result<serde_json::Value, String> {
let response = send(
"POST",
&format!("/api/vaults/{vault_id}/requests/{request_id}/transform/{action}"),
Some(&json!({})),
)
.await?;
check(response).await
}
// ---------------------------------------------------------------------------
// Certificates
// ---------------------------------------------------------------------------
pub async fn certificates(vault_id: &str) -> Result<CertificatesPage, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/certificates")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn certificate(vault_id: &str, cert_id: &str) -> Result<CertificateDetail, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/certificates/{cert_id}")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn create_certificate(vault_id: &str, payload: &serde_json::Value) -> Result<CertificateDetail, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates"), Some(payload)).await?;
check(response).await
}
pub async fn update_certificate(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<CertificateDetail, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/certificates/{cert_id}"), Some(payload)).await?;
check(response).await
}
pub async fn delete_certificate(vault_id: &str, cert_id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{vault_id}/certificates/{cert_id}"), None).await?;
expect_empty(response).await
}
pub async fn export_certificate(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<ExportPayload, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/export"), Some(payload)).await?;
check(response).await
}
pub async fn renew_certificate(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<CertificateDetail, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/renew"), Some(payload)).await?;
check(response).await
}
pub async fn revoke_certificate(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/revoke"), Some(payload)).await?;
check(response).await
}
pub async fn ca_info(vault_id: &str, cert_id: &str) -> Result<serde_json::Value, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/certificates/{cert_id}/ca")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn update_ca(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/ca"), Some(payload)).await?;
check(response).await
}
pub async fn revocations(vault_id: &str, cert_id: &str) -> Result<RevocationsPage, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/certificates/{cert_id}/revocations")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn add_revocation(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/revocations"), Some(payload)).await?;
check(response).await
}
pub async fn update_revocation(vault_id: &str, cert_id: &str, rev_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/revocations/{rev_id}"), Some(payload)).await?;
check(response).await
}
pub async fn delete_revocation(vault_id: &str, cert_id: &str, rev_id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/revocations/{rev_id}"), None).await?;
expect_empty(response).await
}
pub async fn generate_crl(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<CrlDetail, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/crl"), Some(payload)).await?;
check(response).await
}
pub async fn tls_auth(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/tls-auth"), Some(payload)).await?;
check(response).await
}
pub async fn transform_certificate(vault_id: &str, cert_id: &str, action: &str) -> Result<serde_json::Value, String> {
let response = send(
"POST",
&format!("/api/vaults/{vault_id}/certificates/{cert_id}/transform/{action}"),
Some(&json!({})),
)
.await?;
check(response).await
}
// ---------------------------------------------------------------------------
// CRLs
// ---------------------------------------------------------------------------
pub async fn crls(vault_id: &str) -> Result<CrlsPage, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/crls")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn crl(vault_id: &str, crl_id: &str) -> Result<CrlDetail, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/crls/{crl_id}")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn delete_crl(vault_id: &str, crl_id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{vault_id}/crls/{crl_id}"), None).await?;
expect_empty(response).await
}
pub async fn export_crl(vault_id: &str, crl_id: &str, payload: &serde_json::Value) -> Result<ExportPayload, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/crls/{crl_id}/export"), Some(payload)).await?;
check(response).await
}
// ---------------------------------------------------------------------------
// Templates
// ---------------------------------------------------------------------------
pub async fn templates(vault_id: &str) -> Result<TemplatesPage, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/templates")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn template(vault_id: &str, template_id: &str) -> Result<TemplateSummary, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/templates/{template_id}")).send().await.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn create_template(vault_id: &str, payload: &serde_json::Value) -> Result<TemplateSummary, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/templates"), Some(payload)).await?;
check(response).await
}
pub async fn update_template(vault_id: &str, template_id: &str, payload: &serde_json::Value) -> Result<TemplateSummary, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/templates/{template_id}"), Some(payload)).await?;
check(response).await
}
pub async fn delete_template(vault_id: &str, template_id: &str) -> Result<(), String> {
let response = send("DELETE", &format!("/api/vaults/{vault_id}/templates/{template_id}"), None).await?;
expect_empty(response).await
}
pub async fn apply_template(vault_id: &str, template_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/templates/{template_id}/apply"), Some(payload)).await?;
check(response).await
}
pub async fn export_template(vault_id: &str, template_id: &str, payload: &serde_json::Value) -> Result<ExportPayload, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/templates/{template_id}/export"), Some(payload)).await?;
check(response).await
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
pub async fn import_items(vault_id: &str, payload: &serde_json::Value) -> Result<ImportPreview, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/import"), Some(payload)).await?;
check(response).await
}
pub async fn import_commit(vault_id: &str, payload: &serde_json::Value) -> Result<ImportResult, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/import"), Some(payload)).await?;
check(response).await
}
+56
View File
@@ -0,0 +1,56 @@
//! Login redirect and return-to-page handling.
use gloo_timers::callback::Timeout;
use sycamore_router::navigate_replace;
const RETURN_TO_KEY: &str = "kca.login.return_to";
fn storage() -> Option<web_sys::Storage> {
web_sys::window()?.session_storage().ok().flatten()
}
fn current_path() -> String {
match web_sys::window() {
Some(window) => {
let location = window.location();
let path = location.pathname().unwrap_or_default();
let search = location.search().unwrap_or_default();
format!("{path}{search}")
}
None => "/".to_string(),
}
}
fn is_restorable(path: &str) -> bool {
!path.is_empty()
&& path.starts_with('/')
&& !path.starts_with("/api/")
&& !path.starts_with("/auth/")
&& path != "/login"
}
pub fn redirect_to_login() {
let path = current_path();
if is_restorable(&path) {
if let Some(storage) = storage() {
let _ = storage.set_item(RETURN_TO_KEY, &path);
}
}
Timeout::new(0, move || {
if current_path() != "/login" {
navigate_replace("/login");
}
})
.forget();
}
pub fn saved_return_to() -> Option<String> {
storage()?.get_item(RETURN_TO_KEY).ok().flatten()
}
pub fn clear_return_to() {
if let Some(storage) = storage() {
let _ = storage.remove_item(RETURN_TO_KEY);
}
}
pub const LOGOUT_URL: &str = "/auth/logout";
+3
View File
@@ -0,0 +1,3 @@
pub mod nav;
pub mod toast;
pub mod ui;
+88
View File
@@ -0,0 +1,88 @@
//! Navigation bar and user slot.
use sycamore::prelude::*;
use crate::auth::LOGOUT_URL;
use crate::model::Me;
pub use super::ui::spinner;
fn nav_link(href: String, label: &'static str, active: &'static str) -> View {
let class = if href == active { "nav-link active" } else { "nav-link" };
view! {
li(class="nav-item") {
a(class=class, href=href) { (label) }
}
}
}
pub fn app_nav(active: &'static str, vault_id: Option<&'static str>, whoami: impl Fn() -> View + 'static) -> View {
let open = create_signal(false);
let toggle = move |_| open.set(!open.get());
let collapse_class = move || {
if open.get() {
"collapse navbar-collapse show"
} else {
"collapse navbar-collapse"
}
};
let expanded = move || if open.get() { "true" } else { "false" };
let vault_links = move || match vault_id {
Some(id) => view! {
(nav_link(format!("/vaults/{id}"), "Overview", active))
(nav_link(format!("/vaults/{id}/certificates"), "Certificates", active))
(nav_link(format!("/vaults/{id}/requests"), "Requests", active))
(nav_link(format!("/vaults/{id}/keys"), "Keys", active))
(nav_link(format!("/vaults/{id}/crls"), "CRLs", active))
(nav_link(format!("/vaults/{id}/templates"), "Templates", active))
(nav_link(format!("/vaults/{id}/search"), "Search", active))
},
None => view! {
(nav_link("/".to_string(), "Vaults", active))
},
};
view! {
nav(class="navbar navbar-expand-lg") {
div(class="container-fluid") {
a(class="navbar-brand", href="/") {
span(class="brand-glyph") { "⬡" } " KCA"
}
button(class="navbar-toggler", r#type="button",
aria-label="Toggle navigation",
aria-controls="app-nav",
aria-expanded=expanded,
on:click=toggle) {
span(class="navbar-toggler-icon") {}
}
div(id="app-nav", class=collapse_class) {
ul(class="navbar-nav me-auto") {
(vault_links)
}
(whoami())
}
}
}
}
}
pub fn whoami(me: &Me) -> View {
let name = me.display_name.clone();
let initial = name.chars().next().unwrap_or('?').to_string();
let picture = me.picture.clone();
view! {
span(class="navbar-text d-flex align-items-center gap-3") {
span(class="d-flex align-items-center gap-2") {
(if let Some(picture) = picture {
view! { img(class="avatar", src=picture, alt="") }
} else {
view! {
span(class="avatar bg-secondary d-inline-flex align-items-center justify-content-center") {
(initial)
}
}
})
span() { (name) }
}
a(class="btn btn-sm btn-outline-light", href=LOGOUT_URL, rel="external") { "Sign out" }
}
}
}
+20
View File
@@ -0,0 +1,20 @@
//! A transient error/success toast.
use gloo_timers::callback::Timeout;
use sycamore::prelude::*;
const TOAST_MS: u32 = 10_000;
pub fn toast(message: Signal<Option<String>>, success: bool) -> View {
create_effect(move || {
if message.get_clone().is_some() {
let timeout = Timeout::new(TOAST_MS, move || message.set(None));
on_cleanup(move || drop(timeout));
}
});
let class = if success { "app-toast success" } else { "app-toast" };
view! {
(move || message.get_clone().map(|text| view! {
div(class=class, role="alert") { (text) }
}))
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Shared small UI fragments.
use sycamore::prelude::*;
pub fn toast_error(message: Signal<Option<String>>) -> View {
crate::components::toast::toast(message, false)
}
pub fn spinner() -> View {
view! {
div(class="d-flex justify-content-center p-5") {
div(class="spinner-border text-warning", role="status") {
span(class="visually-hidden") { "Loading…" }
}
}
}
}
pub fn empty_state(message: impl Into<String>) -> View {
let message = message.into();
view! { p(class="empty-state") { (message) } }
}
pub fn page_header(title: impl Into<String>, subtitle: impl Into<String>, actions: View) -> View {
let title = title.into();
let subtitle = subtitle.into();
view! {
div(class="d-flex justify-content-between align-items-end flex-wrap gap-2 mb-4") {
div() {
h1(class="h3 mb-1") { (title) }
p(class="text-secondary mb-0") { (subtitle) }
}
div(class="d-flex gap-2 flex-wrap") { (actions) }
}
}
}
pub fn error_alert(message: Signal<Option<String>>) -> View {
view! {
(if message.get_clone().is_some() {
view! {
div(class="alert alert-danger py-2 mb-3") { (message.get_clone().unwrap_or_default()) }
}
} else {
view! {}
})
}
}
pub fn status_badge(label: impl Into<String>, class: impl Into<String>) -> View {
let label = label.into();
let class = class.into();
view! { span(class=format!("badge {class}")) { (label) } }
}
pub fn copy_button(text: String, label: &'static str) -> View {
let feedback = create_signal(None::<String>);
let click = move |_| {
let value = text.clone();
let feedback = feedback;
wasm_bindgen_futures::spawn_local(async move {
match crate::dom::copy_text(&value).await {
Ok(()) => feedback.set(Some("Copied".to_string())),
Err(error) => feedback.set(Some(error)),
}
});
};
view! {
div(class="d-inline-flex align-items-center gap-2") {
button(class="btn btn-sm btn-outline-light", r#type="button", on:click=click) { (label) }
(if let Some(text) = feedback.get_clone() {
view! { span(class="text-secondary small") { (text) } }
} else {
view! {}
})
}
}
}
pub fn kv_row(label: impl Into<String>, value: View) -> View {
let label = label.into();
view! {
div(class="row g-1 py-1 border-bottom border-secondary-subtle") {
div(class="col-sm-4 text-secondary small") { (label) }
div(class="col-sm-8") { (value) }
}
}
}
pub fn mono(text: String) -> View {
view! { span(class="mono fingerprint") { (text) } }
}
+119
View File
@@ -0,0 +1,119 @@
//! Small DOM helpers shared by the pages.
use base64::Engine;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
pub fn input_value(event: &web_sys::Event) -> String {
event
.target()
.and_then(|target| target.dyn_into::<web_sys::HtmlInputElement>().ok())
.map(|input| input.value())
.unwrap_or_default()
}
pub fn input_checked(event: &web_sys::Event) -> bool {
event
.target()
.and_then(|target| target.dyn_into::<web_sys::HtmlInputElement>().ok())
.map(|input| input.checked())
.unwrap_or(false)
}
pub fn textarea_value(event: &web_sys::Event) -> String {
event
.target()
.and_then(|target| target.dyn_into::<web_sys::HtmlTextAreaElement>().ok())
.map(|input| input.value())
.unwrap_or_default()
}
pub fn select_value(event: &web_sys::Event) -> String {
event
.target()
.and_then(|target| target.dyn_into::<web_sys::HtmlSelectElement>().ok())
.map(|select| select.value())
.unwrap_or_default()
}
pub fn input_file(event: &web_sys::Event) -> Option<web_sys::File> {
event
.target()
.and_then(|target| target.dyn_into::<web_sys::HtmlInputElement>().ok())
.and_then(|input| input.files())
.and_then(|files| files.get(0))
}
pub fn reset_file_input(event: &web_sys::Event) {
if let Some(input) = event
.target()
.and_then(|target| target.dyn_into::<web_sys::HtmlInputElement>().ok())
{
input.set_value("");
}
}
pub async fn read_file(file: &web_sys::File) -> Result<Vec<u8>, String> {
let promise = file.array_buffer();
let buffer = JsFuture::from(promise)
.await
.map_err(|_| "the selected file could not be read".to_string())?;
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
}
pub async fn read_file_base64(file: &web_sys::File) -> Result<String, String> {
let bytes = read_file(file).await?;
Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
}
pub fn confirm(message: &str) -> bool {
web_sys::window()
.map(|window| window.confirm_with_message(message).unwrap_or(false))
.unwrap_or(false)
}
pub async fn copy_text(text: &str) -> Result<(), String> {
let window = web_sys::window().ok_or("no browser window")?;
let promise = window.navigator().clipboard().write_text(text);
JsFuture::from(promise)
.await
.map(|_| ())
.map_err(|_| "the browser blocked clipboard access".to_string())
}
/// Decode an API export payload and trigger a browser download.
pub fn download_base64(filename: &str, content_type: &str, content_base64: &str) -> Result<(), String> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(content_base64)
.map_err(|_| "the exported file is not valid base64".to_string())?;
download_bytes(filename, content_type, &bytes)
}
pub fn download_bytes(filename: &str, content_type: &str, bytes: &[u8]) -> Result<(), String> {
let window = web_sys::window().ok_or("no browser window")?;
let document = window.document().ok_or("no document")?;
let array = js_sys::Uint8Array::from(bytes);
let parts = js_sys::Array::new();
parts.push(&array.buffer());
let options = web_sys::BlobPropertyBag::new();
options.set_type(content_type);
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options)
.map_err(|_| "could not create the download".to_string())?;
let url = web_sys::Url::create_object_url_with_blob(&blob)
.map_err(|_| "could not create the download URL".to_string())?;
let anchor = document
.create_element("a")
.map_err(|_| "could not create the download link".to_string())?
.dyn_into::<web_sys::HtmlAnchorElement>()
.map_err(|_| "could not create the download link".to_string())?;
anchor.set_href(&url);
anchor.set_download(filename);
anchor.click();
web_sys::Url::revoke_object_url(&url).ok();
Ok(())
}
pub fn navigate(path: &str) {
if let Some(window) = web_sys::window() {
let _ = window.location().set_href(path);
}
}
+118
View File
@@ -0,0 +1,118 @@
//! KCA: a web reimplementation of XCA's software-key PKI workflows.
mod api;
mod auth;
mod components;
mod dom;
mod model;
mod pages;
use sycamore::prelude::*;
use sycamore_router::{HistoryIntegration, Route, Router};
#[derive(Route, Clone)]
enum AppRoutes {
#[to("/")]
Dashboard,
#[to("/login")]
Login,
#[to("/tokens")]
Tokens,
#[to("/vaults/<id>/certificates/new")]
CertificateNew { id: String },
#[to("/vaults/<id>/certificates/<cert_id>/renew")]
CertificateRenew { id: String, cert_id: String },
#[to("/vaults/<id>/certificates/<cert_id>")]
CertificateDetail { id: String, cert_id: String },
#[to("/vaults/<id>/certificates")]
Certificates { id: String },
#[to("/vaults/<id>/requests/new")]
RequestNew { id: String },
#[to("/vaults/<id>/requests/<request_id>")]
RequestDetail { id: String, request_id: String },
#[to("/vaults/<id>/requests")]
Requests { id: String },
#[to("/vaults/<id>/keys/new")]
KeyNew { id: String },
#[to("/vaults/<id>/keys/<key_id>")]
KeyDetail { id: String, key_id: String },
#[to("/vaults/<id>/keys")]
Keys { id: String },
#[to("/vaults/<id>/crls/<crl_id>")]
CrlDetail { id: String, crl_id: String },
#[to("/vaults/<id>/crls")]
Crls { id: String },
#[to("/vaults/<id>/templates/new")]
TemplateNew { id: String },
#[to("/vaults/<id>/templates/<template_id>")]
TemplateDetail { id: String, template_id: String },
#[to("/vaults/<id>/templates")]
Templates { id: String },
#[to("/vaults/<id>/import")]
Import { id: String },
#[to("/vaults/<id>/search")]
Search { id: String },
#[to("/vaults/<id>/settings")]
Settings { id: String },
#[to("/vaults/<id>")]
VaultOverview { id: String },
#[not_found]
NotFound,
}
fn main() {
console_error_panic_hook::set_once();
sycamore::render(|| {
view! {
Router(
integration=HistoryIntegration::new(),
view=|route: ReadSignal<AppRoutes>| {
view! {
div(class="app") {
(match route.get_clone() {
AppRoutes::Dashboard => view! { pages::dashboard::DashboardPage() },
AppRoutes::Login => view! { pages::login::LoginPage() },
AppRoutes::Tokens => view! { pages::tokens::TokensPage() },
AppRoutes::CertificateNew { id } => view! { (pages::certificate_new::CertificateNewPage(id.clone())) },
AppRoutes::CertificateRenew { id, cert_id } => view! { (pages::certificate_new::CertificateRenewPage(id.clone(), cert_id.clone())) },
AppRoutes::CertificateDetail { id, cert_id } => view! { (pages::certificate_detail::CertificateDetailPage(id.clone(), cert_id.clone())) },
AppRoutes::Certificates { id } => view! { (pages::certificates::CertificatesPage(id.clone())) },
AppRoutes::RequestNew { id } => view! { (pages::request_new::RequestNewPage(id.clone())) },
AppRoutes::RequestDetail { id, request_id } => view! { (pages::request_detail::RequestDetailPage(id.clone(), request_id.clone())) },
AppRoutes::Requests { id } => view! { (pages::requests::RequestsPage(id.clone())) },
AppRoutes::KeyNew { id } => view! { (pages::key_new::KeyNewPage(id.clone())) },
AppRoutes::KeyDetail { id, key_id } => view! { (pages::key_detail::KeyDetailPage(id.clone(), key_id.clone())) },
AppRoutes::Keys { id } => view! { (pages::keys::KeysPage(id.clone())) },
AppRoutes::CrlDetail { id, crl_id } => view! { (pages::crl_detail::CrlDetailPage(id.clone(), crl_id.clone())) },
AppRoutes::Crls { id } => view! { (pages::crls::CrlsPage(id.clone())) },
AppRoutes::TemplateNew { id } => view! { (pages::template_editor::TemplateEditorPage(id.clone(), None)) },
AppRoutes::TemplateDetail { id, template_id } => view! { (pages::template_editor::TemplateEditorPage(id.clone(), Some(template_id.clone()))) },
AppRoutes::Templates { id } => view! { (pages::templates::TemplatesPage(id.clone())) },
AppRoutes::Import { id } => view! { (pages::import::ImportPage(id.clone())) },
AppRoutes::Search { id } => view! { (pages::search::SearchPage(id.clone())) },
AppRoutes::Settings { id } => view! { (pages::settings::SettingsPage(id.clone())) },
AppRoutes::VaultOverview { id } => view! { (pages::vault::VaultPage(id.clone())) },
AppRoutes::NotFound => view! { pages::not_found::NotFoundPage() },
})
}
}
}
)
}
});
restore_page_after_login();
}
fn restore_page_after_login() {
let return_to = auth::saved_return_to();
let Some(path) = return_to else { return };
if path == "/login" {
auth::clear_return_to();
return;
}
wasm_bindgen_futures::spawn_local(async move {
if let Ok(Some(_)) = api::me_optional().await {
auth::clear_return_to();
sycamore_router::navigate(&path);
}
});
}
+738
View File
@@ -0,0 +1,738 @@
//! JSON models mirroring the KCA API and small display helpers.
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct Me {
pub sub: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub picture: Option<String>,
#[serde(default)]
pub master_key_is_dev: bool,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub preferred_username: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct AuthProvider {
pub name: String,
pub login_url: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct AuthProvidersPage {
#[serde(default)]
pub providers: Vec<AuthProvider>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ApiToken {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub prefix: String,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub last_used_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ApiTokensPage {
#[serde(default)]
pub results: Vec<ApiToken>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CreatedApiToken {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub prefix: String,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub last_used_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub token: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct VaultCounts {
#[serde(default)]
pub keys: i64,
#[serde(default)]
pub certificates: i64,
#[serde(default)]
pub requests: i64,
#[serde(default)]
pub crls: i64,
#[serde(default)]
pub templates: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct VaultSummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub counts: VaultCounts,
#[serde(default)]
pub expiring_soon: i64,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub version: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct VaultDetail {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub counts: VaultCounts,
#[serde(default)]
pub expiring_soon: i64,
#[serde(default)]
pub settings: Value,
#[serde(default)]
pub version: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct VaultsPage {
#[serde(default)]
pub results: Vec<VaultSummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct KeySummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub key_type: String,
#[serde(default)]
pub key_size: Option<i64>,
#[serde(default)]
pub curve: Option<String>,
#[serde(default)]
pub has_private: bool,
#[serde(default)]
pub fingerprint_sha256: String,
#[serde(default)]
pub use_count: i64,
#[serde(default)]
pub version: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct KeyDetail {
#[serde(flatten)]
pub summary: KeySummary,
#[serde(default)]
pub public_pem: String,
#[serde(default)]
pub possible_hashes: Vec<String>,
#[serde(default)]
pub can_export_private: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct KeysPage {
#[serde(default)]
pub results: Vec<KeySummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CertificateSummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub subject: String,
#[serde(default)]
pub issuer_id: Option<String>,
#[serde(default)]
pub issuer_name: String,
#[serde(default)]
pub serial_hex: String,
#[serde(default)]
pub not_before: Option<String>,
#[serde(default)]
pub not_after: Option<String>,
#[serde(default)]
pub is_ca: bool,
#[serde(default)]
pub path_length: Option<i64>,
#[serde(default)]
pub has_private_key: bool,
#[serde(default)]
pub revoked: bool,
#[serde(default)]
pub revoked_at: Option<String>,
#[serde(default)]
pub fingerprint_sha256: String,
#[serde(default)]
pub key_id: Option<String>,
#[serde(default)]
pub version: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CertificateDetail {
#[serde(flatten)]
pub summary: CertificateSummary,
#[serde(default)]
pub subject_entries: Vec<DnEntry>,
#[serde(default)]
pub issuer_entries: Vec<DnEntry>,
#[serde(default)]
pub subject_rfc4514: String,
#[serde(default)]
pub issuer_rfc4514: String,
#[serde(default)]
pub subject_der_hex: String,
#[serde(default)]
pub issuer_der_hex: String,
#[serde(default)]
pub signature_algorithm: String,
#[serde(default)]
pub fingerprint_md5: String,
#[serde(default)]
pub fingerprint_sha1: String,
#[serde(default)]
pub public_key_der_hex: String,
#[serde(default)]
pub extensions: Vec<ExtensionRow>,
#[serde(default)]
pub extension_spec: Value,
#[serde(default)]
pub openssl_config: String,
#[serde(default)]
pub validation_errors: Vec<ValidationError>,
#[serde(default)]
pub purposes: Vec<String>,
#[serde(default)]
pub chain: Vec<ChainItem>,
#[serde(default)]
pub revocation: Option<Revocation>,
#[serde(default)]
pub key_name: Option<String>,
#[serde(default)]
pub key_type: Option<String>,
#[serde(default)]
pub key_size: Option<i64>,
#[serde(default)]
pub request_id: Option<String>,
#[serde(default)]
pub request_name: Option<String>,
#[serde(default)]
pub can_sign: bool,
#[serde(default)]
pub authority: Option<AuthorityInfo>,
#[serde(default)]
pub cert_spec: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct DnEntry {
#[serde(default)]
pub oid: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ExtensionRow {
#[serde(default)]
pub oid: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub critical: bool,
#[serde(default)]
pub value: String,
#[serde(default)]
pub der_hex: String,
#[serde(default)]
pub spec: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ValidationError {
#[serde(default)]
pub code: Option<i64>,
#[serde(default)]
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ChainItem {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub subject: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct AuthorityInfo {
#[serde(default)]
pub crl_expire: Option<String>,
#[serde(default)]
pub crl_number: Option<i64>,
#[serde(default)]
pub crl_days: Option<i64>,
#[serde(default)]
pub default_template_id: Option<String>,
#[serde(default)]
pub has_tls_auth_key: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CertificatesPage {
#[serde(default)]
pub results: Vec<CertificateSummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct RequestSummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub subject: String,
#[serde(default)]
pub signed: bool,
#[serde(default)]
pub signed_at: Option<String>,
#[serde(default)]
pub key_id: Option<String>,
#[serde(default)]
pub fingerprint_sha256: String,
#[serde(default)]
pub certificate_count: i64,
#[serde(default)]
pub version: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct RequestDetail {
#[serde(flatten)]
pub summary: RequestSummary,
#[serde(default)]
pub subject_entries: Vec<DnEntry>,
#[serde(default)]
pub subject_rfc4514: String,
#[serde(default)]
pub signature_algorithm: String,
#[serde(default)]
pub signature_valid: bool,
#[serde(default)]
pub attributes: Vec<AttributeRow>,
#[serde(default)]
pub extensions: Vec<ExtensionRow>,
#[serde(default)]
pub extension_spec: Value,
#[serde(default)]
pub openssl_config: String,
#[serde(default)]
pub csr_spec: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct AttributeRow {
#[serde(default)]
pub oid: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub values: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct RequestsPage {
#[serde(default)]
pub results: Vec<RequestSummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CrlSummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub issuer_id: Option<String>,
#[serde(default)]
pub issuer_name: String,
#[serde(default)]
pub crl_number: Option<i64>,
#[serde(default)]
pub last_update: Option<String>,
#[serde(default)]
pub next_update: Option<String>,
#[serde(default)]
pub revoked_count: i64,
#[serde(default)]
pub signature_algorithm: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CrlDetail {
#[serde(flatten)]
pub summary: CrlSummary,
#[serde(default)]
pub issuer_entries: Vec<DnEntry>,
#[serde(default)]
pub issuer_rfc4514: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub extensions: Vec<ExtensionRow>,
#[serde(default)]
pub revocations: Vec<CrlRevocation>,
#[serde(default)]
pub crl_spec: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CrlRevocation {
#[serde(default)]
pub serial_hex: String,
#[serde(default)]
pub revoked_at: String,
#[serde(default)]
pub invalidity_date: Option<String>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct CrlsPage {
#[serde(default)]
pub results: Vec<CrlSummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct TemplateSummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub subject: Value,
#[serde(default)]
pub settings: Value,
#[serde(default)]
pub builtin: bool,
#[serde(default)]
pub version: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct TemplatesPage {
#[serde(default)]
pub results: Vec<TemplateSummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct Revocation {
pub id: String,
#[serde(default)]
pub ca_id: String,
#[serde(default)]
pub serial_hex: String,
#[serde(default)]
pub revoked_at: Option<String>,
#[serde(default)]
pub invalidity_date: Option<String>,
#[serde(default)]
pub reason: String,
#[serde(default)]
pub crl_number: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct RevocationsPage {
#[serde(default)]
pub results: Vec<Revocation>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct SearchResult {
#[serde(default)]
pub r#type: String,
#[serde(default)]
pub item: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct SearchPage {
#[serde(default)]
pub results: Vec<SearchResult>,
#[serde(default)]
pub counts: HashMap<String, i64>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ImportItem {
#[serde(default)]
pub index: i64,
#[serde(default)]
pub kind: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub is_private: bool,
#[serde(default)]
pub duplicate: bool,
#[serde(default)]
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ImportPreview {
#[serde(default)]
pub dry_run: bool,
#[serde(default)]
pub items: Vec<ImportItem>,
#[serde(default)]
pub count: i64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ImportResult {
#[serde(default)]
pub imported: Vec<ImportItem>,
#[serde(default)]
pub skipped: Vec<ImportItem>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct ExportPayload {
#[serde(default)]
pub filename: String,
#[serde(default)]
pub content_type: String,
#[serde(default)]
pub content_base64: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct SummaryPage {
#[serde(default)]
pub summary: VaultSummary,
#[serde(default)]
pub expiring: Vec<CertificateSummary>,
#[serde(default)]
pub crls_due: Vec<CrlSummary>,
#[serde(default)]
pub recent_certificates: Vec<CertificateSummary>,
#[serde(default)]
pub recent_keys: Vec<KeySummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct OptionsPage {
#[serde(default)]
pub key_types: Vec<Value>,
#[serde(default)]
pub hash_algorithms: Vec<String>,
#[serde(default)]
pub curves: Vec<String>,
#[serde(default)]
pub dn_attributes: Vec<DnAttribute>,
#[serde(default)]
pub eku: Vec<DnAttribute>,
#[serde(default)]
pub key_usages: Vec<NamedOption>,
#[serde(default)]
pub revocation_reasons: Vec<NamedOption>,
#[serde(default)]
pub defaults: Value,
#[serde(default)]
pub names: Value,
#[serde(default)]
pub templates: Vec<TemplateSummary>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct DnAttribute {
#[serde(default)]
pub oid: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub long_name: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct NamedOption {
#[serde(default)]
pub name: String,
#[serde(default)]
pub label: String,
}
impl std::ops::Deref for KeyDetail {
type Target = KeySummary;
fn deref(&self) -> &Self::Target { &self.summary }
}
impl std::ops::Deref for CertificateDetail {
type Target = CertificateSummary;
fn deref(&self) -> &Self::Target { &self.summary }
}
impl std::ops::Deref for RequestDetail {
type Target = RequestSummary;
fn deref(&self) -> &Self::Target { &self.summary }
}
impl std::ops::Deref for CrlDetail {
type Target = CrlSummary;
fn deref(&self) -> &Self::Target { &self.summary }
}
// ---------------------------------------------------------------------------
// Display helpers
// ---------------------------------------------------------------------------
pub fn fmt_date(value: &Option<String>) -> String {
match value {
Some(text) if !text.is_empty() => {
// Keep the date and time to the minute; certificates are not
// sub-second artifacts and the full ISO string is noisy.
let cleaned = text.replace('T', " ");
let trimmed = cleaned.trim_end_matches('Z');
let mut parts = trimmed.splitn(2, '.');
let base = parts.next().unwrap_or(trimmed);
base.chars().take(19).collect()
}
_ => "—".to_string(),
}
}
pub fn fmt_fp(value: &str) -> String {
if value.is_empty() {
"—".to_string()
} else if value.len() > 24 {
format!("{}…{}", &value[..16], &value[value.len() - 8..])
} else {
value.to_string()
}
}
pub fn status_badge(cert: &CertificateSummary) -> (&'static str, &'static str) {
if cert.revoked {
("badge-danger", "revoked")
} else if let Some(after) = &cert.not_after {
if after < &now_iso() {
("badge-danger", "expired")
} else {
("badge-soft", "valid")
}
} else {
("badge-soft", "valid")
}
}
fn now_iso() -> String {
// The browser's clock is enough for a visual badge; the server remains
// authoritative for validation and revocation.
js_sys::Date::new_0().to_iso_string().as_string().unwrap_or_default()
}
pub fn item_total(counts: &VaultCounts) -> i64 {
counts.keys + counts.certificates + counts.requests + counts.crls + counts.templates
}
pub fn json_str(value: &Value, key: &str) -> String {
value.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string()
}
pub fn json_bool(value: &Value, key: &str) -> bool {
value.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
}
pub fn json_array(value: &Value, key: &str) -> Vec<Value> {
value
.get(key)
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn formats_fingerprints() {
assert_eq!(fmt_fp(""), "—");
assert_eq!(fmt_fp("abcdefghijklmnopqrstuvwxyz"), "abcdefghijklmnop…stuvwxyz");
}
#[test]
fn formats_dates() {
assert_eq!(fmt_date(&None), "—");
assert_eq!(
fmt_date(&Some("2026-10-23T01:49:02.123Z".to_string())),
"2026-10-23 01:49:02"
);
}
#[test]
fn reads_json_values() {
let value = json!({"name": "CA", "ca": true, "items": [1, 2]});
assert_eq!(json_str(&value, "name"), "CA");
assert!(json_bool(&value, "ca"));
assert_eq!(json_array(&value, "items").len(), 2);
}
}
+421
View File
@@ -0,0 +1,421 @@
//! Certificate detail, validation and CA actions.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{copy_button, empty_state, page_header, toast_error};
use crate::dom;
use crate::model::{fmt_date, CertificateDetail, Me, Revocation};
pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let cert_id_value: &'static str = Box::leak(cert_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let cert = create_signal(None::<CertificateDetail>);
let error = create_signal(None::<String>);
let loading = create_signal(true);
let export_format = create_signal("pem".to_string());
let export_password = create_signal(String::new());
let revocations = create_signal(Vec::<Revocation>::new());
let subject_entries = create_signal(Vec::<crate::model::DnEntry>::new());
let issuer_entries = create_signal(Vec::<crate::model::DnEntry>::new());
let extensions = create_signal(Vec::<crate::model::ExtensionRow>::new());
let validation_errors = create_signal(Vec::<crate::model::ValidationError>::new());
let purposes = create_signal(Vec::<String>::new());
let chain = create_signal(Vec::<crate::model::ChainItem>::new());
let rev_reason = create_signal("unspecified".to_string());
let rev_serial = create_signal(String::new());
let ca_days = create_signal("30".to_string());
let crl_days = create_signal("30".to_string());
let crl_hash = create_signal("sha256".to_string());
let tls_auth_text = create_signal(String::new());
let new_name = create_signal(String::new());
let new_comment = create_signal(String::new());
let load = move || {
spawn_local(async move {
match api::certificate(id, cert_id_value).await {
Ok(value) => {
new_name.set(value.summary.name.clone());
new_comment.set(value.summary.comment.clone());
if let Some(days) = value.authority.as_ref().and_then(|a| a.crl_days) {
ca_days.set(days.to_string());
crl_days.set(days.to_string());
}
subject_entries.set(value.subject_entries.clone());
issuer_entries.set(value.issuer_entries.clone());
extensions.set(value.extensions.clone());
validation_errors.set(value.validation_errors.clone());
purposes.set(value.purposes.clone());
chain.set(value.chain.clone());
cert.set(Some(value));
if let Ok(page) = api::revocations(id, cert_id_value).await {
revocations.set(page.results);
}
}
Err(message) => error.set(Some(message)),
}
});
};
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
loading.set(false);
});
load();
let do_export = move |_| {
let mut payload = json!({"format": export_format.get_clone()});
if !export_password.get_clone().is_empty() {
payload["password"] = json!(export_password.get_clone());
}
spawn_local(async move {
match api::export_certificate(id, cert_id_value, &payload).await {
Ok(payload) => {
if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) {
error.set(Some(message));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let revoke = move |_| {
let payload = json!({"reason": rev_reason.get_clone()});
spawn_local(async move {
match api::revoke_certificate(id, cert_id_value, &payload).await {
Ok(_) => load(),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let remove = move |_| {
if !dom::confirm("Delete this certificate?") {
return;
}
spawn_local(async move {
match api::delete_certificate(id, cert_id_value).await {
Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/certificates")),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let save_meta = move |_| {
let value = cert.get_clone();
let Some(value) = value else { return };
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)),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let transform = move |action: &'static str| {
spawn_local(async move {
match api::transform_certificate(id, cert_id_value, action).await {
Ok(value) => {
if let Some(item_id) = value.get("id").and_then(|v| v.as_str()) {
let route = match action {
"open-key" => "keys",
"request" => "requests",
"template" => "templates",
_ => "certificates",
};
sycamore_router::navigate(&format!("/vaults/{id}/{route}/{item_id}"));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let generate_crl = move |_| {
let payload = json!({
"days": crl_days.get_clone().parse::<i64>().unwrap_or(30),
"signature_hash": crl_hash.get_clone(),
"with_reason": true,
"include_aki": true,
});
spawn_local(async move {
match api::generate_crl(id, cert_id_value, &payload).await {
Ok(crl) => sycamore_router::navigate(&format!("/vaults/{id}/crls/{}", crl.summary.id)),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let add_revocation = move |_| {
let payload = json!({"reason": rev_reason.get_clone(), "serial_hex": rev_serial.get_clone()});
spawn_local(async move {
match api::add_revocation(id, cert_id_value, &payload).await {
Ok(_) => {
if let Ok(page) = api::revocations(id, cert_id_value).await {
revocations.set(page.results);
}
rev_serial.set(String::new());
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let delete_revocation = move |rev_id: String| {
spawn_local(async move {
match api::delete_revocation(id, cert_id_value, &rev_id).await {
Ok(()) => {
if let Ok(page) = api::revocations(id, cert_id_value).await {
revocations.set(page.results);
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let make_tls_auth = move |_| {
spawn_local(async move {
match api::tls_auth(id, cert_id_value, &json!({})).await {
Ok(value) => {
if let Some(key) = value.get("key").and_then(|v| v.as_str()) {
tls_auth_text.set(key.to_string());
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let content = move || {
if loading.get() {
return spinner();
}
let Some(value) = cert.get_clone() else {
return empty_state("Certificate not found.");
};
let summary = value.summary.clone();
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") {
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" }
}
}
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" }
}
}
}
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" }
}
}
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" }
}
}
}
}
(if is_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") { "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-4") {
input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial)
}
div(class="col-md-4") {
select(class="form-select", bind:value=rev_reason) { (reason_options()) }
}
div(class="col-md-4") {
button(class="btn btn-outline-light", on:click=add_revocation) { "Add revocation" }
}
}
table(class="table table-sm mb-0") {
thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() {} } }
tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); view! { tr() { td(class="fingerprint") { (rev.serial_hex) } td() { (fmt_date(&rev.revoked_at)) } td() { (rev.reason) } td() { button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" } } } } }) }
}
}
}
}
} else {
view! {}
})
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn reason_options() -> Vec<View> {
["unspecified", "keyCompromise", "CACompromise", "affiliationChanged", "superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn"]
.iter()
.map(|reason| view! { option(value=*reason) { (*reason) } })
.collect::<Vec<View>>()
}
+727
View File
@@ -0,0 +1,727 @@
//! Certificate creation wizard and renewal form.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, whoami};
use crate::components::ui::{page_header, toast_error};
use crate::model::{CertificateDetail, CertificateSummary, KeySummary, Me, TemplateSummary};
pub fn CertificateNewPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let keys = create_signal(Vec::<KeySummary>::new());
let signers = create_signal(Vec::<CertificateSummary>::new());
let templates = create_signal(Vec::<TemplateSummary>::new());
let error = create_signal(None::<String>);
let saving = create_signal(false);
let request_id = query_param("request").unwrap_or_default();
let request_mode = !request_id.is_empty();
let request_id_for_load = request_id.clone();
let name = create_signal(String::new());
let comment = create_signal(String::new());
let signer_id = create_signal("self".to_string());
let key_id = create_signal(String::new());
let new_key = create_signal(false);
let key_type = create_signal("rsa".to_string());
let key_size = create_signal("2048".to_string());
let template_id = create_signal(String::new());
let signature_hash = create_signal("sha256".to_string());
let cn = create_signal(String::new());
let country = create_signal(String::new());
let state = create_signal(String::new());
let locality = create_signal(String::new());
let organization = create_signal(String::new());
let org_unit = create_signal(String::new());
let email = create_signal(String::new());
let ca_mode = create_signal("end".to_string());
let path_length = create_signal(String::new());
let bc_critical = create_signal(true);
let ku_digital = create_signal(true);
let ku_encipher = create_signal(false);
let ku_key_agreement = create_signal(false);
let ku_cert_sign = create_signal(false);
let ku_crl_sign = create_signal(false);
let ku_critical = create_signal(true);
let eku_server = create_signal(false);
let eku_client = create_signal(false);
let eku_code = create_signal(false);
let eku_email = create_signal(false);
let eku_time = create_signal(false);
let eku_ocsp = create_signal(false);
let eku_critical = create_signal(false);
let san_text = create_signal(String::new());
let ski = create_signal(true);
let aki = create_signal(true);
let ocsp_staple = create_signal(false);
let validity_amount = create_signal("365".to_string());
let validity_unit = create_signal("days".to_string());
let midnight = create_signal(false);
spawn_local(async move {
if let Ok(user) = api::me().await {
if me.is_alive() {
me.set(Some(user));
}
}
if let Ok(page) = api::keys(&id).await {
keys.set(page.results);
}
if let Ok(page) = api::certificates(&id).await {
signers.set(page.results.into_iter().filter(|cert| cert.is_ca && cert.has_private_key).collect());
}
if let Ok(page) = api::templates(&id).await {
templates.set(page.results);
}
if request_mode {
if let Ok(value) = api::request(&id, &request_id_for_load).await {
name.set(format!("{} certificate", value.summary.name));
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "CN") {
cn.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "O") {
organization.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "OU") {
org_unit.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "C") {
country.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "ST") {
state.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "L") {
locality.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "emailAddress") {
email.set(first.value.clone());
}
}
}
});
let apply_template = {
let id = id.clone();
move |_| {
let template = template_id.get_clone();
if template.is_empty() {
return;
}
let id = id.clone();
spawn_local(async move {
match api::template(&id, &template).await {
Ok(template) => {
if let Some(settings) = template.settings.get("extensions") {
if let Some(bc) = settings.get("basic_constraints") {
if bc.get("ca").and_then(|v| v.as_bool()).unwrap_or(false) {
ca_mode.set("ca".to_string());
if let Some(len) = bc.get("path_length").and_then(|v| v.as_i64()) {
path_length.set(len.to_string());
}
} else if bc.get("ca").is_some() {
ca_mode.set("end".to_string());
}
bc_critical.set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
}
if let Some(ku) = settings.get("key_usage") {
let usages = string_array(ku.get("usages"));
ku_digital.set(usages.iter().any(|s| s == "digital_signature"));
ku_encipher.set(usages.iter().any(|s| s == "key_encipherment"));
ku_key_agreement.set(usages.iter().any(|s| s == "key_agreement"));
ku_cert_sign.set(usages.iter().any(|s| s == "key_cert_sign"));
ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign"));
ku_critical.set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
}
if let Some(eku) = settings.get("extended_key_usage") {
let usages = string_array(eku.get("usages"));
eku_server.set(usages.iter().any(|s| s == "serverAuth"));
eku_client.set(usages.iter().any(|s| s == "clientAuth"));
eku_code.set(usages.iter().any(|s| s == "codeSigning"));
eku_email.set(usages.iter().any(|s| s == "emailProtection"));
eku_time.set(usages.iter().any(|s| s == "timeStamping"));
eku_ocsp.set(usages.iter().any(|s| s == "OCSPSigning"));
eku_critical.set(eku.get("critical").and_then(|v| v.as_bool()).unwrap_or(false));
}
if let Some(san) = settings.get("subject_alt_name") {
let entries = san.get("entries").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let text = entries.iter().filter_map(|entry| {
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 value.is_empty() { None } else { Some(format!("{}:{}", kind.to_uppercase(), value)) }
}).collect::<Vec<_>>().join(", ");
san_text.set(text);
}
ski.set(settings.get("subject_key_identifier").is_some());
aki.set(settings.get("authority_key_identifier").is_some());
}
if let Some(subject) = template.subject.as_array() {
for entry in subject {
let key = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string();
match key {
"CN" => cn.set(value),
"O" => organization.set(value),
"OU" => org_unit.set(value),
"C" => country.set(value),
"ST" => state.set(value),
"L" => locality.set(value),
"emailAddress" => email.set(value),
_ => {}
}
}
}
if let Some(validity) = template.settings.get("validity") {
if let Some(amount) = validity.get("amount").and_then(|v| v.as_i64()) {
validity_amount.set(amount.to_string());
}
if let Some(unit) = validity.get("unit").and_then(|v| v.as_str()) {
validity_unit.set(unit.to_string());
}
midnight.set(validity.get("midnight").and_then(|v| v.as_bool()).unwrap_or(false));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
}
};
let submit = {
let id = id.clone();
let request_id = request_id.clone();
move |_| {
let cert_name = name.get_clone();
if cert_name.trim().is_empty() {
error.set(Some("certificate name is required".to_string()));
return;
}
let subject = build_subject(&[
("C", country.get_clone()),
("ST", state.get_clone()),
("L", locality.get_clone()),
("O", organization.get_clone()),
("OU", org_unit.get_clone()),
("CN", cn.get_clone()),
("emailAddress", email.get_clone()),
]);
if subject.is_empty() {
error.set(Some("the subject must not be empty".to_string()));
return;
}
let mut extensions = json!({});
let mode = ca_mode.get_clone();
let crit = bc_critical.get();
if mode == "ca" {
extensions["basic_constraints"] = json!({
"critical": crit,
"ca": true,
"path_length": path_length.get_clone().parse::<i64>().ok(),
});
} else if mode == "end" {
extensions["basic_constraints"] = json!({"critical": crit, "ca": false, "path_length": null});
}
let usages = [
(ku_digital.get(), "digital_signature"),
(ku_encipher.get(), "key_encipherment"),
(ku_key_agreement.get(), "key_agreement"),
(ku_cert_sign.get(), "key_cert_sign"),
(ku_crl_sign.get(), "crl_sign"),
]
.iter()
.filter(|(checked, _)| *checked)
.map(|(_, name)| json!(name))
.collect::<Vec<_>>();
extensions["key_usage"] = json!({"critical": ku_critical.get(), "usages": usages});
let eku = [
(eku_server.get(), "serverAuth"),
(eku_client.get(), "clientAuth"),
(eku_code.get(), "codeSigning"),
(eku_email.get(), "emailProtection"),
(eku_time.get(), "timeStamping"),
(eku_ocsp.get(), "OCSPSigning"),
]
.iter()
.filter(|(checked, _)| *checked)
.map(|(_, name)| json!(name))
.collect::<Vec<_>>();
extensions["extended_key_usage"] = json!({"critical": eku_critical.get(), "usages": eku});
let san = parse_san(&san_text.get_clone());
if !san.is_empty() {
extensions["subject_alt_name"] = json!({"critical": false, "entries": san});
}
if ski.get() {
extensions["subject_key_identifier"] = json!({"critical": false});
}
if aki.get() {
extensions["authority_key_identifier"] = json!({"critical": false});
}
if ocsp_staple.get() {
extensions["tls_feature"] = json!({"critical": false, "features": ["status_request"]});
}
let signer = signer_id.get_clone();
let mut payload = json!({
"name": cert_name,
"comment": comment.get_clone(),
"subject": subject,
"extensions": extensions,
"signature_hash": signature_hash.get_clone(),
"signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) },
"validity": {
"amount": validity_amount.get_clone().parse::<i64>().unwrap_or(365),
"unit": validity_unit.get_clone(),
"midnight": midnight.get(),
},
});
if !request_id.is_empty() {
payload["request_id"] = json!(request_id);
payload["modify_subject"] = json!(true);
payload["copy_request_extensions"] = json!(false);
} else if new_key.get() {
payload["new_key"] = json!({
"key_type": key_type.get_clone(),
"size": key_size.get_clone().parse::<i64>().unwrap_or(2048),
"name": format!("{} key", name.get_clone()),
});
} else if !key_id.get_clone().is_empty() {
payload["key_id"] = json!(key_id.get_clone());
} else {
error.set(Some("select a key or generate a new one".to_string()));
return;
}
saving.set(true);
let id = id.clone();
spawn_local(async move {
match api::create_certificate(&id, &payload).await {
Ok(cert) => {
saving.set(false);
sycamore_router::navigate(&format!("/vaults/{}/certificates/{}", id, cert.summary.id));
return;
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if saving.is_alive() { saving.set(false); }
});
}
};
let signer_options = move || {
signers.get_clone().into_iter().map(|cert| view! {
option(value=cert.id.clone()) { (cert.name.clone()) }
}).collect::<Vec<View>>()
};
let key_options = move || {
keys.get_clone().into_iter().filter(|key| key.has_private).map(|key| view! {
option(value=key.id.clone()) { (key.name.clone()) }
}).collect::<Vec<View>>()
};
let template_options = move || {
templates.get_clone().into_iter().map(|template| view! {
option(value=template.id.clone()) { (if template.builtin { format!("[built-in] {}", template.name) } else { template.name.clone() }) }
}).collect::<Vec<View>>()
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(page_header(
if request_mode { "Sign certificate request" } else { "New certificate" },
"Certificates can be self-signed or issued by a CA with a private key in this vault.",
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Cancel" } },
))
(if request_mode {
view! { div(class="alert alert-info") { "The subject and public key come from the selected request; the fields below override the subject." } }
} else {
view! {}
})
div(class="card") {
div(class="card-body") {
div(class="form-section") {
div(class="form-section-title") { "Source" }
div(class="row g-3") {
div(class="col-md-6") {
label(class="form-label") { "Issuer" }
select(class="form-select", bind:value=signer_id) {
option(value="self") { "Self-signed" }
(signer_options())
}
}
div(class="col-md-6") {
label(class="form-label") { "Signature hash" }
select(class="form-select", bind:value=signature_hash) {
option(value="sha256") { "SHA-256" }
option(value="sha384") { "SHA-384" }
option(value="sha512") { "SHA-512" }
option(value="sha1") { "SHA-1" }
}
}
div(class="col-md-6") {
label(class="form-label") { "Template" }
div(class="input-group") {
select(class="form-select", bind:value=template_id) {
option(value="") { "— no template —" }
(template_options())
}
button(class="btn btn-outline-light", on:click=apply_template) { "Apply" }
}
}
div(class="col-md-6") {
label(class="form-label") { "Internal name" }
input(class="form-control", bind:value=name)
}
div(class="col-12") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="2", bind:value=comment) {}
}
}
}
div(class="form-section") {
div(class="form-section-title") { "Subject" }
div(class="row g-3") {
(subject_input("Common name (CN)", cn))
(subject_input("Organization (O)", organization))
(subject_input("Organizational unit (OU)", org_unit))
(subject_input("Country (C)", country))
(subject_input("State / province (ST)", state))
(subject_input("Locality (L)", locality))
(subject_input("E-mail address", email))
}
}
(if request_mode {
view! {}
} else {
view! {
div(class="form-section") {
div(class="form-section-title") { "Private key" }
div(class="form-check mb-2") {
input(class="form-check-input", r#type="checkbox", id="new-key", bind:checked=new_key)
label(class="form-check-label", r#for="new-key") { "Generate a new key" }
}
(if new_key.get() {
view! {
div(class="row g-3") {
div(class="col-md-4") {
label(class="form-label") { "Key type" }
select(class="form-select", bind:value=key_type) {
option(value="rsa") { "RSA" }
option(value="ec") { "EC" }
option(value="ed25519") { "ED25519" }
}
}
div(class="col-md-4") {
label(class="form-label") { "Key size" }
input(class="form-control", bind:value=key_size)
}
}
}
} else {
view! {
label(class="form-label") { "Existing key" }
select(class="form-select", bind:value=key_id) {
option(value="") { "— select a key —" }
(key_options())
}
}
})
}
}
})
div(class="form-section") {
div(class="form-section-title") { "Extensions" }
div(class="row g-3" ) {
div(class="col-md-4") {
label(class="form-label") { "Basic constraints" }
select(class="form-select", bind:value=ca_mode) {
option(value="end") { "End entity (CA:FALSE)" }
option(value="ca") { "Certification authority (CA:TRUE)" }
option(value="none") { "Not defined" }
}
}
div(class="col-md-3") {
label(class="form-label") { "Path length" }
input(class="form-control", placeholder="unlimited", bind:value=path_length)
}
div(class="col-md-3 d-flex align-items-end") {
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", bind:checked=bc_critical)
label(class="form-check-label") { "Critical" }
}
}
div(class="col-md-2 d-flex align-items-end") {
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", bind:checked=ski)
label(class="form-check-label") { "SKI" }
}
}
}
div(class="row g-3 mt-1") {
div(class="col-md-6") {
div(class="form-section-title") { "Key usage" }
(check("Digital signature", ku_digital))
(check("Key encipherment", ku_encipher))
(check("Key agreement", ku_key_agreement))
(check("Certificate sign", ku_cert_sign))
(check("CRL sign", ku_crl_sign))
(check("Critical", ku_critical))
}
div(class="col-md-6") {
div(class="form-section-title") { "Extended key usage" }
(check("TLS server", eku_server))
(check("TLS client", eku_client))
(check("Code signing", eku_code))
(check("E-mail protection", eku_email))
(check("Time stamping", eku_time))
(check("OCSP signing", eku_ocsp))
(check("Critical", eku_critical))
}
div(class="col-md-6") {
div(class="form-section-title") { "Subject alternative names" }
input(class="form-control", placeholder="DNS:example.com, IP:192.0.2.1", bind:value=san_text)
}
div(class="col-md-6") {
div(class="form-section-title") { "Key identifier / OCSP" }
(check("Authority key identifier", aki))
(check("OCSP stapling", ocsp_staple))
}
}
}
div(class="form-section") {
div(class="form-section-title") { "Validity" }
div(class="row g-3") {
div(class="col-md-3") {
label(class="form-label") { "Duration" }
input(class="form-control", bind:value=validity_amount)
}
div(class="col-md-3") {
label(class="form-label") { "Unit" }
select(class="form-select", bind:value=validity_unit) {
option(value="days") { "Days" }
option(value="months") { "Months" }
option(value="years") { "Years" }
}
}
div(class="col-md-3 d-flex align-items-end") {
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", bind:checked=midnight)
label(class="form-check-label") { "Round to midnight" }
}
}
}
}
button(class="btn btn-primary btn-lg", disabled=saving.get(), on:click=submit) {
(if saving.get() { "Issuing…" } else if request_mode { "Issue certificate" } else { "Create certificate" })
}
}
}
}
}
}
pub fn CertificateRenewPage(vault_id: String, cert_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let cert_id_value: &'static str = Box::leak(cert_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let cert = create_signal(None::<CertificateDetail>);
let error = create_signal(None::<String>);
let loading = create_signal(true);
let saving = create_signal(false);
let name = create_signal(String::new());
let amount = create_signal("365".to_string());
let unit = create_signal("days".to_string());
let keep_serial = create_signal(false);
let revoke_old = create_signal(false);
let replace_old = create_signal(false);
spawn_local(async move {
match api::me().await {
Ok(user) => {
me.set(Some(user));
match api::certificate(&id, &cert_id_value).await {
Ok(value) => {
name.set(value.summary.name.clone());
cert.set(Some(value));
}
Err(message) => error.set(Some(message)),
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
let submit = {
let id = id.clone();
let cert_id_value = cert_id_value.clone();
move |_| {
let id = id.clone();
let cert_id_value = cert_id_value.clone();
let payload = json!({
"name": name.get_clone(),
"validity": {"amount": amount.get_clone().parse::<i64>().unwrap_or(365), "unit": unit.get_clone()},
"keep_serial": keep_serial.get(),
"revoke_old": revoke_old.get(),
"replace_old": replace_old.get(),
});
saving.set(true);
spawn_local(async move {
match api::renew_certificate(&id, &cert_id_value, &payload).await {
Ok(value) => {
saving.set(false);
sycamore_router::navigate(&format!("/vaults/{}/certificates/{}", id, value.summary.id));
return;
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if saving.is_alive() { saving.set(false); }
});
}
};
let content = move || {
if loading.get() {
return crate::components::ui::spinner();
}
let Some(value) = cert.get_clone() else {
return crate::components::ui::empty_state("Certificate not found.");
};
view! {
(page_header("Renew certificate", &format!("Issued by {}", value.issuer_name), view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates/{cert_id_value}")) { "Cancel" }
}))
div(class="card") {
div(class="card-body") {
div(class="row g-3") {
div(class="col-md-8") {
label(class="form-label") { "New internal name" }
input(class="form-control", bind:value=name)
}
div(class="col-md-2") {
label(class="form-label") { "Duration" }
input(class="form-control", bind:value=amount)
}
div(class="col-md-2") {
label(class="form-label") { "Unit" }
select(class="form-select", bind:value=unit) {
option(value="days") { "Days" }
option(value="months") { "Months" }
option(value="years") { "Years" }
}
}
}
div(class="form-check mt-3") {
input(class="form-check-input", r#type="checkbox", id="keep-serial", bind:checked=keep_serial)
label(class="form-check-label", r#for="keep-serial") { "Keep serial number" }
}
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", id="revoke-old", bind:checked=revoke_old)
label(class="form-check-label", r#for="revoke-old") { "Revoke old certificate" }
}
div(class="form-check mb-3") {
input(class="form-check-input", r#type="checkbox", id="replace-old", bind:checked=replace_old)
label(class="form-check-label", r#for="replace-old") { "Replace old certificate" }
}
button(class="btn btn-primary", disabled=saving.get(), on:click=submit) {
(if saving.get() { "Renewing…" } else { "Renew certificate" })
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn subject_input(label: &'static str, value: Signal<String>) -> View {
view! {
div(class="col-md-6") {
label(class="form-label") { (label) }
input(class="form-control", bind:value=value)
}
}
}
fn check(label: &'static str, value: Signal<bool>) -> View {
view! {
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", bind:checked=value)
label(class="form-check-label") { (label) }
}
}
}
fn build_subject(fields: &[(&str, String)]) -> Vec<serde_json::Value> {
fields
.iter()
.filter(|(_, value)| !value.trim().is_empty())
.map(|(name, value)| json!({"name": name, "value": value.trim()}))
.collect()
}
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 = match prefix.trim().to_lowercase().as_str() {
"ip" => "ip",
"uri" => "uri",
"email" => "email",
"rid" => "rid",
"dns" => "dns",
_ => "dns",
};
json!({"type": kind, "value": value.trim()})
} else {
json!({"type": "dns", "value": chunk})
}
})
.collect()
}
fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
value
.and_then(|v| v.as_array())
.map(|items| items.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default()
}
fn query_param(name: &str) -> Option<String> {
let search = web_sys::window()?.location().search().ok()?;
let search = search.trim_start_matches('?');
for pair in search.split('&') {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
if key == name {
return Some(value.to_string());
}
}
None
}
+204
View File
@@ -0,0 +1,204 @@
//! Certificate list, flat or as an issuer tree.
use std::collections::HashMap;
use sycamore::prelude::*;
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::model::{fmt_date, fmt_fp, status_badge, CertificateSummary, Me};
pub fn CertificatesPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let certificates = create_signal(Vec::<CertificateSummary>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
let filter = create_signal(String::new());
let plain = create_signal(false);
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
match api::certificates(id).await {
Ok(page) => {
if certificates.is_alive() {
certificates.set(page.results);
}
}
Err(message) if message != api::LOGIN_REQUIRED => {
if error.is_alive() {
error.set(Some(message));
}
}
_ => {}
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let content = move || {
if loading.get() {
return spinner();
}
let rows = certificates.get_clone();
let query = filter.get_clone().to_lowercase();
let visible: Vec<CertificateSummary> = rows
.into_iter()
.filter(|cert| {
query.is_empty()
|| cert.name.to_lowercase().contains(&query)
|| cert.subject.to_lowercase().contains(&query)
|| cert.issuer_name.to_lowercase().contains(&query)
|| cert.serial_hex.to_lowercase().contains(&query)
|| cert.fingerprint_sha256.to_lowercase().contains(&query)
})
.collect();
let html = if plain.get() {
flat_html(&visible, id)
} else {
tree_html(&visible, id)
};
view! {
(page_header(
"Certificates",
"The tree follows issuer links; use the plain view for a flat table.",
view! {
a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/new")) { "New certificate" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" }
button(class="btn btn-outline-light", on:click=move |_| plain.set(!plain.get())) {
(if plain.get() { "Tree view" } else { "Plain view" })
}
},
))
div(class="card mb-3") {
div(class="card-body py-2") {
input(class="form-control", placeholder="Filter by name, subject, issuer, serial or fingerprint…",
bind:value=filter)
}
}
div(class="card", dangerously_set_inner_html=html) {}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
if rows.is_empty() {
return "<p class=\"empty-state\">No certificates match.</p>".to_string();
}
let mut body = String::new();
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>",
escape(vault_id),
escape(&cert.id),
escape(&cert.name),
escape(&cert.subject),
escape(&cert.issuer_name),
if cert.is_ca { "CA" } else { "end entity" },
escape(&cert.serial_hex),
fmt_date(&cert.not_after),
badge_class,
badge_label,
if cert.has_private_key { " <span class=\"badge badge-soft\">key</span>" } else { "" },
fmt_fp(&cert.fingerprint_sha256),
));
}
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>"
)
}
fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String {
if rows.is_empty() {
return "<p class=\"empty-state\">No certificates match.</p>".to_string();
}
let mut children: HashMap<String, Vec<CertificateSummary>> = HashMap::new();
let mut roots: Vec<CertificateSummary> = Vec::new();
let ids: Vec<String> = rows.iter().map(|cert| cert.id.clone()).collect();
for cert in rows {
match &cert.issuer_id {
Some(issuer) if ids.contains(issuer) && issuer != &cert.id => {
children.entry(issuer.clone()).or_default().push(cert.clone());
}
_ => roots.push(cert.clone()),
}
}
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("</ul></div>");
html
}
fn node_html(
cert: &CertificateSummary,
children: &HashMap<String, Vec<CertificateSummary>>,
vault_id: &str,
) -> String {
let (badge_class, badge_label) = status_badge(cert);
let sub = if cert.subject == cert.issuer_name {
"self-signed".to_string()
} else {
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>",
escape(vault_id),
escape(&cert.id),
escape(&cert.name),
if cert.is_ca { " <span class=\"badge badge-ca\">CA</span>" } else { "" },
badge_class,
badge_label,
escape(&sub),
fmt_date(&cert.not_after),
fmt_fp(&cert.fingerprint_sha256),
);
let kids = children.get(&cert.id);
if let Some(kids) = kids {
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>()
);
} else {
row = format!("<li>{row}</li>");
}
} else {
row = format!("<li>{row}</li>");
}
row
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+162
View File
@@ -0,0 +1,162 @@
//! CRL detail.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{empty_state, page_header, toast_error};
use crate::dom;
use crate::model::{fmt_date, CrlDetail, CrlRevocation, ExtensionRow, Me};
pub fn CrlDetailPage(vault_id: String, crl_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let crl_id_value: &'static str = Box::leak(crl_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let crl = create_signal(None::<CrlDetail>);
let revocations = create_signal(Vec::<CrlRevocation>::new());
let extensions = create_signal(Vec::<ExtensionRow>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
let format = create_signal("pem".to_string());
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
match api::crl(id, crl_id_value).await {
Ok(value) => {
revocations.set(value.revocations.clone());
extensions.set(value.extensions.clone());
crl.set(Some(value));
}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
let export = move |_| {
let fmt = format.get_clone();
spawn_local(async move {
match api::export_crl(id, crl_id_value, &json!({"format": fmt})).await {
Ok(payload) => {
if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) {
error.set(Some(message));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let remove = move |_| {
if !dom::confirm("Delete this revocation list?") {
return;
}
spawn_local(async move {
match api::delete_crl(id, crl_id_value).await {
Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/crls")),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let content = move || {
if loading.get() {
return spinner();
}
let Some(value) = crl.get_clone() else {
return empty_state("CRL not found.");
};
let summary = value.summary.clone();
let name = summary.name.clone();
let number = summary.crl_number.map(|n| n.to_string()).unwrap_or_else(|| "—".to_string());
let issuer = summary.issuer_name.clone();
let last = fmt_date(&summary.last_update);
let next = fmt_date(&summary.next_update);
let sig = summary.signature_algorithm.clone();
let revoked_count = summary.revoked_count;
view! {
(page_header(name.clone(), format!("CRL number {number} · {revoked_count} revoked"), view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/crls")) { "Back" }
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
}))
div(class="row g-3") {
div(class="col-12 col-lg-4") {
div(class="card mb-3") {
div(class="card-header") { "Properties" }
div(class="card-body") {
div(class="py-1") { div(class="small text-secondary") { "Issuer" } div(class="mono") { (issuer) } }
div(class="py-1") { div(class="small text-secondary") { "Last update" } div() { (last) } }
div(class="py-1") { div(class="small text-secondary") { "Next update" } div() { (next) } }
div(class="py-1") { div(class="small text-secondary") { "Signature" } div() { (sig) } }
div(class="py-1") { div(class="small text-secondary") { "Revoked" } div() { (revoked_count) } }
}
}
div(class="card") {
div(class="card-header") { "Export" }
div(class="card-body") {
select(class="form-select mb-2", bind:value=format) {
option(value="pem") { "PEM" }
option(value="der") { "DER" }
option(value="vcalendar") { "vCalendar" }
}
button(class="btn btn-primary", on:click=export) { "Download" }
}
}
}
div(class="col-12 col-lg-8") {
div(class="card mb-3") {
div(class="card-header") { "Revoked certificates" }
div(class="table-responsive") {
table(class="table table-sm mb-0") {
thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() { "Invalid since" } } }
tbody() {
Indexed(list=revocations, view=|rev| view! {
tr() {
td(class="fingerprint") { (rev.serial_hex.clone()) }
td() { (fmt_date(&Some(rev.revoked_at.clone()))) }
td() { (rev.reason.clone().unwrap_or_else(|| "unspecified".to_string())) }
td() { (rev.invalidity_date.clone().map(|v| fmt_date(&Some(v))).unwrap_or_else(|| "—".to_string())) }
}
})
}
}
}
}
div(class="card") {
div(class="card-header") { "Extensions" }
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.clone()) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) }
td(class="extension-value") { (ext.value.clone()) }
}
})
}
}
}
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Revocation list overview.
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{empty_state, page_header, toast_error};
use crate::model::{fmt_date, CrlSummary, Me};
pub fn CrlsPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let crls = create_signal(Vec::<CrlSummary>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
match api::crls(&id).await {
Ok(page) => {
if crls.is_alive() {
crls.set(page.results);
}
}
Err(message) if message != api::LOGIN_REQUIRED => {
if error.is_alive() {
error.set(Some(message));
}
}
_ => {}
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let content = move || {
if loading.get() {
return spinner();
}
let is_empty = crls.get_clone().is_empty();
let table = if is_empty {
view! { div(class="card") { div(class="card-body") { (empty_state("No CRLs yet. Generate one from a CA certificate.")) } } }
} else {
let html = crls.get_clone().into_iter().map(|crl| {
format!(
"<tr><td><a href=\"/vaults/{}/crls/{}\">{}</a><div class=\"item-sub\">{}</div></td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
id,
escape(&crl.id),
escape(&crl.name),
escape(&crl.issuer_name),
crl.crl_number.map(|n| n.to_string()).unwrap_or_else(|| "—".to_string()),
crl.revoked_count,
fmt_date(&crl.last_update),
fmt_date(&crl.next_update),
)
}).collect::<String>();
view! {
div(class="card") {
div(class="table-responsive") {
table(class="table table-hover align-middle mb-0") {
thead() { tr() { th() { "Name" } th() { "Number" } th() { "Revoked" } th() { "Last update" } th() { "Next update" } } }
tbody(dangerously_set_inner_html=html) {}
}
}
}
}
};
view! {
(page_header(
"Revocation lists",
"CRLs are generated from a CA certificate's revocations; importing a CRL merges its serials.",
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Choose a CA" } },
))
(table)
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+206
View File
@@ -0,0 +1,206 @@
//! Vault list and creation.
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{empty_state, page_header, toast_error};
use crate::dom;
use crate::model::{item_total, Me, VaultSummary};
#[component]
pub fn DashboardPage() -> View {
let me = create_signal(None::<Me>);
let vaults = create_signal(Vec::<VaultSummary>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
let show_form = create_signal(false);
let name = create_signal(String::new());
let description = create_signal(String::new());
let saving = create_signal(false);
let reload = move || {
spawn_local(async move {
match api::vaults().await {
Ok(page) => {
if vaults.is_alive() {
vaults.set(page.results);
}
}
Err(message) if message != api::LOGIN_REQUIRED => {
if error.is_alive() {
error.set(Some(message));
}
}
_ => {}
}
});
};
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
reload();
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let create = move |_| {
let vault_name = name.get_clone();
let vault_description = description.get_clone();
if vault_name.trim().is_empty() {
error.set(Some("vault name is required".to_string()));
return;
}
saving.set(true);
spawn_local(async move {
match api::create_vault(&vault_name, &vault_description).await {
Ok(_) => {
name.set(String::new());
description.set(String::new());
show_form.set(false);
reload();
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
saving.set(false);
});
};
let content = move || {
if loading.get() {
return spinner();
}
let rows = vaults.get_clone();
let is_empty = rows.is_empty();
let cards: Vec<View> = rows
.into_iter()
.map(|vault| {
let id = vault.id.clone();
let name = vault.name.clone();
let description = vault.description.clone();
let total = item_total(&vault.counts);
let expiring = vault.expiring_soon;
let counts = vault.counts.clone();
let delete_id = vault.id.clone();
let delete_name = vault.name.clone();
let remove = move |_| {
if !dom::confirm(&format!("Delete the vault '{}' and everything in it?", delete_name)) {
return;
}
let id = delete_id.clone();
spawn_local(async move {
match api::delete_vault(&id).await {
Ok(()) => reload(),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
view! {
div(class="col-12 col-md-6 col-xl-4") {
div(class="card vault-card") {
div(class="card-body d-flex flex-column") {
div(class="d-flex justify-content-between align-items-start gap-2") {
div() {
a(class="h5 stretched-link text-decoration-none", href=format!("/vaults/{id}")) { (name) }
div(class="item-sub") { (description) }
}
button(class="btn btn-sm btn-outline-danger position-relative",
style="z-index: 2;",
on:click=remove) { "Delete" }
}
div(class="row g-2 mt-3") {
div(class="col-4") { div(class="stat-number") { (counts.certificates) } div(class="stat-label") { "Certs" } }
div(class="col-4") { div(class="stat-number") { (counts.keys) } div(class="stat-label") { "Keys" } }
div(class="col-4") { div(class="stat-number") { (total) } div(class="stat-label") { "Items" } }
}
(if expiring > 0 {
view! { div(class="badge badge-warn mt-3") { (expiring) " expiring within 30 days" } }
} else {
view! { div(class="text-secondary small mt-3") { "No certificates expiring soon" } }
})
}
}
}
}
})
.collect();
let empty = if is_empty {
view! { div(class="card") { div(class="card-body") { (empty_state("No vaults yet. Create one to start managing keys and certificates.")) } } }
} else {
view! {}
};
let form = if show_form.get() {
view! {
div(class="card mb-4") {
div(class="card-body") {
h2(class="h5 mb-3") { "New vault" }
div(class="mb-2") {
label(class="form-label") { "Name" }
input(class="form-control", placeholder="Production PKI", bind:value=name)
}
div(class="mb-3") {
label(class="form-label") { "Description" }
textarea(class="form-control", rows="2", bind:value=description) {}
}
button(class="btn btn-primary", disabled=saving.get(), on:click=create) {
(if saving.get() { "Creating…" } else { "Create vault" })
}
button(class="btn btn-outline-light ms-2", on:click=move |_| show_form.set(false)) { "Cancel" }
}
}
}
} else {
view! {}
};
view! {
(page_header(
"Vaults",
"Each vault is a private XCA-style database of keys, certificates, requests, CRLs and templates.",
view! {
button(class="btn btn-primary", on:click=move |_| show_form.set(true)) { "New vault" }
a(class="btn btn-outline-light", href="/tokens") { "API tokens" }
},
))
(form)
(empty)
div(class="row g-3") { (cards) }
(if me.get_clone().map(|u| u.master_key_is_dev).unwrap_or(false) {
view! {
div(class="alert alert-info mt-4 mb-0") {
"This server uses the built-in development master key. Set KCA_MASTER_KEY before storing real private keys."
}
}
} else {
view! {}
})
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", None, nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Import files, PEM bundles and XCA databases.
use serde_json::json;
use sycamore::prelude::*;
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::{ImportItem, Me};
pub fn ImportPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let error = create_signal(None::<String>);
let info = create_signal(None::<String>);
let loading = create_signal(true);
let busy = create_signal(false);
let filename = create_signal(String::new());
let content_base64 = create_signal(String::new());
let pem_text = create_signal(String::new());
let password = create_signal(String::new());
let xca_db = create_signal(false);
let preview_text = create_signal(String::new());
let result_text = create_signal(String::new());
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
loading.set(false);
});
let choose_file = move |event: web_sys::Event| {
let Some(file) = dom::input_file(&event) else { return };
let name = file.name();
xca_db.set(name.to_lowercase().ends_with(".xdb"));
filename.set(name);
preview_text.set(String::new());
result_text.set(String::new());
content_base64.set(String::new());
spawn_local(async move {
match dom::read_file_base64(&file).await {
Ok(value) => content_base64.set(value),
Err(message) => error.set(Some(message)),
}
});
};
let build_payload = move |dry_run: bool| {
let mut payload = json!({
"filename": if filename.get_clone().is_empty() { "paste.pem".to_string() } else { filename.get_clone() },
"password": if password.get_clone().is_empty() { serde_json::Value::Null } else { json!(password.get_clone()) },
"dry_run": dry_run,
"xca_db": xca_db.get(),
});
if !pem_text.get_clone().trim().is_empty() {
payload["pem"] = json!(pem_text.get_clone());
} else if !content_base64.get_clone().is_empty() {
payload["content_base64"] = json!(content_base64.get_clone());
}
payload
};
let dry_run = move |_| {
let payload = build_payload(true);
if payload.get("pem").is_none() && payload.get("content_base64").is_none() {
error.set(Some("choose a file or paste PEM text".to_string()));
return;
}
busy.set(true);
preview_text.set(String::new());
result_text.set(String::new());
spawn_local(async move {
match api::import_items(id, &payload).await {
Ok(value) => preview_text.set(preview_lines(&value.items)),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
busy.set(false);
});
};
let commit = move |_| {
let payload = build_payload(false);
if payload.get("pem").is_none() && payload.get("content_base64").is_none() {
error.set(Some("choose a file or paste PEM text".to_string()));
return;
}
busy.set(true);
result_text.set(String::new());
spawn_local(async move {
match api::import_commit(id, &payload).await {
Ok(value) => {
info.set(Some(format!("Imported {} item(s), skipped {}.", value.imported.len(), value.skipped.len())));
result_text.set(import_result_lines(&value.imported, &value.skipped));
preview_text.set(String::new());
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
busy.set(false);
});
};
let content = move || {
if loading.get() {
return spinner();
}
view! {
(page_header(
"Import",
"PEM bundles, DER items, PKCS#7, PKCS#12, SSH keys, XCA templates and XCA .xdb databases.",
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}")) { "Back" } },
))
(if let Some(message) = info.get_clone() {
view! { div(class="alert alert-success") { (message) } }
} else {
view! {}
})
div(class="row g-3") {
div(class="col-12 col-lg-5") {
div(class="card mb-3") {
div(class="card-header") { "File" }
div(class="card-body") {
input(class="form-control mb-2", r#type="file", on:change=choose_file)
(if filename.get_clone().is_empty() {
view! {}
} else {
view! { div(class="text-secondary small") { (filename.get_clone()) } }
})
div(class="form-check mt-2") {
input(class="form-check-input", r#type="checkbox", id="xca-db", bind:checked=xca_db)
label(class="form-check-label", r#for="xca-db") { "This is an XCA .xdb database" }
}
div(class="mt-2") {
label(class="form-label") { "Password (encrypted keys / PKCS#12 / XCA database)" }
input(class="form-control", r#type="password", bind:value=password)
}
}
}
div(class="card mb-3") {
div(class="card-header") { "Paste PEM" }
div(class="card-body") {
textarea(class="form-control", rows="8", placeholder="-----BEGIN CERTIFICATE-----", bind:value=pem_text) {}
}
}
div(class="d-flex gap-2") {
button(class="btn btn-outline-light", disabled=busy.get(), on:click=dry_run) { "Preview" }
button(class="btn btn-primary", disabled=busy.get(), on:click=commit) { "Import" }
}
}
div(class="col-12 col-lg-7") {
div(class="card mb-3") {
div(class="card-header") { "Preview" }
div(class="card-body") {
pre(class="pem mb-0") { (preview_text.get_clone()) }
}
}
div(class="card") {
div(class="card-header") { "Result" }
div(class="card-body") {
pre(class="pem mb-0") { (result_text.get_clone()) }
}
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn preview_lines(items: &[ImportItem]) -> String {
items.iter().map(|item| {
format!(
"[{}] {} {}{}{}",
item.index,
item.kind,
item.name,
if item.is_private { " (private)" } else { "" },
if item.duplicate { " [duplicate]" } else { "" },
)
}).collect::<Vec<_>>().join("\n")
}
fn import_result_lines(imported: &[ImportItem], skipped: &[ImportItem]) -> String {
let mut lines = Vec::new();
for item in imported {
lines.push(format!("imported {} {}", item.kind, item.name));
}
for item in skipped {
lines.push(format!("skipped {} {} ({})", item.kind, item.name, item.summary));
}
lines.join("\n")
}
+208
View File
@@ -0,0 +1,208 @@
//! Key detail, rename, export and delete.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{copy_button, page_header, toast_error};
use crate::dom;
use crate::model::{fmt_fp, KeyDetail, Me};
const KEY_FORMATS: &[(&str, &str, bool)] = &[
("pem-public", "PEM public", false),
("pem-private", "PEM private", true),
("pem-encrypted", "PEM encrypted", true),
("pkcs8", "PKCS#8", true),
("pkcs8-encrypted", "PKCS#8 encrypted", true),
("der-public", "DER public", false),
("der-private", "DER private", true),
("ssh-public", "SSH2 public", false),
("ssh-private", "SSH2 private", true),
("jwk-public", "JWK public", false),
("jwk-private", "JWK private", true),
("pvk", "PVK private", true),
];
pub fn KeyDetailPage(vault_id: String, key_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let key_id_value: &'static str = Box::leak(key_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let key = create_signal(None::<KeyDetail>);
let error = create_signal(None::<String>);
let loading = create_signal(true);
let name = create_signal(String::new());
let comment = create_signal(String::new());
let format = create_signal("pem-public".to_string());
let password = create_signal(String::new());
let header_sub = create_signal(String::new());
let public_pem = create_signal(String::new());
let name_value = create_signal(String::new());
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
match api::key(id, key_id_value).await {
Ok(value) => {
name.set(value.summary.name.clone());
comment.set(value.summary.comment.clone());
name_value.set(value.summary.name.clone());
header_sub.set(format!("{}{}", value.summary.key_type, value.summary.key_size.map(|s| format!(" · {s} bits")).unwrap_or_default()));
public_pem.set(value.public_pem.clone());
key.set(Some(value));
}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
let save = move |_| {
let value = key.get_clone();
let Some(value) = value else { return };
let payload = json!({"name": name.get_clone(), "comment": comment.get_clone(), "version": value.summary.version});
spawn_local(async move {
match api::update_key(id, key_id_value, &payload).await {
Ok(updated) => {
name.set(updated.summary.name.clone());
comment.set(updated.summary.comment.clone());
key.set(Some(updated));
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let export = move |_| {
let mut payload = json!({"format": format.get_clone()});
if !password.get_clone().is_empty() {
payload["password"] = json!(password.get_clone());
}
spawn_local(async move {
match api::export_key(id, key_id_value, &payload).await {
Ok(export) => {
if let Err(message) = dom::download_base64(&export.filename, &export.content_type, &export.content_base64) {
error.set(Some(message));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let remove = move |_| {
if !dom::confirm("Delete this key? Certificates and requests referencing it keep their data but lose the key link.") {
return;
}
spawn_local(async move {
match api::delete_key(id, key_id_value).await {
Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/keys")),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let content = move || {
if loading.get() {
return spinner();
}
let Some(value) = key.get_clone() else {
return crate::components::ui::empty_state("Key not found.");
};
let summary = value.summary.clone();
let key_type = summary.key_type.clone();
let key_size = summary.key_size.map(|s| format!("{s} bits")).unwrap_or_else(|| "—".to_string());
let curve = summary.curve.clone().unwrap_or_else(|| "—".to_string());
let protection = if summary.has_private { "private key, encrypted at rest" } else { "public only" };
let use_count = summary.use_count;
let fingerprint = fmt_fp(&summary.fingerprint_sha256);
let source = summary.source.clone();
let has_private = summary.has_private;
let formats = KEY_FORMATS.iter().map(|(value, label, needs_private)| {
view! { option(value=*value, disabled=*needs_private && !has_private) { (*label) } }
}).collect::<Vec<View>>();
view! {
(page_header(name_value.get_clone(), header_sub.get_clone(), view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/keys")) { "Back" }
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
}))
div(class="row g-3") {
div(class="col-12 col-lg-7") {
div(class="card mb-3") {
div(class="card-header") { "Properties" }
div(class="card-body") {
table(class="table table-sm mb-0") {
tbody() {
tr() { td(class="text-secondary") { "Type" } td() { (key_type) } }
tr() { td(class="text-secondary") { "Size" } td() { (key_size) } }
tr() { td(class="text-secondary") { "Curve" } td() { (curve) } }
tr() { td(class="text-secondary") { "Protection" } td() { (protection) } }
tr() { td(class="text-secondary") { "Used by" } td() { (use_count) " certificate(s)/request(s)" } }
tr() { td(class="text-secondary") { "SHA-256" } td(class="fingerprint") { (fingerprint) } }
tr() { td(class="text-secondary") { "Source" } td() { (source) } }
}
}
}
}
div(class="card") {
div(class="card-header") { "Public key" }
div(class="card-body") {
(copy_button(public_pem.get_clone(), "Copy PEM"))
pre(class="pem mt-2") { (public_pem.get_clone()) }
}
}
}
div(class="col-12 col-lg-5") {
div(class="card mb-3") {
div(class="card-header") { "Edit metadata" }
div(class="card-body") {
div(class="mb-2") {
label(class="form-label") { "Name" }
input(class="form-control", bind:value=name)
}
div(class="mb-2") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="3", bind:value=comment) {}
}
button(class="btn btn-primary", on:click=save) { "Save" }
}
}
div(class="card") {
div(class="card-header") { "Export" }
div(class="card-body") {
div(class="mb-2") {
label(class="form-label") { "Format" }
select(class="form-select", bind:value=format) { (formats) }
}
div(class="mb-3") {
label(class="form-label") { "Password (encrypted formats)" }
input(class="form-control", r#type="password", bind:value=password)
}
button(class="btn btn-primary", on:click=export) { "Download" }
}
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn value_private(summary: &crate::model::KeySummary) -> bool {
summary.has_private
}
+152
View File
@@ -0,0 +1,152 @@
//! Generate a key.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, whoami};
use crate::components::ui::{page_header, toast_error};
use crate::model::Me;
const RSA_SIZES: &[&str] = &["1024", "2048", "3072", "4096", "8192"];
const DSA_SIZES: &[&str] = &["1024", "2048", "3072"];
const CURVES: &[&str] = &[
"secp256r1",
"secp384r1",
"secp521r1",
"secp256k1",
"brainpoolP256r1",
"brainpoolP384r1",
"brainpoolP512r1",
];
pub fn KeyNewPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let error = create_signal(None::<String>);
let name = create_signal(String::new());
let comment = create_signal(String::new());
let key_type = create_signal("rsa".to_string());
let size = create_signal("2048".to_string());
let curve = create_signal("secp256r1".to_string());
let saving = create_signal(false);
spawn_local(async move {
if let Ok(user) = api::me().await {
if me.is_alive() {
me.set(Some(user));
}
}
});
let submit = move |_| {
let key_name = name.get_clone();
if key_name.trim().is_empty() {
error.set(Some("key name is required".to_string()));
return;
}
let key_type_value = key_type.get_clone();
let mut payload = json!({
"name": key_name,
"comment": comment.get_clone(),
"key_type": key_type_value,
});
if key_type_value == "ec" {
payload["curve"] = json!(curve.get_clone());
} else if key_type_value == "rsa" || key_type_value == "dsa" {
payload["size"] = json!(size.get_clone().parse::<i64>().unwrap_or(2048));
}
saving.set(true);
let id = id.clone();
spawn_local(async move {
match api::generate_key(&id, &payload).await {
Ok(key) => {
saving.set(false);
sycamore_router::navigate(&format!("/vaults/{}/keys/{}", id, key.summary.id));
return;
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if saving.is_alive() {
saving.set(false);
}
});
};
let type_options = move || {
["rsa", "dsa", "ec", "ed25519"]
.iter()
.map(|value| {
view! {
option(value=*value) {
(if *value == "ed25519" { "ED25519".to_string() } else { value.to_uppercase() })
}
}
})
.collect::<Vec<View>>()
};
let parameter_input = move || {
let kind = key_type.get_clone();
if kind == "ec" {
view! {
label(class="form-label") { "Curve" }
select(class="form-select", bind:value=curve) {
(CURVES.iter().map(|value| view! {
option(value=*value) { (*value) }
}).collect::<Vec<View>>())
}
}
} else if kind == "ed25519" {
view! { div(class="alert alert-info py-2 mb-0") { "Ed25519 keys have a fixed 256-bit size." } }
} else {
let sizes = if kind == "dsa" { DSA_SIZES } else { RSA_SIZES };
view! {
label(class="form-label") { "Key size (bits)" }
select(class="form-select", bind:value=size) {
(sizes.iter().map(|value| view! {
option(value=*value) { (*value) }
}).collect::<Vec<View>>())
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(page_header("New key", "Generate a key and store it encrypted in this vault.", view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/keys")) { "Cancel" }
}))
div(class="card") {
div(class="card-body") {
div(class="mb-3") {
label(class="form-label") { "Internal name" }
input(class="form-control", placeholder="webserver-key", bind:value=name)
}
div(class="row g-3 mb-3") {
div(class="col-md-4") {
label(class="form-label") { "Key type" }
select(class="form-select", bind:value=key_type) { (type_options()) }
}
div(class="col-md-4") { (move || parameter_input()) }
}
div(class="mb-3") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="2", bind:value=comment) {}
}
button(class="btn btn-primary", disabled=saving.get(), on:click=submit) {
(if saving.get() { "Generating…" } else { "Generate key" })
}
}
}
}
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Key list.
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{empty_state, page_header, toast_error};
use crate::model::{fmt_fp, KeySummary, Me};
pub fn KeysPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let keys = create_signal(Vec::<KeySummary>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
let reload = move || {
let id = id.clone();
spawn_local(async move {
match api::keys(&id).await {
Ok(page) => {
if keys.is_alive() {
keys.set(page.results);
}
}
Err(message) if message != api::LOGIN_REQUIRED => {
if error.is_alive() {
error.set(Some(message));
}
}
_ => {}
}
});
};
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
reload();
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let content = move || {
if loading.get() {
return spinner();
}
let is_empty = keys.get_clone().is_empty();
let html = keys.get_clone().into_iter().map(|key| {
format!(
"<tr><td><a href=\"/vaults/{}/keys/{}\">{}</a><div class=\"item-sub\">{}</div></td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td class=\"fingerprint\">{}</td></tr>",
id,
escape(&key.id),
escape(&key.name),
escape(&key.comment),
escape(&key.key_type),
key.key_size.map(|v| v.to_string()).unwrap_or_else(|| "—".to_string()),
escape(key.curve.as_deref().unwrap_or("—")),
if key.has_private { "private" } else { "public" },
key.use_count,
fmt_fp(&key.fingerprint_sha256),
)
}).collect::<String>();
let table = if is_empty {
view! { div(class="card") { div(class="card-body") { (empty_state("No keys yet. Generate one or import a PEM/DER key.")) } } }
} else {
view! {
div(class="card") {
div(class="table-responsive") {
table(class="table table-hover align-middle mb-0") {
thead() { tr() { th() { "Name" } th() { "Type" } th() { "Size" } th() { "Curve" } th() { "Protection" } th() { "Use" } th() { "SHA-256" } } }
tbody(dangerously_set_inner_html=html) {}
}
}
}
}
};
view! {
(page_header(
"Private keys",
"Keys are encrypted with the server master key. Private material never leaves the server except through an explicit export.",
view! {
a(class="btn btn-primary", href=format!("/vaults/{id}/keys/new")) { "New key" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" }
},
))
(table)
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+72
View File
@@ -0,0 +1,72 @@
//! OIDC provider choice.
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::app_nav;
use crate::components::ui::{spinner, toast_error};
use crate::model::AuthProvider;
#[component]
pub fn LoginPage() -> View {
let providers = create_signal(Vec::<AuthProvider>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
spawn_local(async move {
match api::auth_providers().await {
Ok(page) => {
if providers.is_alive() {
providers.set(page.providers);
}
}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let content = move || {
if loading.get() {
return spinner();
}
let buttons: Vec<View> = providers
.get_clone()
.into_iter()
.map(|provider| {
view! {
a(class="btn btn-primary btn-lg w-100 mb-2", href=provider.login_url, rel="external") {
"Sign in with " (provider.name)
}
}
})
.collect();
view! {
div(class="card mx-auto", style="max-width: 28rem;") {
div(class="card-body p-4") {
h1(class="h4 mb-2") { "Sign in to KCA" }
p(class="text-secondary") {
"KCA manages private keys and certificates. Sign in with your configured identity provider."
}
(buttons)
p(class="text-secondary small mb-0 mt-3") {
"The development stack accepts any username at the mock provider."
}
}
}
}
};
view! {
(app_nav("/login", None, || view! {}))
main(class="container page-shell py-5") {
(toast_error(error))
(content)
}
}
}
+21
View File
@@ -0,0 +1,21 @@
pub mod certificate_detail;
pub mod certificate_new;
pub mod certificates;
pub mod crl_detail;
pub mod crls;
pub mod dashboard;
pub mod import;
pub mod key_detail;
pub mod key_new;
pub mod keys;
pub mod login;
pub mod not_found;
pub mod request_detail;
pub mod request_new;
pub mod requests;
pub mod search;
pub mod settings;
pub mod template_editor;
pub mod templates;
pub mod tokens;
pub mod vault;
+19
View File
@@ -0,0 +1,19 @@
use sycamore::prelude::*;
use crate::components::nav::app_nav;
#[component]
pub fn NotFoundPage() -> View {
view! {
(app_nav("", None, || view! {}))
main(class="container page-shell py-5") {
div(class="card") {
div(class="card-body p-4") {
h1(class="h3") { "Page not found" }
p(class="text-secondary") { "The page you requested does not exist." }
a(class="btn btn-primary", href="/") { "Back to vaults" }
}
}
}
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Certificate signing request detail.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{empty_state, page_header, toast_error};
use crate::dom;
use crate::model::{fmt_fp, Me, RequestDetail};
pub fn RequestDetailPage(vault_id: String, request_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let req_id: &'static str = Box::leak(request_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let request = create_signal(None::<RequestDetail>);
let error = create_signal(None::<String>);
let loading = create_signal(true);
let name = create_signal(String::new());
let comment = create_signal(String::new());
let request_name = create_signal(String::new());
let request_subject = create_signal(String::new());
let signature_label = create_signal(String::new());
let export_format = create_signal("pem".to_string());
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
match api::request(id, req_id).await {
Ok(value) => {
name.set(value.summary.name.clone());
comment.set(value.summary.comment.clone());
request_name.set(value.summary.name.clone());
request_subject.set(value.summary.subject.clone());
signature_label.set(if value.signature_valid { "PKCS#10 signature valid".to_string() } else { "PKCS#10 signature INVALID".to_string() });
request.set(Some(value));
}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
let save = move |_| {
let value = request.get_clone();
let Some(value) = value else { return };
let payload = json!({"name": name.get_clone(), "comment": comment.get_clone(), "version": value.summary.version});
spawn_local(async move {
match api::update_request(id, req_id, &payload).await {
Ok(updated) => {
request_name.set(updated.summary.name.clone());
request.set(Some(updated));
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let export = move |_| {
let fmt = export_format.get_clone();
spawn_local(async move {
match api::export_request(id, req_id, &json!({"format": fmt})).await {
Ok(payload) => {
if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) {
error.set(Some(message));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let remove = move |_| {
if !dom::confirm("Delete this certificate signing request?") {
return;
}
spawn_local(async move {
match api::delete_request(id, req_id).await {
Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/requests")),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let extract = move |_| {
spawn_local(async move {
match api::transform_request(id, req_id, "open-key").await {
Ok(value) => {
if let Some(key_id) = value.get("id").and_then(|v| v.as_str()) {
sycamore_router::navigate(&format!("/vaults/{id}/keys/{key_id}"));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let to_template = move |_| {
spawn_local(async move {
match api::transform_request(id, req_id, "template").await {
Ok(value) => {
if let Some(template_id) = value.get("id").and_then(|v| v.as_str()) {
sycamore_router::navigate(&format!("/vaults/{id}/templates/{template_id}"));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let content = move || {
if loading.get() {
return spinner();
}
let Some(value) = request.get_clone() else {
return empty_state("Request not found.");
};
let mut html = String::new();
html.push_str("<table class=\"table table-sm mb-0\"><tbody>");
html.push_str(&format!("<tr><td class=\"text-secondary\">Subject</td><td class=\"mono\">{}</td></tr>", escape(&value.subject_rfc4514)));
html.push_str(&format!("<tr><td class=\"text-secondary\">Signature</td><td>{}</td></tr>", escape(&value.signature_algorithm)));
html.push_str(&format!("<tr><td class=\"text-secondary\">Fingerprint</td><td class=\"fingerprint\">{}</td></tr>", fmt_fp(&value.summary.fingerprint_sha256)));
html.push_str(&format!("<tr><td class=\"text-secondary\">Certificates issued</td><td>{}</td></tr>", value.summary.certificate_count));
html.push_str("</tbody></table>");
let mut subject_html = String::new();
for entry in &value.subject_entries {
subject_html.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>", escape(&entry.name), escape(&entry.value)));
}
let mut ext_html = String::new();
for ext in &value.extensions {
ext_html.push_str(&format!(
"<tr><td>{}{}</td><td class=\"extension-value\">{}</td></tr>",
escape(&ext.name),
if ext.critical { " <span class=\"badge badge-critical\">critical</span>" } else { "" },
escape(&ext.value),
));
}
let mut attr_html = String::new();
for attr in &value.attributes {
attr_html.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>", escape(&attr.name), escape(&attr.values.join(", "))));
}
view! {
(page_header(request_name.get_clone(), request_subject.get_clone(), view! {
a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/new?request={req_id}")) { "Sign request" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/requests")) { "Back" }
button(class="btn btn-outline-danger", on:click=remove) { "Delete" }
}))
div(class="alert alert-info") { (signature_label.get_clone()) }
div(class="row g-3") {
div(class="col-12 col-lg-7") {
div(class="card mb-3") {
div(class="card-header") { "Request" }
div(class="card-body", dangerously_set_inner_html=html) {}
}
div(class="card mb-3") {
div(class="card-header") { "Subject" }
table(class="table table-sm mb-0") { tbody(dangerously_set_inner_html=subject_html) {} }
}
div(class="card mb-3") {
div(class="card-header") { "Requested extensions" }
div(class="table-responsive") {
table(class="table table-sm ext-table mb-0") {
thead() { tr() { th() { "Extension" } th() { "Value" } } }
tbody(dangerously_set_inner_html=ext_html) {}
}
}
}
div(class="card") {
div(class="card-header") { "Attributes" }
table(class="table table-sm mb-0") { tbody(dangerously_set_inner_html=attr_html) {} }
}
}
div(class="col-12 col-lg-5") {
div(class="card mb-3") {
div(class="card-header") { "Edit" }
div(class="card-body") {
div(class="mb-2") {
label(class="form-label") { "Name" }
input(class="form-control", bind:value=name)
}
div(class="mb-2") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="3", bind:value=comment) {}
}
button(class="btn btn-primary", on:click=save) { "Save" }
}
}
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="der") { "DER" }
option(value="openssl-config") { "OpenSSL config" }
}
button(class="btn btn-primary", on:click=export) { "Download" }
}
}
div(class="card") {
div(class="card-header") { "Transform" }
div(class="card-body d-grid gap-2") {
button(class="btn btn-outline-light", on:click=extract) { "Extract public key" }
button(class="btn btn-outline-light", on:click=to_template) { "Create template" }
}
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+322
View File
@@ -0,0 +1,322 @@
//! Create a PKCS#10 certificate signing request.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, whoami};
use crate::components::ui::{page_header, toast_error};
use crate::model::{KeySummary, Me};
pub fn RequestNewPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let keys = create_signal(Vec::<KeySummary>::new());
let error = create_signal(None::<String>);
let saving = create_signal(false);
let name = create_signal(String::new());
let comment = create_signal(String::new());
let key_id = create_signal(String::new());
let new_key = create_signal(false);
let key_type = create_signal("rsa".to_string());
let key_size = create_signal("2048".to_string());
let signature_hash = create_signal("sha256".to_string());
let cn = create_signal(String::new());
let country = create_signal(String::new());
let state = create_signal(String::new());
let locality = create_signal(String::new());
let organization = create_signal(String::new());
let org_unit = create_signal(String::new());
let email = create_signal(String::new());
let san_text = create_signal(String::new());
let challenge = create_signal(String::new());
let unstructured = create_signal(String::new());
let ku_digital = create_signal(true);
let ku_encipher = create_signal(false);
let ku_cert_sign = create_signal(false);
let ku_crl_sign = create_signal(false);
let eku_server = create_signal(false);
let eku_client = create_signal(false);
let eku_code = create_signal(false);
let eku_email = create_signal(false);
spawn_local(async move {
if let Ok(user) = api::me().await {
if me.is_alive() {
me.set(Some(user));
}
}
if let Ok(page) = api::keys(&id).await {
if keys.is_alive() {
keys.set(page.results.into_iter().filter(|key| key.has_private).collect());
}
}
});
let submit = move |_| {
let request_name = name.get_clone();
if request_name.trim().is_empty() {
error.set(Some("request name is required".to_string()));
return;
}
let mut subject = Vec::new();
for (key, value) in [
("C", country.get_clone()),
("ST", state.get_clone()),
("L", locality.get_clone()),
("O", organization.get_clone()),
("OU", org_unit.get_clone()),
("CN", cn.get_clone()),
("emailAddress", email.get_clone()),
] {
if !value.trim().is_empty() {
subject.push(json!({"name": key, "value": value.trim()}));
}
}
if subject.is_empty() {
error.set(Some("the subject must not be empty".to_string()));
return;
}
let usages = [
(ku_digital.get(), "digital_signature"),
(ku_encipher.get(), "key_encipherment"),
(ku_cert_sign.get(), "key_cert_sign"),
(ku_crl_sign.get(), "crl_sign"),
]
.iter()
.filter(|(checked, _)| *checked)
.map(|(_, name)| json!(name))
.collect::<Vec<_>>();
let eku = [
(eku_server.get(), "serverAuth"),
(eku_client.get(), "clientAuth"),
(eku_code.get(), "codeSigning"),
(eku_email.get(), "emailProtection"),
]
.iter()
.filter(|(checked, _)| *checked)
.map(|(_, name)| json!(name))
.collect::<Vec<_>>();
let mut extensions = json!({
"key_usage": {"critical": true, "usages": usages},
"extended_key_usage": {"critical": false, "usages": eku},
});
let san = parse_san(&san_text.get_clone());
if !san.is_empty() {
extensions["subject_alt_name"] = json!({"critical": false, "entries": san});
}
let mut payload = json!({
"name": request_name,
"comment": comment.get_clone(),
"subject": subject,
"extensions": extensions,
"signature_hash": signature_hash.get_clone(),
});
if new_key.get() {
payload["new_key"] = json!({
"key_type": key_type.get_clone(),
"size": key_size.get_clone().parse::<i64>().unwrap_or(2048),
"name": format!("{} key", name.get_clone()),
});
} else if !key_id.get_clone().is_empty() {
payload["key_id"] = json!(key_id.get_clone());
} else {
error.set(Some("select a key or generate a new one".to_string()));
return;
}
let mut attributes = serde_json::Map::new();
if !challenge.get_clone().is_empty() {
attributes.insert("1.2.840.113549.1.9.7".to_string(), json!(challenge.get_clone()));
}
if !unstructured.get_clone().is_empty() {
attributes.insert("1.2.840.113549.1.9.2".to_string(), json!(unstructured.get_clone()));
}
payload["attributes"] = serde_json::Value::Object(attributes);
saving.set(true);
let id = id.clone();
spawn_local(async move {
match api::create_request(&id, &payload).await {
Ok(request) => {
saving.set(false);
sycamore_router::navigate(&format!("/vaults/{}/requests/{}", id, request.summary.id));
return;
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if saving.is_alive() { saving.set(false); }
});
};
let key_options = move || {
keys.get_clone()
.into_iter()
.map(|key| view! { option(value=key.id.clone()) { (key.name.clone()) " (" (key.key_type.clone()) ")" } })
.collect::<Vec<View>>()
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(page_header("New certificate signing request",
"The CSR contains the public key and requested extensions; it is signed by the private key.",
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/requests")) { "Cancel" } }))
div(class="card") {
div(class="card-body") {
div(class="form-section") {
div(class="form-section-title") { "General" }
div(class="row g-3") {
div(class="col-md-6") {
label(class="form-label") { "Internal name" }
input(class="form-control", bind:value=name)
}
div(class="col-md-6") {
label(class="form-label") { "Signature hash" }
select(class="form-select", bind:value=signature_hash) {
option(value="sha256") { "SHA-256" }
option(value="sha384") { "SHA-384" }
option(value="sha512") { "SHA-512" }
option(value="sha1") { "SHA-1" }
}
}
div(class="col-12") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="2", bind:value=comment) {}
}
}
}
div(class="form-section") {
div(class="form-section-title") { "Subject" }
div(class="row g-3") {
(subject_input("Common name (CN)", cn))
(subject_input("Organization (O)", organization))
(subject_input("Organizational unit (OU)", org_unit))
(subject_input("Country (C)", country))
(subject_input("State / province (ST)", state))
(subject_input("Locality (L)", locality))
(subject_input("E-mail address", email))
}
}
div(class="form-section") {
div(class="form-section-title") { "Private key" }
div(class="form-check mb-2") {
input(class="form-check-input", r#type="checkbox", id="new-key", bind:checked=new_key)
label(class="form-check-label", r#for="new-key") { "Generate a new key" }
}
(if new_key.get() {
view! {
div(class="row g-3") {
div(class="col-md-4") {
label(class="form-label") { "Key type" }
select(class="form-select", bind:value=key_type) {
option(value="rsa") { "RSA" }
option(value="ec") { "EC" }
option(value="ed25519") { "ED25519" }
}
}
div(class="col-md-4") {
label(class="form-label") { "Key size" }
input(class="form-control", bind:value=key_size)
}
}
}
} else {
view! {
label(class="form-label") { "Existing key" }
select(class="form-select", bind:value=key_id) {
option(value="") { "— select a key —" }
(key_options())
}
}
})
}
div(class="form-section") {
div(class="form-section-title") { "Requested extensions" }
div(class="row g-3") {
div(class="col-md-6") {
label(class="form-label") { "Subject alternative names" }
input(class="form-control", placeholder="DNS:example.com, IP:192.0.2.1", bind:value=san_text)
}
div(class="col-md-6") {
label(class="form-label") { "Challenge password" }
input(class="form-control", bind:value=challenge)
}
div(class="col-md-6") {
label(class="form-label") { "Unstructured name" }
input(class="form-control", bind:value=unstructured)
}
}
div(class="row g-3 mt-1") {
div(class="col-md-6") {
div(class="form-section-title") { "Key usage" }
(check("Digital signature", ku_digital))
(check("Key encipherment", ku_encipher))
(check("Certificate sign", ku_cert_sign))
(check("CRL sign", ku_crl_sign))
}
div(class="col-md-6") {
div(class="form-section-title") { "Extended key usage" }
(check("TLS server", eku_server))
(check("TLS client", eku_client))
(check("Code signing", eku_code))
(check("E-mail protection", eku_email))
}
}
}
button(class="btn btn-primary", disabled=saving.get(), on:click=submit) {
(if saving.get() { "Creating…" } else { "Create request" })
}
}
}
}
}
}
fn subject_input(label: &'static str, value: Signal<String>) -> View {
view! {
div(class="col-md-6") {
label(class="form-label") { (label) }
input(class="form-control", bind:value=value)
}
}
}
fn check(label: &'static str, value: Signal<bool>) -> View {
view! {
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", bind:checked=value)
label(class="form-check-label") { (label) }
}
}
}
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 = match prefix.trim().to_lowercase().as_str() {
"ip" => "ip",
"uri" => "uri",
"email" => "email",
"rid" => "rid",
"dns" => "dns",
_ => "dns",
};
json!({"type": kind, "value": value.trim()})
} else {
json!({"type": "dns", "value": chunk})
}
})
.collect()
}
+110
View File
@@ -0,0 +1,110 @@
//! Certificate signing request list.
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{empty_state, page_header, toast_error};
use crate::model::{fmt_fp, Me, RequestSummary};
pub fn RequestsPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let requests = create_signal(Vec::<RequestSummary>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
match api::requests(&id).await {
Ok(page) => {
if requests.is_alive() {
requests.set(page.results);
}
}
Err(message) if message != api::LOGIN_REQUIRED => {
if error.is_alive() {
error.set(Some(message));
}
}
_ => {}
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let content = move || {
if loading.get() {
return spinner();
}
let is_empty = requests.get_clone().is_empty();
let html = requests.get_clone().into_iter().map(|request| {
format!(
"<tr><td><a href=\"/vaults/{}/requests/{}\">{}</a><div class=\"item-sub\">{}</div></td><td class=\"truncate\">{}</td><td>{}</td><td>{}</td><td class=\"fingerprint\">{}</td></tr>",
id,
escape(&request.id),
escape(&request.name),
escape(&request.comment),
escape(&request.subject),
if request.signed { "signed" } else { "new" },
request.certificate_count,
fmt_fp(&request.fingerprint_sha256),
)
}).collect::<String>();
let table = if is_empty {
view! { div(class="card") { div(class="card-body") { (empty_state("No certificate signing requests yet.")) } } }
} else {
view! {
div(class="card") {
div(class="table-responsive") {
table(class="table table-hover align-middle mb-0") {
thead() { tr() { th() { "Name" } th() { "Subject" } th() { "Status" } th() { "Certs" } th() { "SHA-256" } } }
tbody(dangerously_set_inner_html=html) {}
}
}
}
}
};
view! {
(page_header(
"Certificate signing requests",
"PKCS#10 requests can be created here or imported; signing one marks it as used.",
view! {
a(class="btn btn-primary", href=format!("/vaults/{id}/requests/new")) { "New request" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" }
},
))
(table)
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+145
View File
@@ -0,0 +1,145 @@
//! Global vault search.
use sycamore::prelude::*;
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::model::{fmt_date, fmt_fp, Me};
pub fn SearchPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let query = create_signal(String::new());
let kind = create_signal("all".to_string());
let results_html = create_signal(String::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
let searched = create_signal(false);
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
loading.set(false);
});
let run = move |_| {
let q = query.get_clone();
let kind = kind.get_clone();
if q.trim().is_empty() {
results_html.set(String::new());
searched.set(false);
return;
}
loading.set(true);
spawn_local(async move {
match api::search(id, &q, &kind).await {
Ok(page) => {
results_html.set(search_rows_html(id, &page.results));
searched.set(true);
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
};
let content = move || {
if loading.get() {
return spinner();
}
let results = results_html.get_clone();
let results = if results.is_empty() {
if searched.get() { "<p class=\"empty-state\">No matches.</p>".to_string() } else { "<p class=\"empty-state\">Type a query to search.</p>".to_string() }
} else { results };
view! {
(page_header(
"Search",
"Searches names, subjects, issuers, serials, fingerprints and extension text in this vault.",
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}")) { "Back" } },
))
div(class="card mb-3") {
div(class="card-body") {
div(class="row g-2") {
div(class="col-md-8") {
input(class="form-control", placeholder="Search…", bind:value=query)
}
div(class="col-md-2") {
select(class="form-select", bind:value=kind) {
option(value="all") { "All" }
option(value="certificates") { "Certificates" }
option(value="keys") { "Keys" }
option(value="requests") { "Requests" }
option(value="crls") { "CRLs" }
option(value="templates") { "Templates" }
}
}
div(class="col-md-2") {
button(class="btn btn-primary w-100", on:click=run) { "Search" }
}
}
}
}
div(class="card", dangerously_set_inner_html=results) {}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn search_rows_html(vault_id: &str, results: &[crate::model::SearchResult]) -> String {
results.iter().map(|result| {
let item = &result.item;
let item_id = item.get("id").and_then(|v| v.as_str()).unwrap_or_default();
let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("(unnamed)");
let route = match result.r#type.as_str() {
"certificate" => "certificates",
"key" => "keys",
"request" => "requests",
"crl" => "crls",
"template" => "templates",
_ => "certificates",
};
let subtitle = if result.r#type == "certificate" {
let subject = item.get("subject").and_then(|v| v.as_str()).unwrap_or("");
let expiry = item.get("not_after").and_then(|v| v.as_str()).map(|s| s.to_string());
format!("{subject} · expires {}", fmt_date(&expiry))
} else if result.r#type == "key" {
let key_type = item.get("key_type").and_then(|v| v.as_str()).unwrap_or("");
let fp = item.get("fingerprint_sha256").and_then(|v| v.as_str()).unwrap_or("");
format!("{key_type} · {}", fmt_fp(fp))
} else if result.r#type == "request" {
item.get("subject").and_then(|v| v.as_str()).unwrap_or("").to_string()
} else if result.r#type == "crl" {
item.get("issuer_name").and_then(|v| v.as_str()).unwrap_or("").to_string()
} else {
item.get("comment").and_then(|v| v.as_str()).unwrap_or("").to_string()
};
format!(
"<div class=\"border-bottom border-secondary-subtle p-3 d-flex justify-content-between align-items-center\"><div><a class=\"item-name\" href=\"/vaults/{}/{}/{}\">{}</a><div class=\"item-sub\">{}</div></div><span class=\"badge badge-soft\">{}</span></div>",
escape(vault_id),
route,
escape(item_id),
escape(name),
escape(&subtitle),
escape(&result.r#type),
)
}).collect()
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+220
View File
@@ -0,0 +1,220 @@
//! Per-vault options, mirroring XCA's database settings.
use serde_json::json;
use sycamore::prelude::*;
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::model::Me;
pub fn SettingsPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let error = create_signal(None::<String>);
let info = create_signal(None::<String>);
let loading = create_signal(true);
let saving = create_signal(false);
let version = create_signal(0i64);
let default_hash = create_signal("sha256".to_string());
let serial_len = create_signal("64".to_string());
let explicit_dn = create_signal("C,ST,L,O,OU,CN,emailAddress".to_string());
let mandatory_dn = create_signal(String::new());
let disable_netscape = create_signal(false);
let hide_unusable = create_signal(false);
let default_key_type = create_signal("rsa".to_string());
let default_key_size = create_signal("2048".to_string());
let default_validity = create_signal("365".to_string());
let default_validity_unit = create_signal("days".to_string());
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
match api::vault_settings(&id).await {
Ok(value) => {
version.set(value.get("version").and_then(|v| v.as_i64()).unwrap_or(0));
if let Some(settings) = value.get("settings") {
if let Some(v) = settings.get("default_hash").and_then(|v| v.as_str()) {
default_hash.set(v.to_string());
}
if let Some(v) = settings.get("serial_len_bits").and_then(|v| v.as_i64()) {
serial_len.set(v.to_string());
}
if let Some(items) = settings.get("explicit_dn").and_then(|v| v.as_array()) {
explicit_dn.set(items.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(","));
}
if let Some(items) = settings.get("mandatory_dn").and_then(|v| v.as_array()) {
mandatory_dn.set(items.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(","));
}
disable_netscape.set(settings.get("disable_netscape").and_then(|v| v.as_bool()).unwrap_or(false));
hide_unusable.set(settings.get("hide_unusable").and_then(|v| v.as_bool()).unwrap_or(false));
if let Some(key) = settings.get("default_key") {
if let Some(v) = key.get("type").and_then(|v| v.as_str()) {
default_key_type.set(v.to_string());
}
if let Some(v) = key.get("size").and_then(|v| v.as_i64()) {
default_key_size.set(v.to_string());
}
}
if let Some(validity) = settings.get("default_validity") {
if let Some(v) = validity.get("amount").and_then(|v| v.as_i64()) {
default_validity.set(v.to_string());
}
if let Some(v) = validity.get("unit").and_then(|v| v.as_str()) {
default_validity_unit.set(v.to_string());
}
}
}
}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
let save = {
let id = id.clone();
move |_| {
let id = id.clone();
let settings = json!({
"default_hash": default_hash.get_clone(),
"serial_len_bits": serial_len.get_clone().parse::<i64>().unwrap_or(64),
"explicit_dn": explicit_dn.get_clone().split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect::<Vec<_>>(),
"mandatory_dn": mandatory_dn.get_clone().split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect::<Vec<_>>(),
"disable_netscape": disable_netscape.get(),
"hide_unusable": hide_unusable.get(),
"default_key": {
"type": default_key_type.get_clone(),
"size": default_key_size.get_clone().parse::<i64>().unwrap_or(2048),
"curve": null,
},
"default_validity": {
"amount": default_validity.get_clone().parse::<i64>().unwrap_or(365),
"unit": default_validity_unit.get_clone(),
},
});
saving.set(true);
spawn_local(async move {
match api::update_vault_settings(&id, &settings, version.get()).await {
Ok(value) => {
version.set(value.get("version").and_then(|v| v.as_i64()).unwrap_or(version.get()));
info.set(Some("Settings saved.".to_string()));
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
saving.set(false);
});
}
};
let content = move || {
if loading.get() {
return spinner();
}
view! {
(page_header(
"Vault settings",
"These options are stored with the vault and mirror XCA's database preferences.",
view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}")) { "Back" } },
))
(if let Some(message) = info.get_clone() {
view! { div(class="alert alert-success") { (message) } }
} else {
view! {}
})
div(class="card") {
div(class="card-body") {
div(class="form-section") {
div(class="form-section-title") { "Cryptography" }
div(class="row g-3") {
div(class="col-md-4") {
label(class="form-label") { "Default signature hash" }
select(class="form-select", bind:value=default_hash) {
option(value="sha256") { "SHA-256" }
option(value="sha384") { "SHA-384" }
option(value="sha512") { "SHA-512" }
option(value="sha224") { "SHA-224" }
option(value="sha1") { "SHA-1" }
}
}
div(class="col-md-4") {
label(class="form-label") { "Serial number length (bits)" }
input(class="form-control", bind:value=serial_len)
}
div(class="col-md-4") {
label(class="form-label") { "Default validity" }
div(class="input-group") {
input(class="form-control", bind:value=default_validity)
select(class="form-select", bind:value=default_validity_unit) {
option(value="days") { "Days" }
option(value="months") { "Months" }
option(value="years") { "Years" }
}
}
}
}
}
div(class="form-section") {
div(class="form-section-title") { "Distinguished name" }
div(class="row g-3") {
div(class="col-md-6") {
label(class="form-label") { "Explicit subject entries" }
input(class="form-control", bind:value=explicit_dn)
div(class="form-text") { "Comma-separated short names, e.g. C,ST,L,O,OU,CN,emailAddress." }
}
div(class="col-md-6") {
label(class="form-label") { "Mandatory subject entries" }
input(class="form-control", bind:value=mandatory_dn)
}
}
}
div(class="form-section") {
div(class="form-section-title") { "Editor defaults" }
div(class="row g-3") {
div(class="col-md-4") {
label(class="form-label") { "Default key type" }
select(class="form-select", bind:value=default_key_type) {
option(value="rsa") { "RSA" }
option(value="ec") { "EC" }
option(value="ed25519") { "ED25519" }
}
}
div(class="col-md-4") {
label(class="form-label") { "Default key size" }
input(class="form-control", bind:value=default_key_size)
}
div(class="col-md-4 d-flex align-items-end") {
button(class="btn btn-primary", disabled=saving.get(), on:click=save) {
(if saving.get() { "Saving…" } else { "Save settings" })
}
}
}
div(class="form-check mt-3") {
input(class="form-check-input", r#type="checkbox", bind:checked=disable_netscape)
label(class="form-check-label") { "Disable legacy Netscape extensions" }
}
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", bind:checked=hide_unusable)
label(class="form-check-label") { "Hide expired and revoked certificates by default" }
}
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
+422
View File
@@ -0,0 +1,422 @@
//! Template editor (create/update; built-ins are read-only).
use serde_json::json;
use sycamore::prelude::*;
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::{Me, TemplateSummary};
pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let template_id_value: Option<&'static str> = template_id.as_ref().map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str);
let me = create_signal(None::<Me>);
let template = create_signal(None::<TemplateSummary>);
let error = create_signal(None::<String>);
let loading = create_signal(template_id.is_none());
let saving = create_signal(false);
let builtin = create_signal(false);
let name = create_signal(String::new());
let comment = create_signal(String::new());
let cn = create_signal(String::new());
let organization = create_signal(String::new());
let org_unit = create_signal(String::new());
let country = create_signal(String::new());
let amount = create_signal("365".to_string());
let unit = create_signal("days".to_string());
let midnight = create_signal(false);
let ca_mode = create_signal("end".to_string());
let path_length = create_signal(String::new());
let bc_critical = create_signal(true);
let ku_digital = create_signal(true);
let ku_encipher = create_signal(false);
let ku_key_agreement = create_signal(false);
let ku_cert_sign = create_signal(false);
let ku_crl_sign = create_signal(false);
let ku_critical = create_signal(true);
let eku_server = create_signal(false);
let eku_client = create_signal(false);
let eku_code = create_signal(false);
let eku_email = create_signal(false);
let eku_critical = create_signal(false);
let san_text = create_signal(String::new());
let ski = create_signal(true);
let aki = create_signal(true);
if let Some(template_id) = template_id.clone() {
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
match api::template(&id, &template_id).await {
Ok(value) => {
builtin.set(value.builtin);
name.set(value.name.clone());
comment.set(value.comment.clone());
if let Some(subject) = value.subject.as_array() {
for entry in subject {
let key = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
let val = entry.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string();
match key {
"CN" => cn.set(val),
"O" => organization.set(val),
"OU" => org_unit.set(val),
"C" => country.set(val),
_ => {}
}
}
}
if let Some(validity) = value.settings.get("validity") {
if let Some(v) = validity.get("amount").and_then(|v| v.as_i64()) {
amount.set(v.to_string());
}
if let Some(v) = validity.get("unit").and_then(|v| v.as_str()) {
unit.set(v.to_string());
}
midnight.set(validity.get("midnight").and_then(|v| v.as_bool()).unwrap_or(false));
}
if let Some(extensions) = value.settings.get("extensions") {
if let Some(bc) = extensions.get("basic_constraints") {
if bc.get("ca").and_then(|v| v.as_bool()).unwrap_or(false) {
ca_mode.set("ca".to_string());
} else if bc.get("ca").is_some() {
ca_mode.set("end".to_string());
} else {
ca_mode.set("none".to_string());
}
bc_critical.set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
if let Some(len) = bc.get("path_length").and_then(|v| v.as_i64()) {
path_length.set(len.to_string());
}
}
if let Some(ku) = extensions.get("key_usage") {
let usages = string_array(ku.get("usages"));
ku_digital.set(usages.iter().any(|s| s == "digital_signature"));
ku_encipher.set(usages.iter().any(|s| s == "key_encipherment"));
ku_key_agreement.set(usages.iter().any(|s| s == "key_agreement"));
ku_cert_sign.set(usages.iter().any(|s| s == "key_cert_sign"));
ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign"));
ku_critical.set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
}
if let Some(eku) = extensions.get("extended_key_usage") {
let usages = string_array(eku.get("usages"));
eku_server.set(usages.iter().any(|s| s == "serverAuth"));
eku_client.set(usages.iter().any(|s| s == "clientAuth"));
eku_code.set(usages.iter().any(|s| s == "codeSigning"));
eku_email.set(usages.iter().any(|s| s == "emailProtection"));
eku_critical.set(eku.get("critical").and_then(|v| v.as_bool()).unwrap_or(false));
}
if let Some(san) = extensions.get("subject_alt_name") {
let entries = san.get("entries").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let text = entries.iter().filter_map(|entry| {
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 value.is_empty() { None } else { Some(format!("{}:{}", kind.to_uppercase(), value)) }
}).collect::<Vec<_>>().join(", ");
san_text.set(text);
}
ski.set(extensions.get("subject_key_identifier").is_some());
aki.set(extensions.get("authority_key_identifier").is_some());
}
template.set(Some(value));
}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
} else {
spawn_local(async move {
if let Ok(user) = api::me().await {
me.set(Some(user));
}
loading.set(false);
});
}
let submit = {
let id = id.clone();
let template_id: Option<&'static str> = template_id.as_ref().map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str);
move |_| {
let id = id.clone();
let template_id = template_id.clone();
let mut subject = Vec::new();
for (key, value) in [
("C", country.get_clone()),
("O", organization.get_clone()),
("OU", org_unit.get_clone()),
("CN", cn.get_clone()),
] {
if !value.trim().is_empty() {
subject.push(json!({"name": key, "value": value.trim()}));
}
}
let mut extensions = json!({
"key_usage": {"critical": ku_critical.get(), "usages": [
if ku_digital.get() { json!("digital_signature") } else { json!(null) },
if ku_encipher.get() { json!("key_encipherment") } else { json!(null) },
if ku_key_agreement.get() { json!("key_agreement") } else { json!(null) },
if ku_cert_sign.get() { json!("key_cert_sign") } else { json!(null) },
if ku_crl_sign.get() { json!("crl_sign") } else { json!(null) },
]},
"extended_key_usage": {"critical": eku_critical.get(), "usages": [
if eku_server.get() { json!("serverAuth") } else { json!(null) },
if eku_client.get() { json!("clientAuth") } else { json!(null) },
if eku_code.get() { json!("codeSigning") } else { json!(null) },
if eku_email.get() { json!("emailProtection") } else { json!(null) },
]},
"subject_key_identifier": if ski.get() { json!({"critical": false}) } else { json!(null) },
"authority_key_identifier": if aki.get() { json!({"critical": false}) } else { json!(null) },
});
// Drop null placeholders from the usage arrays.
for key in ["key_usage", "extended_key_usage"] {
if let Some(usages) = extensions[key]["usages"].as_array_mut() {
usages.retain(|value| !value.is_null());
}
}
let mode = ca_mode.get_clone();
if mode == "ca" {
extensions["basic_constraints"] = json!({"critical": bc_critical.get(), "ca": true, "path_length": path_length.get_clone().parse::<i64>().ok()});
} else if mode == "end" {
extensions["basic_constraints"] = json!({"critical": bc_critical.get(), "ca": false, "path_length": null});
} else {
extensions["basic_constraints"] = serde_json::Value::Null;
}
let san = parse_san(&san_text.get_clone());
extensions["subject_alt_name"] = if san.is_empty() { serde_json::Value::Null } else { json!({"critical": false, "entries": san}) };
let payload = json!({
"name": name.get_clone(),
"comment": comment.get_clone(),
"subject": subject,
"settings": {
"extensions": extensions,
"validity": {"amount": amount.get_clone().parse::<i64>().unwrap_or(365), "unit": unit.get_clone(), "midnight": midnight.get()},
},
});
saving.set(true);
spawn_local(async move {
let result = match template_id {
Some(template_id) => api::update_template(&id, &template_id, &payload).await,
None => api::create_template(&id, &payload).await,
};
match result {
Ok(value) => {
saving.set(false);
sycamore_router::navigate(&format!("/vaults/{}/templates/{}", id, value.id));
return;
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if saving.is_alive() { saving.set(false); }
});
}
};
let export = move |_| {
if let Some(template_id) = template_id_value {
spawn_local(async move {
match api::export_template(id, template_id, &json!({"format": "xca"})).await {
Ok(payload) => {
if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) {
error.set(Some(message));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
}
};
let content = move || {
if loading.get() {
return spinner();
}
let read_only = builtin.get();
view! {
(page_header(
if template_id_value.is_some() { "Template" } else { "New template" },
"Templates store subject defaults and extension presets; applying one never signs anything.",
view! {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/templates")) { "Cancel" }
(if template_id_value.is_some() {
view! { button(class="btn btn-outline-light", on:click=export) { "Export .xca" } }
} else {
view! {}
})
},
))
(if read_only {
view! { div(class="alert alert-info") { "This is a built-in template. Create a new template to customise it." } }
} else {
view! {}
})
div(class="card") {
div(class="card-body") {
div(class="form-section") {
div(class="form-section-title") { "General" }
div(class="row g-3") {
div(class="col-md-6") {
label(class="form-label") { "Name" }
input(class="form-control", disabled=read_only, bind:value=name)
}
div(class="col-12") {
label(class="form-label") { "Comment" }
textarea(class="form-control", rows="2", disabled=read_only, bind:value=comment) {}
}
}
}
div(class="form-section") {
div(class="form-section-title") { "Subject defaults" }
div(class="row g-3") {
(subject_input("Common name (CN)", cn, read_only))
(subject_input("Organization (O)", organization, read_only))
(subject_input("Organizational unit (OU)", org_unit, read_only))
(subject_input("Country (C)", country, read_only))
}
}
div(class="form-section") {
div(class="form-section-title") { "Extensions" }
div(class="row g-3") {
div(class="col-md-4") {
label(class="form-label") { "Basic constraints" }
select(class="form-select", disabled=read_only, bind:value=ca_mode) {
option(value="none") { "Not defined" }
option(value="end") { "End entity (CA:FALSE)" }
option(value="ca") { "Certification authority (CA:TRUE)" }
}
}
div(class="col-md-3") {
label(class="form-label") { "Path length" }
input(class="form-control", disabled=read_only, bind:value=path_length)
}
div(class="col-md-3 d-flex align-items-end") {
(check("Critical", bc_critical, read_only))
}
}
div(class="row g-3 mt-1") {
div(class="col-md-6") {
div(class="form-section-title") { "Key usage" }
(check("Digital signature", ku_digital, read_only))
(check("Key encipherment", ku_encipher, read_only))
(check("Key agreement", ku_key_agreement, read_only))
(check("Certificate sign", ku_cert_sign, read_only))
(check("CRL sign", ku_crl_sign, read_only))
(check("Critical", ku_critical, read_only))
}
div(class="col-md-6") {
div(class="form-section-title") { "Extended key usage" }
(check("TLS server", eku_server, read_only))
(check("TLS client", eku_client, read_only))
(check("Code signing", eku_code, read_only))
(check("E-mail protection", eku_email, read_only))
(check("Critical", eku_critical, read_only))
}
div(class="col-md-6") {
div(class="form-section-title") { "Subject alternative names" }
input(class="form-control", disabled=read_only, bind:value=san_text)
}
div(class="col-md-6") {
div(class="form-section-title") { "Key identifiers" }
(check("Subject key identifier", ski, read_only))
(check("Authority key identifier", aki, read_only))
}
}
}
div(class="form-section") {
div(class="form-section-title") { "Default validity" }
div(class="row g-3") {
div(class="col-md-3") {
label(class="form-label") { "Duration" }
input(class="form-control", disabled=read_only, bind:value=amount)
}
div(class="col-md-3") {
label(class="form-label") { "Unit" }
select(class="form-select", disabled=read_only, bind:value=unit) {
option(value="days") { "Days" }
option(value="months") { "Months" }
option(value="years") { "Years" }
}
}
div(class="col-md-3 d-flex align-items-end") {
(check("Round to midnight", midnight, read_only))
}
}
}
(if read_only {
view! {}
} else {
view! {
button(class="btn btn-primary", disabled=saving.get(), on:click=submit.clone()) {
(if saving.get() { "Saving…" } else { "Save template" })
}
}
})
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn subject_input(label: &'static str, value: Signal<String>, disabled: bool) -> View {
view! {
div(class="col-md-6") {
label(class="form-label") { (label) }
input(class="form-control", disabled=disabled, bind:value=value)
}
}
}
fn check(label: &'static str, value: Signal<bool>, disabled: bool) -> View {
view! {
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", disabled=disabled, bind:checked=value)
label(class="form-check-label") { (label) }
}
}
}
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 = match prefix.trim().to_lowercase().as_str() {
"ip" => "ip",
"uri" => "uri",
"email" => "email",
"rid" => "rid",
"dns" => "dns",
_ => "dns",
};
json!({"type": kind, "value": value.trim()})
} else {
json!({"type": "dns", "value": chunk})
}
})
.collect()
}
fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
value
.and_then(|v| v.as_array())
.map(|items| items.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default()
}
+105
View File
@@ -0,0 +1,105 @@
//! Template list.
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{empty_state, page_header, toast_error};
use crate::model::{Me, TemplateSummary};
pub fn TemplatesPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let templates = create_signal(Vec::<TemplateSummary>::new());
let error = create_signal(None::<String>);
let loading = create_signal(true);
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
match api::templates(&id).await {
Ok(page) => {
if templates.is_alive() {
templates.set(page.results);
}
}
Err(message) if message != api::LOGIN_REQUIRED => {
if error.is_alive() {
error.set(Some(message));
}
}
_ => {}
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let content = move || {
if loading.get() {
return spinner();
}
let is_empty = templates.get_clone().is_empty();
let html = templates.get_clone().into_iter().map(|template| {
format!(
"<tr><td><a href=\"/vaults/{}/templates/{}\">{}</a>{}<div class=\"item-sub\">{}</div></td><td>{}</td></tr>",
id,
escape(&template.id),
escape(&template.name),
if template.builtin { " <span class=\"badge badge-ca\">built-in</span>" } else { "" },
escape(&template.comment),
escape(&template.source),
)
}).collect::<String>();
let table = if is_empty {
view! { div(class="card") { div(class="card-body") { (empty_state("No templates yet.")) } } }
} else {
view! {
div(class="card") {
div(class="table-responsive") {
table(class="table table-hover align-middle mb-0") {
thead() { tr() { th() { "Name" } th() { "Source" } } }
tbody(dangerously_set_inner_html=html) {}
}
}
}
}
};
view! {
(page_header(
"Templates",
"Templates are reusable subject and extension presets. Built-ins mirror XCA's CA, TLS server and TLS client templates.",
view! { a(class="btn btn-primary", href=format!("/vaults/{id}/templates/new")) { "New template" } },
))
(table)
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+169
View File
@@ -0,0 +1,169 @@
//! Personal API tokens.
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::{copy_button, page_header, toast_error};
use crate::dom;
use crate::model::{ApiToken, CreatedApiToken, Me};
#[component]
pub fn TokensPage() -> View {
let me = create_signal(None::<Me>);
let tokens = create_signal(Vec::<ApiToken>::new());
let created = create_signal(None::<CreatedApiToken>);
let error = create_signal(None::<String>);
let loading = create_signal(true);
let name = create_signal(String::new());
let expires = create_signal(String::new());
let busy = create_signal(false);
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
match api::tokens().await {
Ok(page) => tokens.set(page.results),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
loading.set(false);
});
let reload = move || {
spawn_local(async move {
if let Ok(page) = api::tokens().await {
tokens.set(page.results);
}
});
};
let create = move |_| {
let token_name = name.get_clone();
if token_name.trim().is_empty() {
error.set(Some("token name is required".to_string()));
return;
}
let expiry = expires.get_clone();
busy.set(true);
spawn_local(async move {
match api::create_token(&token_name, if expiry.is_empty() { None } else { Some(&expiry) }).await {
Ok(token) => {
created.set(Some(token));
name.set(String::new());
expires.set(String::new());
reload();
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
busy.set(false);
});
};
let content = move || {
if loading.get() {
return spinner();
}
let html = tokens.get_clone().into_iter().map(|token| {
format!(
"<tr><td>{}</td><td class=\"fingerprint\">{}…</td><td>{}</td><td>{}</td><td>{}</td><td><button class=\"btn btn-sm btn-outline-danger\" data-token-id=\"{}\">Revoke</button></td></tr>",
escape(&token.name),
escape(&token.prefix),
escape(&token.created_at),
escape(token.last_used_at.as_deref().unwrap_or("never")),
escape(token.expires_at.as_deref().unwrap_or("never")),
escape(&token.id),
)
}).collect::<String>();
let created_panel = if let Some(token) = created.get_clone() {
let token_code = token.token.clone();
let token_copy = token.token.clone();
view! {
div(class="alert alert-success") {
div(class="fw-bold mb-1") { "Token created — copy it now, it is shown only once." }
div(class="d-flex align-items-center gap-2 flex-wrap") {
code(class="text-break") { (token_code.clone()) }
(copy_button(token_copy.clone(), "Copy"))
}
}
}
} else {
view! {}
};
view! {
(page_header(
"API tokens",
"Programs authenticate with Authorization: Bearer kca_… and act as their owner.",
view! { a(class="btn btn-outline-light", href="/") { "Back" } },
))
(created_panel)
div(class="card mb-3") {
div(class="card-header") { "Create token" }
div(class="card-body") {
div(class="row g-2 align-items-end") {
div(class="col-md-5") {
label(class="form-label") { "Name" }
input(class="form-control", placeholder="CI uploader", bind:value=name)
}
div(class="col-md-4") {
label(class="form-label") { "Expires (optional)" }
input(class="form-control", r#type="date", bind:value=expires)
}
div(class="col-md-3") {
button(class="btn btn-primary w-100", disabled=busy.get(), on:click=create) { "Create" }
}
}
}
}
div(class="card") {
div(class="card-header") { "Tokens" }
div(class="table-responsive", on:click=move |event: web_sys::MouseEvent| {
if let Some(target) = event.target().and_then(|t| t.dyn_into::<web_sys::HtmlElement>().ok()) {
if let Some(token_id) = target.get_attribute("data-token-id") {
if dom::confirm("Revoke this API token?") {
spawn_local(async move {
match api::delete_token(&token_id).await {
Ok(()) => reload(),
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
}
}
}
}) {
table(class="table table-sm align-middle mb-0") {
thead() { tr() { th() { "Name" } th() { "Prefix" } th() { "Created" } th() { "Last used" } th() { "Expires" } th() {} } }
tbody(dangerously_set_inner_html=html) {}
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", None, nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+173
View File
@@ -0,0 +1,173 @@
//! One vault's overview: counts, expiring certificates, CRLs due.
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{empty_state, page_header, toast_error};
use crate::model::{fmt_date, item_total, Me, SummaryPage, VaultDetail};
pub fn VaultPage(vault_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let vault = create_signal(None::<VaultDetail>);
let summary = create_signal(None::<SummaryPage>);
let error = create_signal(None::<String>);
let loading = create_signal(true);
spawn_local(async move {
match api::me().await {
Ok(user) => {
if me.is_alive() {
me.set(Some(user));
}
match api::vault(&id).await {
Ok(value) => {
if vault.is_alive() {
vault.set(Some(value));
}
}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
match api::vault_summary(&id).await {
Ok(value) => {
if summary.is_alive() {
summary.set(Some(value));
}
}
Err(message) if message != api::LOGIN_REQUIRED => {
if error.is_alive() {
error.set(Some(message));
}
}
_ => {}
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => {
if error.is_alive() {
error.set(Some(message));
}
}
}
if loading.is_alive() {
loading.set(false);
}
});
let content = move || {
if loading.get() {
return spinner();
}
let Some(vault) = vault.get_clone() else {
return empty_state("Vault not found.");
};
let Some(summary) = summary.get_clone() else {
return spinner();
};
let id = id;
let counts = vault.counts.clone();
let expiring_html = summary.expiring.iter().map(|cert| {
format!(
"<li class=\"list-group-item d-flex justify-content-between align-items-center\"><div><a href=\"/vaults/{}/certificates/{}\">{}</a><div class=\"item-sub\">{}</div></div><span class=\"badge badge-warn\">{}</span></li>",
id, escape(&cert.id), escape(&cert.name), escape(&cert.subject), fmt_date(&cert.not_after)
)
}).collect::<String>();
let recent_html = summary.recent_certificates.iter().map(|cert| {
format!(
"<li class=\"list-group-item d-flex justify-content-between align-items-center\"><div><a href=\"/vaults/{}/certificates/{}\">{}</a><div class=\"item-sub\">{}</div></div><span class=\"badge badge-soft\">{}</span></li>",
id, escape(&cert.id), escape(&cert.name), escape(&cert.subject), if cert.is_ca { "CA" } else { "cert" }
)
}).collect::<String>();
let due_html = summary.crls_due.iter().map(|crl| {
format!(
"<li class=\"list-group-item d-flex justify-content-between align-items-center\"><a href=\"/vaults/{}/crls/{}\">{}</a><span class=\"badge badge-warn\">due {}</span></li>",
id, escape(&crl.id), escape(&crl.name), fmt_date(&crl.next_update)
)
}).collect::<String>();
view! {
(page_header(
&vault.name,
if vault.description.is_empty() { "Private PKI vault" } else { &vault.description },
view! {
a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/new")) { "New certificate" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/keys/new")) { "New key" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/settings")) { "Settings" }
},
))
div(class="row g-3 mb-4") {
(stat_card("Certificates", counts.certificates, format!("/vaults/{id}/certificates")))
(stat_card("Requests", counts.requests, format!("/vaults/{id}/requests")))
(stat_card("Keys", counts.keys, format!("/vaults/{id}/keys")))
(stat_card("CRLs", counts.crls, format!("/vaults/{id}/crls")))
(stat_card("Templates", counts.templates, format!("/vaults/{id}/templates")))
(stat_card("Total items", item_total(&counts), format!("/vaults/{id}/search")))
}
div(class="row g-3") {
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header d-flex justify-content-between") {
span() { "Expiring soon" }
span(class="badge badge-warn") { (summary.expiring.len()) }
}
ul(class="list-group list-group-flush", dangerously_set_inner_html=expiring_html) {}
}
}
div(class="col-12 col-lg-6") {
div(class="card") {
div(class="card-header d-flex justify-content-between") {
span() { "CRLs due" }
span(class="badge badge-warn") { (summary.crls_due.len()) }
}
ul(class="list-group list-group-flush", dangerously_set_inner_html=due_html) {}
}
}
div(class="col-12") {
div(class="card") {
div(class="card-header d-flex justify-content-between") {
span() { "Recent certificates" }
a(class="small", href=format!("/vaults/{id}/certificates")) { "All certificates" }
}
ul(class="list-group list-group-flush", dangerously_set_inner_html=recent_html) {}
}
}
}
}
};
let nav = move || match me.get_clone() {
Some(user) => whoami(&user),
None => view! {},
};
view! {
(app_nav("/", Some(id), nav))
main(class="container page-shell py-4") {
(toast_error(error))
(content)
}
}
}
fn stat_card(label: impl Into<String>, value: i64, href: String) -> View {
let label = label.into();
view! {
div(class="col-6 col-md-4 col-xl-2") {
a(class="card vault-card text-decoration-none", href=href) {
div(class="card-body text-center") {
div(class="stat-number") { (value) }
div(class="stat-label") { (label) }
}
}
}
}
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+232
View File
@@ -0,0 +1,232 @@
/* App-specific layout for KCA. */
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app main {
flex: 1 0 auto;
}
.app-toast {
position: fixed;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 1080;
background: var(--danger);
color: #fff;
padding: 0.5rem 1rem;
border-radius: 8px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
max-width: 40rem;
}
.app-toast.success {
background: #15803d;
}
.page-shell {
max-width: 1240px;
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
object-fit: cover;
display: block;
flex: 0 0 auto;
}
.vault-card {
height: 100%;
}
.stat-number {
font-size: 1.7rem;
font-weight: 700;
line-height: 1.1;
}
.stat-label {
color: var(--muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.item-name {
font-weight: 600;
}
.item-sub {
color: var(--muted);
font-size: 0.85rem;
word-break: break-word;
}
.cert-tree {
list-style: none;
margin: 0;
padding-left: 1.1rem;
}
.cert-tree > li {
border-left: 1px dashed var(--line);
padding-left: 0.8rem;
margin: 0.15rem 0;
}
.cert-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.25rem 0.35rem;
border-radius: 6px;
}
.cert-row:hover {
background: rgba(94, 234, 212, 0.06);
}
.cert-row .item-name a {
text-decoration: none;
}
details.cert-node > summary {
cursor: pointer;
list-style: none;
}
details.cert-node > summary::-webkit-details-marker {
display: none;
}
details.cert-node > summary::before {
content: "▸";
color: var(--accent);
display: inline-block;
width: 1rem;
}
details.cert-node[open] > summary::before {
content: "▾";
}
.tab-pane {
padding-top: 1rem;
}
.dn-table td,
.dn-table th,
.ext-table td,
.ext-table th {
vertical-align: top;
}
.fingerprint {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.78rem;
word-break: break-all;
}
.extension-value {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.78rem;
white-space: pre-wrap;
word-break: break-word;
}
.form-section {
border: 1px solid var(--line);
border-radius: 10px;
padding: 1rem;
margin-bottom: 1rem;
background: rgba(0, 0, 0, 0.08);
}
.form-section-title {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
margin-bottom: 0.75rem;
}
.dn-row,
.san-row,
.raw-row {
display: grid;
gap: 0.5rem;
align-items: start;
margin-bottom: 0.5rem;
}
.dn-row {
grid-template-columns: 12rem 1fr auto;
}
.san-row {
grid-template-columns: 11rem 1fr auto;
}
.raw-row {
grid-template-columns: 14rem 5rem 1fr auto;
}
.extension-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 0.35rem 1rem;
}
.wizard-nav {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.wizard-nav button {
min-width: 7rem;
}
.empty-state {
text-align: center;
color: var(--muted);
padding: 2rem 1rem;
}
.import-preview {
max-height: 24rem;
overflow: auto;
}
.table-actions {
white-space: nowrap;
}
.truncate {
max-width: 28rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.spinner-border.text-warning {
color: var(--accent) !important;
}
@media (max-width: 576px) {
.dn-row,
.san-row,
.raw-row {
grid-template-columns: 1fr;
}
.truncate {
max-width: 14rem;
}
}
+243
View File
@@ -0,0 +1,243 @@
/* KCA dark PKI-console theme bridged onto Bootstrap 5.3. */
:root,
[data-bs-theme="dark"] {
--ink: #0f2430;
--ink-dark: #0a1720;
--panel: #132c39;
--panel-light: #1b3b4a;
--line: #2b5668;
--text: #e8f2f4;
--muted: #9db8c2;
--accent: #5eead4;
--accent-bright: #8ff5e4;
--warning: #fbbf24;
--danger: #f87171;
--positive: #4ade80;
--bs-body-bg: var(--ink-dark);
--bs-body-color: var(--text);
--bs-body-color-rgb: 232, 242, 244;
--bs-body-bg-rgb: 10, 23, 32;
--bs-emphasis-color: #fff;
--bs-secondary-color: var(--muted);
--bs-secondary-bg: var(--panel);
--bs-tertiary-bg: var(--panel);
--bs-border-color: var(--line);
--bs-border-color-translucent: rgba(232, 242, 244, 0.12);
--bs-link-color: var(--accent);
--bs-link-color-rgb: 94, 234, 212;
--bs-link-hover-color: var(--accent-bright);
--bs-link-hover-color-rgb: 143, 245, 228;
--bs-primary: var(--accent);
--bs-primary-rgb: 94, 234, 212;
--bs-warning: var(--warning);
--bs-warning-rgb: 251, 191, 36;
--bs-danger: var(--danger);
}
body {
min-height: 100vh;
background-color: var(--ink-dark);
background-image:
radial-gradient(ellipse at 50% 0%, rgba(94, 234, 212, 0.08), transparent 55%),
radial-gradient(ellipse at 50% 100%, rgba(0, 0, 0, 0.45), transparent 65%);
background-repeat: no-repeat;
background-attachment: fixed;
color: var(--text);
}
.navbar {
--bs-navbar-padding-y: 0.7rem;
--bs-navbar-color: var(--muted);
--bs-navbar-hover-color: var(--text);
--bs-navbar-active-color: var(--accent);
--bs-navbar-brand-color: var(--text);
--bs-navbar-brand-hover-color: var(--accent);
border-bottom: 1px solid rgba(0, 0, 0, 0.35);
background: rgb(7, 18, 25);
}
.navbar .navbar-brand {
font-weight: 700;
letter-spacing: 0.04em;
}
.brand-glyph {
color: var(--accent);
}
.card {
--bs-card-bg: rgba(19, 44, 57, 0.78);
border-color: var(--line);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
}
.card-header {
background: rgba(0, 0, 0, 0.16);
border-bottom-color: var(--line);
font-weight: 600;
}
.table {
--bs-table-bg: transparent;
--bs-table-color: var(--text);
--bs-table-border-color: var(--line);
}
.table > :not(caption) > * > * {
background-color: transparent;
}
.btn-primary {
--bs-btn-bg: var(--accent);
--bs-btn-border-color: var(--accent);
--bs-btn-color: #06221f;
--bs-btn-hover-bg: var(--accent-bright);
--bs-btn-hover-border-color: var(--accent-bright);
--bs-btn-hover-color: #06221f;
--bs-btn-active-bg: var(--accent-bright);
}
.btn-warning {
--bs-btn-bg: var(--warning);
--bs-btn-border-color: var(--warning);
--bs-btn-color: #251a00;
}
.btn-outline-light {
--bs-btn-color: var(--text);
--bs-btn-border-color: var(--line);
--bs-btn-hover-bg: var(--panel-light);
--bs-btn-hover-border-color: var(--accent);
--bs-btn-hover-color: var(--text);
}
.form-control,
.form-select {
background-color: rgba(7, 18, 25, 0.65);
border-color: var(--line);
color: var(--text);
}
.form-control:focus,
.form-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 0.2rem rgba(94, 234, 212, 0.16);
background-color: rgba(7, 18, 25, 0.8);
color: var(--text);
}
.form-control::placeholder {
color: #6f8d98;
}
.form-check-input:checked {
background-color: var(--accent);
border-color: var(--accent);
}
.nav-tabs {
--bs-nav-tabs-border-color: var(--line);
--bs-nav-tabs-link-active-bg: rgba(19, 44, 57, 0.9);
--bs-nav-tabs-link-active-color: var(--accent);
--bs-nav-tabs-link-active-border-color: var(--line) var(--line) transparent;
}
.nav-tabs .nav-link {
color: var(--muted);
}
.nav-tabs .nav-link:hover {
color: var(--text);
}
.text-secondary,
.muted {
color: var(--muted) !important;
}
code,
pre,
.mono {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.875em;
}
pre.pem {
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.75rem;
color: #c9f7ee;
white-space: pre-wrap;
word-break: break-all;
max-height: 22rem;
overflow: auto;
}
.badge-soft {
background: var(--panel-light);
color: var(--text);
border: 1px solid var(--line);
font-weight: 500;
}
.badge-critical {
background: rgba(248, 113, 113, 0.18);
color: #fecaca;
border: 1px solid rgba(248, 113, 113, 0.45);
}
.badge-ca {
background: rgba(94, 234, 212, 0.16);
color: var(--accent);
border: 1px solid rgba(94, 234, 212, 0.4);
}
.badge-warn {
background: rgba(251, 191, 36, 0.16);
color: #fde68a;
border: 1px solid rgba(251, 191, 36, 0.4);
}
.badge-danger {
background: rgba(248, 113, 113, 0.16);
color: #fecaca;
border: 1px solid rgba(248, 113, 113, 0.4);
}
.list-group {
--bs-list-group-bg: transparent;
--bs-list-group-border-color: var(--line);
--bs-list-group-color: var(--text);
--bs-list-group-hover-bg: var(--panel-light);
}
.modal-content {
background: var(--panel);
border-color: var(--line);
}
.modal-header,
.modal-footer {
border-color: var(--line);
}
.alert {
border-color: var(--line);
}
.alert-danger {
background: rgba(248, 113, 113, 0.12);
color: #fecaca;
}
.alert-success {
background: rgba(74, 222, 128, 0.12);
color: #bbf7d0;
}
.alert-info {
background: rgba(94, 234, 212, 0.1);
color: #c9f7ee;
}
+6
View File
File diff suppressed because one or more lines are too long