10 Commits
Author SHA1 Message Date
schn33fuchs af8efedf39 Styles better 2026-05-13 22:01:58 +02:00
schn33fuchs 5098ec8b99 Home networks 2026-05-13 22:01:48 +02:00
schn33fuchs dc7c87613a Funny counter 2026-05-13 22:01:39 +02:00
schn33fuchs 2c5458743c .gitignore 2026-05-13 16:59:10 +02:00
schn33fuchs cf24d6156c Small Fix
Removed unneccesary things from archive
2026-05-12 15:47:35 +02:00
schn33fuchs de2199e1c3 After merge fix
Forgor a &&
2026-05-11 21:23:08 +02:00
schn33fuchs 5199300856 Merge branch 'main' into styles-nino 2026-05-11 21:06:26 +02:00
schn33fuchs 289f83df3b Old logout look
It's better
2026-05-11 20:48:04 +02:00
schn33fuchs 928ca1de11 Improvements
More styling
2026-05-11 19:53:30 +02:00
schn33fuchs 10de47b911 Styling
Copied Ninos styles with copilot
2026-05-11 13:15:03 +02:00
60 changed files with 1666 additions and 2751 deletions
-13
View File
@@ -1,13 +0,0 @@
target/
backend/target/
frontend/target/
frontend/dist/
frontend/node_modules/
dist/
.git/
.idea/
.env
.antigravitycli/
*.pdb
**/*.rs.bk
info ding video.mp4
-4
View File
@@ -26,7 +26,3 @@ frontend/node_modules/
# Added by cargo # Added by cargo
/target /target
.antigravitycli/
.sqlx/
backend/.sqlx/
dist/
Generated
+6 -465
View File
@@ -2,12 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -29,12 +23,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "anymap2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
[[package]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.5.3"
@@ -156,9 +144,7 @@ dependencies = [
"axum-extra", "axum-extra",
"chrono", "chrono",
"dotenv", "dotenv",
"hex",
"jsonwebtoken", "jsonwebtoken",
"rand",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
@@ -222,36 +208,18 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "boolinator"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -374,15 +342,6 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.12" version = "0.3.12"
@@ -614,15 +573,6 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "ff" name = "ff"
version = "0.13.1" version = "0.13.1"
@@ -645,16 +595,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@@ -706,21 +646,17 @@ dependencies = [
name = "frontend" name = "frontend"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"dotenv",
"gloo 0.12.0", "gloo 0.12.0",
"gloo-net 0.7.0", "gloo-net 0.7.0",
"gloo-storage 0.4.0", "gloo-storage 0.4.0",
"qrcode-generator",
"serde", "serde",
"serde_json", "serde_json",
"theme",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"yew 0.23.0", "yew",
"yew-router", "yew-router",
] ]
@@ -859,44 +795,6 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "gloo"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d"
dependencies = [
"gloo-console 0.2.3",
"gloo-dialogs 0.1.1",
"gloo-events 0.1.2",
"gloo-file 0.2.3",
"gloo-history 0.1.5",
"gloo-net 0.3.1",
"gloo-render 0.1.1",
"gloo-storage 0.2.2",
"gloo-timers 0.2.6",
"gloo-utils 0.1.7",
"gloo-worker 0.2.1",
]
[[package]]
name = "gloo"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd35526c28cc55c1db77aed6296de58677dbab863b118483a27845631d870249"
dependencies = [
"gloo-console 0.3.0",
"gloo-dialogs 0.2.0",
"gloo-events 0.2.0",
"gloo-file 0.3.0",
"gloo-history 0.2.2",
"gloo-net 0.4.0",
"gloo-render 0.2.0",
"gloo-storage 0.3.0",
"gloo-timers 0.3.0",
"gloo-utils 0.2.0",
"gloo-worker 0.4.0",
]
[[package]] [[package]]
name = "gloo" name = "gloo"
version = "0.11.0" version = "0.11.0"
@@ -935,19 +833,6 @@ dependencies = [
"gloo-worker 0.6.0", "gloo-worker 0.6.0",
] ]
[[package]]
name = "gloo-console"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f"
dependencies = [
"gloo-utils 0.1.7",
"js-sys",
"serde",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-console" name = "gloo-console"
version = "0.3.0" version = "0.3.0"
@@ -974,16 +859,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-dialogs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-dialogs" name = "gloo-dialogs"
version = "0.2.0" version = "0.2.0"
@@ -1004,16 +879,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-events"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-events" name = "gloo-events"
version = "0.2.0" version = "0.2.0"
@@ -1034,18 +899,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-file"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7"
dependencies = [
"gloo-events 0.1.2",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-file" name = "gloo-file"
version = "0.3.0" version = "0.3.0"
@@ -1071,22 +924,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-history"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f"
dependencies = [
"gloo-events 0.1.2",
"gloo-utils 0.1.7",
"serde",
"serde-wasm-bindgen 0.5.0",
"serde_urlencoded",
"thiserror 1.0.69",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-history" name = "gloo-history"
version = "0.2.2" version = "0.2.2"
@@ -1097,7 +934,7 @@ dependencies = [
"gloo-events 0.2.0", "gloo-events 0.2.0",
"gloo-utils 0.2.0", "gloo-utils 0.2.0",
"serde", "serde",
"serde-wasm-bindgen 0.6.5", "serde-wasm-bindgen",
"serde_urlencoded", "serde_urlencoded",
"thiserror 1.0.69", "thiserror 1.0.69",
"wasm-bindgen", "wasm-bindgen",
@@ -1114,55 +951,13 @@ dependencies = [
"gloo-events 0.3.0", "gloo-events 0.3.0",
"gloo-utils 0.3.0", "gloo-utils 0.3.0",
"serde", "serde",
"serde-wasm-bindgen 0.6.5", "serde-wasm-bindgen",
"serde_urlencoded", "serde_urlencoded",
"thiserror 2.0.18", "thiserror 2.0.18",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-net"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620"
dependencies = [
"futures-channel",
"futures-core",
"futures-sink",
"gloo-utils 0.1.7",
"http 0.2.12",
"js-sys",
"pin-project",
"serde",
"serde_json",
"thiserror 1.0.69",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-net"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ac9e8288ae2c632fa9f8657ac70bfe38a1530f345282d7ba66a1f70b72b7dc4"
dependencies = [
"futures-channel",
"futures-core",
"futures-sink",
"gloo-utils 0.2.0",
"http 0.2.12",
"js-sys",
"pin-project",
"serde",
"serde_json",
"thiserror 1.0.69",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "gloo-net" name = "gloo-net"
version = "0.5.0" version = "0.5.0"
@@ -1205,16 +1000,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-render"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-render" name = "gloo-render"
version = "0.2.0" version = "0.2.0"
@@ -1235,21 +1020,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-storage"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480"
dependencies = [
"gloo-utils 0.1.7",
"js-sys",
"serde",
"serde_json",
"thiserror 1.0.69",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-storage" name = "gloo-storage"
version = "0.3.0" version = "0.3.0"
@@ -1280,16 +1050,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-timers"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "gloo-timers" name = "gloo-timers"
version = "0.3.0" version = "0.3.0"
@@ -1312,19 +1072,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "gloo-utils"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-utils" name = "gloo-utils"
version = "0.2.0" version = "0.2.0"
@@ -1351,42 +1098,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-worker"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a"
dependencies = [
"anymap2",
"bincode",
"gloo-console 0.2.3",
"gloo-utils 0.1.7",
"js-sys",
"serde",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-worker"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76495d3dd87de51da268fa3a593da118ab43eb7f8809e17eb38d3319b424e400"
dependencies = [
"bincode",
"futures",
"gloo-utils 0.2.0",
"gloo-worker-macros 0.1.0",
"js-sys",
"pinned",
"serde",
"thiserror 1.0.69",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "gloo-worker" name = "gloo-worker"
version = "0.5.0" version = "0.5.0"
@@ -1555,12 +1266,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "html-escape"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e"
[[package]] [[package]]
name = "http" name = "http"
version = "0.2.12" version = "0.2.12"
@@ -1785,29 +1490,6 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png",
]
[[package]]
name = "implicit-clone"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84"
dependencies = [
"implicit-clone-derive",
"indexmap",
]
[[package]] [[package]]
name = "implicit-clone" name = "implicit-clone"
version = "0.6.0" version = "0.6.0"
@@ -1985,16 +1667,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.0"
@@ -2006,16 +1678,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.18" version = "0.2.18"
@@ -2332,19 +1994,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -2440,46 +2089,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "prokio"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03b55e106e5791fa5a13abd13c85d6127312e8e09098059ca2bc9b03ca4cf488"
dependencies = [
"futures",
"gloo 0.8.1",
"num_cpus",
"once_cell",
"pin-project",
"pinned",
"tokio",
"tokio-stream",
"wasm-bindgen-futures",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "qrcode-generator"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0051849b5465059b75f59d388c7318aad6554701b74ecf02afc2573b0306c"
dependencies = [
"html-escape",
"image",
"qrcodegen",
]
[[package]]
name = "qrcodegen"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142"
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -2681,17 +2290,6 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-wasm-bindgen"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]] [[package]]
name = "serde-wasm-bindgen" name = "serde-wasm-bindgen"
version = "0.6.5" version = "0.6.5"
@@ -2810,12 +2408,6 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]] [[package]]
name = "simple_asn1" name = "simple_asn1"
version = "0.6.4" version = "0.6.4"
@@ -3145,17 +2737,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "theme"
version = "0.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5379f4c4eb851af44d4d818ba43fac8cff83e9b8e624aedc8a3346ca45065fe"
dependencies = [
"serde",
"web-sys",
"yew 0.21.0",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -3878,31 +3459,6 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yew"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac"
dependencies = [
"console_error_panic_hook",
"futures",
"gloo 0.10.0",
"implicit-clone 0.4.9",
"indexmap",
"js-sys",
"prokio",
"rustversion",
"serde",
"slab",
"thiserror 1.0.69",
"tokio",
"tracing",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"yew-macro 0.21.0",
]
[[package]] [[package]]
name = "yew" name = "yew"
version = "0.23.0" version = "0.23.0"
@@ -3912,7 +3468,7 @@ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"futures", "futures",
"gloo 0.11.0", "gloo 0.11.0",
"implicit-clone 0.6.0", "implicit-clone",
"indexmap", "indexmap",
"js-sys", "js-sys",
"rustversion", "rustversion",
@@ -3925,22 +3481,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"yew-macro 0.23.0", "yew-macro",
]
[[package]]
name = "yew-macro"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2"
dependencies = [
"boolinator",
"once_cell",
"prettyplease",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.117",
] ]
[[package]] [[package]]
@@ -3972,7 +3513,7 @@ dependencies = [
"urlencoding", "urlencoding",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
"yew 0.23.0", "yew",
"yew-router-macro", "yew-router-macro",
] ]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Silas Raffauf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-63
View File
@@ -1,63 +0,0 @@
# Ticketsystem
A ticket system with backend and frontend components.
## Components
- **[Backend](../backend/index.html)** - The server-side API and business logic
- **[Frontend](../frontend/index.html)** - The client-side user interface
## Usage
### Prerequisite
#### IMPORTANT
Before compiling the programm you have to install the rust toolchain.
For a guide to do this, visit: <https://rust-lang.org/tools/install/>
A instance of a postgresql has to be accessible to the backend. Place the connection details in a .env file into the variable `DATABASE_URL`
To setup the tables you either can create them manually by following the sheme specified in `backend/migrations` or apply them with sqlx
To install sqlx run `cargo install sqlx` and then in the `backend` directory run `sqlx migrate run` to create the tables
### Environment
The .env file has to be in the root directory of the project or in the same directory as the executable
Keys:
`DATABASE_URL`: Specifying the url and connection details for the database
`TOKEN_SECRET`: The JWT token secret, can theoretically be anything but is more secure when generated with a tool, e.g: <https://jwtsecrets.com/>
`ORIGIN`: The origin of the frontend, used for CORS rules
`BACKEND_PORT`: The port which the backend should use to run on
### Backend
The backend can either be run via `cargo run --release` or `cargo build --release` using the correct target architecture, e.g. 'x86_64-unknown-linux-gnu',
the executable will be placed in the `target/release` directory and can then be run via any method
### Frontend
The HTML code for the frontend can be generated by using `trunk build`. The resulting files will end up in the `frontend/dist` directory and can be served over any webserver supporting wasm
#### NOTE
To install trunk run `cargo install trunk`
#### IMPORTANT
Requests from the frontend to /api/* have to be proxied to the Backend
Example with nginx and frontend running at localhost:8000 and backend at localhost:9000 :
```nginx
location /api/ {
proxy_pass http://localhost:9000/api;
}
```
## Usage of AI
Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation
Google Antigravity generated the Sequence Diagrams
### Prompt
Generate comments for cargo doc describing the indivilual components and create links to relevant structs, functions etc.
Generate a sequence diagramm in @[README.md] behind the class diagramms
### Output
The comments with `///` or `//!`
I've gone over it and modified it to my needs and opinions
The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
+2 -386
View File
@@ -4,389 +4,5 @@ A ticket system with backend and frontend components.
## Components ## Components
- **[Backend]** - The server-side API and business logic - **[Backend](../backend/index.html)** - The server-side API and business logic
- **[Frontend]** - The client-side user interface - **[Frontend](../frontend/index.html)** - The client-side user interface
## Usage
### Prerequisite
> [!IMPORTANT]
> Before compiling the programm you have to install the rust toolchain.
> For a guide to do this, visit: <https://rust-lang.org/tools/install/>
A instance of a postgresql has to be accessible to the backend. Place the connection details in a .env file into the variable `DATABASE_URL`
To setup the tables you either can create them manually by following the sheme specified in `backend/migrations` or apply them with sqlx
To install sqlx run `cargo install sqlx` and then in the `backend` directory run `sqlx migrate run` to create the tables
### Environment
The .env file has to be in the root directory of the project or in the same directory as the executable
Keys:
`DATABASE_URL`: Specifying the url and connection details for the database
`TOKEN_SECRET`: The JWT token secret, can theoretically be anything but is more secure when generated with a tool, e.g: <https://jwtsecrets.com/>
`ORIGIN`: The origin of the frontend, used for CORS rules
`BACKEND_PORT`: The port which the backend should use to run on
### Backend
The backend can either be run via `cargo run --release` or `cargo build --release` using the correct target architecture, e.g. 'x86_64-unknown-linux-gnu',
the executable will be placed in the `target/release` directory and can then be run via any method
Alternatively download the executable from the 'Releases' Page for your device
### Frontend
The HTML code for the frontend can be generated by using `trunk build`. The resulting files will end up in the `frontend/dist` directory and can be served over any webserver supporting WASM
Alternatively download the fontend files from the 'Releases' Page and serve them via any webserver supporting WASM
> [!NOTE]
> To install trunk run `cargo install trunk`
> [!IMPORTANT]
> Requests from the frontend to /api/* have to be proxied to the Backend
> Example with nginx and frontend running at localhost:8000 and backend at localhost:9000 :
> ```nginx
> location /api/ {
> proxy_pass http://localhost:9000/api;
> }
> ```
### Example
[![Watch the video]()](https://git.raffauf-clan.de/schn33fuchs/ticketsystem/raw/branch/main/info%20ding%20video.mp4)
## Diagrams
### Class Diagramm
#### Backend
```mermaid
classDiagram
class Error {
+status: &'static str
+message: String
}
class TicketResponse {
+id: i32
+category: String
+betreff: String
+description: String
+room: i16
+status: String
+date: chrono::DateTime~chrono::Utc~
+user_id: i16
+user_first_name: String
+user_last_name: String
}
class User {
+id: i16
+last_name: String
+first_name: String
+username: String
+is_admin: bool
+pwd: String
}
class TicketCreateScheme {
+category: String
+betreff: String
+description: String
+room: i16
}
class TicketUpdateScheme {
+status: String
}
class UserUpdateScheme {
+id: i16
+first_name: String
+last_name: String
+username: String
+make_admin: bool
+new_pwd: String
}
class UserCreateScheme {
+first_name: String
+last_name: String
+username: String
+is_admin: bool
+pwd: String
}
class LoginScheme {
+username: String
+pwd: String
}
class FilteredUser {
+id: i16
+first_name: String
+last_name: String
+username: String
+is_admin: bool
}
class Claims {
+sub: String
+issued: usize
+expires: usize
}
class AppState {
-db: PgPool
-env: Env
}
class Env {
+db_url: String
+token_secret: String
+origin: String
+backend_port: String
+load() Env
}
AppState --> Env
Env ..> Env
```
#### Frontend
```mermaid
classDiagram
class TicketCreateScheme {
+category: String
+betreff: String
+description: String
+room: i16
}
class TicketUpdateScheme {
+status: String
}
class Ticket {
+id: i32
+category: String
+betreff: String
+description: String
+room: i16
+status: String
+date: chrono::DateTime~chrono::Utc~
+user_id: i16
+user_first_name: String
+user_last_name: String
}
class TicketProps {
+id: i32
}
class ActiveUser {
+id: Option~i16~
+is_admin: bool
}
class ApiError {
-message: String
-_status: String
}
class SidebarExpandState {
+ticket_open: bool
+users_open: bool
}
class Default {
+default() Self
}
class SidebarState {
+expand: SidebarExpandState
+set_tickets_open: Callback~bool~
+toggle_tickets: Callback~()~
+set_users_open: Callback~bool~
+toggle_users: Callback~()~
+new(expand:SidebarExpandState, set_tickets_open:Callback~bool~, toggle_tickets:Callback~()~, set_users_open:Callback~bool~, toggle_users:Callback~()~) Self
}
class SidebarProps {
+children: Children
}
class TicketPartial {
-date: DateTime~Utc~
-room: i16
-user_id: i16
}
class UserPartial {
-id: i16
-first_name: String
-last_name: String
}
class RoomTotalsProps {
-tickets: Vec~TicketPartial~
}
class UserTotalProps {
-users: Vec~UserPartial~
-tickets: Vec~TicketPartial~
}
class AdminSetupScheme {
+first_name: String
+last_name: String
+username: String
+pwd: String
}
class UserCreateScheme {
+first_name: String
+last_name: String
+username: String
+is_admin: bool
+pwd: String
}
class LoginScheme {
+username: String
+pwd: String
}
class UserUpdateScheme {
+id: i16
+first_name: String
+last_name: String
+username: String
+make_admin: bool
+new_pwd: String
}
class FilteredUser {
+id: i16
+first_name: String
+last_name: String
+username: String
+is_admin: bool
}
class UserProps {
+id: i16
}
class ApiError {
-message: String
-_status: String
}
class AuthState {
+is_authenticated: Option~bool~
+is_admin: Option~bool~
}
class ProtectedRouteProps {
+children: Children
+admin_page: bool
}
class SidebarShellProps {
+children: Children
}
class SidebarComponentProps {
+is_open: bool
+on_close: Callback~()~
}
class AdminCheckWrapperProps {
+children: Children
}
SidebarState --> SidebarExpandState
RoomTotalsProps --> TicketPartial
UserTotalProps --> UserPartial
UserTotalProps --> TicketPartial
SidebarShellProps ..> SidebarComponentProps
```
### Sequence Diagrams
#### 1. System Initialization & Administrator Setup
```mermaid
sequenceDiagram
autonumber
actor Admin as Initial Administrator
participant FE as Frontend (Yew)
participant BE as Backend (Axum)
participant DB@{"type": "database", "alias": "Database"}
Note over Admin, DB: System Initialization Flow
FE->>BE: GET /api/check-admin
BE->>DB: SELECT COUNT(*) FROM users WHERE is_admin = true
DB-->>BE: 0 (No admin found)
BE-->>FE: HTTP 200 OK {"exists": false}
FE-->>Admin: Render Admin Setup Page
Admin->>FE: Input username, password, first/last name
FE->>BE: POST /api/setup-admin {username, pwd, ...}
Note over BE: Hash password using Argon2
BE->>DB: INSERT INTO users (username, pwd, is_admin, ...)
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>Admin: Redirect to Login Page
```
#### 2. User Authentication Flow (Login)
```mermaid
sequenceDiagram
autonumber
actor User
participant FE as Frontend (Yew)
participant BE as Backend (Axum)
participant DB as Database@{"type": "database"}
Note over User, DB: Authentication & Cookie Session Setup
User->>FE: Enter username & password
FE->>BE: POST /api/login {username, pwd}
BE->>DB: SELECT * FROM users WHERE username = $1
DB-->>BE: Return user record with password hash
Note over BE: Verify password using Argon2
alt Password Valid
Note over BE: Generate JWT token containing claims (sub: user_id)
Note over BE: Build HttpOnly, Secure, Lax cookie 'token'
BE-->>FE: HTTP 200 OK {"status": "success", "token": "...", "user": {...}}
Note over BE,FE: Header: Set-Cookie: token=...#59; Path=/#59; HttpOnly#59; SameSite=Lax
Note over FE: Save auth state to global context
FE-->>User: Redirect to Dashboard / Home
else Password Invalid
BE-->>FE: HTTP 400 Bad Request {"status": "error", "message": "Invalid password"}
FE-->>User: Display error message
end
```
#### 3. Ticket Lifecycle Flow
```mermaid
sequenceDiagram
autonumber
actor User as Authenticated User
actor Admin as Administrator
participant FE as Frontend (Yew)
participant BE as Backend (Axum)
participant DB as Database@{"type": "database"}
Note over User, DB: Ticket Creation Flow (Protected Route)
User->>FE: Fill out ticket form & submit
FE->>BE: POST /api/tickets/create {category, betreff, description, room} (Includes 'token' cookie)
Note over BE: validate_token middleware decodes & verifies JWT
BE->>DB: INSERT INTO tickets (category, description, betreff, room, user_id)
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>User: Clear form & display success notification
Note over Admin, DB: Ticket Review & Resolution (Admin Only Route)
Admin->>FE: View Ticket Board
FE->>BE: GET /api/tickets (Includes 'token' cookie)
Note over BE: validate_token middleware checks JWT
BE->>DB: SELECT tickets JOIN users ...
DB-->>BE: Return list of tickets
BE-->>FE: HTTP 200 OK [tickets]
FE-->>Admin: Render Ticket List
Admin->>FE: Click "Resolve" on ticket
FE->>BE: PATCH /api/tickets/{id} {"status": "Resolved"} (Includes 'token' cookie)
Note over BE: validate_admin middleware verifies token & checks is_admin = true
BE->>DB: UPDATE tickets SET status = $1 WHERE id = $2
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>Admin: Update ticket status in UI
Note over Admin, DB: Ticket Archiving Flow (Admin Only Route)
Admin->>FE: Open Archived Tickets page
FE->>BE: GET /api/tickets/archive (Includes 'token' cookie)
Note over BE: validate_admin middleware verifies token & admin role
BE->>DB: SELECT * FROM tickets WHERE status = 'Archived'
DB-->>BE: Return archived tickets
BE-->>FE: HTTP 200 OK [Archived Tickets]
FE-->>Admin: Render historical archive board
```
## Usage of AI
Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation
Google Antigravity generated the Sequence Diagrams
### Prompt
Generate comments for cargo doc describing the indivilual components and create links to relevant structs, functions etc.
Generate a sequence diagramm in @[README.md] behind the class diagramms
### Output
The comments with `///` or `//!`
I've gone over it and modified it to my needs and opinions
The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
+1 -3
View File
@@ -8,7 +8,7 @@ axum = "0.8.9"
axum-extra = { version = "0.12.6", features = ["cookie", "typed-header", "form"] } axum-extra = { version = "0.12.6", features = ["cookie", "typed-header", "form"] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls", "chrono", "migrate"] } sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls", "chrono"] }
tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros"] } tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros"] }
dotenv = "0.15.0" dotenv = "0.15.0"
chrono = { workspace = true } chrono = { workspace = true }
@@ -17,5 +17,3 @@ argon2 = "0.5.3"
time = "0.3.47" time = "0.3.47"
tower = "0.5.3" tower = "0.5.3"
tower-http = { version = "0.6.8", features = ["cors"] } tower-http = { version = "0.6.8", features = ["cors"] }
rand = '0.8'
hex = '0.4'
@@ -6,3 +6,9 @@ CREATE TABLE users (
pwd TEXT, pwd TEXT,
is_admin BOOLEAN NOT NULL DEFAULT false is_admin BOOLEAN NOT NULL DEFAULT false
); );
INSERT INTO users(last_name, first_name, username, pwd, is_admin) VALUES ('Raffauf', 'Silas', 'raffaufsil',
'$argon2id$v=19$m=19456,t=2,p=1$urV91vY49mehWQoShkm6pw$1AGLuMDuFnY1CKuQU9ed89s1u9kvuszQjX+eIXmCBKI', true);
INSERT INTO users(last_name, first_name, username, pwd, is_admin) VALUES ('Misun', 'Sam', 'misunsam',
'$argon2id$v=19$m=19456,t=2,p=1$AMmy1zMOQG8Wg1jE5BXhDg$xhYIZPAPPOEoKQMtzWll/uhG8YJQvcpHKQU2++L23o4', true);
@@ -1 +0,0 @@
DROP TABLE qr_codes;
@@ -1,8 +0,0 @@
CREATE TABLE qr_codes (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id SMALLINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_qr_token_hash ON qr_codes(token_hash);
+6 -7
View File
@@ -6,8 +6,8 @@ use crate::models::Claims;
/// Error response for JWT token operations. /// Error response for JWT token operations.
/// ///
/// Returned when token encoding or decoding fails via `encode_token` or `decode_token`. /// Returned when token encoding or decoding fails. Used in error responses
/// Used in error responses for invalid or expired [`Claims`] tokens. /// for invalid or expired tokens.
/// ///
/// # Fields /// # Fields
/// - `status`: HTTP status text (e.g., "error") /// - `status`: HTTP status text (e.g., "error")
@@ -22,7 +22,7 @@ pub struct Error {
/// ///
/// This function creates a new JWT with the provided user ID as the subject, /// This function creates a new JWT with the provided user ID as the subject,
/// sets the issued-at and expiration times (60 minutes from now), and signs it /// sets the issued-at and expiration times (60 minutes from now), and signs it
/// using the given encoding key. The resulting token is a serialized [`Claims`]. /// using the given encoding key.
/// ///
/// # Arguments /// # Arguments
/// - `header`: The JWT header, specifying the algorithm (e.g., HS256). /// - `header`: The JWT header, specifying the algorithm (e.g., HS256).
@@ -30,7 +30,7 @@ pub struct Error {
/// - `key`: The `EncodingKey` used to sign the JWT. /// - `key`: The `EncodingKey` used to sign the JWT.
/// ///
/// # Returns /// # Returns
/// A `String` representing the encoded JWT containing [`Claims`]. /// A `String` representing the encoded JWT.
/// ///
/// # Panics /// # Panics
/// Panics if the token encoding fails for any reason (e.g., invalid key). /// Panics if the token encoding fails for any reason (e.g., invalid key).
@@ -50,15 +50,14 @@ pub fn encode_token(header: &Header, id: String, key: &EncodingKey) -> String {
/// ///
/// This function attempts to decode a JWT string, validate its signature and claims /// This function attempts to decode a JWT string, validate its signature and claims
/// using the provided decoding key. It specifically ignores expiration (`validate_exp`) /// using the provided decoding key. It specifically ignores expiration (`validate_exp`)
/// and "not before" (`validate_nbf`) claims during validation. Returns the extracted [`Claims`] /// and "not before" (`validate_nbf`) claims during validation.
/// on success.
/// ///
/// # Arguments /// # Arguments
/// - `token`: The JWT string to decode. /// - `token`: The JWT string to decode.
/// - `key`: The `DecodingKey` used to verify the JWT's signature. /// - `key`: The `DecodingKey` used to verify the JWT's signature.
/// ///
/// # Returns /// # Returns
/// - `Ok(Claims)`: If the token is successfully decoded and verified, returns the extracted [`Claims`]. /// - `Ok(Claims)`: If the token is successfully decoded and verified, returns the extracted `Claims`.
/// - `Err((StatusCode, Json<Error>))`: If the token is invalid, expired, or cannot be decoded, /// - `Err((StatusCode, Json<Error>))`: If the token is invalid, expired, or cannot be decoded,
/// returns an `UNAUTHORIZED` status code along with a JSON error message. /// returns an `UNAUTHORIZED` status code along with a JSON error message.
pub fn decode_token(token: String, key: &DecodingKey) -> Result<Claims, (StatusCode, Json<Error>)> { pub fn decode_token(token: String, key: &DecodingKey) -> Result<Claims, (StatusCode, Json<Error>)> {
+6 -8
View File
@@ -17,10 +17,9 @@ use crate::{AppState, cookie::jwt::decode_token, handlers::auth::filter_user, mo
/// Axum middleware to validate a JWT token present in cookies or Authorization header. /// Axum middleware to validate a JWT token present in cookies or Authorization header.
/// ///
/// This function extracts a JWT from the request (either from the `token` cookie or /// This function extracts a JWT from the request (either from the `token` cookie or
/// the `Authorization: Bearer` header), decodes and validates it using [`decode_token`](`crate::cookie::jwt::decode_token`)). /// the `Authorization: Bearer` header), decodes and validates it. If valid, it fetches
/// If valid, it fetches the corresponding [`User`] from the database and inserts a /// the corresponding user from the database and inserts a `FilteredUser` into the
/// [`FilteredUser`](crate::models::FilteredUser) /// request extensions for subsequent handlers to use.
/// (converted via [`filter_user`](`crate::handlers::auth::filter_user`)) into the request extensions for subsequent handlers to use.
/// ///
/// If the token is missing, invalid, or the user is not found, it returns an /// If the token is missing, invalid, or the user is not found, it returns an
/// appropriate error response (401 Unauthorized). /// appropriate error response (401 Unauthorized).
@@ -111,10 +110,9 @@ pub async fn validate_token(
/// Axum middleware to validate JWT token and ensure the authenticated user has admin privileges. /// Axum middleware to validate JWT token and ensure the authenticated user has admin privileges.
/// ///
/// This middleware first performs all checks of [`validate_token`]: extracting, decoding, /// This middleware first performs all checks of `validate_token`: extracting, decoding,
/// and validating the JWT via [`decode_token`](`crate::cookie::jwt::decode_token`), and fetching the associated [`User`] from the database. /// and validating the JWT, and fetching the associated user from the database.
/// Additionally, it verifies that the fetched user has `is_admin` set to `true`. Returns a [`FilteredUser`](crate::models::FilteredUser) /// Additionally, it verifies that the fetched user has `is_admin` set to `true`.
/// (converted via [`filter_user`](`crate::handlers::auth::filter_user`)) in the request extensions if both authentication and admin status are valid.
/// ///
/// If the user is not authenticated or not an administrator, it returns an /// If the user is not authenticated or not an administrator, it returns an
/// appropriate error response (401 Unauthorized or 403 Forbidden). /// appropriate error response (401 Unauthorized or 403 Forbidden).
+5 -14
View File
@@ -1,13 +1,12 @@
/// Environment configuration for the application. /// Environment configuration for the application.
/// ///
/// Loads required configuration from environment variables at startup. /// Loads required configuration from environment variables at startup.
/// All variables must be present or the application will panic during [`Env::load`]. /// All variables must be present or the application will panic.
/// Used by [`AppState`](crate::AppState) for configuring JWT signing and CORS.
/// ///
/// # Fields /// # Fields
/// - `db_url`: PostgreSQL database connection URL /// - `db_url`: PostgreSQL database connection URL.
/// - `token_secret`: Secret key used to sign and verify [`Claims`](crate::models::Claims) in JWT tokens /// - `token_secret`: Secret key used to sign and verify JWT tokens.
/// - `origin`: Frontend origin URL for CORS policy configuration /// - `origin`: Frontend origin URL for CORS policy.
/// ///
/// # Required Environment Variables /// # Required Environment Variables
/// - `DATABASE_URL`: PostgreSQL connection string (e.g., `postgresql://user:pass@localhost/dbname`) /// - `DATABASE_URL`: PostgreSQL connection string (e.g., `postgresql://user:pass@localhost/dbname`)
@@ -26,20 +25,12 @@ pub struct Env {
impl Env { impl Env {
/// Loads environment configuration from system environment variables. /// Loads environment configuration from system environment variables.
/// ///
/// Reads `DATABASE_URL`, `TOKEN_SECRET`, and `ORIGIN` from the environment and returns /// Panics if any required variable is missing.
/// a configured [`Env`] instance. Used during server initialization in the `main` function.
///
/// # Panics
/// If any required variable is missing (DATABASE_URL, TOKEN_SECRET, or ORIGIN).
/// ///
/// # Example /// # Example
/// ```ignore /// ```ignore
/// let env = Env::load(); /// let env = Env::load();
/// // Environment must have DATABASE_URL, TOKEN_SECRET, and ORIGIN set /// // Environment must have DATABASE_URL, TOKEN_SECRET, and ORIGIN set
/// let app_state = AppState {
/// db: pool,
/// env,
/// };
/// ``` /// ```
pub fn load() -> Env { pub fn load() -> Env {
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
+59 -67
View File
@@ -22,12 +22,11 @@ use crate::{
/// Registers a new user in the system. /// Registers a new user in the system.
/// ///
/// Creates a new [`User`] account with the provided [`UserCreateScheme`] credentials. /// Creates a new user account with the provided credentials. The password is hashed using Argon2
/// The password is hashed using Argon2 before being stored. Only administrators can create new users. /// before being stored. Only administrators can create new users.
/// ///
/// # Arguments /// # Arguments
/// - `State(data)`: Application state containing [`AppState`] for database access /// - `request`: User creation details including first/last name, username, admin flag, and password
/// - `request`: [`UserCreateScheme`] containing user details including first/last name, username, admin flag, and password
/// ///
/// # Returns /// # Returns
/// - `200 OK` on successful user creation /// - `200 OK` on successful user creation
@@ -35,7 +34,12 @@ use crate::{
/// - `500 Internal Server Error` if database insertion fails /// - `500 Internal Server Error` if database insertion fails
/// ///
/// # Password Hashing /// # Password Hashing
/// Uses Argon2 with a cryptographically secure random salt. /// Uses Argon2 with a cryptographically secure random salt:
/// ```ignore
/// let argon = Argon2::default();
/// let salt = SaltString::generate(&mut OsRng);
/// let hashed_pwd = argon.hash_password(password.as_bytes(), &salt)?;
/// ```
pub async fn create_user( pub async fn create_user(
State(data): State<Arc<AppState>>, State(data): State<Arc<AppState>>,
Json(request): Json<UserCreateScheme>, Json(request): Json<UserCreateScheme>,
@@ -102,23 +106,27 @@ pub async fn create_user(
/// Authenticates a user and creates a JWT token for session management. /// Authenticates a user and creates a JWT token for session management.
/// ///
/// Verifies the provided username and password against stored credentials using Argon2 verification. /// Verifies the provided username and password against stored credentials using Argon2 verification.
/// On successful authentication, generates and encodes a [`Claims`](crate::models::Claims) token via [`encode_token`](`crate::cookie::jwt::encode_token`) and sets it as an HTTP-only cookie. /// On successful authentication, generates a JWT token and sets it as an HTTP-only cookie.
/// The token is valid for 1 hour. /// The token is valid for 1 hour.
/// ///
/// # Arguments /// # Arguments
/// - `State(data)`: Application state containing [`AppState`] for database access /// - `request`: Login credentials (username, password)
/// - `request`: [`LoginScheme`] containing login credentials (username, password)
/// ///
/// # Returns /// # Returns
/// - `200 OK` with JSON containing token and filtered [`FilteredUser`] info /// - `200 OK` with JSON containing token and filtered user info
/// - `400 Bad Request` if username not found or password invalid /// - `400 Bad Request` if username not found or password invalid
/// - `500 Internal Server Error` if database query fails /// - `500 Internal Server Error` if database query fails
/// ///
/// # Security Features /// # Security Features
/// - HTTP-only cookie prevents JavaScript access /// - HTTP-only cookie prevents JavaScript access
/// - SameSite=Lax protects against CSRF attacks /// - SameSite=Lax protects against CSRF attacks
/// - Password verification uses Argon2 with stored [`User`] hash /// - Password verification uses Argon2:
/// - JWT token includes user ID and expiration timestamp via [`Claims`](crate::models::Claims) encoded by [`encode_token`](`crate::cookie::jwt::encode_token`) /// ```ignore
/// let valid_pwd = Argon2::default()
/// .verify_password(&request.pwd.as_bytes(), &pwd_hash.unwrap())
/// .is_ok();
/// ```
/// - JWT token includes user ID and expiration timestamp
/// ///
/// # Example Response /// # Example Response
/// ```json /// ```json
@@ -187,7 +195,7 @@ pub async fn login(
/// ///
/// Sets the authentication cookie to expire immediately (max_age = -1 hour) which causes /// Sets the authentication cookie to expire immediately (max_age = -1 hour) which causes
/// the browser to discard it. This effectively logs the user out without requiring server-side /// the browser to discard it. This effectively logs the user out without requiring server-side
/// session invalidation. The cookie no longer contains a valid [`Claims`](crate::models::Claims) token. /// session invalidation.
/// ///
/// # Returns /// # Returns
/// Always returns `200 OK` with success message and an expired cookie header /// Always returns `200 OK` with success message and an expired cookie header
@@ -219,11 +227,11 @@ pub async fn logout() -> Result<impl IntoResponse, (StatusCode, Json<serde_json:
/// Retrieves the currently authenticated user's information. /// Retrieves the currently authenticated user's information.
/// ///
/// Uses the [`FilteredUser`] data embedded in the JWT token (via middleware). /// Uses the user data embedded in the JWT token (via middleware).
/// Useful for frontends to display logged-in user info or verify authentication. /// Useful for frontends to display logged-in user info or verify authentication.
/// ///
/// # Returns /// # Returns
/// - `200 OK` with [`FilteredUser`] data (excluding password) /// - `200 OK` with user data (excluding password)
/// - Automatically returns `401 Unauthorized` if not authenticated (middleware) /// - Automatically returns `401 Unauthorized` if not authenticated (middleware)
/// ///
/// # Example Response /// # Example Response
@@ -256,19 +264,18 @@ pub async fn get_current_user(
/// Deletes a user account from the system. /// Deletes a user account from the system.
/// ///
/// Only admins can delete users (enforced by middleware). The [`User`] account and all associated data is removed. /// Only admins can delete users. The user account and all associated data is removed.
/// Note: Tickets created by deleted users will have NULL user_id references. /// Note: Tickets created by deleted users will have NULL user_id references.
/// ///
/// # Arguments /// # Arguments
/// - `Path(id)`: [`User`] ID to delete, extracted from URL path /// - `id`: User ID to delete
/// - `State(data)`: Application state containing [`AppState`] for database access
/// ///
/// # Returns /// # Returns
/// - `204 No Content` on successful deletion /// - `204 No Content` on successful deletion
/// - `404 Not Found` if user doesn't exist /// - `404 Not Found` if user doesn't exist
/// - `500 Internal Server Error` if database error occurs /// - `500 Internal Server Error` if database error occurs
pub async fn delete_user( pub async fn delete_user(
Path(id): Path<i16>, Path(id): Path<i32>,
State(data): State<Arc<AppState>>, State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> { ) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let query = sqlx::query(r#"DELETE FROM users WHERE id = $1"#) let query = sqlx::query(r#"DELETE FROM users WHERE id = $1"#)
@@ -295,14 +302,11 @@ pub async fn delete_user(
/// Retrieves all users in the system. /// Retrieves all users in the system.
/// ///
/// Only admins can call this endpoint (enforced by middleware). Returns all [`User`] records converted to [`FilteredUser`] /// Only admins can call this endpoint. Returns all users sorted alphabetically by last name.
/// and sorted alphabetically by last name. Password hashes are not included in the response. /// Password hashes are not included in the response.
///
/// # Arguments
/// - `State(data)`: Application state containing [`AppState`] for database access
/// ///
/// # Returns /// # Returns
/// - `200 OK` with array of [`FilteredUser`] objects /// - `200 OK` with array of FilteredUser objects
/// - `500 Internal Server Error` if database query fails /// - `500 Internal Server Error` if database query fails
/// ///
/// # Example Response /// # Example Response
@@ -348,15 +352,15 @@ pub async fn get_users(
/// Retrieves a single user's details by their ID. /// Retrieves a single user's details by their ID.
/// ///
/// This endpoint allows fetching a specific [`User`]'s information. It returns a [`FilteredUser`] /// This endpoint allows fetching a specific user's information. It returns a `FilteredUser`
/// object (converted via [`filter_user`]), ensuring sensitive data like password hashes are not exposed. /// object, ensuring sensitive data like password hashes are not exposed.
/// ///
/// # Arguments /// # Arguments
/// - `Path(id)`: The ID of the [`User`] to retrieve, extracted from the URL path. /// - `Path(id)`: The ID of the user to retrieve, extracted from the URL path.
/// - `State(data)`: Application state containing [`AppState`] for database access. /// - `State(data)`: Application state containing `AppState` for database access.
/// ///
/// # Returns /// # Returns
/// - `200 OK` with a [`FilteredUser`] JSON object if the user is found. /// - `200 OK` with a `FilteredUser` JSON object if the user is found.
/// - `404 Not Found` if a user with the given ID does not exist. /// - `404 Not Found` if a user with the given ID does not exist.
/// - `500 Internal Server Error` if a database query error occurs. /// - `500 Internal Server Error` if a database query error occurs.
/// ///
@@ -392,30 +396,28 @@ pub async fn get_user_by_id(
/// Updates an existing user's information. /// Updates an existing user's information.
/// ///
/// This endpoint allows administrators to modify a [`User`]'s `first_name`, `last_name`, /// This endpoint allows administrators to modify a user's `first_name`, `last_name`,
/// `username`, `is_admin` status, and optionally their password. If `new_pwd` in the /// `username`, `is_admin` status, and optionally their password. If `new_pwd` in the
/// request body is an empty string, the user's password remains unchanged. /// request body is an empty string, the user's password remains unchanged.
/// ///
/// # Arguments /// # Arguments
/// - `Path(id)`: The ID of the [`User`] to update, extracted from the URL path. /// - `Path(id)`: The ID of the user to update, extracted from the URL path.
/// - `State(data)`: Application state containing [`AppState`] for database access. /// - `State(data)`: Application state containing `AppState` for database access.
/// - `Json(body)`: [`UserUpdateScheme`] containing the fields to update. /// - `Json(body)`: `UserUpdateScheme` containing the fields to update.
/// ///
/// # Returns /// # Returns
/// - `200 OK` with the [`FilteredUser`] object of the updated user (converted via [`filter_user`]). /// - `200 OK` with the `FilteredUser` object of the updated user.
/// - `404 Not Found` if a user with the given ID does not exist. /// - `404 Not Found` if a user with the given ID does not exist.
/// - `500 Internal Server Error` if a database query or password hashing error occurs. /// - `500 Internal Server Error` if a database query or password hashing error occurs.
/// ///
/// # Security Note /// # Security Note
/// - Passwords are hashed using Argon2 before storage. /// - Passwords are hashed using Argon2 before storage.
/// - This endpoint requires admin privileges (enforced by middleware via /// - This endpoint typically requires admin privileges (enforced by middleware).
/// [`validate_admin`](crate::cookie::validation::validate_admin)).
pub async fn update_user( pub async fn update_user(
Path(id): Path<i16>, Path(id): Path<i32>,
State(data): State<Arc<AppState>>, State(data): State<Arc<AppState>>,
Json(body): Json<UserUpdateScheme>, Json(body): Json<UserUpdateScheme>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> { ) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let update_result = if !body.new_pwd.is_empty() {
let argon = Argon2::default(); let argon = Argon2::default();
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let hashed_pwd = match argon.hash_password(body.new_pwd.clone().as_bytes(), &salt) { let hashed_pwd = match argon.hash_password(body.new_pwd.clone().as_bytes(), &salt) {
@@ -423,25 +425,15 @@ pub async fn update_user(
Err(e) => panic!("Error hashing {:}", e), Err(e) => panic!("Error hashing {:}", e),
}; };
sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, pwd = $4, is_admin = $5 WHERE id = $6"#) let update_result = sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, pwd = $4, is_admin = $5 WHERE id = $6"#)
.bind(body.first_name.to_owned()) .bind(body.first_name.to_owned())
.bind(body.last_name.to_owned()) .bind(body.last_name.to_owned())
.bind(body.username.to_owned()) .bind(body.username.to_owned())
.bind(hashed_pwd) .bind(&hashed_pwd)
.bind(body.make_admin.to_owned()) .bind(body.make_admin.to_owned())
.bind(id) .bind(id)
.execute(&data.db) .execute(&data.db)
.await .await
} else {
sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, is_admin = $4 WHERE id = $5"#)
.bind(body.first_name.to_owned())
.bind(body.last_name.to_owned())
.bind(body.username.to_owned())
.bind(body.make_admin.to_owned())
.bind(id)
.execute(&data.db)
.await
}
.map_err(|e| { .map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -479,10 +471,7 @@ pub async fn update_user(
/// Checks if any administrator user exists in the system. /// Checks if any administrator user exists in the system.
/// ///
/// This endpoint is used during initialization to determine if the setup page should be displayed. /// This endpoint is used during initialization to determine if the setup page should be displayed.
/// It counts all [`User`] records with `is_admin = true` in the database. /// It counts all users with `is_admin = true` in the database.
///
/// # Arguments
/// - `State(data)`: Application state containing [`AppState`] for database access
/// ///
/// # Returns /// # Returns
/// - `200 OK` with JSON: `{"has_admin": bool}` - Whether at least one admin exists /// - `200 OK` with JSON: `{"has_admin": bool}` - Whether at least one admin exists
@@ -512,24 +501,28 @@ pub async fn check_admin_exists(
/// Creates the initial administrator account for a fresh system. /// Creates the initial administrator account for a fresh system.
/// ///
/// This function handles the one-time setup of the first admin [`User`]. It checks that no admin exists /// This function handles the one-time setup of the first admin user. It checks that no admin exists
/// before allowing creation via database count. This endpoint is only functional when the system has no administrators. /// before allowing creation. This endpoint is only functional when the system has no administrators.
/// Once created, subsequent admin registrations must go through the normal `create_user` endpoint /// Once created, subsequent admin registrations must go through the normal `create_user` endpoint
/// with proper authorization. /// with proper authorization.
/// ///
/// # Arguments /// # Arguments
/// - `State(data)`: Application state containing [`AppState`] for database access /// - `request`: User creation details (first_name, last_name, username, password)
/// - `request`: [`UserCreateScheme`] containing user creation details (first_name, last_name, username, password)
/// ///
/// # Returns /// # Returns
/// - `200 OK` with success message if admin account created /// - `200 OK` with success message if admin account created
/// - `400 Bad Request` if: /// - `400 Bad Request` if:
/// - Admin already exists (checked via admin count) /// - Admin already exists
/// - Username or password is empty /// - Username or password is empty
/// - `500 Internal Server Error` if database insertion fails /// - `500 Internal Server Error` if database insertion fails
/// ///
/// # Security Note /// # Security Note
/// The password is hashed using Argon2 with a random salt before storage. /// The password is hashed using Argon2 with a random salt before storage:
/// ```ignore
/// let argon = Argon2::default();
/// let salt = SaltString::generate(&mut OsRng);
/// let hashed_pwd = argon.hash_password(request.pwd.as_bytes(), &salt)?;
/// ```
pub async fn setup_initial_admin( pub async fn setup_initial_admin(
State(data): State<Arc<AppState>>, State(data): State<Arc<AppState>>,
Json(request): Json<UserCreateScheme>, Json(request): Json<UserCreateScheme>,
@@ -594,18 +587,17 @@ pub async fn setup_initial_admin(
} }
} }
/// Converts a [`User`] with sensitive data into a [`FilteredUser`] safe for API responses. /// Converts a User with sensitive data into a FilteredUser safe for API responses.
/// ///
/// This function removes password hashes and other sensitive information before /// This function removes password hashes and other sensitive information before
/// returning [`User`] data to clients. Always use this helper instead of directly /// returning user data to clients. Always use this helper instead of directly
/// serializing [`User`] objects. /// serializing User objects.
/// Used by all authentication endpoints to ensure passwords are never exposed.
/// ///
/// # Arguments /// # Arguments
/// - `user`: Reference to the internal [`User`] struct containing password hash /// - `user`: Reference to the internal User struct containing password hash
/// ///
/// # Returns /// # Returns
/// [`FilteredUser`] with only safe-to-share information: /// FilteredUser with only safe-to-share information:
/// - `id`: User ID /// - `id`: User ID
/// - `first_name`, `last_name`: User name /// - `first_name`, `last_name`: User name
/// - `username`: Login username /// - `username`: Login username
@@ -618,7 +610,7 @@ pub async fn setup_initial_admin(
/// # Example /// # Example
/// ```ignore /// ```ignore
/// let user = get_user_from_db(1).await?; /// let user = get_user_from_db(1).await?;
/// let safe_user = filter_user(&user); // Convert User to FilteredUser /// let safe_user = filter_user(&user);
/// // safe_user can be safely serialized and sent to client /// // safe_user can be safely serialized and sent to client
/// ``` /// ```
pub fn filter_user(user: &User) -> FilteredUser { pub fn filter_user(user: &User) -> FilteredUser {
-1
View File
@@ -3,5 +3,4 @@
//! It serves as a central point for managing the logic that responds to various //! It serves as a central point for managing the logic that responds to various
//! HTTP requests, categorizing handlers by their domain (e.g., authentication, tickets). //! HTTP requests, categorizing handlers by their domain (e.g., authentication, tickets).
pub mod auth; pub mod auth;
pub mod qr;
pub mod ticket; pub mod ticket;
-130
View File
@@ -1,130 +0,0 @@
use std::sync::Arc;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use axum::{
Json,
extract::{Query, State},
http::{StatusCode, header::SET_COOKIE},
response::{Html, IntoResponse, Response},
};
use jsonwebtoken::{EncodingKey, Header};
use rand::{RngCore, thread_rng};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use crate::{AppState, cookie::jwt::encode_token};
#[derive(Serialize)]
pub struct QrCodeResponse {
pub raw_token: String,
}
#[derive(Serialize, Deserialize)]
pub struct AuthQuery {
pub token: String,
}
#[derive(Deserialize)]
pub struct GenerateQrRequest {
pub user_id: i16,
}
pub async fn generate_qr_token(
State(data): State<Arc<AppState>>,
Json(payload): Json<GenerateQrRequest>,
) -> Result<impl IntoResponse, StatusCode> {
let mut token_bytes = [0u8; 32];
thread_rng().fill_bytes(&mut token_bytes);
let raw_token = hex::encode(token_bytes);
let salt = SaltString::generate(&mut thread_rng());
let argon2 = Argon2::default();
let token_hash = argon2
.hash_password(raw_token.as_bytes(), &salt)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.to_string();
sqlx::query("INSERT INTO qr_codes (user_id, token_hash) VALUES ($1, $2)")
.bind(payload.user_id)
.bind(token_hash)
.execute(&data.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok((StatusCode::CREATED, Json(QrCodeResponse { raw_token })))
}
pub async fn validate_qr_token(
State(data): State<Arc<AppState>>,
Query(params): Query<AuthQuery>,
) -> Response {
let active_tokens_res = sqlx::query("SELECT user_id, token_hash FROM qr_codes")
.fetch_all(&data.db)
.await;
let active_tokens = match active_tokens_res {
Ok(tokens) => tokens,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
let mut authenticated_user_id: Option<i16> = None;
let argon2 = Argon2::default();
for record in active_tokens {
let user_id: i16 = record.get("user_id");
let token_hash: String = record.get("token_hash");
if let Ok(parsed_hash) = PasswordHash::new(&token_hash) {
if argon2
.verify_password(params.token.as_bytes(), &parsed_hash)
.is_ok()
{
authenticated_user_id = Some(user_id);
break;
}
}
}
if let Some(user_id) = authenticated_user_id {
let base_origin = data.env.origin.trim_end_matches('/');
let header = Header::default();
let id_str = user_id.to_string();
let secret_bytes = data.env.token_secret.as_bytes();
let encoding_key = EncodingKey::from_secret(secret_bytes);
let token = encode_token(&header, id_str, &encoding_key);
let cookie_value = format!(
"token={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600",
token
);
let redirect_html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0; url={}/" />
<script type="text/javascript">
window.location.href = "{}/"
</script>
<title>Redirecting...</title>
</head>
<body>
<p>Authenticated! Redirecting you to the dashboard...</p>
</body>
</html>"#,
base_origin, base_origin
);
let mut response = Html(redirect_html).into_response();
if let Ok(header_value) = axum::http::HeaderValue::from_str(&cookie_value) {
response.headers_mut().insert(SET_COOKIE, header_value);
}
response
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}
+16 -25
View File
@@ -16,14 +16,12 @@ use crate::{
/// Creates a new support ticket. /// Creates a new support ticket.
/// ///
/// Associates the ticket with the authenticated [`FilteredUser`] and sets the current timestamp. /// Associates the ticket with the authenticated user and sets the current timestamp.
/// Converts the [`TicketCreateScheme`] request into a database record.
/// Tickets are automatically created with "open" status. /// Tickets are automatically created with "open" status.
/// ///
/// # Arguments /// # Arguments
/// - `Extension(user)`: Authenticated [`FilteredUser`] (extracted from JWT token via middleware) /// - `user`: Authenticated user (extracted from JWT token)
/// - `State(data)`: Application state containing [`AppState`] for database access /// - `body`: Ticket details (category, subject, description, room)
/// - `Json(body)`: [`TicketCreateScheme`] containing ticket details (category, subject, description, room)
/// ///
/// # Returns /// # Returns
/// - `200 OK` on successful creation /// - `200 OK` on successful creation
@@ -62,11 +60,10 @@ pub async fn create_ticket(
/// Deletes a ticket by ID. /// Deletes a ticket by ID.
/// ///
/// Only admins can delete tickets (enforced by middleware). Removes the [`TicketResponse`] and associated data from the database. /// Only admins can delete tickets. Marks the ticket as deleted or removes from database.
/// ///
/// # Arguments /// # Arguments
/// - `Path(id)`: Ticket ID to delete, extracted from URL path /// - `id`: Ticket ID to delete
/// - `State(data)`: Application state containing [`AppState`] for database access
/// ///
/// # Returns /// # Returns
/// - `204 No Content` on successful deletion /// - `204 No Content` on successful deletion
@@ -100,18 +97,15 @@ pub async fn delete_ticket(
/// Retrieves all non-archived tickets. /// Retrieves all non-archived tickets.
/// ///
/// Returns a list of all active [`TicketResponse`] objects with user information denormalized for easier rendering. /// Returns a list of all active tickets with user information denormalized for easier rendering.
/// Tickets are ordered by creation date (newest first). Joins with [`User`](crate::models::User) table to include creator information. /// Tickets are ordered by creation date (newest first).
///
/// # Arguments
/// - `State(data)`: Application state containing [`AppState`] for database access
/// ///
/// # Filtering /// # Filtering
/// - Excludes tickets with status "Archived" /// - Excludes tickets with status "Archived"
/// - Uses LEFT JOIN to include creator information from [`User`](crate::models::User) /// - Uses LEFT JOIN to include creator information
/// ///
/// # Returns /// # Returns
/// - `200 OK` with array of [`TicketResponse`] objects /// - `200 OK` with array of TicketResponse objects
/// - `500 Internal Server Error` if database query fails /// - `500 Internal Server Error` if database query fails
/// ///
/// # Example Response /// # Example Response
@@ -175,14 +169,12 @@ pub async fn get_tickets(
/// Retrieves a specific ticket by ID. /// Retrieves a specific ticket by ID.
/// ///
/// Includes full ticket details and denormalized user information (creator name). /// Includes full ticket details and denormalized user information (creator name).
/// Returns a [`TicketResponse`] with all metadata by joining with [`User`](crate::models::User) table.
/// ///
/// # Arguments /// # Arguments
/// - `Path(id)`: Ticket ID to retrieve, extracted from URL path /// - `id`: Ticket ID to retrieve
/// - `State(data)`: Application state containing [`AppState`] for database access
/// ///
/// # Returns /// # Returns
/// - `200 OK` with [`TicketResponse`] object /// - `200 OK` with TicketResponse object
/// - `404 Not Found` if ticket doesn't exist /// - `404 Not Found` if ticket doesn't exist
/// - `500 Internal Server Error` if database error occurs /// - `500 Internal Server Error` if database error occurs
/// ///
@@ -250,16 +242,15 @@ pub async fn get_ticket_by_id(
/// Updates a ticket's status. /// Updates a ticket's status.
/// ///
/// Only admins can update ticket status (enforced by middleware). Applies [`TicketUpdateScheme`] to modify the [`TicketResponse`]. /// Only admins can update ticket status. This is typically used to transition tickets
/// This is typically used to transition tickets through their lifecycle (open → in_progress → resolved → archived). /// through their lifecycle (open → in_progress → resolved → archived).
/// ///
/// # Arguments /// # Arguments
/// - `Path(id)`: Ticket ID to update, extracted from URL path /// - `id`: Ticket ID to update
/// - `State(data)`: Application state containing [`AppState`] for database access /// - `body`: Update payload containing new status
/// - `Json(body)`: [`TicketUpdateScheme`] update payload containing new status
/// ///
/// # Returns /// # Returns
/// - `200 OK` with updated [`TicketResponse`] /// - `200 OK` with updated TicketResponse
/// - `500 Internal Server Error` if ticket not found or database error /// - `500 Internal Server Error` if ticket not found or database error
/// ///
/// # Typical Status Flow /// # Typical Status Flow
+35 -26
View File
@@ -9,52 +9,69 @@ mod models;
/// Axum router configuration with all routes and middleware /// Axum router configuration with all routes and middleware
mod router; mod router;
use std::sync::Arc; use std::sync::{
Arc,
atomic::{AtomicI64, Ordering},
};
use axum::http::{ use axum::{
HeaderValue, Method, Json,
http::{
HeaderValue, Method, StatusCode,
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
},
}; };
use dotenv::dotenv; use dotenv::dotenv;
use router::create_router; use router::create_router;
use serde::Serialize;
use sqlx::{PgPool, postgres::PgPoolOptions}; use sqlx::{PgPool, postgres::PgPoolOptions};
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use crate::env::Env; use crate::env::Env;
static EASY_FIX_COUNT: AtomicI64 = AtomicI64::new(0);
#[derive(Serialize)]
struct CounterResp {
value: i64,
}
async fn get_count() -> Json<CounterResp> {
let v = EASY_FIX_COUNT.load(Ordering::SeqCst);
Json(CounterResp { value: v })
}
async fn increment() -> Result<Json<CounterResp>, StatusCode> {
let new = EASY_FIX_COUNT.fetch_add(1, Ordering::SeqCst) + 1;
Ok(Json(CounterResp { value: new }))
}
/// Shared application state passed to all route handlers. /// Shared application state passed to all route handlers.
/// ///
/// Contains the database connection pool and environment configuration. /// Contains the database connection pool and environment configuration.
/// This is wrapped in Arc for thread-safe sharing across async tasks and cloned into each route /// This is wrapped in Arc for thread-safe sharing across async tasks.
/// via `with_state`.
/// ///
/// # Fields /// # Fields
/// - `db`: PostgreSQL connection pool for database access (via `sqlx::PgPool`) /// - `db`: PostgreSQL connection pool for database access
/// - `env`: [`Env`] configuration loaded from environment variables /// - `env`: Configuration loaded from environment variables
pub struct AppState { pub struct AppState {
/// PostgreSQL connection pool for all database operations db: PgPool,
pub db: PgPool, env: Env,
/// Environment configuration with secrets and settings
pub env: Env,
} }
/// Main application entry point. /// Main application entry point.
/// ///
/// Initializes the server by: /// Initializes the server by:
/// 1. Loading environment variables from `.env` file /// 1. Loading environment variables from `.env` file
/// 2. Establishing database connection pool to PostgreSQL /// 2. Establishing database connection pool
/// 3. Configuring CORS policy for cross-origin requests /// 3. Configuring CORS policy for cross-origin requests
/// 4. Creating the router with [`create_router`] containing all endpoints /// 4. Starting HTTP server on port 8001
/// 5. Starting HTTP server on port 8001
/// ///
/// # Server Configuration /// # Server Configuration
/// - Binds to `0.0.0.0:8001` (all network interfaces) /// - Binds to `0.0.0.0:8001` (all network interfaces)
/// - Allows: GET, POST, PATCH, DELETE methods /// - Allows: GET, POST, PATCH, DELETE methods
/// - Allows credentials and custom headers /// - Allows credentials and custom headers
/// - CORS origin configured from [`Env`] /// - CORS origin configured from environment
///
/// # State Setup
/// Creates shared [`AppState`] wrapped in `Arc` and passes to all routes
/// ///
/// # Panics /// # Panics
/// - If environment loading fails /// - If environment loading fails
@@ -77,14 +94,6 @@ async fn main() {
} }
}; };
// Run database migrations on container/application startup
println!("Running database migrations...");
if let Err(err) = sqlx::migrate!("./migrations").run(&pool).await {
println!("Failed to run database migrations: {:?}", err);
std::process::exit(1);
}
println!("Database migrations completed successfully");
// Configure CORS to allow requests from frontend // Configure CORS to allow requests from frontend
let cors = CorsLayer::new() let cors = CorsLayer::new()
.allow_origin(env.origin.parse::<HeaderValue>().unwrap()) .allow_origin(env.origin.parse::<HeaderValue>().unwrap())
+11 -51
View File
@@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize};
/// API response for a ticket with user information. /// API response for a ticket with user information.
/// ///
/// Returned by ticket endpoints. Includes denormalized user data for easier frontend rendering. /// Returned by ticket endpoints. Includes denormalized user data for easier frontend rendering.
/// Created via [`TicketCreateScheme`].
/// ///
/// # Fields /// # Fields
/// - `id`: Unique ticket identifier /// - `id`: Unique ticket identifier
@@ -13,7 +12,7 @@ use serde::{Deserialize, Serialize};
/// - `room`: Room number associated with the issue /// - `room`: Room number associated with the issue
/// - `status`: Current ticket status (e.g., "open", "in_progress", "resolved") /// - `status`: Current ticket status (e.g., "open", "in_progress", "resolved")
/// - `date`: When the ticket was created (UTC timestamp) /// - `date`: When the ticket was created (UTC timestamp)
/// - `user_id`: ID of the user who created the ticket (references [`User`]) /// - `user_id`: ID of the user who created the ticket
/// - `user_first_name`, `user_last_name`: User's name (denormalized for convenience) /// - `user_first_name`, `user_last_name`: User's name (denormalized for convenience)
/// ///
/// # Example /// # Example
@@ -33,32 +32,22 @@ use serde::{Deserialize, Serialize};
/// ``` /// ```
#[derive(Deserialize, Serialize, Debug, PartialEq)] #[derive(Deserialize, Serialize, Debug, PartialEq)]
pub struct TicketResponse { pub struct TicketResponse {
/// Unique ticket identifier
pub id: i32, pub id: i32,
/// Ticket category/type (e.g., "maintenance", "support")
pub category: String, pub category: String,
/// Ticket subject line
pub betreff: String, pub betreff: String,
/// Detailed ticket description
pub description: String, pub description: String,
/// Room number associated with the issue
pub room: i16, pub room: i16,
/// Current ticket status (e.g., "open", "in_progress", "resolved", "archived")
pub status: String, pub status: String,
/// When the ticket was created (UTC timestamp)
pub date: chrono::DateTime<chrono::Utc>, pub date: chrono::DateTime<chrono::Utc>,
/// ID of the user who created the ticket
pub user_id: i16, pub user_id: i16,
/// First name of the ticket creator (denormalized from `User`)
pub user_first_name: String, pub user_first_name: String,
/// Last name of the ticket creator (denormalized from `User`)
pub user_last_name: String, pub user_last_name: String,
} }
/// Complete user record from the database. /// Complete user record from the database.
/// ///
/// Contains all user information including the password hash. /// Contains all user information including the password hash.
/// This should NEVER be sent directly to clients - always use [`FilteredUser`] instead. /// This should NEVER be sent directly to clients - always use `FilteredUser` instead.
/// ///
/// # Fields /// # Fields
/// - `id`: Unique user identifier /// - `id`: Unique user identifier
@@ -69,27 +58,21 @@ pub struct TicketResponse {
/// ///
/// # Security Note /// # Security Note
/// The `pwd` field contains the password hash and should never be included in API responses. /// The `pwd` field contains the password hash and should never be included in API responses.
/// Use [`filter_user()`](`crate::handlers::auth::filter_user`) to convert to [`FilteredUser`] for responses. /// Use `filter_user()` to convert to `FilteredUser` for responses.
#[derive(Deserialize, Serialize, PartialEq, Debug, Clone, sqlx::FromRow)] #[derive(Deserialize, Serialize, PartialEq, Debug, Clone, sqlx::FromRow)]
pub struct User { pub struct User {
/// Unique user identifier
pub id: i16, pub id: i16,
/// User's last name
pub last_name: String, pub last_name: String,
/// User's first name
pub first_name: String, pub first_name: String,
/// Unique login username (must be unique in the database)
pub username: String, pub username: String,
/// Whether this user has administrator privileges
pub is_admin: bool, pub is_admin: bool,
/// Argon2 password hash (NEVER expose to clients)
pub pwd: String, pub pwd: String,
} }
/// Payload for creating a new ticket. /// Payload for creating a new ticket.
/// ///
/// Sent to `/api/tickets/create`. The backend automatically associates it with the /// Sent to `/api/tickets/create`. The backend automatically associates it with the
/// authenticated user and sets the creation timestamp. Converted to [`TicketResponse`] for the response. /// authenticated user and sets the creation timestamp.
/// ///
/// # Fields /// # Fields
/// - `category`: Ticket category/type /// - `category`: Ticket category/type
@@ -98,60 +81,49 @@ pub struct User {
/// - `room`: Room number where the issue is located /// - `room`: Room number where the issue is located
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
pub struct TicketCreateScheme { pub struct TicketCreateScheme {
/// Ticket category/type
pub category: String, pub category: String,
/// Subject line for the ticket
pub betreff: String, pub betreff: String,
/// Detailed problem description
pub description: String, pub description: String,
/// Room number where the issue is located
pub room: i16, pub room: i16,
} }
/// Payload for updating a ticket. /// Payload for updating a ticket.
/// ///
/// Sent to `PATCH /api/tickets/{id}`. Allows updating the ticket [`TicketResponse::status`]. /// Sent to `PATCH /api/tickets/{id}`. Currently only allows status updates.
/// Only admins can update tickets. /// Only admins can update tickets.
/// ///
/// # Fields /// # Fields
/// - `status`: New ticket status (e.g., "open", "in_progress", "resolved") /// - `status`: New ticket status (e.g., "open", "in_progress", "resolved")
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
pub struct TicketUpdateScheme { pub struct TicketUpdateScheme {
/// New ticket status (e.g., "open", "in_progress", "resolved", "archived")
pub status: String, pub status: String,
} }
/// Payload for updating user information. /// Payload for updating user information.
/// ///
/// Sent to `PATCH /api/users/{id}`. Allows updating profile and admin status. /// Sent to `PATCH /api/users/{id}`. Allows updating profile and admin status.
/// Only admins can update [`User`] records. Empty password field means no password change. /// Only admins can update users. Empty password field means no password change.
/// ///
/// # Fields /// # Fields
/// - `id`: [`User`] ID to update /// - `id`: User ID to update
/// - `first_name`, `last_name`: Updated user name /// - `first_name`, `last_name`: Updated user name
/// - `username`: Updated login username /// - `username`: Updated login username
/// - `make_admin`: New admin privilege status /// - `make_admin`: New admin privilege status
/// - `new_pwd`: New password (empty string = keep existing password) /// - `new_pwd`: New password (empty string = keep existing password)
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
pub struct UserUpdateScheme { pub struct UserUpdateScheme {
/// User ID to update
pub id: i16, pub id: i16,
/// Updated user first name
pub first_name: String, pub first_name: String,
/// Updated user last name
pub last_name: String, pub last_name: String,
/// Updated login username
pub username: String, pub username: String,
/// New admin privilege status
pub make_admin: bool, pub make_admin: bool,
/// New password (empty string = keep existing password)
pub new_pwd: String, pub new_pwd: String,
} }
/// Payload for creating a new user account. /// Payload for creating a new user account.
/// ///
/// Used in both admin registration (`/api/register`) and initial setup (`/api/setup-admin`). /// Used in both admin registration (`/api/register`) and initial setup (`/api/setup-admin`).
/// The password is hashed server-side before storage using Argon2. Converted to [`User`] for storage. /// The password is hashed server-side before storage using Argon2.
/// ///
/// # Fields /// # Fields
/// - `first_name`: User's first name /// - `first_name`: User's first name
@@ -161,15 +133,10 @@ pub struct UserUpdateScheme {
/// - `pwd`: Plain text password (hashed on server) /// - `pwd`: Plain text password (hashed on server)
#[derive(Deserialize, Serialize, Debug, sqlx::FromRow)] #[derive(Deserialize, Serialize, Debug, sqlx::FromRow)]
pub struct UserCreateScheme { pub struct UserCreateScheme {
/// User's first name
pub first_name: String, pub first_name: String,
/// User's last name
pub last_name: String, pub last_name: String,
/// Unique username for login
pub username: String, pub username: String,
/// Whether to grant admin privileges
pub is_admin: bool, pub is_admin: bool,
/// Plain text password (hashed on server before storage)
pub pwd: String, pub pwd: String,
} }
@@ -182,38 +149,31 @@ pub struct UserCreateScheme {
/// The password is never stored in plain text - only the Argon2 hash is persisted. /// The password is never stored in plain text - only the Argon2 hash is persisted.
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
pub struct LoginScheme { pub struct LoginScheme {
/// Username for login
pub username: String, pub username: String,
/// Plain text password (verified against stored Argon2 hash)
pub pwd: String, pub pwd: String,
} }
/// User information sent to clients, excluding password hashes. /// User information sent to clients, excluding password hashes.
/// ///
/// This is the safe version of [`User`] data that gets returned in API responses. /// This is the safe version of User data that gets returned in API responses.
/// It never includes the password hash or JWT claims. Always use this for responses /// It never includes the password hash or JWT claims. Always use this for responses
/// to prevent leaking sensitive data. /// to prevent leaking sensitive data.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct FilteredUser { pub struct FilteredUser {
/// Unique user identifier
pub id: i16, pub id: i16,
/// User's first name
pub first_name: String, pub first_name: String,
/// User's last name
pub last_name: String, pub last_name: String,
/// Login username
pub username: String, pub username: String,
/// Whether user has admin privileges
pub is_admin: bool, pub is_admin: bool,
} }
/// JWT token claims embedded in the session token. /// JWT token claims embedded in the session token.
/// ///
/// Contains user identification and token validity information. /// Contains user identification and token validity information.
/// Generated during login via `encode_token` and verified via `decode_token`. /// Generated during login and verified by middleware on protected routes.
/// ///
/// # Fields /// # Fields
/// - `sub`: Subject - the user ID as a string (references [`User`]) /// - `sub`: Subject - the user ID as a string
/// - `issued`: Unix timestamp when token was created /// - `issued`: Unix timestamp when token was created
/// - `expires`: Unix timestamp when token expires (currently 1 hour from creation) /// - `expires`: Unix timestamp when token expires (currently 1 hour from creation)
/// ///
+22 -38
View File
@@ -1,61 +1,50 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Router, Router, middleware,
http::StatusCode,
middleware,
response::IntoResponse,
routing::{get, post}, routing::{get, post},
}; };
use crate::{ use crate::{
AppState, AppState,
cookie::validation::{validate_admin, validate_token}, cookie::validation::{validate_admin, validate_token},
get_count,
handlers::{ handlers::{
auth::{ auth::{
check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id, check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id,
get_users, login, logout, setup_initial_admin, update_user, get_users, login, logout, setup_initial_admin, update_user,
}, },
qr::{generate_qr_token, validate_qr_token},
ticket::{create_ticket, delete_ticket, edit_ticket, get_ticket_by_id, get_tickets}, ticket::{create_ticket, delete_ticket, edit_ticket, get_ticket_by_id, get_tickets},
}, },
increment,
}; };
async fn debug_fallback_handler(uri: axum::http::Uri) -> impl IntoResponse {
println!(
"[BACKEND DEBUG] Axum Fallback triggered! Unmatched request path: {}",
uri
);
(StatusCode::NOT_FOUND, "Backend route not found")
}
/// Creates the complete router with all API endpoints. /// Creates the complete router with all API endpoints.
/// ///
/// The router is organized in layers for proper middleware application. Uses [`AppState`] /// The router is organized in layers for proper middleware application:
/// for shared application context across all routes.
/// ///
/// ## Route Layers (from most to least restricted): /// ## Route Layers (from most to least restricted):
/// ///
/// ### Admin-Only Routes (requires admin privilege + valid token) /// ### Admin-Only Routes (requires admin privilege + valid token)
/// - `GET /api/tickets/{id}` - Get specific ticket details (via `get_ticket_by_id`) /// - `GET /api/tickets/{id}` - Get specific ticket details
/// - `DELETE /api/tickets/{id}` - Delete a ticket (via `delete_ticket`) /// - `DELETE /api/tickets/{id}` - Delete a ticket
/// - `PATCH /api/tickets/{id}` - Update ticket status (via `edit_ticket`) /// - `PATCH /api/tickets/{id}` - Update ticket status
/// - `POST /api/register` - Create a new user (via `create_user`) /// - `POST /api/register` - Create a new user
/// - `GET /api/users` - List all users (via `get_users`) /// - `GET /api/users` - List all users
/// - `GET /api/users/{id}` - Get user details (via `get_user_by_id`) /// - `GET /api/users/{id}` - Get user details
/// - `DELETE /api/users/{id}` - Delete a user (via `delete_user`) /// - `DELETE /api/users/{id}` - Delete a user
/// - `PATCH /api/users/{id}` - Update user details (via `update_user`) /// - `PATCH /api/users/{id}` - Update user details
/// ///
/// ### Protected Routes (requires valid token) /// ### Protected Routes (requires valid token)
/// - `GET /api/tickets` - List all tickets (via `get_tickets`) /// - `GET /api/tickets` - List all tickets
/// - `POST /api/tickets/create` - Create a new ticket (via `create_ticket`) /// - `POST /api/tickets/create` - Create a new ticket
/// - `GET /api/logout` - Logout user (via `logout`) /// - `GET /api/logout` - Logout user
/// - `GET /api/users/current` - Get current authenticated user (via `get_current_user`) /// - `GET /api/users/current` - Get current authenticated user
/// ///
/// ### Public Routes (no authentication required) /// ### Public Routes (no authentication required)
/// - `POST /api/login` - User login (via `login`) /// - `POST /api/login` - User login
/// - `GET /api/check-admin` - Check if admin exists (via `check_admin_exists`) /// - `GET /api/check-admin` - Check if admin exists (for setup detection)
/// - `POST /api/setup-admin` - Create initial admin account (via `setup_initial_admin`) /// - `POST /api/setup-admin` - Create initial admin account (only if no admin exists)
/// ///
/// # Middleware Stack /// # Middleware Stack
/// - Admin routes have `validate_admin` middleware /// - Admin routes have `validate_admin` middleware
@@ -75,7 +64,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/api/users/{id}", "/api/users/{id}",
get(get_user_by_id).delete(delete_user).patch(update_user), get(get_user_by_id).delete(delete_user).patch(update_user),
) )
.route("/api/generate_qr", post(generate_qr_token))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
validate_admin, validate_admin,
@@ -87,20 +75,16 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/api/tickets/create", post(create_ticket)) .route("/api/tickets/create", post(create_ticket))
.route("/api/logout", get(logout)) .route("/api/logout", get(logout))
.route("/api/users/current", get(get_current_user)) .route("/api/users/current", get(get_current_user))
.route("/api/count", get(get_count).post(increment))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
validate_token, validate_token,
)); ));
let public_routes = Router::new() Router::new()
.merge(protected_routes)
.route("/api/login", post(login)) .route("/api/login", post(login))
.route("/api/check-admin", get(check_admin_exists)) .route("/api/check-admin", get(check_admin_exists))
.route("/api/setup-admin", post(setup_initial_admin)) .route("/api/setup-admin", post(setup_initial_admin))
.route("/api/qr_login", get(validate_qr_token));
Router::new()
.merge(protected_routes)
.merge(public_routes)
.fallback(debug_fallback_handler)
.with_state(state) .with_state(state)
} }
+14
View File
@@ -0,0 +1,14 @@
services:
postgres:
image: postgres:latest
container_name: postgres_ticket
restart: unless-stopped
ports:
- 5432:5432
environment:
- POSTGRES_PASSWORD=tickets
volumes:
- pg_data:/var/lib/postregsql/pg_data
volumes:
pg_data:
-127
View File
@@ -1,127 +0,0 @@
# ============================================================
# Dockerfile — Full application (frontend + backend)
# ============================================================
# Multi-stage build:
# 1. chef install cargo-chef for dependency caching
# 2. planner generate recipe.json from the workspace
# 3. backend build the backend binary (release)
# 4. frontend build the frontend WASM bundle with Trunk
# 5. runtime minimal image: backend binary + static frontend + nginx
# ============================================================
# --------------- Stage 1: Chef base ---------------
FROM rust:1-bookworm AS chef
RUN cargo install cargo-chef
WORKDIR /app
# --------------- Stage 2: Planner ---------------
FROM chef AS planner
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
RUN cargo chef prepare --recipe-path recipe.json
# --------------- Stage 3: Backend builder ---------------
FROM chef AS backend-builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json -p backend
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
RUN cargo build --release -p backend
# --------------- Stage 4: Frontend builder ---------------
FROM chef AS frontend-builder
# Add the WASM target
RUN rustup target add wasm32-unknown-unknown
# Install Trunk (the WASM bundler used by the frontend) and sass (for SCSS)
RUN cargo install trunk
# Cache frontend dependencies
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --target wasm32-unknown-unknown --recipe-path recipe.json -p frontend
# Copy full workspace source for the build
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
# Build the frontend WASM bundle
# Trunk outputs to frontend/dist by default
WORKDIR /app/frontend
RUN trunk build --release
# --------------- Stage 5: Runtime ---------------
FROM debian:bookworm-slim AS runtime
# Install runtime dependencies:
# - ca-certificates & libssl3: TLS for PostgreSQL connections
# - nginx: serves the frontend static files and reverse-proxies /api to the backend
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates libssl3 nginx && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the compiled backend binary
COPY --from=backend-builder /app/target/release/backend /app/backend
# Copy migrations
COPY backend/migrations/ /app/migrations/
# Copy the frontend static build output
COPY --from=frontend-builder /app/frontend/dist /var/www/html
# Nginx configuration: serve frontend + reverse-proxy /api to the backend
RUN cat > /etc/nginx/sites-available/default <<'EOF'
server {
listen 80;
server_name _;
root /var/www/html;
index index.html;
# Serve static frontend files, fall back to index.html for client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Reverse-proxy API requests to the Axum backend
location /api/ {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOF
# Startup script: launch backend in the background, then nginx in the foreground
RUN cat > /app/start.sh <<'SCRIPT'
#!/bin/bash
set -e
# Start the backend API server in the background
/app/backend &
# Start nginx in the foreground (keeps the container alive)
nginx -g 'daemon off;'
SCRIPT
RUN chmod +x /app/start.sh
EXPOSE 80
# Environment variables should be provided at runtime:
# DATABASE_URL PostgreSQL connection string
# TOKEN_SECRET JWT signing key
# ORIGIN Allowed CORS origin (should be the public URL of this container)
CMD ["/app/start.sh"]
-66
View File
@@ -1,66 +0,0 @@
# ============================================================
# Dockerfile.backend — Backend only
# ============================================================
# Multi-stage build:
# 1. chef install cargo-chef for dependency caching
# 2. planner generate a recipe.json from the workspace
# 3. builder build dependencies first (cached), then the backend binary
# 4. runtime minimal image with just the compiled binary
# ============================================================
# --------------- Stage 1: Chef base ---------------
FROM rust:1-bookworm AS chef
RUN cargo install cargo-chef
WORKDIR /app
# --------------- Stage 2: Planner ---------------
FROM chef AS planner
# Copy the full workspace so cargo-chef can resolve all dependencies
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
RUN cargo chef prepare --recipe-path recipe.json
# --------------- Stage 3: Builder ---------------
FROM chef AS builder
# Cache dependency compilation in its own layer
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json -p backend
# Now copy the real source and build
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
RUN cargo build --release -p backend
# --------------- Stage 4: Runtime ---------------
FROM debian:bookworm-slim AS runtime
# Install TLS root certificates and PostgreSQL client libs (needed by sqlx
# at runtime for TLS connections via native-tls)
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates libssl3 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the compiled binary
COPY --from=builder /app/target/release/backend /app/backend
# Copy migrations so they can be run at startup if needed
COPY backend/migrations/ /app/migrations/
EXPOSE 8001
# Environment variables should be provided at runtime via docker run --env
# or docker compose environment/env_file.
#
# Required:
# DATABASE_URL PostgreSQL connection string
# TOKEN_SECRET JWT signing key
# ORIGIN Allowed CORS origin (frontend URL)
CMD ["/app/backend"]
-58
View File
@@ -1,58 +0,0 @@
services:
postgres:
image: postgres:latest
container_name: postgres_ticket
restart: unless-stopped
ports:
- 5432:5432
environment:
POSTGRES_PASSWORD: tickets
POSTGRES_DB: system_data
volumes:
- pg_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
# Full application (frontend + backend + nginx)
# Usage: docker compose --profile full up --build
app:
profiles: ["full"]
build:
context: ..
dockerfile: docker/Dockerfile
container_name: ticketsystem
restart: unless-stopped
ports:
- 8080:80
environment:
DATABASE_URL: postgres://postgres:tickets@postgres:5432/system_data
TOKEN_SECRET: 160257c8c5ff2298363529e963a5901d2efa6d6ae67d634487c4d2bbc41c533f
ORIGIN: http://localhost:8080
depends_on:
postgres:
condition: service_healthy
# Backend only (API server)
# Usage: docker compose --profile backend up --build
backend:
profiles: ["backend"]
build:
context: ..
dockerfile: docker/Dockerfile.backend
container_name: ticketsystem-backend
restart: unless-stopped
ports:
- 8001:8001
environment:
DATABASE_URL: postgres://postgres:tickets@postgres:5432/system_data
TOKEN_SECRET: 160257c8c5ff2298363529e963a5901d2efa6d6ae67d634487c4d2bbc41c533f
ORIGIN: http://localhost:8000
depends_on:
postgres:
condition: service_healthy
volumes:
pg_data:
-4
View File
@@ -26,7 +26,3 @@ gloo = "0.12.0"
chrono = { workspace = true } chrono = { workspace = true }
chrono-tz = "0.10.4" chrono-tz = "0.10.4"
wasm-bindgen = "0.2.120" wasm-bindgen = "0.2.120"
theme = { version = "0.0.3", features = ["yew"] }
qrcode-generator = "5.0.0"
base64 = '0.22'
dotenv = '0.15.0'
+1 -1
View File
@@ -1,6 +1,6 @@
[serve] [serve]
# The address to serve on LAN. # The address to serve on LAN.
addresses = ["127.0.0.1"] #,"10.150.9.7"] addresses = ["127.0.0.1", "192.168.178.56"] #,"10.150.9.116"]
# The address to serve on WAN. # The address to serve on WAN.
# address = "0.0.0.0" # address = "0.0.0.0"
# The port to serve on. # The port to serve on.
-2
View File
@@ -4,8 +4,6 @@
<head> <head>
<link data-trunk rel="rust" data-bin="bin" /> <link data-trunk rel="rust" data-bin="bin" />
<link data-trunk rel="scss" href="src/styles/main.scss" /> <link data-trunk rel="scss" href="src/styles/main.scss" />
<link data-trunk rel="icon" href="src/assets/favicon.ico" type="image/x-icon" />
<link data-trunk rel="copy-dir" href="src/assets" data-target-path="assets" />
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Yew App</title> <title>Yew App</title>
</head> </head>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

+7 -7
View File
@@ -33,14 +33,14 @@ pub struct ProtectedRouteProps {
/// A component that protects routes by enforcing authentication and optional administrator privileges. /// A component that protects routes by enforcing authentication and optional administrator privileges.
/// ///
/// This component uses the backend's validation middleware by fetching the current user's authentication /// This component fetches the current user's authentication and admin status from the
/// and admin status from the `/api/users/current` endpoint (which requires a valid JWT token). /// `/api/users/current` endpoint upon mounting. Based on the `AuthState` and the
/// Based on the [`AuthState`] and the `admin_page` property, it either renders its children or redirects the user. /// `admin_page` property, it either renders its children or redirects the user.
/// ///
/// # Behavior /// # Behavior
/// - **Initial Load**: Displays "Loading..." while checking authentication status via the backend. /// - **Initial Load**: Displays "Loading..." while checking authentication status.
/// - **Not Authenticated**: Redirects to the login page (`crate::Route::Login`). /// - **Not Authenticated**: Redirects to the login page (`crate::Route::Login`).
/// - **Authenticated** (valid JWT token from backend): /// - **Authenticated**:
/// - If `admin_page` is `true`: /// - If `admin_page` is `true`:
/// - If the user is an administrator (`is_admin: Some(true)`), it renders `children`. /// - If the user is an administrator (`is_admin: Some(true)`), it renders `children`.
/// - If the user is not an administrator (`is_admin: Some(false)`), it redirects to /// - If the user is not an administrator (`is_admin: Some(false)`), it redirects to
@@ -109,7 +109,7 @@ pub fn protected_route(props: &ProtectedRouteProps) -> Html {
AuthState { AuthState {
is_authenticated: None, is_authenticated: None,
.. ..
} => html! { <div>{ "Wird geladen..." } </div> }, } => html! { <div>{ "Loading..." } </div> },
AuthState { AuthState {
is_authenticated: Some(false), is_authenticated: Some(false),
.. ..
@@ -126,7 +126,7 @@ pub fn protected_route(props: &ProtectedRouteProps) -> Html {
Some(false) => { Some(false) => {
html! { <Redirect<crate::Route> to={crate::Route::PermissionDenied}/> } html! { <Redirect<crate::Route> to={crate::Route::PermissionDenied}/> }
} }
None => html! { <div>{ "Überprüfe Berechtigungen..." }</div> }, None => html! { <div>{ "Checking permissions..." }</div> },
} }
} else { } else {
props.children.clone().into() props.children.clone().into()
-78
View File
@@ -1,78 +0,0 @@
use yew::prelude::*;
/// A button component that toggles between light and dark theme modes.
///
/// Clicking this button switches the application's theme by modifying the `data-theme` attribute
/// on the document element. The theme preference is applied via CSS variables for consistent styling.
///
/// # Behavior
/// - Reads the current `data-theme` attribute value from the HTML element
/// - Sets `data-theme="dark"` if currently in light mode
/// - Clears the attribute (light mode) if currently in dark mode
/// - Re-renders to reflect the visual changes (typically toggled via CSS)
///
/// # Example Usage
/// ```ignore
/// html! {
/// <ThemeToggle />
/// }
/// ```
#[function_component]
pub fn ThemeToggle() -> Html {
let is_dark = use_state(|| {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Some(html) = document.document_element() {
return html.get_attribute("data-theme").unwrap_or_default() == "dark";
}
}
}
false
});
let onclick = {
let is_dark = is_dark.clone();
Callback::from(move |_| {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Some(html) = document.document_element() {
let current = html.get_attribute("data-theme").unwrap_or_default();
let new_theme = if current == "dark" { "" } else { "dark" };
let _ = html.set_attribute("data-theme", new_theme);
is_dark.set(new_theme == "dark");
}
}
}
})
};
let icon_html = if *is_dark {
// Sun SVG for dark mode
html! {
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="theme-icon">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
}
} else {
// Moon SVG for light mode
html! {
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="theme-icon">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
}
};
html! {
<button {onclick} class="sidebar-button theme-toggle-btn">
{icon_html}
</button>
}
}
+30 -78
View File
@@ -1,5 +1,4 @@
mod auth; mod auth;
mod darkmode;
mod pages; mod pages;
use crate::auth::ProtectedRoute; use crate::auth::ProtectedRoute;
use crate::pages::*; use crate::pages::*;
@@ -11,9 +10,7 @@ use yew_router::prelude::*;
/// Defines the application's various routes and their corresponding paths. /// Defines the application's various routes and their corresponding paths.
/// ///
/// This enum is used by `yew-router` to map URLs to specific components, /// This enum is used by `yew-router` to map URLs to specific components,
/// enabling navigation within the single-page application. Each route is protected /// enabling navigation within the single-page application.
/// by [`ProtectedRoute`] middleware where appropriate to enforce authentication and authorization.
/// See [`switch`] for the routing logic.
#[derive(Clone, PartialEq, Routable)] #[derive(Clone, PartialEq, Routable)]
enum Route { enum Route {
/// The application's home page. /// The application's home page.
@@ -68,17 +65,10 @@ pub struct SidebarShellProps {
/// A shell component that provides a consistent layout with a sidebar and a main content area. /// A shell component that provides a consistent layout with a sidebar and a main content area.
/// ///
/// This component is designed to wrap page-specific content, ensuring that the sidebar /// This component is designed to wrap page-specific content, ensuring that the sidebar
/// is always present for navigation. Integrates with [`crate::pages::sidebar::Sidebar`] for navigation. /// is always present for navigation.
///
/// # Mobile Support
/// On mobile displays, the sidebar is hidden by default and can be toggled:
/// - **Menu Toggle Button**: Renders a floating menu button to slide the sidebar open.
/// - **Dark Mode Toggle**: Floating button to change theme.
/// - **Overlay Backdrop**: Dims the screen when the sidebar is open. Clicking it closes the sidebar.
/// - **Auto-Close on Navigation**: Automatically closes the sidebar when navigating to a new route.
/// ///
/// # Components /// # Components
/// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component, accepting mobile open state. /// - [`sidebar::Sidebar`]: The navigation sidebar component.
/// - Main content area: Renders the `children` passed to this component. /// - Main content area: Renders the `children` passed to this component.
/// ///
/// # Example /// # Example
@@ -91,46 +81,9 @@ pub struct SidebarShellProps {
/// ``` /// ```
#[component(SidebarShell)] #[component(SidebarShell)]
fn sidebar_shell(props: &SidebarShellProps) -> Html { fn sidebar_shell(props: &SidebarShellProps) -> Html {
let route = use_route::<Route>();
let mobile_sidebar_open = use_state(|| false);
// Close mobile sidebar automatically on any route transition
{
let mobile_sidebar_open = mobile_sidebar_open.clone();
use_effect_with(route, move |_| {
mobile_sidebar_open.set(false);
|| ()
});
}
let on_open = {
let mobile_sidebar_open = mobile_sidebar_open.clone();
Callback::from(move |_: MouseEvent| mobile_sidebar_open.set(true))
};
let on_close = {
let mobile_sidebar_open = mobile_sidebar_open.clone();
Callback::from(move |_: ()| mobile_sidebar_open.set(false))
};
let on_close_click = {
let on_close = on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
};
html! { html! {
<div class="layout"> <div class="layout">
<button class="mobile-menu-toggle" onclick={on_open}> <sidebar::Sidebar/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="12" y2="12"/>
<line x1="4" x2="20" y1="6" y2="6"/>
<line x1="4" x2="20" y1="18" y2="18"/>
</svg>
</button>
<div class={if *mobile_sidebar_open { "sidebar-overlay open" } else { "sidebar-overlay" }} onclick={on_close_click}></div>
<sidebar::Sidebar is_open={*mobile_sidebar_open} on_close={on_close} />
<main class="content"> <main class="content">
{ for props.children.iter() } { for props.children.iter() }
</main> </main>
@@ -148,11 +101,11 @@ pub struct AdminCheckWrapperProps {
/// ///
/// This component is used to gate access to pages that should only be accessible before /// This component is used to gate access to pages that should only be accessible before
/// system initialization (e.g., login page). It performs an asynchronous check to the /// system initialization (e.g., login page). It performs an asynchronous check to the
/// backend's `/api/check-admin` endpoint (via `crate::handlers::auth::check_admin_exists`) to determine system state. /// `/api/check-admin` endpoint to determine system state.
/// ///
/// # Behavior /// # Behavior
/// - **Loading**: Displays "Loading..." while checking admin status from the backend /// - **Loading**: Displays "Loading..." while checking admin status
/// - **No Admin**: Automatically redirects to [`Route::Setup`] page for initialization /// - **No Admin**: Automatically redirects to `/setup` page to initialize
/// - **Admin Exists**: Renders the wrapped children (e.g., login page) /// - **Admin Exists**: Renders the wrapped children (e.g., login page)
/// ///
/// # Example Usage /// # Example Usage
@@ -162,9 +115,19 @@ pub struct AdminCheckWrapperProps {
/// </AdminCheckWrapper> /// </AdminCheckWrapper>
/// ``` /// ```
/// ///
/// # Backend Integration /// # Implementation Detail
/// The check queries the backend's `/api/check-admin` endpoint which returns `{"has_admin": bool}`. /// The check happens in `use_effect_with` on component mount:
/// This allows the frontend to determine if initial admin setup is required. /// ```ignore
/// spawn_local(async move {
/// match Request::get("/api/check-admin").send().await {
/// Ok(resp) if resp.status() == 200 => {
/// let has_admin = data["has_admin"].as_bool().unwrap_or(false);
/// admin_exists.set(Some(has_admin));
/// }
/// _ => admin_exists.set(Some(false))
/// }
/// });
/// ```
#[component(AdminCheckWrapper)] #[component(AdminCheckWrapper)]
fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html { fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html {
let admin_exists = use_state(|| None::<bool>); let admin_exists = use_state(|| None::<bool>);
@@ -192,10 +155,10 @@ fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html {
} }
match *admin_exists { match *admin_exists {
None => html! { <div>{ "Wird geladen..." }</div> }, None => html! { <div>{ "Loading..." }</div> },
Some(false) => { Some(false) => {
navigator.push(&Route::Setup); navigator.push(&Route::Setup);
html! { <div>{ "Wird weitergeleitet zur Einrichtung..." }</div> } html! { <div>{ "Redirecting to setup..." }</div> }
} }
Some(true) => props.children.clone().into(), Some(true) => props.children.clone().into(),
} }
@@ -203,24 +166,18 @@ fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html {
/// The main routing logic for the application. /// The main routing logic for the application.
/// ///
/// This function takes a [`Route`] enum variant and returns the corresponding HTML /// This function takes a `Route` enum variant and returns the corresponding HTML
/// content to be rendered. It acts as a central dispatcher for the application's /// content to be rendered. It acts as a central dispatcher for the application's
/// navigation and authentication flow. /// navigation.
/// ///
/// Most routes are wrapped in [`ProtectedRoute`] to enforce authentication /// Many routes are wrapped in a [`ProtectedRoute`] to enforce authentication
/// and authorization based on the `admin_page` flag, and in [`SidebarShell`] to maintain consistent layout. /// and authorization, and in a [`SidebarShell`] to maintain consistent layout.
/// Login and Setup routes use [`AdminCheckWrapper`] instead to handle pre-authentication states.
/// ///
/// # Arguments /// # Arguments
/// - `route`: The [`Route`] enum variant representing the current URL path. /// - `route`: The [`Route`] enum variant representing the current URL path.
/// ///
/// # Returns /// # Returns
/// An `Html` component that should be rendered for the given route. /// An `Html` component that should be rendered for the given route.
///
/// # Route Protection
/// - **Admin-required routes** (`admin_page={true}`): Require both authentication and admin privileges
/// - **Public routes** (`admin_page={false}`): Require only authentication
/// - **Pre-auth routes** (`AdminCheckWrapper`): Used before admin creation or login
fn switch(route: Route) -> Html { fn switch(route: Route) -> Html {
match route { match route {
Route::Home => html! { Route::Home => html! {
@@ -302,21 +259,16 @@ fn switch(route: Route) -> Html {
/// The root component of the Yew application. /// The root component of the Yew application.
/// ///
/// This component sets up the application's routing using `yew-router`'s /// This component sets up the application's routing using `yew-router`'s
/// `BrowserRouter` and `Switch` components. All other application content /// [`BrowserRouter`] and [`Switch`] components. All other application content
/// is rendered based on the current [`Route`] matched by the `switch` function. /// is rendered based on the current route.
///
/// Uses [`switch`] as the routing dispatcher to handle all route-specific rendering,
/// which applies appropriate middleware like [`ProtectedRoute`] and [`AdminCheckWrapper`].
/// ///
/// # Structure /// # Structure
/// - `BrowserRouter`: Enables client-side routing. /// - [`BrowserRouter`]: Enables client-side routing.
/// - `Switch`: Renders components based on the matched [`Route`]. /// - [`Switch`]: Renders components based on the matched [`Route`].
/// - `switch` function: Determines which component to render for each route.
#[component(App)] #[component(App)]
pub fn app() -> Html { pub fn app() -> Html {
html! { html! {
<BrowserRouter> <BrowserRouter>
<basic_pages::Icon/>
<Switch<Route> render={switch} /> <Switch<Route> render={switch} />
</BrowserRouter> </BrowserRouter>
} }
-4
View File
@@ -1,9 +1,5 @@
use frontend::App; use frontend::App;
/// Main entry point for the Yew frontend application.
///
/// Initializes the Yew framework and renders the root [`App`] component into the DOM.
/// This function is typically called automatically by the WebAssembly runtime.
fn main() { fn main() {
yew::Renderer::<App>::new().render(); yew::Renderer::<App>::new().render();
} }
+22 -41
View File
@@ -3,29 +3,7 @@ use wasm_bindgen_futures::spawn_local;
use yew::prelude::*; use yew::prelude::*;
use yew_router::prelude::*; use yew_router::prelude::*;
/// Removes surrounding double quotes from a string. #[macro_export]
///
/// This macro takes an expression that evaluates to a string and returns a new `String`
/// with any leading or trailing double quotes removed. It's useful for cleaning up
/// string data that might be inadvertently wrapped in quotes, such as JSON string values.
///
/// # Arguments
///
/// * `$str`: An expression that can be converted into a string slice (`&str`).
///
/// # Examples
///
/// ```rust
/// use your_crate::dequote; // Assuming `dequote` is re-exported or in scope
///
/// let quoted_string = "\"hello world\"";
/// let dequoted_string = dequote!(quoted_string);
/// assert_eq!(dequoted_string, "hello world");
///
/// let already_clean = "no quotes";
/// let dequoted_clean = dequote!(already_clean);
/// assert_eq!(dequoted_clean, "no quotes");
/// ```
macro_rules! dequote { macro_rules! dequote {
($str:expr) => { ($str:expr) => {
$str.trim_matches('"').to_string() $str.trim_matches('"').to_string()
@@ -79,11 +57,15 @@ pub fn home_component() -> Html {
} }
html! { html! {
<div class="rundbg home"> <div class="form-container home">
<div class="page-header">
<h1>{ "Welcome" }</h1>
</div>
<crate::utilities::TicketCount/> <crate::utilities::TicketCount/>
<div>
<p class="login-status">{ "Sie sind angemeldet als: " }</p> <p>{ "You are logged in as: " }</p>
<p class="name">{ &*name }</p> <p class="text-muted">{ &*name }</p>
</div>
</div> </div>
} }
} }
@@ -101,11 +83,14 @@ pub fn home_component() -> Html {
/// ``` /// ```
#[component(NotFound)] #[component(NotFound)]
pub fn not_found_component() -> Html { pub fn not_found_component() -> Html {
let message = "404 Nicht gefunden"; let message = "404 Not found";
html! { html! {
<div> <div class="form-container">
<div class="empty-state">
<h1>{&message}</h1> <h1>{&message}</h1>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>> <p>{ "The page you are looking for does not exist." }</p>
<Link<crate::Route> to={crate::Route::Home}>{ "Back to Home" }</Link<crate::Route>>
</div>
</div> </div>
} }
} }
@@ -125,17 +110,13 @@ pub fn not_found_component() -> Html {
#[component(PermissionDenied)] #[component(PermissionDenied)]
pub fn denied_component() -> Html { pub fn denied_component() -> Html {
html! { html! {
<div> <div class="form-container">
<h1>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</h1> <div class="empty-state">
<h3>{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</h3> <h1>{ "Access Denied" }</h1>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>> <p>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</p>
<p class="text-muted">{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</p>
<Link<crate::Route> to={crate::Route::Home}>{ "Back to Home" }</Link<crate::Route>>
</div>
</div> </div>
} }
} }
#[component(Icon)]
pub fn icon_component() -> Html {
html! {
<img src="/assets/csg.png" class="csg-icon" />
}
}
-1
View File
@@ -6,7 +6,6 @@
//! By re-exporting them here, other parts of the application can import page //! By re-exporting them here, other parts of the application can import page
//! components more conveniently using `crate::pages::*`. //! components more conveniently using `crate::pages::*`.
pub mod basic_pages; pub mod basic_pages;
pub mod qr;
pub mod setup; pub mod setup;
pub mod sidebar; pub mod sidebar;
pub mod ticket; pub mod ticket;
-118
View File
@@ -1,118 +0,0 @@
use base64::{Engine, engine::general_purpose::STANDARD};
use gloo_net::http::Request;
use serde::{Deserialize, Serialize};
use yew::prelude::*;
#[derive(Deserialize, Clone)]
struct QrResponse {
raw_token: String,
}
#[derive(Properties, PartialEq)]
pub struct QrManagerProps {
pub user_id: i16,
}
#[derive(Serialize)]
pub struct QrPayload {
user_id: i16,
}
#[function_component(QrManager)]
pub fn qr_manager(props: &QrManagerProps) -> Html {
let qr_image_data = use_state(|| None::<String>);
let error_message = use_state(|| None::<String>);
let user_id = props.user_id;
let on_generate_click = {
let qr_image_data = qr_image_data.clone();
let error_message = error_message.clone();
Callback::from(move |_| {
let qr_image_data = qr_image_data.clone();
let error_message = error_message.clone();
wasm_bindgen_futures::spawn_local(async move {
let payload = QrPayload { user_id };
match Request::post("/api/generate_qr")
.json(&payload)
.unwrap()
.send()
.await
{
Ok(response) if response.ok() => {
if let Ok(data) = response.json::<QrResponse>().await {
// Point scanned link to the public login endpoint with the token
let target_url = format!(
"{}/api/qr_login?token={}",
std::env::var("ORIGIN")
.ok()
.unwrap_or("http://localhost:8000".to_string()),
data.raw_token
);
match qrcode_generator::to_png_to_vec(
target_url,
qrcode_generator::QrCodeEcc::Medium,
250,
) {
Ok(png_bytes) => {
let base64_png = format!(
"data:image/png;base64,{}",
STANDARD.encode(&png_bytes)
);
qr_image_data.set(Some(base64_png));
error_message.set(None);
}
Err(_) => {
error_message
.set(Some("Failed to render QR graphic.".to_string()));
}
}
}
}
_ => {
error_message.set(Some(
"Failed to generate token. Are you logged in as an admin?".to_string(),
));
}
}
});
})
};
html! {
<div style="text-align: center; padding: 15px; border: 1px dashed #ccc; border-radius: 6px;">
<h4>{"Permanent Login Access"}</h4>
<p style="font-size: 0.85em; color: #555;">
{ format!("Generate a permanent bypass login QR code for User #{}", user_id) }
</p>
<button
onclick={on_generate_click}
style="padding: 8px 16px; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;"
>
{"Generate QR Code"}
</button>
{ if let Some(ref err) = *error_message {
html! { <p style="color: #dc3545; margin-top: 10px; font-size: 0.9em;">{err}</p> }
} else {
html! {}
}}
{ if let Some(ref qr_src) = *qr_image_data {
html! {
<div style="margin-top: 15px;">
<img src={qr_src.clone()} alt="Login QR Code" style="border: 1px solid #ddd; padding: 8px; background: white;" />
<p style="font-size: 0.8em; color: #dc3545; font-weight: bold; margin-top: 8px;">
{"Warn the user to keep this QR code secure!"}
</p>
</div>
}
} else {
html! {}
}}
</div>
}
}
+23 -25
View File
@@ -20,7 +20,6 @@ pub struct AdminSetupScheme {
pub first_name: String, pub first_name: String,
pub last_name: String, pub last_name: String,
pub username: String, pub username: String,
pub is_admin: bool,
pub pwd: String, pub pwd: String,
} }
@@ -30,7 +29,7 @@ pub struct AdminSetupScheme {
/// a form to create the first admin user. Key functionality: /// a form to create the first admin user. Key functionality:
/// ///
/// - **Admin Check**: On mount, verifies if an admin already exists by calling `/api/check-admin`. /// - **Admin Check**: On mount, verifies if an admin already exists by calling `/api/check-admin`.
/// If an admin is found, the user is redirected to the login page ([`crate::Route::Login`]). /// If an admin is found, the user is redirected to the login page (`crate::Route::Login`).
/// - **Form Fields**: Collects `first_name`, `last_name`, `username`, `password`, and `confirm_password`. /// - **Form Fields**: Collects `first_name`, `last_name`, `username`, `password`, and `confirm_password`.
/// - **Form Validation**: /// - **Form Validation**:
/// - Ensures password fields are not empty. /// - Ensures password fields are not empty.
@@ -104,7 +103,7 @@ pub fn initial_admin_setup() -> Html {
} }
if !*admin_check_done { if !*admin_check_done {
return html! { <div>{ "Wird überprüft..." }</div> }; return html! { <div>{ "Checking..." }</div> };
} }
let onsubmit = { let onsubmit = {
@@ -122,17 +121,17 @@ pub fn initial_admin_setup() -> Html {
e.prevent_default(); e.prevent_default();
if (*pwd).is_empty() || (*pwd_confirm).is_empty() { if (*pwd).is_empty() || (*pwd_confirm).is_empty() {
error.set("Passwortfelder dürfen nicht leer sein".to_string()); error.set("Password fields cannot be empty".to_string());
return; return;
} }
if *pwd != *pwd_confirm { if *pwd != *pwd_confirm {
error.set("Passwörter stimmen nicht überein".to_string()); error.set("Passwords do not match".to_string());
return; return;
} }
if (*username).is_empty() { if (*username).is_empty() {
error.set("Benutzername darf nicht leer sein".to_string()); error.set("Username cannot be empty".to_string());
return; return;
} }
@@ -155,7 +154,6 @@ pub fn initial_admin_setup() -> Html {
first_name: first_name_val, first_name: first_name_val,
last_name: last_name_val, last_name: last_name_val,
username: username_val, username: username_val,
is_admin: true,
pwd: pwd_val, pwd: pwd_val,
}; };
@@ -174,28 +172,28 @@ pub fn initial_admin_setup() -> Html {
navigator.push(&crate::Route::Login); navigator.push(&crate::Route::Login);
} }
Ok(r) => { Ok(r) => {
let text = r.text().await.unwrap_or_else(|_| "Unbekannt".into()); let text = r.text().await.unwrap_or_else(|_| "unknown".into());
error.set(text); error.set(format!("HTTP {}: {}", r.status(), text));
} }
Err(err) => error.set(format!("Netzwerkfehler: {}", err)), Err(err) => error.set(format!("Network error: {}", err)),
} }
}); });
}) })
}; };
html! { html! {
<div class="setup-container rundbg"> <div class="setup-container">
<div class="setup-box"> <div class="setup-box">
<h1>{ "Admin-Einrichtung" }</h1> <h1>{ "Initial Admin Setup" }</h1>
<p>{ "Erstellen Sie Ihr erstes Administratorkonto" }</p> <p>{ "Create your first administrator account" }</p>
<form {onsubmit} class="setup-form"> <form {onsubmit} class="setup-form">
<div class="form-group"> <div class="form-group">
<label for="first_name">{ "Vorname:" } <label for="first_name">{ "First Name:" }
<input <input
id="first_name" id="first_name"
type="text" type="text"
placeholder="Vorname" placeholder="First name"
value={(*first_name).clone()} value={(*first_name).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -206,11 +204,11 @@ pub fn initial_admin_setup() -> Html {
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="last_name">{ "Nachname:" } <label for="last_name">{ "Last Name:" }
<input <input
id="last_name" id="last_name"
type="text" type="text"
placeholder="Nachname" placeholder="Last name"
value={(*last_name).clone()} value={(*last_name).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -221,11 +219,11 @@ pub fn initial_admin_setup() -> Html {
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="username">{ "Benutzername:" } <label for="username">{ "Username:" }
<input <input
id="username" id="username"
type="text" type="text"
placeholder="Benutzername" placeholder="Username"
value={(*username).clone()} value={(*username).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -236,11 +234,11 @@ pub fn initial_admin_setup() -> Html {
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">{ "Passwort:" } <label for="password">{ "Password:" }
<input <input
id="password" id="password"
type="password" type="password"
placeholder="Passwort" placeholder="Password"
value={(*pwd).clone()} value={(*pwd).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -251,11 +249,11 @@ pub fn initial_admin_setup() -> Html {
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="pwd_confirm">{ "Passwort bestätigen:" } <label for="pwd_confirm">{ "Confirm Password:" }
<input <input
id="pwd_confirm" id="pwd_confirm"
type="password" type="password"
placeholder="Passwort bestätigen" placeholder="Confirm password"
value={(*pwd_confirm).clone()} value={(*pwd_confirm).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -266,7 +264,7 @@ pub fn initial_admin_setup() -> Html {
</div> </div>
<button type="submit" disabled={*loading} class="submit-btn"> <button type="submit" disabled={*loading} class="submit-btn">
{ if *loading { "Wird erstellt..." } else { "Administratorkonto erstellen" } } { if *loading { "Creating..." } else { "Create Admin Account" } }
</button> </button>
if !error.is_empty() { if !error.is_empty() {
@@ -274,7 +272,7 @@ pub fn initial_admin_setup() -> Html {
} }
if *success { if *success {
<p class="success-message" style="color:green">{ "Administratorkonto erfolgreich erstellt! Wird weitergeleitet zum Login..." }</p> <p class="success-message" style="color:green">{ "Admin account created successfully! Redirecting to login..." }</p>
} }
</form> </form>
</div> </div>
+25 -84
View File
@@ -7,11 +7,6 @@ use wasm_bindgen_futures::spawn_local;
use yew::prelude::*; use yew::prelude::*;
use yew_router::prelude::*; use yew_router::prelude::*;
/// The key used to store and retrieve the sidebar's expansion state in `LocalStorage`.
///
/// This constant ensures consistency when accessing the stored state across different
/// parts of the application. The value associated with this key in `LocalStorage`
/// will be a serialized [`SidebarExpandState`] object.
const STORAGE_KEY: &str = "sidebar_state"; const STORAGE_KEY: &str = "sidebar_state";
/// Represents the expansion state of collapsible menus within the sidebar. /// Represents the expansion state of collapsible menus within the sidebar.
@@ -133,7 +128,7 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
Callback::from(move |v: bool| { Callback::from(move |v: bool| {
state.set(SidebarExpandState { state.set(SidebarExpandState {
ticket_open: v, ticket_open: v,
users_open: state.users_open, users_open: (*state).users_open,
}) })
}) })
}; };
@@ -141,10 +136,10 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let toggle_tickets = { let toggle_tickets = {
let state = state.clone(); let state = state.clone();
Callback::from(move |_| { Callback::from(move |_| {
let current = state.ticket_open; let current = (*state).ticket_open;
state.set(SidebarExpandState { state.set(SidebarExpandState {
ticket_open: !current, ticket_open: !current,
users_open: state.users_open, users_open: (*state).users_open,
}); });
}) })
}; };
@@ -153,7 +148,7 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let state = state.clone(); let state = state.clone();
Callback::from(move |v: bool| { Callback::from(move |v: bool| {
state.set(SidebarExpandState { state.set(SidebarExpandState {
ticket_open: state.ticket_open, ticket_open: (*state).ticket_open,
users_open: v, users_open: v,
}) })
}) })
@@ -162,9 +157,9 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let toggle_users = { let toggle_users = {
let state = state.clone(); let state = state.clone();
Callback::from(move |_| { Callback::from(move |_| {
let current = state.users_open; let current = (*state).users_open;
state.set(SidebarExpandState { state.set(SidebarExpandState {
ticket_open: state.ticket_open, ticket_open: (*state).ticket_open,
users_open: !current, users_open: !current,
}); });
}) })
@@ -234,10 +229,10 @@ pub fn ticket_menu() -> Html {
html! { html! {
<ul class="submenu" role="menu"> <ul class="submenu" role="menu">
<li role="none"> <li role="none">
<Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Ticket einreichen" }</span></Link<crate::Route>> <Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Submit Ticket" }</span></Link<crate::Route>>
</li> </li>
<li role="none"> <li role="none">
<Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "Tickets anzeigen" }</span></Link<crate::Route>> <Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "View Tickets" }</span></Link<crate::Route>>
</li> </li>
</ul> </ul>
} }
@@ -289,7 +284,7 @@ pub fn users_menu() -> Html {
onclick={on_toggle} onclick={on_toggle}
aria-expanded={open.to_string()} aria-expanded={open.to_string()}
> >
{ "Benutzer" } { "Users" }
{ if open { "" } else { "" } } { if open { "" } else { "" } }
</button> </button>
@@ -298,10 +293,10 @@ pub fn users_menu() -> Html {
html! { html! {
<ul class="submenu" role="menu"> <ul class="submenu" role="menu">
<li role="none"> <li role="none">
<Link<crate::Route> to={crate::Route::Register}><span role="menuitem">{ "Benutzer erstellen" }</span></Link<crate::Route>> <Link<crate::Route> to={crate::Route::Register}><span role="menuitem">{ "Create User" }</span></Link<crate::Route>>
</li> </li>
<li role="none"> <li role="none">
<Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "Benutzer anzeigen" }</span></Link<crate::Route>> <Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "View Users" }</span></Link<crate::Route>>
</li> </li>
</ul> </ul>
} }
@@ -319,11 +314,6 @@ pub fn users_menu() -> Html {
/// and administrative status. It fetches the current user's details via `/api/users/current` /// and administrative status. It fetches the current user's details via `/api/users/current`
/// to determine what menu items to display. /// to determine what menu items to display.
/// ///
/// # Mobile Support
/// On small screens:
/// - Slides into view from the left when `props.is_open` is `true`.
/// - Renders a close button (`✕`) in the header that emits `props.on_close`.
///
/// # Structure /// # Structure
/// - Wraps its content in a [`SidebarStateProvider`] to allow nested menus to manage their state. /// - Wraps its content in a [`SidebarStateProvider`] to allow nested menus to manage their state.
/// - Contains a navigation (`<nav>`) element with an unordered list (`<ul>`) of menu items. /// - Contains a navigation (`<nav>`) element with an unordered list (`<ul>`) of menu items.
@@ -342,22 +332,8 @@ pub fn users_menu() -> Html {
/// # Logout Functionality /// # Logout Functionality
/// The "Logout" button sends a GET request to `/api/logout`, clears the user's session, /// The "Logout" button sends a GET request to `/api/logout`, clears the user's session,
/// and then redirects the user to the login page (`crate::Route::Login`). /// and then redirects the user to the login page (`crate::Route::Login`).
/// Properties for the [`Sidebar`] component.
///
/// These properties enable managing and controlling the responsive mobile sidebar
/// layout and its visibility settings.
#[derive(Properties, PartialEq)]
pub struct SidebarComponentProps {
/// A boolean flag indicating whether the mobile sidebar is currently slid open (`true`) or hidden (`false`).
#[prop_or_default]
pub is_open: bool,
/// A callback emitted when the user requests to close/hide the mobile sidebar.
#[prop_or_default]
pub on_close: Callback<()>,
}
#[component(Sidebar)] #[component(Sidebar)]
pub fn sidebar(props: &SidebarComponentProps) -> Html { pub fn sidebar() -> Html {
let is_admin = use_state(|| None::<bool>); let is_admin = use_state(|| None::<bool>);
let navigator = use_navigator().expect("Sidebar must be used within a Router"); let navigator = use_navigator().expect("Sidebar must be used within a Router");
@@ -398,84 +374,49 @@ pub fn sidebar(props: &SidebarComponentProps) -> Html {
}; };
match *is_admin { match *is_admin {
None => html! { <div class="sidebar-loading">{ "Wird geladen..." }</div> }, None => html! { <div class="sidebar-loading">{ "Loading..." }</div> },
// Non-admin: render a condensed user sidebar (no diagnostics, limited links) // Non-admin: render a condensed user sidebar (no diagnostics, limited links)
Some(false) => { Some(false) => html! {
let on_close = props.on_close.clone();
html! {
<SidebarStateProvider> <SidebarStateProvider>
<nav class={if props.is_open { "sidebar user open" } else { "sidebar user" }}> <nav class="sidebar user">
<ul> <ul>
<li class="sidebar-header"> <Link<crate::Route> to={crate::Route::Home}>{ "󰟒" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::Home} classes="home">
<svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</Link<crate::Route>>
<button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</li>
<TicketMenu/> <TicketMenu/>
<li class="logout-item"> <li class="logout-item">
<crate::darkmode::ThemeToggle/>
<button <button
class="sidebar-button" class="logout-button"
onclick={on_logout.clone()} onclick={on_logout.clone()}
> >
{ "Abmelden" } { "Logout" }
</button> </button>
</li> </li>
</ul> </ul>
</nav> </nav>
</SidebarStateProvider> </SidebarStateProvider>
} },
}
// Admin: full sidebar wrapped in provider so submenu state persists // Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => { Some(true) => html! {
let on_close = props.on_close.clone();
html! {
<SidebarStateProvider> <SidebarStateProvider>
<nav class={if props.is_open { "sidebar admin open" } else { "sidebar admin" }}> <nav class="sidebar admin">
<ul> <ul>
<li class="sidebar-header"> <Link<crate::Route> to={crate::Route::Home}>{ "󰟒" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::Home} classes="home">
<svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</Link<crate::Route>>
<button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</li>
<TicketMenu/> <TicketMenu/>
<UsersMenu/> <UsersMenu/>
<Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>> <Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>> <Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>>
<li class="logout-item"> <li class="logout-item">
<crate::darkmode::ThemeToggle/>
<button <button
class="sidebar-button" class="logout-button"
onclick={on_logout.clone()} onclick={on_logout.clone()}
> >
{ "Abmelden" } { "Logout" }
</button> </button>
</li> </li>
</ul> </ul>
</nav> </nav>
</SidebarStateProvider> </SidebarStateProvider>
} },
}
} }
} }
+58 -56
View File
@@ -180,7 +180,7 @@ pub fn submit_ticket_component() -> Html {
Callback::from(move |e: SubmitEvent| { Callback::from(move |e: SubmitEvent| {
e.prevent_default(); e.prevent_default();
if room.is_none() { if room.is_none() {
status.set(Some("Ungültiger Raum".into())); status.set(Some("Invalid room".into()));
return; return;
} }
let category = (*category).clone(); let category = (*category).clone();
@@ -188,7 +188,7 @@ pub fn submit_ticket_component() -> Html {
let description = (*description).clone(); let description = (*description).clone();
let room = room.unwrap(); let room = room.unwrap();
if !valid_rooms.contains(&room) { if !valid_rooms.contains(&room) {
status.set(Some("Raum nicht erlaubt".into())); status.set(Some("Room not allowed".into()));
return; return;
} }
let status = status.clone(); let status = status.clone();
@@ -208,12 +208,9 @@ pub fn submit_ticket_component() -> Html {
.expect("Failed to build request"); .expect("Failed to build request");
match request.send().await { match request.send().await {
Ok(response) if response.status() == 200 => status.set(Some("Erfolg".into())), Ok(response) if response.status() == 200 => status.set(Some("Success".into())),
Ok(response) => { Ok(response) => status.set(Some(format!("Error: {}", response.status()))),
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into()); Err(err) => status.set(Some(format!("Network error: {}", err))),
status.set(Some(text));
},
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
} }
}); });
}) })
@@ -290,15 +287,17 @@ pub fn submit_ticket_component() -> Html {
let room_valid = (*room).is_some(); let room_valid = (*room).is_some();
html! { html! {
<form class="rundbg" {onsubmit}> <div class="form-container">
<div class="page-header">
<h1>{ "Create Ticket" }</h1>
</div>
<form {onsubmit}>
<label>{ "Betreff:" } <label>{ "Betreff:" }
<input type="text" value={(*betreff).clone()} oninput={betreff_change}/> <input type="text" value={(*betreff).clone()} oninput={betreff_change}/>
</label> </label>
<br/>
<label>{ "Beschreibung:" } <label>{ "Beschreibung:" }
<input type="text" value={(*description).clone()} oninput={description_change}/> <textarea value={(*description).clone()} oninput={description_change}/>
</label> </label>
<br/>
<label>{ "Kategorie:" } <label>{ "Kategorie:" }
<select value={(*category).clone()} onchange={category_change}> <select value={(*category).clone()} onchange={category_change}>
<option value="Whiteboard Beamer">{ "Whiteboard Beamer" }</option> <option value="Whiteboard Beamer">{ "Whiteboard Beamer" }</option>
@@ -309,33 +308,32 @@ pub fn submit_ticket_component() -> Html {
<option value="Sonstiges">{ "Sonstiges" }</option> <option value="Sonstiges">{ "Sonstiges" }</option>
</select> </select>
</label> </label>
<br/>
<label>{ "Raum:" } <label>{ "Raum:" }
<input type="text" value={(*room_input).clone()} oninput={room_change}/> <input type="text" value={(*room_input).clone()} oninput={room_change}/>
</label> </label>
{ {
if !room_valid { if !room_valid {
html! { html! {
<p class="warning" >{ "Ungültiger/nicht erlaubter Raum (z. B. 101, K12, D5)" }</p> <p class="alert error">{ "Ungültiger oder nicht erlaubter Raum (z. B. 101, K12, D5)" }</p>
} }
} else { } else {
html! {} html! {}
} }
} }
<br/> <button type="submit">{ "Send" }</button>
<button type="submit">{ "Absenden" }</button>
<Link<crate::Route> to={crate::Route::AllTickets}>{ "Tickets ansehen" }</Link<crate::Route>> <Link<crate::Route> to={crate::Route::AllTickets}>{ "View All Tickets" }</Link<crate::Route>>
{ {
if let Some(s) = &*status { if let Some(s) = &*status {
html!{ <p>{ s }</p> } html!{ <p class="alert success">{ s }</p> }
} else { } else {
html!{} html!{}
} }
} }
</form> </form>
</div>
} }
} }
@@ -451,12 +449,9 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
.expect("Failed to construct request"); .expect("Failed to construct request");
match request.send().await { match request.send().await {
Ok(response) if response.status() == 200 => error.set(Some("Erfolg".into())), Ok(response) if response.status() == 200 => error.set(Some("Success".into())),
Ok(response) => { Ok(response) => error.set(Some(format!("Error: {}", response.status()))),
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into()); Err(err) => error.set(Some(format!("Network error: {}", err))),
error.set(Some(text));
},
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
} }
}); });
}) })
@@ -494,13 +489,14 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
match request.send().await { match request.send().await {
Ok(resp) if resp.status() == 200 || resp.status() == 204 => { Ok(resp) if resp.status() == 200 || resp.status() == 204 => {
ticket_state.set(None); // remove local state or navigate away
ticket_state.set(None); // clears the shown item
} }
Ok(resp) => { Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unbekannt".into()); let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
delete_error.set(Some(txt)); delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
} }
Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => delete_error.set(Some(format!("Network error: {}", err))),
} }
deleting.set(false); deleting.set(false);
}); });
@@ -508,12 +504,12 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
}; };
if *loading { if *loading {
html! {<p>{ "Wird geladen" }</p>} html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> } html! { <p>{ format!("Error: {}", e) }</p> }
} else if let Some(t) = &*ticket { } else if let Some(t) = &*ticket {
html! { html! {
<div class="rundbg"> <div>
<h1>{ format!("Ticket #{}", t.id) }</h1> <h1>{ format!("Ticket #{}", t.id) }</h1>
<p><strong>{ "Kategorie: " }</strong>{ &t.category }</p> <p><strong>{ "Kategorie: " }</strong>{ &t.category }</p>
<p><strong>{ "Betreff: " }</strong>{ &t.betreff }</p> <p><strong>{ "Betreff: " }</strong>{ &t.betreff }</p>
@@ -554,7 +550,7 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
</div> </div>
} }
} else { } else {
html! { <p>{ "Kein Ticket gefunden." }</p> } html! { <p>{ "No ticket found." }</p> }
} }
} }
@@ -664,12 +660,16 @@ pub fn all_tickets_component() -> Html {
} }
if *loading { if *loading {
html! {<p>{ "Wird geladen" }</p>} html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> } html! { <p>{ format!("Error: {}", e) }</p> }
} else { } else {
html! { html! {
<ul class= "postits"> <div>
<div class="page-header">
<h1>{ "All Tickets" }</h1>
</div>
<ul class="ticket-list">
{ for tickets.iter().filter(|t| t.status != "Archived" && (if user.is_admin { true } else if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| { { for tickets.iter().filter(|t| t.status != "Archived" && (if user.is_admin { true } else if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| {
let status_class = match t.status.as_str() { let status_class = match t.status.as_str() {
"ToDo" => "To-Do", "ToDo" => "To-Do",
@@ -679,14 +679,17 @@ pub fn all_tickets_component() -> Html {
_ => "To-Do" _ => "To-Do"
}; };
html! { html! {
<div class={classes!{status_class, "listbox"}}> <li key={t.id.to_string()} class={status_class}>
<li key={t.id.to_string()}> <Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff) }</h3></Link<crate::Route>>
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff,) }</h3></Link<crate::Route>> <p>{ &t.description }</p>
</li> </li>
}
</div> })}
}})}
</ul> </ul>
<div class="ticket-list-actions">
<Link<crate::Route> to={crate::Route::Ticket}>{ "Zurück zur Startseite" }</Link<crate::Route>>
</div>
</div>
} }
} }
} }
@@ -747,17 +750,17 @@ pub fn archived_tickets_component() -> Html {
Ok(response) if response.status() == 200 => { Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await { match response.json::<Vec<Ticket>>().await {
Ok(t) => tickets.set(t), Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("Parsefehler: {}", e))), Err(e) => error.set(Some(format!("parse error: {}", e))),
} }
} }
Ok(response) => { Ok(response) => {
if let Ok(text) = response.text().await { if let Ok(text) = response.text().await {
error.set(Some(text)); error.set(Some(text));
} else { } else {
error.set(Some(format!("Status {}", response.status()))); error.set(Some(format!("status {}", response.status())));
} }
} }
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => error.set(Some(format!("Network error: {}", err))),
} }
loading.set(false); loading.set(false);
}); });
@@ -797,29 +800,28 @@ pub fn archived_tickets_component() -> Html {
} }
if *loading { if *loading {
html! {<p>{ "Wird geladen" }</p>} html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> } html! { <p>{ format!("Error: {}", e) }</p> }
} else { } else {
html! { html! {
<ul class="postits"> <div>
<div class="page-header">
<h1>{ "Archivierte Tickets" }</h1>
</div>
<ul class="ticket-list">
{ for tickets.iter().filter(|t| t.status == "Archived" && (user.is_admin || if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| html! { { for tickets.iter().filter(|t| t.status == "Archived" && (user.is_admin || if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| html! {
<div class="listbox"> <div>
<li key={t.id.to_string()}> <li key={t.id.to_string()}>
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{} - #{}", t.betreff, t.id) }</h3></Link<crate::Route>> <Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff) }</h3></Link<crate::Route>>
<p>{ &t.description }</p> <p>{ &t.description }</p>
<p>{ match t.status.as_str() {
"ToDo" => "Zu tun",
"InProgress" => "In Bearbeitung",
"Completed" => "Erledigt",
"Archived" => "Archiviert",
_ => "Ungültiger Status"
}}</p>
</li> </li>
</div> </div>
})} })}
</ul> </ul>
</div>
} }
} }
} }
+59 -44
View File
@@ -102,14 +102,6 @@ pub struct UserProps {
pub id: i16, pub id: i16,
} }
/// Represents an error response from the API.
///
/// This struct is used to deserialize error messages received from the backend API.
/// It typically contains a human-readable message and an internal status code.
///
/// # Fields
/// - `message`: A `String` containing a description of the error.
/// - `_status`: An internal status code or message, often ignored in frontend display.
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
struct ApiError { struct ApiError {
message: String, message: String,
@@ -181,12 +173,9 @@ pub fn register_component() -> Html {
.expect("Error building request"); .expect("Error building request");
match request.send().await { match request.send().await {
Ok(response) if response.status() == 200 => status.set(Some("Erfolg".into())), Ok(response) if response.status() == 200 => status.set(Some("Success".into())),
Ok(response) => { Ok(response) => status.set(Some(format!("Error: {}", response.status()))),
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into()); Err(err) => status.set(Some(format!("Network error: {}", err))),
status.set(Some(text));
}
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
} }
}); });
}) })
@@ -233,7 +222,11 @@ pub fn register_component() -> Html {
}; };
html! { html! {
<form {onsubmit} class="rundbg"> <div class="form-container">
<div class="page-header">
<h1>{ "Register User" }</h1>
</div>
<form {onsubmit}>
<label>{ "Vorname:" } <label>{ "Vorname:" }
<input type="text" value={(*first_name).clone()} oninput={fn_change}/> <input type="text" value={(*first_name).clone()} oninput={fn_change}/>
</label> </label>
@@ -251,6 +244,7 @@ pub fn register_component() -> Html {
</label> </label>
<button type="submit">{ "Bestätigen" }</button> <button type="submit">{ "Bestätigen" }</button>
</form> </form>
</div>
} }
} }
@@ -329,21 +323,24 @@ pub fn login_component() -> Html {
navigator.push(&crate::Route::Home); navigator.push(&crate::Route::Home);
} }
Ok(r) => { Ok(r) => {
let text = r.text().await.unwrap_or_else(|_| "Unbekannt".into()); let text = r.text().await.unwrap_or_else(|_| "unknown".into());
error.set(text); error.set(format!("HTTP {}: {}", r.status(), text));
} }
Err(err) => error.set(format!("Netzwerkfehler: {}", err)), Err(err) => error.set(format!("Network error: {}", err)),
} }
}); });
}) })
}; };
html! { html! {
<div> <main class="content">
<h1 class="headline">{ "Anmeldung" }</h1> <div class="form-container">
<form {onsubmit} class="rundbg login"> <div class="page-header">
<h1>{ "Login" }</h1>
</div>
<form {onsubmit}>
<input <input
placeholder="Benutzername" placeholder="username"
value={(*username).clone()} value={(*username).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -352,17 +349,18 @@ pub fn login_component() -> Html {
/> />
<input <input
type="password" type="password"
placeholder="Passwort" placeholder="password"
value={(*pwd).clone()} value={(*pwd).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let input: web_sys::HtmlInputElement = e.target_unchecked_into();
pwd.set(input.value()); pwd.set(input.value());
})} })}
/> />
<button type="submit" disabled={*loading}>{ if *loading { "Anmeldung läuft..." } else { "Anmelden" } }</button> <button type="submit" disabled={*loading}>{ if *loading { "Logging in..." } else { "Login" } }</button>
if !error.is_empty() { <p style="color:red">{(*error).clone()}</p> } if !error.is_empty() { <p class="alert error">{(*error).clone()}</p> }
</form> </form>
</div> </div>
</main>
} }
} }
@@ -431,18 +429,37 @@ pub fn all_users_component() -> Html {
} }
if *loading { if *loading {
html! {<p>{ "Wird geladen" }</p>} html! {
<div class="form-container">
<div class="page-header">
<h1>{ "All Users" }</h1>
</div>
<p>{ "Loading..." }</p>
</div>
}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> } html! {
<div class="form-container">
<div class="page-header">
<h1>{ "All Users" }</h1>
</div>
<p class="alert error">{ format!("Error: {}", e) }</p>
</div>
}
} else { } else {
html! { html! {
<ul class="postits"> <div class="form-container">
<div class="page-header">
<h1>{ "All Users" }</h1>
</div>
<ul class="user-list">
{ for users.iter().map(|t| html! { { for users.iter().map(|t| html! {
<li key={t.id.to_string()} class="listbox"> <li key={t.id.to_string()}>
<Link<crate::Route> to={crate::Route::UserByID{id: t.id}}><h3>{ format!("{} {}", t.first_name, t.last_name) }</h3></Link<crate::Route>> <Link<crate::Route> to={crate::Route::UserByID{id: t.id}}><h3>{ format!("{} {}", t.first_name, t.last_name) }</h3></Link<crate::Route>>
</li> </li>
})} })}
</ul> </ul>
</div>
} }
} }
} }
@@ -577,7 +594,6 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let last_name = (*last_name).clone(); let last_name = (*last_name).clone();
let username = (*username).clone(); let username = (*username).clone();
let make_admin = *make_admin; let make_admin = *make_admin;
let new_pwd_state = new_pwd.clone();
let new_pwd = (*new_pwd).clone(); let new_pwd = (*new_pwd).clone();
saving.set(true); saving.set(true);
@@ -612,7 +628,6 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
if let Ok(updated) = resp.json::<FilteredUser>().await { if let Ok(updated) = resp.json::<FilteredUser>().await {
user_state.set(Some(updated)); user_state.set(Some(updated));
} }
new_pwd_state.set(String::new());
save_success.set(true); save_success.set(true);
} }
Ok(resp) => { Ok(resp) => {
@@ -640,7 +655,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
// confirm // confirm
if !web_sys::window() if !web_sys::window()
.and_then(|w| { .and_then(|w| {
w.confirm_with_message("Möchten Sie diesen Eintrag wirklich löschen?") w.confirm_with_message("Are you sure you want to delete this item?")
.ok() .ok()
}) })
.unwrap_or(false) .unwrap_or(false)
@@ -656,18 +671,19 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let user_state = user_state.clone(); let user_state = user_state.clone();
spawn_local(async move { spawn_local(async move {
let url = format!("/api/users/{}", id); let url = format!("/api/users/{}", id); // or /api/tickets/{}
let req = Request::delete(&url).credentials(web_sys::RequestCredentials::Include); let req = Request::delete(&url).credentials(web_sys::RequestCredentials::Include);
match req.send().await { match req.send().await {
Ok(resp) if resp.status() == 200 || resp.status() == 204 => { Ok(resp) if resp.status() == 200 || resp.status() == 204 => {
user_state.set(None); // remove local state or navigate away
user_state.set(None); // clears the shown item
} }
Ok(resp) => { Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unbekannt".into()); let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
delete_error.set(Some(txt)); delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
} }
Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => delete_error.set(Some(format!("Network error: {}", err))),
} }
deleting.set(false); deleting.set(false);
}); });
@@ -675,12 +691,12 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
}; };
if *loading { if *loading {
html! {<p>{ "Wird geladen" }</p>} html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> } html! { <p>{ format!("Error: {}", e) }</p> }
} else if let Some(u) = &*user { } else if let Some(u) = &*user {
html! { html! {
<div class="rundbg"> <div>
<div> <div>
<p><strong>{ "Vorname: " }</strong>{ &u.first_name }</p> <p><strong>{ "Vorname: " }</strong>{ &u.first_name }</p>
<p><strong>{ "Nachname: " }</strong>{ &u.last_name }</p> <p><strong>{ "Nachname: " }</strong>{ &u.last_name }</p>
@@ -688,8 +704,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
<p><strong>{ "Ist Admin: " }</strong>{ u.is_admin }</p> <p><strong>{ "Ist Admin: " }</strong>{ u.is_admin }</p>
</div> </div>
<crate::pages::qr::QrManager user_id={u.id}/> <h1>{ format!("User #{}", u.id) }</h1>
<form onsubmit={onsubmit}> <form onsubmit={onsubmit}>
<div> <div>
<label>{ "Vorname" } <label>{ "Vorname" }
@@ -746,7 +761,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
<button type="submit" disabled={*saving}>{ if *saving { "Speichern..." } else { "Speichern" } }</button> <button type="submit" disabled={*saving}>{ if *saving { "Speichern..." } else { "Speichern" } }</button>
if *save_success { if *save_success {
<p style="color:green">{ "Erfolgreich aktualisiert" }</p> <p style="color:green">{ "Updated successfully" }</p>
} }
if let Some(err) = &*save_error { if let Some(err) = &*save_error {
<p style="color:red">{ err.clone() }</p> <p style="color:red">{ err.clone() }</p>
@@ -764,6 +779,6 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
</div> </div>
} }
} else { } else {
html! { <p>{ "Kein Benutzer gefunden." }</p> } html! { <p>{ "No ticket found." }</p> }
} }
} }
+129 -33
View File
@@ -6,6 +6,7 @@ use serde::Deserialize;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use yew::prelude::*; use yew::prelude::*;
use crate::dequote;
use crate::pages::ticket::{ActiveUser, Ticket}; use crate::pages::ticket::{ActiveUser, Ticket};
/// A partial representation of a ticket, containing only the fields necessary for statistical analysis. /// A partial representation of a ticket, containing only the fields necessary for statistical analysis.
@@ -20,6 +21,14 @@ use crate::pages::ticket::{ActiveUser, Ticket};
struct TicketPartial { struct TicketPartial {
date: DateTime<Utc>, date: DateTime<Utc>,
room: i16, room: i16,
user_id: i16,
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
struct UserPartial {
id: i16,
first_name: String,
last_name: String,
} }
/// Properties for components that display room-wise ticket totals. /// Properties for components that display room-wise ticket totals.
@@ -28,12 +37,18 @@ struct TicketPartial {
/// for calculating and visualizing ticket distribution per room. /// for calculating and visualizing ticket distribution per room.
/// ///
/// # Fields /// # Fields
/// - `tickets`: A vector of [`TicketPartial`] containing ticket data relevant for room totals. /// - `tickets`: A vector of `TicketPartial` containing ticket data relevant for room totals.
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
struct RoomTotalsProps { struct RoomTotalsProps {
tickets: Vec<TicketPartial>, tickets: Vec<TicketPartial>,
} }
#[derive(Properties, PartialEq)]
struct UserTotalProps {
users: Vec<UserPartial>,
tickets: Vec<TicketPartial>,
}
/// Converts a `chrono::DateTime<Utc>` object's weekday to a 0-indexed integer. /// Converts a `chrono::DateTime<Utc>` object's weekday to a 0-indexed integer.
/// ///
/// This function maps `chrono::Weekday` values (where Monday is 1, Sunday is 7) /// This function maps `chrono::Weekday` values (where Monday is 1, Sunday is 7)
@@ -64,7 +79,7 @@ fn weekday_index(dt: &DateTime<Utc>) -> usize {
/// the total count of tickets submitted on that day. /// the total count of tickets submitted on that day.
/// ///
/// # Arguments /// # Arguments
/// - `tickets`: A slice of [`TicketPartial`] items to count. /// - `tickets`: A slice of `TicketPartial` items to count.
/// ///
/// # Returns /// # Returns
/// An array `[usize; 7]` with ticket counts for each weekday. /// An array `[usize; 7]` with ticket counts for each weekday.
@@ -78,12 +93,12 @@ fn count_by_weekday(tickets: &[TicketPartial]) -> [usize; 7] {
/// Calculates the occurrences of each weekday within the date range covered by the tickets. /// Calculates the occurrences of each weekday within the date range covered by the tickets.
/// ///
/// This function determines the minimum and maximum dates from the provided [`TicketPartial`] /// This function determines the minimum and maximum dates from the provided `TicketPartial`
/// slice and then counts how many times each weekday occurs within that inclusive date range. /// slice and then counts how many times each weekday occurs within that inclusive date range.
/// This is useful for normalizing ticket counts against the number of available days for each weekday. /// This is useful for normalizing ticket counts against the number of available days for each weekday.
/// ///
/// # Arguments /// # Arguments
/// - `partials`: A slice of [`TicketPartial`] items defining the date range. /// - `partials`: A slice of `TicketPartial` items defining the date range.
/// ///
/// # Returns /// # Returns
/// An array `[usize; 7]` where each element represents the number of times a /// An array `[usize; 7]` where each element represents the number of times a
@@ -132,8 +147,7 @@ fn day_counts(partials: &[TicketPartial]) -> [usize; 7] {
/// Converts a numerical room representation back into a human-readable string format. /// Converts a numerical room representation back into a human-readable string format.
/// ///
/// This function is the inverse of the room parsing logic in /// This function is the inverse of the room parsing logic in `SubmitTicket` component.
/// [`SubmitTicket`](`crate::pages::ticket::SubmitTicket`) component.
/// It converts negative numbers back to "K" prefixed rooms, numbers >= 1000 back to "D" prefixed rooms, /// It converts negative numbers back to "K" prefixed rooms, numbers >= 1000 back to "D" prefixed rooms,
/// and other numbers to their string representation. /// and other numbers to their string representation.
/// ///
@@ -170,7 +184,7 @@ fn parse_room(r: i16) -> String {
#[component(Diagnostics)] #[component(Diagnostics)]
pub fn diagnostics_component() -> Html { pub fn diagnostics_component() -> Html {
html! { html! {
<div class="rundbg"> <div>
<TicketCount/> <TicketCount/>
<SubmitStats/> <SubmitStats/>
</div> </div>
@@ -225,17 +239,17 @@ pub fn ticket_count_component() -> Html {
Ok(response) if response.status() == 200 => { Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await { match response.json::<Vec<Ticket>>().await {
Ok(t) => tickets.set(t), Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("Parsefehler: {}", e))), Err(e) => error.set(Some(format!("parse error: {}", e))),
} }
} }
Ok(response) => { Ok(response) => {
if let Ok(text) = response.text().await { if let Ok(text) = response.text().await {
error.set(Some(text)); error.set(Some(text));
} else { } else {
error.set(Some(format!("Status {}", response.status()))); error.set(Some(format!("status {}", response.status())));
} }
} }
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => error.set(Some(format!("Network error: {}", err))),
} }
loading.set(false); loading.set(false);
}); });
@@ -275,9 +289,9 @@ pub fn ticket_count_component() -> Html {
} }
if *loading { if *loading {
html! {<p>{ "Wird geladen" }</p>} html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> } html! { <p>{ format!("Error: {}", e) }</p> }
} else { } else {
let status_conditions = |t: &Ticket| t.status == "ToDo" || t.status == "InProgress"; let status_conditions = |t: &Ticket| t.status == "ToDo" || t.status == "InProgress";
let count = tickets let count = tickets
@@ -288,9 +302,9 @@ pub fn ticket_count_component() -> Html {
}) })
.count(); .count();
html! { html! {
<div class="ticket-count"> <div class="open-tickets">
<h2>{ "Offene Tickets" }</h2> <h2 class="left">{ "Offene Tickets" }</h2>
<h4>{ count }</h4> <h4 class="ticket_count center">{ count }</h4>
</div> </div>
} }
} }
@@ -304,12 +318,12 @@ pub fn ticket_count_component() -> Html {
/// ///
/// # State /// # State
/// Uses `use_state` hooks to manage: /// Uses `use_state` hooks to manage:
/// - `tickets`: A vector of [`TicketPartial`] for statistical analysis. /// - `tickets`: A vector of `TicketPartial` for statistical analysis.
/// - `error`: Any error message from API calls. /// - `error`: Any error message from API calls.
/// - `loading`: A boolean indicating if data is being fetched. /// - `loading`: A boolean indicating if data is being fetched.
/// ///
/// # Functionality /// # Functionality
/// - Fetches all tickets (as [`TicketPartial`]) from `/api/tickets`. /// - Fetches all tickets (as `TicketPartial`) from `/api/tickets`.
/// - Calculates: /// - Calculates:
/// - `counts`: Number of tickets submitted on each weekday. /// - `counts`: Number of tickets submitted on each weekday.
/// - `occ`: Number of occurrences of each weekday in the ticket date range. /// - `occ`: Number of occurrences of each weekday in the ticket date range.
@@ -326,11 +340,13 @@ pub fn ticket_count_component() -> Html {
#[component(SubmitStats)] #[component(SubmitStats)]
pub fn submit_stats_component() -> Html { pub fn submit_stats_component() -> Html {
let tickets = use_state(|| Vec::<TicketPartial>::new()); let tickets = use_state(|| Vec::<TicketPartial>::new());
let users = use_state(|| Vec::<UserPartial>::new());
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
let loading = use_state(|| false); let loading = use_state(|| false);
{ {
let tickets = tickets.clone(); let tickets = tickets.clone();
let users = users.clone();
let error = error.clone(); let error = error.clone();
let loading = loading.clone(); let loading = loading.clone();
@@ -342,18 +358,36 @@ pub fn submit_stats_component() -> Html {
Ok(response) if response.status() == 200 => { Ok(response) if response.status() == 200 => {
match response.json::<Vec<TicketPartial>>().await { match response.json::<Vec<TicketPartial>>().await {
Ok(t) => tickets.set(t), Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("Parsefehler: {}", e))), Err(e) => error.set(Some(format!("parse error: {}", e))),
} }
} }
Ok(response) => { Ok(response) => {
if let Ok(text) = response.text().await { if let Ok(text) = response.text().await {
error.set(Some(text)); error.set(Some(text));
} else { } else {
error.set(Some(format!("Status {}", response.status()))); error.set(Some(format!("status {}", response.status())));
} }
} }
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => error.set(Some(format!("Network error: {}", err))),
} }
match Request::get("/api/users").send().await {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<UserPartial>>().await {
Ok(u) => users.set(u),
Err(e) => error.set(Some(format!("users parse error: {}", e))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("users status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("users network error: {}", err))),
}
loading.set(false); loading.set(false);
}); });
|| () || ()
@@ -372,7 +406,7 @@ pub fn submit_stats_component() -> Html {
} }
} }
let weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; let weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let (max_idx, _max_val) = counts let (max_idx, _max_val) = counts
.iter() .iter()
.enumerate() .enumerate()
@@ -383,12 +417,12 @@ pub fn submit_stats_component() -> Html {
html! { html! {
<div class="diagnostics-section"> <div class="diagnostics-section">
if *loading { if *loading {
<p>{ "Wird geladen..." }</p> <p>{ "Loading..." }</p>
} }
if let Some(e) = &*error { if let Some(e) = &*error {
<p style="color: red;">{ e.clone() }</p> <p style="color: red;">{ e.clone() }</p>
} }
<h3>{ "Tickets pro Wochentag" }</h3> <h3>{ "Tickets per weekday" }</h3>
<div class="weekday-chart"> <div class="weekday-chart">
<div class="weekday-bars"> <div class="weekday-bars">
{ for (0..7).map(|i| { { for (0..7).map(|i| {
@@ -415,14 +449,23 @@ pub fn submit_stats_component() -> Html {
})} })}
</div> </div>
</div> </div>
<div class="diagnostics-row">
<div class="diagnostics-column">
<h3>{ "Tickets pro Raum" }</h3>
<RoomTotalTickets tickets={(*tickets).clone()}/> <RoomTotalTickets tickets={(*tickets).clone()}/>
</div> </div>
<div class="diagnostics-column">
<h3>{ "Tickets pro Benutzer" }</h3>
<UserTotal users={(*users).clone()} tickets={(*tickets).clone()}/>
</div>
</div>
</div>
} }
} }
/// A component that displays the total number of tickets per room. /// A component that displays the total number of tickets per room.
/// ///
/// This component takes a list of [`TicketPartial`] items and calculates the /// This component takes a list of `TicketPartial` items and calculates the
/// total number of tickets for each room. It then displays these totals /// total number of tickets for each room. It then displays these totals
/// in a sorted list with a bar chart visualization. /// in a sorted list with a bar chart visualization.
/// ///
@@ -434,7 +477,7 @@ pub fn submit_stats_component() -> Html {
/// - **Sorts Results**: Displays rooms sorted by ticket count in descending order. /// - **Sorts Results**: Displays rooms sorted by ticket count in descending order.
/// - **Visualizes Data**: Renders a bar chart where the width of each bar is /// - **Visualizes Data**: Renders a bar chart where the width of each bar is
/// proportional to the ticket count for that room, relative to the room with the maximum tickets. /// proportional to the ticket count for that room, relative to the room with the maximum tickets.
/// - **Room Formatting**: Uses the [`parse_room`] function to display room numbers /// - **Room Formatting**: Uses the `parse_room` function to display room numbers
/// in a human-readable format. /// in a human-readable format.
/// ///
/// # Example /// # Example
@@ -457,19 +500,72 @@ fn room_total_component(props: &RoomTotalsProps) -> Html {
html! { html! {
<div class="diagnostics-section"> <div class="diagnostics-section">
<h3>{ "Tickets pro Raum" }</h3> <div class="chart">
<div class="room-chart">
{ for totals_vec.into_iter().map(|(room, count)| { { for totals_vec.into_iter().map(|(room, count)| {
let label = parse_room(room); let label = parse_room(room);
let bar_width_percent = (count as f64 / max_count as f64) * 100.0; let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
html! { html! {
<div class="room-bar-item"> <div class="bar-item">
<div class="room-header"> <div class="diagnostics-header">
<span class="room-label">{ label }</span> <span class="diagnostics-label">{ label }</span>
<span class="room-count">{ count }</span> <span class="diagnostics-count">{ count }</span>
</div> </div>
<div class="room-bar-container"> <div class="bar-container">
<div class="room-bar" style={format!("width: {}%;", bar_width_percent)}> <div class="bar" style={format!("width: {}%;", bar_width_percent)}>
</div>
</div>
</div>
}
}) }
</div>
</div>
}
}
#[component(UserTotal)]
fn user_total_component(props: &UserTotalProps) -> Html {
let name_map: HashMap<i16, (String, String)> = props
.users
.iter()
.map(|u| (u.id, (u.first_name.clone(), u.last_name.clone())))
.collect();
let mut counts: HashMap<i16, usize> = HashMap::new();
for t in &props.tickets {
*counts.entry(t.user_id).or_insert(0) += 1;
}
let mut totals_vec: Vec<(i16, String, String, usize)> = name_map
.into_iter()
.map(|(id, (fname, lname))| {
let c = counts.get(&id).cloned().unwrap_or(0);
(id, fname, lname, c)
})
.collect();
totals_vec.sort_by(|a, b| b.3.cmp(&a.3));
let max_count = totals_vec
.iter()
.map(|(_, _, _, c)| *c)
.max()
.unwrap_or(1)
.max(1);
html! {
<div class="diagnostics-section">
<div class="chart">
{ for totals_vec.into_iter().map(|(_id, fname, lname, count)| {
let label = format!("{} {}", dequote!(fname), dequote!(lname));
let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
html! {
<div class="bar-item">
<div class="diagnostics-header">
<span class="diagnostics-label">{ label }</span>
<span class="diagnostics-count">{ count }</span>
</div>
<div class="bar-container">
<div class="bar" style={format!("width: {}%;", bar_width_percent)}>
</div> </div>
</div> </div>
</div> </div>
+8 -4
View File
@@ -1,8 +1,12 @@
@use "variables" as *; @use "variables" as *;
@mixin card { @mixin card {
backgroud: #fff; background: $color-container;
border-radius: &border-radius; border-radius: $border-radius;
box-shadow: 0 1px 3px rgba(0,0,0,0.06); box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
padding: var(--spacing-md); padding: $spacing-md;
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
} }
+2 -3
View File
@@ -1,6 +1,5 @@
@use "variables" as *; @use "variables" as *;
.u-hidden { display: none !important; } .u-hidden { display: none !important; }
.u-gap-sm { gap: var(--spacing-sm); } .u-gap-sm { gap: $spacing-sm; }
.text-muted { color: var(--color-muted); } .text-muted { color: $color-muted; }
+32 -32
View File
@@ -1,34 +1,34 @@
:root { // Color Palette (Reference Style)
--color-bg: #ffffff; $color-bg: #f0f2f5;
--color-sidebar: #570000; $color-bg-dark: #121212;
--color-accent: #2563eb; $color-container: #ffffff;
--color-muted: #6b7280; $color-container-dark: #333333;
--spacing-sm: 8px; $color-sidebar: #0f172a;
--spacing-md: 16px; $color-primary: #2b79c2;
--border-radius: 6px; $color-primary-hover: #1d5fa0;
--rundbgbackgroundcolor: #a0a0a0; $color-accent: #2b79c2;
--sidebar-font: #fffefe; $color-muted: #6b7280;
--color: #000000; $color-text: #111827;
--color-container: #f0f0f0; $color-text-dark: #e2e2e2;
--color-text: #000000;
--color-placeholder: #757575;
--color-input: #1a1a1a;
}
[data-theme='dark'] { // Status Colors
--color-bg: #000000; $color-status-todo: #ffcccc;
--color-sidebar: #b7d300; $color-status-todo-text: #a00;
--color-accent: #eb2525; $color-status-inprogress: #fff3cd;
--color-muted: #999999; $color-status-inprogress-text: #856404;
--spacing-sm: 8px; $color-status-done: #d4edda;
--spacing-md: 16px; $color-status-done-text: #155724;
--border-radius: 6px; $color-status-archived: #e0e0e0;
--rundbgbackgroundcolor: #505050; $color-status-archived-text: #666666;
--sidebar-font: #000000;
--color: #000000; // Action Colors
--color-container: #1a1a1a; $color-logout: #ff4d4d;
--color-text: #000000; $color-logout-hover: #e60000;
--color-placeholder: #a0a0a0;
--color-input: #f0f0f0; // Spacing
} $spacing-sm: 8px;
$spacing-md: 16px;
$spacing-lg: 2rem;
$border-radius: 0.5rem;
$border-radius-lg: 10px;
@@ -0,0 +1,82 @@
@use "../variables" as *;
.ticket-list {
list-style: none;
padding: 0;
margin: 0;
margin-bottom: $spacing-lg;
li {
border-left: 4px solid;
padding: 1rem;
margin-bottom: 1rem;
border-radius: $border-radius-lg;
background: $color-container;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
h3 {
margin-top: 0;
margin-bottom: $spacing-md;
color: $color-primary;
a {
color: $color-primary;
text-decoration: none;
transition: color 0.2s ease-in-out;
&:hover {
color: $color-primary-hover;
text-decoration: underline;
}
}
}
p {
margin: 0.3rem 0;
}
&.To-Do {
border-left-color: $color-status-todo-text;
}
&.InProgress {
border-left-color: $color-status-inprogress-text;
}
&.Completed {
border-left-color: $color-status-done-text;
}
&.Archived {
border-left-color: $color-status-archived-text;
}
}
}
.ticket-list-actions {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
margin-top: $spacing-lg;
a {
display: inline-block;
padding: $spacing-md $spacing-lg;
background: $color-primary;
color: white;
text-decoration: none;
border-radius: $border-radius;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: $color-primary-hover;
}
}
}
@@ -0,0 +1,13 @@
@use "../variables" as *;
.user-list {
list-style: none;
padding-left: 0;
margin: 0;
li {
h3 {
margin-top: 0;
}
}
}
@@ -1,27 +0,0 @@
body {
/* Formular Styles */
input, textarea, select, button {
width: 100%;
margin-top: 1rem;
padding: 0.75rem;
font-size: 1rem;
border-radius: 0.5rem;
color: var(--color-input);
border: none;
cursor: pointer;
margin-bottom: 15px;
}
input::placeholder,
textarea::placeholder {
color: var(--color-placeholder);
opacity: 1;
}
button:hover {
cursor: pointer;
border: none;
transition: all 0.2s ease-in-out;
margin-bottom: 15px;
}
}
+105 -28
View File
@@ -4,18 +4,22 @@
.diagnostics-container { .diagnostics-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-md); gap: $spacing-md;
} }
.diagnostics-section { .diagnostics-section {
background-color: #ffffff; background-color: $color-container;
border-radius: var(--border-radius); border-radius: $border-radius;
padding: var(--spacing-md); padding: $spacing-md;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background-color: $color-container-dark;
}
h3 { h3 {
margin: 0 0 var(--spacing-md) 0; margin: 0;
color: #1f2937; color: $color-primary;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
} }
@@ -26,8 +30,14 @@
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: var(--border-radius); border-radius: $border-radius;
background-color: #f9fafb; background-color: $color-container;
margin: 16px 0px;
@media (prefers-color-scheme: dark) {
background-color: $color-container-dark;
border-color: #555;
}
} }
.weekday-bars { .weekday-bars {
@@ -35,7 +45,7 @@
gap: 12px; gap: 12px;
align-items: flex-end; align-items: flex-end;
height: 205px; height: 205px;
padding: var(--spacing-md); padding: $spacing-md;
overflow-x: auto; overflow-x: auto;
padding-bottom: 0px; padding-bottom: 0px;
} }
@@ -47,11 +57,10 @@
flex: 1; flex: 1;
min-width: 60px; min-width: 60px;
height: 100%; height: 100%;
justify-content: end;
.bar { .bar {
width: 100%; width: 100%;
background-color: var(--color-accent); background-color: $color-primary;
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
@@ -64,7 +73,7 @@
.weekday-labels { .weekday-labels {
display: flex; display: flex;
gap: 12px; gap: 12px;
padding: var(--spacing-md); padding: $spacing-md;
padding-top: 0; padding-top: 0;
overflow-x: auto; overflow-x: auto;
@@ -77,62 +86,130 @@
.day { .day {
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
color: #1f2937; color: $color-text;
margin-bottom: 2px; margin-bottom: 2px;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
} }
.value { .value {
font-size: 11px; font-size: 11px;
color: var(--color-muted); color: $color-muted;
} }
} }
} }
.room-chart { .chart {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
padding: var(--spacing-md); padding: $spacing-md;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: var(--border-radius); border-radius: $border-radius;
background-color: #f9fafb; background-color: $color-container;
width: 100%;
@media (prefers-color-scheme: dark) {
background-color: $color-container-dark;
border-color: #555;
}
} }
.room-bar-item { .bar-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
.room-header { .diagnostics-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
.room-label { .diagnostics-label {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
color: #1f2937; color: $color-text;
min-width: 50px; min-width: 50px;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
} }
.room-count { .diagnostics-count {
font-size: 12px; font-size: 12px;
color: var(--color-muted); color: $color-muted;
font-weight: 500; font-weight: 500;
} }
} }
.room-bar-container { .bar-container {
width: 100%; width: 100%;
height: 24px; height: 24px;
background-color: #e5e7eb; background-color: #e5e7eb;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
.room-bar { @media (prefers-color-scheme: dark) {
background-color: #555;
}
.bar {
height: 100%; height: 100%;
background-color: #570000; background-color: $color-primary;
transition: width 0.3s ease; transition: width 0.3s ease;
} }
} }
} }
.diagnostics-row {
display: flex;
gap: 24px;
align-items: flex-start;
flex-wrap: wrap;
}
.diagnostics-column {
display: flex;
flex-direction: column;
gap: 12px;
width: 48%;
min-width: 280px;
}
.open-tickets{
display: flex;
align-items: center;
position: relative;
width: 100%;
padding: 0 1rem;
box-sizing: border-box;
min-height: 60px;
.left {
display: flex;
align-items: center;
flex-shrink: 0;
h2 {
margin: 0;
white-space: nowrap;
}
}
.center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
}
.home {
.ticket_count {
font-size: xxx-large;
height: 115px;
}
}
@@ -0,0 +1,97 @@
@use "../variables" as *;
.form-container {
display: flex;
flex-direction: column;
gap: $spacing-lg;
margin-bottom: $spacing-lg;
form {
display: flex;
flex-direction: column;
gap: $spacing-md;
label {
display: flex;
flex-direction: column;
gap: $spacing-sm;
font-weight: 500;
color: $color-text;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
}
input, textarea, select {
padding: $spacing-md;
border: 1px solid #ccc;
border-radius: $border-radius;
font-family: Arial, sans-serif;
font-size: 1rem;
@media (prefers-color-scheme: dark) {
background-color: #6b6b6b;
color: $color-text-dark;
border-color: #555;
}
&:focus {
outline: none;
border-color: $color-primary;
box-shadow: 0 0 0 2px rgba($color-primary, 0.2);
}
}
textarea {
min-height: 120px;
resize: vertical;
}
button {
align-self: flex-start;
padding: $spacing-md $spacing-lg;
background-color: $color-primary;
color: white;
border: none;
border-radius: $border-radius;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: $color-primary-hover;
}
&:active {
transform: scale(0.98);
}
}
a {
display: inline-block;
padding: $spacing-md $spacing-lg;
background: $color-primary;
color: white;
text-decoration: none;
border-radius: $border-radius;
text-align: center;
transition: background-color 0.2s ease-in-out;
align-self: flex-start;
&:hover {
background-color: $color-primary-hover;
}
}
}
ul {
list-style: none;
padding-left: 0;
li h3 {
margin-top: 0;
}
}
}
@@ -1,7 +0,0 @@
.headline {
font-size: 40px;
font-weight: bold;
text-align: center;
color: #570000;
margin-top: 150px;
}
@@ -1,9 +0,0 @@
.login-status {
font-weight: bold;
margin: 4px 8px;
}
.name {
font-size: 24px;
margin: 4px 8px;
}
-27
View File
@@ -1,27 +0,0 @@
.csg-icon {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
@media (max-width: 768px) {
max-width: 60px;
img {
width: 100%;
height: auto;
}
}
img {
width: auto;
height: auto;
}
}
@@ -0,0 +1,39 @@
@use "../variables" as *;
.login-btn {
display: inline-block;
padding: 12px 400px;
background: $color-primary;
color: white;
border-radius: $border-radius-lg;
text-decoration: none;
font-weight: bold;
font-size: 1rem;
cursor: pointer;
border: none;
transition: all 0.2s ease-in-out;
margin-bottom: 15px;
&:hover {
background: $color-primary-hover;
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
}
.logout-btn {
display: inline-block;
padding: 10px 20px;
background: $color-logout;
color: white;
border-radius: $border-radius-lg;
text-decoration: none;
font-weight: bold;
transition: 0.2s ease-in-out;
margin-bottom: 20px;
&:hover {
background: $color-logout-hover;
transform: scale(1.05);
}
}
+272
View File
@@ -0,0 +1,272 @@
@use "../variables" as *;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
border-bottom: 2px solid #e5e7eb;
@media (prefers-color-scheme: dark) {
border-bottom-color: #555;
}
h1 {
margin: 0;
color: $color-primary;
font-size: 1.75rem;
}
}
.page-actions {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
a, button {
display: inline-block;
padding: $spacing-md;
background: $color-primary;
color: white;
text-decoration: none;
border: none;
border-radius: $border-radius;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: $color-primary-hover;
}
}
}
input[type="checkbox"] {
width: fit-content;
}
.grid-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: $spacing-lg;
margin-top: $spacing-lg;
.grid-item {
background: $color-container;
border: 1px solid #e5e7eb;
border-radius: $border-radius;
padding: $spacing-md;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s ease, transform 0.2s ease;
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
border-color: #555;
}
&:hover {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
h3 {
color: $color-primary;
margin-top: 0;
margin-bottom: $spacing-md;
}
p {
margin: $spacing-sm 0;
color: $color-muted;
&:last-child {
margin-bottom: 0;
}
}
.item-actions {
display: flex;
gap: $spacing-md;
margin-top: $spacing-md;
flex-wrap: wrap;
a, button {
flex: 1;
min-width: 100px;
padding: $spacing-md;
border: none;
border-radius: $border-radius;
font-weight: bold;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: background-color 0.2s ease-in-out;
}
a.view, button.view {
background: $color-primary;
color: white;
&:hover {
background-color: $color-primary-hover;
}
}
a.edit, button.edit {
background: #f59e0b;
color: white;
&:hover {
background-color: #d97706;
}
}
a.delete, button.delete {
background: $color-logout;
color: white;
&:hover {
background-color: $color-logout-hover;
}
}
}
}
}
.table-container {
overflow-x: auto;
margin-top: $spacing-lg;
border-radius: $border-radius;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
}
table {
width: 100%;
border-collapse: collapse;
background: $color-container;
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
thead {
background: $color-primary;
color: white;
th {
padding: $spacing-md;
text-align: left;
font-weight: 600;
border-bottom: 2px solid darken($color-primary, 10%);
}
}
tbody {
tr {
border-bottom: 1px solid #e5e7eb;
transition: background-color 0.2s ease;
@media (prefers-color-scheme: dark) {
border-bottom-color: #555;
}
&:hover {
background-color: #f9fafb;
@media (prefers-color-scheme: dark) {
background-color: #444;
}
}
td {
padding: $spacing-md;
color: $color-text;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
}
}
}
}
}
.alert {
padding: $spacing-md;
border-radius: $border-radius;
margin-bottom: $spacing-lg;
border-left: 4px solid;
&.success {
background: $color-status-done;
color: $color-status-done-text;
border-left-color: $color-status-done-text;
}
&.error {
background: $color-status-todo;
color: $color-status-todo-text;
border-left-color: $color-status-todo-text;
}
&.warning {
background: $color-status-inprogress;
color: $color-status-inprogress-text;
border-left-color: $color-status-inprogress-text;
}
&.info {
background: #dbeafe;
color: #1e40af;
border-left-color: #1e40af;
@media (prefers-color-scheme: dark) {
background: #1e3a8a;
color: #93c5fd;
border-left-color: #93c5fd;
}
}
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #e5e7eb;
border-top-color: $color-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
@keyframes spin {
to { transform: rotate(360deg); }
}
}
.empty-state {
text-align: center;
padding: $spacing-lg;
color: $color-muted;
p {
font-size: 1.1rem;
margin-bottom: $spacing-md;
}
a {
display: inline-block;
padding: $spacing-md $spacing-lg;
background: $color-primary;
color: white;
text-decoration: none;
border-radius: $border-radius;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: $color-primary-hover;
}
}
}
+120
View File
@@ -0,0 +1,120 @@
@use "../variables" as *;
.setup-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: $color-bg;
padding: $spacing-lg;
@media (prefers-color-scheme: dark) {
background: $color-bg-dark;
}
}
.setup-box {
width: 100%;
max-width: 400px;
background: $color-container;
padding: $spacing-lg;
border-radius: 1rem;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
h1 {
color: $color-primary;
text-align: center;
margin-top: 0;
margin-bottom: $spacing-md;
}
p {
text-align: center;
color: $color-muted;
margin-bottom: $spacing-lg;
}
}
.setup-form {
display: flex;
flex-direction: column;
gap: $spacing-md;
.form-group {
display: flex;
flex-direction: column;
gap: $spacing-sm;
label {
font-weight: 500;
color: $color-text;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
}
}
input {
padding: $spacing-md;
border: 1px solid #ccc;
border-radius: $border-radius;
font-family: Arial, sans-serif;
font-size: 1rem;
@media (prefers-color-scheme: dark) {
background-color: #6b6b6b;
color: $color-text-dark;
border-color: #555;
}
&:focus {
outline: none;
border-color: $color-primary;
box-shadow: 0 0 0 2px rgba($color-primary, 0.2);
}
}
button {
padding: $spacing-md;
background-color: $color-primary;
color: white;
border: none;
border-radius: $border-radius;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
margin-top: $spacing-md;
&:hover {
background-color: $color-primary-hover;
}
&:active {
transform: scale(0.98);
}
}
}
.success-message {
background: $color-status-done;
color: $color-status-done-text;
padding: $spacing-md;
border-radius: $border-radius;
margin-bottom: $spacing-md;
border-left: 4px solid $color-status-done-text;
}
.error-message {
background: $color-status-todo;
color: $color-status-todo-text;
padding: $spacing-md;
border-radius: $border-radius;
margin-bottom: $spacing-md;
border-left: 4px solid $color-status-todo-text;
}
+13 -153
View File
@@ -1,30 +1,23 @@
@use "../variables" as *; @use "../variables" as *;
@use "../mixins" as *; @use "../mixins" as *;
:root {
color-scheme: dark light;
}
.sidebar { .sidebar {
width: 260px; width: 260px;
background: var(--color-sidebar); background: $color-sidebar;
color: var(--sidebar-font); color: #fff;
min-height: 100%; min-height: 100%;
padding: var(--spacing-md); padding: $spacing-md;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $spacing-sm; flex: 1; }
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--spacing-sm); flex: 1; }
a, .menu-toggle { a, .menu-toggle {
color: var(--sidebar-font); color: #fff;
text-decoration: none; text-decoration: none;
display: block; display: block;
padding: 8px 12px; padding: 8px 12px;
border-radius: var(--border-radius); border-radius: $border-radius;
transition: background 0.2s ease-in-out; transition: background 0.2s ease-in-out;
&:hover { background: rgba(255,255,255,0.1); } &:hover { background: rgba(255,255,255,0.1); }
} }
@@ -34,7 +27,7 @@
.submenu { .submenu {
margin-left: 8px; margin-left: 8px;
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.05);
border-radius: var(--border-radius); border-radius: $border-radius;
padding: 6px; padding: 6px;
li { padding: 4px 0; } li { padding: 4px 0; }
} }
@@ -42,153 +35,20 @@
.logout-item { .logout-item {
margin-top: auto; margin-top: auto;
border-top: 1px solid rgba(255,255,255,0.1); border-top: 1px solid rgba(255,255,255,0.1);
padding-top: var(--spacing-sm); padding-top: $spacing-sm;
} }
.sidebar-button { .logout-button {
background: rgba(255, 93, 43, 0.527); background: rgba(255, 255, 255, 0.1);
color: var(--sidebar-font); color: #fff;
border: 1px solid rgba(255, 255, 255, 0.801); border: 1px solid rgba(255, 255, 255, 0.2);
padding: 8px 12px; padding: 8px 12px;
border-radius: 4px; border-radius: 4px;
width: 100%; width: 100%;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
margin-top: var(--spacing-sm);
&.theme-toggle-btn {
display: flex;
justify-content: center;
align-items: center;
}
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: var(--spacing-md);
.home {
flex-grow: 1;
display: flex;
justify-content: center;
@media (max-width: 768px) {
padding-left: 36px;
}
}
.sidebar-close {
display: none;
background: transparent;
border: none;
color: var(--sidebar-font);
cursor: pointer;
padding: 8px;
border-radius: 50%;
align-items: center;
justify-content: center;
transition: background 0.2s ease-in-out;
margin-bottom: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
svg {
width: 20px;
height: 20px;
stroke: var(--sidebar-font);
}
@media (max-width: 768px) {
display: flex;
}
}
}
.home-svg {
width: 24px;
height: 24px;
vertical-align: middle;
margin-right: 8px;
}
&.user { width: 220px; background: darken(#570000, 6%); }
}
// Floating mobile menu toggle button
.mobile-menu-toggle {
display: none;
@media (max-width: 768px) {
display: flex;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 999;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid rgba(128, 128, 128, 0.2);
background-color: var(--color-container);
color: var(--color-text);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s ease-in-out;
svg {
width: 24px;
height: 24px;
stroke: var(--color-text);
transition: stroke 0.2s ease-in-out;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
background-color: var(--color-bg);
color: var(--color-container);
svg {
stroke: var(--color-container);
}
}
&:active {
transform: translateY(0);
}
}
}
// Overlay backdrop that dims the screen and allows dismissals on mobile
.sidebar-overlay {
display: none;
@media (max-width: 768px) {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 999;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
&.open {
opacity: 1;
pointer-events: auto;
}
} }
&.user { width: 220px; background: darken($color-sidebar, 6%); }
} }
+56 -55
View File
@@ -1,79 +1,80 @@
@use "../mixins" as *; @use "../mixins" as *;
@use "../variables" as *; @use "../variables" as *;
.ticket-card { .ticket {
@include card(); border: 1px solid #ccc;
border-left: 4px solid var(--color-accent); padding: 1rem;
display: flex; margin-bottom: 1rem;
flex-direction: column; border-radius: $border-radius-lg;
gap: var(--spacing-sm); background: $color-container;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
.meta { color: var(--color-muted); font-size: 0.9rem; } @media (prefers-color-scheme: dark) {
.title { font-weight: 600; } background: $color-container-dark;
}
h3 {
margin-top: 0;
color: $color-primary;
}
p {
margin: 0.3rem 0;
}
} }
.listbox { .ticket-card {
max-width: auto; @include card();
margin:0 0; border-left: 4px solid $color-primary;
background-color: #90ff82; display: flex;
background-color: var(--rundbgbackgroundcolor); flex-direction: column;
padding-bottom: 8px; gap: $spacing-sm;
color: white; .meta { color: $color-muted; font-size: 0.9rem; }
padding-left: 8px; .title { font-weight: 600; }
padding-right: 8px; }
padding-top: 8px;
border-radius: 20px;
border-color: #000000;
border-style: double;
box-shadow: 10px 10px 10px rgba(43, 43, 43, 0.8);
font-size: 26px;
list-style-type: none;
text-align: center;
.status {
display: inline-block;
padding: 4px 10px;
border-radius: 8px;
font-size: 0.8rem;
font-weight: bold;
margin-top: 8px;
&.To-Do { &.To-Do {
background-color: #b1003b; background: $color-status-todo;
color: $color-status-todo-text;
} }
&.InProgress { &.InProgress {
background-color: #fff385; background: $color-status-inprogress;
color: $color-status-inprogress-text;
}
&.Done {
background: $color-status-done;
color: $color-status-done-text;
} }
&.Completed { &.Completed {
background-color: #90ff82; background: $color-status-done;
color: $color-status-done-text;
} }
&.Archived { &.Archived {
background-color: #af69ee; background: $color-status-archived;
} color: $color-status-archived-text;
a {
text-decoration: none;
color: white;
} }
} }
.postits { .ticket_count {
display: grid;
gap: 8px;
margin-top: 96px;
}
.warning {
color: #570000;
}
.ticket-count {
border-radius: 10px;
border: 2px solid black;
width: fit-content;
padding: 8px;
margin: 0 auto;
margin-bottom: 32px;
h4 {
text-align: center; text-align: center;
font-size: 32px; align-content: center;
margin: 8px; color: #005f00;
} border: 2px solid #848484;
border-radius: 10px;
height: 50px;
width: 250px;
margin: 0;
font-size: xx-large;
} }
+132 -96
View File
@@ -3,25 +3,125 @@
@use "utilities"; @use "utilities";
@use "components/sidebar"; @use "components/sidebar";
@use "components/tickets"; @use "components/tickets";
@use "components/alltickets";
@use "components/allusers";
@use "components/forms";
@use "components/login";
@use "components/diagnostics"; @use "components/diagnostics";
@use "components/frontpage"; @use "components/pages";
@use "components/home"; @use "components/setup";
@use "components/icon";
* {
box-sizing: border-box;
}
body { body {
background: var(--color-bg); background: variables.$color-bg;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; font-family: Arial, sans-serif;
color: #111827; color: variables.$color-text;
margin: 0; margin: 0;
padding: 0;
@media (prefers-color-scheme: dark) {
background: variables.$color-bg-dark;
color: variables.$color-text-dark;
}
}
input, textarea, select {
width: 100%;
padding: variables.$spacing-md;
font-size: 1rem;
border-radius: variables.$border-radius;
border: 1px solid #ccc;
box-sizing: border-box;
margin-bottom: 15px;
font-family: Arial, sans-serif;
@media (prefers-color-scheme: dark) {
background-color: #6b6b6b;
color: variables.$color-text-dark;
border-color: #555;
}
}
button {
padding: variables.$spacing-md;
font-size: 1rem;
border-radius: variables.$border-radius;
background-color: variables.$color-primary;
color: white;
border: none;
cursor: pointer;
margin-bottom: 15px;
transition: background-color 0.2s ease-in-out;
font-family: Arial, sans-serif;
font-weight: bold;
&:hover {
background-color: variables.$color-primary-hover;
}
}
form {
display: flex;
flex-direction: column;
gap: variables.$spacing-md;
label {
display: flex;
flex-direction: column;
gap: variables.$spacing-sm;
font-weight: 500;
}
a {
display: inline-block;
padding: variables.$spacing-md;
background: variables.$color-primary;
color: white;
text-decoration: none;
border-radius: variables.$border-radius;
text-align: center;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: variables.$color-primary-hover;
}
}
}
.container {
max-width: 900px;
margin: auto;
background: variables.$color-container;
padding: variables.$spacing-lg;
border-radius: 1rem;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: variables.$color-container-dark;
}
}
.header-with-image {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-with-image img {
height: 60px;
} }
.admin { display: flex; } .admin { display: flex; }
.layout { .layout {
display: flex; display: flex;
height: 100vh; min-height: 100vh;
overflow: hidden; margin: 0;
box-sizing: border-box; padding: 0;
} }
.sidebar { .sidebar {
@@ -32,108 +132,44 @@ body {
top: 0; top: 0;
height: 100vh; height: 100vh;
overflow-y: auto; overflow-y: auto;
box-sizing: border-box;
} }
.content { .content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-left: 260px; margin-left: 260px;
height: 100%; padding: variables.$spacing-lg;
overflow-y: auto;
padding: var(--spacing-md); > :first-child {
box-sizing: border-box; max-width: 900px;
margin: 0 auto;
background: variables.$color-container;
padding: variables.$spacing-lg;
border-radius: 1rem;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: variables.$color-container-dark;
}
}
h1, h2, h3 {
color: variables.$color-primary;
margin-top: 0;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .sidebar {
position: fixed; position: fixed;
top: 0;
bottom: 0;
left: -260px; left: -260px;
transition: left .3s ease-in-out; transition: left .3s ease-in-out;
z-index: 1000; z-index: 1000;
height: 100%;
box-sizing: border-box;
} }
.sidebar.open { left: 0; } .sidebar.open {
.content { margin-left: 0; padding: var(--spacing-md); box-sizing: border-box; } left: 0;
}
.rundbg {
display: flex;
justify-content: center;
flex-direction: column;
max-width: 60%;
margin: 0 auto;
background-color: var(--rundbgbackgroundcolor);
padding-bottom: 96px;
padding-left: 32px;
padding-right: 32px;
padding-top: 96px;
border-radius: 20px;
gap: 8px;
border-color: #000000;
border-style: double;
box-shadow: 20px 20px 10px rgb(43, 43, 43);
font-size: 26px;
label {
display: block;
width: 100%;
box-sizing: border-box;
} }
.content {
input, select, button, a { margin-left: 0;
width: 100%;
box-sizing: border-box;
padding: 16px;
font-size: 1.8rem;
background-color: #f0f0f0;
color: var(--color-input);
box-shadow: #6d6d6d;
box-shadow: 6px 6px 8px;
}
input[type="checkbox"] {
width: auto;
}
button {
margin: 16px 0;
}
a {
color: var (--color);
text-align: center;
border-style: dashed;
}
&.login {
margin-top: 96px;
max-width: 40%;
}
&.home {
padding-top: 16px;
} }
} }
.setup-form input {
width: 100%;
box-sizing: border-box;
}
input, select, textarea {
color: var(--color-input);
}
input::placeholder,
textarea::placeholder {
color: var(--color-placeholder);
opacity: 1;
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
#![doc = include_str!("../README.cargo.md")] #![doc = include_str!("../README.md")]