Author SHA1 Message Date
schn33fuchs b404ff5bc9 Path
Fixed layout issues and joke message
2026-06-06 18:31:05 +02:00
schn33fuchs c3b7321005 Styling
The ticket backgrounds have color depending on background and some
improvements
2026-06-06 18:18:22 +02:00
TheDiluTek dfb031949b view tickets 2026-06-06 17:25:12 +02:00
TheDiluTek b030e133a2 Merge branch 'main' of https://git.raffauf-clan.de/schn33fuchs/ticketsystem 2026-06-06 17:01:58 +02:00
schn33fuchs 810d554cb9 Fixed bug
You could set the password to be an empty string
2026-06-05 21:35:54 +02:00
TheDiluTek 2e6bd2a781 bisschen sidebar schön. problem gefunden: es reicht wenn man benutzernamen eingibt. man logt sich auch ohne passwort ein 2026-06-04 21:48:47 +02:00
schn33fuchs 390630886e Merge pull request 'Icon' (#42) from Icon into main
Reviewed-on: #42
2026-06-03 16:57:13 +02:00
schn33fuchs 0ee20e2833 Icon in the corner 2026-06-03 16:10:37 +02:00
schn33fuchs b7f8df624c Favion
Its there now
2026-06-03 15:53:57 +02:00
schn33fuchs 8a9c9efbcc Icons 2026-06-03 15:52:10 +02:00
schn33fuchs c31375573f .gitignore
update
2026-06-03 15:48:52 +02:00
TheDiluTek b2daed8e99 bug fixes 2026-06-03 15:13:09 +02:00
TheDiluTek 076bd6c545 mehr von login seite gemacht, sah voll gut aus aber hab irgendwas kaputt gemacht und finds nicht 2026-05-30 23:45:39 +02:00
TheDiluTek a42897de9f anmeldung überarbeitet, login braucht noch mehr gap als die anderen inputs haben (command?) 2026-05-30 14:03:25 +02:00
TheDiluTek 3154e3de44 login schön gemacht 2026-05-30 13:29:41 +02:00
schn33fuchs bf0197adae .gitignore 2026-05-13 16:58:44 +02:00
schn33fuchs c6a4a24fb6 Sam did something 2026-05-13 13:31:56 +02:00
schn33fuchs 52387f7333 Fun times 2026-05-13 12:18:55 +02:00
schn33fuchs 6eb84d24e0 Archive
There is now an archive for tickets
2026-05-11 20:46:06 +02:00
schn33fuchs d9ef5746a2 Home page and sidebar update
Home page now shows who is logged in and the sidebar has a button to
home
2026-05-11 13:03:00 +02:00
schn33fuchs 535d940857 Removed unneeded imports 2026-05-10 10:26:16 +02:00
schn33fuchs 33df619c8d Cargo doc fixes
It's good now
2026-05-10 10:18:45 +02:00
schn33fuchs b87a6ff297 Docs.rs comments
Comments for generating the docs with cargo doc
2026-05-09 23:00:15 +02:00
schn33fuchs 8ddfe2ba14 Frontend initial admin setup 2026-05-09 17:33:24 +02:00
schn33fuchs 26ac32db21 Backend initial admin setup 2026-05-09 17:33:14 +02:00
schn33fuchs 99bcbaf3b0 Trunk 2026-05-06 15:56:55 +02:00
schn33fuchs 72f126c7da Some minor updates 2026-05-06 15:56:21 +02:00
schn33fuchs d41b31b5af Diagnosics styling
It looks nice now
2026-05-05 16:34:09 +02:00
schn33fuchs 7bde1470a1 Logout button
And styling for it
2026-05-04 16:45:52 +02:00
schn33fuchs 331552b656 Some bugfixes 2026-05-04 16:45:39 +02:00
schn33fuchs 654b55a2a4 Put some api calls behind admin access 2026-05-02 19:15:40 +02:00
schn33fuchs 931bd27c65 Sidebar fix
Styles now actually get applied and sidebar doesn't block content
Also the login page doesn't have a sidebar now
2026-05-02 19:09:41 +02:00
schn33fuchs 4a3d432e9a Fixed dependency mismatch 2026-05-02 18:42:52 +02:00
schn33fuchs daeabad01e Packed both crates into one 2026-05-02 16:25:17 +02:00
schn33fuchs 1ba2d20786 Clippy satisfied 2026-05-02 16:18:28 +02:00
schn33fuchs 31162bb8c9 Minor Improvements 2026-05-02 16:16:09 +02:00
schn33fuchs 410bf95f4a Sidebar update 2026-05-02 15:24:03 +02:00
schn33fuchs 1017f495f8 Tickets per rooms shown
In diagnostics
2026-05-02 15:18:11 +02:00
schn33fuchs 38603dc84b Room parsing from text
K1 -> -1
D1 -> 1001
1 -> 1
2026-05-02 15:01:26 +02:00
schn33fuchs 504569e305 Average of tickets submitted per weekday
Avvailable in the diagnostics
2026-05-02 15:00:46 +02:00
schn33fuchs 37c00b0190 View still active tickets 2026-05-02 13:00:30 +02:00
schn33fuchs 4bdf3dc04a Updated gitignore
node_modules now ignored
2026-05-02 12:32:38 +02:00
schn33fuchs 9eb843a786 Sidebar styling
In general styling capabilities with scsss
2026-05-02 12:27:27 +02:00
schn33fuchs fa77190555 Sidebar logic 2026-05-02 12:26:59 +02:00
schn33fuchs 3d1366e704 Pages link to each other 2026-05-02 10:41:11 +02:00
schn33fuchs 6d8fcc6b53 Home page changes depending on role 2026-05-02 10:23:50 +02:00
schn33fuchs d541cf18b1 Minor bugfixes 2026-05-02 10:23:25 +02:00
schn33fuchs 12c618f8dd Bux fix
Bug: when getting user by id, it would try to parse i16 to i32, type
changed
2026-05-01 18:12:24 +02:00
schn33fuchs 92fdb87cf0 Timezone now CET 2026-05-01 18:11:40 +02:00
schn33fuchs 4fc1c2eeb8 Non admin only see their tickets 2026-05-01 17:07:20 +02:00
schn33fuchs fdd2e2a757 admin pages
Pages can now be locked behind admin privileges
2026-05-01 16:47:42 +02:00
schn33fuchs 802e5876f7 Ticket now shows who submitted it 2026-05-01 16:46:56 +02:00
schn33fuchs e54be14526 When not logged in redirection to login page
Every page is locked behind a jwt, when it is not supplied neither other
pages not api calls will work
2026-05-01 16:18:15 +02:00
schn33fuchs b672fe9768 Deleting users now possible 2026-04-29 21:52:03 +02:00
schn33fuchs fd9e429760 Deleting tickets now possible 2026-04-29 21:48:57 +02:00
schn33fuchs a6f818505b Implemented updating users
at /users/{id}
2026-04-29 21:28:36 +02:00
schn33fuchs 63af32c4af Show user by ID implemented
At /users/{id},
Also minor bugfixes and improvements
2026-04-29 20:54:14 +02:00
schn33fuchs 6dfa21a005 Login and Showing of all Users implemented
At /login and /users
2026-04-29 20:41:31 +02:00
schn33fuchs aa1c11047e User creation now possible
at /register
2026-04-29 19:47:16 +02:00
schn33fuchs 50c0b99f20 Update ticket status now possible
Also bugfixes and api is now called over the same url as frontend
2026-04-29 14:22:45 +02:00
schn33fuchs 01c33f4a1d Avvailable from external source
pages and backend are now available from everywhere on the network
2026-04-29 14:21:55 +02:00
schn33fuchs de10d694e1 View every ticket now on /tickets 2026-04-28 21:40:55 +02:00
schn33fuchs 21aa213ff4 Viewing ticket by ID
This is now possible by going to /tickets/{id}
2026-04-28 21:23:40 +02:00
schn33fuchs ac7b2e59b9 Added Page with ticket submitting functionality 2026-04-28 20:44:28 +02:00
schn33fuchs dac2d91213 Minor Bugfixes 2026-04-28 20:42:18 +02:00
schn33fuchs ce86d8bc8f Now functioning frontend
Changed some things to make trunk work
2026-04-28 06:36:01 +02:00
schn33fuchs 76b4ccf943 Updated gitignore 2026-04-27 12:49:21 +02:00
schn33fuchs 7a5a725ba4 Added sample page
Not working yet
2026-04-27 12:48:52 +02:00
schn33fuchs 6ea3a512e8 Updated .gitignore 2026-04-27 12:12:52 +02:00
schn33fuchs 2578d7cf51 Added frontend project 2026-04-27 12:10:31 +02:00
schn33fuchs 6e4439e7fc Moved migrations into backend 2026-04-27 12:09:58 +02:00
schn33fuchs f63131ebc7 User listing, deleting and updating 2026-04-25 14:33:02 +02:00
schn33fuchs 6c1c13e088 Cors layer added
header, origin and method limitations
2026-04-25 14:32:22 +02:00
schn33fuchs 834a0b7aa4 Get currnet user function 2026-04-25 13:33:41 +02:00
schn33fuchs c4d374dbfe Added token validation 2026-04-25 13:21:31 +02:00
schn33fuchs 616efa5282 Fixed name mismatches and missing feature flag 2026-04-25 13:01:41 +02:00
schn33fuchs 0ce17fe805 Routes for auth handling
Also db and structs are now identical
2026-04-24 19:53:43 +02:00
schn33fuchs 51b6f89df2 Added user login, logout and creation functionality
Also minor changes to ticketing and AppState
2026-04-24 19:46:02 +02:00
schn33fuchs fe04483e76 Updated user scheme
Forgot password and username
2026-04-24 17:49:52 +02:00
schn33fuchs b94a94a28e Added Json Web Tokens
Also down migration for users
2026-04-24 17:49:32 +02:00
schn33fuchs 5e643503aa Added ticket edit functionality
As said, also minor change to database for testing purposes
2026-04-23 20:13:09 +02:00
schn33fuchs b905ffc9b1 Changed enums to string
String is easyer to decode and the sendable data in the frontend will be
limited.
2026-04-23 16:51:21 +02:00
schn33fuchs d890255631 Added router for routing requests
1. Duh
2. Also minor bugfix
2026-04-22 21:27:08 +02:00
schn33fuchs 20a0534c76 Added selecting specific ticket by id 2026-04-22 20:58:52 +02:00
schn33fuchs 8b34dac813 Added functions for adding, deleting and showing tickets
As said, functions got implemented. Also minor changes to migration
layout and dependencies
2026-04-22 20:40:28 +02:00
schn33fuchs 1725d2538c Added status Struct 2026-04-22 18:45:02 +02:00
schn33fuchs a9e31e2fdf Implemeted Display trait for Category enum 2026-04-22 18:29:56 +02:00
48 changed files with 6793 additions and 105 deletions
+6 -2
View File
@@ -3,9 +3,12 @@
# will have compiled files and executables # will have compiled files and executables
debug/ debug/
target/ target/
data/
backend/debug/ backend/debug/
backend/target/ backend/target/
frontend/debug/
frontend/target/
frontend/dist/
frontend/node_modules/
# These are backup files generated by rustfmt # These are backup files generated by rustfmt
**/*.rs.bk **/*.rs.bk
@@ -17,9 +20,10 @@ backend/target/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ .idea/
# Added by cargo # Added by cargo
/target /target
.antigravitycli/
+1293 -54
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "ticketsystem"
version = "0.1.0"
edition = "2021"
readme = "README.md"
description = "A ticket system with backend and frontend components"
publish = false
[workspace]
members = ["backend", "frontend"]
resolver = "2"
[workspace.dependencies]
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
chrono = { version = "0.4.44", features = ["serde"] }
+6
View File
@@ -1,2 +1,8 @@
# Ticketsystem # 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
+10 -4
View File
@@ -5,9 +5,15 @@ edition = "2024"
[dependencies] [dependencies]
axum = "0.8.9" axum = "0.8.9"
serde = { version = "1.0.228", features = ["derive"] } axum-extra = { version = "0.12.6", features = ["cookie", "typed-header", "form"] }
serde_json = "1.0.149" serde = { workspace = true }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls"] } serde_json = { workspace = true }
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 = { version = "0.4.44", features = ["serde"] } chrono = { workspace = true }
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
argon2 = "0.5.3"
time = "0.3.47"
tower = "0.5.3"
tower-http = { version = "0.6.8", features = ["cors"] }
@@ -0,0 +1,2 @@
DROP TABLE tickets;
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
category VARCHAR(20) NOT NULL DEFAULT 'Sonstiges',
betreff VARCHAR(100),
description TEXT,
room SMALLINT,
status VARCHAR(15) NOT NULL DEFAULT 'ToDo',
date TIMESTAMP WITH TIME ZONE DEFAULT now(),
user_id SMALLINT DEFAULT 1
);
@@ -0,0 +1 @@
DROP TABLE users;
@@ -0,0 +1,14 @@
CREATE TABLE users (
id SMALLINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
last_name VARCHAR(30),
first_name VARCHAR(30),
username VARCHAR(30) UNIQUE,
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);
+80
View File
@@ -0,0 +1,80 @@
use axum::{Json, http::StatusCode};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, decode, encode};
use serde::{Deserialize, Serialize};
use crate::models::Claims;
/// Error response for JWT token operations.
///
/// Returned when token encoding or decoding fails. Used in error responses
/// for invalid or expired tokens.
///
/// # Fields
/// - `status`: HTTP status text (e.g., "error")
/// - `message`: Human-readable error description
#[derive(Debug, Deserialize, Serialize)]
pub struct Error {
pub status: &'static str,
pub message: String,
}
/// Encodes user information into a JSON Web Token (JWT).
///
/// 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
/// using the given encoding key.
///
/// # Arguments
/// - `header`: The JWT header, specifying the algorithm (e.g., HS256).
/// - `id`: The user ID (`String`) to be embedded as the subject (`sub`) claim.
/// - `key`: The `EncodingKey` used to sign the JWT.
///
/// # Returns
/// A `String` representing the encoded JWT.
///
/// # Panics
/// Panics if the token encoding fails for any reason (e.g., invalid key).
pub fn encode_token(header: &Header, id: String, key: &EncodingKey) -> String {
let now = chrono::Utc::now();
let expires = (now + chrono::Duration::minutes(60)).timestamp();
let claims: Claims = Claims {
sub: id,
issued: now.timestamp() as usize,
expires: expires as usize,
};
let token = encode(header, &claims, key);
return token.expect("token return failed");
}
/// Decodes and validates a JSON Web Token (JWT).
///
/// This function attempts to decode a JWT string, validate its signature and claims
/// using the provided decoding key. It specifically ignores expiration (`validate_exp`)
/// and "not before" (`validate_nbf`) claims during validation.
///
/// # Arguments
/// - `token`: The JWT string to decode.
/// - `key`: The `DecodingKey` used to verify the JWT's signature.
///
/// # Returns
/// - `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,
/// returns an `UNAUTHORIZED` status code along with a JSON error message.
pub fn decode_token(token: String, key: &DecodingKey) -> Result<Claims, (StatusCode, Json<Error>)> {
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
validation.validate_exp = false;
validation.validate_nbf = false;
validation.leeway = 0;
let claims = decode::<Claims>(&token, key, &validation)
.map_err(|err| {
let message = format!("Invalid Token: {}", err);
let error = Error {
status: "error",
message,
};
(StatusCode::UNAUTHORIZED, Json(error))
})?
.claims;
return Ok(claims);
}
+7
View File
@@ -0,0 +1,7 @@
//! This module aggregates and re-exports all cookie-related functionalities,
//! including JWT handling and validation middleware.
//!
//! It serves as a central point for managing session and authentication cookies
//! within the application.
pub mod jwt;
pub mod validation;
+211
View File
@@ -0,0 +1,211 @@
use std::sync::Arc;
use axum::{
Json,
body::Body,
extract::State,
http::{Request, StatusCode, header},
middleware::Next,
response::IntoResponse,
};
use axum_extra::extract::CookieJar;
use jsonwebtoken::DecodingKey;
use serde_json::json;
use crate::{AppState, cookie::jwt::decode_token, handlers::auth::filter_user, models::User};
/// 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
/// the `Authorization: Bearer` header), decodes and validates it. If valid, it fetches
/// the corresponding user from the database and inserts a `FilteredUser` into the
/// request extensions for subsequent handlers to use.
///
/// If the token is missing, invalid, or the user is not found, it returns an
/// appropriate error response (401 Unauthorized).
///
/// # Arguments
/// - `cookies`: The `CookieJar` from the request, used to extract the `token` cookie.
/// - `State(data)`: Application state containing `AppState` for database access and `token_secret`.
/// - `mut request`: The incoming HTTP request, which will have user data injected into its extensions.
/// - `next`: The next middleware or handler in the chain.
///
/// # Returns
/// - `Ok(impl IntoResponse)`: If validation succeeds, the request proceeds to the next handler.
/// - `Err((StatusCode, Json<serde_json::Value>))`: An error response if validation fails.
pub async fn validate_token(
cookies: CookieJar,
State(data): State<Arc<AppState>>,
mut request: Request<Body>,
next: Next,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let token = cookies
.get("token")
.map(|cookie| cookie.value().to_string())
.or_else(|| {
request
.headers()
.get(header::AUTHORIZATION)
.and_then(|header| header.to_str().ok())
.and_then(|value| {
if value.starts_with("Bearer ") {
Some(value[7..].to_owned())
} else {
None
}
})
});
let token = token.ok_or_else(|| {
let error = json!({
"status": "error",
"message": "Please provide a valid token"
});
(StatusCode::UNAUTHORIZED, Json(error))
})?;
let claims = decode_token(
token,
&DecodingKey::from_secret(data.env.token_secret.as_ref()),
)
.map_err(|(status, json_err)| {
let error = json!({
"status": json_err.status,
"message": json_err.message
});
(status, Json(error))
})?;
let uuid = (&claims.sub).parse::<i16>().map_err(|_| {
let error = json!({
"status": "error",
"message": "Invalid user id"
});
(StatusCode::UNAUTHORIZED, Json(error))
})?;
let user = sqlx::query_as::<_, User>(r#"SELECT * FROM users WHERE id = $1"#)
.bind(uuid)
.fetch_optional(&data.db)
.await
.map_err(|e| {
let error = json!({
"status": "error",
"message": format!("Database error: {}", e)
});
(StatusCode::INTERNAL_SERVER_ERROR, Json(error))
})?;
let user = user.ok_or_else(|| {
let error = json!({
"status": "error",
"message": "Invalid user"
});
(StatusCode::UNAUTHORIZED, Json(error))
})?;
request.extensions_mut().insert(filter_user(&user));
Ok(next.run(request).await)
}
/// 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,
/// 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`.
///
/// If the user is not authenticated or not an administrator, it returns an
/// appropriate error response (401 Unauthorized or 403 Forbidden).
///
/// # Arguments
/// - `cookies`: The `CookieJar` from the request.
/// - `State(data)`: Application state containing `AppState`.
/// - `mut request`: The incoming HTTP request, which will have admin user data injected.
/// - `next`: The next middleware or handler in the chain.
///
/// # Returns
/// - `Ok(impl IntoResponse)`: If validation and admin check succeed, the request proceeds.
/// - `Err((StatusCode, Json<serde_json::Value>))`: An error response if validation fails
/// or the user is not an admin.
pub async fn validate_admin(
cookies: CookieJar,
State(data): State<Arc<AppState>>,
mut request: Request<Body>,
next: Next,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let token = cookies
.get("token")
.map(|cookie| cookie.value().to_string())
.or_else(|| {
request
.headers()
.get(header::AUTHORIZATION)
.and_then(|header| header.to_str().ok())
.and_then(|value| {
if value.starts_with("Bearer ") {
Some(value[7..].to_owned())
} else {
None
}
})
});
let token = token.ok_or_else(|| {
let error = json!({
"status": "error",
"message": "Please provide a valid token"
});
(StatusCode::UNAUTHORIZED, Json(error))
})?;
let claims = decode_token(
token,
&DecodingKey::from_secret(data.env.token_secret.as_ref()),
)
.map_err(|(status, json_err)| {
let error = json!({
"status": json_err.status,
"message": json_err.message
});
(status, Json(error))
})?;
let uuid = (&claims.sub).parse::<i16>().map_err(|_| {
let error = json!({
"status": "error",
"message": "Invalid user id"
});
(StatusCode::UNAUTHORIZED, Json(error))
})?;
let user = sqlx::query_as::<_, User>(r#"SELECT * FROM users WHERE id = $1"#)
.bind(uuid)
.fetch_optional(&data.db)
.await
.map_err(|e| {
let error = json!({
"status": "error",
"message": format!("Database error: {}", e)
});
(StatusCode::INTERNAL_SERVER_ERROR, Json(error))
})?;
let user = user.ok_or_else(|| {
let error = json!({
"status": "error",
"message": "Invalid user"
});
(StatusCode::UNAUTHORIZED, Json(error))
})?;
if !user.is_admin {
let error = json!({
"status": "error",
"message": "Admin access required"
});
return Err((StatusCode::FORBIDDEN, Json(error)));
}
request.extensions_mut().insert(filter_user(&user));
Ok(next.run(request).await)
}
+45
View File
@@ -0,0 +1,45 @@
/// Environment configuration for the application.
///
/// Loads required configuration from environment variables at startup.
/// All variables must be present or the application will panic.
///
/// # Fields
/// - `db_url`: PostgreSQL database connection URL.
/// - `token_secret`: Secret key used to sign and verify JWT tokens.
/// - `origin`: Frontend origin URL for CORS policy.
///
/// # Required Environment Variables
/// - `DATABASE_URL`: PostgreSQL connection string (e.g., `postgresql://user:pass@localhost/dbname`)
/// - `TOKEN_SECRET`: Secret key for JWT token signing (use a strong random string in production)
/// - `ORIGIN`: Frontend URL for CORS (e.g., `http://localhost:8080`)
#[derive(Debug, Clone)]
pub struct Env {
/// PostgreSQL database connection URL
pub db_url: String,
/// Secret key used to sign and verify JWT tokens
pub token_secret: String,
/// Frontend origin URL for CORS policy
pub origin: String,
}
impl Env {
/// Loads environment configuration from system environment variables.
///
/// Panics if any required variable is missing.
///
/// # Example
/// ```ignore
/// let env = Env::load();
/// // Environment must have DATABASE_URL, TOKEN_SECRET, and ORIGIN set
/// ```
pub fn load() -> Env {
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let token_secret = std::env::var("TOKEN_SECRET").expect("TOKEN_SECRET must be set");
let origin = std::env::var("ORIGIN").expect("ORIGIN must be set");
Env {
db_url,
token_secret,
origin,
}
}
}
+635
View File
@@ -0,0 +1,635 @@
use std::sync::Arc;
use argon2::{
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
password_hash::{SaltString, rand_core::OsRng},
};
use axum::{
Extension, Json,
extract::{Path, State},
http::{Response, StatusCode, header},
response::IntoResponse,
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use jsonwebtoken::{EncodingKey, Header};
use serde_json::json;
use crate::{
AppState,
cookie::jwt::encode_token,
models::{FilteredUser, LoginScheme, User, UserCreateScheme, UserUpdateScheme},
};
/// Registers a new user in the system.
///
/// Creates a new user account with the provided credentials. The password is hashed using Argon2
/// before being stored. Only administrators can create new users.
///
/// # Arguments
/// - `request`: User creation details including first/last name, username, admin flag, and password
///
/// # Returns
/// - `200 OK` on successful user creation
/// - `400 Bad Request` if username/password missing or user already exists
/// - `500 Internal Server Error` if database insertion fails
///
/// # Password Hashing
/// 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(
State(data): State<Arc<AppState>>,
Json(request): Json<UserCreateScheme>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
if request.username.is_empty() || request.pwd.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"status": "error", "message": "Missing credential"})),
));
}
let exist_check = sqlx::query_as::<_, UserCreateScheme>(
r#"SELECT first_name, last_name, username, is_admin, pwd FROM users WHERE username = $1"#,
)
.bind(&request.username)
.fetch_optional(&data.db)
.await
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
)
})?;
if let Some(_) = exist_check {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"status": "error", "message": "user already exists"})),
));
}
let argon = Argon2::default();
let salt = SaltString::generate(&mut OsRng);
let hashed_pwd = match argon.hash_password(request.pwd.clone().as_bytes(), &salt) {
Ok(h) => h.to_string(),
Err(e) => panic!("Error hashing {:}", e),
};
let user = sqlx::query("INSERT INTO users (username, pwd, first_name, last_name, is_admin) VALUES ($1, $2, $3, $4, $5)")
.bind(request.username)
.bind(&hashed_pwd)
.bind(request.first_name)
.bind(request.last_name)
.bind(request.is_admin)
.execute(&data.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{}", e)})),
)
})?;
if user.rows_affected() < 1 {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": "Error creating user"})),
));
} else {
Ok(Json(json!({"status": "success", "result": "User created"})))
}
}
/// Authenticates a user and creates a JWT token for session management.
///
/// Verifies the provided username and password against stored credentials using Argon2 verification.
/// On successful authentication, generates a JWT token and sets it as an HTTP-only cookie.
/// The token is valid for 1 hour.
///
/// # Arguments
/// - `request`: Login credentials (username, password)
///
/// # Returns
/// - `200 OK` with JSON containing token and filtered user info
/// - `400 Bad Request` if username not found or password invalid
/// - `500 Internal Server Error` if database query fails
///
/// # Security Features
/// - HTTP-only cookie prevents JavaScript access
/// - SameSite=Lax protects against CSRF attacks
/// - Password verification uses Argon2:
/// ```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
/// ```json
/// {
/// "status": "success",
/// "token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
/// "user": {"id": 1, "first_name": "Admin", "last_name": "User", "username": "admin", "is_admin": true}
/// }
/// ```
pub async fn login(
State(data): State<Arc<AppState>>,
Json(request): Json<LoginScheme>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let user = sqlx::query_as::<_, User>(r#"SELECT * FROM users WHERE username = $1"#)
.bind(request.username)
.fetch_optional(&data.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{}", e)})),
)
})?
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({"status": "error", "message": "Invalid username"})),
)
})?;
let pwd_hash = PasswordHash::new(&user.pwd);
let valid_pwd = Argon2::default()
.verify_password(&request.pwd.as_bytes(), &pwd_hash.unwrap())
.is_ok();
if !valid_pwd {
let error_response = serde_json::json!({
"status": "error",
"message": "Invalid password"
});
return Err((StatusCode::BAD_REQUEST, Json(error_response)));
}
let token = encode_token(
&Header::default(),
user.id.clone().to_string(),
&EncodingKey::from_secret(data.env.token_secret.as_ref()),
);
let cookie = Cookie::build(("token", token.to_owned()))
.path("/")
.max_age(time::Duration::hours(1))
.same_site(SameSite::Lax)
.http_only(true);
let mut response = Response::new(
json!({"status": "success", "token": token, "user": filter_user(&user)}).to_string(),
);
response
.headers_mut()
.insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
Ok(response)
}
/// Logs out the current user by invalidating their session cookie.
///
/// 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
/// session invalidation.
///
/// # Returns
/// Always returns `200 OK` with success message and an expired cookie header
///
/// # Security
/// - HTTP-only cookie prevents client-side manipulation
/// - SameSite=Lax protects against CSRF
/// - Setting max_age to negative value causes immediate expiration
///
/// # Example Response
/// ```json
/// {"status": "success", "message": "successfully logged out"}
/// ```
pub async fn logout() -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let cookie = Cookie::build(("token", ""))
.path("/")
.max_age(time::Duration::hours(-1))
.same_site(SameSite::Lax)
.http_only(true);
let mut response = Response::new(
json!({"status": "success", "message": "successfully logged out"}).to_string(),
);
response
.headers_mut()
.insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
Ok(response)
}
/// Retrieves the currently authenticated user's information.
///
/// Uses the user data embedded in the JWT token (via middleware).
/// Useful for frontends to display logged-in user info or verify authentication.
///
/// # Returns
/// - `200 OK` with user data (excluding password)
/// - Automatically returns `401 Unauthorized` if not authenticated (middleware)
///
/// # Example Response
/// ```json
/// {
/// "status": "success",
/// "data": {
/// "id": 1,
/// "first_name": "Admin",
/// "last_name": "User",
/// "is_admin": true
/// }
/// }
/// ```
pub async fn get_current_user(
Extension(user): Extension<FilteredUser>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let response = json!({
"status": "success",
"data": json!({
"id": user.id,
"first_name": user.first_name,
"last_name": user.last_name,
"is_admin": user.is_admin
})
});
Ok(Json(response))
}
/// Deletes a user account from the system.
///
/// 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.
///
/// # Arguments
/// - `id`: User ID to delete
///
/// # Returns
/// - `204 No Content` on successful deletion
/// - `404 Not Found` if user doesn't exist
/// - `500 Internal Server Error` if database error occurs
pub async fn delete_user(
Path(id): Path<i16>,
State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let query = sqlx::query(r#"DELETE FROM users WHERE id = $1"#)
.bind(id)
.execute(&data.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
)
})?;
if query.rows_affected() == 0 {
let error = json!({
"status": "error",
"message": format!("User with ID {} not found", id)
});
return Err((StatusCode::NOT_FOUND, Json(error)));
}
Ok(StatusCode::NO_CONTENT)
}
/// Retrieves all users in the system.
///
/// Only admins can call this endpoint. Returns all users sorted alphabetically by last name.
/// Password hashes are not included in the response.
///
/// # Returns
/// - `200 OK` with array of FilteredUser objects
/// - `500 Internal Server Error` if database query fails
///
/// # Example Response
/// ```json
/// [
/// {
/// "id": 1,
/// "first_name": "Admin",
/// "last_name": "User",
/// "username": "admin",
/// "is_admin": true
/// },
/// {
/// "id": 2,
/// "first_name": "Regular",
/// "last_name": "User",
/// "username": "regularuser",
/// "is_admin": false
/// }
/// ]
/// ```
pub async fn get_users(
State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let users = sqlx::query_as::<_, User>(r#"SELECT * FROM users ORDER BY last_name ASC"#)
.fetch_all(&data.db)
.await
.map_err(|e| {
let error = json!({
"status": "error",
"message": format!("{:?}", e)
});
(StatusCode::INTERNAL_SERVER_ERROR, Json(error))
})?;
let response = users
.iter()
.map(|user| filter_user(&user))
.collect::<Vec<FilteredUser>>();
let json_respnse = json!(response);
Ok(Json(json_respnse))
}
/// Retrieves a single user's details by their ID.
///
/// This endpoint allows fetching a specific user's information. It returns a `FilteredUser`
/// object, ensuring sensitive data like password hashes are not exposed.
///
/// # Arguments
/// - `Path(id)`: The ID of the user to retrieve, extracted from the URL path.
/// - `State(data)`: Application state containing `AppState` for database access.
///
/// # Returns
/// - `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.
/// - `500 Internal Server Error` if a database query error occurs.
///
pub async fn get_user_by_id(
Path(id): Path<i16>,
State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let query = sqlx::query_as::<_, User>(r#"SELECT * FROM users WHERE id = $1"#)
.bind(id)
.fetch_one(&data.db)
.await;
match query {
Ok(user) => {
let response = serde_json::json!(filter_user(&user));
return Ok(Json(response));
}
Err(sqlx::Error::RowNotFound) => {
let error_response = serde_json::json!({
"status": "fail",
"message": format!("User with ID {} not found", id)
});
return Err((StatusCode::NOT_FOUND, Json(error_response)));
}
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
));
}
};
}
/// Updates an existing user's information.
///
/// 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
/// request body is an empty string, the user's password remains unchanged.
///
/// # Arguments
/// - `Path(id)`: The ID of the user to update, extracted from the URL path.
/// - `State(data)`: Application state containing `AppState` for database access.
/// - `Json(body)`: `UserUpdateScheme` containing the fields to update.
///
/// # Returns
/// - `200 OK` with the `FilteredUser` object of the updated user.
/// - `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.
///
/// # Security Note
/// - Passwords are hashed using Argon2 before storage.
/// - This endpoint typically requires admin privileges (enforced by middleware).
pub async fn update_user(
Path(id): Path<i16>,
State(data): State<Arc<AppState>>,
Json(body): Json<UserUpdateScheme>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let update_result = if !body.new_pwd.is_empty() {
let argon = Argon2::default();
let salt = SaltString::generate(&mut OsRng);
let hashed_pwd = match argon.hash_password(body.new_pwd.clone().as_bytes(), &salt) {
Ok(h) => h.to_string(),
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"#)
.bind(body.first_name.to_owned())
.bind(body.last_name.to_owned())
.bind(body.username.to_owned())
.bind(hashed_pwd)
.bind(body.make_admin.to_owned())
.bind(id)
.execute(&data.db)
.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| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
)
})?;
if update_result.rows_affected() == 0 {
let error_response = serde_json::json!({
"status": "error",
"message": format!("User with ID {} not found", id)
});
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)));
}
let updated_user = sqlx::query_as::<_, User>(r#"SELECT * FROM users WHERE id = $1"#)
.bind(id)
.fetch_one(&data.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
)
})?;
let response = serde_json::json!({
"user": filter_user(&updated_user),
"status": "success"
});
Ok(Json(response))
}
/// Checks if any administrator user exists in the system.
///
/// This endpoint is used during initialization to determine if the setup page should be displayed.
/// It counts all users with `is_admin = true` in the database.
///
/// # Returns
/// - `200 OK` with JSON: `{"has_admin": bool}` - Whether at least one admin exists
/// - `500 Internal Server Error` if database query fails
///
/// # Example Response
/// ```json
/// {"has_admin": false}
/// ```
pub async fn check_admin_exists(
State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let admin_count =
sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM users WHERE is_admin = true"#)
.fetch_one(&data.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
)
})?;
let has_admin = admin_count > 0;
Ok(Json(json!({"has_admin": has_admin})))
}
/// 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
/// 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
/// with proper authorization.
///
/// # Arguments
/// - `request`: User creation details (first_name, last_name, username, password)
///
/// # Returns
/// - `200 OK` with success message if admin account created
/// - `400 Bad Request` if:
/// - Admin already exists
/// - Username or password is empty
/// - `500 Internal Server Error` if database insertion fails
///
/// # Security Note
/// 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(
State(data): State<Arc<AppState>>,
Json(request): Json<UserCreateScheme>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
// Check if any admin already exists
let admin_count =
sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM users WHERE is_admin = true"#)
.fetch_one(&data.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
)
})?;
if admin_count > 0 {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"status": "error", "message": "Admin user already exists"})),
));
}
if request.username.is_empty() || request.pwd.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"status": "error", "message": "Missing credential"})),
));
}
let argon = Argon2::default();
let salt = SaltString::generate(&mut OsRng);
let hashed_pwd = match argon.hash_password(request.pwd.clone().as_bytes(), &salt) {
Ok(h) => h.to_string(),
Err(e) => panic!("Error hashing {:}", e),
};
let user = sqlx::query("INSERT INTO users (username, pwd, first_name, last_name, is_admin) VALUES ($1, $2, $3, $4, $5)")
.bind(request.username)
.bind(&hashed_pwd)
.bind(request.first_name)
.bind(request.last_name)
.bind(true)
.execute(&data.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{}", e)})),
)
})?;
if user.rows_affected() < 1 {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": "Error creating admin user"})),
));
} else {
Ok(Json(
json!({"status": "success", "result": "Admin user created"}),
))
}
}
/// Converts a User with sensitive data into a FilteredUser safe for API responses.
///
/// This function removes password hashes and other sensitive information before
/// returning user data to clients. Always use this helper instead of directly
/// serializing User objects.
///
/// # Arguments
/// - `user`: Reference to the internal User struct containing password hash
///
/// # Returns
/// FilteredUser with only safe-to-share information:
/// - `id`: User ID
/// - `first_name`, `last_name`: User name
/// - `username`: Login username
/// - `is_admin`: Admin privilege flag
///
/// # Important
/// The password hash is explicitly **NOT** included in the output. This prevents
/// accidental exposure of sensitive authentication data.
///
/// # Example
/// ```ignore
/// let user = get_user_from_db(1).await?;
/// let safe_user = filter_user(&user);
/// // safe_user can be safely serialized and sent to client
/// ```
pub fn filter_user(user: &User) -> FilteredUser {
FilteredUser {
id: user.id,
first_name: user.first_name.clone(),
last_name: user.last_name.clone(),
username: user.username.clone(),
is_admin: user.is_admin.clone(),
}
}
+6
View File
@@ -0,0 +1,6 @@
//! This module aggregates and re-exports all API endpoint handler functions.
//!
//! 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 ticket;
+323
View File
@@ -0,0 +1,323 @@
use std::sync::Arc;
use axum::{
Extension, Json,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use serde_json::json;
use sqlx::{Row, query};
use crate::{
AppState,
models::{FilteredUser, TicketCreateScheme, TicketResponse, TicketUpdateScheme},
};
/// Creates a new support ticket.
///
/// Associates the ticket with the authenticated user and sets the current timestamp.
/// Tickets are automatically created with "open" status.
///
/// # Arguments
/// - `user`: Authenticated user (extracted from JWT token)
/// - `body`: Ticket details (category, subject, description, room)
///
/// # Returns
/// - `200 OK` on successful creation
/// - `500 Internal Server Error` if database insertion fails
///
/// # Database Fields Set Automatically
/// - `user_id`: From authenticated user
/// - `status`: Defaults to "open"
/// - `date`: Current UTC timestamp
pub async fn create_ticket(
Extension(user): Extension<FilteredUser>,
State(data): State<Arc<AppState>>,
Json(body): Json<TicketCreateScheme>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let query = query(
r#"INSERT INTO tickets (category, description, betreff, room, user_id) VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(body.category.to_string())
.bind(body.description.to_string())
.bind(body.betreff.to_string())
.bind(body.room)
.bind(user.id)
.execute(&data.db)
.await;
if let Err(err) = query {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", err),})),
));
}
let response_status = serde_json::json!({"status": "success"});
Ok(Json(response_status))
}
/// Deletes a ticket by ID.
///
/// Only admins can delete tickets. Marks the ticket as deleted or removes from database.
///
/// # Arguments
/// - `id`: Ticket ID to delete
///
/// # Returns
/// - `204 No Content` on successful deletion
/// - `404 Not Found` if ticket doesn't exist
/// - `500 Internal Server Error` if database error occurs
pub async fn delete_ticket(
Path(id): Path<i32>,
State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let query = sqlx::query(r#"DELETE FROM tickets WHERE id = $1"#)
.bind(id)
.execute(&data.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
)
})?;
if query.rows_affected() == 0 {
let error_response = serde_json::json!({
"status": "error",
"message": format!("Ticket with ID {} not found", id)
});
return Err((StatusCode::NOT_FOUND, Json(error_response)));
}
Ok(StatusCode::NO_CONTENT)
}
/// Retrieves all non-archived tickets.
///
/// Returns a list of all active tickets with user information denormalized for easier rendering.
/// Tickets are ordered by creation date (newest first).
///
/// # Filtering
/// - Excludes tickets with status "Archived"
/// - Uses LEFT JOIN to include creator information
///
/// # Returns
/// - `200 OK` with array of TicketResponse objects
/// - `500 Internal Server Error` if database query fails
///
/// # Example Response
/// ```json
/// [
/// {
/// "id": 1,
/// "category": "maintenance",
/// "betreff": "Broken light",
/// "description": "Ceiling light not working",
/// "room": 101,
/// "status": "open",
/// "date": "2024-01-15T10:30:00Z",
/// "user_id": 5,
/// "user_first_name": "John",
/// "user_last_name": "Doe"
/// }
/// ]
/// ```
pub async fn get_tickets(
State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
// Query tickets with denormalized user info, excluding archived tickets
let tickets = sqlx::query(
r#"SELECT t.id, t.category, t.betreff, t.description, t.room, t.status, t.date, t.user_id, u.first_name, u.last_name
FROM tickets t
LEFT JOIN users u ON t.user_id = u.id
ORDER BY t.date DESC"#,
)
.fetch_all(&data.db)
.await
.map_err(|e| {
let error_response = serde_json::json!({
"status": "error",
"message": format!("Database error: {}", e),
});
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
})?;
// Transform raw database rows into TicketResponse structs
let ticket_response: Vec<TicketResponse> = tickets
.iter()
.map(|row| TicketResponse {
id: row.get("id"),
category: row.get("category"),
betreff: row.get("betreff"),
description: row.get("description"),
room: row.get("room"),
status: row.get("status"),
date: row.get("date"),
user_id: row.get("user_id"),
user_first_name: row.get("first_name"),
user_last_name: row.get("last_name"),
})
.collect();
let json_response = serde_json::json!(ticket_response);
Ok(Json(json_response))
}
/// Retrieves a specific ticket by ID.
///
/// Includes full ticket details and denormalized user information (creator name).
///
/// # Arguments
/// - `id`: Ticket ID to retrieve
///
/// # Returns
/// - `200 OK` with TicketResponse object
/// - `404 Not Found` if ticket doesn't exist
/// - `500 Internal Server Error` if database error occurs
///
/// # Example Response
/// ```json
/// {
/// "id": 1,
/// "category": "maintenance",
/// "betreff": "Broken light in room 101",
/// "description": "The ceiling light is not working",
/// "room": 101,
/// "status": "open",
/// "date": "2024-01-15T10:30:00Z",
/// "user_id": 5,
/// "user_first_name": "John",
/// "user_last_name": "Doe"
/// }
/// ```
pub async fn get_ticket_by_id(
Path(id): Path<i32>,
State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let query = sqlx::query(
r#"SELECT t.id, t.category, t.betreff, t.description, t.room, t.status, t.date, t.user_id, u.first_name, u.last_name
FROM tickets t
LEFT JOIN users u ON t.user_id = u.id
WHERE t.id = $1"#,
)
.bind(id)
.fetch_one(&data.db)
.await;
match query {
Ok(row) => {
let ticket_response = TicketResponse {
id: row.get("id"),
category: row.get("category"),
betreff: row.get("betreff"),
description: row.get("description"),
room: row.get("room"),
status: row.get("status"),
date: row.get("date"),
user_id: row.get("user_id"),
user_first_name: row.get("first_name"),
user_last_name: row.get("last_name"),
};
let response = serde_json::json!(ticket_response);
return Ok(Json(response));
}
Err(sqlx::Error::RowNotFound) => {
let error_response = serde_json::json!({
"status": "fail",
"message": format!("Ticket with ID {} not found", id)
});
return Err((StatusCode::NOT_FOUND, Json(error_response)));
}
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
));
}
};
}
/// Updates a ticket's status.
///
/// Only admins can update ticket status. This is typically used to transition tickets
/// through their lifecycle (open → in_progress → resolved → archived).
///
/// # Arguments
/// - `id`: Ticket ID to update
/// - `body`: Update payload containing new status
///
/// # Returns
/// - `200 OK` with updated TicketResponse
/// - `500 Internal Server Error` if ticket not found or database error
///
/// # Typical Status Flow
/// - `open`: Initial state, waiting for action
/// - `in_progress`: Currently being worked on
/// - `resolved`: Issue fixed
/// - `archived`: Closed/hidden from normal view
pub async fn edit_ticket(
Path(id): Path<i32>,
State(data): State<Arc<AppState>>,
Json(body): Json<TicketUpdateScheme>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
// Update the ticket status
let update_result = sqlx::query(r#"UPDATE tickets SET status = $1 WHERE id = $2"#)
.bind(body.status.to_owned())
.bind(id)
.execute(&data.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
)
})?;
if update_result.rows_affected() == 0 {
let error_response = serde_json::json!({
"status": "error",
"message": format!("Ticket with ID {} not found", id)
});
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)));
}
// Fetch and return the updated ticket
let updated_ticket = sqlx::query(
r#"SELECT t.id, t.category, t.betreff, t.description, t.room, t.status, t.date, t.user_id, u.first_name, u.last_name
FROM tickets t
LEFT JOIN users u ON t.user_id = u.id
WHERE t.id = $1"#,
)
.bind(id)
.fetch_one(&data.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
)
})?;
let ticket_response = TicketResponse {
id: updated_ticket.get("id"),
category: updated_ticket.get("category"),
betreff: updated_ticket.get("betreff"),
description: updated_ticket.get("description"),
room: updated_ticket.get("room"),
status: updated_ticket.get("status"),
date: updated_ticket.get("date"),
user_id: updated_ticket.get("user_id"),
user_first_name: updated_ticket.get("first_name"),
user_last_name: updated_ticket.get("last_name"),
};
let response = serde_json::json!({
"ticket": ticket_response,
"status": "success"
});
Ok(Json(response))
}
+72 -12
View File
@@ -1,14 +1,64 @@
/// Cookie and JWT authentication utilities
mod cookie;
/// Environment configuration loading
mod env;
/// HTTP request handlers for all endpoints
mod handlers;
/// Data structures for request/response serialization
mod models; mod models;
use axum::{Router, routing}; /// Axum router configuration with all routes and middleware
use dotenv::dotenv; mod router;
use models::*;
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::sync::Arc;
use axum::http::{
HeaderValue, Method,
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
};
use dotenv::dotenv;
use router::create_router;
use sqlx::{PgPool, postgres::PgPoolOptions};
use tower_http::cors::CorsLayer;
use crate::env::Env;
/// Shared application state passed to all route handlers.
///
/// Contains the database connection pool and environment configuration.
/// This is wrapped in Arc for thread-safe sharing across async tasks.
///
/// # Fields
/// - `db`: PostgreSQL connection pool for database access
/// - `env`: Configuration loaded from environment variables
pub struct AppState {
db: PgPool,
env: Env,
}
/// Main application entry point.
///
/// Initializes the server by:
/// 1. Loading environment variables from `.env` file
/// 2. Establishing database connection pool
/// 3. Configuring CORS policy for cross-origin requests
/// 4. Starting HTTP server on port 8001
///
/// # Server Configuration
/// - Binds to `0.0.0.0:8001` (all network interfaces)
/// - Allows: GET, POST, PATCH, DELETE methods
/// - Allows credentials and custom headers
/// - CORS origin configured from environment
///
/// # Panics
/// - If environment loading fails
/// - If database connection fails
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
dotenv().ok(); dotenv().ok();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL variable not set"); let env = Env::load();
let database_url = &env.db_url;
// Establish connection pool to PostgreSQL
let pool = match PgPoolOptions::new().connect(&database_url).await { let pool = match PgPoolOptions::new().connect(&database_url).await {
Ok(pool) => { Ok(pool) => {
println!("Database connection successful"); println!("Database connection successful");
@@ -19,12 +69,22 @@ async fn main() {
std::process::exit(1); std::process::exit(1);
} }
}; };
let app = Router::new().route("/", routing::get(root_handler));
// Configure CORS to allow requests from frontend
let cors = CorsLayer::new()
.allow_origin(env.origin.parse::<HeaderValue>().unwrap())
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
.allow_credentials(true)
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]);
// Build router with all endpoints and apply CORS middleware
let app = create_router(Arc::new(AppState {
db: pool.clone(),
env: env.clone(),
}))
.layer(cors);
// Start listening for incoming connections
let listener = tokio::net::TcpListener::bind("0.0.0.0:8001").await.unwrap(); let listener = tokio::net::TcpListener::bind("0.0.0.0:8001").await.unwrap();
axum::serve(listener, app).await; let _ = axum::serve(listener, app).await;
}
async fn root_handler() -> &'static str {
"Hello, World"
} }
+179 -16
View File
@@ -1,30 +1,193 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] /// API response for a ticket with user information.
pub enum category { ///
WhiteboardBeamer, /// Returned by ticket endpoints. Includes denormalized user data for easier frontend rendering.
Internet, ///
IPadKoffer, /// # Fields
AppleTV, /// - `id`: Unique ticket identifier
DocuCam, /// - `category`: Ticket category/type
Sonstiges, /// - `betreff`: Ticket subject line
} /// - `description`: Detailed ticket description
/// - `room`: Room number associated with the issue
#[derive(Deserialize, Serialize, PartialEq, Debug)] /// - `status`: Current ticket status (e.g., "open", "in_progress", "resolved")
pub struct ticket { /// - `date`: When the ticket was created (UTC timestamp)
/// - `user_id`: ID of the user who created the ticket
/// - `user_first_name`, `user_last_name`: User's name (denormalized for convenience)
///
/// # Example
/// ```json
/// {
/// "id": 1,
/// "category": "maintenance",
/// "betreff": "Broken light in room 101",
/// "description": "The ceiling light is not working",
/// "room": 101,
/// "status": "open",
/// "date": "2024-01-15T10:30:00Z",
/// "user_id": 5,
/// "user_first_name": "John",
/// "user_last_name": "Doe"
/// }
/// ```
#[derive(Deserialize, Serialize, Debug, PartialEq)]
pub struct TicketResponse {
pub id: i32, pub id: i32,
pub category: category, pub category: String,
pub betreff: String, pub betreff: String,
pub description: String, pub description: String,
pub room: i16, pub room: i16,
pub date: chrono::NaiveDateTime, pub status: String,
pub date: chrono::DateTime<chrono::Utc>,
pub user_id: i16, pub user_id: i16,
pub user_first_name: String,
pub user_last_name: String,
} }
#[derive(Deserialize, Serialize, PartialEq, Debug)] /// Complete user record from the database.
pub struct user { ///
/// Contains all user information including the password hash.
/// This should NEVER be sent directly to clients - always use `FilteredUser` instead.
///
/// # Fields
/// - `id`: Unique user identifier
/// - `first_name`, `last_name`: User's full name
/// - `username`: Login username (must be unique)
/// - `is_admin`: Whether user has admin privileges
/// - `pwd`: Argon2 password hash (NEVER expose to clients)
///
/// # Security Note
/// The `pwd` field contains the password hash and should never be included in API responses.
/// Use `filter_user()` to convert to `FilteredUser` for responses.
#[derive(Deserialize, Serialize, PartialEq, Debug, Clone, sqlx::FromRow)]
pub struct User {
pub id: i16,
pub last_name: String,
pub first_name: String,
pub username: String,
pub is_admin: bool,
pub pwd: String,
}
/// Payload for creating a new ticket.
///
/// Sent to `/api/tickets/create`. The backend automatically associates it with the
/// authenticated user and sets the creation timestamp.
///
/// # Fields
/// - `category`: Ticket category/type
/// - `betreff`: Subject line for the ticket
/// - `description`: Detailed problem description
/// - `room`: Room number where the issue is located
#[derive(Deserialize, Serialize, Debug)]
pub struct TicketCreateScheme {
pub category: String,
pub betreff: String,
pub description: String,
pub room: i16,
}
/// Payload for updating a ticket.
///
/// Sent to `PATCH /api/tickets/{id}`. Currently only allows status updates.
/// Only admins can update tickets.
///
/// # Fields
/// - `status`: New ticket status (e.g., "open", "in_progress", "resolved")
#[derive(Deserialize, Serialize, Debug)]
pub struct TicketUpdateScheme {
pub status: String,
}
/// Payload for updating user information.
///
/// Sent to `PATCH /api/users/{id}`. Allows updating profile and admin status.
/// Only admins can update users. Empty password field means no password change.
///
/// # Fields
/// - `id`: User ID to update
/// - `first_name`, `last_name`: Updated user name
/// - `username`: Updated login username
/// - `make_admin`: New admin privilege status
/// - `new_pwd`: New password (empty string = keep existing password)
#[derive(Deserialize, Serialize, Debug)]
pub struct UserUpdateScheme {
pub id: i16, pub id: i16,
pub first_name: String, pub first_name: String,
pub last_name: String, pub last_name: String,
pub username: String,
pub make_admin: bool,
pub new_pwd: String,
}
/// Payload for creating a new user account.
///
/// Used in both admin registration (`/api/register`) and initial setup (`/api/setup-admin`).
/// The password is hashed server-side before storage using Argon2.
///
/// # Fields
/// - `first_name`: User's first name
/// - `last_name`: User's last name
/// - `username`: Unique username for login
/// - `is_admin`: Whether to grant admin privileges (setup endpoint always sets this to true)
/// - `pwd`: Plain text password (hashed on server)
#[derive(Deserialize, Serialize, Debug, sqlx::FromRow)]
pub struct UserCreateScheme {
pub first_name: String,
pub last_name: String,
pub username: String,
pub is_admin: bool,
pub pwd: String,
}
/// Payload for user login.
///
/// Sent to `/api/login` endpoint with credentials. The backend verifies the password
/// against the stored Argon2 hash.
///
/// # Security
/// The password is never stored in plain text - only the Argon2 hash is persisted.
#[derive(Deserialize, Serialize, Debug)]
pub struct LoginScheme {
pub username: String,
pub pwd: String,
}
/// User information sent to clients, excluding password hashes.
///
/// 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
/// to prevent leaking sensitive data.
#[derive(Debug, Clone, Serialize)]
pub struct FilteredUser {
pub id: i16,
pub first_name: String,
pub last_name: String,
pub username: String,
pub is_admin: bool, pub is_admin: bool,
} }
/// JWT token claims embedded in the session token.
///
/// Contains user identification and token validity information.
/// Generated during login and verified by middleware on protected routes.
///
/// # Fields
/// - `sub`: Subject - the user ID as a string
/// - `issued`: Unix timestamp when token was created
/// - `expires`: Unix timestamp when token expires (currently 1 hour from creation)
///
/// # Token Lifetime
/// Tokens are valid for 1 hour. After expiration, user must log in again.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
/// Subject - typically the user ID
#[serde(alias = "subject")]
pub sub: String,
/// Issued at time (Unix timestamp)
#[serde(rename = "iat", alias = "issued", default)]
pub issued: usize,
/// Expiration time (Unix timestamp)
#[serde(rename = "exp", alias = "expires", default)]
pub expires: usize,
}
+87
View File
@@ -0,0 +1,87 @@
use std::sync::Arc;
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
AppState,
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,
},
ticket::{create_ticket, delete_ticket, edit_ticket, get_ticket_by_id, get_tickets},
},
};
/// Creates the complete router with all API endpoints.
///
/// The router is organized in layers for proper middleware application:
///
/// ## Route Layers (from most to least restricted):
///
/// ### Admin-Only Routes (requires admin privilege + valid token)
/// - `GET /api/tickets/{id}` - Get specific ticket details
/// - `DELETE /api/tickets/{id}` - Delete a ticket
/// - `PATCH /api/tickets/{id}` - Update ticket status
/// - `POST /api/register` - Create a new user
/// - `GET /api/users` - List all users
/// - `GET /api/users/{id}` - Get user details
/// - `DELETE /api/users/{id}` - Delete a user
/// - `PATCH /api/users/{id}` - Update user details
///
/// ### Protected Routes (requires valid token)
/// - `GET /api/tickets` - List all tickets
/// - `POST /api/tickets/create` - Create a new ticket
/// - `GET /api/logout` - Logout user
/// - `GET /api/users/current` - Get current authenticated user
///
/// ### Public Routes (no authentication required)
/// - `POST /api/login` - User login
/// - `GET /api/check-admin` - Check if admin exists (for setup detection)
/// - `POST /api/setup-admin` - Create initial admin account (only if no admin exists)
///
/// # Middleware Stack
/// - Admin routes have `validate_admin` middleware
/// - Protected routes have `validate_token` middleware
/// - Public routes have no authentication requirements
pub fn create_router(state: Arc<AppState>) -> Router {
let admin_routes = Router::new()
.route(
"/api/tickets/{id}",
get(get_ticket_by_id)
.delete(delete_ticket)
.patch(edit_ticket),
)
.route("/api/register", post(create_user))
.route("/api/users", get(get_users))
.route(
"/api/users/{id}",
get(get_user_by_id).delete(delete_user).patch(update_user),
)
.layer(middleware::from_fn_with_state(
state.clone(),
validate_admin,
));
let protected_routes = Router::new()
.merge(admin_routes)
.route("/api/tickets", get(get_tickets))
.route("/api/tickets/create", post(create_ticket))
.route("/api/logout", get(logout))
.route("/api/users/current", get(get_current_user))
.layer(middleware::from_fn_with_state(
state.clone(),
validate_token,
));
Router::new()
.merge(protected_routes)
.route("/api/login", post(login))
.route("/api/check-admin", get(check_admin_exists))
.route("/api/setup-admin", post(setup_initial_admin))
.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:
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "frontend"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "bin"
path = "src/main.rs"
[dependencies]
yew = { version = "0.23", features = ["csr"] }
serde = { workspace = true }
wasm-bindgen-futures = "0.4.70"
web-sys = { version = "0.3.95", features = [
"Window","Document","Request","Response","Headers", "HtmlSelectElement", "RequestCredentials",
"SubmitEvent","InputEvent","HtmlInputElement","Event", "HtmlFormElement", "MouseEvent"
] }
gloo-net = "0.7.0"
gloo-storage = "0.4.0"
yew-router = "0.20.0"
serde_json = { workspace = true }
gloo = "0.12.0"
chrono = { workspace = true }
chrono-tz = "0.10.4"
wasm-bindgen = "0.2.120"
+10
View File
@@ -0,0 +1,10 @@
[serve]
# The address to serve on LAN.
addresses = ["127.0.0.1"] #,"10.150.9.7"]
# The address to serve on WAN.
# address = "0.0.0.0"
# The port to serve on.
port = 8000
[[proxy]]
backend = "http://localhost:8001/api"
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html>
<head>
<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" />
<meta charset="utf-8" />
<title>Yew App</title>
</head>
<body></body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+136
View File
@@ -0,0 +1,136 @@
use gloo_net::http::Request;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
/// Represents the authentication state of the current user.
///
/// This struct holds information about whether a user is authenticated and if they
/// possess administrator privileges.
///
/// # Fields
/// - `is_authenticated`: An `Option<bool>` indicating if the user is logged in.
/// `None` means the status is still being checked.
/// - `is_admin`: An `Option<bool>` indicating if the authenticated user is an administrator.
/// `None` means the admin status is still being checked or is not applicable.
#[derive(Clone, Debug, PartialEq)]
pub struct AuthState {
pub is_authenticated: Option<bool>,
pub is_admin: Option<bool>,
}
/// Properties for the [`ProtectedRoute`] component.
///
/// # Fields
/// - `children`: The child components that this protected route will render if access is granted.
/// - `admin_page`: A boolean flag indicating whether this route requires administrator privileges.
/// If `true`, the user must be authenticated AND be an administrator to access the `children`.
#[derive(Properties, PartialEq)]
pub struct ProtectedRouteProps {
pub children: Children,
pub admin_page: bool,
}
/// A component that protects routes by enforcing authentication and optional administrator privileges.
///
/// This component fetches the current user's authentication and admin status from the
/// `/api/users/current` endpoint upon mounting. Based on the `AuthState` and the
/// `admin_page` property, it either renders its children or redirects the user.
///
/// # Behavior
/// - **Initial Load**: Displays "Loading..." while checking authentication status.
/// - **Not Authenticated**: Redirects to the login page (`crate::Route::Login`).
/// - **Authenticated**:
/// - If `admin_page` is `true`:
/// - 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
/// the permission denied page (`crate::Route::PermissionDenied`).
/// - If admin status is still being checked (`is_admin: None`), it displays "Checking permissions...".
/// - If `admin_page` is `false`: It renders `children` directly, as only authentication is required.
///
/// # Example Usage
/// ```ignore
/// html! {
/// <ProtectedRoute admin_page={true}>
/// <AdminDashboard />
/// </ProtectedRoute>
/// }
/// ```
#[component(ProtectedRoute)]
pub fn protected_route(props: &ProtectedRouteProps) -> Html {
let auth_state = use_state(|| AuthState {
is_authenticated: None,
is_admin: None,
});
{
let auth_state = auth_state.clone();
use_effect_with((), move |_| {
let auth_state = auth_state.clone();
spawn_local(async move {
match Request::get("/api/users/current")
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
web_sys::console::log_1(&format!("Auth check: status {}", status).into());
if status == 200 {
let user_data: serde_json::Value =
resp.json().await.unwrap_or_default();
let is_admin = user_data["data"]["is_admin"].as_bool();
auth_state.set(AuthState {
is_authenticated: Some(true),
is_admin,
});
} else {
auth_state.set(AuthState {
is_authenticated: Some(false),
is_admin: Some(false),
});
}
}
Err(err) => {
web_sys::console::log_1(&format!("Auth check error: {:?}", err).into());
auth_state.set(AuthState {
is_authenticated: Some(false),
is_admin: Some(false),
});
}
}
});
|| ()
});
}
match *auth_state {
AuthState {
is_authenticated: None,
..
} => html! { <div>{ "Loading..." } </div> },
AuthState {
is_authenticated: Some(false),
..
} => html! {
<Redirect<crate::Route> to={crate::Route::Login}/>
},
AuthState {
is_authenticated: Some(true),
is_admin: admin_flag,
} => {
if props.admin_page {
match admin_flag {
Some(true) => props.children.clone().into(),
Some(false) => {
html! { <Redirect<crate::Route> to={crate::Route::PermissionDenied}/> }
}
None => html! { <div>{ "Checking permissions..." }</div> },
}
} else {
props.children.clone().into()
}
}
}
}
+276
View File
@@ -0,0 +1,276 @@
mod auth;
mod pages;
use crate::auth::ProtectedRoute;
use crate::pages::*;
use gloo_net::http::Request;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
/// Defines the application's various routes and their corresponding paths.
///
/// This enum is used by `yew-router` to map URLs to specific components,
/// enabling navigation within the single-page application.
#[derive(Clone, PartialEq, Routable)]
enum Route {
/// The application's home page.
#[at("/")]
Home,
/// Route for submitting a new ticket.
#[at("/ticket")]
Ticket,
/// Route for viewing a specific ticket by its ID.
#[at("/tickets/:id")]
TicketById { id: i32 },
/// Route for viewing all tickets.
#[at("/tickets")]
AllTickets,
/// Route for viewing archived tickets.
#[at("/tickets/archive")]
ArchivedTickets,
/// Route for user registration.
#[at("/register")]
Register,
/// Route for user login.
#[at("/login")]
Login,
/// Route for the initial administrator setup.
#[at("/setup")]
Setup,
/// Route for viewing all users.
#[at("/users")]
AllUsers,
/// Route for viewing a specific user by their ID.
#[at("/users/:id")]
UserByID { id: i16 },
/// Route for displaying diagnostics information (admin-only).
#[at("/diagnostics")]
Diagnostics,
/// Route displayed when a user attempts to access a page without sufficient permissions.
#[at("/denied")]
PermissionDenied,
/// Catch-all route for unmatched paths, leading to a 404 Not Found page.
#[not_found]
#[at("/404")]
NotFound,
}
/// Properties for the [`SidebarShell`] component.
#[derive(Properties, PartialEq)]
pub struct SidebarShellProps {
/// The child components to be rendered within the main content area of the shell.
pub children: Children,
}
/// 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
/// is always present for navigation.
///
/// # Components
/// - [`sidebar::Sidebar`]: The navigation sidebar component.
/// - Main content area: Renders the `children` passed to this component.
///
/// # Example
/// ```rust
/// html! {
/// <SidebarShell>
/// <p>{"Your page content goes here."}</p>
/// </SidebarShell>
/// }
/// ```
#[component(SidebarShell)]
fn sidebar_shell(props: &SidebarShellProps) -> Html {
html! {
<div class="layout">
<sidebar::Sidebar/>
<main class="content">
{ for props.children.iter() }
</main>
</div>
}
}
/// Props for the AdminCheckWrapper component.
#[derive(Properties, PartialEq)]
pub struct AdminCheckWrapperProps {
pub children: Children,
}
/// Wrapper component that checks if an admin exists before rendering children.
///
/// 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
/// `/api/check-admin` endpoint to determine system state.
///
/// # Behavior
/// - **Loading**: Displays "Loading..." while checking admin status
/// - **No Admin**: Automatically redirects to `/setup` page to initialize
/// - **Admin Exists**: Renders the wrapped children (e.g., login page)
///
/// # Example Usage
/// ```ignore
/// <AdminCheckWrapper>
/// <LoginPage />
/// </AdminCheckWrapper>
/// ```
///
/// # Implementation Detail
/// The check happens in `use_effect_with` on component mount:
/// ```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)]
fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html {
let admin_exists = use_state(|| None::<bool>);
let navigator = use_navigator().unwrap();
{
let admin_exists = admin_exists.clone();
use_effect_with((), move |_| {
let admin_exists = admin_exists.clone();
spawn_local(async move {
match Request::get("/api/check-admin").send().await {
Ok(resp) if resp.status() == 200 => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
let has_admin = data["has_admin"].as_bool().unwrap_or(false);
admin_exists.set(Some(has_admin));
}
}
_ => {
admin_exists.set(Some(false));
}
}
});
|| ()
});
}
match *admin_exists {
None => html! { <div>{ "Loading..." }</div> },
Some(false) => {
navigator.push(&Route::Setup);
html! { <div>{ "Redirecting to setup..." }</div> }
}
Some(true) => props.children.clone().into(),
}
}
/// The main routing logic for the application.
///
/// 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
/// navigation.
///
/// Many routes are wrapped in a [`ProtectedRoute`] to enforce authentication
/// and authorization, and in a [`SidebarShell`] to maintain consistent layout.
///
/// # Arguments
/// - `route`: The [`Route`] enum variant representing the current URL path.
///
/// # Returns
/// An `Html` component that should be rendered for the given route.
fn switch(route: Route) -> Html {
match route {
Route::Home => html! {
<ProtectedRoute admin_page={false}>
<SidebarShell>
<basic_pages::Home/>
</SidebarShell>
</ProtectedRoute>
},
Route::NotFound => html! { <basic_pages::NotFound/> },
Route::Ticket => html! {
<ProtectedRoute admin_page={false}>
<SidebarShell>
<ticket::SubmitTicket/>
</SidebarShell>
</ProtectedRoute>
},
Route::TicketById { id } => html! {
<ProtectedRoute admin_page={true}>
<SidebarShell>
<ticket::TicketByID {id}/>
</SidebarShell>
</ProtectedRoute>
},
Route::AllTickets => html! {
<ProtectedRoute admin_page={false}>
<SidebarShell>
<ticket::AllTickets/>
</SidebarShell>
</ProtectedRoute>
},
Route::ArchivedTickets => html! {
<ProtectedRoute admin_page={true}>
<SidebarShell>
<ticket::ArchivedTickets/>
</SidebarShell>
</ProtectedRoute>
},
Route::Register => html! {
<ProtectedRoute admin_page={true}>
<SidebarShell>
<user::Register/>
</SidebarShell>
</ProtectedRoute>
},
Route::Login => html! {
<AdminCheckWrapper>
<user::Login/>
</AdminCheckWrapper>
},
Route::Setup => html! {
<setup::InitialAdminSetup/>
},
Route::AllUsers => html! {
<ProtectedRoute admin_page={true}>
<SidebarShell>
<user::AllUsers/>
</SidebarShell>
</ProtectedRoute>
},
Route::UserByID { id } => html! {
<ProtectedRoute admin_page={true}>
<SidebarShell>
<user::UserByID {id}/>
</SidebarShell>
</ProtectedRoute>
},
Route::PermissionDenied => html! { <basic_pages::PermissionDenied/> },
Route::Diagnostics => html! {
<ProtectedRoute admin_page={true}>
<SidebarShell>
<utilities::Diagnostics/>
</SidebarShell>
</ProtectedRoute>
},
}
}
/// The root component of the Yew application.
///
/// This component sets up the application's routing using `yew-router`'s
/// [`BrowserRouter`] and [`Switch`] components. All other application content
/// is rendered based on the current route.
///
/// # Structure
/// - [`BrowserRouter`]: Enables client-side routing.
/// - [`Switch`]: Renders components based on the matched [`Route`].
#[component(App)]
pub fn app() -> Html {
html! {
<BrowserRouter>
<basic_pages::Icon/>
<Switch<Route> render={switch} />
</BrowserRouter>
}
}
+5
View File
@@ -0,0 +1,5 @@
use frontend::App;
fn main() {
yew::Renderer::<App>::new().render();
}
+118
View File
@@ -0,0 +1,118 @@
use gloo_net::http::Request;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
macro_rules! dequote {
($str:expr) => {
$str.trim_matches('"').to_string()
};
}
/// The main home page component of the application.
///
/// This component displays different content based on whether the logged-in user
/// is an administrator. It fetches the user's admin status from the
/// `/api/users/current` endpoint upon initialization.
///
/// # Behavior
/// - **Loading**: Displays "Loading..." while fetching user data.
/// - **Admin User**: Renders the `TicketCount` utility component.
/// - **Non-Admin User**: Renders the `TicketCount` utility component.
///
/// # Example
/// ```rust
/// html! {
/// <Home />
/// }
/// ```
#[component(Home)]
pub fn home_component() -> Html {
let name = use_state(|| "".to_string());
{
let name = name.clone();
use_effect_with((), move |_| {
spawn_local(async move {
let response = Request::get("/api/users/current")
.credentials(web_sys::RequestCredentials::Include)
.send()
.await;
match response {
Ok(resp) if resp.status() == 200 => {
let user_data: serde_json::Value = resp.json().await.unwrap_or_default();
let name_value = format!(
"{} {}",
dequote!(user_data["data"]["first_name"].to_string()),
dequote!(user_data["data"]["last_name"].to_string())
);
name.set(name_value);
}
_ => name.set("Unknown".to_string()),
}
});
|| ()
});
}
html! {
<div class="rundbg">
<crate::utilities::TicketCount/>
<p>{ "You are logged in as: " }</p>
<p>{ &*name }</p>
</div>
}
}
/// A basic component displayed when a requested route does not match any defined paths (404 error).
///
/// It provides a simple message indicating that the page was not found and includes
/// a link to navigate back to the home page.
///
/// # Example
/// ```rust
/// html! {
/// <NotFound />
/// }
/// ```
#[component(NotFound)]
pub fn not_found_component() -> Html {
let message = "404 Not found";
html! {
<div>
<h1>{&message}</h1>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>>
</div>
}
}
/// A component displayed when a user attempts to access a page for which they do not have sufficient permissions.
///
/// It informs the user about the access restriction and provides instructions to contact
/// a specific person ("Herr Winter") if they believe this is an error.
/// It also includes a link to return to the home page.
///
/// # Example
/// ```rust
/// html! {
/// <PermissionDenied />
/// }
/// ```
#[component(PermissionDenied)]
pub fn denied_component() -> Html {
html! {
<div>
<h1>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</h1>
<h3>{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</h3>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>>
</div>
}
}
#[component(Icon)]
pub fn icon_component() -> Html {
html! {
<img src="assets/csg.png" class="csg-icon" />
}
}
+13
View File
@@ -0,0 +1,13 @@
//! This module aggregates and re-exports all individual page components of the application.
//!
//! Each sub-module within `pages` typically represents a distinct view or section
//! of the user interface, such as authentication, ticket management, or user profiles.
//!
//! 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 setup;
pub mod sidebar;
pub mod ticket;
pub mod user;
pub mod utilities;
+281
View File
@@ -0,0 +1,281 @@
use gloo_net::http::Request;
use serde::{Deserialize, Serialize};
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
/// Payload for creating the initial administrator account.
///
/// This struct is sent to the `/api/setup-admin` endpoint to create the first admin user
/// when no administrators exist in the system. It carries the necessary information
/// for the new admin's profile and credentials.
///
/// # Fields
/// - `first_name`: The first name of the administrator.
/// - `last_name`: The last name of the administrator.
/// - `username`: The unique username for the administrator's login.
/// - `pwd`: The password for the administrator's account. This will be hashed on the backend.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AdminSetupScheme {
pub first_name: String,
pub last_name: String,
pub username: String,
pub pwd: String,
}
/// Component for the initial admin account setup page.
///
/// This page is displayed when a fresh system has no administrator accounts. It provides
/// 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`.
/// 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 Validation**:
/// - Ensures password fields are not empty.
/// - Verifies that `password` and `confirm_password` match.
/// - Ensures the `username` field is not empty.
/// - **API Interaction**: On form submission, a POST request is sent to `/api/setup-admin`
/// with the new admin's details.
/// - **Password Hashing**: The backend is responsible for hashing the password using Argon2
/// before storage; this component only sends the plain text password.
/// - **Auto-redirect**: On successful admin account creation, the user is automatically
/// redirected to the login page (`crate::Route::Login`).
/// - **State Management**: Uses Yew's `use_state` hooks to manage:
/// - Input field values (`first_name`, `last_name`, `username`, `pwd`, `pwd_confirm`).
/// - UI states like `error` messages, `success` status, and `loading` indicators.
/// - `admin_check_done` to prevent rendering the form before the initial admin check completes.
///
/// # Example Flow
/// 1. User navigates to `/setup`.
/// 2. The component checks `/api/check-admin`.
/// 3. If an admin exists, redirects to `/login`.
/// 4. If no admin exists, the setup form is displayed.
/// 5. User fills out the form and submits.
/// 6. Form data is sent via POST to `/api/setup-admin`.
/// 7. On successful response (HTTP 200), redirects to `/login`.
/// 8. On error, displays an error message to the user.
///
/// # Security Notes
/// - The initial admin check prevents re-creating an admin if one already exists.
/// - Password confirmation helps prevent user typos for critical credentials.
/// - Backend validation further ensures non-empty and secure credentials.
#[component(InitialAdminSetup)]
pub fn initial_admin_setup() -> Html {
let first_name = use_state(|| "".to_string());
let last_name = use_state(|| "".to_string());
let username = use_state(|| "".to_string());
let pwd = use_state(|| "".to_string());
let pwd_confirm = use_state(|| "".to_string());
let error = use_state(|| String::new());
let success = use_state(|| false);
let loading = use_state(|| false);
let admin_check_done = use_state(|| false);
let navigator = use_navigator().unwrap();
{
let admin_check_done = admin_check_done.clone();
let navigator = navigator.clone();
use_effect_with((), move |_| {
let admin_check_done = admin_check_done.clone();
let navigator = navigator.clone();
spawn_local(async move {
match Request::get("/api/check-admin").send().await {
Ok(resp) if resp.status() == 200 => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
let has_admin = data["has_admin"].as_bool().unwrap_or(false);
if has_admin {
navigator.push(&crate::Route::Login);
} else {
admin_check_done.set(true);
}
} else {
admin_check_done.set(true);
}
}
_ => {
admin_check_done.set(true);
}
}
});
|| ()
});
}
if !*admin_check_done {
return html! { <div>{ "Checking..." }</div> };
}
let onsubmit = {
let first_name = first_name.clone();
let last_name = last_name.clone();
let username = username.clone();
let pwd = pwd.clone();
let pwd_confirm = pwd_confirm.clone();
let error = error.clone();
let success = success.clone();
let loading = loading.clone();
let navigator = navigator.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
if (*pwd).is_empty() || (*pwd_confirm).is_empty() {
error.set("Password fields cannot be empty".to_string());
return;
}
if *pwd != *pwd_confirm {
error.set("Passwords do not match".to_string());
return;
}
if (*username).is_empty() {
error.set("Username cannot be empty".to_string());
return;
}
let first_name_val = (*first_name).clone();
let last_name_val = (*last_name).clone();
let username_val = (*username).clone();
let pwd_val = (*pwd).clone();
loading.set(true);
error.set(String::new());
success.set(false);
let error = error.clone();
let success = success.clone();
let loading = loading.clone();
let navigator = navigator.clone();
spawn_local(async move {
let payload = AdminSetupScheme {
first_name: first_name_val,
last_name: last_name_val,
username: username_val,
pwd: pwd_val,
};
let response = Request::post("/api/setup-admin")
.header("Content-Type", "application/json")
.json(&payload)
.unwrap()
.send()
.await;
loading.set(false);
match response {
Ok(r) if r.status() == 200 => {
success.set(true);
navigator.push(&crate::Route::Login);
}
Ok(r) => {
let text = r.text().await.unwrap_or_else(|_| "unknown".into());
error.set(format!("HTTP {}: {}", r.status(), text));
}
Err(err) => error.set(format!("Network error: {}", err)),
}
});
})
};
html! {
<div class="setup-container">
<div class="setup-box">
<h1>{ "Initial Admin Setup" }</h1>
<p>{ "Create your first administrator account" }</p>
<form {onsubmit} class="setup-form">
<div class="form-group">
<label for="first_name">{ "First Name:" }
<input
id="first_name"
type="text"
placeholder="First name"
value={(*first_name).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
first_name.set(input.value());
})}
/>
</label>
</div>
<div class="form-group">
<label for="last_name">{ "Last Name:" }
<input
id="last_name"
type="text"
placeholder="Last name"
value={(*last_name).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
last_name.set(input.value());
})}
/>
</label>
</div>
<div class="form-group">
<label for="username">{ "Username:" }
<input
id="username"
type="text"
placeholder="Username"
value={(*username).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
username.set(input.value());
})}
/>
</label>
</div>
<div class="form-group">
<label for="password">{ "Password:" }
<input
id="password"
type="password"
placeholder="Password"
value={(*pwd).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
pwd.set(input.value());
})}
/>
</label>
</div>
<div class="form-group">
<label for="pwd_confirm">{ "Confirm Password:" }
<input
id="pwd_confirm"
type="password"
placeholder="Confirm password"
value={(*pwd_confirm).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
pwd_confirm.set(input.value());
})}
/>
</label>
</div>
<button type="submit" disabled={*loading} class="submit-btn">
{ if *loading { "Creating..." } else { "Create Admin Account" } }
</button>
if !error.is_empty() {
<p class="error-message" style="color:red">{ (*error).clone() }</p>
}
if *success {
<p class="success-message" style="color:green">{ "Admin account created successfully! Redirecting to login..." }</p>
}
</form>
</div>
</div>
}
}
+422
View File
@@ -0,0 +1,422 @@
use gloo_net::http::Request;
use gloo_storage::{LocalStorage, Storage};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::rc::Rc;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
const STORAGE_KEY: &str = "sidebar_state";
/// Represents the expansion state of collapsible menus within the sidebar.
///
/// This struct is used to persist the open/closed state of sidebar submenus,
/// improving user experience by remembering their last interaction.
/// The state is stored in and retrieved from `LocalStorage`.
///
/// # Fields
/// - `ticket_open`: A boolean indicating if the "Tickets" submenu is expanded (`true`) or collapsed (`false`).
/// - `users_open`: A boolean indicating if the "Users" submenu is expanded (`true`) or collapsed (`false`).
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct SidebarExpandState {
pub ticket_open: bool,
pub users_open: bool,
}
impl Default for SidebarExpandState {
/// Provides the default expansion state, where all submenus are collapsed.
fn default() -> Self {
Self {
ticket_open: false,
users_open: false,
}
}
}
/// Represents the shared state for the sidebar's expandable menus.
///
/// This context struct is provided via Yew's `ContextProvider` and allows child components
/// within the sidebar to read and modify the expansion state of the "Tickets" and "Users" menus.
///
/// # Fields
/// - `expand`: The current [`SidebarExpandState`] holding whether each menu is open or closed.
/// - `set_tickets_open`: A `Callback<bool>` to explicitly set the open state of the "Tickets" menu.
/// - `toggle_tickets`: A `Callback<()>` to toggle the open state of the "Tickets" menu.
/// - `set_users_open`: A `Callback<bool>` to explicitly set the open state of the "Users" menu.
/// - `toggle_users`: A `Callback<()>` to toggle the open state of the "Users" menu.
#[derive(Clone, PartialEq)]
pub struct SidebarState {
pub expand: SidebarExpandState,
pub set_tickets_open: Callback<bool>,
pub toggle_tickets: Callback<()>,
pub set_users_open: Callback<bool>,
pub toggle_users: Callback<()>,
}
impl SidebarState {
/// Creates a new `SidebarState` instance.
///
/// This constructor is typically used within the [`SidebarStateProvider`] to
/// bundle the current expansion state and its associated callbacks for context sharing.
fn new(
expand: SidebarExpandState,
set_tickets_open: Callback<bool>,
toggle_tickets: Callback<()>,
set_users_open: Callback<bool>,
toggle_users: Callback<()>,
) -> Self {
Self {
expand,
set_tickets_open,
toggle_tickets,
set_users_open,
toggle_users,
}
}
}
/// Properties for components that provide sidebar state.
///
/// This struct is typically used by context providers that wrap child components
/// and supply them with shared sidebar-related state or functionality.
///
/// # Fields
/// - `children`: The child components that will have access to the provided sidebar state.
#[derive(Properties, PartialEq)]
pub struct SidebarProps {
pub children: Children,
}
/// A Yew context provider component that manages and supplies the sidebar's expansion state.
///
/// This component is responsible for:
/// 1. Loading the initial `SidebarExpandState` from browser `LocalStorage` (or using defaults).
/// 2. Providing a `SidebarState` context (`Rc<SidebarState>`) to its children, which includes
/// the current expansion state and callbacks to modify it.
/// 3. Persisting any changes to the `SidebarExpandState` back to `LocalStorage`.
///
/// Child components (like [`TicketMenu`] and [`UsersMenu`]) can consume this context
/// to react to and control the sidebar's collapsible sections.
///
/// # LocalStorage Key
/// The state is stored under the key `STORAGE_KEY` ("sidebar_state").
///
/// # Example Usage
/// ```rust
/// html! {
/// <SidebarStateProvider>
/// <Sidebar /> // Sidebar and its sub-components will have access to the state
/// </SidebarStateProvider>
/// }
/// ```
#[component(SidebarStateProvider)]
pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let default = LocalStorage::get(STORAGE_KEY).unwrap_or_else(|_| SidebarExpandState::default());
let state = use_state(|| default);
{
let state = state.clone();
use_effect_with(state, move |s| {
LocalStorage::set(STORAGE_KEY, &**s).ok();
|| ()
});
}
let set_tickets_open = {
let state = state.clone();
Callback::from(move |v: bool| {
state.set(SidebarExpandState {
ticket_open: v,
users_open: (*state).users_open,
})
})
};
let toggle_tickets = {
let state = state.clone();
Callback::from(move |_| {
let current = (*state).ticket_open;
state.set(SidebarExpandState {
ticket_open: !current,
users_open: (*state).users_open,
});
})
};
let set_users_open = {
let state = state.clone();
Callback::from(move |v: bool| {
state.set(SidebarExpandState {
ticket_open: (*state).ticket_open,
users_open: v,
})
})
};
let toggle_users = {
let state = state.clone();
Callback::from(move |_| {
let current = (*state).users_open;
state.set(SidebarExpandState {
ticket_open: (*state).ticket_open,
users_open: !current,
});
})
};
let ctx = SidebarState::new(
(*state).clone(),
set_tickets_open,
toggle_tickets,
set_users_open,
toggle_users,
);
html! {
<ContextProvider<Rc<SidebarState>> context={Rc::new(ctx)}>
{ for props.children.iter() }
</ContextProvider<Rc<SidebarState>>>
}
}
/// A collapsible menu component for "Tickets" within the sidebar.
///
/// This component consumes the [`SidebarState`] context to manage its expanded/collapsed state.
/// It displays a button to toggle its visibility and, when expanded, reveals links
/// for "Submit Ticket" and "View Tickets".
///
/// # Context
/// Requires [`SidebarStateProvider`] as an ancestor to provide the necessary context.
///
/// # Functionality
/// - **Toggle Button**: Clicking the button toggles the `ticket_open` state in the `SidebarState`.
/// - **Submenu Links**:
/// - [`crate::Route::Ticket`]: Link to submit a new ticket.
/// - [`crate::Route::AllTickets`]: Link to view all tickets.
///
/// # Example
/// ```rust
/// html! {
/// <TicketMenu />
/// }
/// ```
#[component(TicketMenu)]
pub fn ticket_menu() -> Html {
let ctx =
use_context::<Rc<SidebarState>>().expect("TicketsMenu must be inside SidebarStateProvider");
let on_toggle = {
let cb = ctx.toggle_tickets.clone();
Callback::from(move |_| cb.emit(()))
};
let open = ctx.expand.ticket_open;
html! {
<li class={ if open { "menu open" } else { "menu" } }>
<button
class="menu-toggle"
onclick={on_toggle}
aria-expanded={open.to_string()}
>
{ "Tickets" }
{ if open { "" } else { "" } }
</button>
{
if open {
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>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "View Tickets" }</span></Link<crate::Route>>
</li>
</ul>
}
} else {
html!{}
}
}
</li>
}
}
/// A collapsible menu component for "Users" within the sidebar.
///
/// This component consumes the [`SidebarState`] context to manage its expanded/collapsed state.
/// It displays a button to toggle its visibility and, when expanded, reveals links
/// for "Create User" and "View Users". This menu is typically only visible to administrators.
///
/// # Context
/// Requires [`SidebarStateProvider`] as an ancestor to provide the necessary context.
///
/// # Functionality
/// - **Toggle Button**: Clicking the button toggles the `users_open` state in the `SidebarState`.
/// - **Submenu Links**:
/// - [`crate::Route::Register`]: Link to create a new user account.
/// - [`crate::Route::AllUsers`]: Link to view all registered users.
///
/// # Example
/// ```rust
/// html! {
/// <UsersMenu />
/// }
/// ```
#[component(UsersMenu)]
pub fn users_menu() -> Html {
let ctx =
use_context::<Rc<SidebarState>>().expect("UsersMenu must be inside SidebarStateProvider");
let on_toggle = {
let cb = ctx.toggle_users.clone();
Callback::from(move |_| cb.emit(()))
};
let open = ctx.expand.users_open;
html! {
<li class={ if open { "menu open" } else { "menu" } }>
<button
class="menu-toggle"
onclick={on_toggle}
aria-expanded={open.to_string()}
>
{ "Users" }
{ if open { "" } else { "" } }
</button>
{
if open {
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>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "View Users" }</span></Link<crate::Route>>
</li>
</ul>
}
} else {
html!{}
}
}
</li>
}
}
/// The main sidebar component of the application.
///
/// This component dynamically renders its content based on the user's authentication
/// and administrative status. It fetches the current user's details via `/api/users/current`
/// to determine what menu items to display.
///
/// # Structure
/// - 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.
///
/// # Conditional Rendering
/// - **Loading**: Displays "Loading..." while fetching user data.
/// - **Non-Admin User**: Renders a condensed sidebar including:
/// - [`TicketMenu`]: For managing tickets.
/// - A "Logout" button.
/// - **Admin User**: Renders a full sidebar including:
/// - [`TicketMenu`]: For managing tickets.
/// - [`UsersMenu`]: For managing user accounts.
/// - A direct link to [`crate::Route::Diagnostics`] (Statistiken).
/// - A "Logout" button.
///
/// # Logout Functionality
/// 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`).
#[component(Sidebar)]
pub fn sidebar() -> Html {
let is_admin = use_state(|| None::<bool>);
let navigator = use_navigator().expect("Sidebar must be used within a Router");
{
let is_admin = is_admin.clone();
use_effect_with((), move |_| {
spawn_local(async move {
let response = Request::get("/api/users/current")
.credentials(web_sys::RequestCredentials::Include)
.send()
.await;
match response {
Ok(resp) if resp.status() == 200 => {
let user_data: Value = resp.json().await.unwrap_or_default();
let admin_value = user_data["data"]["is_admin"].as_bool();
is_admin.set(admin_value);
}
_ => is_admin.set(Some(false)),
}
});
|| ()
});
}
let on_logout = {
let navigator = navigator.clone();
Callback::from(move |_| {
let navigator = navigator.clone();
spawn_local(async move {
let _ = Request::get("/api/logout")
.credentials(web_sys::RequestCredentials::Include)
.send()
.await;
navigator.push(&crate::Route::Login);
});
})
};
match *is_admin {
None => html! { <div class="sidebar-loading">{ "Loading..." }</div> },
// Non-admin: render a condensed user sidebar (no diagnostics, limited links)
Some(false) => html! {
<SidebarStateProvider>
<nav class="sidebar user">
<ul>
<Link<crate::Route> to={crate::Route::Home}>{ "󰟒" }</Link<crate::Route>>
<TicketMenu/>
<li class="logout-item">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Logout" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
},
// Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => html! {
<SidebarStateProvider>
<nav class="sidebar admin">
<ul>
<Link<crate::Route> to={crate::Route::Home}>{ "󰟒" }</Link<crate::Route>>
<TicketMenu/>
<UsersMenu/>
<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">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Logout" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
},
}
}
+830
View File
@@ -0,0 +1,830 @@
use std::collections::HashSet;
use chrono_tz::Europe::Berlin;
use gloo_net::http::Request;
use serde::{Deserialize, Serialize};
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use web_sys::HtmlSelectElement;
use yew::prelude::*;
use yew_router::prelude::*;
/// A hardcoded list of valid room numbers for ticket submissions.
///
/// This array contains `i16` values representing valid rooms. Negative numbers typically
/// denote rooms prefixed with 'K' (e.g., -1 for K1), and numbers greater than or equal to 1000
/// denote rooms prefixed with 'D' (e.g., 1001 for D1).
///
/// This list is used for client-side validation of room input during ticket creation.
const VALID_ROOMS: &[i16] = &[
1, 2, 3, 4, 5, 6, 7, 8, 9, 13, 14, 15, 16, 17, 18, 19, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30,
32, 34, 36, 37, 39, 41, 49, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113,
118, 120, 121, 122, 123, 124, 126, 127, 128, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142,
143, 144, 145, 146, 147, 148, 150, 152, 153, 154, 155, 156, 157, 158, 159, 160, 201, 202, 203,
204, 205, 206, 207, 208, 209, 210, 211, 212, 214, 1001, 1002, 1003, -1, -2, -3, -4, -5, -6, -7,
-8,
];
/// Data transfer object (DTO) for creating a new ticket.
///
/// This struct defines the payload sent to the backend when a user submits a new ticket.
/// It includes all necessary information for initial ticket creation.
///
/// # Fields
/// - `category`: The category of the ticket (e.g., "Internet", "Hardware").
/// - `betreff`: The subject or brief summary of the ticket.
/// - `description`: A detailed description of the issue or request.
/// - `room`: The room number where the issue is located.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct TicketCreateScheme {
pub category: String,
pub betreff: String,
pub description: String,
pub room: i16,
}
/// Data transfer object (DTO) for updating a ticket's status.
///
/// This struct is used when sending a request to the backend to change the status
/// of an existing ticket.
///
/// # Fields
/// - `status`: The new status of the ticket (e.g., "ToDo", "InProgress", "Completed", "Archived").
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct TicketUpdateScheme {
pub status: String,
}
/// Represents a complete ticket entity retrieved from the backend.
///
/// This struct holds all details of a ticket, including its metadata, content,
/// and associated user information.
///
/// # Fields
/// - `id`: The unique identifier of the ticket.
/// - `category`: The category of the ticket.
/// - `betreff`: The subject of the ticket.
/// - `description`: The detailed description of the ticket.
/// - `room`: The room number associated with the ticket.
/// - `status`: The current status of the ticket.
/// - `date`: The creation date and time of the ticket in UTC.
/// - `user_id`: The ID of the user who created the ticket.
/// - `user_first_name`: The first name of the user who created the ticket.
/// - `user_last_name`: The last name of the user who created the ticket.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Ticket {
pub id: i32,
pub category: String,
pub betreff: String,
pub description: String,
pub room: i16,
pub status: String,
pub date: chrono::DateTime<chrono::Utc>,
pub user_id: i16,
pub user_first_name: String,
pub user_last_name: String,
}
/// Properties for components that display a single ticket.
///
/// This struct is used to pass the ID of a specific ticket to components
/// that need to fetch or display its details.
///
/// # Fields
/// - `id`: The unique identifier of the ticket to be displayed.
#[derive(Properties, PartialEq)]
pub struct TicketProps {
pub id: i32,
}
/// Represents the essential information of the currently authenticated user.
///
/// This struct is used to hold a subset of user data relevant for client-side
/// logic, such as determining if the user is an administrator or filtering
/// tickets by their ID.
///
/// # Fields
/// - `id`: An `Option<i16>` holding the unique ID of the active user. `None` if not authenticated or ID is unavailable.
/// - `is_admin`: A boolean indicating if the active user has administrator privileges.
#[derive(Clone, Debug, PartialEq)]
pub struct ActiveUser {
pub id: Option<i16>,
pub is_admin: bool,
}
/// Represents a standardized error response from the API.
///
/// This struct is used to deserialize error messages returned by the backend API,
/// providing a consistent way to handle and display error information to the user.
///
/// # Fields
/// - `message`: A descriptive error message.
/// - `_status`: (Ignored) The HTTP status code or a status string from the API.
#[derive(Deserialize, Debug)]
struct ApiError {
message: String,
_status: String,
}
/// A component that provides a form for users to submit new tickets.
///
/// This component allows users to input details such as category, subject (`betreff`),
/// description, and room number for a new support ticket. It includes client-side
/// validation for the room number and interacts with the backend API to create the ticket.
///
/// # State
/// Uses `use_state` hooks to manage:
/// - `category`, `betreff`, `description`: Values of the form input fields.
/// - `room`, `room_input`: The parsed and raw room numbers, respectively.
/// - `status`: To display messages about submission success or errors.
///
/// # Room Input Handling
/// The `room_change` callback handles parsing the room input:
/// - Supports numerical room numbers (e.g., "101").
/// - Supports 'K' prefixed rooms (e.g., "K1" parses to -1).
/// - Supports 'D' prefixed rooms (e.g., "D1" parses to 1001).
/// - Validates the parsed room against the `VALID_ROOMS` constant.
///
/// # Form Submission
/// - Prevents default form submission behavior.
/// - Performs validation for room number and presence in `VALID_ROOMS`.
/// - Constructs a `TicketCreateScheme` payload.
/// - Sends a POST request to `/api/tickets/create`.
/// - Updates the `status` state based on the API response.
///
/// # Example
/// ```rust
/// html! {
/// <SubmitTicket />
/// }
/// ```
#[component(SubmitTicket)]
pub fn submit_ticket_component() -> Html {
let category = use_state(|| "".to_string());
let betreff = use_state(|| "".to_string());
let description = use_state(|| "".to_string());
let room = use_state(|| None::<i16>);
let room_input = use_state(|| "".to_string());
let status = use_state(|| None::<String>);
let valid_rooms: HashSet<i16> = VALID_ROOMS.iter().copied().collect();
let onsubmit = {
let category = category.clone();
let betreff = betreff.clone();
let description = description.clone();
let room = room.clone();
let status = status.clone();
let valid_rooms = valid_rooms.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
if room.is_none() {
status.set(Some("Invalid room".into()));
return;
}
let category = (*category).clone();
let betreff = (*betreff).clone();
let description = (*description).clone();
let room = room.unwrap();
if !valid_rooms.contains(&room) {
status.set(Some("Room not allowed".into()));
return;
}
let status = status.clone();
spawn_local(async move {
let payload = TicketCreateScheme {
category,
betreff,
description,
room,
};
let request = Request::post("/api/tickets/create")
.header("Content-Type", "application/json")
.credentials(web_sys::RequestCredentials::Include)
.json(&payload)
.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))),
}
});
})
};
let category_change = {
let category = category.clone();
Callback::from(move |e: Event| {
let input: web_sys::HtmlSelectElement = e.target_unchecked_into();
category.set(input.value());
})
};
let betreff_change = {
let betreff = betreff.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
betreff.set(input.value());
})
};
let description_change = {
let description = description.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
description.set(input.value());
})
};
let room_change = {
let room = room.clone();
let room_input = room_input.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
let raw = input.value();
room_input.set(raw.clone());
let parsed_value: Option<i16> = if raw.trim().is_empty() {
None
} else {
let raw_trim = raw.trim();
if raw_trim.len() > 1 && (raw_trim.starts_with('K') || raw_trim.starts_with('k')) {
match raw_trim[1..].parse::<i16>() {
Ok(n) => Some(-n),
Err(_) => None,
}
} else if raw_trim.len() > 1
&& (raw_trim.starts_with('D') || raw_trim.starts_with('d'))
{
match raw_trim[1..].parse::<i16>() {
Ok(n) => Some(1000 + n),
Err(_) => None,
}
} else {
match raw_trim.parse::<i16>() {
Ok(n) => Some(n),
Err(_) => None,
}
}
};
if let Some(v) = parsed_value {
if !valid_rooms.contains(&v) {
room.set(None);
} else {
room.set(Some(v));
}
} else {
room.set(None);
}
})
};
let room_valid = (*room).is_some();
html! {
<form class="rundbg" {onsubmit}>
<label>{ "Betreff:" }
<input type="text" value={(*betreff).clone()} oninput={betreff_change}/>
</label>
<br/>
<label>{ "Beschreibung:" }
<input type="text" value={(*description).clone()} oninput={description_change}/>
</label>
<br/>
<label>{ "Kategorie:" }
<select value={(*category).clone()} onchange={category_change}>
<option value="Whiteboard Beamer">{ "Whiteboard Beamer" }</option>
<option value="Internet">{ "Internet" }</option>
<option value="iPad Koffer">{ "iPad Koffer" }</option>
<option value="Apple TV">{ "Apple TV" }</option>
<option value="Docu Cam">{ "Dokumenten Kamera" }</option>
<option value="Sonstiges">{ "Sonstiges" }</option>
</select>
</label>
<br/>
<label>{ "Raum:" }
<input type="text" value={(*room_input).clone()} oninput={room_change}/>
</label>
{
if !room_valid {
html! {
<p style="color: red;">{ "Ungültiger oder nicht erlaubter Raum (z. B. 101, K12, D5)" }</p>
}
} else {
html! {}
}
}
<br/>
<button type="submit">{ "Send" }</button>
<Link<crate::Route> to={crate::Route::AllTickets}>{ "Tickets ansehen" }</Link<crate::Route>>
{
if let Some(s) = &*status {
html!{ <p>{ s }</p> }
} else {
html!{}
}
}
</form>
}
}
/// A component for displaying, updating, and deleting a single ticket by its ID.
///
/// This component fetches a specific ticket's details from the backend based on the
/// `id` provided in its `TicketProps`. It allows administrators to update the ticket's
/// status and delete the ticket.
///
/// # Props
/// - `id`: The `i32` ID of the ticket to fetch and display.
///
/// # State
/// Uses `use_state` hooks to manage:
/// - `ticket`: The fetched [`Ticket`] data, if available.
/// - `error`: Any error message encountered during API calls.
/// - `loading`: A boolean indicating if data is currently being fetched.
/// - `status`: The selected status for updating the ticket.
/// - `deleting`: A boolean indicating if a delete operation is in progress.
/// - `delete_error`: Any error message from a delete operation.
///
/// # Functionality
/// - **Data Fetching**: On component mount or `id` change, fetches ticket data from
/// `/api/tickets/:id`.
/// - **Status Update**: Provides a dropdown to change the ticket's status. Submitting the
/// form sends a PATCH request to `/api/tickets/:id` with a `TicketUpdateScheme` payload.
/// - **Ticket Deletion**: Includes a "Delete" button that, after user confirmation,
/// sends a DELETE request to `/api/tickets/:id`. On success, clears the ticket from display.
/// - **Error Handling**: Displays error messages for network issues, API errors, or parsing failures.
/// - **Date Formatting**: Displays the ticket creation date formatted to `Europe/Berlin` timezone.
///
/// # Example
/// ```rust
/// html! {
/// <TicketByID id={123} />
/// }
/// ```
#[component(TicketByID)]
pub fn ticket_by_id_component(props: &TicketProps) -> Html {
let ticket = use_state(|| None::<Ticket>);
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
let id = props.id;
let status = use_state(|| "".to_string());
{
let ticket = ticket.clone();
let error = error.clone();
let loading = loading.clone();
use_effect_with(id, move |id_ref| {
loading.set(true);
let ticket = ticket.clone();
let error = error.clone();
let id = *id_ref;
spawn_local(async move {
let url = format!("/api/tickets/{}", id);
match Request::get(&url).send().await {
Ok(response) => {
let status = response.status();
if status == 200 {
match response.json::<Ticket>().await {
Ok(t) => ticket.set(Some(t)),
Err(err) => error.set(Some(format!("Parse error: {}", err))),
}
} else {
match response.json::<ApiError>().await {
Ok(ae) => error.set(Some(ae.message)),
Err(_) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("Server error: {}", status)));
}
}
}
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
}
loading.set(false);
});
|| ()
});
}
let onsubmit = {
let status = status.clone();
let id = id.clone();
let error = error.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let form: web_sys::HtmlFormElement = e.target_unchecked_into();
let select_element = form
.query_selector("select[name=\"status\"]")
.ok()
.and_then(|opt| opt)
.and_then(|el| el.dyn_into::<HtmlSelectElement>().ok());
let new_status = select_element
.map(|s| s.value())
.unwrap_or_else(|| (*status).clone());
status.set(new_status.clone());
let id = id.clone();
let error = error.clone();
spawn_local(async move {
let value = TicketUpdateScheme { status: new_status };
let url = format!("/api/tickets/{}", id);
let request = Request::patch(&url)
.json(&value)
.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))),
}
});
})
};
let deleting = use_state(|| false);
let delete_error = use_state(|| None::<String>);
let ondelete = {
let deleting = deleting.clone();
let delete_error = delete_error.clone();
let ticket_state = ticket.clone();
let id = id;
Callback::from(move |e: MouseEvent| {
e.prevent_default();
if !web_sys::window()
.and_then(|w| w.confirm_with_message("Sicher löschen?").ok())
.unwrap_or(false)
{
return;
}
deleting.set(true);
delete_error.set(None);
let deleting = deleting.clone();
let delete_error = delete_error.clone();
let ticket_state = ticket_state.clone();
spawn_local(async move {
let url = format!("/api/tickets/{}", id);
let request =
Request::delete(&url).credentials(web_sys::RequestCredentials::Include);
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
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
}
Err(err) => delete_error.set(Some(format!("Network error: {}", err))),
}
deleting.set(false);
});
})
};
if *loading {
html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
} else if let Some(t) = &*ticket {
html! {
<div>
<h1>{ format!("Ticket #{}", t.id) }</h1>
<p><strong>{ "Kategorie: " }</strong>{ &t.category }</p>
<p><strong>{ "Betreff: " }</strong>{ &t.betreff }</p>
<p><strong>{ "Beschreibung: " }</strong>{ &t.description }</p>
<p><strong>{ "Raum: " }</strong>{ t.room }</p>
<p><strong>{ "Datum: " }</strong>{
t.date.with_timezone(&Berlin).format("%d.%m.%Y %H:%M:%S").to_string()
}</p>
<p><strong>{ "Status: "}</strong>{ match t.status.as_str() {
"ToDo" => "Zu tun",
"InProgress" => "In Bearbeitung",
"Completed" => "Erledigt",
"Archived" => "Archiviert",
_ => "Ungültiger Status"
}}</p>
<p><strong>{ "Name: "}</strong>{ format!{"{} {}", t.user_first_name, t.user_last_name } }</p>
<form {onsubmit}>
<label>{ "Status ändern" }
<select name="status">
<option value="ToDo">{ "Zu tun" }</option>
<option value="InProgress">{ "In Bearbeitung" }</option>
<option value="Completed">{ "Erledigt" }</option>
<option value="Archived">{ "Archiviert" }</option>
</select>
</label>
<button type="submit">{ "Aktualisieren" }</button>
</form>
<button onclick={ondelete} disabled={*deleting}>
{if *deleting {"Löschen..."} else {"Löschen"}}
</button>
<Link<crate::Route> to={crate::Route::AllTickets}>{ "Zurück zur Ticketübersicht" }</Link<crate::Route>>
if let Some(err) = &*delete_error {
<p style="color:red">{ err.clone() }</p>
}
</div>
}
} else {
html! { <p>{ "No ticket found." }</p> }
}
}
/// A component for fetching and displaying a list of all tickets.
///
/// This component retrieves all tickets from the backend and presents them as a list.
/// It dynamically filters the displayed tickets based on the current user's role:
/// administrators see all tickets, while regular users only see tickets they created.
///
/// # State
/// Uses `use_state` hooks to manage:
/// - `tickets`: A vector of `Ticket` structs to store the fetched tickets.
/// - `error`: Any error message encountered during API calls.
/// - `loading`: A boolean indicating if data is currently being fetched.
/// - `user`: An `ActiveUser` struct holding the current user's ID and admin status.
///
/// # Functionality
/// - **Fetch Tickets**: On component mount, fetches all tickets from `/api/tickets`.
/// - **Fetch Current User**: Concurrently fetches the current user's details from
/// `/api/users/current` to determine their `user_id` and `is_admin` status.
/// - **Conditional Display**:
/// - If `loading` is true, displays "Loading...".
/// - If an `error` occurs, displays the error message.
/// - Otherwise, renders a list of tickets.
/// - **Filtering**:
/// - Administrators (`user.is_admin = true`) see all tickets.
/// - Regular users (`user.is_admin = false`) only see tickets where `t.user_id == user.id`.
/// - **Navigation**: Each ticket in the list is a link to [`crate::Route::TicketById`]
/// for viewing individual ticket details.
///
/// # Example
/// ```rust
/// html! {
/// <AllTickets />
/// }
/// ```
#[component(AllTickets)]
pub fn all_tickets_component() -> Html {
let tickets = use_state(|| Vec::<Ticket>::new());
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
let user = use_state(|| ActiveUser {
id: None,
is_admin: false,
});
{
let tickets = tickets.clone();
let error = error.clone();
let loading = loading.clone();
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = format!("/api/tickets");
match Request::get(&url).send().await {
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))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
}
loading.set(false);
});
|| ()
});
}
{
let user = user.clone();
use_effect_with((), move |_| {
let user = user.clone();
spawn_local(async move {
if let Ok(response) = Request::get("/api/users/current")
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
{
if response.status() == 200 {
if let Ok(json) = response.json::<serde_json::Value>().await {
let id = json
.get("data")
.and_then(|d| d.get("id"))
.and_then(|v| v.as_i64())
.and_then(|n| i16::try_from(n).ok());
let is_admin = json
.get("data")
.and_then(|d| d.get("is_admin"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
}
}
});
|| ()
});
}
if *loading {
html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
} else {
html! {
<ul class= "postits">
{ 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() {
"ToDo" => "To-Do",
"InProgress" => "InProgress",
"Completed" => "Completed",
"Archived" => "Archived",
_ => "To-Do"
};
html! {
<div class={classes!{status_class, "listbox"}}>
<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>>
<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>
</div>
}})}
<Link<crate::Route> to={crate::Route::Ticket}>{ "Zurück zur Startseite" }</Link<crate::Route>>
</ul>
}
}
}
/// A component for fetching and displaying a list of archived tickets.
///
/// This component retrieves all tickets from the backend and presents them as a list,
/// filtered to only show those with "Archived" status.
///
/// # State
/// Uses `use_state` hooks to manage:
/// - `tickets`: A vector of `Ticket` structs to store the fetched tickets.
/// - `error`: Any error message encountered during API calls.
/// - `loading`: A boolean indicating if data is currently being fetched.
/// - `user`: An `ActiveUser` struct holding the current user's ID and admin status.
///
/// # Functionality
/// - **Fetch Tickets**: On component mount, fetches all tickets from `/api/tickets`.
/// - **Fetch Current User**: Concurrently fetches the current user's details from
/// `/api/users/current` to determine their `user_id` and `is_admin` status.
/// - **Conditional Display**:
/// - If `loading` is true, displays "Loading...".
/// - If an `error` occurs, displays the error message.
/// - Otherwise, renders a list of tickets.
/// - **Filtering**:
/// - Only tickets with `t.status == "Archived"` are displayed.
/// - If the user is an admin, all archived tickets are shown.
/// - If the user is not an admin, only their own archived tickets are shown.
/// - **Navigation**: Each ticket in the list is a link to [`crate::Route::TicketById`]
/// for viewing individual ticket details.
///
/// # Example
/// ```rust
/// html! {
/// <ArchivedTickets />
/// }
/// ```
#[component(ArchivedTickets)]
pub fn archived_tickets_component() -> Html {
let tickets = use_state(|| Vec::<Ticket>::new());
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
let user = use_state(|| ActiveUser {
id: None,
is_admin: false,
});
{
let tickets = tickets.clone();
let error = error.clone();
let loading = loading.clone();
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = format!("/api/tickets");
match Request::get(&url).send().await {
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))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
}
loading.set(false);
});
|| ()
});
}
{
let user = user.clone();
use_effect_with((), move |_| {
let user = user.clone();
spawn_local(async move {
if let Ok(response) = Request::get("/api/users/current")
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
{
if response.status() == 200 {
if let Ok(json) = response.json::<serde_json::Value>().await {
let id = json
.get("data")
.and_then(|d| d.get("id"))
.and_then(|v| v.as_i64())
.and_then(|n| i16::try_from(n).ok());
let is_admin = json
.get("data")
.and_then(|d| d.get("is_admin"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
}
}
});
|| ()
});
}
if *loading {
html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
} else {
html! {
<ul>
{ 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>
<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>>
<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>
</div>
})}
</ul>
}
}
}
+758
View File
@@ -0,0 +1,758 @@
use gloo_net::http::Request;
use serde::{Deserialize, Serialize};
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
// #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
// pub struct User {
// pub id: i16,
// pub last_name: String,
// pub first_name: String,
// pub username: String,
// pub is_admin: bool,
// pub pwd: String,
// }
/// Data transfer object (DTO) for creating a new user.
///
/// This struct defines the payload sent to the backend when an administrator
/// registers a new user. It includes all necessary information for user creation.
///
/// # Fields
/// - `first_name`: The first name of the user.
/// - `last_name`: The last name of the user.
/// - `username`: The unique username for the user's login.
/// - `is_admin`: A boolean indicating whether the new user should have administrator privileges.
/// - `pwd`: The password for the user's account. This will be hashed on the backend.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UserCreateScheme {
pub first_name: String,
pub last_name: String,
pub username: String,
pub is_admin: bool,
pub pwd: String,
}
/// Data transfer object (DTO) for user login credentials.
///
/// This struct defines the payload sent to the backend when a user attempts to log in.
///
/// # Fields
/// - `username`: The username provided by the user.
/// - `pwd`: The password provided by the user.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LoginScheme {
pub username: String,
pub pwd: String,
}
/// Data transfer object (DTO) for updating an existing user's information.
///
/// This struct is used to send updated user details, including an optional new password,
/// to the backend for an existing user.
///
/// # Fields
/// - `id`: The unique identifier of the user to be updated.
/// - `first_name`: The updated first name of the user.
/// - `last_name`: The updated last name of the user.
/// - `username`: The updated username for the user.
/// - `make_admin`: A boolean indicating whether to grant/revoke administrator privileges.
/// - `new_pwd`: An optional new password for the user. If empty, the password remains unchanged.
#[derive(Deserialize, Serialize, Debug)]
pub struct UserUpdateScheme {
pub id: i16,
pub first_name: String,
pub last_name: String,
pub username: String,
pub make_admin: bool,
pub new_pwd: String,
}
/// Represents a user entity with sensitive information filtered out.
///
/// This struct is used for displaying user information in contexts where
/// the password hash or other sensitive details are not needed or should not be exposed.
/// It's typically used for listing users or displaying user profiles.
///
/// # Fields
/// - `id`: The unique identifier of the user.
/// - `first_name`: The first name of the user.
/// - `last_name`: The last name of the user.
/// - `username`: The unique username of the user.
/// - `is_admin`: A boolean indicating if the user has administrator privileges.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FilteredUser {
pub id: i16,
pub first_name: String,
pub last_name: String,
pub username: String,
pub is_admin: bool,
}
/// Properties for components that display or interact with a single user.
///
/// This struct is used to pass the ID of a specific user to components
/// that need to fetch, display, or modify their details.
///
/// # Fields
/// - `id`: The unique identifier of the user to be displayed or acted upon.
#[derive(Properties, PartialEq)]
pub struct UserProps {
pub id: i16,
}
#[derive(Deserialize, Debug)]
struct ApiError {
message: String,
_status: String,
}
/// A component providing a form for registering new users.
///
/// This component allows an administrator to create new user accounts by providing
/// their first name, last name, username, password, and specifying if they should
/// be an administrator. The component handles form input, validation (basic),
/// and interaction with the backend `/api/register` endpoint.
///
/// # State
/// Uses `use_state` hooks to manage:
/// - `first_name`, `last_name`, `username`, `pwd`: Values of the form input fields.
/// - `is_admin`: Boolean state for the admin checkbox.
/// - `status`: To display messages about registration success or errors.
///
/// # Form Submission
/// - Prevents default form submission behavior.
/// - Constructs a `UserCreateScheme` payload from the form data.
/// - Sends a POST request to `/api/register`.
/// - Updates the `status` state based on the API response.
///
/// # Example
/// ```rust
/// html! {
/// <Register />
/// }
/// ```
#[component(Register)]
pub fn register_component() -> Html {
let first_name = use_state(|| "".to_string());
let last_name = use_state(|| "".to_string());
let username = use_state(|| "".to_string());
let is_admin = use_state(|| false);
let pwd = use_state(|| "".to_string());
let status = use_state(|| None::<String>);
let onsubmit = {
let first_name = first_name.clone();
let last_name = last_name.clone();
let username = username.clone();
let is_admin = is_admin.clone();
let pwd = pwd.clone();
let status = status.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let first_name = (*first_name).clone();
let last_name = (*last_name).clone();
let username = (*username).clone();
let is_admin = *is_admin;
let pwd = (*pwd).clone();
let status = status.clone();
spawn_local(async move {
let payload = UserCreateScheme {
first_name,
last_name,
username,
is_admin,
pwd,
};
let request = Request::post("/api/register")
.json(&payload)
.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))),
}
});
})
};
let fn_change = {
let first_name = first_name.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
first_name.set(input.value());
})
};
let ln_change = {
let last_name = last_name.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
last_name.set(input.value());
})
};
let un_change = {
let username = username.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
username.set(input.value());
})
};
let admin_change = {
let is_admin = is_admin.clone();
Callback::from(move |e: Event| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
is_admin.set(input.checked());
})
};
let pwd_change = {
let pwd = pwd.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
pwd.set(input.value());
})
};
html! {
<form {onsubmit} class="rundbg">
<label>{ "Vorname:" }
<input type="text" value={(*first_name).clone()} oninput={fn_change}/>
</label>
<label>{ "Nachname:" }
<input type="text" value={(*last_name).clone()} oninput={ln_change}/>
</label>
<label>{ "Benutzername:" }
<input type="text" value={(*username).clone()} oninput={un_change}/>
</label>
<label>{ "Admin:" }
<input type="checkbox" checked={*is_admin} onchange={admin_change}/>
</label>
<label>{ "Password:" }
<input type="password" value={(*pwd).clone()} oninput={pwd_change}/>
</label>
<button type="submit">{ "Bestätigen" }</button>
</form>
}
}
/// A component providing a form for user login.
///
/// This component handles user authentication by collecting a username and password,
/// then sending these credentials to the backend `/api/login` endpoint. It manages
/// UI states for loading, errors, and success, and redirects to the home page on successful login.
///
/// # State
/// Uses `use_state` hooks to manage:
/// - `username`, `pwd`: Values of the login form input fields.
/// - `loading`: A boolean to indicate if the login process is ongoing.
/// - `error`: A string to display any login error messages.
/// - `success`: A boolean to indicate if login was successful.
///
/// # Form Submission
/// - Prevents default form submission behavior.
/// - Constructs a `LoginScheme` payload from the input fields.
/// - Sends a POST request to `/api/login`.
/// - On successful response (HTTP 200), sets `success` to true and redirects to `crate::Route::Home`.
/// - On error, updates the `error` state with the appropriate message.
///
/// # Example
/// ```rust
/// html! {
/// <Login />
/// }
/// ```
#[component(Login)]
pub fn login_component() -> Html {
let username = use_state(|| "".to_string());
let pwd = use_state(|| "".to_string());
let loading = use_state(|| false);
let error = use_state(|| String::new());
let success = use_state(|| false);
let navigator = use_navigator().unwrap();
let onsubmit = {
let username = username.clone();
let pwd = pwd.clone();
let loading = loading.clone();
let error = error.clone();
let success = success.clone();
let navigator = navigator.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let username = (*username).clone();
let pwd = (*pwd).clone();
let loading = loading.clone();
let error = error.clone();
let success = success.clone();
let navigator = navigator.clone();
loading.set(true);
error.set(String::new());
success.set(false);
spawn_local(async move {
let payload = LoginScheme { username, pwd };
let response = Request::post("/api/login")
.header("Content-Type", "application/json")
.credentials(web_sys::RequestCredentials::Include)
.json(&payload)
.unwrap()
.send()
.await;
loading.set(false);
match response {
Ok(r) if r.status() == 200 => {
success.set(true);
navigator.push(&crate::Route::Home);
}
Ok(r) => {
let text = r.text().await.unwrap_or_else(|_| "unknown".into());
error.set(format!("HTTP {}: {}", r.status(), text));
}
Err(err) => error.set(format!("Network error: {}", err)),
}
});
})
};
html! {
<div>
<h1 class="headline">{ "Anmeldung" }</h1>
<form {onsubmit} class="rundbg login">
<input
placeholder="username"
value={(*username).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
username.set(input.value());
})}
/>
<input
type="password"
placeholder="password"
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>
if !error.is_empty() { <p style="color:red">{(*error).clone()}</p> }
</form>
</div>
}
}
/// A component for fetching and displaying a list of all registered users.
///
/// This component retrieves a list of `FilteredUser` entities from the backend
/// via the `/api/users` endpoint and displays them. Each user entry includes a
/// link to view their individual details through `UserByID`.
///
/// # State
/// Uses `use_state` hooks to manage:
/// - `users`: A vector of `FilteredUser` structs to store the fetched user data.
/// - `error`: Any error message encountered during API calls.
/// - `loading`: A boolean indicating if user data is currently being fetched.
///
/// # Functionality
/// - **Data Fetching**: On component mount, fetches all users from `/api/users`.
/// - **Conditional Display**:
/// - If `loading` is true, displays "Loading...".
/// - If an `error` occurs, displays the error message.
/// - Otherwise, renders an unordered list of users.
/// - **Navigation**: Each user in the list is a link to [`crate::Route::UserByID`]
/// for viewing individual user details.
///
/// # Example
/// ```rust
/// html! {
/// <AllUsers />
/// }
/// ```
#[component(AllUsers)]
pub fn all_users_component() -> Html {
let users = use_state(|| Vec::<FilteredUser>::new());
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
{
let users = users.clone();
let error = error.clone();
let loading = loading.clone();
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = format!("/api/users");
match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => {
match response.json::<Vec<FilteredUser>>().await {
Ok(u) => users.set(u),
Err(err) => error.set(Some(format!("Parse error: {}", err))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
}
loading.set(false);
});
|| ()
});
}
if *loading {
html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
} else {
html! {
<ul class="posti8s">
{ for users.iter().map(|t| html! {
<li key={t.id.to_string()} class="listbox">
<Link<crate::Route> to={crate::Route::UserByID{id: t.id}}><h3>{ format!("{} {}- #{}", t.first_name, t.last_name, t.id) }</h3></Link<crate::Route>>
</li>
})}
</ul>
}
}
}
/// A component for displaying, updating, and deleting a single user by their ID.
///
/// This component fetches a specific user's details from the backend based on the
/// `id` provided in its `UserProps`. It allows administrators to view, modify
/// (first name, last name, username, admin status, and password), and delete user accounts.
///
/// # Props
/// - `id`: The `i16` ID of the user to fetch and display.
///
/// # State
/// Uses `use_state` hooks to manage:
/// - `user`: The fetched [`FilteredUser`] data, if available.
/// - `error`: Any error message encountered during initial user data fetching.
/// - `loading`: A boolean indicating if initial user data is being fetched.
/// - `first_name`, `last_name`, `username`, `make_admin`, `new_pwd`:
/// States for the update form fields, pre-filled with current user data.
/// - `saving`, `save_error`, `save_success`: States for the user update operation.
/// - `deleting`, `delete_error`: States for the user deletion operation.
///
/// # Functionality
/// - **Initial Data Fetching**: On component mount or `id` change, fetches user data from
/// `/api/users/:id`.
/// - **Form Initialization**: Populates the update form fields with the fetched user's
/// current details once loaded.
/// - **User Update**: The update form sends a PATCH request to `/api/users/:id` with a
/// `UserUpdateScheme` payload. On success, updates the displayed user data and shows a
/// success message.
/// - **User Deletion**: Includes a "Delete" button that, after user confirmation, sends
/// a DELETE request to `/api/users/:id`. On success, clears the user from display.
/// - **Error Handling**: Displays error messages for various API and network issues.
///
/// # Example
/// ```rust
/// html! {
/// <UserByID id={42} />
/// }
/// ```
#[component(UserByID)]
pub fn user_by_id_component(props: &UserProps) -> Html {
let user = use_state(|| None::<FilteredUser>);
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
let id = props.id;
{
let user = user.clone();
let error = error.clone();
let loading = loading.clone();
use_effect_with(id, move |id_ref| {
loading.set(true);
let user = user.clone();
let error = error.clone();
let id = *id_ref;
spawn_local(async move {
let url = format!("/api/users/{}", id);
match Request::get(&url).send().await {
Ok(response) => {
let status = response.status();
if status == 200 {
match response.json::<FilteredUser>().await {
Ok(u) => user.set(Some(u)),
Err(err) => error.set(Some(format!("Parse error: {}", err))),
}
} else {
match response.json::<ApiError>().await {
Ok(ae) => error.set(Some(ae.message)),
Err(_) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("Server error: {}", status)));
}
}
}
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
}
loading.set(false);
});
|| ()
});
}
let first_name = use_state(|| "".to_string());
let last_name = use_state(|| "".to_string());
let username = use_state(|| "".to_string());
let make_admin = use_state(|| false);
let new_pwd = use_state(|| String::new());
let saving = use_state(|| false);
let save_error = use_state(|| None::<String>);
let save_success = use_state(|| false);
{
let user = user.clone();
let first_name = first_name.clone();
let last_name = last_name.clone();
let username = username.clone();
let make_admin = make_admin.clone();
use_effect_with(user, move |user_ref| {
if let Some(u) = &**user_ref {
first_name.set(u.first_name.clone());
last_name.set(u.last_name.clone());
username.set(u.username.clone());
make_admin.set(u.is_admin);
}
|| ()
});
}
let onsubmit = {
let first_name = first_name.clone();
let last_name = last_name.clone();
let username = username.clone();
let make_admin = make_admin.clone();
let new_pwd = new_pwd.clone();
let saving = saving.clone();
let save_error = save_error.clone();
let save_success = save_success.clone();
let user_state = user.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let first_name = (*first_name).clone();
let last_name = (*last_name).clone();
let username = (*username).clone();
let make_admin = *make_admin;
let new_pwd_state = new_pwd.clone();
let new_pwd = (*new_pwd).clone();
saving.set(true);
save_error.set(None);
save_success.set(false);
let saving = saving.clone();
let save_error = save_error.clone();
let save_success = save_success.clone();
let user_state = user_state.clone();
let id = id;
spawn_local(async move {
let payload = UserUpdateScheme {
id: id,
first_name,
last_name,
username,
make_admin,
new_pwd,
};
let url = format!("/api/users/{}", id);
let request = Request::patch(&url)
.header("Content-Type", "application/json")
.credentials(web_sys::RequestCredentials::Include)
.json(&payload)
.expect("Failed to construct Request");
match request.send().await {
Ok(resp) if resp.status() == 200 => {
if let Ok(updated) = resp.json::<FilteredUser>().await {
user_state.set(Some(updated));
}
new_pwd_state.set(String::new());
save_success.set(true);
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
save_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
}
Err(err) => save_error.set(Some(format!("Network error: {}", err))),
}
saving.set(false);
});
})
};
let deleting = use_state(|| false);
let delete_error = use_state(|| None::<String>);
let ondelete = {
let deleting = deleting.clone();
let delete_error = delete_error.clone();
let user_state = user.clone(); // or ticket
let id = id;
Callback::from(move |e: MouseEvent| {
e.prevent_default();
// confirm
if !web_sys::window()
.and_then(|w| {
w.confirm_with_message("Are you sure you want to delete this item?")
.ok()
})
.unwrap_or(false)
{
return;
}
deleting.set(true);
delete_error.set(None);
let deleting = deleting.clone();
let delete_error = delete_error.clone();
let user_state = user_state.clone();
spawn_local(async move {
let url = format!("/api/users/{}", id); // or /api/tickets/{}
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
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
}
Err(err) => delete_error.set(Some(format!("Network error: {}", err))),
}
deleting.set(false);
});
})
};
if *loading {
html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
} else if let Some(u) = &*user {
html! {
<div>
<div>
<p><strong>{ "Vorname: " }</strong>{ &u.first_name }</p>
<p><strong>{ "Nachname: " }</strong>{ &u.last_name }</p>
<p><strong>{ "Benutzername: " }</strong>{ &u.username }</p>
<p><strong>{ "Ist Admin: " }</strong>{ u.is_admin }</p>
</div>
<h1>{ format!("User #{}", u.id) }</h1>
<form onsubmit={onsubmit}>
<div>
<label>{ "Vorname" }
<input name="first_name"
value={(*first_name).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
first_name.set(input.value());
})}
/>
</label>
</div>
<div>
<label>{ "Nachname" }
<input name="last_name"
value={(*last_name).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
last_name.set(input.value());
})}
/>
</label>
</div>
<div>
<label>{ "Benutzername" }
<input name="username"
value={(*username).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
username.set(input.value());
})}
/>
</label>
</div>
<div>
<label>{ "Ist Admin" }
<input type="checkbox" name="make_admin" checked={*make_admin} onchange={Callback::from(move |e: Event| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
make_admin.set(input.checked());
})}/>
</label>
</div>
<div>
<label>{ "Neues Passwort (leer = unchanged)" }
<input name="new_pwd" type="password"
value={(*new_pwd).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
new_pwd.set(input.value());
})}
/>
</label>
</div>
<button type="submit" disabled={*saving}>{ if *saving { "Speichern..." } else { "Speichern" } }</button>
if *save_success {
<p style="color:green">{ "Updated successfully" }</p>
}
if let Some(err) = &*save_error {
<p style="color:red">{ err.clone() }</p>
}
</form>
<button onclick={ondelete} disabled={*deleting}>
{if *deleting {"Löschen..."} else {"Löschen"}}
</button>
<Link<crate::Route> to={crate::Route::AllUsers}>{ "Zurück zur Benutzerübersicht" }</Link<crate::Route>>
if let Some(err) = &*delete_error {
<p style="color:red">{ err.clone() }</p>
}
</div>
}
} else {
html! { <p>{ "No ticket found." }</p> }
}
}
+480
View File
@@ -0,0 +1,480 @@
use std::collections::HashMap;
use chrono::{DateTime, Datelike, Utc};
use gloo_net::http::Request;
use serde::Deserialize;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use crate::pages::ticket::{ActiveUser, Ticket};
/// A partial representation of a ticket, containing only the fields necessary for statistical analysis.
///
/// This struct is used to efficiently retrieve and process ticket data for diagnostics
/// without fetching the full ticket details.
///
/// # Fields
/// - `date`: The creation date and time of the ticket in UTC.
/// - `room`: The room number associated with the ticket.
#[derive(Debug, Deserialize, Clone, PartialEq)]
struct TicketPartial {
date: DateTime<Utc>,
room: i16,
}
/// Properties for components that display room-wise ticket totals.
///
/// This struct passes a list of partial ticket data to a component responsible
/// for calculating and visualizing ticket distribution per room.
///
/// # Fields
/// - `tickets`: A vector of `TicketPartial` containing ticket data relevant for room totals.
#[derive(Properties, PartialEq)]
struct RoomTotalsProps {
tickets: Vec<TicketPartial>,
}
/// 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)
/// to a 0-indexed system (Monday = 0, Sunday = 6).
///
/// # Arguments
/// - `dt`: A reference to a `DateTime<Utc>` object.
///
/// # Returns
/// A `usize` representing the weekday index (0 for Monday, ..., 6 for Sunday).
fn weekday_index(dt: &DateTime<Utc>) -> usize {
// chrono::Weekday: Mon = 1 ... Sun = 7
match dt.weekday() {
chrono::Weekday::Mon => 0,
chrono::Weekday::Tue => 1,
chrono::Weekday::Wed => 2,
chrono::Weekday::Thu => 3,
chrono::Weekday::Fri => 4,
chrono::Weekday::Sat => 5,
chrono::Weekday::Sun => 6,
}
}
/// Counts the number of tickets submitted on each day of the week.
///
/// This function takes a slice of `TicketPartial` and returns an array where each
/// element corresponds to a day of the week (Monday=0, Sunday=6) and contains
/// the total count of tickets submitted on that day.
///
/// # Arguments
/// - `tickets`: A slice of `TicketPartial` items to count.
///
/// # Returns
/// An array `[usize; 7]` with ticket counts for each weekday.
fn count_by_weekday(tickets: &[TicketPartial]) -> [usize; 7] {
let mut counts = [0usize; 7];
for t in tickets {
counts[weekday_index(&t.date)] += 1;
}
counts
}
/// 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`
/// 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.
///
/// # Arguments
/// - `partials`: A slice of `TicketPartial` items defining the date range.
///
/// # Returns
/// An array `[usize; 7]` where each element represents the number of times a
/// specific weekday (Monday=0, ..., Sunday=6) appears in the date range.
fn day_counts(partials: &[TicketPartial]) -> [usize; 7] {
if partials.is_empty() {
return [0usize; 7];
}
let mut min = partials[0].date.date_naive();
let mut max = min;
for d in partials.iter().skip(1) {
let dt = d.date.date_naive();
if dt < min {
min = dt
}
if dt > max {
max = dt
}
}
let total_days = (max - min).num_days() + 1;
if total_days <= 0 {
return [0usize; 7];
}
let mut occ = [0usize; 7];
let mut current = min;
for _ in 0..total_days {
let wd = current.weekday();
let idx = match wd {
chrono::Weekday::Mon => 0,
chrono::Weekday::Tue => 1,
chrono::Weekday::Wed => 2,
chrono::Weekday::Thu => 3,
chrono::Weekday::Fri => 4,
chrono::Weekday::Sat => 5,
chrono::Weekday::Sun => 6,
};
occ[idx] += 1;
current = current + chrono::Duration::days(1);
}
occ
}
/// Converts a numerical room representation back into a human-readable string format.
///
/// This function is the inverse of the room parsing logic in `SubmitTicket` component.
/// It converts negative numbers back to "K" prefixed rooms, numbers >= 1000 back to "D" prefixed rooms,
/// and other numbers to their string representation.
///
/// # Arguments
/// - `r`: An `i16` representing the internal numerical representation of a room.
///
/// # Returns
/// A `String` containing the formatted room number (e.g., "K1", "D1", "101").
fn parse_room(r: i16) -> String {
if r < 0 {
format!("K{}", r.abs())
} else if r > 1000 {
format!("D{}", r - 1000)
} else {
r.to_string()
}
}
/// The main diagnostics dashboard component.
///
/// This component serves as a container for various statistical and diagnostic
/// views related to the application's tickets.
///
/// # Components Rendered
/// - [`TicketCount`]: Displays the count of open tickets.
/// - [`SubmitStats`]: Displays statistics related to ticket submissions (e.g., by weekday, by room).
///
/// # Example
/// ```rust
/// html! {
/// <Diagnostics />
/// }
/// ```
#[component(Diagnostics)]
pub fn diagnostics_component() -> Html {
html! {
<div>
<TicketCount/>
<SubmitStats/>
</div>
}
}
/// A component that displays the count of currently open (ToDo or InProgress) tickets.
///
/// This component fetches all tickets and the current user's details. It then filters
/// the tickets to count only those that are "ToDo" or "InProgress" and are either
/// created by the current user (for non-admins) or all tickets (for admins).
///
/// # State
/// Uses `use_state` hooks to manage:
/// - `tickets`: A vector of `Ticket` structs for all fetched tickets.
/// - `error`: Any error message from API calls.
/// - `loading`: A boolean indicating if data is being fetched.
/// - `user`: The `ActiveUser` details for conditional filtering.
///
/// # Functionality
/// - Fetches all tickets from `/api/tickets`.
/// - Fetches current user details from `/api/users/current`.
/// - Filters tickets by status ("ToDo" or "InProgress") and user ID (if not admin).
/// - Displays the count of matching tickets.
///
/// # Example
/// ```rust
/// html! {
/// <TicketCount />
/// }
/// ```
#[component(TicketCount)]
pub fn ticket_count_component() -> Html {
let tickets = use_state(|| Vec::<Ticket>::new());
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
let user = use_state(|| ActiveUser {
id: None,
is_admin: false,
});
{
let tickets = tickets.clone();
let error = error.clone();
let loading = loading.clone();
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = format!("/api/tickets");
match Request::get(&url).send().await {
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))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
}
loading.set(false);
});
|| ()
});
}
{
let user = user.clone();
use_effect_with((), move |_| {
let user = user.clone();
spawn_local(async move {
if let Ok(response) = Request::get("/api/users/current")
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
{
if response.status() == 200 {
if let Ok(json) = response.json::<serde_json::Value>().await {
let id = json
.get("data")
.and_then(|d| d.get("id"))
.and_then(|v| v.as_i64())
.and_then(|n| i16::try_from(n).ok());
let is_admin = json
.get("data")
.and_then(|d| d.get("is_admin"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
}
}
});
|| ()
});
}
if *loading {
html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
} else {
let status_conditions = |t: &Ticket| t.status == "ToDo" || t.status == "InProgress";
let count = tickets
.iter()
.filter(|t| {
status_conditions(t)
&& (user.is_admin || user.id.map_or(false, |uid| t.user_id == uid))
})
.count();
html! {
<div>
<h2>{ "Offene Tickets" }</h2>
<h4>{ count }</h4>
</div>
}
}
}
/// A component that displays various statistics about ticket submissions.
///
/// This component fetches all tickets (in a partial format for efficiency)
/// and calculates statistics such as tickets per weekday and tickets per room.
/// It renders these statistics visually using simple bar charts.
///
/// # State
/// Uses `use_state` hooks to manage:
/// - `tickets`: A vector of `TicketPartial` for statistical analysis.
/// - `error`: Any error message from API calls.
/// - `loading`: A boolean indicating if data is being fetched.
///
/// # Functionality
/// - Fetches all tickets (as `TicketPartial`) from `/api/tickets`.
/// - Calculates:
/// - `counts`: Number of tickets submitted on each weekday.
/// - `occ`: Number of occurrences of each weekday in the ticket date range.
/// - `avg`: Average number of tickets per day for each weekday, normalized by `occ`.
/// - Renders a bar chart for average tickets per weekday.
/// - Renders a [`RoomTotalTickets`] component to display tickets per room.
///
/// # Example
/// ```rust
/// html! {
/// <SubmitStats />
/// }
/// ```
#[component(SubmitStats)]
pub fn submit_stats_component() -> Html {
let tickets = use_state(|| Vec::<TicketPartial>::new());
let error = use_state(|| None::<String>);
let loading = use_state(|| false);
{
let tickets = tickets.clone();
let error = error.clone();
let loading = loading.clone();
use_effect_with((), move |_| {
loading.set(true);
spawn_local(async move {
let url = "/api/tickets".to_string();
match Request::get(&url).send().await {
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))),
}
}
Ok(response) => {
if let Ok(text) = response.text().await {
error.set(Some(text));
} else {
error.set(Some(format!("status {}", response.status())));
}
}
Err(err) => error.set(Some(format!("Network error: {}", err))),
}
loading.set(false);
});
|| ()
});
}
let counts = count_by_weekday(&tickets);
let occ = day_counts(&tickets);
let mut avg = [0.0f64; 7];
for i in 0..7 {
if occ[i] > 0 {
avg[i] = counts[i] as f64 / occ[i] as f64;
} else {
avg[i] = 0.0;
}
}
let weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let (max_idx, _max_val) = counts
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.map(|(i, _)| (i, ()))
.unwrap_or((0, ()));
html! {
<div class="diagnostics-section">
if *loading {
<p>{ "Loading..." }</p>
}
if let Some(e) = &*error {
<p style="color: red;">{ e.clone() }</p>
}
<h3>{ "Tickets per weekday" }</h3>
<div class="weekday-chart">
<div class="weekday-bars">
{ for (0..7).map(|i| {
let is_max = i == max_idx;
let max_value = counts.iter().cloned().max().unwrap_or(1).max(1);
let bar_height_percent = if max_value > 0 { (avg[i] / max_value as f64) * 100.0 } else { 0.0 };
let bar_height_px = (bar_height_percent * 200.0 / 100.0) as i32;
html! {
<div class="weekday-bar">
<div class={format!("bar{}", if is_max { " max" } else { "" })} style={format!("height: {}px;", bar_height_px)}>
</div>
</div>
}
})}
</div>
<div class="weekday-labels">
{ for (0..7).map(|i| {
html! {
<div class="label-item">
<div class="day">{ weekdays[i] }</div>
<div class="value">{ format!("{:.1}", avg[i]) }</div>
</div>
}
})}
</div>
</div>
<RoomTotalTickets tickets={(*tickets).clone()}/>
</div>
}
}
/// A component that displays the total number of tickets per room.
///
/// This component takes a list of `TicketPartial` items and calculates the
/// total number of tickets for each room. It then displays these totals
/// in a sorted list with a bar chart visualization.
///
/// # Props
/// - `tickets`: A `Vec<TicketPartial>` containing the partial ticket data for analysis.
///
/// # Functionality
/// - **Calculates Totals**: Aggregates ticket counts for each unique room.
/// - **Sorts Results**: Displays rooms sorted by ticket count in descending order.
/// - **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.
/// - **Room Formatting**: Uses the `parse_room` function to display room numbers
/// in a human-readable format.
///
/// # Example
/// ```rust
/// html! {
/// <RoomTotalTickets tickets={my_ticket_partials} />
/// }
/// ```
#[component(RoomTotalTickets)]
fn room_total_component(props: &RoomTotalsProps) -> Html {
let mut totals: HashMap<i16, usize> = HashMap::new();
for t in &props.tickets {
*totals.entry(t.room).or_insert(0) += 1;
}
let mut totals_vec: Vec<(i16, usize)> = totals.into_iter().collect();
totals_vec.sort_by(|a, b| b.1.cmp(&a.1));
let max_count = totals_vec.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
html! {
<div class="diagnostics-section">
<h3>{ "Tickets pro Raum" }</h3>
<div class="room-chart">
{ for totals_vec.into_iter().map(|(room, count)| {
let label = parse_room(room);
let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
html! {
<div class="room-bar-item">
<div class="room-header">
<span class="room-label">{ label }</span>
<span class="room-count">{ count }</span>
</div>
<div class="room-bar-container">
<div class="room-bar" style={format!("width: {}%;", bar_width_percent)}>
</div>
</div>
</div>
}
}) }
</div>
</div>
}
}
+8
View File
@@ -0,0 +1,8 @@
@use "variables" as *;
@mixin card {
backgroud: #fff;
border-radius: &border-radius;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
padding: $spacing-md;
}
+6
View File
@@ -0,0 +1,6 @@
@use "variables" as *;
.u-hidden { display: none !important; }
.u-gap-sm { gap: $spacing-sm; }
.text-muted { color: $color-muted; }
+8
View File
@@ -0,0 +1,8 @@
$color-bg: #ffffff;
$color-sidebar: #570000;
$color-accent: #2563eb;
$color-muted: #6b7280;
$spacing-sm: 8px;
$spacing-md: 16px;
$border-radius: 6px;
$rundbgbackgroundcolor: #a0a0a0;
@@ -0,0 +1,137 @@
@use "../mixins" as *;
@use "../variables" as *;
.diagnostics-container {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.diagnostics-section {
background-color: $color-bg;
border-radius: $border-radius;
padding: $spacing-md;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
h3 {
margin: 0 0 $spacing-md 0;
color: #1f2937;
font-size: 1.1rem;
font-weight: 600;
}
}
.weekday-chart {
display: flex;
flex-direction: column;
gap: 12px;
border: 1px solid #e5e7eb;
border-radius: $border-radius;
background-color: #f9fafb;
}
.weekday-bars {
display: flex;
gap: 12px;
align-items: flex-end;
height: 205px;
padding: $spacing-md;
overflow-x: auto;
padding-bottom: 0px;
}
.weekday-bar {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 60px;
height: 100%;
.bar {
width: 100%;
background-color: $color-accent;
border-radius: 4px 4px 0 0;
transition: background-color 0.2s ease;
&.max {
background-color: #10b981;
}
}
}
.weekday-labels {
display: flex;
gap: 12px;
padding: $spacing-md;
padding-top: 0;
overflow-x: auto;
.label-item {
flex: 1;
min-width: 60px;
text-align: center;
white-space: nowrap;
.day {
font-weight: 600;
font-size: 12px;
color: #1f2937;
margin-bottom: 2px;
}
.value {
font-size: 11px;
color: $color-muted;
}
}
}
.room-chart {
display: flex;
flex-direction: column;
gap: 12px;
padding: $spacing-md;
border: 1px solid #e5e7eb;
border-radius: $border-radius;
background-color: #f9fafb;
}
.room-bar-item {
display: flex;
flex-direction: column;
gap: 4px;
.room-header {
display: flex;
justify-content: space-between;
align-items: center;
.room-label {
font-weight: 600;
font-size: 14px;
color: #1f2937;
min-width: 50px;
}
.room-count {
font-size: 12px;
color: $color-muted;
font-weight: 500;
}
}
.room-bar-container {
width: 100%;
height: 24px;
background-color: #e5e7eb;
border-radius: 4px;
overflow: hidden;
.room-bar {
height: 100%;
background-color: #570000;
transition: width 0.3s ease;
}
}
}
@@ -0,0 +1,7 @@
.headline {
font-size: 40px;
font-weight: bold;
text-align: center;
color: #570000;
margin-top: 150px;
}
+17
View File
@@ -0,0 +1,17 @@
.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;
img {
width: auto;
height: auto;
}
}
@@ -0,0 +1,57 @@
@use "../variables" as *;
@use "../mixins" as *;
.sidebar {
width: 260px;
background: $color-sidebar;
color: #fffefe;
min-height: 100%;
padding: $spacing-md;
display: flex;
flex-direction: column;
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $spacing-sm; flex: 1; }
a, .menu-toggle {
color: #ffffff;
text-decoration: none;
display: block;
padding: 8px 12px;
border-radius: 4px;
&:hover { background: rgba(158, 45, 1, 0.842); }
}
.menu-toggle { background: #ff5a316c; border: none; text-align: left; width: 100%; cursor: pointer; }
.submenu {
margin-left: 8px;
background: rgba(255,255,255,0.02);
border-radius: 4px;
padding: 6px;
li { padding: 4px 0; }
}
.logout-item {
margin-top: auto;
border-top: 1px solid rgba(255,255,255,0.1);
padding-top: $spacing-sm;
}
.logout-button {
background: rgba(255, 93, 43, 0.527);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.801);
padding: 8px 12px;
border-radius: 4px;
width: 100%;
cursor: pointer;
text-align: left;
&:hover { background: rgba(255,255,255,0.15); }
&:active { background: rgba(255,255,255,0.2); }
}
&.user { width: 220px; background: darken($color-sidebar, 6%); }
}
@@ -0,0 +1,54 @@
@use "../mixins" as *;
@use "../variables" as *;
.ticket-card {
@include card();
border-left: 4px solid $color-accent;
display: flex;
flex-direction: column;
gap: $spacing-sm;
.meta { color: $color-muted; font-size: 0.9rem; }
.title { font-weight: 600; }
}
.listbox {
max-width: auto;
margin:0 0;
background-color: $rundbgbackgroundcolor;
padding-bottom: 96px;
color: white;
padding-left: 32px;
padding-right: 32px;
padding-top: 96px;
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;
&.To-Do {
background-color: #b1003b;
}
&.InProgress {
background-color: #fff385;
}
&.Completed {
background-color: #90ff82;
}
&.Archived {
background-color: #af69ee;
}
}
.postits {
display: grid;
grid-template-columns: auto auto auto;
gap: 8px;
}
+109
View File
@@ -0,0 +1,109 @@
@use "variables";
@use "mixins";
@use "utilities";
@use "components/sidebar";
@use "components/tickets";
@use "components/diagnostics";
@use "components/frontpage";
@use "components/home";
@use "components/icon";
body {
background: variables.$color-bg;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
color: #111827;
margin: 0;
}
.admin { display: flex; }
.layout {
display: flex;
height: 100vh;
overflow: hidden;
box-sizing: border-box;
}
.sidebar {
width: 260px;
flex-shrink: 0;
height: 100%;
overflow-y: auto;
box-sizing: border-box;
}
.content {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
padding: variables.$spacing-md;
box-sizing: border-box;
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: -100%;
transition: left .2s;
z-index: 1000;
height: 100%;
box-sizing: border-box;
}
.sidebar.open { left: 0; }
.content { margin-left: 0; padding: variables.$spacing-md; box-sizing: border-box; }
}
.rundbg {
display: flex;
justify-content: center;
flex-direction: column;
max-width: 40%;
margin:0 auto;
background-color: variables.$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;
}
input, select, button {
width: 100%;
box-sizing: border-box;
padding: 16px;
font-size: 1.8rem;
background-color: #f0f0f0;
box-shadow: #6d6d6d;
box-shadow: 6px 6px 8px;
}
input[type="checkbox"] {
width: auto;
}
button {
margin: 16px 0;
}
&.login {
margin-top: 96px;
}
}
.setup-form input {
width: 100%;
box-sizing: border-box;
}
@@ -1,11 +0,0 @@
CREATE TYPE category AS ENUM('Whiteboard Beamer', 'Internet', 'iPad Koffer', 'Apple TV', 'Docu Cam', 'Sonstiges')
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category category NOT NULL DEFAULT 'Sonstiges',
betreff VARCHAR(100),
description VARCHAR,
room SMALLINT,
date TIMESTAMP,
user_id SMALLINT
);
-6
View File
@@ -1,6 +0,0 @@
CREATE TABLE users (
id SMALLINT PRIMARY KEY AUTOINCREMENT,
name VARCHAR(30),
firstname VARCHAR(30),
is_admin BOOLEAN NOT NULL DEFAULT false
);
+1
View File
@@ -0,0 +1 @@
#![doc = include_str!("../README.md")]