109 Commits
Author SHA1 Message Date
schn33fuchs a04c3cd8e2 Bugfixes 2026-07-16 18:43:24 +02:00
schn33fuchs ae0176b49a QR Codes 2026-07-16 18:01:58 +02:00
schn33fuchs b62bba0173 Docker shit 2026-07-14 19:23:14 +02:00
schn33fuchs 5585917eb1 rename shit 2026-07-14 17:06:47 +02:00
schn33fuchs b1b29d9f39 Update backend/migrations/20260422094717_user_table.sql 2026-06-20 13:34:46 +02:00
schn33fuchs 042637d248 Add LICENSE.md 2026-06-19 22:35:11 +02:00
schn33fuchs 07003bd305 Update README.md 2026-06-19 20:26:57 +02:00
schn33fuchs 83eadc532c Video 2026-06-19 20:21:47 +02:00
schn33fuchs 2bb0f01e06 Delete frontend/frontend.tar.xz 2026-06-19 20:13:37 +02:00
TheDiluTek 9686441f71 schwarz 2026-06-18 20:16:52 +02:00
TheDiluTek a496338042 schwarze schrift sonst sieht man nix 2026-06-18 20:16:01 +02:00
TheDiluTek 3915f997da Merge branch 'main' of https://git.raffauf-clan.de/schn33fuchs/ticketsystem 2026-06-18 19:58:46 +02:00
TheDiluTek 9704d5792a njix 2026-06-18 19:58:41 +02:00
schn33fuchs c11309a3a5 Docs 2026-06-17 16:35:26 +02:00
schn33fuchs a1d95f1ba2 Styling improvements 2026-06-17 15:47:42 +02:00
schn33fuchs 45e5c657c7 Translation to German 2026-06-17 15:47:34 +02:00
schn33fuchs 584a75009f README update 2026-06-17 13:35:29 +02:00
schn33fuchs 85d73919b5 Small Fix 2026-06-17 13:33:20 +02:00
schn33fuchs 4ba95e6537 Merge branch 'Silas' 2026-06-17 13:32:56 +02:00
schn33fuchs 22bbb7900a Darkmode styling 2026-06-17 12:51:30 +02:00
schn33fuchs 4a58f49253 Variable fixes 2026-06-17 12:31:01 +02:00
schn33fuchs 38a9e00434 Darkmode 2026-06-17 12:30:51 +02:00
schn33fuchs 926a4fcc42 Styling and improvements 2026-06-10 12:47:29 +02:00
schn33fuchs 5101c0ff4d Sam did styling 2026-06-08 12:52:54 +02:00
schn33fuchs 2a53c7372d Archive style 2026-06-07 20:43:12 +02:00
schn33fuchs 75f3ef392e Mobile fix
The CSG logo shrinks when on mobile screens
2026-06-07 12:11:49 +02:00
schn33fuchs 159202a7af Sidebar improvement
It now works on mobile
2026-06-07 12:11:32 +02:00
schn33fuchs 8fd35f3e82 Documentation 2026-06-07 11:56:16 +02:00
schn33fuchs 177e959bbd Center 2026-06-06 18:53:44 +02:00
schn33fuchs 3d68064b33 Resize 2026-06-06 18:50:56 +02:00
schn33fuchs 52ca6ec96f Home button
now has an svg
2026-06-06 18:48:14 +02:00
schn33fuchs 7f3913dcfb Post merge fix 2026-06-06 18:44:40 +02:00
schn33fuchs 31e98c0936 Merge branch 'ticket_background' 2026-06-06 18:36:31 +02:00
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 2a15f77f4f so ein bisschen buttons und links und non admin user tests 2026-06-06 18:13:40 +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
58 changed files with 7942 additions and 168 deletions
+13
View File
@@ -0,0 +1,13 @@
target/
backend/target/
frontend/target/
frontend/dist/
frontend/node_modules/
dist/
.git/
.idea/
.env
.antigravitycli/
*.pdb
**/*.rs.bk
info ding video.mp4
+9 -2
View File
@@ -3,9 +3,12 @@
# will have compiled files and executables
debug/
target/
data/
backend/debug/
backend/target/
frontend/debug/
frontend/target/
frontend/dist/
frontend/node_modules/
# These are backup files generated by rustfmt
**/*.rs.bk
@@ -17,9 +20,13 @@ backend/target/
# 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
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.idea/
# Added by cargo
/target
.antigravitycli/
.sqlx/
backend/.sqlx/
dist/
+1336 -58
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"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Silas Raffauf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+63
View File
@@ -0,0 +1,63 @@
# Ticketsystem
A ticket system with backend and frontend components.
## Components
- **[Backend](../backend/index.html)** - The server-side API and business logic
- **[Frontend](../frontend/index.html)** - The client-side user interface
## Usage
### Prerequisite
#### IMPORTANT
Before compiling the programm you have to install the rust toolchain.
For a guide to do this, visit: <https://rust-lang.org/tools/install/>
A instance of a postgresql has to be accessible to the backend. Place the connection details in a .env file into the variable `DATABASE_URL`
To setup the tables you either can create them manually by following the sheme specified in `backend/migrations` or apply them with sqlx
To install sqlx run `cargo install sqlx` and then in the `backend` directory run `sqlx migrate run` to create the tables
### Environment
The .env file has to be in the root directory of the project or in the same directory as the executable
Keys:
`DATABASE_URL`: Specifying the url and connection details for the database
`TOKEN_SECRET`: The JWT token secret, can theoretically be anything but is more secure when generated with a tool, e.g: <https://jwtsecrets.com/>
`ORIGIN`: The origin of the frontend, used for CORS rules
`BACKEND_PORT`: The port which the backend should use to run on
### Backend
The backend can either be run via `cargo run --release` or `cargo build --release` using the correct target architecture, e.g. 'x86_64-unknown-linux-gnu',
the executable will be placed in the `target/release` directory and can then be run via any method
### Frontend
The HTML code for the frontend can be generated by using `trunk build`. The resulting files will end up in the `frontend/dist` directory and can be served over any webserver supporting wasm
#### NOTE
To install trunk run `cargo install trunk`
#### IMPORTANT
Requests from the frontend to /api/* have to be proxied to the Backend
Example with nginx and frontend running at localhost:8000 and backend at localhost:9000 :
```nginx
location /api/ {
proxy_pass http://localhost:9000/api;
}
```
## Usage of AI
Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation
Google Antigravity generated the Sequence Diagrams
### Prompt
Generate comments for cargo doc describing the indivilual components and create links to relevant structs, functions etc.
Generate a sequence diagramm in @[README.md] behind the class diagramms
### Output
The comments with `///` or `//!`
I've gone over it and modified it to my needs and opinions
The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
+390
View File
@@ -1,2 +1,392 @@
# Ticketsystem
A ticket system with backend and frontend components.
## Components
- **[Backend]** - The server-side API and business logic
- **[Frontend]** - The client-side user interface
## Usage
### Prerequisite
> [!IMPORTANT]
> Before compiling the programm you have to install the rust toolchain.
> For a guide to do this, visit: <https://rust-lang.org/tools/install/>
A instance of a postgresql has to be accessible to the backend. Place the connection details in a .env file into the variable `DATABASE_URL`
To setup the tables you either can create them manually by following the sheme specified in `backend/migrations` or apply them with sqlx
To install sqlx run `cargo install sqlx` and then in the `backend` directory run `sqlx migrate run` to create the tables
### Environment
The .env file has to be in the root directory of the project or in the same directory as the executable
Keys:
`DATABASE_URL`: Specifying the url and connection details for the database
`TOKEN_SECRET`: The JWT token secret, can theoretically be anything but is more secure when generated with a tool, e.g: <https://jwtsecrets.com/>
`ORIGIN`: The origin of the frontend, used for CORS rules
`BACKEND_PORT`: The port which the backend should use to run on
### Backend
The backend can either be run via `cargo run --release` or `cargo build --release` using the correct target architecture, e.g. 'x86_64-unknown-linux-gnu',
the executable will be placed in the `target/release` directory and can then be run via any method
Alternatively download the executable from the 'Releases' Page for your device
### Frontend
The HTML code for the frontend can be generated by using `trunk build`. The resulting files will end up in the `frontend/dist` directory and can be served over any webserver supporting WASM
Alternatively download the fontend files from the 'Releases' Page and serve them via any webserver supporting WASM
> [!NOTE]
> To install trunk run `cargo install trunk`
> [!IMPORTANT]
> Requests from the frontend to /api/* have to be proxied to the Backend
> Example with nginx and frontend running at localhost:8000 and backend at localhost:9000 :
> ```nginx
> location /api/ {
> proxy_pass http://localhost:9000/api;
> }
> ```
### Example
[![Watch the video]()](https://git.raffauf-clan.de/schn33fuchs/ticketsystem/raw/branch/main/info%20ding%20video.mp4)
## Diagrams
### Class Diagramm
#### Backend
```mermaid
classDiagram
class Error {
+status: &'static str
+message: String
}
class TicketResponse {
+id: i32
+category: String
+betreff: String
+description: String
+room: i16
+status: String
+date: chrono::DateTime~chrono::Utc~
+user_id: i16
+user_first_name: String
+user_last_name: String
}
class User {
+id: i16
+last_name: String
+first_name: String
+username: String
+is_admin: bool
+pwd: String
}
class TicketCreateScheme {
+category: String
+betreff: String
+description: String
+room: i16
}
class TicketUpdateScheme {
+status: String
}
class UserUpdateScheme {
+id: i16
+first_name: String
+last_name: String
+username: String
+make_admin: bool
+new_pwd: String
}
class UserCreateScheme {
+first_name: String
+last_name: String
+username: String
+is_admin: bool
+pwd: String
}
class LoginScheme {
+username: String
+pwd: String
}
class FilteredUser {
+id: i16
+first_name: String
+last_name: String
+username: String
+is_admin: bool
}
class Claims {
+sub: String
+issued: usize
+expires: usize
}
class AppState {
-db: PgPool
-env: Env
}
class Env {
+db_url: String
+token_secret: String
+origin: String
+backend_port: String
+load() Env
}
AppState --> Env
Env ..> Env
```
#### Frontend
```mermaid
classDiagram
class TicketCreateScheme {
+category: String
+betreff: String
+description: String
+room: i16
}
class TicketUpdateScheme {
+status: String
}
class Ticket {
+id: i32
+category: String
+betreff: String
+description: String
+room: i16
+status: String
+date: chrono::DateTime~chrono::Utc~
+user_id: i16
+user_first_name: String
+user_last_name: String
}
class TicketProps {
+id: i32
}
class ActiveUser {
+id: Option~i16~
+is_admin: bool
}
class ApiError {
-message: String
-_status: String
}
class SidebarExpandState {
+ticket_open: bool
+users_open: bool
}
class Default {
+default() Self
}
class SidebarState {
+expand: SidebarExpandState
+set_tickets_open: Callback~bool~
+toggle_tickets: Callback~()~
+set_users_open: Callback~bool~
+toggle_users: Callback~()~
+new(expand:SidebarExpandState, set_tickets_open:Callback~bool~, toggle_tickets:Callback~()~, set_users_open:Callback~bool~, toggle_users:Callback~()~) Self
}
class SidebarProps {
+children: Children
}
class TicketPartial {
-date: DateTime~Utc~
-room: i16
-user_id: i16
}
class UserPartial {
-id: i16
-first_name: String
-last_name: String
}
class RoomTotalsProps {
-tickets: Vec~TicketPartial~
}
class UserTotalProps {
-users: Vec~UserPartial~
-tickets: Vec~TicketPartial~
}
class AdminSetupScheme {
+first_name: String
+last_name: String
+username: String
+pwd: String
}
class UserCreateScheme {
+first_name: String
+last_name: String
+username: String
+is_admin: bool
+pwd: String
}
class LoginScheme {
+username: String
+pwd: String
}
class UserUpdateScheme {
+id: i16
+first_name: String
+last_name: String
+username: String
+make_admin: bool
+new_pwd: String
}
class FilteredUser {
+id: i16
+first_name: String
+last_name: String
+username: String
+is_admin: bool
}
class UserProps {
+id: i16
}
class ApiError {
-message: String
-_status: String
}
class AuthState {
+is_authenticated: Option~bool~
+is_admin: Option~bool~
}
class ProtectedRouteProps {
+children: Children
+admin_page: bool
}
class SidebarShellProps {
+children: Children
}
class SidebarComponentProps {
+is_open: bool
+on_close: Callback~()~
}
class AdminCheckWrapperProps {
+children: Children
}
SidebarState --> SidebarExpandState
RoomTotalsProps --> TicketPartial
UserTotalProps --> UserPartial
UserTotalProps --> TicketPartial
SidebarShellProps ..> SidebarComponentProps
```
### Sequence Diagrams
#### 1. System Initialization & Administrator Setup
```mermaid
sequenceDiagram
autonumber
actor Admin as Initial Administrator
participant FE as Frontend (Yew)
participant BE as Backend (Axum)
participant DB@{"type": "database", "alias": "Database"}
Note over Admin, DB: System Initialization Flow
FE->>BE: GET /api/check-admin
BE->>DB: SELECT COUNT(*) FROM users WHERE is_admin = true
DB-->>BE: 0 (No admin found)
BE-->>FE: HTTP 200 OK {"exists": false}
FE-->>Admin: Render Admin Setup Page
Admin->>FE: Input username, password, first/last name
FE->>BE: POST /api/setup-admin {username, pwd, ...}
Note over BE: Hash password using Argon2
BE->>DB: INSERT INTO users (username, pwd, is_admin, ...)
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>Admin: Redirect to Login Page
```
#### 2. User Authentication Flow (Login)
```mermaid
sequenceDiagram
autonumber
actor User
participant FE as Frontend (Yew)
participant BE as Backend (Axum)
participant DB as Database@{"type": "database"}
Note over User, DB: Authentication & Cookie Session Setup
User->>FE: Enter username & password
FE->>BE: POST /api/login {username, pwd}
BE->>DB: SELECT * FROM users WHERE username = $1
DB-->>BE: Return user record with password hash
Note over BE: Verify password using Argon2
alt Password Valid
Note over BE: Generate JWT token containing claims (sub: user_id)
Note over BE: Build HttpOnly, Secure, Lax cookie 'token'
BE-->>FE: HTTP 200 OK {"status": "success", "token": "...", "user": {...}}
Note over BE,FE: Header: Set-Cookie: token=...#59; Path=/#59; HttpOnly#59; SameSite=Lax
Note over FE: Save auth state to global context
FE-->>User: Redirect to Dashboard / Home
else Password Invalid
BE-->>FE: HTTP 400 Bad Request {"status": "error", "message": "Invalid password"}
FE-->>User: Display error message
end
```
#### 3. Ticket Lifecycle Flow
```mermaid
sequenceDiagram
autonumber
actor User as Authenticated User
actor Admin as Administrator
participant FE as Frontend (Yew)
participant BE as Backend (Axum)
participant DB as Database@{"type": "database"}
Note over User, DB: Ticket Creation Flow (Protected Route)
User->>FE: Fill out ticket form & submit
FE->>BE: POST /api/tickets/create {category, betreff, description, room} (Includes 'token' cookie)
Note over BE: validate_token middleware decodes & verifies JWT
BE->>DB: INSERT INTO tickets (category, description, betreff, room, user_id)
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>User: Clear form & display success notification
Note over Admin, DB: Ticket Review & Resolution (Admin Only Route)
Admin->>FE: View Ticket Board
FE->>BE: GET /api/tickets (Includes 'token' cookie)
Note over BE: validate_token middleware checks JWT
BE->>DB: SELECT tickets JOIN users ...
DB-->>BE: Return list of tickets
BE-->>FE: HTTP 200 OK [tickets]
FE-->>Admin: Render Ticket List
Admin->>FE: Click "Resolve" on ticket
FE->>BE: PATCH /api/tickets/{id} {"status": "Resolved"} (Includes 'token' cookie)
Note over BE: validate_admin middleware verifies token & checks is_admin = true
BE->>DB: UPDATE tickets SET status = $1 WHERE id = $2
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>Admin: Update ticket status in UI
Note over Admin, DB: Ticket Archiving Flow (Admin Only Route)
Admin->>FE: Open Archived Tickets page
FE->>BE: GET /api/tickets/archive (Includes 'token' cookie)
Note over BE: validate_admin middleware verifies token & admin role
BE->>DB: SELECT * FROM tickets WHERE status = 'Archived'
DB-->>BE: Return archived tickets
BE-->>FE: HTTP 200 OK [Archived Tickets]
FE-->>Admin: Render historical archive board
```
## Usage of AI
Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation
Google Antigravity generated the Sequence Diagrams
### Prompt
Generate comments for cargo doc describing the indivilual components and create links to relevant structs, functions etc.
Generate a sequence diagramm in @[README.md] behind the class diagramms
### Output
The comments with `///` or `//!`
I've gone over it and modified it to my needs and opinions
The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
+8 -4
View File
@@ -6,12 +6,16 @@ edition = "2024"
[dependencies]
axum = "0.8.9"
axum-extra = { version = "0.12.6", features = ["cookie", "typed-header", "form"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls", "chrono"] }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls", "chrono", "migrate"] }
tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros"] }
dotenv = "0.15.0"
chrono = { 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"] }
rand = '0.8'
hex = '0.4'
@@ -5,6 +5,6 @@ CREATE TABLE IF NOT EXISTS tickets (
description TEXT,
room SMALLINT,
status VARCHAR(15) NOT NULL DEFAULT 'ToDo',
date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
date TIMESTAMP WITH TIME ZONE DEFAULT now(),
user_id SMALLINT DEFAULT 1
);
@@ -2,7 +2,7 @@ CREATE TABLE users (
id SMALLINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
last_name VARCHAR(30),
first_name VARCHAR(30),
username VARCHAR(30),
username VARCHAR(30) UNIQUE,
pwd TEXT,
is_admin BOOLEAN NOT NULL DEFAULT false
);
@@ -0,0 +1 @@
DROP TABLE qr_codes;
@@ -0,0 +1,8 @@
CREATE TABLE qr_codes (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id SMALLINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_qr_token_hash ON qr_codes(token_hash);
+53 -9
View File
@@ -1,34 +1,78 @@
use axum::{Json, http::StatusCode};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
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 via `encode_token` or `decode_token`.
/// Used in error responses for invalid or expired [`Claims`] 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. The resulting token is a serialized [`Claims`].
///
/// # 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 containing [`Claims`].
///
/// # 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 issued = now.timestamp() as usize;
let expires = (now + chrono::Duration::minutes(60)).timestamp() as usize;
let expires = (now + chrono::Duration::minutes(60)).timestamp();
let claims: Claims = Claims {
subject: id,
issued: issued,
expires: expires,
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. Returns the extracted [`Claims`]
/// on success.
///
/// # 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 claims = decode::<Claims>(&token, key, &Validation::default())
.map_err(|_| {
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: "Invalid Token".to_string(),
message,
};
(StatusCode::UNAUTHORIZED, Json(error))
})?
+6
View File
@@ -1 +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;
+213
View File
@@ -0,0 +1,213 @@
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 using [`decode_token`](`crate::cookie::jwt::decode_token`)).
/// If valid, it fetches the corresponding [`User`] from the database and inserts a
/// [`FilteredUser`](crate::models::FilteredUser)
/// (converted via [`filter_user`](`crate::handlers::auth::filter_user`)) into the request extensions for subsequent handlers to use.
///
/// If the token is missing, invalid, or the user is not found, it returns an
/// 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 via [`decode_token`](`crate::cookie::jwt::decode_token`), and fetching the associated [`User`] from the database.
/// Additionally, it verifies that the fetched user has `is_admin` set to `true`. Returns a [`FilteredUser`](crate::models::FilteredUser)
/// (converted via [`filter_user`](`crate::handlers::auth::filter_user`)) in the request extensions if both authentication and admin status are valid.
///
/// If the user is not authenticated or not an administrator, it returns an
/// 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)
}
+38
View File
@@ -1,16 +1,54 @@
/// Environment configuration for the application.
///
/// Loads required configuration from environment variables at startup.
/// All variables must be present or the application will panic during [`Env::load`].
/// Used by [`AppState`](crate::AppState) for configuring JWT signing and CORS.
///
/// # Fields
/// - `db_url`: PostgreSQL database connection URL
/// - `token_secret`: Secret key used to sign and verify [`Claims`](crate::models::Claims) in JWT tokens
/// - `origin`: Frontend origin URL for CORS policy configuration
///
/// # 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.
///
/// Reads `DATABASE_URL`, `TOKEN_SECRET`, and `ORIGIN` from the environment and returns
/// a configured [`Env`] instance. Used during server initialization in the `main` function.
///
/// # Panics
/// If any required variable is missing (DATABASE_URL, TOKEN_SECRET, or ORIGIN).
///
/// # Example
/// ```ignore
/// let env = Env::load();
/// // Environment must have DATABASE_URL, TOKEN_SECRET, and ORIGIN set
/// let app_state = AppState {
/// db: pool,
/// env,
/// };
/// ```
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,
}
}
}
+475 -8
View File
@@ -1,26 +1,41 @@
use std::{sync::Arc, usize};
use std::sync::Arc;
use argon2::{
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
password_hash::{SaltString, rand_core::OsRng},
};
use axum::{
Json,
extract::State,
Extension, Json,
extract::{Path, State},
http::{Response, StatusCode, header},
response::IntoResponse,
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use chrono::format;
use jsonwebtoken::{EncodingKey, Header};
use serde_json::json;
use crate::{
AppState,
cookie::jwt::encode_token,
models::{FilteredUser, LoginModel, LoginScheme, UserCreateScheme},
models::{FilteredUser, LoginScheme, User, UserCreateScheme, UserUpdateScheme},
};
/// Registers a new user in the system.
///
/// Creates a new [`User`] account with the provided [`UserCreateScheme`] credentials.
/// The password is hashed using Argon2 before being stored. Only administrators can create new users.
///
/// # Arguments
/// - `State(data)`: Application state containing [`AppState`] for database access
/// - `request`: [`UserCreateScheme`] containing user 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.
pub async fn create_user(
State(data): State<Arc<AppState>>,
Json(request): Json<UserCreateScheme>,
@@ -84,11 +99,40 @@ pub async fn create_user(
}
}
/// 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 and encodes a [`Claims`](crate::models::Claims) token via [`encode_token`](`crate::cookie::jwt::encode_token`) and sets it as an HTTP-only cookie.
/// The token is valid for 1 hour.
///
/// # Arguments
/// - `State(data)`: Application state containing [`AppState`] for database access
/// - `request`: [`LoginScheme`] containing login credentials (username, password)
///
/// # Returns
/// - `200 OK` with JSON containing token and filtered [`FilteredUser`] 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 with stored [`User`] hash
/// - JWT token includes user ID and expiration timestamp via [`Claims`](crate::models::Claims) encoded by [`encode_token`](`crate::cookie::jwt::encode_token`)
///
/// # 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::<_, LoginModel>(r#"SELECT * FROM users WHERE username = $1"#)
let user = sqlx::query_as::<_, User>(r#"SELECT * FROM users WHERE username = $1"#)
.bind(request.username)
.fetch_optional(&data.db)
.await
@@ -131,7 +175,7 @@ pub async fn login(
.http_only(true);
let mut response = Response::new(
json!({"status": "success", "token": token, "user": filter_users(&user)}).to_string(),
json!({"status": "success", "token": token, "user": filter_user(&user)}).to_string(),
);
response
.headers_mut()
@@ -139,6 +183,24 @@ pub async fn login(
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. The cookie no longer contains a valid [`Claims`](crate::models::Claims) token.
///
/// # 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("/")
@@ -155,11 +217,416 @@ pub async fn logout() -> Result<impl IntoResponse, (StatusCode, Json<serde_json:
Ok(response)
}
fn filter_users(user: &LoginModel) -> FilteredUser {
/// Retrieves the currently authenticated user's information.
///
/// Uses the [`FilteredUser`] data embedded in the JWT token (via middleware).
/// Useful for frontends to display logged-in user info or verify authentication.
///
/// # Returns
/// - `200 OK` with [`FilteredUser`] 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 (enforced by middleware). The [`User`] account and all associated data is removed.
/// Note: Tickets created by deleted users will have NULL user_id references.
///
/// # Arguments
/// - `Path(id)`: [`User`] ID to delete, extracted from URL path
/// - `State(data)`: Application state containing [`AppState`] for database access
///
/// # 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 (enforced by middleware). Returns all [`User`] records converted to [`FilteredUser`]
/// and sorted alphabetically by last name. Password hashes are not included in the response.
///
/// # Arguments
/// - `State(data)`: Application state containing [`AppState`] for database access
///
/// # 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 (converted via [`filter_user`]), 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 (converted via [`filter_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 requires admin privileges (enforced by middleware via
/// [`validate_admin`](crate::cookie::validation::validate_admin)).
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 [`User`] records with `is_admin = true` in the database.
///
/// # Arguments
/// - `State(data)`: Application state containing [`AppState`] for database access
///
/// # 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 via database count. 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
/// - `State(data)`: Application state containing [`AppState`] for database access
/// - `request`: [`UserCreateScheme`] containing 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 (checked via admin count)
/// - 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.
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.
/// Used by all authentication endpoints to ensure passwords are never exposed.
///
/// # 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); // Convert User to FilteredUser
/// // 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(),
}
}
+5
View File
@@ -1,2 +1,7 @@
//! 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 qr;
pub mod ticket;
+130
View File
@@ -0,0 +1,130 @@
use std::sync::Arc;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use axum::{
Json,
extract::{Query, State},
http::{StatusCode, header::SET_COOKIE},
response::{Html, IntoResponse, Response},
};
use jsonwebtoken::{EncodingKey, Header};
use rand::{RngCore, thread_rng};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use crate::{AppState, cookie::jwt::encode_token};
#[derive(Serialize)]
pub struct QrCodeResponse {
pub raw_token: String,
}
#[derive(Serialize, Deserialize)]
pub struct AuthQuery {
pub token: String,
}
#[derive(Deserialize)]
pub struct GenerateQrRequest {
pub user_id: i16,
}
pub async fn generate_qr_token(
State(data): State<Arc<AppState>>,
Json(payload): Json<GenerateQrRequest>,
) -> Result<impl IntoResponse, StatusCode> {
let mut token_bytes = [0u8; 32];
thread_rng().fill_bytes(&mut token_bytes);
let raw_token = hex::encode(token_bytes);
let salt = SaltString::generate(&mut thread_rng());
let argon2 = Argon2::default();
let token_hash = argon2
.hash_password(raw_token.as_bytes(), &salt)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.to_string();
sqlx::query("INSERT INTO qr_codes (user_id, token_hash) VALUES ($1, $2)")
.bind(payload.user_id)
.bind(token_hash)
.execute(&data.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok((StatusCode::CREATED, Json(QrCodeResponse { raw_token })))
}
pub async fn validate_qr_token(
State(data): State<Arc<AppState>>,
Query(params): Query<AuthQuery>,
) -> Response {
let active_tokens_res = sqlx::query("SELECT user_id, token_hash FROM qr_codes")
.fetch_all(&data.db)
.await;
let active_tokens = match active_tokens_res {
Ok(tokens) => tokens,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
let mut authenticated_user_id: Option<i16> = None;
let argon2 = Argon2::default();
for record in active_tokens {
let user_id: i16 = record.get("user_id");
let token_hash: String = record.get("token_hash");
if let Ok(parsed_hash) = PasswordHash::new(&token_hash) {
if argon2
.verify_password(params.token.as_bytes(), &parsed_hash)
.is_ok()
{
authenticated_user_id = Some(user_id);
break;
}
}
}
if let Some(user_id) = authenticated_user_id {
let base_origin = data.env.origin.trim_end_matches('/');
let header = Header::default();
let id_str = user_id.to_string();
let secret_bytes = data.env.token_secret.as_bytes();
let encoding_key = EncodingKey::from_secret(secret_bytes);
let token = encode_token(&header, id_str, &encoding_key);
let cookie_value = format!(
"token={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600",
token
);
let redirect_html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0; url={}/" />
<script type="text/javascript">
window.location.href = "{}/"
</script>
<title>Redirecting...</title>
</head>
<body>
<p>Authenticated! Redirecting you to the dashboard...</p>
</body>
</html>"#,
base_origin, base_origin
);
let mut response = Html(redirect_html).into_response();
if let Ok(header_value) = axum::http::HeaderValue::from_str(&cookie_value) {
response.headers_mut().insert(SET_COOKIE, header_value);
}
response
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}
+196 -42
View File
@@ -1,30 +1,51 @@
use std::sync::Arc;
use axum::{
Json,
Extension, Json,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use serde_json::json;
use sqlx::query;
use sqlx::{Row, query};
use crate::{
AppState,
models::{Ticket, TicketCreateScheme, TicketResponse, TicketUpdateScheme},
models::{FilteredUser, TicketCreateScheme, TicketResponse, TicketUpdateScheme},
};
/// Creates a new support ticket.
///
/// Associates the ticket with the authenticated [`FilteredUser`] and sets the current timestamp.
/// Converts the [`TicketCreateScheme`] request into a database record.
/// Tickets are automatically created with "open" status.
///
/// # Arguments
/// - `Extension(user)`: Authenticated [`FilteredUser`] (extracted from JWT token via middleware)
/// - `State(data)`: Application state containing [`AppState`] for database access
/// - `Json(body)`: [`TicketCreateScheme`] containing 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) VALUES ($1, $2, $3, $4)"#,
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;
@@ -39,6 +60,18 @@ pub async fn create_ticket(
Ok(Json(response_status))
}
/// Deletes a ticket by ID.
///
/// Only admins can delete tickets (enforced by middleware). Removes the [`TicketResponse`] and associated data from the database.
///
/// # Arguments
/// - `Path(id)`: Ticket ID to delete, extracted from URL path
/// - `State(data)`: Application state containing [`AppState`] for database access
///
/// # 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>>,
@@ -65,11 +98,48 @@ pub async fn delete_ticket(
Ok(StatusCode::NO_CONTENT)
}
/// Retrieves all non-archived tickets.
///
/// Returns a list of all active [`TicketResponse`] objects with user information denormalized for easier rendering.
/// Tickets are ordered by creation date (newest first). Joins with [`User`](crate::models::User) table to include creator information.
///
/// # Arguments
/// - `State(data)`: Application state containing [`AppState`] for database access
///
/// # Filtering
/// - Excludes tickets with status "Archived"
/// - Uses LEFT JOIN to include creator information from [`User`](crate::models::User)
///
/// # 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>)> {
let tickets = sqlx::query_as::<_, Ticket>(
r#"SELECT * FROM tickets WHERE status <> 'Archived' ORDER BY date DESC"#,
// 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
@@ -81,28 +151,86 @@ pub async fn get_tickets(
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
})?;
let ticket_response = tickets
// Transform raw database rows into TicketResponse structs
let ticket_response: Vec<TicketResponse> = tickets
.iter()
.map(|ticket| filter_record(&ticket))
.collect::<Vec<TicketResponse>>();
.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).
/// Returns a [`TicketResponse`] with all metadata by joining with [`User`](crate::models::User) table.
///
/// # Arguments
/// - `Path(id)`: Ticket ID to retrieve, extracted from URL path
/// - `State(data)`: Application state containing [`AppState`] for database access
///
/// # 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_as::<_, Ticket>(r#"SELECT * FROM tickets WHERE id = $1"#)
.bind(id)
.fetch_one(&data.db)
.await;
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(ticket) => {
let ticket_response = serde_json::json!(filter_record(&ticket));
return Ok(Json(ticket_response));
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!({
@@ -120,11 +248,31 @@ pub async fn get_ticket_by_id(
};
}
/// Updates a ticket's status.
///
/// Only admins can update ticket status (enforced by middleware). Applies [`TicketUpdateScheme`] to modify the [`TicketResponse`].
/// This is typically used to transition tickets through their lifecycle (open → in_progress → resolved → archived).
///
/// # Arguments
/// - `Path(id)`: Ticket ID to update, extracted from URL path
/// - `State(data)`: Application state containing [`AppState`] for database access
/// - `Json(body)`: [`TicketUpdateScheme`] 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)
@@ -145,34 +293,40 @@ pub async fn edit_ticket(
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)));
}
let updated_ticket = sqlx::query_as::<_, Ticket>(r#"SELECT * FROM tickets WHERE id = $1"#)
.bind(id)
.fetch_one(&data.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
)
})?;
// 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 = serde_json::json!({
"ticket": filter_record(&updated_ticket),
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(ticket_response))
}
fn filter_record(ticket: &Ticket) -> TicketResponse {
TicketResponse {
id: ticket.id.to_owned(),
category: ticket.category.to_owned(),
betreff: ticket.betreff.to_owned(),
description: ticket.description.to_owned(),
room: ticket.room.to_owned(),
status: ticket.status.to_owned(),
date: ticket.date.to_owned(),
user_id: ticket.user_id.to_owned(),
}
Ok(Json(response))
}
+70 -9
View File
@@ -1,30 +1,71 @@
#![allow(unused_imports)]
/// 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;
/// Axum router configuration with all routes and middleware
mod router;
use std::sync::Arc;
use axum::{Router, routing};
use axum::http::{
HeaderValue, Method,
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
};
use dotenv::dotenv;
use models::*;
use router::create_router;
use serde::{Deserialize, Serialize};
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 and cloned into each route
/// via `with_state`.
///
/// # Fields
/// - `db`: PostgreSQL connection pool for database access (via `sqlx::PgPool`)
/// - `env`: [`Env`] configuration loaded from environment variables
pub struct AppState {
db: PgPool,
env: Env,
/// PostgreSQL connection pool for all database operations
pub db: PgPool,
/// Environment configuration with secrets and settings
pub env: Env,
}
/// Main application entry point.
///
/// Initializes the server by:
/// 1. Loading environment variables from `.env` file
/// 2. Establishing database connection pool to PostgreSQL
/// 3. Configuring CORS policy for cross-origin requests
/// 4. Creating the router with [`create_router`] containing all endpoints
/// 5. 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 [`Env`]
///
/// # State Setup
/// Creates shared [`AppState`] wrapped in `Arc` and passes to all routes
///
/// # Panics
/// - If environment loading fails
/// - If database connection fails
#[tokio::main]
async fn main() {
dotenv().ok();
let env = Env::load();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL variable not set");
let database_url = &env.db_url;
// Establish connection pool to PostgreSQL
let pool = match PgPoolOptions::new().connect(&database_url).await {
Ok(pool) => {
println!("Database connection successful");
@@ -35,10 +76,30 @@ async fn main() {
std::process::exit(1);
}
};
// Run database migrations on container/application startup
println!("Running database migrations...");
if let Err(err) = sqlx::migrate!("./migrations").run(&pool).await {
println!("Failed to run database migrations: {:?}", err);
std::process::exit(1);
}
println!("Database migrations completed successfully");
// Configure CORS to allow requests from frontend
let cors = CorsLayer::new()
.allow_origin(env.origin.parse::<HeaderValue>().unwrap())
.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();
axum::serve(listener, app).await;
let _ = axum::serve(listener, app).await;
}
+172 -28
View File
@@ -1,89 +1,233 @@
use std::fmt::Display;
use serde::{Deserialize, Serialize};
use sqlx::{Decode, prelude::Type};
#[derive(Deserialize, Serialize, PartialEq, Debug, sqlx::FromRow)]
pub struct Ticket {
pub id: i32,
pub category: String,
pub betreff: String,
pub description: String,
pub room: i16,
pub status: String,
pub date: chrono::NaiveDateTime,
pub user_id: i16,
}
/// API response for a ticket with user information.
///
/// Returned by ticket endpoints. Includes denormalized user data for easier frontend rendering.
/// Created via [`TicketCreateScheme`].
///
/// # Fields
/// - `id`: Unique ticket identifier
/// - `category`: Ticket category/type
/// - `betreff`: Ticket subject line
/// - `description`: Detailed ticket description
/// - `room`: Room number associated with the issue
/// - `status`: Current ticket status (e.g., "open", "in_progress", "resolved")
/// - `date`: When the ticket was created (UTC timestamp)
/// - `user_id`: ID of the user who created the ticket (references [`User`])
/// - `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 {
/// Unique ticket identifier
pub id: i32,
/// Ticket category/type (e.g., "maintenance", "support")
pub category: String,
/// Ticket subject line
pub betreff: String,
/// Detailed ticket description
pub description: String,
/// Room number associated with the issue
pub room: i16,
/// Current ticket status (e.g., "open", "in_progress", "resolved", "archived")
pub status: String,
pub date: chrono::NaiveDateTime,
/// When the ticket was created (UTC timestamp)
pub date: chrono::DateTime<chrono::Utc>,
/// ID of the user who created the ticket
pub user_id: i16,
/// First name of the ticket creator (denormalized from `User`)
pub user_first_name: String,
/// Last name of the ticket creator (denormalized from `User`)
pub user_last_name: String,
}
#[derive(Deserialize, Serialize, PartialEq, Debug)]
/// Complete user record from the database.
///
/// 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()`](`crate::handlers::auth::filter_user`) to convert to [`FilteredUser`] for responses.
#[derive(Deserialize, Serialize, PartialEq, Debug, Clone, sqlx::FromRow)]
pub struct User {
/// Unique user identifier
pub id: i16,
/// User's last name
pub last_name: String,
/// User's first name
pub first_name: String,
/// Unique login username (must be unique in the database)
pub username: String,
/// Whether this user has administrator privileges
pub is_admin: bool,
/// Argon2 password hash (NEVER expose to clients)
pub pwd: String,
}
/// 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. Converted to [`TicketResponse`] for the response.
///
/// # 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 {
/// Ticket category/type
pub category: String,
/// Subject line for the ticket
pub betreff: String,
/// Detailed problem description
pub description: String,
/// Room number where the issue is located
pub room: i16,
}
/// Payload for updating a ticket.
///
/// Sent to `PATCH /api/tickets/{id}`. Allows updating the ticket [`TicketResponse::status`].
/// Only admins can update tickets.
///
/// # Fields
/// - `status`: New ticket status (e.g., "open", "in_progress", "resolved")
#[derive(Deserialize, Serialize, Debug)]
pub struct TicketUpdateScheme {
/// New ticket status (e.g., "open", "in_progress", "resolved", "archived")
pub status: String,
}
/// Payload for updating user information.
///
/// Sent to `PATCH /api/users/{id}`. Allows updating profile and admin status.
/// Only admins can update [`User`] records. Empty password field means no password change.
///
/// # 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 {
/// User ID to update
pub id: i16,
/// Updated user first name
pub first_name: String,
/// Updated user last name
pub last_name: String,
/// Updated login username
pub username: String,
/// New admin privilege status
pub make_admin: bool,
/// New password (empty string = keep existing password)
pub new_pwd: String,
}
/// 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. Converted to [`User`] for storage.
///
/// # 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 {
/// User's first name
pub first_name: String,
/// User's last name
pub last_name: String,
/// Unique username for login
pub username: String,
/// Whether to grant admin privileges
pub is_admin: bool,
/// Plain text password (hashed on server before storage)
pub pwd: String,
}
/// 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 {
/// Username for login
pub username: String,
/// Plain text password (verified against stored Argon2 hash)
pub pwd: String,
}
#[derive(Deserialize, Serialize, Debug, sqlx::FromRow)]
pub struct LoginModel {
pub id: i16,
pub last_name: String,
pub first_name: String,
pub is_admin: bool,
pub pwd: String,
}
#[derive(Debug, Serialize)]
/// 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 {
/// Unique user identifier
pub id: i16,
/// User's first name
pub first_name: String,
/// User's last name
pub last_name: String,
/// Login username
pub username: String,
/// Whether user has admin privileges
pub is_admin: bool,
}
/// JWT token claims embedded in the session token.
///
/// Contains user identification and token validity information.
/// Generated during login via `encode_token` and verified via `decode_token`.
///
/// # Fields
/// - `sub`: Subject - the user ID as a string (references [`User`])
/// - `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 {
pub subject: String,
/// 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,
}
+81 -5
View File
@@ -2,21 +2,67 @@ use std::sync::Arc;
use axum::{
Router,
http::StatusCode,
middleware,
response::IntoResponse,
routing::{get, post},
};
use crate::{
AppState,
cookie::validation::{validate_admin, validate_token},
handlers::{
auth::{create_user, login, logout},
auth::{
check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id,
get_users, login, logout, setup_initial_admin, update_user,
},
qr::{generate_qr_token, validate_qr_token},
ticket::{create_ticket, delete_ticket, edit_ticket, get_ticket_by_id, get_tickets},
},
};
async fn debug_fallback_handler(uri: axum::http::Uri) -> impl IntoResponse {
println!(
"[BACKEND DEBUG] Axum Fallback triggered! Unmatched request path: {}",
uri
);
(StatusCode::NOT_FOUND, "Backend route not found")
}
/// Creates the complete router with all API endpoints.
///
/// The router is organized in layers for proper middleware application. Uses [`AppState`]
/// for shared application context across all routes.
///
/// ## Route Layers (from most to least restricted):
///
/// ### Admin-Only Routes (requires admin privilege + valid token)
/// - `GET /api/tickets/{id}` - Get specific ticket details (via `get_ticket_by_id`)
/// - `DELETE /api/tickets/{id}` - Delete a ticket (via `delete_ticket`)
/// - `PATCH /api/tickets/{id}` - Update ticket status (via `edit_ticket`)
/// - `POST /api/register` - Create a new user (via `create_user`)
/// - `GET /api/users` - List all users (via `get_users`)
/// - `GET /api/users/{id}` - Get user details (via `get_user_by_id`)
/// - `DELETE /api/users/{id}` - Delete a user (via `delete_user`)
/// - `PATCH /api/users/{id}` - Update user details (via `update_user`)
///
/// ### Protected Routes (requires valid token)
/// - `GET /api/tickets` - List all tickets (via `get_tickets`)
/// - `POST /api/tickets/create` - Create a new ticket (via `create_ticket`)
/// - `GET /api/logout` - Logout user (via `logout`)
/// - `GET /api/users/current` - Get current authenticated user (via `get_current_user`)
///
/// ### Public Routes (no authentication required)
/// - `POST /api/login` - User login (via `login`)
/// - `GET /api/check-admin` - Check if admin exists (via `check_admin_exists`)
/// - `POST /api/setup-admin` - Create initial admin account (via `setup_initial_admin`)
///
/// # 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 {
Router::new()
.route("/api/tickets", get(get_tickets))
.route("/api/tickets/create", post(create_ticket))
let admin_routes = Router::new()
.route(
"/api/tickets/{id}",
get(get_ticket_by_id)
@@ -24,7 +70,37 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.patch(edit_ticket),
)
.route("/api/register", post(create_user))
.route("/api/login", post(login))
.route("/api/users", get(get_users))
.route(
"/api/users/{id}",
get(get_user_by_id).delete(delete_user).patch(update_user),
)
.route("/api/generate_qr", post(generate_qr_token))
.layer(middleware::from_fn_with_state(
state.clone(),
validate_admin,
));
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,
));
let public_routes = Router::new()
.route("/api/login", post(login))
.route("/api/check-admin", get(check_admin_exists))
.route("/api/setup-admin", post(setup_initial_admin))
.route("/api/qr_login", get(validate_qr_token));
Router::new()
.merge(protected_routes)
.merge(public_routes)
.fallback(debug_fallback_handler)
.with_state(state)
}
+127
View File
@@ -0,0 +1,127 @@
# ============================================================
# Dockerfile — Full application (frontend + backend)
# ============================================================
# Multi-stage build:
# 1. chef install cargo-chef for dependency caching
# 2. planner generate recipe.json from the workspace
# 3. backend build the backend binary (release)
# 4. frontend build the frontend WASM bundle with Trunk
# 5. runtime minimal image: backend binary + static frontend + nginx
# ============================================================
# --------------- Stage 1: Chef base ---------------
FROM rust:1-bookworm AS chef
RUN cargo install cargo-chef
WORKDIR /app
# --------------- Stage 2: Planner ---------------
FROM chef AS planner
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
RUN cargo chef prepare --recipe-path recipe.json
# --------------- Stage 3: Backend builder ---------------
FROM chef AS backend-builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json -p backend
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
RUN cargo build --release -p backend
# --------------- Stage 4: Frontend builder ---------------
FROM chef AS frontend-builder
# Add the WASM target
RUN rustup target add wasm32-unknown-unknown
# Install Trunk (the WASM bundler used by the frontend) and sass (for SCSS)
RUN cargo install trunk
# Cache frontend dependencies
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --target wasm32-unknown-unknown --recipe-path recipe.json -p frontend
# Copy full workspace source for the build
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
# Build the frontend WASM bundle
# Trunk outputs to frontend/dist by default
WORKDIR /app/frontend
RUN trunk build --release
# --------------- Stage 5: Runtime ---------------
FROM debian:bookworm-slim AS runtime
# Install runtime dependencies:
# - ca-certificates & libssl3: TLS for PostgreSQL connections
# - nginx: serves the frontend static files and reverse-proxies /api to the backend
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates libssl3 nginx && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the compiled backend binary
COPY --from=backend-builder /app/target/release/backend /app/backend
# Copy migrations
COPY backend/migrations/ /app/migrations/
# Copy the frontend static build output
COPY --from=frontend-builder /app/frontend/dist /var/www/html
# Nginx configuration: serve frontend + reverse-proxy /api to the backend
RUN cat > /etc/nginx/sites-available/default <<'EOF'
server {
listen 80;
server_name _;
root /var/www/html;
index index.html;
# Serve static frontend files, fall back to index.html for client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Reverse-proxy API requests to the Axum backend
location /api/ {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOF
# Startup script: launch backend in the background, then nginx in the foreground
RUN cat > /app/start.sh <<'SCRIPT'
#!/bin/bash
set -e
# Start the backend API server in the background
/app/backend &
# Start nginx in the foreground (keeps the container alive)
nginx -g 'daemon off;'
SCRIPT
RUN chmod +x /app/start.sh
EXPOSE 80
# Environment variables should be provided at runtime:
# DATABASE_URL PostgreSQL connection string
# TOKEN_SECRET JWT signing key
# ORIGIN Allowed CORS origin (should be the public URL of this container)
CMD ["/app/start.sh"]
+66
View File
@@ -0,0 +1,66 @@
# ============================================================
# Dockerfile.backend — Backend only
# ============================================================
# Multi-stage build:
# 1. chef install cargo-chef for dependency caching
# 2. planner generate a recipe.json from the workspace
# 3. builder build dependencies first (cached), then the backend binary
# 4. runtime minimal image with just the compiled binary
# ============================================================
# --------------- Stage 1: Chef base ---------------
FROM rust:1-bookworm AS chef
RUN cargo install cargo-chef
WORKDIR /app
# --------------- Stage 2: Planner ---------------
FROM chef AS planner
# Copy the full workspace so cargo-chef can resolve all dependencies
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
RUN cargo chef prepare --recipe-path recipe.json
# --------------- Stage 3: Builder ---------------
FROM chef AS builder
# Cache dependency compilation in its own layer
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json -p backend
# Now copy the real source and build
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
COPY backend/ backend/
COPY frontend/ frontend/
RUN cargo build --release -p backend
# --------------- Stage 4: Runtime ---------------
FROM debian:bookworm-slim AS runtime
# Install TLS root certificates and PostgreSQL client libs (needed by sqlx
# at runtime for TLS connections via native-tls)
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates libssl3 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the compiled binary
COPY --from=builder /app/target/release/backend /app/backend
# Copy migrations so they can be run at startup if needed
COPY backend/migrations/ /app/migrations/
EXPOSE 8001
# Environment variables should be provided at runtime via docker run --env
# or docker compose environment/env_file.
#
# Required:
# DATABASE_URL PostgreSQL connection string
# TOKEN_SECRET JWT signing key
# ORIGIN Allowed CORS origin (frontend URL)
CMD ["/app/backend"]
+58
View File
@@ -0,0 +1,58 @@
services:
postgres:
image: postgres:latest
container_name: postgres_ticket
restart: unless-stopped
ports:
- 5432:5432
environment:
POSTGRES_PASSWORD: tickets
POSTGRES_DB: system_data
volumes:
- pg_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
# Full application (frontend + backend + nginx)
# Usage: docker compose --profile full up --build
app:
profiles: ["full"]
build:
context: ..
dockerfile: docker/Dockerfile
container_name: ticketsystem
restart: unless-stopped
ports:
- 8080:80
environment:
DATABASE_URL: postgres://postgres:tickets@postgres:5432/system_data
TOKEN_SECRET: 160257c8c5ff2298363529e963a5901d2efa6d6ae67d634487c4d2bbc41c533f
ORIGIN: http://localhost:8080
depends_on:
postgres:
condition: service_healthy
# Backend only (API server)
# Usage: docker compose --profile backend up --build
backend:
profiles: ["backend"]
build:
context: ..
dockerfile: docker/Dockerfile.backend
container_name: ticketsystem-backend
restart: unless-stopped
ports:
- 8001:8001
environment:
DATABASE_URL: postgres://postgres:tickets@postgres:5432/system_data
TOKEN_SECRET: 160257c8c5ff2298363529e963a5901d2efa6d6ae67d634487c4d2bbc41c533f
ORIGIN: http://localhost:8000
depends_on:
postgres:
condition: service_healthy
volumes:
pg_data:
+32
View File
@@ -0,0 +1,32 @@
[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"
theme = { version = "0.0.3", features = ["yew"] }
qrcode-generator = "5.0.0"
base64 = '0.22'
dotenv = '0.15.0'
+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" data-target-path="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 uses the backend's validation middleware by fetching the current user's authentication
/// and admin status from the `/api/users/current` endpoint (which requires a valid JWT token).
/// 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 via the backend.
/// - **Not Authenticated**: Redirects to the login page (`crate::Route::Login`).
/// - **Authenticated** (valid JWT token from backend):
/// - 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>{ "Wird geladen..." } </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>{ "Überprüfe Berechtigungen..." }</div> },
}
} else {
props.children.clone().into()
}
}
}
}
+78
View File
@@ -0,0 +1,78 @@
use yew::prelude::*;
/// A button component that toggles between light and dark theme modes.
///
/// Clicking this button switches the application's theme by modifying the `data-theme` attribute
/// on the document element. The theme preference is applied via CSS variables for consistent styling.
///
/// # Behavior
/// - Reads the current `data-theme` attribute value from the HTML element
/// - Sets `data-theme="dark"` if currently in light mode
/// - Clears the attribute (light mode) if currently in dark mode
/// - Re-renders to reflect the visual changes (typically toggled via CSS)
///
/// # Example Usage
/// ```ignore
/// html! {
/// <ThemeToggle />
/// }
/// ```
#[function_component]
pub fn ThemeToggle() -> Html {
let is_dark = use_state(|| {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Some(html) = document.document_element() {
return html.get_attribute("data-theme").unwrap_or_default() == "dark";
}
}
}
false
});
let onclick = {
let is_dark = is_dark.clone();
Callback::from(move |_| {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Some(html) = document.document_element() {
let current = html.get_attribute("data-theme").unwrap_or_default();
let new_theme = if current == "dark" { "" } else { "dark" };
let _ = html.set_attribute("data-theme", new_theme);
is_dark.set(new_theme == "dark");
}
}
}
})
};
let icon_html = if *is_dark {
// Sun SVG for dark mode
html! {
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="theme-icon">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
}
} else {
// Moon SVG for light mode
html! {
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="theme-icon">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
}
};
html! {
<button {onclick} class="sidebar-button theme-toggle-btn">
{icon_html}
</button>
}
}
+323
View File
@@ -0,0 +1,323 @@
mod auth;
mod darkmode;
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. Each route is protected
/// by [`ProtectedRoute`] middleware where appropriate to enforce authentication and authorization.
/// See [`switch`] for the routing logic.
#[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. Integrates with [`crate::pages::sidebar::Sidebar`] for navigation.
///
/// # Mobile Support
/// On mobile displays, the sidebar is hidden by default and can be toggled:
/// - **Menu Toggle Button**: Renders a floating menu button to slide the sidebar open.
/// - **Dark Mode Toggle**: Floating button to change theme.
/// - **Overlay Backdrop**: Dims the screen when the sidebar is open. Clicking it closes the sidebar.
/// - **Auto-Close on Navigation**: Automatically closes the sidebar when navigating to a new route.
///
/// # Components
/// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component, accepting mobile open state.
/// - 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 {
let route = use_route::<Route>();
let mobile_sidebar_open = use_state(|| false);
// Close mobile sidebar automatically on any route transition
{
let mobile_sidebar_open = mobile_sidebar_open.clone();
use_effect_with(route, move |_| {
mobile_sidebar_open.set(false);
|| ()
});
}
let on_open = {
let mobile_sidebar_open = mobile_sidebar_open.clone();
Callback::from(move |_: MouseEvent| mobile_sidebar_open.set(true))
};
let on_close = {
let mobile_sidebar_open = mobile_sidebar_open.clone();
Callback::from(move |_: ()| mobile_sidebar_open.set(false))
};
let on_close_click = {
let on_close = on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
};
html! {
<div class="layout">
<button class="mobile-menu-toggle" onclick={on_open}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="12" y2="12"/>
<line x1="4" x2="20" y1="6" y2="6"/>
<line x1="4" x2="20" y1="18" y2="18"/>
</svg>
</button>
<div class={if *mobile_sidebar_open { "sidebar-overlay open" } else { "sidebar-overlay" }} onclick={on_close_click}></div>
<sidebar::Sidebar is_open={*mobile_sidebar_open} on_close={on_close} />
<main class="content">
{ 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
/// backend's `/api/check-admin` endpoint (via `crate::handlers::auth::check_admin_exists`) to determine system state.
///
/// # Behavior
/// - **Loading**: Displays "Loading..." while checking admin status from the backend
/// - **No Admin**: Automatically redirects to [`Route::Setup`] page for initialization
/// - **Admin Exists**: Renders the wrapped children (e.g., login page)
///
/// # Example Usage
/// ```ignore
/// <AdminCheckWrapper>
/// <LoginPage />
/// </AdminCheckWrapper>
/// ```
///
/// # Backend Integration
/// The check queries the backend's `/api/check-admin` endpoint which returns `{"has_admin": bool}`.
/// This allows the frontend to determine if initial admin setup is required.
#[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>{ "Wird geladen..." }</div> },
Some(false) => {
navigator.push(&Route::Setup);
html! { <div>{ "Wird weitergeleitet zur Einrichtung..." }</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 and authentication flow.
///
/// Most routes are wrapped in [`ProtectedRoute`] to enforce authentication
/// and authorization based on the `admin_page` flag, and in [`SidebarShell`] to maintain consistent layout.
/// Login and Setup routes use [`AdminCheckWrapper`] instead to handle pre-authentication states.
///
/// # Arguments
/// - `route`: The [`Route`] enum variant representing the current URL path.
///
/// # Returns
/// An `Html` component that should be rendered for the given route.
///
/// # Route Protection
/// - **Admin-required routes** (`admin_page={true}`): Require both authentication and admin privileges
/// - **Public routes** (`admin_page={false}`): Require only authentication
/// - **Pre-auth routes** (`AdminCheckWrapper`): Used before admin creation or login
fn switch(route: Route) -> Html {
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`] matched by the `switch` function.
///
/// Uses [`switch`] as the routing dispatcher to handle all route-specific rendering,
/// which applies appropriate middleware like [`ProtectedRoute`] and [`AdminCheckWrapper`].
///
/// # Structure
/// - `BrowserRouter`: Enables client-side routing.
/// - `Switch`: Renders components based on the matched [`Route`].
/// - `switch` function: Determines which component to render for each route.
#[component(App)]
pub fn app() -> Html {
html! {
<BrowserRouter>
<basic_pages::Icon/>
<Switch<Route> render={switch} />
</BrowserRouter>
}
}
+9
View File
@@ -0,0 +1,9 @@
use frontend::App;
/// Main entry point for the Yew frontend application.
///
/// Initializes the Yew framework and renders the root [`App`] component into the DOM.
/// This function is typically called automatically by the WebAssembly runtime.
fn main() {
yew::Renderer::<App>::new().render();
}
+141
View File
@@ -0,0 +1,141 @@
use gloo_net::http::Request;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
/// Removes surrounding double quotes from a string.
///
/// This macro takes an expression that evaluates to a string and returns a new `String`
/// with any leading or trailing double quotes removed. It's useful for cleaning up
/// string data that might be inadvertently wrapped in quotes, such as JSON string values.
///
/// # Arguments
///
/// * `$str`: An expression that can be converted into a string slice (`&str`).
///
/// # Examples
///
/// ```rust
/// use your_crate::dequote; // Assuming `dequote` is re-exported or in scope
///
/// let quoted_string = "\"hello world\"";
/// let dequoted_string = dequote!(quoted_string);
/// assert_eq!(dequoted_string, "hello world");
///
/// let already_clean = "no quotes";
/// let dequoted_clean = dequote!(already_clean);
/// assert_eq!(dequoted_clean, "no quotes");
/// ```
macro_rules! dequote {
($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 home">
<crate::utilities::TicketCount/>
<p class="login-status">{ "Sie sind angemeldet als: " }</p>
<p class="name">{ &*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 Nicht gefunden";
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" />
}
}
+14
View File
@@ -0,0 +1,14 @@
//! 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 qr;
pub mod setup;
pub mod sidebar;
pub mod ticket;
pub mod user;
pub mod utilities;
+118
View File
@@ -0,0 +1,118 @@
use base64::{Engine, engine::general_purpose::STANDARD};
use gloo_net::http::Request;
use serde::{Deserialize, Serialize};
use yew::prelude::*;
#[derive(Deserialize, Clone)]
struct QrResponse {
raw_token: String,
}
#[derive(Properties, PartialEq)]
pub struct QrManagerProps {
pub user_id: i16,
}
#[derive(Serialize)]
pub struct QrPayload {
user_id: i16,
}
#[function_component(QrManager)]
pub fn qr_manager(props: &QrManagerProps) -> Html {
let qr_image_data = use_state(|| None::<String>);
let error_message = use_state(|| None::<String>);
let user_id = props.user_id;
let on_generate_click = {
let qr_image_data = qr_image_data.clone();
let error_message = error_message.clone();
Callback::from(move |_| {
let qr_image_data = qr_image_data.clone();
let error_message = error_message.clone();
wasm_bindgen_futures::spawn_local(async move {
let payload = QrPayload { user_id };
match Request::post("/api/generate_qr")
.json(&payload)
.unwrap()
.send()
.await
{
Ok(response) if response.ok() => {
if let Ok(data) = response.json::<QrResponse>().await {
// Point scanned link to the public login endpoint with the token
let target_url = format!(
"{}/api/qr_login?token={}",
std::env::var("ORIGIN")
.ok()
.unwrap_or("http://localhost:8000".to_string()),
data.raw_token
);
match qrcode_generator::to_png_to_vec(
target_url,
qrcode_generator::QrCodeEcc::Medium,
250,
) {
Ok(png_bytes) => {
let base64_png = format!(
"data:image/png;base64,{}",
STANDARD.encode(&png_bytes)
);
qr_image_data.set(Some(base64_png));
error_message.set(None);
}
Err(_) => {
error_message
.set(Some("Failed to render QR graphic.".to_string()));
}
}
}
}
_ => {
error_message.set(Some(
"Failed to generate token. Are you logged in as an admin?".to_string(),
));
}
}
});
})
};
html! {
<div style="text-align: center; padding: 15px; border: 1px dashed #ccc; border-radius: 6px;">
<h4>{"Permanent Login Access"}</h4>
<p style="font-size: 0.85em; color: #555;">
{ format!("Generate a permanent bypass login QR code for User #{}", user_id) }
</p>
<button
onclick={on_generate_click}
style="padding: 8px 16px; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;"
>
{"Generate QR Code"}
</button>
{ if let Some(ref err) = *error_message {
html! { <p style="color: #dc3545; margin-top: 10px; font-size: 0.9em;">{err}</p> }
} else {
html! {}
}}
{ if let Some(ref qr_src) = *qr_image_data {
html! {
<div style="margin-top: 15px;">
<img src={qr_src.clone()} alt="Login QR Code" style="border: 1px solid #ddd; padding: 8px; background: white;" />
<p style="font-size: 0.8em; color: #dc3545; font-weight: bold; margin-top: 8px;">
{"Warn the user to keep this QR code secure!"}
</p>
</div>
}
} else {
html! {}
}}
</div>
}
}
+283
View File
@@ -0,0 +1,283 @@
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 is_admin: bool,
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>{ "Wird überprüft..." }</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("Passwortfelder dürfen nicht leer sein".to_string());
return;
}
if *pwd != *pwd_confirm {
error.set("Passwörter stimmen nicht überein".to_string());
return;
}
if (*username).is_empty() {
error.set("Benutzername darf nicht leer sein".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,
is_admin: true,
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(|_| "Unbekannt".into());
error.set(text);
}
Err(err) => error.set(format!("Netzwerkfehler: {}", err)),
}
});
})
};
html! {
<div class="setup-container rundbg">
<div class="setup-box">
<h1>{ "Admin-Einrichtung" }</h1>
<p>{ "Erstellen Sie Ihr erstes Administratorkonto" }</p>
<form {onsubmit} class="setup-form">
<div class="form-group">
<label for="first_name">{ "Vorname:" }
<input
id="first_name"
type="text"
placeholder="Vorname"
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">{ "Nachname:" }
<input
id="last_name"
type="text"
placeholder="Nachname"
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">{ "Benutzername:" }
<input
id="username"
type="text"
placeholder="Benutzername"
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">{ "Passwort:" }
<input
id="password"
type="password"
placeholder="Passwort"
value={(*pwd).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
pwd.set(input.value());
})}
/>
</label>
</div>
<div class="form-group">
<label for="pwd_confirm">{ "Passwort bestätigen:" }
<input
id="pwd_confirm"
type="password"
placeholder="Passwort bestätigen"
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 { "Wird erstellt..." } else { "Administratorkonto erstellen" } }
</button>
if !error.is_empty() {
<p class="error-message" style="color:red">{ (*error).clone() }</p>
}
if *success {
<p class="success-message" style="color:green">{ "Administratorkonto erfolgreich erstellt! Wird weitergeleitet zum Login..." }</p>
}
</form>
</div>
</div>
}
}
+481
View File
@@ -0,0 +1,481 @@
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::*;
/// The key used to store and retrieve the sidebar's expansion state in `LocalStorage`.
///
/// This constant ensures consistency when accessing the stored state across different
/// parts of the application. The value associated with this key in `LocalStorage`
/// will be a serialized [`SidebarExpandState`] object.
const STORAGE_KEY: &str = "sidebar_state";
/// 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">{ "Ticket einreichen" }</span></Link<crate::Route>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "Tickets anzeigen" }</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()}
>
{ "Benutzer" }
{ 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">{ "Benutzer erstellen" }</span></Link<crate::Route>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "Benutzer anzeigen" }</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.
///
/// # Mobile Support
/// On small screens:
/// - Slides into view from the left when `props.is_open` is `true`.
/// - Renders a close button (`✕`) in the header that emits `props.on_close`.
///
/// # Structure
/// - 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`).
/// Properties for the [`Sidebar`] component.
///
/// These properties enable managing and controlling the responsive mobile sidebar
/// layout and its visibility settings.
#[derive(Properties, PartialEq)]
pub struct SidebarComponentProps {
/// A boolean flag indicating whether the mobile sidebar is currently slid open (`true`) or hidden (`false`).
#[prop_or_default]
pub is_open: bool,
/// A callback emitted when the user requests to close/hide the mobile sidebar.
#[prop_or_default]
pub on_close: Callback<()>,
}
#[component(Sidebar)]
pub fn sidebar(props: &SidebarComponentProps) -> 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">{ "Wird geladen..." }</div> },
// Non-admin: render a condensed user sidebar (no diagnostics, limited links)
Some(false) => {
let on_close = props.on_close.clone();
html! {
<SidebarStateProvider>
<nav class={if props.is_open { "sidebar user open" } else { "sidebar user" }}>
<ul>
<li class="sidebar-header">
<Link<crate::Route> to={crate::Route::Home} classes="home">
<svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</Link<crate::Route>>
<button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</li>
<TicketMenu/>
<li class="logout-item">
<crate::darkmode::ThemeToggle/>
<button
class="sidebar-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
}
}
// Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => {
let on_close = props.on_close.clone();
html! {
<SidebarStateProvider>
<nav class={if props.is_open { "sidebar admin open" } else { "sidebar admin" }}>
<ul>
<li class="sidebar-header">
<Link<crate::Route> to={crate::Route::Home} classes="home">
<svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</Link<crate::Route>>
<button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</li>
<TicketMenu/>
<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">
<crate::darkmode::ThemeToggle/>
<button
class="sidebar-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
}
}
}
}
+825
View File
@@ -0,0 +1,825 @@
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("Ungültiger Raum".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("Raum nicht erlaubt".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("Erfolg".into())),
Ok(response) => {
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into());
status.set(Some(text));
},
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
}
});
})
};
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 class="warning" >{ "Ungültiger/nicht erlaubter Raum (z. B. 101, K12, D5)" }</p>
}
} else {
html! {}
}
}
<br/>
<button type="submit">{ "Absenden" }</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("Erfolg".into())),
Ok(response) => {
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into());
error.set(Some(text));
},
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
}
});
})
};
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 => {
ticket_state.set(None);
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unbekannt".into());
delete_error.set(Some(txt));
}
Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))),
}
deleting.set(false);
});
})
};
if *loading {
html! {<p>{ "Wird geladen" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> }
} else if let Some(t) = &*ticket {
html! {
<div class="rundbg">
<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>{ "Kein Ticket gefunden." }</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>{ "Wird geladen" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", 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,) }</h3></Link<crate::Route>>
</li>
</div>
}})}
</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!("Parsefehler: {}", 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!("Netzwerkfehler: {}", 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>{ "Wird geladen" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> }
} else {
html! {
<ul class="postits">
{ for tickets.iter().filter(|t| t.status == "Archived" && (user.is_admin || if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| html! {
<div class="listbox">
<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>
}
}
}
+769
View File
@@ -0,0 +1,769 @@
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,
}
/// Represents an error response from the API.
///
/// This struct is used to deserialize error messages received from the backend API.
/// It typically contains a human-readable message and an internal status code.
///
/// # Fields
/// - `message`: A `String` containing a description of the error.
/// - `_status`: An internal status code or message, often ignored in frontend display.
#[derive(Deserialize, Debug)]
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("Erfolg".into())),
Ok(response) => {
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into());
status.set(Some(text));
}
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
}
});
})
};
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(|_| "Unbekannt".into());
error.set(text);
}
Err(err) => error.set(format!("Netzwerkfehler: {}", err)),
}
});
})
};
html! {
<div>
<h1 class="headline">{ "Anmeldung" }</h1>
<form {onsubmit} class="rundbg login">
<input
placeholder="Benutzername"
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="Passwort"
value={(*pwd).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
pwd.set(input.value());
})}
/>
<button type="submit" disabled={*loading}>{ if *loading { "Anmeldung läuft..." } else { "Anmelden" } }</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>{ "Wird geladen" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> }
} else {
html! {
<ul class="postits">
{ 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) }</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("Möchten Sie diesen Eintrag wirklich 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 user_state = user_state.clone();
spawn_local(async move {
let url = format!("/api/users/{}", id);
let req = Request::delete(&url).credentials(web_sys::RequestCredentials::Include);
match req.send().await {
Ok(resp) if resp.status() == 200 || resp.status() == 204 => {
user_state.set(None);
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unbekannt".into());
delete_error.set(Some(txt));
}
Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))),
}
deleting.set(false);
});
})
};
if *loading {
html! {<p>{ "Wird geladen" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> }
} else if let Some(u) = &*user {
html! {
<div class="rundbg">
<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>
<crate::pages::qr::QrManager user_id={u.id}/>
<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">{ "Erfolgreich aktualisiert" }</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>{ "Kein Benutzer gefunden." }</p> }
}
}
+481
View File
@@ -0,0 +1,481 @@
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`](`crate::pages::ticket::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 class="rundbg">
<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!("Parsefehler: {}", 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!("Netzwerkfehler: {}", 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>{ "Wird geladen" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", 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 class="ticket-count">
<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!("Parsefehler: {}", 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!("Netzwerkfehler: {}", 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 = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
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>{ "Wird geladen..." }</p>
}
if let Some(e) = &*error {
<p style="color: red;">{ e.clone() }</p>
}
<h3>{ "Tickets pro Wochentag" }</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: var(--spacing-md);
}
+6
View File
@@ -0,0 +1,6 @@
@use "variables" as *;
.u-hidden { display: none !important; }
.u-gap-sm { gap: var(--spacing-sm); }
.text-muted { color: var(--color-muted); }
+34
View File
@@ -0,0 +1,34 @@
:root {
--color-bg: #ffffff;
--color-sidebar: #570000;
--color-accent: #2563eb;
--color-muted: #6b7280;
--spacing-sm: 8px;
--spacing-md: 16px;
--border-radius: 6px;
--rundbgbackgroundcolor: #a0a0a0;
--sidebar-font: #fffefe;
--color: #000000;
--color-container: #f0f0f0;
--color-text: #000000;
--color-placeholder: #757575;
--color-input: #1a1a1a;
}
[data-theme='dark'] {
--color-bg: #000000;
--color-sidebar: #b7d300;
--color-accent: #eb2525;
--color-muted: #999999;
--spacing-sm: 8px;
--spacing-md: 16px;
--border-radius: 6px;
--rundbgbackgroundcolor: #505050;
--sidebar-font: #000000;
--color: #000000;
--color-container: #1a1a1a;
--color-text: #000000;
--color-placeholder: #a0a0a0;
--color-input: #f0f0f0;
}
@@ -0,0 +1,27 @@
body {
/* Formular Styles */
input, textarea, select, button {
width: 100%;
margin-top: 1rem;
padding: 0.75rem;
font-size: 1rem;
border-radius: 0.5rem;
color: var(--color-input);
border: none;
cursor: pointer;
margin-bottom: 15px;
}
input::placeholder,
textarea::placeholder {
color: var(--color-placeholder);
opacity: 1;
}
button:hover {
cursor: pointer;
border: none;
transition: all 0.2s ease-in-out;
margin-bottom: 15px;
}
}
@@ -0,0 +1,138 @@
@use "../mixins" as *;
@use "../variables" as *;
.diagnostics-container {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.diagnostics-section {
background-color: #ffffff;
border-radius: var(--border-radius);
padding: var(--spacing-md);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
h3 {
margin: 0 0 var(--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: var(--border-radius);
background-color: #f9fafb;
}
.weekday-bars {
display: flex;
gap: 12px;
align-items: flex-end;
height: 205px;
padding: var(--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%;
justify-content: end;
.bar {
width: 100%;
background-color: var(--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: var(--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: var(--color-muted);
}
}
}
.room-chart {
display: flex;
flex-direction: column;
gap: 12px;
padding: var(--spacing-md);
border: 1px solid #e5e7eb;
border-radius: var(--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: var(--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;
}
@@ -0,0 +1,9 @@
.login-status {
font-weight: bold;
margin: 4px 8px;
}
.name {
font-size: 24px;
margin: 4px 8px;
}
+27
View File
@@ -0,0 +1,27 @@
.csg-icon {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
@media (max-width: 768px) {
max-width: 60px;
img {
width: 100%;
height: auto;
}
}
img {
width: auto;
height: auto;
}
}
@@ -0,0 +1,194 @@
@use "../variables" as *;
@use "../mixins" as *;
:root {
color-scheme: dark light;
}
.sidebar {
width: 260px;
background: var(--color-sidebar);
color: var(--sidebar-font);
min-height: 100%;
padding: var(--spacing-md);
display: flex;
flex-direction: column;
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--spacing-sm); flex: 1; }
a, .menu-toggle {
color: var(--sidebar-font);
text-decoration: none;
display: block;
padding: 8px 12px;
border-radius: var(--border-radius);
transition: background 0.2s ease-in-out;
&:hover { background: rgba(255,255,255,0.1); }
}
.menu-toggle { background: transparent; border: none; text-align: left; width: 100%; cursor: pointer; }
.submenu {
margin-left: 8px;
background: rgba(255,255,255,0.05);
border-radius: var(--border-radius);
padding: 6px;
li { padding: 4px 0; }
}
.logout-item {
margin-top: auto;
border-top: 1px solid rgba(255,255,255,0.1);
padding-top: var(--spacing-sm);
}
.sidebar-button {
background: rgba(255, 93, 43, 0.527);
color: var(--sidebar-font);
border: 1px solid rgba(255, 255, 255, 0.801);
padding: 8px 12px;
border-radius: 4px;
width: 100%;
cursor: pointer;
text-align: left;
margin-top: var(--spacing-sm);
&.theme-toggle-btn {
display: flex;
justify-content: center;
align-items: center;
}
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: var(--spacing-md);
.home {
flex-grow: 1;
display: flex;
justify-content: center;
@media (max-width: 768px) {
padding-left: 36px;
}
}
.sidebar-close {
display: none;
background: transparent;
border: none;
color: var(--sidebar-font);
cursor: pointer;
padding: 8px;
border-radius: 50%;
align-items: center;
justify-content: center;
transition: background 0.2s ease-in-out;
margin-bottom: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
svg {
width: 20px;
height: 20px;
stroke: var(--sidebar-font);
}
@media (max-width: 768px) {
display: flex;
}
}
}
.home-svg {
width: 24px;
height: 24px;
vertical-align: middle;
margin-right: 8px;
}
&.user { width: 220px; background: darken(#570000, 6%); }
}
// Floating mobile menu toggle button
.mobile-menu-toggle {
display: none;
@media (max-width: 768px) {
display: flex;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 999;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid rgba(128, 128, 128, 0.2);
background-color: var(--color-container);
color: var(--color-text);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s ease-in-out;
svg {
width: 24px;
height: 24px;
stroke: var(--color-text);
transition: stroke 0.2s ease-in-out;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
background-color: var(--color-bg);
color: var(--color-container);
svg {
stroke: var(--color-container);
}
}
&:active {
transform: translateY(0);
}
}
}
// Overlay backdrop that dims the screen and allows dismissals on mobile
.sidebar-overlay {
display: none;
@media (max-width: 768px) {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 999;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
&.open {
opacity: 1;
pointer-events: auto;
}
}
}
@@ -0,0 +1,79 @@
@use "../mixins" as *;
@use "../variables" as *;
.ticket-card {
@include card();
border-left: 4px solid var(--color-accent);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
.meta { color: var(--color-muted); font-size: 0.9rem; }
.title { font-weight: 600; }
}
.listbox {
max-width: auto;
margin:0 0;
background-color: #90ff82;
background-color: var(--rundbgbackgroundcolor);
padding-bottom: 8px;
color: white;
padding-left: 8px;
padding-right: 8px;
padding-top: 8px;
border-radius: 20px;
border-color: #000000;
border-style: double;
box-shadow: 10px 10px 10px rgba(43, 43, 43, 0.8);
font-size: 26px;
list-style-type: none;
text-align: center;
&.To-Do {
background-color: #b1003b;
}
&.InProgress {
background-color: #fff385;
}
&.Completed {
background-color: #90ff82;
}
&.Archived {
background-color: #af69ee;
}
a {
text-decoration: none;
color: white;
}
}
.postits {
display: grid;
gap: 8px;
margin-top: 96px;
}
.warning {
color: #570000;
}
.ticket-count {
border-radius: 10px;
border: 2px solid black;
width: fit-content;
padding: 8px;
margin: 0 auto;
margin-bottom: 32px;
h4 {
text-align: center;
font-size: 32px;
margin: 8px;
}
}
+139
View File
@@ -0,0 +1,139 @@
@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: var(--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;
position: fixed;
left: 0;
top: 0;
height: 100vh;
overflow-y: auto;
box-sizing: border-box;
}
.content {
flex: 1;
min-width: 0;
margin-left: 260px;
height: 100%;
overflow-y: auto;
padding: var(--spacing-md);
box-sizing: border-box;
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: -260px;
transition: left .3s ease-in-out;
z-index: 1000;
height: 100%;
box-sizing: border-box;
}
.sidebar.open { left: 0; }
.content { margin-left: 0; padding: var(--spacing-md); box-sizing: border-box; }
}
.rundbg {
display: flex;
justify-content: center;
flex-direction: column;
max-width: 60%;
margin: 0 auto;
background-color: var(--rundbgbackgroundcolor);
padding-bottom: 96px;
padding-left: 32px;
padding-right: 32px;
padding-top: 96px;
border-radius: 20px;
gap: 8px;
border-color: #000000;
border-style: double;
box-shadow: 20px 20px 10px rgb(43, 43, 43);
font-size: 26px;
label {
display: block;
width: 100%;
box-sizing: border-box;
}
input, select, button, a {
width: 100%;
box-sizing: border-box;
padding: 16px;
font-size: 1.8rem;
background-color: #f0f0f0;
color: var(--color-input);
box-shadow: #6d6d6d;
box-shadow: 6px 6px 8px;
}
input[type="checkbox"] {
width: auto;
}
button {
margin: 16px 0;
}
a {
color: var (--color);
text-align: center;
border-style: dashed;
}
&.login {
margin-top: 96px;
max-width: 40%;
}
&.home {
padding-top: 16px;
}
}
.setup-form input {
width: 100%;
box-sizing: border-box;
}
input, select, textarea {
color: var(--color-input);
}
input::placeholder,
textarea::placeholder {
color: var(--color-placeholder);
opacity: 1;
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
#![doc = include_str!("../README.cargo.md")]