frontend/pages/setup.rs
1use gloo_net::http::Request;
2use serde::{Deserialize, Serialize};
3use wasm_bindgen_futures::spawn_local;
4use yew::prelude::*;
5use yew_router::prelude::*;
6
7/// Payload for creating the initial administrator account.
8///
9/// This struct is sent to the `/api/setup-admin` endpoint to create the first admin user
10/// when no administrators exist in the system. It carries the necessary information
11/// for the new admin's profile and credentials.
12///
13/// # Fields
14/// - `first_name`: The first name of the administrator.
15/// - `last_name`: The last name of the administrator.
16/// - `username`: The unique username for the administrator's login.
17/// - `pwd`: The password for the administrator's account. This will be hashed on the backend.
18#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
19pub struct AdminSetupScheme {
20 pub first_name: String,
21 pub last_name: String,
22 pub username: String,
23 pub pwd: String,
24}
25
26/// Component for the initial admin account setup page.
27///
28/// This page is displayed when a fresh system has no administrator accounts. It provides
29/// a form to create the first admin user. Key functionality:
30///
31/// - **Admin Check**: On mount, verifies if an admin already exists by calling `/api/check-admin`.
32/// If an admin is found, the user is redirected to the login page ([`crate::Route::Login`]).
33/// - **Form Fields**: Collects `first_name`, `last_name`, `username`, `password`, and `confirm_password`.
34/// - **Form Validation**:
35/// - Ensures password fields are not empty.
36/// - Verifies that `password` and `confirm_password` match.
37/// - Ensures the `username` field is not empty.
38/// - **API Interaction**: On form submission, a POST request is sent to `/api/setup-admin`
39/// with the new admin's details.
40/// - **Password Hashing**: The backend is responsible for hashing the password using Argon2
41/// before storage; this component only sends the plain text password.
42/// - **Auto-redirect**: On successful admin account creation, the user is automatically
43/// redirected to the login page (`crate::Route::Login`).
44/// - **State Management**: Uses Yew's `use_state` hooks to manage:
45/// - Input field values (`first_name`, `last_name`, `username`, `pwd`, `pwd_confirm`).
46/// - UI states like `error` messages, `success` status, and `loading` indicators.
47/// - `admin_check_done` to prevent rendering the form before the initial admin check completes.
48///
49/// # Example Flow
50/// 1. User navigates to `/setup`.
51/// 2. The component checks `/api/check-admin`.
52/// 3. If an admin exists, redirects to `/login`.
53/// 4. If no admin exists, the setup form is displayed.
54/// 5. User fills out the form and submits.
55/// 6. Form data is sent via POST to `/api/setup-admin`.
56/// 7. On successful response (HTTP 200), redirects to `/login`.
57/// 8. On error, displays an error message to the user.
58///
59/// # Security Notes
60/// - The initial admin check prevents re-creating an admin if one already exists.
61/// - Password confirmation helps prevent user typos for critical credentials.
62/// - Backend validation further ensures non-empty and secure credentials.
63#[component(InitialAdminSetup)]
64pub fn initial_admin_setup() -> Html {
65 let first_name = use_state(|| "".to_string());
66 let last_name = use_state(|| "".to_string());
67 let username = use_state(|| "".to_string());
68 let pwd = use_state(|| "".to_string());
69 let pwd_confirm = use_state(|| "".to_string());
70 let error = use_state(|| String::new());
71 let success = use_state(|| false);
72 let loading = use_state(|| false);
73 let admin_check_done = use_state(|| false);
74 let navigator = use_navigator().unwrap();
75
76 {
77 let admin_check_done = admin_check_done.clone();
78 let navigator = navigator.clone();
79 use_effect_with((), move |_| {
80 let admin_check_done = admin_check_done.clone();
81 let navigator = navigator.clone();
82 spawn_local(async move {
83 match Request::get("/api/check-admin").send().await {
84 Ok(resp) if resp.status() == 200 => {
85 if let Ok(data) = resp.json::<serde_json::Value>().await {
86 let has_admin = data["has_admin"].as_bool().unwrap_or(false);
87 if has_admin {
88 navigator.push(&crate::Route::Login);
89 } else {
90 admin_check_done.set(true);
91 }
92 } else {
93 admin_check_done.set(true);
94 }
95 }
96 _ => {
97 admin_check_done.set(true);
98 }
99 }
100 });
101 || ()
102 });
103 }
104
105 if !*admin_check_done {
106 return html! { <div>{ "Wird überprüft..." }</div> };
107 }
108
109 let onsubmit = {
110 let first_name = first_name.clone();
111 let last_name = last_name.clone();
112 let username = username.clone();
113 let pwd = pwd.clone();
114 let pwd_confirm = pwd_confirm.clone();
115 let error = error.clone();
116 let success = success.clone();
117 let loading = loading.clone();
118 let navigator = navigator.clone();
119
120 Callback::from(move |e: SubmitEvent| {
121 e.prevent_default();
122
123 if (*pwd).is_empty() || (*pwd_confirm).is_empty() {
124 error.set("Passwortfelder dürfen nicht leer sein".to_string());
125 return;
126 }
127
128 if *pwd != *pwd_confirm {
129 error.set("Passwörter stimmen nicht überein".to_string());
130 return;
131 }
132
133 if (*username).is_empty() {
134 error.set("Benutzername darf nicht leer sein".to_string());
135 return;
136 }
137
138 let first_name_val = (*first_name).clone();
139 let last_name_val = (*last_name).clone();
140 let username_val = (*username).clone();
141 let pwd_val = (*pwd).clone();
142
143 loading.set(true);
144 error.set(String::new());
145 success.set(false);
146
147 let error = error.clone();
148 let success = success.clone();
149 let loading = loading.clone();
150 let navigator = navigator.clone();
151
152 spawn_local(async move {
153 let payload = AdminSetupScheme {
154 first_name: first_name_val,
155 last_name: last_name_val,
156 username: username_val,
157 pwd: pwd_val,
158 };
159
160 let response = Request::post("/api/setup-admin")
161 .header("Content-Type", "application/json")
162 .json(&payload)
163 .unwrap()
164 .send()
165 .await;
166
167 loading.set(false);
168
169 match response {
170 Ok(r) if r.status() == 200 => {
171 success.set(true);
172 navigator.push(&crate::Route::Login);
173 }
174 Ok(r) => {
175 let text = r.text().await.unwrap_or_else(|_| "Unbekannt".into());
176 error.set(text);
177 }
178 Err(err) => error.set(format!("Netzwerkfehler: {}", err)),
179 }
180 });
181 })
182 };
183
184 html! {
185 <div class="setup-container rundbg">
186 <div class="setup-box">
187 <h1>{ "Admin-Einrichtung" }</h1>
188 <p>{ "Erstellen Sie Ihr erstes Administratorkonto" }</p>
189
190 <form {onsubmit} class="setup-form">
191 <div class="form-group">
192 <label for="first_name">{ "Vorname:" }
193 <input
194 id="first_name"
195 type="text"
196 placeholder="Vorname"
197 value={(*first_name).clone()}
198 oninput={Callback::from(move |e: InputEvent| {
199 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
200 first_name.set(input.value());
201 })}
202 />
203 </label>
204 </div>
205
206 <div class="form-group">
207 <label for="last_name">{ "Nachname:" }
208 <input
209 id="last_name"
210 type="text"
211 placeholder="Nachname"
212 value={(*last_name).clone()}
213 oninput={Callback::from(move |e: InputEvent| {
214 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
215 last_name.set(input.value());
216 })}
217 />
218 </label>
219 </div>
220
221 <div class="form-group">
222 <label for="username">{ "Benutzername:" }
223 <input
224 id="username"
225 type="text"
226 placeholder="Benutzername"
227 value={(*username).clone()}
228 oninput={Callback::from(move |e: InputEvent| {
229 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
230 username.set(input.value());
231 })}
232 />
233 </label>
234 </div>
235
236 <div class="form-group">
237 <label for="password">{ "Passwort:" }
238 <input
239 id="password"
240 type="password"
241 placeholder="Passwort"
242 value={(*pwd).clone()}
243 oninput={Callback::from(move |e: InputEvent| {
244 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
245 pwd.set(input.value());
246 })}
247 />
248 </label>
249 </div>
250
251 <div class="form-group">
252 <label for="pwd_confirm">{ "Passwort bestätigen:" }
253 <input
254 id="pwd_confirm"
255 type="password"
256 placeholder="Passwort bestätigen"
257 value={(*pwd_confirm).clone()}
258 oninput={Callback::from(move |e: InputEvent| {
259 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
260 pwd_confirm.set(input.value());
261 })}
262 />
263 </label>
264 </div>
265
266 <button type="submit" disabled={*loading} class="submit-btn">
267 { if *loading { "Wird erstellt..." } else { "Administratorkonto erstellen" } }
268 </button>
269
270 if !error.is_empty() {
271 <p class="error-message" style="color:red">{ (*error).clone() }</p>
272 }
273
274 if *success {
275 <p class="success-message" style="color:green">{ "Administratorkonto erfolgreich erstellt! Wird weitergeleitet zum Login..." }</p>
276 }
277 </form>
278 </div>
279 </div>
280 }
281}