17 Commits
Author SHA1 Message Date
schn33fuchs a04c3cd8e2 Bugfixes 2026-07-16 18:43:24 +02:00
schn33fuchs ae0176b49a QR Codes 2026-07-16 18:01:58 +02:00
schn33fuchs b62bba0173 Docker shit 2026-07-14 19:23:14 +02:00
schn33fuchs 5585917eb1 rename shit 2026-07-14 17:06:47 +02:00
schn33fuchs b1b29d9f39 Update backend/migrations/20260422094717_user_table.sql 2026-06-20 13:34:46 +02:00
schn33fuchs 042637d248 Add LICENSE.md 2026-06-19 22:35:11 +02:00
schn33fuchs 07003bd305 Update README.md 2026-06-19 20:26:57 +02:00
schn33fuchs 83eadc532c Video 2026-06-19 20:21:47 +02:00
schn33fuchs 2bb0f01e06 Delete frontend/frontend.tar.xz 2026-06-19 20:13:37 +02:00
TheDiluTek 9686441f71 schwarz 2026-06-18 20:16:52 +02:00
TheDiluTek a496338042 schwarze schrift sonst sieht man nix 2026-06-18 20:16:01 +02:00
TheDiluTek 3915f997da Merge branch 'main' of https://git.raffauf-clan.de/schn33fuchs/ticketsystem 2026-06-18 19:58:46 +02:00
TheDiluTek 9704d5792a njix 2026-06-18 19:58:41 +02:00
schn33fuchs c11309a3a5 Docs 2026-06-17 16:35:26 +02:00
schn33fuchs a1d95f1ba2 Styling improvements 2026-06-17 15:47:42 +02:00
schn33fuchs 45e5c657c7 Translation to German 2026-06-17 15:47:34 +02:00
schn33fuchs 584a75009f README update 2026-06-17 13:35:29 +02:00
37 changed files with 1015 additions and 155 deletions
+13
View File
@@ -0,0 +1,13 @@
target/
backend/target/
frontend/target/
frontend/dist/
frontend/node_modules/
dist/
.git/
.idea/
.env
.antigravitycli/
*.pdb
**/*.rs.bk
info ding video.mp4
+3
View File
@@ -27,3 +27,6 @@ frontend/node_modules/
/target
.antigravitycli/
.sqlx/
backend/.sqlx/
dist/
Generated
+132
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -150,7 +156,9 @@ dependencies = [
"axum-extra",
"chrono",
"dotenv",
"hex",
"jsonwebtoken",
"rand",
"serde",
"serde_json",
"sqlx",
@@ -226,12 +234,24 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -354,6 +374,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -585,6 +614,15 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "ff"
version = "0.13.1"
@@ -607,6 +645,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "flume"
version = "0.11.1"
@@ -658,11 +706,14 @@ dependencies = [
name = "frontend"
version = "0.1.0"
dependencies = [
"base64",
"chrono",
"chrono-tz",
"dotenv",
"gloo 0.12.0",
"gloo-net 0.7.0",
"gloo-storage 0.4.0",
"qrcode-generator",
"serde",
"serde_json",
"theme",
@@ -1504,6 +1555,12 @@ dependencies = [
"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]]
name = "http"
version = "0.2.12"
@@ -1728,6 +1785,19 @@ dependencies = [
"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"
@@ -1915,6 +1985,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "mio"
version = "1.2.0"
@@ -1926,6 +2006,16 @@ dependencies = [
"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]]
name = "native-tls"
version = "0.2.18"
@@ -2242,6 +2332,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "potential_utf"
version = "0.1.5"
@@ -2354,6 +2457,29 @@ dependencies = [
"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]]
name = "quote"
version = "1.0.45"
@@ -2684,6 +2810,12 @@ dependencies = [
"rand_core",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simple_asn1"
version = "0.6.4"
+21
View File
@@ -0,0 +1,21 @@
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.
+8 -1
View File
@@ -30,8 +30,12 @@ Keys:
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
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`
@@ -44,6 +48,9 @@ The HTML code for the frontend can be generated by using `trunk build`. The resu
> }
> ```
### Example
[![Watch the video]()](https://git.raffauf-clan.de/schn33fuchs/ticketsystem/raw/branch/main/info%20ding%20video.mp4)
## Diagrams
### Class Diagramm
#### Backend
+3 -1
View File
@@ -8,7 +8,7 @@ axum = "0.8.9"
axum-extra = { version = "0.12.6", features = ["cookie", "typed-header", "form"] }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls", "chrono"] }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls", "chrono", "migrate"] }
tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros"] }
dotenv = "0.15.0"
chrono = { workspace = true }
@@ -17,3 +17,5 @@ argon2 = "0.5.3"
time = "0.3.47"
tower = "0.5.3"
tower-http = { version = "0.6.8", features = ["cors"] }
rand = '0.8'
hex = '0.4'
@@ -6,9 +6,3 @@ CREATE TABLE users (
pwd TEXT,
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);
@@ -0,0 +1 @@
DROP TABLE qr_codes;
@@ -0,0 +1,8 @@
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);
+1
View File
@@ -3,4 +3,5 @@
//! 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).
pub mod auth;
pub mod qr;
pub mod ticket;
+130
View File
@@ -0,0 +1,130 @@
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()
}
}
+12 -2
View File
@@ -32,8 +32,10 @@ use crate::env::Env;
/// - `db`: PostgreSQL connection pool for database access (via `sqlx::PgPool`)
/// - `env`: [`Env`] configuration loaded from environment variables
pub struct AppState {
db: PgPool,
env: Env,
/// PostgreSQL connection pool for all database operations
pub db: PgPool,
/// Environment configuration with secrets and settings
pub env: Env,
}
/// Main application entry point.
@@ -75,6 +77,14 @@ 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
let cors = CorsLayer::new()
.allow_origin(env.origin.parse::<HeaderValue>().unwrap())
+39
View File
@@ -33,15 +33,25 @@ use serde::{Deserialize, Serialize};
/// ```
#[derive(Deserialize, Serialize, Debug, PartialEq)]
pub struct TicketResponse {
/// Unique ticket identifier
pub id: i32,
/// Ticket category/type (e.g., "maintenance", "support")
pub category: String,
/// Ticket subject line
pub betreff: String,
/// Detailed ticket description
pub description: String,
/// Room number associated with the issue
pub room: i16,
/// Current ticket status (e.g., "open", "in_progress", "resolved", "archived")
pub status: String,
/// When the ticket was created (UTC timestamp)
pub date: chrono::DateTime<chrono::Utc>,
/// ID of the user who created the ticket
pub user_id: i16,
/// First name of the ticket creator (denormalized from `User`)
pub user_first_name: String,
/// Last name of the ticket creator (denormalized from `User`)
pub user_last_name: String,
}
@@ -62,11 +72,17 @@ pub struct TicketResponse {
/// Use [`filter_user()`](`crate::handlers::auth::filter_user`) to convert to [`FilteredUser`] for responses.
#[derive(Deserialize, Serialize, PartialEq, Debug, Clone, sqlx::FromRow)]
pub struct User {
/// Unique user identifier
pub id: i16,
/// User's last name
pub last_name: String,
/// User's first name
pub first_name: String,
/// Unique login username (must be unique in the database)
pub username: String,
/// Whether this user has administrator privileges
pub is_admin: bool,
/// Argon2 password hash (NEVER expose to clients)
pub pwd: String,
}
@@ -82,9 +98,13 @@ pub struct User {
/// - `room`: Room number where the issue is located
#[derive(Deserialize, Serialize, Debug)]
pub struct TicketCreateScheme {
/// Ticket category/type
pub category: String,
/// Subject line for the ticket
pub betreff: String,
/// Detailed problem description
pub description: String,
/// Room number where the issue is located
pub room: i16,
}
@@ -97,6 +117,7 @@ pub struct TicketCreateScheme {
/// - `status`: New ticket status (e.g., "open", "in_progress", "resolved")
#[derive(Deserialize, Serialize, Debug)]
pub struct TicketUpdateScheme {
/// New ticket status (e.g., "open", "in_progress", "resolved", "archived")
pub status: String,
}
@@ -113,11 +134,17 @@ pub struct TicketUpdateScheme {
/// - `new_pwd`: New password (empty string = keep existing password)
#[derive(Deserialize, Serialize, Debug)]
pub struct UserUpdateScheme {
/// User ID to update
pub id: i16,
/// Updated user first name
pub first_name: String,
/// Updated user last name
pub last_name: String,
/// Updated login username
pub username: String,
/// New admin privilege status
pub make_admin: bool,
/// New password (empty string = keep existing password)
pub new_pwd: String,
}
@@ -134,10 +161,15 @@ pub struct UserUpdateScheme {
/// - `pwd`: Plain text password (hashed on server)
#[derive(Deserialize, Serialize, Debug, sqlx::FromRow)]
pub struct UserCreateScheme {
/// User's first name
pub first_name: String,
/// User's last name
pub last_name: String,
/// Unique username for login
pub username: String,
/// Whether to grant admin privileges
pub is_admin: bool,
/// Plain text password (hashed on server before storage)
pub pwd: String,
}
@@ -150,7 +182,9 @@ pub struct UserCreateScheme {
/// The password is never stored in plain text - only the Argon2 hash is persisted.
#[derive(Deserialize, Serialize, Debug)]
pub struct LoginScheme {
/// Username for login
pub username: String,
/// Plain text password (verified against stored Argon2 hash)
pub pwd: String,
}
@@ -161,10 +195,15 @@ pub struct LoginScheme {
/// to prevent leaking sensitive data.
#[derive(Debug, Clone, Serialize)]
pub struct FilteredUser {
/// Unique user identifier
pub id: i16,
/// User's first name
pub first_name: String,
/// User's last name
pub last_name: String,
/// Login username
pub username: String,
/// Whether user has admin privileges
pub is_admin: bool,
}
+23 -5
View File
@@ -1,7 +1,10 @@
use std::sync::Arc;
use axum::{
Router, middleware,
Router,
http::StatusCode,
middleware,
response::IntoResponse,
routing::{get, post},
};
@@ -10,13 +13,22 @@ use crate::{
cookie::validation::{validate_admin, validate_token},
handlers::{
auth::{
check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id, get_users, login, logout,
setup_initial_admin, update_user,
check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id,
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},
},
};
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.
///
/// The router is organized in layers for proper middleware application. Uses [`AppState`]
@@ -63,6 +75,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/api/users/{id}",
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(
state.clone(),
validate_admin,
@@ -79,10 +92,15 @@ pub fn create_router(state: Arc<AppState>) -> Router {
validate_token,
));
Router::new()
.merge(protected_routes)
let public_routes = Router::new()
.route("/api/login", post(login))
.route("/api/check-admin", get(check_admin_exists))
.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)
}
-14
View File
@@ -1,14 +0,0 @@
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
@@ -0,0 +1,127 @@
# ============================================================
# 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
@@ -0,0 +1,66 @@
# ============================================================
# 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
@@ -0,0 +1,58 @@
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:
+3
View File
@@ -27,3 +27,6 @@ chrono = { workspace = true }
chrono-tz = "0.10.4"
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
@@ -5,7 +5,7 @@
<link data-trunk rel="rust" data-bin="bin" />
<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" />
<link data-trunk rel="copy-dir" href="src/assets" data-target-path="assets" />
<meta charset="utf-8" />
<title>Yew App</title>
</head>
+2 -2
View File
@@ -109,7 +109,7 @@ pub fn protected_route(props: &ProtectedRouteProps) -> Html {
AuthState {
is_authenticated: None,
..
} => html! { <div>{ "Loading..." } </div> },
} => html! { <div>{ "Wird geladen..." } </div> },
AuthState {
is_authenticated: Some(false),
..
@@ -126,7 +126,7 @@ pub fn protected_route(props: &ProtectedRouteProps) -> Html {
Some(false) => {
html! { <Redirect<crate::Route> to={crate::Route::PermissionDenied}/> }
}
None => html! { <div>{ "Checking permissions..." }</div> },
None => html! { <div>{ "Überprüfe Berechtigungen..." }</div> },
}
} else {
props.children.clone().into()
+57 -3
View File
@@ -1,24 +1,78 @@
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 = {
Callback::from(|_| {
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">
{"🌙"}
<button {onclick} class="sidebar-button theme-toggle-btn">
{icon_html}
</button>
}
}
+2 -3
View File
@@ -138,7 +138,6 @@ fn sidebar_shell(props: &SidebarShellProps) -> Html {
}
}
/// Props for the AdminCheckWrapper component.
#[derive(Properties, PartialEq)]
pub struct AdminCheckWrapperProps {
@@ -193,10 +192,10 @@ fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html {
}
match *admin_exists {
None => html! { <div>{ "Loading..." }</div> },
None => html! { <div>{ "Wird geladen..." }</div> },
Some(false) => {
navigator.push(&Route::Setup);
html! { <div>{ "Redirecting to setup..." }</div> }
html! { <div>{ "Wird weitergeleitet zur Einrichtung..." }</div> }
}
Some(true) => props.children.clone().into(),
}
+4
View File
@@ -1,5 +1,9 @@
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() {
yew::Renderer::<App>::new().render();
}
+3 -3
View File
@@ -82,7 +82,7 @@ pub fn home_component() -> Html {
<div class="rundbg home">
<crate::utilities::TicketCount/>
<p class="login-status">{ "You are logged in as: " }</p>
<p class="login-status">{ "Sie sind angemeldet als: " }</p>
<p class="name">{ &*name }</p>
</div>
}
@@ -101,7 +101,7 @@ pub fn home_component() -> Html {
/// ```
#[component(NotFound)]
pub fn not_found_component() -> Html {
let message = "404 Not found";
let message = "404 Nicht gefunden";
html! {
<div>
<h1>{&message}</h1>
@@ -136,6 +136,6 @@ pub fn denied_component() -> Html {
#[component(Icon)]
pub fn icon_component() -> Html {
html! {
<img src="assets/csg.png" class="csg-icon" />
<img src="/assets/csg.png" class="csg-icon" />
}
}
+1
View File
@@ -6,6 +6,7 @@
//! By re-exporting them here, other parts of the application can import page
//! components more conveniently using `crate::pages::*`.
pub mod basic_pages;
pub mod qr;
pub mod setup;
pub mod sidebar;
pub mod ticket;
+118
View File
@@ -0,0 +1,118 @@
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 -21
View File
@@ -20,6 +20,7 @@ pub struct AdminSetupScheme {
pub first_name: String,
pub last_name: String,
pub username: String,
pub is_admin: bool,
pub pwd: String,
}
@@ -103,7 +104,7 @@ pub fn initial_admin_setup() -> Html {
}
if !*admin_check_done {
return html! { <div>{ "Checking..." }</div> };
return html! { <div>{ "Wird überprüft..." }</div> };
}
let onsubmit = {
@@ -121,17 +122,17 @@ pub fn initial_admin_setup() -> Html {
e.prevent_default();
if (*pwd).is_empty() || (*pwd_confirm).is_empty() {
error.set("Password fields cannot be empty".to_string());
error.set("Passwortfelder dürfen nicht leer sein".to_string());
return;
}
if *pwd != *pwd_confirm {
error.set("Passwords do not match".to_string());
error.set("Passwörter stimmen nicht überein".to_string());
return;
}
if (*username).is_empty() {
error.set("Username cannot be empty".to_string());
error.set("Benutzername darf nicht leer sein".to_string());
return;
}
@@ -154,6 +155,7 @@ pub fn initial_admin_setup() -> Html {
first_name: first_name_val,
last_name: last_name_val,
username: username_val,
is_admin: true,
pwd: pwd_val,
};
@@ -172,10 +174,10 @@ pub fn initial_admin_setup() -> Html {
navigator.push(&crate::Route::Login);
}
Ok(r) => {
let text = r.text().await.unwrap_or_else(|_| "unknown".into());
error.set(format!("HTTP {}: {}", r.status(), text));
let text = r.text().await.unwrap_or_else(|_| "Unbekannt".into());
error.set(text);
}
Err(err) => error.set(format!("Network error: {}", err)),
Err(err) => error.set(format!("Netzwerkfehler: {}", err)),
}
});
})
@@ -184,16 +186,16 @@ pub fn initial_admin_setup() -> Html {
html! {
<div class="setup-container rundbg">
<div class="setup-box">
<h1>{ "Initial Admin Setup" }</h1>
<p>{ "Create your first administrator account" }</p>
<h1>{ "Admin-Einrichtung" }</h1>
<p>{ "Erstellen Sie Ihr erstes Administratorkonto" }</p>
<form {onsubmit} class="setup-form">
<div class="form-group">
<label for="first_name">{ "First Name:" }
<label for="first_name">{ "Vorname:" }
<input
id="first_name"
type="text"
placeholder="First name"
placeholder="Vorname"
value={(*first_name).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -204,11 +206,11 @@ pub fn initial_admin_setup() -> Html {
</div>
<div class="form-group">
<label for="last_name">{ "Last Name:" }
<label for="last_name">{ "Nachname:" }
<input
id="last_name"
type="text"
placeholder="Last name"
placeholder="Nachname"
value={(*last_name).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -219,11 +221,11 @@ pub fn initial_admin_setup() -> Html {
</div>
<div class="form-group">
<label for="username">{ "Username:" }
<label for="username">{ "Benutzername:" }
<input
id="username"
type="text"
placeholder="Username"
placeholder="Benutzername"
value={(*username).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -234,11 +236,11 @@ pub fn initial_admin_setup() -> Html {
</div>
<div class="form-group">
<label for="password">{ "Password:" }
<label for="password">{ "Passwort:" }
<input
id="password"
type="password"
placeholder="Password"
placeholder="Passwort"
value={(*pwd).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -249,11 +251,11 @@ pub fn initial_admin_setup() -> Html {
</div>
<div class="form-group">
<label for="pwd_confirm">{ "Confirm Password:" }
<label for="pwd_confirm">{ "Passwort bestätigen:" }
<input
id="pwd_confirm"
type="password"
placeholder="Confirm password"
placeholder="Passwort bestätigen"
value={(*pwd_confirm).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -264,7 +266,7 @@ pub fn initial_admin_setup() -> Html {
</div>
<button type="submit" disabled={*loading} class="submit-btn">
{ if *loading { "Creating..." } else { "Create Admin Account" } }
{ if *loading { "Wird erstellt..." } else { "Administratorkonto erstellen" } }
</button>
if !error.is_empty() {
@@ -272,7 +274,7 @@ pub fn initial_admin_setup() -> Html {
}
if *success {
<p class="success-message" style="color:green">{ "Admin account created successfully! Redirecting to login..." }</p>
<p class="success-message" style="color:green">{ "Administratorkonto erfolgreich erstellt! Wird weitergeleitet zum Login..." }</p>
}
</form>
</div>
+11 -8
View File
@@ -234,10 +234,10 @@ pub fn ticket_menu() -> Html {
html! {
<ul class="submenu" role="menu">
<li role="none">
<Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Submit Ticket" }</span></Link<crate::Route>>
<Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Ticket einreichen" }</span></Link<crate::Route>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "View Tickets" }</span></Link<crate::Route>>
<Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "Tickets anzeigen" }</span></Link<crate::Route>>
</li>
</ul>
}
@@ -289,7 +289,7 @@ pub fn users_menu() -> Html {
onclick={on_toggle}
aria-expanded={open.to_string()}
>
{ "Users" }
{ "Benutzer" }
{ if open { "" } else { "" } }
</button>
@@ -298,10 +298,10 @@ pub fn users_menu() -> Html {
html! {
<ul class="submenu" role="menu">
<li role="none">
<Link<crate::Route> to={crate::Route::Register}><span role="menuitem">{ "Create User" }</span></Link<crate::Route>>
<Link<crate::Route> to={crate::Route::Register}><span role="menuitem">{ "Benutzer erstellen" }</span></Link<crate::Route>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "View Users" }</span></Link<crate::Route>>
<Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "Benutzer anzeigen" }</span></Link<crate::Route>>
</li>
</ul>
}
@@ -398,7 +398,7 @@ pub fn sidebar(props: &SidebarComponentProps) -> Html {
};
match *is_admin {
None => html! { <div class="sidebar-loading">{ "Lade..." }</div> },
None => html! { <div class="sidebar-loading">{ "Wird geladen..." }</div> },
// Non-admin: render a condensed user sidebar (no diagnostics, limited links)
Some(false) => {
@@ -423,6 +423,7 @@ pub fn sidebar(props: &SidebarComponentProps) -> Html {
</li>
<TicketMenu/>
<li class="logout-item">
<crate::darkmode::ThemeToggle/>
<button
class="sidebar-button"
onclick={on_logout.clone()}
@@ -434,7 +435,7 @@ pub fn sidebar(props: &SidebarComponentProps) -> Html {
</nav>
</SidebarStateProvider>
}
},
}
// Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => {
@@ -462,6 +463,7 @@ pub fn sidebar(props: &SidebarComponentProps) -> Html {
<Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>>
<li class="logout-item">
<crate::darkmode::ThemeToggle/>
<button
class="sidebar-button"
onclick={on_logout.clone()}
@@ -473,6 +475,7 @@ pub fn sidebar(props: &SidebarComponentProps) -> Html {
</nav>
</SidebarStateProvider>
}
},
}
}
}
+30 -25
View File
@@ -180,7 +180,7 @@ pub fn submit_ticket_component() -> Html {
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
if room.is_none() {
status.set(Some("Invalid room".into()));
status.set(Some("Ungültiger Raum".into()));
return;
}
let category = (*category).clone();
@@ -188,7 +188,7 @@ pub fn submit_ticket_component() -> Html {
let description = (*description).clone();
let room = room.unwrap();
if !valid_rooms.contains(&room) {
status.set(Some("Room not allowed".into()));
status.set(Some("Raum nicht erlaubt".into()));
return;
}
let status = status.clone();
@@ -208,9 +208,12 @@ pub fn submit_ticket_component() -> Html {
.expect("Failed to build request");
match request.send().await {
Ok(response) if response.status() == 200 => status.set(Some("Success".into())),
Ok(response) => status.set(Some(format!("Error: {}", response.status()))),
Err(err) => status.set(Some(format!("Network error: {}", err))),
Ok(response) if response.status() == 200 => status.set(Some("Erfolg".into())),
Ok(response) => {
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into());
status.set(Some(text));
},
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
}
});
})
@@ -313,14 +316,14 @@ pub fn submit_ticket_component() -> Html {
{
if !room_valid {
html! {
<p class="warning" >{ "Ungültiger / nicht erlaubter Raum (z. B. 101, K12, D5)" }</p>
<p class="warning" >{ "Ungültiger/nicht erlaubter Raum (z. B. 101, K12, D5)" }</p>
}
} else {
html! {}
}
}
<br/>
<button type="submit">{ "Send" }</button>
<button type="submit">{ "Absenden" }</button>
<Link<crate::Route> to={crate::Route::AllTickets}>{ "Tickets ansehen" }</Link<crate::Route>>
@@ -448,9 +451,12 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
.expect("Failed to construct request");
match request.send().await {
Ok(response) if response.status() == 200 => error.set(Some("Success".into())),
Ok(response) => error.set(Some(format!("Error: {}", response.status()))),
Err(err) => error.set(Some(format!("Network error: {}", err))),
Ok(response) if response.status() == 200 => error.set(Some("Erfolg".into())),
Ok(response) => {
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into());
error.set(Some(text));
},
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
});
})
@@ -488,14 +494,13 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
match request.send().await {
Ok(resp) if resp.status() == 200 || resp.status() == 204 => {
// remove local state or navigate away
ticket_state.set(None); // clears the shown item
ticket_state.set(None);
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
let txt = resp.text().await.unwrap_or_else(|_| "Unbekannt".into());
delete_error.set(Some(txt));
}
Err(err) => delete_error.set(Some(format!("Network error: {}", err))),
Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))),
}
deleting.set(false);
});
@@ -503,9 +508,9 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
};
if *loading {
html! {<p>{ "Loading" }</p>}
html! {<p>{ "Wird geladen" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
html! { <p>{ format!("Fehler: {}", e) }</p> }
} else if let Some(t) = &*ticket {
html! {
<div class="rundbg">
@@ -549,7 +554,7 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
</div>
}
} else {
html! { <p>{ "No ticket found." }</p> }
html! { <p>{ "Kein Ticket gefunden." }</p> }
}
}
@@ -659,9 +664,9 @@ pub fn all_tickets_component() -> Html {
}
if *loading {
html! {<p>{ "Loading" }</p>}
html! {<p>{ "Wird geladen" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
html! { <p>{ format!("Fehler: {}", e) }</p> }
} else {
html! {
<ul class= "postits">
@@ -742,17 +747,17 @@ pub fn archived_tickets_component() -> Html {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await {
Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("parse error: {}", e))),
Err(e) => error.set(Some(format!("Parsefehler: {}", e))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
error.set(Some(format!("Status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
loading.set(false);
});
@@ -792,9 +797,9 @@ pub fn archived_tickets_component() -> Html {
}
if *loading {
html! {<p>{ "Loading" }</p>}
html! {<p>{ "Wird geladen" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
html! { <p>{ format!("Fehler: {}", e) }</p> }
} else {
html! {
<ul class="postits">
+26 -23
View File
@@ -181,9 +181,12 @@ pub fn register_component() -> Html {
.expect("Error building request");
match request.send().await {
Ok(response) if response.status() == 200 => status.set(Some("Success".into())),
Ok(response) => status.set(Some(format!("Error: {}", response.status()))),
Err(err) => status.set(Some(format!("Network error: {}", err))),
Ok(response) if response.status() == 200 => status.set(Some("Erfolg".into())),
Ok(response) => {
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into());
status.set(Some(text));
}
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
}
});
})
@@ -326,10 +329,10 @@ pub fn login_component() -> Html {
navigator.push(&crate::Route::Home);
}
Ok(r) => {
let text = r.text().await.unwrap_or_else(|_| "unknown".into());
error.set(format!("HTTP {}: {}", r.status(), text));
let text = r.text().await.unwrap_or_else(|_| "Unbekannt".into());
error.set(text);
}
Err(err) => error.set(format!("Network error: {}", err)),
Err(err) => error.set(format!("Netzwerkfehler: {}", err)),
}
});
})
@@ -340,7 +343,7 @@ pub fn login_component() -> Html {
<h1 class="headline">{ "Anmeldung" }</h1>
<form {onsubmit} class="rundbg login">
<input
placeholder="username"
placeholder="Benutzername"
value={(*username).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -349,14 +352,14 @@ pub fn login_component() -> Html {
/>
<input
type="password"
placeholder="password"
placeholder="Passwort"
value={(*pwd).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
pwd.set(input.value());
})}
/>
<button type="submit" disabled={*loading}>{ if *loading { "Logging in..." } else { "Login" } }</button>
<button type="submit" disabled={*loading}>{ if *loading { "Anmeldung läuft..." } else { "Anmelden" } }</button>
if !error.is_empty() { <p style="color:red">{(*error).clone()}</p> }
</form>
</div>
@@ -428,9 +431,9 @@ pub fn all_users_component() -> Html {
}
if *loading {
html! {<p>{ "Loading" }</p>}
html! {<p>{ "Wird geladen" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
html! { <p>{ format!("Fehler: {}", e) }</p> }
} else {
html! {
<ul class="postits">
@@ -637,7 +640,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
// confirm
if !web_sys::window()
.and_then(|w| {
w.confirm_with_message("Are you sure you want to delete this item?")
w.confirm_with_message("Möchten Sie diesen Eintrag wirklich löschen?")
.ok()
})
.unwrap_or(false)
@@ -653,19 +656,18 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let user_state = user_state.clone();
spawn_local(async move {
let url = format!("/api/users/{}", id); // or /api/tickets/{}
let url = format!("/api/users/{}", id);
let req = Request::delete(&url).credentials(web_sys::RequestCredentials::Include);
match req.send().await {
Ok(resp) if resp.status() == 200 || resp.status() == 204 => {
// remove local state or navigate away
user_state.set(None); // clears the shown item
user_state.set(None);
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
let txt = resp.text().await.unwrap_or_else(|_| "Unbekannt".into());
delete_error.set(Some(txt));
}
Err(err) => delete_error.set(Some(format!("Network error: {}", err))),
Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))),
}
deleting.set(false);
});
@@ -673,9 +675,9 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
};
if *loading {
html! {<p>{ "Loading" }</p>}
html! {<p>{ "Wird geladen" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
html! { <p>{ format!("Fehler: {}", e) }</p> }
} else if let Some(u) = &*user {
html! {
<div class="rundbg">
@@ -686,7 +688,8 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
<p><strong>{ "Ist Admin: " }</strong>{ u.is_admin }</p>
</div>
<h1>{ format!("User #{}", u.id) }</h1>
<crate::pages::qr::QrManager user_id={u.id}/>
<form onsubmit={onsubmit}>
<div>
<label>{ "Vorname" }
@@ -743,7 +746,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
<button type="submit" disabled={*saving}>{ if *saving { "Speichern..." } else { "Speichern" } }</button>
if *save_success {
<p style="color:green">{ "Updated successfully" }</p>
<p style="color:green">{ "Erfolgreich aktualisiert" }</p>
}
if let Some(err) = &*save_error {
<p style="color:red">{ err.clone() }</p>
@@ -761,6 +764,6 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
</div>
}
} else {
html! { <p>{ "No ticket found." }</p> }
html! { <p>{ "Kein Benutzer gefunden." }</p> }
}
}
+11 -11
View File
@@ -225,17 +225,17 @@ pub fn ticket_count_component() -> Html {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await {
Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("parse error: {}", e))),
Err(e) => error.set(Some(format!("Parsefehler: {}", e))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
error.set(Some(format!("Status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
loading.set(false);
});
@@ -275,9 +275,9 @@ pub fn ticket_count_component() -> Html {
}
if *loading {
html! {<p>{ "Loading" }</p>}
html! {<p>{ "Wird geladen" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
html! { <p>{ format!("Fehler: {}", e) }</p> }
} else {
let status_conditions = |t: &Ticket| t.status == "ToDo" || t.status == "InProgress";
let count = tickets
@@ -342,17 +342,17 @@ pub fn submit_stats_component() -> Html {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<TicketPartial>>().await {
Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("parse error: {}", e))),
Err(e) => error.set(Some(format!("Parsefehler: {}", e))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
error.set(Some(format!("Status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
loading.set(false);
});
@@ -372,7 +372,7 @@ pub fn submit_stats_component() -> Html {
}
}
let weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
let (max_idx, _max_val) = counts
.iter()
.enumerate()
@@ -383,12 +383,12 @@ pub fn submit_stats_component() -> Html {
html! {
<div class="diagnostics-section">
if *loading {
<p>{ "Loading..." }</p>
<p>{ "Wird geladen..." }</p>
}
if let Some(e) = &*error {
<p style="color: red;">{ e.clone() }</p>
}
<h3>{ "Tickets per weekday" }</h3>
<h3>{ "Tickets pro Wochentag" }</h3>
<div class="weekday-chart">
<div class="weekday-bars">
{ for (0..7).map(|i| {
+10 -3
View File
@@ -8,8 +8,11 @@
--border-radius: 6px;
--rundbgbackgroundcolor: #a0a0a0;
--sidebar-font: #fffefe;
--color: #ffffff;
--color: #000000;
--color-container: #f0f0f0;
--color-text: #000000;
--color-placeholder: #757575;
--color-input: #1a1a1a;
}
[data-theme='dark'] {
@@ -23,5 +26,9 @@
--rundbgbackgroundcolor: #505050;
--sidebar-font: #000000;
--color: #000000;
--color-container: #1a1a1a;
--color-text: #000000;
--color-placeholder: #a0a0a0;
--color-input: #f0f0f0;
}
@@ -0,0 +1,27 @@
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;
}
}
+25 -6
View File
@@ -1,6 +1,11 @@
@use "../variables" as *;
@use "../mixins" as *;
:root {
color-scheme: dark light;
}
.sidebar {
width: 260px;
background: var(--color-sidebar);
@@ -19,7 +24,7 @@
text-decoration: none;
display: block;
padding: 8px 12px;
border-radius: $border-radius;
border-radius: var(--border-radius);
transition: background 0.2s ease-in-out;
&:hover { background: rgba(255,255,255,0.1); }
}
@@ -29,7 +34,7 @@
.submenu {
margin-left: 8px;
background: rgba(255,255,255,0.05);
border-radius: $border-radius;
border-radius: var(--border-radius);
padding: 6px;
li { padding: 4px 0; }
}
@@ -49,6 +54,13 @@
width: 100%;
cursor: pointer;
text-align: left;
margin-top: var(--spacing-sm);
&.theme-toggle-btn {
display: flex;
justify-content: center;
align-items: center;
}
}
.sidebar-header {
@@ -56,7 +68,7 @@
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: $spacing-md;
margin-bottom: var(--spacing-md);
.home {
flex-grow: 1;
@@ -72,7 +84,7 @@
display: none;
background: transparent;
border: none;
color: #fff;
color: var(--sidebar-font);
cursor: pointer;
padding: 8px;
border-radius: 50%;
@@ -88,7 +100,7 @@
svg {
width: 20px;
height: 20px;
stroke: #fff;
stroke: var(--sidebar-font);
}
@media (max-width: 768px) {
@@ -116,7 +128,7 @@
position: fixed;
top: 1rem;
left: 1rem;
z-index: 998;
z-index: 999;
width: 48px;
height: 48px;
border-radius: 50%;
@@ -141,6 +153,11 @@
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 {
@@ -172,4 +189,6 @@
pointer-events: auto;
}
}
}
+13 -14
View File
@@ -8,20 +8,6 @@
@use "components/home";
@use "components/icon";
:root {
--color-bg: #ffffff;
--color-container: #ffffff;
--color-text: #111827;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #121212;
--color-container: #333333;
--color-text: #e2e2e2;
}
}
body {
background: var(--color-bg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
@@ -104,10 +90,12 @@ body {
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;
}
@@ -137,4 +125,15 @@ body {
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.