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:
Generated
+568
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 & 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>
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod nav;
|
||||
pub mod toast;
|
||||
pub mod ui;
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>>()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
+232
@@ -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
@@ -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;
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user