diff --git a/Cargo.lock b/Cargo.lock index 2116272..272f479 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + [[package]] name = "argon2" version = "0.5.3" @@ -208,6 +214,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "boolinator" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9" + [[package]] name = "bumpalo" version = "3.20.2" @@ -653,10 +665,11 @@ dependencies = [ "gloo-storage 0.4.0", "serde", "serde_json", + "theme", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "yew", + "yew 0.23.0", "yew-router", ] @@ -795,6 +808,44 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gloo" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d" +dependencies = [ + "gloo-console 0.2.3", + "gloo-dialogs 0.1.1", + "gloo-events 0.1.2", + "gloo-file 0.2.3", + "gloo-history 0.1.5", + "gloo-net 0.3.1", + "gloo-render 0.1.1", + "gloo-storage 0.2.2", + "gloo-timers 0.2.6", + "gloo-utils 0.1.7", + "gloo-worker 0.2.1", +] + +[[package]] +name = "gloo" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd35526c28cc55c1db77aed6296de58677dbab863b118483a27845631d870249" +dependencies = [ + "gloo-console 0.3.0", + "gloo-dialogs 0.2.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-history 0.2.2", + "gloo-net 0.4.0", + "gloo-render 0.2.0", + "gloo-storage 0.3.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "gloo-worker 0.4.0", +] + [[package]] name = "gloo" version = "0.11.0" @@ -833,6 +884,19 @@ dependencies = [ "gloo-worker 0.6.0", ] +[[package]] +name = "gloo-console" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-console" version = "0.3.0" @@ -859,6 +923,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-dialogs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-dialogs" version = "0.2.0" @@ -879,6 +953,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-events" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-events" version = "0.2.0" @@ -899,6 +983,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-file" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7" +dependencies = [ + "gloo-events 0.1.2", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-file" version = "0.3.0" @@ -924,6 +1020,22 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-history" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f" +dependencies = [ + "gloo-events 0.1.2", + "gloo-utils 0.1.7", + "serde", + "serde-wasm-bindgen 0.5.0", + "serde_urlencoded", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-history" version = "0.2.2" @@ -934,7 +1046,7 @@ dependencies = [ "gloo-events 0.2.0", "gloo-utils 0.2.0", "serde", - "serde-wasm-bindgen", + "serde-wasm-bindgen 0.6.5", "serde_urlencoded", "thiserror 1.0.69", "wasm-bindgen", @@ -951,13 +1063,55 @@ dependencies = [ "gloo-events 0.3.0", "gloo-utils 0.3.0", "serde", - "serde-wasm-bindgen", + "serde-wasm-bindgen 0.6.5", "serde_urlencoded", "thiserror 2.0.18", "wasm-bindgen", "web-sys", ] +[[package]] +name = "gloo-net" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.1.7", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac9e8288ae2c632fa9f8657ac70bfe38a1530f345282d7ba66a1f70b72b7dc4" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "gloo-net" version = "0.5.0" @@ -1000,6 +1154,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-render" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-render" version = "0.2.0" @@ -1020,6 +1184,21 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-storage" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-storage" version = "0.3.0" @@ -1050,6 +1229,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -1072,6 +1261,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-utils" version = "0.2.0" @@ -1098,6 +1300,42 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-worker" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a" +dependencies = [ + "anymap2", + "bincode", + "gloo-console 0.2.3", + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76495d3dd87de51da268fa3a593da118ab43eb7f8809e17eb38d3319b424e400" +dependencies = [ + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros 0.1.0", + "js-sys", + "pinned", + "serde", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "gloo-worker" version = "0.5.0" @@ -1490,6 +1728,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "implicit-clone" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84" +dependencies = [ + "implicit-clone-derive", + "indexmap", +] + [[package]] name = "implicit-clone" version = "0.6.0" @@ -2089,6 +2337,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prokio" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b55e106e5791fa5a13abd13c85d6127312e8e09098059ca2bc9b03ca4cf488" +dependencies = [ + "futures", + "gloo 0.8.1", + "num_cpus", + "once_cell", + "pin-project", + "pinned", + "tokio", + "tokio-stream", + "wasm-bindgen-futures", +] + [[package]] name = "quote" version = "1.0.45" @@ -2290,6 +2555,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde-wasm-bindgen" version = "0.6.5" @@ -2737,6 +3013,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "theme" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5379f4c4eb851af44d4d818ba43fac8cff83e9b8e624aedc8a3346ca45065fe" +dependencies = [ + "serde", + "web-sys", + "yew 0.21.0", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3459,6 +3746,31 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yew" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac" +dependencies = [ + "console_error_panic_hook", + "futures", + "gloo 0.10.0", + "implicit-clone 0.4.9", + "indexmap", + "js-sys", + "prokio", + "rustversion", + "serde", + "slab", + "thiserror 1.0.69", + "tokio", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew-macro 0.21.0", +] + [[package]] name = "yew" version = "0.23.0" @@ -3468,7 +3780,7 @@ dependencies = [ "console_error_panic_hook", "futures", "gloo 0.11.0", - "implicit-clone", + "implicit-clone 0.6.0", "indexmap", "js-sys", "rustversion", @@ -3481,7 +3793,22 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "yew-macro", + "yew-macro 0.23.0", +] + +[[package]] +name = "yew-macro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2" +dependencies = [ + "boolinator", + "once_cell", + "prettyplease", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3513,7 +3840,7 @@ dependencies = [ "urlencoding", "wasm-bindgen", "web-sys", - "yew", + "yew 0.23.0", "yew-router-macro", ] diff --git a/README.cargo.md b/README.cargo.md new file mode 100644 index 0000000..c0f06d7 --- /dev/null +++ b/README.cargo.md @@ -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: + +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: +`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 diff --git a/README.md b/README.md index 525c212..6c0765a 100644 --- a/README.md +++ b/README.md @@ -4,5 +4,386 @@ 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 +- **[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: + +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: +`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; +> } +> ``` + +## 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 diff --git a/backend/src/cookie/jwt.rs b/backend/src/cookie/jwt.rs index 8f37315..b386fff 100644 --- a/backend/src/cookie/jwt.rs +++ b/backend/src/cookie/jwt.rs @@ -6,8 +6,8 @@ use crate::models::Claims; /// Error response for JWT token operations. /// -/// Returned when token encoding or decoding fails. Used in error responses -/// for invalid or expired tokens. +/// 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") @@ -22,7 +22,7 @@ pub struct Error { /// /// 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. +/// using the given encoding key. The resulting token is a serialized [`Claims`]. /// /// # Arguments /// - `header`: The JWT header, specifying the algorithm (e.g., HS256). @@ -30,7 +30,7 @@ pub struct Error { /// - `key`: The `EncodingKey` used to sign the JWT. /// /// # Returns -/// A `String` representing the encoded JWT. +/// A `String` representing the encoded JWT containing [`Claims`]. /// /// # Panics /// Panics if the token encoding fails for any reason (e.g., invalid key). @@ -50,14 +50,15 @@ pub fn encode_token(header: &Header, id: String, key: &EncodingKey) -> String { /// /// 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. +/// 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`. +/// - `Ok(Claims)`: If the token is successfully decoded and verified, returns the extracted [`Claims`]. /// - `Err((StatusCode, Json))`: 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)> { diff --git a/backend/src/cookie/validation.rs b/backend/src/cookie/validation.rs index e1d238c..bfaecd9 100644 --- a/backend/src/cookie/validation.rs +++ b/backend/src/cookie/validation.rs @@ -17,9 +17,10 @@ use crate::{AppState, cookie::jwt::decode_token, handlers::auth::filter_user, mo /// Axum middleware to validate a JWT token present in cookies or Authorization header. /// /// This function extracts a JWT from the request (either from the `token` cookie or -/// the `Authorization: Bearer` header), decodes and validates it. If valid, it fetches -/// the corresponding user from the database and inserts a `FilteredUser` into the -/// request extensions for subsequent handlers to use. +/// 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). @@ -110,9 +111,10 @@ pub async fn validate_token( /// Axum middleware to validate JWT token and ensure the authenticated user has admin privileges. /// -/// This middleware first performs all checks of `validate_token`: extracting, decoding, -/// and validating the JWT, and fetching the associated user from the database. -/// Additionally, it verifies that the fetched user has `is_admin` set to `true`. +/// 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). diff --git a/backend/src/env.rs b/backend/src/env.rs index f385bf8..ed4a760 100644 --- a/backend/src/env.rs +++ b/backend/src/env.rs @@ -1,12 +1,13 @@ /// Environment configuration for the application. /// /// Loads required configuration from environment variables at startup. -/// All variables must be present or the application will panic. +/// 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 JWT tokens. -/// - `origin`: Frontend origin URL for CORS policy. +/// - `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`) @@ -25,12 +26,20 @@ pub struct Env { impl Env { /// Loads environment configuration from system environment variables. /// - /// Panics if any required variable is missing. + /// 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"); diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 282ee96..36ad6f8 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -22,11 +22,12 @@ use crate::{ /// Registers a new user in the system. /// -/// Creates a new user account with the provided credentials. The password is hashed using Argon2 -/// before being stored. Only administrators can create new users. +/// 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 -/// - `request`: User creation details including first/last name, username, admin flag, and password +/// - `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 @@ -34,12 +35,7 @@ use crate::{ /// - `500 Internal Server Error` if database insertion fails /// /// # Password Hashing -/// Uses Argon2 with a cryptographically secure random salt: -/// ```ignore -/// let argon = Argon2::default(); -/// let salt = SaltString::generate(&mut OsRng); -/// let hashed_pwd = argon.hash_password(password.as_bytes(), &salt)?; -/// ``` +/// Uses Argon2 with a cryptographically secure random salt. pub async fn create_user( State(data): State>, Json(request): Json, @@ -106,27 +102,23 @@ 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 a JWT token and sets it as an HTTP-only cookie. +/// 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 -/// - `request`: Login credentials (username, password) +/// - `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 user info +/// - `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: -/// ```ignore -/// let valid_pwd = Argon2::default() -/// .verify_password(&request.pwd.as_bytes(), &pwd_hash.unwrap()) -/// .is_ok(); -/// ``` -/// - JWT token includes user ID and expiration timestamp +/// - 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 @@ -195,7 +187,7 @@ pub async fn login( /// /// 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. +/// 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 @@ -227,11 +219,11 @@ pub async fn logout() -> Result, State(data): State>, @@ -482,7 +479,10 @@ pub async fn update_user( /// Checks if any administrator user exists in the system. /// /// This endpoint is used during initialization to determine if the setup page should be displayed. -/// It counts all users with `is_admin = true` in the database. +/// 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 @@ -512,28 +512,24 @@ pub async fn check_admin_exists( /// Creates the initial administrator account for a fresh system. /// -/// This function handles the one-time setup of the first admin user. It checks that no admin exists -/// before allowing creation. This endpoint is only functional when the system has no administrators. +/// 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 -/// - `request`: User creation details (first_name, last_name, username, password) +/// - `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 +/// - 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: -/// ```ignore -/// let argon = Argon2::default(); -/// let salt = SaltString::generate(&mut OsRng); -/// let hashed_pwd = argon.hash_password(request.pwd.as_bytes(), &salt)?; -/// ``` +/// The password is hashed using Argon2 with a random salt before storage. pub async fn setup_initial_admin( State(data): State>, Json(request): Json, @@ -598,17 +594,18 @@ pub async fn setup_initial_admin( } } -/// Converts a User with sensitive data into a FilteredUser safe for API responses. +/// Converts a [`User`] with sensitive data into a [`FilteredUser`] safe for API responses. /// /// This function removes password hashes and other sensitive information before -/// returning user data to clients. Always use this helper instead of directly -/// serializing User objects. +/// 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 +/// - `user`: Reference to the internal [`User`] struct containing password hash /// /// # Returns -/// FilteredUser with only safe-to-share information: +/// [`FilteredUser`] with only safe-to-share information: /// - `id`: User ID /// - `first_name`, `last_name`: User name /// - `username`: Login username @@ -621,7 +618,7 @@ pub async fn setup_initial_admin( /// # Example /// ```ignore /// let user = get_user_from_db(1).await?; -/// let safe_user = filter_user(&user); +/// 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 { diff --git a/backend/src/handlers/ticket.rs b/backend/src/handlers/ticket.rs index 8f05cfc..7f2e131 100644 --- a/backend/src/handlers/ticket.rs +++ b/backend/src/handlers/ticket.rs @@ -16,12 +16,14 @@ use crate::{ /// Creates a new support ticket. /// -/// Associates the ticket with the authenticated user and sets the current timestamp. +/// 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 -/// - `user`: Authenticated user (extracted from JWT token) -/// - `body`: Ticket details (category, subject, description, room) +/// - `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 @@ -60,10 +62,11 @@ pub async fn create_ticket( /// Deletes a ticket by ID. /// -/// Only admins can delete tickets. Marks the ticket as deleted or removes from database. +/// Only admins can delete tickets (enforced by middleware). Removes the [`TicketResponse`] and associated data from the database. /// /// # Arguments -/// - `id`: Ticket ID to delete +/// - `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 @@ -97,15 +100,18 @@ pub async fn delete_ticket( /// Retrieves all non-archived tickets. /// -/// Returns a list of all active tickets with user information denormalized for easier rendering. -/// Tickets are ordered by creation date (newest first). +/// 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 +/// - Uses LEFT JOIN to include creator information from [`User`](crate::models::User) /// /// # Returns -/// - `200 OK` with array of TicketResponse objects +/// - `200 OK` with array of [`TicketResponse`] objects /// - `500 Internal Server Error` if database query fails /// /// # Example Response @@ -169,12 +175,14 @@ pub async fn get_tickets( /// 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 -/// - `id`: Ticket ID to retrieve +/// - `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 +/// - `200 OK` with [`TicketResponse`] object /// - `404 Not Found` if ticket doesn't exist /// - `500 Internal Server Error` if database error occurs /// @@ -242,15 +250,16 @@ pub async fn get_ticket_by_id( /// Updates a ticket's status. /// -/// Only admins can update ticket status. This is typically used to transition tickets -/// through their lifecycle (open → in_progress → resolved → archived). +/// 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 -/// - `id`: Ticket ID to update -/// - `body`: Update payload containing new status +/// - `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 +/// - `200 OK` with updated [`TicketResponse`] /// - `500 Internal Server Error` if ticket not found or database error /// /// # Typical Status Flow diff --git a/backend/src/main.rs b/backend/src/main.rs index 04c0dd9..be3dba6 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -25,29 +25,36 @@ 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. +/// 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 -/// - `env`: Configuration loaded from environment variables +/// - `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 +/// 2. Establishing database connection pool to PostgreSQL /// 3. Configuring CORS policy for cross-origin requests -/// 4. Starting HTTP server on port 8001 +/// 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 environment +/// - CORS origin configured from [`Env`] +/// +/// # State Setup +/// Creates shared [`AppState`] wrapped in `Arc` and passes to all routes /// /// # Panics /// - If environment loading fails diff --git a/backend/src/models.rs b/backend/src/models.rs index 184670d..e1e8353 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; /// 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 @@ -12,7 +13,7 @@ use serde::{Deserialize, Serialize}; /// - `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 +/// - `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 @@ -32,22 +33,32 @@ use serde::{Deserialize, Serialize}; /// ``` #[derive(Deserialize, Serialize, Debug, PartialEq)] pub struct TicketResponse { + /// Unique ticket identifier pub id: i32, + /// Ticket category/type (e.g., "maintenance", "support") pub category: String, + /// Ticket subject line pub betreff: String, + /// Detailed ticket description pub description: String, + /// Room number associated with the issue pub room: i16, + /// Current ticket status (e.g., "open", "in_progress", "resolved", "archived") pub status: String, + /// When the ticket was created (UTC timestamp) pub date: chrono::DateTime, + /// 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, } /// 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. +/// This should NEVER be sent directly to clients - always use [`FilteredUser`] instead. /// /// # Fields /// - `id`: Unique user identifier @@ -58,21 +69,27 @@ pub struct TicketResponse { /// /// # Security Note /// The `pwd` field contains the password hash and should never be included in API responses. -/// Use `filter_user()` to convert to `FilteredUser` for responses. +/// 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. +/// authenticated user and sets the creation timestamp. Converted to [`TicketResponse`] for the response. /// /// # Fields /// - `category`: Ticket category/type @@ -81,49 +98,60 @@ pub struct User { /// - `room`: Room number where the issue is located #[derive(Deserialize, Serialize, Debug)] pub struct TicketCreateScheme { + /// Ticket category/type pub category: String, + /// Subject line for the ticket pub betreff: String, + /// Detailed problem description pub description: String, + /// Room number where the issue is located pub room: i16, } /// Payload for updating a ticket. /// -/// Sent to `PATCH /api/tickets/{id}`. Currently only allows status updates. +/// 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 users. Empty password field means no password change. +/// Only admins can update [`User`] records. Empty password field means no password change. /// /// # Fields -/// - `id`: User ID to update +/// - `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. +/// The password is hashed server-side before storage using Argon2. Converted to [`User`] for storage. /// /// # Fields /// - `first_name`: User's first name @@ -133,10 +161,15 @@ pub struct UserUpdateScheme { /// - `pwd`: Plain text password (hashed on server) #[derive(Deserialize, Serialize, Debug, sqlx::FromRow)] pub struct UserCreateScheme { + /// User's first name pub first_name: String, + /// User's last name pub last_name: String, + /// Unique username for login pub username: String, + /// Whether to grant admin privileges pub is_admin: bool, + /// Plain text password (hashed on server before storage) pub pwd: String, } @@ -149,31 +182,38 @@ pub struct UserCreateScheme { /// The password is never stored in plain text - only the Argon2 hash is persisted. #[derive(Deserialize, Serialize, Debug)] pub struct LoginScheme { + /// Username for login pub username: String, + /// Plain text password (verified against stored Argon2 hash) pub pwd: String, } /// User information sent to clients, excluding password hashes. /// -/// This is the safe version of User data that gets returned in API responses. +/// This is the safe version of [`User`] data that gets returned in API responses. /// It never includes the password hash or JWT claims. Always use this for responses /// 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 and verified by middleware on protected routes. +/// Generated during login via `encode_token` and verified via `decode_token`. /// /// # Fields -/// - `sub`: Subject - the user ID as a string +/// - `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) /// diff --git a/backend/src/router.rs b/backend/src/router.rs index bc41ba5..0c37299 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -19,30 +19,31 @@ use crate::{ /// Creates the complete router with all API endpoints. /// -/// The router is organized in layers for proper middleware application: +/// 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 -/// - `DELETE /api/tickets/{id}` - Delete a ticket -/// - `PATCH /api/tickets/{id}` - Update ticket status -/// - `POST /api/register` - Create a new user -/// - `GET /api/users` - List all users -/// - `GET /api/users/{id}` - Get user details -/// - `DELETE /api/users/{id}` - Delete a user -/// - `PATCH /api/users/{id}` - Update user details +/// - `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 -/// - `POST /api/tickets/create` - Create a new ticket -/// - `GET /api/logout` - Logout user -/// - `GET /api/users/current` - Get current authenticated user +/// - `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 -/// - `GET /api/check-admin` - Check if admin exists (for setup detection) -/// - `POST /api/setup-admin` - Create initial admin account (only if no admin exists) +/// - `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 diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 083c177..e4c6c43 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -26,3 +26,4 @@ gloo = "0.12.0" chrono = { workspace = true } chrono-tz = "0.10.4" wasm-bindgen = "0.2.120" +theme = { version = "0.0.3", features = ["yew"] } diff --git a/frontend/frontend.tar.xz b/frontend/frontend.tar.xz new file mode 100644 index 0000000..535a831 Binary files /dev/null and b/frontend/frontend.tar.xz differ diff --git a/frontend/src/auth.rs b/frontend/src/auth.rs index 18720fa..8ff46a1 100644 --- a/frontend/src/auth.rs +++ b/frontend/src/auth.rs @@ -33,14 +33,14 @@ pub struct ProtectedRouteProps { /// A component that protects routes by enforcing authentication and optional administrator privileges. /// -/// This component fetches the current user's authentication and admin status from the -/// `/api/users/current` endpoint upon mounting. Based on the `AuthState` and the -/// `admin_page` property, it either renders its children or redirects the user. +/// 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. +/// - **Initial Load**: Displays "Loading..." while checking authentication status via the backend. /// - **Not Authenticated**: Redirects to the login page (`crate::Route::Login`). -/// - **Authenticated**: +/// - **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 @@ -109,7 +109,7 @@ pub fn protected_route(props: &ProtectedRouteProps) -> Html { AuthState { is_authenticated: None, .. - } => html! {
{ "Loading..." }
}, + } => html! {
{ "Wird geladen..." }
}, AuthState { is_authenticated: Some(false), .. @@ -126,7 +126,7 @@ pub fn protected_route(props: &ProtectedRouteProps) -> Html { Some(false) => { html! { to={crate::Route::PermissionDenied}/> } } - None => html! {
{ "Checking permissions..." }
}, + None => html! {
{ "Überprüfe Berechtigungen..." }
}, } } else { props.children.clone().into() diff --git a/frontend/src/darkmode.rs b/frontend/src/darkmode.rs new file mode 100644 index 0000000..3c2ed48 --- /dev/null +++ b/frontend/src/darkmode.rs @@ -0,0 +1,41 @@ +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! { +/// +/// } +/// ``` +#[function_component] +pub fn ThemeToggle() -> Html { + let onclick = { + Callback::from(|_| { + 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); + } + } + } + }) + }; + + html! { + + } +} diff --git a/frontend/src/lib.rs b/frontend/src/lib.rs index f1a4bcb..f1d663a 100644 --- a/frontend/src/lib.rs +++ b/frontend/src/lib.rs @@ -1,4 +1,5 @@ mod auth; +mod darkmode; mod pages; use crate::auth::ProtectedRoute; use crate::pages::*; @@ -10,7 +11,9 @@ 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. +/// 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. @@ -65,10 +68,17 @@ pub struct SidebarShellProps { /// 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. +/// 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 -/// - [`sidebar::Sidebar`]: The navigation sidebar component. +/// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component, accepting mobile open state. /// - Main content area: Renders the `children` passed to this component. /// /// # Example @@ -81,9 +91,46 @@ pub struct SidebarShellProps { /// ``` #[component(SidebarShell)] fn sidebar_shell(props: &SidebarShellProps) -> Html { + let route = use_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! {
- + + +
+ +
{ for props.children.iter() }
@@ -91,6 +138,7 @@ fn sidebar_shell(props: &SidebarShellProps) -> Html { } } + /// Props for the AdminCheckWrapper component. #[derive(Properties, PartialEq)] pub struct AdminCheckWrapperProps { @@ -101,11 +149,11 @@ pub struct AdminCheckWrapperProps { /// /// This component is used to gate access to pages that should only be accessible before /// system initialization (e.g., login page). It performs an asynchronous check to the -/// `/api/check-admin` endpoint to determine system state. +/// 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 -/// - **No Admin**: Automatically redirects to `/setup` page to initialize +/// - **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 @@ -115,19 +163,9 @@ pub struct AdminCheckWrapperProps { /// /// ``` /// -/// # Implementation Detail -/// The check happens in `use_effect_with` on component mount: -/// ```ignore -/// spawn_local(async move { -/// match Request::get("/api/check-admin").send().await { -/// Ok(resp) if resp.status() == 200 => { -/// let has_admin = data["has_admin"].as_bool().unwrap_or(false); -/// admin_exists.set(Some(has_admin)); -/// } -/// _ => admin_exists.set(Some(false)) -/// } -/// }); -/// ``` +/// # 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::); @@ -155,10 +193,10 @@ fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html { } match *admin_exists { - None => html! {
{ "Loading..." }
}, + None => html! {
{ "Wird geladen..." }
}, Some(false) => { navigator.push(&Route::Setup); - html! {
{ "Redirecting to setup..." }
} + html! {
{ "Wird weitergeleitet zur Einrichtung..." }
} } Some(true) => props.children.clone().into(), } @@ -166,18 +204,24 @@ fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html { /// The main routing logic for the application. /// -/// This function takes a `Route` enum variant and returns the corresponding HTML +/// This function takes a [`Route`] enum variant and returns the corresponding HTML /// content to be rendered. It acts as a central dispatcher for the application's -/// navigation. +/// navigation and authentication flow. /// -/// Many routes are wrapped in a [`ProtectedRoute`] to enforce authentication -/// and authorization, and in a [`SidebarShell`] to maintain consistent layout. +/// 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! { @@ -259,12 +303,16 @@ fn switch(route: Route) -> Html { /// 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. +/// `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`]. +/// - `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! { diff --git a/frontend/src/main.rs b/frontend/src/main.rs index 0577e52..7cce742 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -1,5 +1,9 @@ use frontend::App; +/// Main entry point for the Yew frontend application. +/// +/// Initializes the Yew framework and renders the root [`App`] component into the DOM. +/// This function is typically called automatically by the WebAssembly runtime. fn main() { yew::Renderer::::new().render(); } diff --git a/frontend/src/pages/basic_pages.rs b/frontend/src/pages/basic_pages.rs index 064f03c..42a286a 100644 --- a/frontend/src/pages/basic_pages.rs +++ b/frontend/src/pages/basic_pages.rs @@ -3,6 +3,29 @@ 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() @@ -56,11 +79,11 @@ pub fn home_component() -> Html { } html! { -
+
-

{ "You are logged in as: " }

-

{ &*name }

+ +

{ &*name }

} } @@ -78,7 +101,7 @@ pub fn home_component() -> Html { /// ``` #[component(NotFound)] pub fn not_found_component() -> Html { - let message = "404 Not found"; + let message = "404 Nicht gefunden"; html! {

{&message}

diff --git a/frontend/src/pages/setup.rs b/frontend/src/pages/setup.rs index 2f8c532..9aa86ab 100644 --- a/frontend/src/pages/setup.rs +++ b/frontend/src/pages/setup.rs @@ -29,7 +29,7 @@ pub struct AdminSetupScheme { /// 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`). +/// 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. @@ -103,7 +103,7 @@ pub fn initial_admin_setup() -> Html { } if !*admin_check_done { - return html! {
{ "Checking..." }
}; + return html! {
{ "Wird überprüft..." }
}; } let onsubmit = { @@ -121,17 +121,17 @@ pub fn initial_admin_setup() -> Html { e.prevent_default(); if (*pwd).is_empty() || (*pwd_confirm).is_empty() { - error.set("Password fields cannot be empty".to_string()); + error.set("Passwortfelder dürfen nicht leer sein".to_string()); return; } if *pwd != *pwd_confirm { - error.set("Passwords do not match".to_string()); + error.set("Passwörter stimmen nicht überein".to_string()); return; } if (*username).is_empty() { - error.set("Username cannot be empty".to_string()); + error.set("Benutzername darf nicht leer sein".to_string()); return; } @@ -172,28 +172,28 @@ pub fn initial_admin_setup() -> Html { navigator.push(&crate::Route::Login); } Ok(r) => { - let text = r.text().await.unwrap_or_else(|_| "unknown".into()); - error.set(format!("HTTP {}: {}", r.status(), text)); + let text = r.text().await.unwrap_or_else(|_| "Unbekannt".into()); + error.set(text); } - Err(err) => error.set(format!("Network error: {}", err)), + Err(err) => error.set(format!("Netzwerkfehler: {}", err)), } }); }) }; html! { -
+
-

{ "Initial Admin Setup" }

-

{ "Create your first administrator account" }

+

{ "Admin-Einrichtung" }

+

{ "Erstellen Sie Ihr erstes Administratorkonto" }

-
-
-
-
-
if !error.is_empty() { @@ -272,7 +272,7 @@ pub fn initial_admin_setup() -> Html { } if *success { -

{ "Admin account created successfully! Redirecting to login..." }

+

{ "Administratorkonto erfolgreich erstellt! Wird weitergeleitet zum Login..." }

}
diff --git a/frontend/src/pages/sidebar.rs b/frontend/src/pages/sidebar.rs index ade0e6c..cc53b72 100644 --- a/frontend/src/pages/sidebar.rs +++ b/frontend/src/pages/sidebar.rs @@ -7,6 +7,11 @@ 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. @@ -128,7 +133,7 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html { Callback::from(move |v: bool| { state.set(SidebarExpandState { ticket_open: v, - users_open: (*state).users_open, + users_open: state.users_open, }) }) }; @@ -136,10 +141,10 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html { let toggle_tickets = { let state = state.clone(); Callback::from(move |_| { - let current = (*state).ticket_open; + let current = state.ticket_open; state.set(SidebarExpandState { ticket_open: !current, - users_open: (*state).users_open, + users_open: state.users_open, }); }) }; @@ -148,7 +153,7 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html { let state = state.clone(); Callback::from(move |v: bool| { state.set(SidebarExpandState { - ticket_open: (*state).ticket_open, + ticket_open: state.ticket_open, users_open: v, }) }) @@ -157,9 +162,9 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html { let toggle_users = { let state = state.clone(); Callback::from(move |_| { - let current = (*state).users_open; + let current = state.users_open; state.set(SidebarExpandState { - ticket_open: (*state).ticket_open, + ticket_open: state.ticket_open, users_open: !current, }); }) @@ -229,10 +234,10 @@ pub fn ticket_menu() -> Html { html! { } @@ -284,7 +289,7 @@ pub fn users_menu() -> Html { onclick={on_toggle} aria-expanded={open.to_string()} > - { "Users" } + { "Benutzer" } { if open { " ▾" } else { " ▸" } } @@ -293,10 +298,10 @@ pub fn users_menu() -> Html { html! { } @@ -314,6 +319,11 @@ pub fn users_menu() -> Html { /// 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 (`