feat: implement initial user authentication, session management, and admin dashboard routing with

This commit is contained in:
2026-03-03 15:55:26 +00:00
parent 02709fbea1
commit ba199b8bbe
16 changed files with 1419 additions and 33 deletions

600
Cargo.lock generated
View File

@@ -2,12 +2,27 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "askama"
version = "0.15.4"
@@ -133,6 +148,28 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76"
dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
@@ -154,6 +191,19 @@ dependencies = [
"serde",
]
[[package]]
name = "bcrypt"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a0f5948f30df5f43ac29d310b7476793be97c50787e6ef4a63d960a0d0be827"
dependencies = [
"base64",
"blowfish",
"getrandom 0.3.4",
"subtle",
"zeroize",
]
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -176,13 +226,39 @@ dependencies = [
name = "blog_cms"
version = "0.1.0"
dependencies = [
"anyhow",
"askama",
"axum",
"axum-extra",
"base64",
"bcrypt",
"dotenvy",
"rand 0.10.0",
"serde",
"sqlx",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"cipher",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -211,6 +287,27 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.0",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -226,6 +323,17 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -235,6 +343,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"
@@ -286,6 +403,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -476,6 +602,32 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core 0.10.0",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -703,6 +855,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
@@ -732,6 +890,17 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
@@ -740,6 +909,16 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "js-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -749,6 +928,12 @@ dependencies = [
"spin",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.182"
@@ -805,6 +990,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.8.4"
@@ -844,6 +1038,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@@ -855,11 +1058,17 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.5",
"smallvec",
"zeroize",
]
[[package]]
name = "num-conv"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -994,6 +1203,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1003,6 +1218,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1021,6 +1246,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.5"
@@ -1029,7 +1260,18 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
dependencies = [
"chacha20",
"getrandom 0.4.1",
"rand_core 0.10.0",
]
[[package]]
@@ -1039,7 +1281,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -1048,9 +1290,15 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1069,6 +1317,23 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rsa"
version = "0.9.10"
@@ -1082,7 +1347,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
@@ -1095,6 +1360,12 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
@@ -1107,6 +1378,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@@ -1180,7 +1457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -1191,10 +1468,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1208,7 +1494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -1369,7 +1655,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.5",
"rsa",
"serde",
"sha1",
@@ -1407,7 +1693,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
@@ -1514,6 +1800,46 @@ dependencies = [
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -1592,6 +1918,22 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags",
"bytes",
"http",
"http-body",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -1634,6 +1976,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -1669,6 +2041,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "url"
version = "2.5.8"
@@ -1687,6 +2065,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
dependencies = [
"getrandom 0.4.1",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -1705,12 +2100,109 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "whoami"
version = "1.6.1"
@@ -1885,6 +2377,94 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"
version = "0.6.2"

View File

@@ -4,8 +4,18 @@ version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.102"
askama = "0.15.4"
axum = "0.8.8"
axum-extra = { version = "0.12.5", features = ["cookie"] }
base64 = "0.22.1"
bcrypt = "0.18.0"
dotenvy = "0.15.7"
rand = "0.10.0"
serde = { version = "1.0.228", features = ["derive"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] }
tower-http = { version = "0.6.8", features = ["trace"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
uuid = { version = "1.21.0", features = ["v4"] }

4
cookies.txt Normal file
View File

@@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

3
server.log Normal file
View File

@@ -0,0 +1,3 @@
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
Running `target/debug/blog_cms`
Server running at http://0.0.0.0:3000

27
src/error.rs Normal file
View File

@@ -0,0 +1,27 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
pub struct AppError(anyhow::Error);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
tracing::error!("Application error: {:?}", self.0);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", self.0),
)
.into_response()
}
}
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}

142
src/handlers/admin.rs Normal file
View File

@@ -0,0 +1,142 @@
use askama::Template;
use axum::{
extract::{Extension, Form, Path, State},
response::{IntoResponse, Redirect, Response},
routing::{get, post},
Router,
};
use bcrypt::{hash, DEFAULT_COST};
use serde::Deserialize;
use std::sync::Arc;
use crate::models::{Role, User};
use crate::utils::HtmlTemplate;
use crate::AppState;
use crate::error::AppError;
use anyhow::Context;
#[derive(Template)]
#[template(path = "dashboard.html")]
pub struct DashboardTemplate<'a> {
pub current_user: &'a User,
pub users: Vec<User>,
pub error: Option<&'a str>,
}
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(dashboard))
.route("/users/add", post(add_user))
.route("/users/delete/{id}", post(delete_user))
.route("/users/password/{id}", post(change_password))
}
pub async fn dashboard(
State(state): State<Arc<AppState>>,
Extension(current_user): Extension<User>,
) -> Result<Response, AppError> {
render_dashboard(&state, &current_user, None).await
}
async fn render_dashboard(
state: &AppState,
current_user: &User,
error: Option<&str>,
) -> Result<Response, AppError> {
let users: Vec<User> = sqlx::query_as("SELECT * FROM users")
.fetch_all(&state.db)
.await
.context("Failed to fetch users from database in render_dashboard")?;
Ok(HtmlTemplate(DashboardTemplate {
current_user,
users,
error,
})
.into_response())
}
#[derive(Deserialize)]
pub struct AddUserPayload {
pub username: String,
pub password: Option<String>,
pub role: String,
}
pub async fn add_user(
State(state): State<Arc<AppState>>,
Extension(current_user): Extension<User>,
Form(payload): Form<AddUserPayload>,
) -> Result<Response, AppError> {
if current_user.role() != Role::Admin {
return render_dashboard(&state, &current_user, Some("Permission denied")).await;
}
let password = payload.password.unwrap_or_else(|| "password".to_string());
let hashed = hash(&password, DEFAULT_COST).context("Failed to hash password")?;
let role = if payload.role == "admin" {
"admin"
} else {
"readonly"
};
sqlx::query("INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)")
.bind(&payload.username)
.bind(&hashed)
.bind(role)
.execute(&state.db)
.await
.context("Failed to insert new user into database")?;
Ok(Redirect::temporary("/__dungeon").into_response())
}
pub async fn delete_user(
State(state): State<Arc<AppState>>,
Extension(current_user): Extension<User>,
Path(id): Path<i64>,
) -> Result<Response, AppError> {
if current_user.role() != Role::Admin {
return render_dashboard(&state, &current_user, Some("Permission denied")).await;
}
if current_user.id == id {
return render_dashboard(&state, &current_user, Some("Cannot delete yourself")).await;
}
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(id)
.execute(&state.db)
.await
.context(format!("Failed to delete user with id {}", id))?;
Ok(Redirect::temporary("/__dungeon").into_response())
}
#[derive(Deserialize)]
pub struct ChangePasswordPayload {
pub password: String,
}
pub async fn change_password(
State(state): State<Arc<AppState>>,
Extension(current_user): Extension<User>,
Path(id): Path<i64>,
Form(payload): Form<ChangePasswordPayload>,
) -> Result<Response, AppError> {
if current_user.role() != Role::Admin {
return render_dashboard(&state, &current_user, Some("Permission denied")).await;
}
let hashed = hash(&payload.password, DEFAULT_COST).context("Failed to hash password")?;
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
.bind(&hashed)
.bind(id)
.execute(&state.db)
.await
.context(format!("Failed to update password for user with id {}", id))?;
Ok(Redirect::temporary("/__dungeon").into_response())
}

153
src/handlers/auth.rs Normal file
View File

@@ -0,0 +1,153 @@
use askama::Template;
use axum::{
extract::{Form, State},
response::{IntoResponse, Redirect, Response},
routing::{get, post},
Router,
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use bcrypt::{hash, verify, DEFAULT_COST};
use serde::Deserialize;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
use crate::models::User;
use crate::utils::HtmlTemplate;
use crate::AppState;
use crate::error::AppError;
use anyhow::Context;
#[derive(Template)]
#[template(path = "setup.html")]
pub struct SetupTemplate<'a> {
pub error: Option<&'a str>,
}
#[derive(Template)]
#[template(path = "login.html")]
pub struct LoginTemplate<'a> {
pub error: Option<&'a str>,
}
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/setup", get(setup_form).post(setup_submit))
.route("/login", get(login_form).post(login_submit))
.route("/logout", post(logout_submit))
}
pub async fn setup_form(State(state): State<Arc<AppState>>) -> Result<Response, AppError> {
// Check if users exist
let count: (i64,) = sqlx::query_as("SELECT count(*) FROM users")
.fetch_one(&state.db)
.await
.context("Failed to check user count for setup form")?;
if count.0 > 0 {
return Ok(Redirect::temporary("/__dungeon/login").into_response());
}
Ok(HtmlTemplate(SetupTemplate { error: None }).into_response())
}
#[derive(Deserialize)]
pub struct AuthPayload {
pub username: String,
pub password: String,
}
pub async fn setup_submit(
State(state): State<Arc<AppState>>,
Form(payload): Form<AuthPayload>,
) -> Result<Response, AppError> {
let count: (i64,) = sqlx::query_as("SELECT count(*) FROM users")
.fetch_one(&state.db)
.await
.context("Failed to check user count for setup submit")?;
if count.0 > 0 {
return Ok(Redirect::temporary("/__dungeon/login").into_response());
}
let hashed = hash(&payload.password, DEFAULT_COST).context("Failed to hash password")?;
sqlx::query("INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)")
.bind(&payload.username)
.bind(&hashed)
.bind("admin")
.execute(&state.db)
.await
.context("Failed to insert initial admin user")?;
Ok(Redirect::temporary("/__dungeon/login").into_response())
}
pub async fn login_form() -> impl IntoResponse {
HtmlTemplate(LoginTemplate { error: None })
}
pub async fn login_submit(
State(state): State<Arc<AppState>>,
cookie_jar: CookieJar,
Form(payload): Form<AuthPayload>,
) -> Result<Response, AppError> {
let user: Option<User> = sqlx::query_as("SELECT * FROM users WHERE username = ?")
.bind(&payload.username)
.fetch_optional(&state.db)
.await
.context("Failed to fetch user during login")?;
if let Some(user) = user {
let is_valid = verify(&payload.password, &user.password_hash).context("Failed to verify password")?;
if is_valid {
// Create session
let session_id = Uuid::new_v4().to_string();
let expires_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("Failed to get system time")?
.as_secs() as i64
+ 86400 * 30; // 30 days
sqlx::query("INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)")
.bind(&session_id)
.bind(user.id)
.bind(expires_at)
.execute(&state.db)
.await
.context("Failed to insert session into database")?;
let mut cookie = Cookie::new("dungeon_session", session_id);
cookie.set_path("/");
return Ok((cookie_jar.add(cookie), Redirect::temporary("/__dungeon")).into_response());
}
}
Ok(HtmlTemplate(LoginTemplate {
error: Some("Invalid username or password"),
})
.into_response())
}
pub async fn logout_submit(
State(state): State<Arc<AppState>>,
cookie_jar: CookieJar,
) -> Result<Response, AppError> {
if let Some(cookie) = cookie_jar.get("dungeon_session") {
sqlx::query("DELETE FROM sessions WHERE id = ?")
.bind(cookie.value())
.execute(&state.db)
.await
.context("Failed to delete session on logout")?;
}
// Cookie removal might need build or just empty cookie matching the name.
// Setting max-age to 0 is an alternative, but jar.remove works with path and matching name.
let remove_cookie = Cookie::build(("dungeon_session", "")).path("/").build();
Ok((
cookie_jar.remove(remove_cookie),
Redirect::temporary("/__dungeon/login"),
)
.into_response())
}

19
src/handlers/mod.rs Normal file
View File

@@ -0,0 +1,19 @@
use crate::{middleware::auth_middleware, AppState};
use axum::{middleware, Router};
use std::sync::Arc;
pub mod admin;
pub mod auth;
pub fn router(state: &Arc<AppState>) -> Router<Arc<AppState>> {
Router::new()
// Admin dashboard routes under /__dungeon
.merge(admin::router())
// Auth routes under /__dungeon
.merge(auth::router())
// Apply middleware to all /__dungeon routes
.route_layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
))
}

View File

@@ -1,7 +1,7 @@
use askama::Template;
use axum::{
extract::State,
response::{Html, IntoResponse},
response::IntoResponse,
routing::get,
Router,
};
@@ -11,9 +11,15 @@ use std::sync::Arc;
use tokio::net::TcpListener;
use std::env;
pub mod models;
pub mod middleware;
pub mod utils;
pub mod error;
pub mod handlers;
#[derive(Clone)]
struct AppState {
db: SqlitePool,
pub struct AppState {
pub db: SqlitePool,
}
#[derive(Template)]
@@ -22,35 +28,25 @@ struct IndexTemplate {
title: String,
}
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> axum::response::Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template: {}", err),
)
.into_response(),
}
}
}
async fn index(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
let template = IndexTemplate {
title: "Coming Soon".to_string(),
};
HtmlTemplate(template)
crate::utils::HtmlTemplate(template)
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
"blog_cms=debug,tower_http=debug,axum=debug".into()
}),
)
.init();
let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://data.db".to_string());
let port = env::var("PORT").unwrap_or_else(|_| "3000".to_string());
let addr = format!("0.0.0.0:{}", port);
@@ -65,11 +61,42 @@ async fn main() {
.await
.expect("Failed to connect to SQLite database");
// Create schema
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'readonly'
);
"#,
)
.execute(&db_pool)
.await
.expect("Failed to create users table");
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
"#,
)
.execute(&db_pool)
.await
.expect("Failed to create sessions table");
let app_state = Arc::new(AppState { db: db_pool });
let app = Router::new()
.route("/", get(index))
.with_state(app_state);
.nest("/__dungeon", handlers::router(&app_state)) // I'll create a single `handlers::router`
.with_state(app_state.clone())
.layer(tower_http::trace::TraceLayer::new_for_http());
let listener = TcpListener::bind(&addr).await.unwrap();
println!("Server running at http://{}", addr);

92
src/middleware.rs Normal file
View File

@@ -0,0 +1,92 @@
use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Redirect, Response},
};
use axum_extra::extract::cookie::CookieJar;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::models::{Session, User};
use crate::AppState;
pub async fn auth_middleware(
cookie_jar: CookieJar,
State(state): State<Arc<AppState>>,
mut request: Request,
next: Next,
) -> Result<Response, Response> {
let path = request.uri().path();
// Allow access to login and setup directly
// Because this middleware is on a nested router, the path will have the `/__dungeon` prefix stripped.
if path == "/login" || path == "/setup" || path == "/__dungeon/login" || path == "/__dungeon/setup" {
return Ok(next.run(request).await);
}
// Check if there are any users in the database
let user_count: (i64,) = sqlx::query_as("SELECT count(*) FROM users")
.fetch_one(&state.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)
.into_response()
})?;
if user_count.0 == 0 {
return Err(Redirect::temporary("/__dungeon/setup").into_response());
}
// Check session
let session_cookie = cookie_jar.get("dungeon_session");
if let Some(cookie) = session_cookie {
let session_id = cookie.value();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let session: Option<Session> = sqlx::query_as("SELECT * FROM sessions WHERE id = ? AND expires_at > ?")
.bind(session_id)
.bind(now)
.fetch_optional(&state.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)
.into_response()
})?;
if let Some(session) = session {
// Get user
let user: Option<User> = sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(session.user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)
.into_response()
})?;
if let Some(user) = user {
// Insert User into request extensions
request.extensions_mut().insert(user);
return Ok(next.run(request).await);
}
}
}
// If no valid session or user, redirect to login
Err(Redirect::temporary("/__dungeon/login").into_response())
}

49
src/models.rs Normal file
View File

@@ -0,0 +1,49 @@
use serde::{Deserialize, Serialize};
use sqlx::prelude::FromRow;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum Role {
Admin,
Readonly,
}
impl ToString for Role {
fn to_string(&self) -> String {
match self {
Role::Admin => "admin".to_string(),
Role::Readonly => "readonly".to_string(),
}
}
}
impl std::str::FromStr for Role {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"admin" => Ok(Role::Admin),
"readonly" => Ok(Role::Readonly),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: i64,
pub username: String,
pub password_hash: String,
pub role: String,
}
impl User {
pub fn role(&self) -> Role {
self.role.parse().unwrap_or(Role::Readonly)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Session {
pub id: String,
pub user_id: i64,
pub expires_at: i64, // Unix timestamp in seconds
}

23
src/utils.rs Normal file
View File

@@ -0,0 +1,23 @@
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
pub struct HtmlTemplate<T>(pub T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template: {}", err),
)
.into_response(),
}
}
}

139
templates/base.html Normal file
View File

@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Dungeon{% endblock %}</title>
<style>
:root {
--bg: #0f172a;
--surface: #1e293b;
--primary: #3b82f6;
--text: #f8fafc;
--text-muted: #94a3b8;
--border: #334155;
--danger: #ef4444;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
}
main {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.card {
background: var(--surface);
padding: 2rem;
border-radius: 8px;
border: 1px solid var(--border);
max-width: 400px;
margin: 4rem auto;
}
.dashboard-container {
display: flex;
gap: 2rem;
}
input,
select,
button {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 1rem;
box-sizing: border-box;
}
button {
background: var(--primary);
border: none;
font-weight: bold;
cursor: pointer;
transition: opacity 0.2s;
}
button:hover {
opacity: 0.9;
}
.btn-danger {
background: var(--danger);
}
.error {
color: var(--danger);
margin-bottom: 1rem;
font-weight: bold;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th,
td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
color: var(--text-muted);
font-weight: 500;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
h1 {
margin: 0;
}
.form-row {
display: flex;
gap: 1rem;
}
.flex {
display: flex;
}
.gap-2 {
gap: 0.5rem;
}
</style>
</head>
<body>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

83
templates/dashboard.html Normal file
View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Dungeon Dashboard{% endblock %}
{% block content %}
<div class="header">
<h1>Dungeon Dashboard</h1>
<form method="POST" action="/__dungeon/logout" style="margin: 0;">
<button type="submit" style="width: auto; margin: 0; padding: 0.5rem 1rem;">Logout ({{ current_user.username
}})</button>
</form>
</div>
{% if let Some(err) = error %}
<div class="error">{{ err }}</div>
{% endif %}
<div class="dashboard-container">
<div style="flex: 2;">
<h3>Users</h3>
<div style="background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden;">
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td><span
style="padding: 0.25rem 0.5rem; background: var(--bg); border-radius: 4px; font-size: 0.875rem;">{{
user.role }}</span></td>
<td>
{% if current_user.role == "admin" %}
<div class="flex gap-2" style="align-items: center;">
{% if user.id != current_user.id %}
<form method="POST" action="/__dungeon/users/delete/{{ user.id }}" style="margin: 0;">
<button type="submit" class="btn-danger"
style="padding: 0.25rem 0.5rem; margin: 0; font-size: 0.875rem;">Delete</button>
</form>
{% endif %}
<form method="POST" action="/__dungeon/users/password/{{ user.id }}"
style="margin: 0; display: flex; gap: 0.5rem;">
<input type="password" name="password" placeholder="New Password" required
style="margin: 0; padding: 0.25rem 0.5rem; width: 120px;">
<button type="submit"
style="padding: 0.25rem 0.5rem; margin: 0; font-size: 0.875rem; width: auto;">Change</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if current_user.role == "admin" %}
<div style="flex: 1;">
<div class="card" style="margin: 0; max-width: 100%;">
<h3 style="margin-top: 0;">Add User</h3>
<form method="POST" action="/__dungeon/users/add">
<label>Username</label>
<input type="text" name="username" required>
<label>Password</label>
<input type="password" name="password" required>
<label>Role</label>
<select name="role">
<option value="readonly">Read Only</option>
<option value="admin">Admin</option>
</select>
<button type="submit">Create User</button>
</form>
</div>
</div>
{% endif %}
</div>
{% endblock %}

17
templates/login.html Normal file
View File

@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Dungeon Login{% endblock %}
{% block content %}
<div class="card">
<h2>Enter the Dungeon</h2>
{% if let Some(err) = error %}
<div class="error">{{ err }}</div>
{% endif %}
<form method="POST" action="/__dungeon/login">
<label>Username</label>
<input type="text" name="username" required autocomplete="username">
<label>Password</label>
<input type="password" name="password" required autocomplete="current-password">
<button type="submit">Login</button>
</form>
</div>
{% endblock %}

18
templates/setup.html Normal file
View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Setup Dungeon{% endblock %}
{% block content %}
<div class="card">
<h2>Initial Setup</h2>
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">Create the initial admin user to access the dungeon.</p>
{% if let Some(err) = error %}
<div class="error">{{ err }}</div>
{% endif %}
<form method="POST" action="/__dungeon/setup">
<label>Username</label>
<input type="text" name="username" required autocomplete="off">
<label>Password</label>
<input type="password" name="password" required>
<button type="submit">Create Admin</button>
</form>
</div>
{% endblock %}