1use askama::Template;
2use axum::{
3 extract::{Form, State},
4 http::StatusCode,
5 response::{Html, IntoResponse, Redirect, Response},
6};
7use axum_extra::extract::cookie::{Cookie, CookieJar};
8use serde::Deserialize;
9use time::Duration;
10
11use mini_apm::{DbPool, config::Config, models};
12
13use super::project_context::{WebProjectContext, get_project_context};
14
15const SESSION_COOKIE: &str = "miniapm_session";
16
17#[derive(Template)]
20#[template(path = "auth/login.html")]
21pub struct LoginTemplate {
22 pub error: Option<String>,
23}
24
25#[derive(Template)]
26#[template(path = "auth/change_password.html")]
27pub struct ChangePasswordTemplate {
28 pub error: Option<String>,
29 pub username: String,
30}
31
32#[derive(Template)]
33#[template(path = "auth/users.html")]
34pub struct UsersTemplate {
35 pub users: Vec<models::User>,
36 pub current_user_id: i64,
37 pub error: Option<String>,
38 pub success: Option<String>,
39 pub invite_url: Option<String>,
40 pub ctx: WebProjectContext,
41}
42
43#[derive(Template)]
44#[template(path = "auth/invite.html")]
45pub struct InviteTemplate {
46 pub username: String,
47 pub error: Option<String>,
48}
49
50#[derive(Deserialize)]
53pub struct LoginForm {
54 pub username: String,
55 pub password: String,
56}
57
58#[derive(Deserialize)]
59pub struct ChangePasswordForm {
60 pub current_password: String,
61 pub new_password: String,
62 pub confirm_password: String,
63}
64
65#[derive(Deserialize)]
66pub struct CreateUserForm {
67 pub username: String,
68 pub is_admin: Option<String>,
69}
70
71pub fn get_current_user(pool: &DbPool, jar: &CookieJar) -> Option<models::User> {
73 let token = jar.get(SESSION_COOKIE)?.value();
74 models::user::get_user_from_session(pool, token)
75 .ok()
76 .flatten()
77}
78
79pub async fn login_page(State(pool): State<DbPool>, jar: CookieJar) -> Response {
82 if get_current_user(&pool, &jar).is_some() {
84 return Redirect::to("/").into_response();
85 }
86
87 Html(LoginTemplate { error: None }.render().unwrap_or_default()).into_response()
88}
89
90pub async fn login_submit(
91 State(pool): State<DbPool>,
92 jar: CookieJar,
93 Form(form): Form<LoginForm>,
94) -> Response {
95 match models::user::authenticate(&pool, &form.username, &form.password) {
96 Ok(Some(user)) => {
97 match models::user::create_session(&pool, user.id) {
99 Ok(token) => {
100 let cookie = Cookie::build((SESSION_COOKIE, token))
101 .path("/")
102 .http_only(true)
103 .secure(true)
104 .same_site(axum_extra::extract::cookie::SameSite::Lax)
105 .max_age(Duration::days(7))
106 .build();
107
108 let jar = jar.add(cookie);
109
110 if user.must_change_password {
112 (jar, Redirect::to("/auth/change-password")).into_response()
113 } else {
114 (jar, Redirect::to("/")).into_response()
115 }
116 }
117 Err(_) => Html(
118 LoginTemplate {
119 error: Some("Failed to create session".to_string()),
120 }
121 .render()
122 .unwrap_or_default(),
123 )
124 .into_response(),
125 }
126 }
127 Ok(None) => Html(
128 LoginTemplate {
129 error: Some("Invalid username or password".to_string()),
130 }
131 .render()
132 .unwrap_or_default(),
133 )
134 .into_response(),
135 Err(_) => Html(
136 LoginTemplate {
137 error: Some("Authentication error".to_string()),
138 }
139 .render()
140 .unwrap_or_default(),
141 )
142 .into_response(),
143 }
144}
145
146pub async fn logout(State(pool): State<DbPool>, jar: CookieJar) -> Response {
147 if let Some(cookie) = jar.get(SESSION_COOKIE) {
148 let _ = models::user::delete_session(&pool, cookie.value());
149 }
150
151 let jar = jar.remove(Cookie::from(SESSION_COOKIE));
152 (jar, Redirect::to("/auth/login")).into_response()
153}
154
155pub async fn change_password_page(State(pool): State<DbPool>, jar: CookieJar) -> Response {
156 let Some(user) = get_current_user(&pool, &jar) else {
157 return Redirect::to("/auth/login").into_response();
158 };
159
160 Html(
161 ChangePasswordTemplate {
162 error: None,
163 username: user.username,
164 }
165 .render()
166 .unwrap_or_default(),
167 )
168 .into_response()
169}
170
171pub async fn change_password_submit(
172 State(pool): State<DbPool>,
173 jar: CookieJar,
174 Form(form): Form<ChangePasswordForm>,
175) -> Response {
176 let Some(user) = get_current_user(&pool, &jar) else {
177 return Redirect::to("/auth/login").into_response();
178 };
179
180 if form.new_password != form.confirm_password {
182 return Html(
183 ChangePasswordTemplate {
184 error: Some("Passwords do not match".to_string()),
185 username: user.username,
186 }
187 .render()
188 .unwrap_or_default(),
189 )
190 .into_response();
191 }
192
193 if form.new_password.len() < 8 {
194 return Html(
195 ChangePasswordTemplate {
196 error: Some("Password must be at least 8 characters".to_string()),
197 username: user.username,
198 }
199 .render()
200 .unwrap_or_default(),
201 )
202 .into_response();
203 }
204
205 let password_valid = user
207 .password_hash
208 .as_ref()
209 .is_some_and(|h| models::user::verify_password(&form.current_password, h));
210 if !password_valid {
211 return Html(
212 ChangePasswordTemplate {
213 error: Some("Current password is incorrect".to_string()),
214 username: user.username,
215 }
216 .render()
217 .unwrap_or_default(),
218 )
219 .into_response();
220 }
221
222 match models::user::change_password(&pool, user.id, &form.new_password) {
224 Ok(_) => Redirect::to("/").into_response(),
225 Err(_) => Html(
226 ChangePasswordTemplate {
227 error: Some("Failed to change password".to_string()),
228 username: user.username,
229 }
230 .render()
231 .unwrap_or_default(),
232 )
233 .into_response(),
234 }
235}
236
237pub async fn users_page(
240 State(pool): State<DbPool>,
241 jar: CookieJar,
242 cookies: tower_cookies::Cookies,
243) -> Response {
244 let Some(user) = get_current_user(&pool, &jar) else {
245 return Redirect::to("/auth/login").into_response();
246 };
247
248 if !user.is_admin {
249 return (StatusCode::FORBIDDEN, "Admin access required").into_response();
250 }
251
252 let users = models::user::list_all(&pool).unwrap_or_default();
253 let ctx = get_project_context(&pool, &cookies);
254
255 Html(
256 UsersTemplate {
257 users,
258 current_user_id: user.id,
259 error: None,
260 success: None,
261 invite_url: None,
262 ctx,
263 }
264 .render()
265 .unwrap_or_default(),
266 )
267 .into_response()
268}
269
270pub async fn create_user(
271 State(pool): State<DbPool>,
272 jar: CookieJar,
273 cookies: tower_cookies::Cookies,
274 Form(form): Form<CreateUserForm>,
275) -> Response {
276 let Some(user) = get_current_user(&pool, &jar) else {
277 return Redirect::to("/auth/login").into_response();
278 };
279
280 if !user.is_admin {
281 return (StatusCode::FORBIDDEN, "Admin access required").into_response();
282 }
283
284 let ctx = get_project_context(&pool, &cookies);
285
286 if form.username.is_empty() {
287 let users = models::user::list_all(&pool).unwrap_or_default();
288 return Html(
289 UsersTemplate {
290 users,
291 current_user_id: user.id,
292 error: Some("Username is required".to_string()),
293 success: None,
294 invite_url: None,
295 ctx,
296 }
297 .render()
298 .unwrap_or_default(),
299 )
300 .into_response();
301 }
302
303 let is_admin = form.is_admin.as_deref() == Some("on");
304
305 match models::user::create_with_invite(&pool, &form.username, is_admin) {
306 Ok(invite_token) => {
307 let users = models::user::list_all(&pool).unwrap_or_default();
308 let base_url = std::env::var("MINI_APM_URL")
309 .unwrap_or_else(|_| "http://localhost:3000".to_string());
310 let invite_url = format!(
311 "{}/auth/invite/{}",
312 base_url.trim_end_matches('/'),
313 invite_token
314 );
315 Html(
316 UsersTemplate {
317 users,
318 current_user_id: user.id,
319 error: None,
320 success: Some(format!("User '{}' created", form.username)),
321 invite_url: Some(invite_url),
322 ctx,
323 }
324 .render()
325 .unwrap_or_default(),
326 )
327 .into_response()
328 }
329 Err(_) => {
330 let users = models::user::list_all(&pool).unwrap_or_default();
331 Html(
332 UsersTemplate {
333 users,
334 current_user_id: user.id,
335 error: Some("Failed to create user (username may already exist)".to_string()),
336 success: None,
337 invite_url: None,
338 ctx,
339 }
340 .render()
341 .unwrap_or_default(),
342 )
343 .into_response()
344 }
345 }
346}
347
348#[derive(Deserialize)]
349pub struct DeleteUserForm {
350 pub user_id: i64,
351}
352
353pub async fn delete_user(
354 State(pool): State<DbPool>,
355 jar: CookieJar,
356 cookies: tower_cookies::Cookies,
357 Form(form): Form<DeleteUserForm>,
358) -> Response {
359 let Some(user) = get_current_user(&pool, &jar) else {
360 return Redirect::to("/auth/login").into_response();
361 };
362
363 if !user.is_admin {
364 return (StatusCode::FORBIDDEN, "Admin access required").into_response();
365 }
366
367 let ctx = get_project_context(&pool, &cookies);
368
369 if form.user_id == user.id {
370 let users = models::user::list_all(&pool).unwrap_or_default();
371 return Html(
372 UsersTemplate {
373 users,
374 current_user_id: user.id,
375 error: Some("Cannot delete yourself".to_string()),
376 success: None,
377 invite_url: None,
378 ctx,
379 }
380 .render()
381 .unwrap_or_default(),
382 )
383 .into_response();
384 }
385
386 match models::user::delete(&pool, form.user_id) {
387 Ok(_) => {
388 let users = models::user::list_all(&pool).unwrap_or_default();
389 Html(
390 UsersTemplate {
391 users,
392 current_user_id: user.id,
393 error: None,
394 success: Some("User deleted".to_string()),
395 invite_url: None,
396 ctx,
397 }
398 .render()
399 .unwrap_or_default(),
400 )
401 .into_response()
402 }
403 Err(_) => {
404 let users = models::user::list_all(&pool).unwrap_or_default();
405 Html(
406 UsersTemplate {
407 users,
408 current_user_id: user.id,
409 error: Some("Failed to delete user".to_string()),
410 success: None,
411 invite_url: None,
412 ctx,
413 }
414 .render()
415 .unwrap_or_default(),
416 )
417 .into_response()
418 }
419 }
420}
421
422#[derive(Deserialize)]
425pub struct InviteForm {
426 pub password: String,
427 pub confirm_password: String,
428}
429
430pub async fn invite_page(
431 State(pool): State<DbPool>,
432 axum::extract::Path(token): axum::extract::Path<String>,
433) -> Response {
434 match models::user::find_by_invite_token(&pool, &token) {
435 Ok(Some(user)) => Html(
436 InviteTemplate {
437 username: user.username,
438 error: None,
439 }
440 .render()
441 .unwrap_or_default(),
442 )
443 .into_response(),
444 _ => Html(
445 "<h1>Invalid or expired invite link</h1><p><a href=\"/auth/login\">Go to login</a></p>",
446 )
447 .into_response(),
448 }
449}
450
451pub async fn invite_submit(
452 State(pool): State<DbPool>,
453 jar: CookieJar,
454 axum::extract::Path(token): axum::extract::Path<String>,
455 Form(form): Form<InviteForm>,
456) -> Response {
457 let user = match models::user::find_by_invite_token(&pool, &token) {
458 Ok(Some(u)) => u,
459 _ => return Html(
460 "<h1>Invalid or expired invite link</h1><p><a href=\"/auth/login\">Go to login</a></p>",
461 )
462 .into_response(),
463 };
464
465 if form.password != form.confirm_password {
466 return Html(
467 InviteTemplate {
468 username: user.username,
469 error: Some("Passwords do not match".to_string()),
470 }
471 .render()
472 .unwrap_or_default(),
473 )
474 .into_response();
475 }
476
477 if form.password.len() < 8 {
478 return Html(
479 InviteTemplate {
480 username: user.username,
481 error: Some("Password must be at least 8 characters".to_string()),
482 }
483 .render()
484 .unwrap_or_default(),
485 )
486 .into_response();
487 }
488
489 if models::user::accept_invite(&pool, user.id, &form.password).is_err() {
491 return Html(
492 InviteTemplate {
493 username: user.username,
494 error: Some("Failed to set password".to_string()),
495 }
496 .render()
497 .unwrap_or_default(),
498 )
499 .into_response();
500 }
501
502 match models::user::create_session(&pool, user.id) {
504 Ok(session_token) => {
505 let cookie = Cookie::build((SESSION_COOKIE, session_token))
506 .path("/")
507 .http_only(true)
508 .secure(true)
509 .same_site(axum_extra::extract::cookie::SameSite::Lax)
510 .max_age(Duration::days(7))
511 .build();
512
513 (jar.add(cookie), Redirect::to("/")).into_response()
514 }
515 Err(_) => Redirect::to("/auth/login").into_response(),
516 }
517}
518
519pub async fn require_auth(
521 pool: &DbPool,
522 config: &Config,
523 jar: &CookieJar,
524) -> Result<Option<models::User>, Redirect> {
525 if !config.enable_user_accounts {
527 return Ok(None);
528 }
529
530 match get_current_user(pool, jar) {
532 Some(user) => {
533 if user.must_change_password {
535 Err(Redirect::to("/auth/change-password"))
536 } else {
537 Ok(Some(user))
538 }
539 }
540 None => Err(Redirect::to("/auth/login")),
541 }
542}