Files
ticketsystem/frontend/src/pages/user.rs
2026-05-11 19:53:30 +02:00

785 lines
29 KiB
Rust

use gloo_net::http::Request;
use serde::{Deserialize, Serialize};
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
// #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
// pub struct User {
// pub id: i16,
// pub last_name: String,
// pub first_name: String,
// pub username: String,
// pub is_admin: bool,
// pub pwd: String,
// }
/// Data transfer object (DTO) for creating a new user.
///
/// This struct defines the payload sent to the backend when an administrator
/// registers a new user. It includes all necessary information for user creation.
///
/// # Fields
/// - `first_name`: The first name of the user.
/// - `last_name`: The last name of the user.
/// - `username`: The unique username for the user's login.
/// - `is_admin`: A boolean indicating whether the new user should have administrator privileges.
/// - `pwd`: The password for the user's account. This will be hashed on the backend.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UserCreateScheme {
pub first_name: String,
pub last_name: String,
pub username: String,
pub is_admin: bool,
pub pwd: String,
}
/// Data transfer object (DTO) for user login credentials.
///
/// This struct defines the payload sent to the backend when a user attempts to log in.
///
/// # Fields
/// - `username`: The username provided by the user.
/// - `pwd`: The password provided by the user.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LoginScheme {
pub username: String,
pub pwd: String,
}
/// Data transfer object (DTO) for updating an existing user's information.
///
/// This struct is used to send updated user details, including an optional new password,
/// to the backend for an existing user.
///
/// # Fields
/// - `id`: The unique identifier of the user to be updated.
/// - `first_name`: The updated first name of the user.
/// - `last_name`: The updated last name of the user.
/// - `username`: The updated username for the user.
/// - `make_admin`: A boolean indicating whether to grant/revoke administrator privileges.
/// - `new_pwd`: An optional new password for the user. If empty, the password remains unchanged.
#[derive(Deserialize, Serialize, Debug)]
pub struct UserUpdateScheme {
pub id: i16,
pub first_name: String,
pub last_name: String,
pub username: String,
pub make_admin: bool,
pub new_pwd: String,
}
/// Represents a user entity with sensitive information filtered out.
///
/// This struct is used for displaying user information in contexts where
/// the password hash or other sensitive details are not needed or should not be exposed.
/// It's typically used for listing users or displaying user profiles.
///
/// # Fields
/// - `id`: The unique identifier of the user.
/// - `first_name`: The first name of the user.
/// - `last_name`: The last name of the user.
/// - `username`: The unique username of the user.
/// - `is_admin`: A boolean indicating if the user has administrator privileges.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FilteredUser {
pub id: i16,
pub first_name: String,
pub last_name: String,
pub username: String,
pub is_admin: bool,
}
/// Properties for components that display or interact with a single user.
///
/// This struct is used to pass the ID of a specific user to components
/// that need to fetch, display, or modify their details.
///
/// # Fields
/// - `id`: The unique identifier of the user to be displayed or acted upon.
#[derive(Properties, PartialEq)]
pub struct UserProps {
pub id: i16,
}
#[derive(Deserialize, Debug)]
struct ApiError {
message: String,
_status: String,
}
/// A component providing a form for registering new users.
///
/// This component allows an administrator to create new user accounts by providing
/// their first name, last name, username, password, and specifying if they should
/// be an administrator. The component handles form input, validation (basic),
/// and interaction with the backend `/api/register` endpoint.
///
/// # State
/// Uses `use_state` hooks to manage:
/// - `first_name`, `last_name`, `username`, `pwd`: Values of the form input fields.
/// - `is_admin`: Boolean state for the admin checkbox.
/// - `status`: To display messages about registration success or errors.
///
/// # Form Submission
/// - Prevents default form submission behavior.
/// - Constructs a `UserCreateScheme` payload from the form data.
/// - Sends a POST request to `/api/register`.
/// - Updates the `status` state based on the API response.
///
/// # Example
/// ```rust
/// html! {
/// <Register />
/// }
/// ```
#[component(Register)]
pub fn register_component() -> Html {
let first_name = use_state(|| "".to_string());
let last_name = use_state(|| "".to_string());
let username = use_state(|| "".to_string());
let is_admin = use_state(|| false);
let pwd = use_state(|| "".to_string());
let status = use_state(|| None::<String>);
let onsubmit = {
let first_name = first_name.clone();
let last_name = last_name.clone();
let username = username.clone();
let is_admin = is_admin.clone();
let pwd = pwd.clone();
let status = status.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let first_name = (*first_name).clone();
let last_name = (*last_name).clone();
let username = (*username).clone();
let is_admin = *is_admin;
let pwd = (*pwd).clone();
let status = status.clone();
spawn_local(async move {
let payload = UserCreateScheme {
first_name,
last_name,
username,
is_admin,
pwd,
};
let request = Request::post("/api/register")
.json(&payload)
.expect("Error building request");
match request.send().await {
Ok(response) if response.status() == 200 => status.set(Some("Success".into())),
Ok(response) => status.set(Some(format!("Error: {}", response.status()))),
Err(err) => status.set(Some(format!("Network error: {}", err))),
}
});
})
};
let fn_change = {
let first_name = first_name.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
first_name.set(input.value());
})
};
let ln_change = {
let last_name = last_name.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
last_name.set(input.value());
})
};
let un_change = {
let username = username.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
username.set(input.value());
})
};
let admin_change = {
let is_admin = is_admin.clone();
Callback::from(move |e: Event| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
is_admin.set(input.checked());
})
};
let pwd_change = {
let pwd = pwd.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
pwd.set(input.value());
})
};
html! {
<div class="form-container">
<div class="page-header">
<h1>{ "Register User" }</h1>
</div>
<form {onsubmit}>
<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>
</div>
}
}
/// A component providing a form for user login.
///
/// This component handles user authentication by collecting a username and password,
/// then sending these credentials to the backend `/api/login` endpoint. It manages
/// UI states for loading, errors, and success, and redirects to the home page on successful login.
///
/// # State
/// Uses `use_state` hooks to manage:
/// - `username`, `pwd`: Values of the login form input fields.
/// - `loading`: A boolean to indicate if the login process is ongoing.
/// - `error`: A string to display any login error messages.
/// - `success`: A boolean to indicate if login was successful.
///
/// # Form Submission
/// - Prevents default form submission behavior.
/// - Constructs a `LoginScheme` payload from the input fields.
/// - Sends a POST request to `/api/login`.
/// - On successful response (HTTP 200), sets `success` to true and redirects to `crate::Route::Home`.
/// - On error, updates the `error` state with the appropriate message.
///
/// # Example
/// ```rust
/// html! {
/// <Login />
/// }
/// ```
#[component(Login)]
pub fn login_component() -> Html {
let username = use_state(|| "".to_string());
let pwd = use_state(|| "".to_string());
let loading = use_state(|| false);
let error = use_state(|| String::new());
let success = use_state(|| false);
let navigator = use_navigator().unwrap();
let onsubmit = {
let username = username.clone();
let pwd = pwd.clone();
let loading = loading.clone();
let error = error.clone();
let success = success.clone();
let navigator = navigator.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let username = (*username).clone();
let pwd = (*pwd).clone();
let loading = loading.clone();
let error = error.clone();
let success = success.clone();
let navigator = navigator.clone();
loading.set(true);
error.set(String::new());
success.set(false);
spawn_local(async move {
let payload = LoginScheme { username, pwd };
let response = Request::post("/api/login")
.header("Content-Type", "application/json")
.credentials(web_sys::RequestCredentials::Include)
.json(&payload)
.unwrap()
.send()
.await;
loading.set(false);
match response {
Ok(r) if r.status() == 200 => {
success.set(true);
navigator.push(&crate::Route::Home);
}
Ok(r) => {
let text = r.text().await.unwrap_or_else(|_| "unknown".into());
error.set(format!("HTTP {}: {}", r.status(), text));
}
Err(err) => error.set(format!("Network error: {}", err)),
}
});
})
};
html! {
<main class="content">
<div class="form-container">
<div class="page-header">
<h1>{ "Login" }</h1>
</div>
<form {onsubmit}>
<input
placeholder="username"
value={(*username).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
username.set(input.value());
})}
/>
<input
type="password"
placeholder="password"
value={(*pwd).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
pwd.set(input.value());
})}
/>
<button type="submit" disabled={*loading}>{ if *loading { "Logging in..." } else { "Login" } }</button>
if !error.is_empty() { <p class="alert error">{(*error).clone()}</p> }
</form>
</div>
</main>
}
}
/// 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! {
<div class="form-container">
<div class="page-header">
<h1>{ "All Users" }</h1>
</div>
<p>{ "Loading..." }</p>
</div>
}
} else if let Some(e) = &*error {
html! {
<div class="form-container">
<div class="page-header">
<h1>{ "All Users" }</h1>
</div>
<p class="alert error">{ format!("Error: {}", e) }</p>
</div>
}
} else {
html! {
<div class="form-container">
<div class="page-header">
<h1>{ "All Users" }</h1>
</div>
<ul class="user-list">
{ for users.iter().map(|t| html! {
<li key={t.id.to_string()}>
<Link<crate::Route> to={crate::Route::UserByID{id: t.id}}><h3>{ format!("{} {}", t.first_name, t.last_name) }</h3></Link<crate::Route>>
</li>
})}
</ul>
</div>
}
}
}
/// 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 = (*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));
}
save_success.set(true);
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
save_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
}
Err(err) => save_error.set(Some(format!("Network error: {}", err))),
}
saving.set(false);
});
})
};
let deleting = use_state(|| false);
let delete_error = use_state(|| None::<String>);
let ondelete = {
let deleting = deleting.clone();
let delete_error = delete_error.clone();
let user_state = user.clone(); // or ticket
let id = id;
Callback::from(move |e: MouseEvent| {
e.prevent_default();
// confirm
if !web_sys::window()
.and_then(|w| {
w.confirm_with_message("Are you sure you want to delete this item?")
.ok()
})
.unwrap_or(false)
{
return;
}
deleting.set(true);
delete_error.set(None);
let deleting = deleting.clone();
let delete_error = delete_error.clone();
let user_state = user_state.clone();
spawn_local(async move {
let url = format!("/api/users/{}", id); // or /api/tickets/{}
let req = Request::delete(&url).credentials(web_sys::RequestCredentials::Include);
match req.send().await {
Ok(resp) if resp.status() == 200 || resp.status() == 204 => {
// remove local state or navigate away
user_state.set(None); // clears the shown item
}
Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
}
Err(err) => delete_error.set(Some(format!("Network error: {}", err))),
}
deleting.set(false);
});
})
};
if *loading {
html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error {
html! { <p>{ format!("Error: {}", e) }</p> }
} else if let Some(u) = &*user {
html! {
<div>
<div>
<p><strong>{ "Vorname: " }</strong>{ &u.first_name }</p>
<p><strong>{ "Nachname: " }</strong>{ &u.last_name }</p>
<p><strong>{ "Benutzername: " }</strong>{ &u.username }</p>
<p><strong>{ "Ist Admin: " }</strong>{ u.is_admin }</p>
</div>
<h1>{ format!("User #{}", u.id) }</h1>
<form onsubmit={onsubmit}>
<div>
<label>{ "Vorname" }
<input name="first_name"
value={(*first_name).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
first_name.set(input.value());
})}
/>
</label>
</div>
<div>
<label>{ "Nachname" }
<input name="last_name"
value={(*last_name).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
last_name.set(input.value());
})}
/>
</label>
</div>
<div>
<label>{ "Benutzername" }
<input name="username"
value={(*username).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
username.set(input.value());
})}
/>
</label>
</div>
<div>
<label>{ "Ist Admin" }
<input type="checkbox" name="make_admin" checked={*make_admin} onchange={Callback::from(move |e: Event| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
make_admin.set(input.checked());
})}/>
</label>
</div>
<div>
<label>{ "Neues Passwort (leer = unchanged)" }
<input name="new_pwd" type="password"
value={(*new_pwd).clone()}
oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
new_pwd.set(input.value());
})}
/>
</label>
</div>
<button type="submit" disabled={*saving}>{ if *saving { "Speichern..." } else { "Speichern" } }</button>
if *save_success {
<p style="color:green">{ "Updated successfully" }</p>
}
if let Some(err) = &*save_error {
<p style="color:red">{ err.clone() }</p>
}
</form>
<button onclick={ondelete} disabled={*deleting}>
{if *deleting {"Löschen..."} else {"Löschen"}}
</button>
<Link<crate::Route> to={crate::Route::AllUsers}>{ "Zurück zur Benutzerübersicht" }</Link<crate::Route>>
if let Some(err) = &*delete_error {
<p style="color:red">{ err.clone() }</p>
}
</div>
}
} else {
html! { <p>{ "No ticket found." }</p> }
}
}