mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(admin): serve Operator Console SPA at /admin (rust-embed)
Preact + Vite standalone admin-ui bundled into the server binary via rust-embed. Login is NIP-07 (Smirk wallet signs the NIP-98 admin challenge); tabs cover instance status, admin keys, invite codes, and the effective-config editor (live vs restart-required per field). admin-ui/dist is committed because rust-embed reads it at compile time; node_modules is gitignored (npm ci reproduces it). A lib test asserts index.html and the /admin/ asset base are embedded so an unbuilt console fails the build instead of an operator. The console is served on the admin plane only (loopback bind), behind the existing NIP-98 admin_guard + MAC allowlist.
This commit is contained in:
Generated
+177
-33
@@ -14,7 +14,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -77,14 +77,14 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e7e126d41c3e0a6c85db7ba216bce5e41d53c156191c67d993613685fcc9410"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"hex",
|
||||
"hmac",
|
||||
"pbkdf2",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -355,6 +355,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
@@ -418,7 +427,7 @@ version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"inout",
|
||||
]
|
||||
|
||||
@@ -445,6 +454,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -480,6 +495,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
@@ -548,6 +572,15 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
@@ -564,9 +597,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
@@ -609,7 +642,7 @@ version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -626,12 +659,23 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
@@ -662,7 +706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
@@ -688,7 +732,7 @@ dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -710,7 +754,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
@@ -1202,7 +1246,7 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1281,6 +1325,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.32"
|
||||
@@ -1627,7 +1680,7 @@ dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"once_cell",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"signature",
|
||||
]
|
||||
|
||||
@@ -1724,7 +1777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1739,6 +1792,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -2038,7 +2101,7 @@ version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
@@ -2143,7 +2206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
@@ -2580,8 +2643,8 @@ version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
"const-oid 0.9.6",
|
||||
"digest 0.10.7",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
@@ -2594,6 +2657,41 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
|
||||
dependencies = [
|
||||
"rust-embed-impl",
|
||||
"rust-embed-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-impl"
|
||||
version = "8.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
|
||||
dependencies = [
|
||||
"mime_guess",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn 2.0.118",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-utils"
|
||||
version = "8.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
|
||||
dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hex"
|
||||
version = "2.1.0"
|
||||
@@ -2699,6 +2797,15 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
@@ -2852,8 +2959,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2863,8 +2970,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2898,7 +3016,7 @@ version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
@@ -2962,11 +3080,12 @@ dependencies = [
|
||||
"rand",
|
||||
"reqwest",
|
||||
"rmp-serde",
|
||||
"rust-embed",
|
||||
"rustls 0.22.4",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
"tokio",
|
||||
@@ -3093,7 +3212,7 @@ dependencies = [
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"sqlformat",
|
||||
"thiserror 1.0.69",
|
||||
@@ -3133,7 +3252,7 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
@@ -3157,7 +3276,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"futures-channel",
|
||||
@@ -3178,7 +3297,7 @@ dependencies = [
|
||||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
@@ -3219,7 +3338,7 @@ dependencies = [
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
@@ -3822,6 +3941,12 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -3867,7 +3992,7 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -4003,6 +4128,16 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
@@ -4161,6 +4296,15 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
|
||||
@@ -32,6 +32,7 @@ tower_governor = "0.4"
|
||||
governor = "0.6"
|
||||
|
||||
# Database
|
||||
rust-embed = { version = "8", features = ["debug-embed"] }
|
||||
arc-swap = "1"
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "ipnetwork", "migrate", "json"] }
|
||||
ipnetwork = "0.20"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Node deps — reproducible from package-lock.json (`npm ci`).
|
||||
node_modules/
|
||||
|
||||
# NOTE: dist/ is intentionally NOT ignored. rust-embed bakes admin-ui/dist/ into
|
||||
# the server binary at compile time, so the built console is committed. Rebuild it
|
||||
# with `npm run build` in this directory whenever src/ changes (CI checks freshness).
|
||||
+5
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
:root{--smirk-bg: #2c1e12;--smirk-bg-elevated: #3a2a1b;--smirk-bg-sunken: #241810;--smirk-fg: #f3e9dd;--smirk-fg-muted: #b9a891;--smirk-accent: #f5c542;--smirk-accent-fg: #2c1e12;--smirk-border: rgba(255, 255, 255, .12);--smirk-border-strong: rgba(255, 255, 255, .22);--smirk-danger: #ef4444;--smirk-ok: #22c55e;--smirk-warn: #f59e0b;--smirk-radius: 10px;--smirk-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif}*{box-sizing:border-box}body{margin:0;background:var(--smirk-bg);color:var(--smirk-fg);font-family:var(--smirk-font);font-size:14px;line-height:1.5}h1,h2,h3{font-weight:700}button{font:inherit;cursor:pointer;border-radius:8px;border:1px solid var(--smirk-border);background:var(--smirk-bg-elevated);color:inherit;padding:8px 14px}button.primary{background:var(--smirk-accent);color:var(--smirk-accent-fg);border:none;font-weight:700}button.danger{color:var(--smirk-danger);border-color:var(--smirk-danger)}button:disabled{opacity:.5;cursor:default}input,textarea,select{font:inherit;color:inherit;background:var(--smirk-bg-sunken);border:1px solid var(--smirk-border);border-radius:8px;padding:8px 10px}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}a{color:var(--smirk-accent)}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Smirk Operator Console</title>
|
||||
<script type="module" crossorigin src="/admin/assets/index-DLOlKNyc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-uKG4VNAS.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Smirk Operator Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1023
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "smirk-admin-ui",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"description": "Operator console SPA, embedded into smirk-backend-core and served at /admin.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"preact": "^10.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Fetch layer for the /admin JSON API. Same-origin (the SPA is served from the admin
|
||||
// plane), bearer access token held in memory only (never localStorage). Admin routes
|
||||
// are outside the public OpenAPI, so this is hand-written.
|
||||
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export function setToken(t: string | null): void {
|
||||
accessToken = t;
|
||||
}
|
||||
export function hasToken(): boolean {
|
||||
return accessToken !== null;
|
||||
}
|
||||
|
||||
async function req(method: string, path: string, body?: unknown): Promise<unknown> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
const data: unknown = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
(data as { error?: string; message?: string })?.error ??
|
||||
(data as { message?: string })?.message ??
|
||||
`${res.status} ${res.statusText}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface ChallengeResp {
|
||||
challenge: string;
|
||||
url: string;
|
||||
instance_id: string;
|
||||
expires_in: number;
|
||||
}
|
||||
export interface TokenResp {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
}
|
||||
export interface KeyRow {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
status: string;
|
||||
}
|
||||
export interface InviteView {
|
||||
code_prefix: string;
|
||||
label: string | null;
|
||||
created_at: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
challenge: () => req('GET', '/admin/auth/challenge') as Promise<ChallengeResp>,
|
||||
verify: (admin_token: string, challenge: string) =>
|
||||
req('POST', '/admin/auth/verify', { admin_token, challenge }) as Promise<TokenResp>,
|
||||
logout: () => req('POST', '/admin/auth/logout', {}),
|
||||
me: () => req('GET', '/admin/me'),
|
||||
features: () => req('GET', '/admin/features'),
|
||||
keys: () => req('GET', '/admin/keys') as Promise<KeyRow[]>,
|
||||
addKey: (pubkey: string, label?: string) =>
|
||||
req('POST', '/admin/keys', label ? { pubkey, label } : { pubkey }),
|
||||
revokeKey: (id: string) => req('DELETE', `/admin/keys/${id}`),
|
||||
config: () => req('GET', '/admin/config'),
|
||||
putConfig: (patch: unknown, expected_versions: Record<string, number>) =>
|
||||
req('PUT', '/admin/config', { patch, expected_versions }),
|
||||
invites: () =>
|
||||
req('GET', '/admin/invites') as Promise<{ invites: InviteView[]; unused_count: number }>,
|
||||
mintInvites: (count: number, label?: string) =>
|
||||
req('POST', '/admin/invites', label ? { count, label } : { count }) as Promise<{
|
||||
codes: string[];
|
||||
}>,
|
||||
};
|
||||
@@ -0,0 +1,363 @@
|
||||
import { render } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import './tokens.css';
|
||||
import { api, setToken, type KeyRow, type InviteView } from './api';
|
||||
import { buildAdminToken, nip07 } from './nip98';
|
||||
|
||||
// ── login ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Login({ onDone }: { onDone: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const signIn = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const signer = nip07();
|
||||
if (!signer) {
|
||||
throw new Error(
|
||||
'No NIP-07 signer found. Enable your Smirk wallet (or nos2x/Alby) on this origin, then reload.',
|
||||
);
|
||||
}
|
||||
const c = await api.challenge();
|
||||
const token = await buildAdminToken(c, signer);
|
||||
const { access_token } = await api.verify(token, c.challenge);
|
||||
setToken(access_token);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Sign-in failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 420, margin: '12vh auto', padding: 24, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 40 }}>🐸</div>
|
||||
<h1>Smirk Operator Console</h1>
|
||||
<p style={{ color: 'var(--smirk-fg-muted)' }}>
|
||||
Sign in with an admin Nostr key on your instance's allowlist.
|
||||
</p>
|
||||
<button class="primary" style={{ padding: '12px 20px', fontSize: 16 }} disabled={busy} onClick={signIn}>
|
||||
{busy ? 'Signing in…' : 'Sign in with Smirk'}
|
||||
</button>
|
||||
{err && <p style={{ color: 'var(--smirk-danger)', marginTop: 16 }}>{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── shared ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, children }: { title: string; children: unknown }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--smirk-bg-elevated)',
|
||||
border: '1px solid var(--smirk-border)',
|
||||
borderRadius: 'var(--smirk-radius)',
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const reload = () => {
|
||||
setErr(null);
|
||||
fn()
|
||||
.then(setData)
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(reload, deps);
|
||||
return { data, err, reload };
|
||||
}
|
||||
|
||||
// ── status ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusTab() {
|
||||
const { data, err } = useAsync(() => api.features());
|
||||
return (
|
||||
<Section title="Instance features">
|
||||
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
|
||||
<pre style={{ overflow: 'auto', fontSize: 12 }}>{JSON.stringify(data, null, 2)}</pre>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── admin keys ────────────────────────────────────────────────────────────────
|
||||
|
||||
function KeysTab() {
|
||||
const { data, err, reload } = useAsync(() => api.keys());
|
||||
const [pubkey, setPubkey] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const add = async () => {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.addKey(pubkey.trim().toLowerCase());
|
||||
setPubkey('');
|
||||
reload();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const revoke = async (id: string) => {
|
||||
if (!confirm('Revoke this admin key?')) return;
|
||||
await api.revokeKey(id);
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Admin keys">
|
||||
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<tbody>
|
||||
{(data ?? []).map((k: KeyRow) => (
|
||||
<tr style={{ borderTop: '1px solid var(--smirk-border)' }}>
|
||||
<td style={{ fontFamily: 'monospace', padding: '6px 4px' }}>{k.pubkey.slice(0, 16)}…</td>
|
||||
<td style={{ padding: '6px 4px' }}>{k.status}</td>
|
||||
<td style={{ padding: '6px 4px', textAlign: 'right' }}>
|
||||
{k.status !== 'revoked' && (
|
||||
<button class="danger" onClick={() => revoke(k.id)}>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<input
|
||||
style={{ flex: 1 }}
|
||||
placeholder="new admin pubkey (64 hex)"
|
||||
value={pubkey}
|
||||
onInput={(e) => setPubkey((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button class="primary" disabled={busy || pubkey.trim().length !== 64} onClick={add}>
|
||||
Add key
|
||||
</button>
|
||||
</div>
|
||||
{msg && <p style={{ color: 'var(--smirk-danger)' }}>{msg}</p>}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── invites ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function InvitesTab() {
|
||||
const { data, err, reload } = useAsync(() => api.invites());
|
||||
const [count, setCount] = useState(5);
|
||||
const [label, setLabel] = useState('');
|
||||
const [minted, setMinted] = useState<string[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const mint = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.mintInvites(count, label.trim() || undefined);
|
||||
setMinted(r.codes);
|
||||
reload();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title={`Invite codes${data ? ` — ${data.unused_count} unused` : ''}`}>
|
||||
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
style={{ width: 80 }}
|
||||
value={count}
|
||||
onInput={(e) => setCount(Number((e.target as HTMLInputElement).value))}
|
||||
/>
|
||||
<input
|
||||
style={{ flex: 1 }}
|
||||
placeholder="label (optional)"
|
||||
value={label}
|
||||
onInput={(e) => setLabel((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button class="primary" disabled={busy} onClick={mint}>
|
||||
Mint
|
||||
</button>
|
||||
</div>
|
||||
{minted && (
|
||||
<div style={{ background: 'var(--smirk-bg-sunken)', padding: 12, borderRadius: 8, marginBottom: 12 }}>
|
||||
<b>New codes — copy now, they are shown once:</b>
|
||||
<pre style={{ margin: '6px 0 0', whiteSpace: 'pre-wrap' }}>{minted.join('\n')}</pre>
|
||||
</div>
|
||||
)}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<tbody>
|
||||
{(data?.invites ?? []).map((i: InviteView) => (
|
||||
<tr style={{ borderTop: '1px solid var(--smirk-border)' }}>
|
||||
<td style={{ fontFamily: 'monospace', padding: '6px 4px' }}>{i.code_prefix}…</td>
|
||||
<td style={{ padding: '6px 4px' }}>{i.label ?? ''}</td>
|
||||
<td style={{ padding: '6px 4px', color: 'var(--smirk-fg-muted)' }}>{i.created_at.slice(0, 10)}</td>
|
||||
<td style={{ padding: '6px 4px' }}>{i.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Overlay = Record<string, Record<string, unknown>>;
|
||||
interface ConfigResp {
|
||||
effective: Overlay;
|
||||
overlay: Overlay;
|
||||
versions: Record<string, number>;
|
||||
runtime_class: Record<string, string>;
|
||||
restart_pending: boolean;
|
||||
}
|
||||
|
||||
function ConfigTab() {
|
||||
const { data, err, reload } = useAsync(() => api.config() as Promise<ConfigResp>);
|
||||
const [edit, setEdit] = useState<Overlay>({});
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setEdit(structuredClone(data.effective));
|
||||
}, [data]);
|
||||
|
||||
if (err) return <Section title="Config">{<p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}</Section>;
|
||||
if (!data) return <Section title="Config">Loading…</Section>;
|
||||
|
||||
const setField = (section: string, field: string, value: unknown) =>
|
||||
setEdit((e) => ({ ...e, [section]: { ...(e[section] ?? {}), [field]: value } }));
|
||||
|
||||
const save = async (section: string) => {
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.putConfig(
|
||||
{ [section]: edit[section] },
|
||||
{ [section]: data.versions[section] ?? 0 },
|
||||
);
|
||||
setMsg(`Saved ${section}.`);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.restart_pending && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(245,158,11,0.12)',
|
||||
border: '1px solid var(--smirk-warn)',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
⚠ A saved change needs a restart to take effect (systemctl restart, or auto mode).
|
||||
</div>
|
||||
)}
|
||||
{msg && <p style={{ color: 'var(--smirk-accent)' }}>{msg}</p>}
|
||||
{Object.keys(edit)
|
||||
.sort()
|
||||
.map((section) => (
|
||||
<Section title={section}>
|
||||
{Object.entries(edit[section] ?? {}).map(([field, value]) => {
|
||||
const cls = data.runtime_class[`${section}.${field}`] ?? 'restart-required';
|
||||
const overridden = data.overlay[section]?.[field] !== undefined;
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
|
||||
<label style={{ flex: 1, minWidth: 0 }}>
|
||||
{field}{' '}
|
||||
<span style={{ fontSize: 11, color: 'var(--smirk-fg-muted)' }}>
|
||||
· {cls === 'restart-required' ? 'restart' : 'live'}
|
||||
{overridden ? ' · db' : ''}
|
||||
</span>
|
||||
</label>
|
||||
{typeof value === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => setField(section, field, (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
style={{ width: 220 }}
|
||||
value={String(value ?? '')}
|
||||
onInput={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
setField(section, field, typeof value === 'number' ? Number(v) : v);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button class="primary" style={{ marginTop: 8 }} onClick={() => save(section)}>
|
||||
Save {section}
|
||||
</button>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── shell ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = ['Status', 'Keys', 'Invites', 'Config'] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
function Console({ onLogout }: { onLogout: () => void }) {
|
||||
const [tab, setTab] = useState<Tab>('Status');
|
||||
return (
|
||||
<div style={{ maxWidth: 760, margin: '0 auto', padding: 20 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0, flex: 1 }}>🐸 Smirk Operator Console</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
api.logout().catch(() => {});
|
||||
setToken(null);
|
||||
onLogout();
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</header>
|
||||
<nav style={{ display: 'flex', gap: 6, marginBottom: 16 }}>
|
||||
{TABS.map((t) => (
|
||||
<button class={t === tab ? 'primary' : ''} onClick={() => setTab(t)}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{tab === 'Status' && <StatusTab />}
|
||||
{tab === 'Keys' && <KeysTab />}
|
||||
{tab === 'Invites' && <InvitesTab />}
|
||||
{tab === 'Config' && <ConfigTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [authed, setAuthed] = useState(false);
|
||||
return authed ? <Console onLogout={() => setAuthed(false)} /> : <Login onDone={() => setAuthed(true)} />;
|
||||
}
|
||||
|
||||
render(<App />, document.getElementById('app')!);
|
||||
@@ -0,0 +1,60 @@
|
||||
// Admin login = a NIP-98 kind-27235 "signed action" over a server-issued nonce.
|
||||
// The backend (admin_verify) binds SIX tags exactly once and a request-descriptor
|
||||
// hash over an EMPTY body. Get any of this wrong and verify returns an opaque 401,
|
||||
// so this mirrors src/core/crypto/nip98.rs precisely.
|
||||
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export interface Challenge {
|
||||
challenge: string;
|
||||
url: string;
|
||||
instance_id: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
/** A NIP-07 signer: `window.nostr` (Smirk / Alby / nos2x). */
|
||||
export interface Nip07 {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: {
|
||||
kind: number;
|
||||
created_at: number;
|
||||
content: string;
|
||||
tags: string[][];
|
||||
}): Promise<{ id: string; pubkey: string; sig: string; [k: string]: unknown }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + sign the admin_login event for a challenge and return the
|
||||
* `Nostr <base64(event)>` token the backend's `admin_token` field expects.
|
||||
*/
|
||||
export async function buildAdminToken(c: Challenge, signer: Nip07): Promise<string> {
|
||||
// request_descriptor("POST", "/admin/auth/verify", "", b"") then its sha256.
|
||||
const emptyBodyHash = await sha256Hex('');
|
||||
const descriptor = `POST\n/admin/auth/verify\n\n${emptyBodyHash}`;
|
||||
const payload = await sha256Hex(descriptor);
|
||||
|
||||
const signed = await signer.signEvent({
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [
|
||||
['u', c.url],
|
||||
['method', 'POST'],
|
||||
['purpose', 'admin_login'],
|
||||
['challenge', c.challenge],
|
||||
['payload', payload],
|
||||
['instance_id', c.instance_id],
|
||||
],
|
||||
});
|
||||
|
||||
return `Nostr ${btoa(JSON.stringify(signed))}`;
|
||||
}
|
||||
|
||||
/** The page-injected NIP-07 provider, or null. Smirk installs `window.nostr`. */
|
||||
export function nip07(): Nip07 | null {
|
||||
const n = (window as unknown as { nostr?: Nip07 }).nostr;
|
||||
return n && typeof n.signEvent === 'function' ? n : null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* Smirk brand tokens (vendored — the admin-ui is standalone, no @smirk/ui dep). */
|
||||
:root {
|
||||
--smirk-bg: #2c1e12;
|
||||
--smirk-bg-elevated: #3a2a1b;
|
||||
--smirk-bg-sunken: #241810;
|
||||
--smirk-fg: #f3e9dd;
|
||||
--smirk-fg-muted: #b9a891;
|
||||
--smirk-accent: #f5c542;
|
||||
--smirk-accent-fg: #2c1e12;
|
||||
--smirk-border: rgba(255, 255, 255, 0.12);
|
||||
--smirk-border-strong: rgba(255, 255, 255, 0.22);
|
||||
--smirk-danger: #ef4444;
|
||||
--smirk-ok: #22c55e;
|
||||
--smirk-warn: #f59e0b;
|
||||
--smirk-radius: 10px;
|
||||
--smirk-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--smirk-bg);
|
||||
color: var(--smirk-fg);
|
||||
font-family: var(--smirk-font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1, h2, h3 { font-weight: 700; }
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--smirk-border);
|
||||
background: var(--smirk-bg-elevated);
|
||||
color: inherit;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
button.primary { background: var(--smirk-accent); color: var(--smirk-accent-fg); border: none; font-weight: 700; }
|
||||
button.danger { color: var(--smirk-danger); border-color: var(--smirk-danger); }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
input, textarea, select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--smirk-bg-sunken);
|
||||
border: 1px solid var(--smirk-border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
a { color: var(--smirk-accent); }
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Served by the Rust backend at /admin (rust-embed). `base: '/admin/'` makes the
|
||||
// built asset URLs absolute under /admin/assets/ so they resolve regardless of the
|
||||
// trailing slash. Preact JSX via esbuild (no plugin, keeps the toolchain tiny).
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
target: 'es2022',
|
||||
emptyOutDir: true,
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'preact',
|
||||
},
|
||||
});
|
||||
+70
-1
@@ -1032,8 +1032,61 @@ pub async fn admin_invites_list(
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Operator console SPA (embedded, served at /admin) ─────────────────────────
|
||||
// The Preact console built from admin-ui/ and embedded via rust-embed (single-binary
|
||||
// self-host, no separate deploy). Served UNAUTHENTICATED (no admin_guard) — the shell
|
||||
// loads without a session and runs the NIP-98 login client-side against the guarded
|
||||
// JSON routes. Only exact /admin, /admin/, /admin/assets/*path are added, so the JSON
|
||||
// routes (/admin/keys, /admin/config, ...) are never shadowed.
|
||||
|
||||
#[derive(rust_embed::RustEmbed)]
|
||||
#[folder = "admin-ui/dist/"]
|
||||
struct AdminAssets;
|
||||
|
||||
pub async fn admin_index() -> Response {
|
||||
match AdminAssets::get("index.html") {
|
||||
Some(f) => (
|
||||
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
f.data.into_owned(),
|
||||
)
|
||||
.into_response(),
|
||||
None => (
|
||||
StatusCode::NOT_FOUND,
|
||||
"operator console not built (run: npm --prefix admin-ui run build)",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn admin_asset(Path(path): Path<String>) -> Response {
|
||||
match AdminAssets::get(&format!("assets/{path}")) {
|
||||
Some(f) => {
|
||||
let mime = if path.ends_with(".js") || path.ends_with(".mjs") {
|
||||
"application/javascript; charset=utf-8"
|
||||
} else if path.ends_with(".css") {
|
||||
"text/css; charset=utf-8"
|
||||
} else if path.ends_with(".svg") {
|
||||
"image/svg+xml"
|
||||
} else if path.ends_with(".woff2") {
|
||||
"font/woff2"
|
||||
} else if path.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
([(axum::http::header::CONTENT_TYPE, mime)], f.data.into_owned()).into_response()
|
||||
}
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/admin", get(admin_index))
|
||||
.route("/admin/", get(admin_index))
|
||||
.route("/admin/assets/*path", get(admin_asset))
|
||||
.route("/admin/auth/challenge", post(admin_challenge))
|
||||
.route("/admin/auth/verify", post(admin_verify))
|
||||
.route("/admin/auth/refresh", post(admin_refresh))
|
||||
@@ -1049,7 +1102,23 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::host_allowed;
|
||||
use super::{host_allowed, AdminAssets};
|
||||
|
||||
// The Operator Console SPA must be built into admin-ui/dist/ and embedded at
|
||||
// compile time (rust-embed). If admin-ui was never built, index.html is absent
|
||||
// and /admin serves nothing — this catches that before it reaches an operator.
|
||||
#[test]
|
||||
fn admin_console_is_embedded() {
|
||||
let index = AdminAssets::get("index.html").expect("admin-ui/dist/index.html embedded");
|
||||
let html = std::str::from_utf8(&index.data).expect("index.html is utf-8");
|
||||
assert!(html.contains("Smirk Operator Console"), "console title missing");
|
||||
// Vite rewrites asset URLs to the /admin/ base; the served route matches.
|
||||
assert!(html.contains("/admin/assets/"), "asset base not rewritten to /admin/");
|
||||
assert!(
|
||||
AdminAssets::iter().any(|p| p.starts_with("assets/")),
|
||||
"no built JS/CSS assets embedded",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_allowlist() {
|
||||
|
||||
Reference in New Issue
Block a user