use std::sync::Arc; use std::time::Duration; use axum::extract::rejection::JsonRejection; use axum::extract::{Json, Path, State}; use axum::http::{HeaderMap, header}; use axum::middleware::{from_fn, from_fn_with_state}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Extension, Router}; use serde::Deserialize; use nomifun_api_types::{ ApiResponse, AuthStatusResponse, ChangePasswordRequest, CreateUserRequest, LoginRequest, LoginResponse, PublicUser, QrLoginRequest, RefreshResponse, RefreshTokenRequest, UpdateUserRequest, UserInfoResponse, UserListItem, WebuiChangePasswordRequest, WebuiChangeUsernameRequest, WebuiChangeUsernameResponse, WebuiGenerateQrTokenResponse, WebuiResetPasswordResponse, WsTokenResponse, }; use nomifun_common::AppError; use nomifun_common::constants::COOKIE_MAX_AGE_DAYS; use nomifun_db::{IUserRepository, models::User}; use crate::extract::extract_token_from_headers; use crate::middleware::{AuthState, CurrentUser, auth_middleware, require_admin_middleware}; use crate::trust::require_local_trust_middleware; use crate::password::{dummy_password_hash, generate_password, hash_password, verify_password_timed}; use crate::qr_token::QrTokenStore; use crate::rate_limit::{ RateLimiter, api_rate_limit_middleware, auth_rate_limit_middleware, authenticated_action_rate_limit_middleware, }; use crate::validation::{validate_password, validate_username}; use crate::{CookieConfig, JwtService}; /// Shared state for all auth route handlers. #[derive(Clone)] pub struct AuthRouterState { pub jwt_service: Arc, pub user_repo: Arc, pub cookie_config: Arc, pub qr_token_store: Arc, } #[derive(Debug, Deserialize)] struct CreateInternalUserRequest { username: String, password_hash: String, } #[derive(Debug, Deserialize)] struct SetSystemUserCredentialsRequest { username: String, password_hash: String, } #[derive(Debug, Deserialize)] struct UpdatePasswordHashRequest { password_hash: String, } #[derive(Debug, Deserialize)] struct UpdateUsernameRequest { username: String, } #[derive(Debug, Deserialize)] struct UpdateJwtSecretRequest { jwt_secret: String, } /// Build the auth router with all endpoints and middleware layers. /// /// Returns a `Router` with these endpoints: /// - `POST /login` /// - `POST /api/auth/setup` (one-time first-run admin creation) /// - `POST /logout` /// - `GET /api/auth/status` /// - `GET /api/auth/user` /// - `POST /api/auth/change-password` /// - `POST /api/auth/refresh` /// - `GET /api/ws-token` /// - `POST /api/auth/qr-login` /// - `GET /qr-login` /// - `GET /api/users` (admin-only) /// - `POST /api/users` (admin-only) /// - `GET /api/users/:id` (admin-only) /// - `PUT /api/users/:id` (admin-only) /// - `DELETE /api/users/:id` (admin-only) /// - `POST /api/webui/change-password` (local-only) /// - `POST /api/webui/change-username` (local-only) /// - `POST /api/webui/reset-password` (local-only) /// - `POST /api/webui/generate-qr-token` (local-only) pub fn auth_routes(state: AuthRouterState) -> Router { let auth_limiter = Arc::new(RateLimiter::auth()); let api_limiter = Arc::new(RateLimiter::api()); let action_limiter = Arc::new(RateLimiter::authenticated_action()); // Start periodic cleanup for rate limiters let cleanup_interval = Duration::from_secs(60); auth_limiter.start_cleanup_task(cleanup_interval); api_limiter.start_cleanup_task(cleanup_interval); action_limiter.start_cleanup_task(cleanup_interval); let auth_state = AuthState { jwt_service: state.jwt_service.clone(), user_repo: state.user_repo.clone(), }; // Auth rate limited routes (login, setup, qr-login) let auth_rate_limited = Router::new() .route("/login", post(login_handler)) .route("/api/auth/setup", post(setup_handler)) .route("/api/auth/qr-login", post(qr_login_handler)) .route_layer(from_fn_with_state(auth_limiter, auth_rate_limit_middleware)) .with_state(state.clone()); // Truly public, no-auth route: first-run/login status probe. let api_public = Router::new() .route("/api/auth/status", get(status_handler)) .route_layer(from_fn_with_state(api_limiter.clone(), api_rate_limit_middleware)) .with_state(state.clone()); // Local-only credential/internal routes. These have NO auth middleware, so // they are gated by `require_local_trust_middleware`: only the local desktop // client (which presents the per-boot trust secret, resolved upstream by // `trust_resolve_middleware` into a `LocalTrusted` marker) may reach them. let api_local_only = Router::new() .route( "/api/auth/internal/users", get(list_internal_users_handler).post(create_internal_user_handler), ) .route("/api/auth/internal/users/system", get(get_system_user_handler)) .route( "/api/auth/internal/users/system/credentials", post(set_system_user_credentials_handler), ) .route( "/api/auth/internal/users/by-username/{username}", get(find_user_by_username_handler), ) .route("/api/auth/internal/users/{id}", get(find_user_by_id_handler)) .route( "/api/auth/internal/users/{id}/password", post(update_user_password_hash_handler), ) .route( "/api/auth/internal/users/{id}/username", post(update_user_username_handler), ) .route( "/api/auth/internal/users/{id}/jwt-secret", post(update_user_jwt_secret_handler), ) .route( "/api/auth/internal/users/{id}/last-login", post(update_user_last_login_handler), ) // WebUI admin credential endpoints — local desktop client only. .route("/api/webui/change-password", post(webui_change_password_handler)) .route("/api/webui/change-username", post(webui_change_username_handler)) .route("/api/webui/reset-password", post(webui_reset_password_handler)) .route("/api/webui/generate-qr-token", post(webui_generate_qr_token_handler)) .route_layer(from_fn(require_local_trust_middleware)) .route_layer(from_fn_with_state(api_limiter.clone(), api_rate_limit_middleware)) .with_state(state.clone()); // Authenticated routes: api limiter -> auth -> action limiter // route_layer order: last added = outermost (first to process) let authenticated = Router::new() .route("/logout", post(logout_handler)) .route("/api/auth/user", get(user_handler)) .route("/api/auth/change-password", post(change_password_handler)) .route("/api/ws-token", get(ws_token_handler)) .route_layer(from_fn_with_state( action_limiter.clone(), authenticated_action_rate_limit_middleware, )) .route_layer(from_fn_with_state(auth_state.clone(), auth_middleware)) .route_layer(from_fn_with_state(api_limiter.clone(), api_rate_limit_middleware)) .with_state(state.clone()); // Admin-only user management routes let admin_routes = Router::new() .route("/api/users", get(list_users_handler).post(create_user_handler)) .route("/api/users/{id}", get(get_user_handler).put(update_user_handler).delete(delete_user_handler)) .route_layer(from_fn(require_admin_middleware)) .route_layer(from_fn_with_state( action_limiter.clone(), authenticated_action_rate_limit_middleware, )) .route_layer(from_fn_with_state(auth_state, auth_middleware)) .route_layer(from_fn_with_state(api_limiter.clone(), api_rate_limit_middleware)) .with_state(state.clone()); // API + action limited routes (token in body, no auth middleware) let api_action_limited = Router::new() .route("/api/auth/refresh", post(refresh_handler)) .route_layer(from_fn_with_state( action_limiter, authenticated_action_rate_limit_middleware, )) .route_layer(from_fn_with_state(api_limiter, api_rate_limit_middleware)) .with_state(state); // Static page (no middleware) let static_routes = Router::new().route("/qr-login", get(qr_login_page)); Router::new() .merge(auth_rate_limited) .merge(api_public) .merge(api_local_only) .merge(authenticated) .merge(admin_routes) .merge(api_action_limited) .merge(static_routes) } // --------------------------------------------------------------------------- // POST /login // --------------------------------------------------------------------------- async fn login_handler( State(state): State, body: Result, JsonRejection>, ) -> Result { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; // Input length validation (per API spec) if req.username.len() > 32 { return Err(AppError::BadRequest("Username must not exceed 32 characters".into())); } if req.password.len() > 128 { return Err(AppError::BadRequest("Password must not exceed 128 characters".into())); } // Look up user; run dummy verify on miss to prevent timing attacks let user = state .user_repo .find_by_username(&req.username) .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))?; let (found_user, password_valid) = match user { Some(u) if u.password_hash.trim().is_empty() => { // Seeded user with no password yet (first-run local mode). // Treat as invalid credentials; run dummy verify for timing symmetry // and to avoid bcrypt error on empty hash leaking as a 500. let _ = verify_password_timed(&req.password, dummy_password_hash()).await; (None, false) } Some(u) => { let valid = verify_password_timed(&req.password, &u.password_hash).await?; (Some(u), valid) } None => { // Prevent user enumeration via timing let _ = verify_password_timed(&req.password, dummy_password_hash()).await; (None, false) } }; if !password_valid { return Err(AppError::Unauthorized("Invalid username or password".into())); } let user = found_user.ok_or_else(|| AppError::Unauthorized("Invalid username or password".into()))?; let token = state .jwt_service .sign(&user.id, &user.username, &user.role) .map_err(|e| AppError::Internal(format!("Token signing error: {e}")))?; // Update last login (best-effort) if let Err(e) = state.user_repo.update_last_login(&user.id).await { tracing::warn!("Failed to update last login for {}: {e}", user.id); } let cookie = state.cookie_config.build_session_cookie(&token); let resp = LoginResponse::new( PublicUser { id: user.id, username: user.username, role: user.role, }, token, ); Ok(([(header::SET_COOKIE, cookie)], Json(resp)).into_response()) } // --------------------------------------------------------------------------- // POST /logout // --------------------------------------------------------------------------- async fn logout_handler(State(state): State, headers: HeaderMap) -> Result { if let Some(token) = extract_token_from_headers(&headers) { state.jwt_service.blacklist_token(&token); } let cookie = state.cookie_config.clear_session_cookie(); let resp = ApiResponse::message("Logged out successfully"); Ok(([(header::SET_COOKIE, cookie)], Json(resp)).into_response()) } // --------------------------------------------------------------------------- // GET /api/auth/status // --------------------------------------------------------------------------- async fn status_handler( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let has_users = state .user_repo .has_users() .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))?; let user_count = state .user_repo .count_users() .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))?; // Check authentication without requiring it, extract role if authenticated let (is_authenticated, is_admin) = extract_token_from_headers(&headers) .and_then(|token| state.jwt_service.verify(&token).ok()) .map(|payload| (true, payload.role == "admin")) .unwrap_or((false, false)); Ok(Json(AuthStatusResponse { success: true, needs_setup: !has_users, user_count: user_count as u64, is_authenticated, is_admin: Some(is_admin), })) } // --------------------------------------------------------------------------- // POST /api/auth/setup — one-time first-run admin creation // --------------------------------------------------------------------------- /// Create the initial admin account on a fresh install, then log them in. /// /// Available ONLY while the install is uninitialised: the very first visitor's /// chosen username + password become the admin credentials, and the response /// sets the session cookie so they are immediately logged in. The write is an /// atomic conditional UPDATE (only matches the empty-password system user), so /// even two concurrent first-run requests cannot both win — the loser gets /// `409 Conflict` and never overwrites the winner's account. /// /// Reuses [`LoginRequest`]/[`LoginResponse`] (same `{username, password}` shape /// and `{success, user, token}` reply as `/login`). CSRF-exempt like `/login` /// (see `csrf::csrf_middleware`) and behind the auth rate limiter. /// /// SECURITY: there is a brief first-run window before setup completes where any /// client reaching the port could claim the admin. Operators who need to close /// it can pre-seed with `NOMIFUN_ADMIN_PASSWORD` (see /// `nomifun_app::bootstrap::ensure_admin_credentials`), which makes this return /// 409 from the first boot. async fn setup_handler( State(state): State, body: Result, JsonRejection>, ) -> Result { // One-time only: refuse once any real (non-empty-password) user exists. let has_users = state .user_repo .has_users() .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))?; if has_users { return Err(AppError::Conflict("Admin account already initialized".into())); } let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; let username = req.username.trim().to_owned(); validate_username(&username).map_err(|e| AppError::BadRequest(e.to_string()))?; validate_password(&req.password).map_err(|e| AppError::BadRequest(e.to_string()))?; // Hash on a blocking thread (bcrypt is CPU-bound), mirroring change-password. let password = req.password.clone(); let password_hash = tokio::task::spawn_blocking(move || hash_password(&password)) .await .map_err(|e| AppError::Internal(format!("Task join error: {e}")))??; // Atomically claim the uninitialised admin slot. The conditional UPDATE is // the authoritative one-time gate: if a concurrent request already set the // credentials, this writes 0 rows and we return 409 instead of clobbering // the winner. (The has_users() check above is just a cheap pre-reject.) let provisioned = state .user_repo .set_system_user_credentials_if_uninitialized(&username, &password_hash) .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))?; if !provisioned { return Err(AppError::Conflict("Admin account already initialized".into())); } let user = state .user_repo .get_primary_webui_user() .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))? .ok_or_else(|| AppError::Internal("Admin user missing after setup".into()))?; let token = state .jwt_service .sign(&user.id, &user.username, "admin") .map_err(|e| AppError::Internal(format!("Token signing error: {e}")))?; if let Err(e) = state.user_repo.update_last_login(&user.id).await { tracing::warn!("Failed to update last login for {}: {e}", user.id); } let cookie = state.cookie_config.build_session_cookie(&token); let resp = LoginResponse::new( PublicUser { id: user.id, username: user.username, role: "admin".to_string(), }, token, ); tracing::info!("first-run setup: initial admin account created"); Ok(([(header::SET_COOKIE, cookie)], Json(resp)).into_response()) } // --------------------------------------------------------------------------- // Local-only internal user routes // --------------------------------------------------------------------------- async fn list_internal_users_handler( State(state): State, ) -> Result>>, AppError> { let users = state.user_repo.list_users().await?; Ok(Json(ApiResponse::ok(users))) } async fn get_system_user_handler( State(state): State, ) -> Result>>, AppError> { let user = state.user_repo.get_system_user().await?; Ok(Json(ApiResponse::ok(user))) } async fn find_user_by_username_handler( State(state): State, Path(username): Path, ) -> Result>>, AppError> { let user = state.user_repo.find_by_username(&username).await?; Ok(Json(ApiResponse::ok(user))) } async fn find_user_by_id_handler( State(state): State, Path(id): Path, ) -> Result>>, AppError> { let user = state.user_repo.find_by_id(&id).await?; Ok(Json(ApiResponse::ok(user))) } async fn create_internal_user_handler( State(state): State, body: Result, JsonRejection>, ) -> Result>, AppError> { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; let user = state.user_repo.create_user(&req.username, &req.password_hash).await?; Ok(Json(ApiResponse::ok(user))) } async fn set_system_user_credentials_handler( State(state): State, body: Result, JsonRejection>, ) -> Result>, AppError> { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; state .user_repo .set_system_user_credentials(&req.username, &req.password_hash) .await?; Ok(Json(ApiResponse::ok(()))) } async fn update_user_password_hash_handler( State(state): State, Path(id): Path, body: Result, JsonRejection>, ) -> Result>, AppError> { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; state.user_repo.update_password(&id, &req.password_hash).await?; Ok(Json(ApiResponse::ok(()))) } async fn update_user_username_handler( State(state): State, Path(id): Path, body: Result, JsonRejection>, ) -> Result>, AppError> { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; state.user_repo.update_username(&id, &req.username).await?; Ok(Json(ApiResponse::ok(()))) } async fn update_user_jwt_secret_handler( State(state): State, Path(id): Path, body: Result, JsonRejection>, ) -> Result>, AppError> { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; state.user_repo.update_jwt_secret(&id, &req.jwt_secret).await?; Ok(Json(ApiResponse::ok(()))) } async fn update_user_last_login_handler( State(state): State, Path(id): Path, ) -> Result>, AppError> { state.user_repo.update_last_login(&id).await?; Ok(Json(ApiResponse::ok(()))) } // --------------------------------------------------------------------------- // GET /api/auth/user // --------------------------------------------------------------------------- async fn user_handler(Extension(user): Extension) -> Json { Json(UserInfoResponse { success: true, user: PublicUser { id: user.id, username: user.username, role: user.role, }, }) } // --------------------------------------------------------------------------- // POST /api/auth/change-password // --------------------------------------------------------------------------- async fn change_password_handler( State(state): State, Extension(current_user): Extension, body: Result, JsonRejection>, ) -> Result>, AppError> { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; // Validate new password strength validate_password(&req.new_password)?; // Fetch user record let user = state .user_repo .find_by_id(¤t_user.id) .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))? .ok_or_else(|| AppError::NotFound("User not found".into()))?; // Verify current password let valid = verify_password_timed(&req.current_password, &user.password_hash).await?; if !valid { return Err(AppError::Unauthorized("Current password is incorrect".into())); } // Hash new password on blocking thread let password = req.new_password.clone(); let new_hash = tokio::task::spawn_blocking(move || hash_password(&password)) .await .map_err(|e| AppError::Internal(format!("Task join error: {e}")))??; // Persist new password hash state .user_repo .update_password(¤t_user.id, &new_hash) .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))?; // Rotate JWT secret to invalidate all sessions let new_secret = state .jwt_service .rotate_secret() .map_err(|e| AppError::Internal(format!("Secret rotation error: {e}")))?; // Persist new secret to database state .user_repo .update_jwt_secret(¤t_user.id, &new_secret) .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))?; Ok(Json(ApiResponse::message("Password changed successfully"))) } // --------------------------------------------------------------------------- // POST /api/auth/refresh // --------------------------------------------------------------------------- async fn refresh_handler( State(state): State, body: Result, JsonRejection>, ) -> Result, AppError> { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; let payload = state .jwt_service .verify(&req.token) .map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?; let new_token = state .jwt_service .sign(&payload.user_id, &payload.username, &payload.role) .map_err(|e| AppError::Internal(format!("Token signing error: {e}")))?; Ok(Json(RefreshResponse { success: true, token: new_token, })) } // --------------------------------------------------------------------------- // GET /api/ws-token // --------------------------------------------------------------------------- async fn ws_token_handler( State(state): State, Extension(current_user): Extension, headers: HeaderMap, ) -> Result, AppError> { // Reuse the existing session token for WebSocket connections let token = extract_token_from_headers(&headers).ok_or_else(|| AppError::Unauthorized("No token found".into()))?; // Ensure user still exists state .user_repo .find_by_id(¤t_user.id) .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))? .ok_or_else(|| AppError::Unauthorized("User not found".into()))?; // Cookie max age in milliseconds let expires_in = u64::from(COOKIE_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000; Ok(Json(WsTokenResponse { success: true, ws_token: token, expires_in, })) } // --------------------------------------------------------------------------- // POST /api/auth/qr-login // --------------------------------------------------------------------------- async fn qr_login_handler( State(state): State, body: Result, JsonRejection>, ) -> Result { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; // Validate and consume QR token (one-time use) state.qr_token_store.validate_and_consume(&req.qr_token)?; // Get primary WebUI user for QR login let user = state .user_repo .get_primary_webui_user() .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))? .ok_or_else(|| AppError::Internal("No primary user configured".into()))?; let token = state .jwt_service .sign(&user.id, &user.username, &user.role) .map_err(|e| AppError::Internal(format!("Token signing error: {e}")))?; // Update last login (best-effort) if let Err(e) = state.user_repo.update_last_login(&user.id).await { tracing::warn!("Failed to update last login for {}: {e}", user.id); } let cookie = state.cookie_config.build_session_cookie(&token); let resp = LoginResponse::new( PublicUser { id: user.id, username: user.username, role: user.role, }, token, ); Ok(([(header::SET_COOKIE, cookie)], Json(resp)).into_response()) } // --------------------------------------------------------------------------- // GET /qr-login (static HTML page) // --------------------------------------------------------------------------- async fn qr_login_page() -> Html<&'static str> { Html(QR_LOGIN_HTML) } const QR_LOGIN_HTML: &str = r#" QR Login - Nomi

Nomi

Processing login...

"#; // --------------------------------------------------------------------------- // WebUI admin credential endpoints (local-only) // --------------------------------------------------------------------------- /// Random password length for `/api/webui/reset-password`. const RESET_PASSWORD_LEN: usize = 16; /// Resolve the WebUI admin user, falling back to NotFound when absent. async fn resolve_webui_admin(user_repo: &dyn IUserRepository) -> Result { user_repo .get_primary_webui_user() .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))? .ok_or_else(|| AppError::NotFound("No WebUI admin user configured".into())) } // --------------------------------------------------------------------------- // POST /api/webui/change-password // --------------------------------------------------------------------------- async fn webui_change_password_handler( State(state): State, body: Result, JsonRejection>, ) -> Result>, AppError> { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; validate_password(&req.new_password)?; let user = resolve_webui_admin(&*state.user_repo).await?; let password = req.new_password; let new_hash = tokio::task::spawn_blocking(move || hash_password(&password)) .await .map_err(|e| AppError::Internal(format!("Task join error: {e}")))??; state .user_repo .update_password(&user.id, &new_hash) .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))?; Ok(Json(ApiResponse::message("Password changed successfully"))) } // --------------------------------------------------------------------------- // POST /api/webui/change-username // --------------------------------------------------------------------------- async fn webui_change_username_handler( State(state): State, body: Result, JsonRejection>, ) -> Result>, AppError> { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; let trimmed = req.new_username.trim().to_owned(); validate_username(&trimmed)?; let user = resolve_webui_admin(&*state.user_repo).await?; if user.username != trimmed { state .user_repo .update_username(&user.id, &trimmed) .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))?; } Ok(Json(ApiResponse::ok(WebuiChangeUsernameResponse { username: trimmed }))) } // --------------------------------------------------------------------------- // POST /api/webui/reset-password // --------------------------------------------------------------------------- async fn webui_reset_password_handler( State(state): State, ) -> Result>, AppError> { let user = resolve_webui_admin(&*state.user_repo).await?; let new_password = generate_password(RESET_PASSWORD_LEN); let password_for_hash = new_password.clone(); let new_hash = tokio::task::spawn_blocking(move || hash_password(&password_for_hash)) .await .map_err(|e| AppError::Internal(format!("Task join error: {e}")))??; state .user_repo .update_password(&user.id, &new_hash) .await .map_err(|e| AppError::Internal(format!("Database error: {e}")))?; Ok(Json(ApiResponse::ok(WebuiResetPasswordResponse { new_password }))) } // --------------------------------------------------------------------------- // POST /api/webui/generate-qr-token // --------------------------------------------------------------------------- async fn webui_generate_qr_token_handler( State(state): State, ) -> Result>, AppError> { let (token, expires_at_ms) = state.qr_token_store.generate_with_expiry(); Ok(Json(ApiResponse::ok(WebuiGenerateQrTokenResponse { token, expires_at_ms, }))) } // --------------------------------------------------------------------------- // Admin-only user management handlers // --------------------------------------------------------------------------- /// GET /api/users — list all users (admin-only) async fn list_users_handler( State(state): State, ) -> Result>>, AppError> { let users = state.user_repo.list_users().await?; let user_list: Vec = users .into_iter() .map(|u| UserListItem { id: u.id, username: u.username, role: u.role, email: u.email, created_at: u.created_at, last_login: u.last_login, }) .collect(); Ok(Json(ApiResponse::ok(user_list))) } /// POST /api/users — create a new user (admin-only) async fn create_user_handler( State(state): State, Extension(current_user): Extension, body: Result, JsonRejection>, ) -> Result>, AppError> { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; // Validate username and password let username = req.username.trim().to_owned(); validate_username(&username)?; validate_password(&req.password)?; // Validate role let role = req.role.to_lowercase(); if role != "admin" && role != "user" { return Err(AppError::BadRequest("Role must be 'admin' or 'user'".into())); } // Hash password let password = req.password.clone(); let password_hash = tokio::task::spawn_blocking(move || hash_password(&password)) .await .map_err(|e| AppError::Internal(format!("Task join error: {e}")))? .map_err(|e| AppError::Internal(format!("Password hashing error: {e}")))?; // Create user with specified role let user = state.user_repo.create_user_with_role(&username, &password_hash, &role).await?; tracing::info!(admin_id = %current_user.id, new_user_id = %user.id, username = %username, role = %role, "Admin created new user"); Ok(Json(ApiResponse::ok(UserListItem { id: user.id, username: user.username, role: user.role, email: req.email, created_at: user.created_at, last_login: user.last_login, }))) } /// GET /api/users/:id — get a single user (admin-only) async fn get_user_handler( State(state): State, Path(id): Path, ) -> Result>, AppError> { let user = state .user_repo .find_by_id(&id) .await? .ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?; Ok(Json(ApiResponse::ok(UserListItem { id: user.id, username: user.username, role: user.role, email: user.email, created_at: user.created_at, last_login: user.last_login, }))) } /// PUT /api/users/:id — update a user (admin-only) async fn update_user_handler( State(state): State, Extension(current_user): Extension, Path(id): Path, body: Result, JsonRejection>, ) -> Result>, AppError> { let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?; // Prevent admin from modifying themselves if id == current_user.id { return Err(AppError::BadRequest("Cannot modify your own account".into())); } // Fetch existing user let mut user = state .user_repo .find_by_id(&id) .await? .ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?; // Update username if provided if let Some(username) = req.username { let username = username.trim().to_owned(); validate_username(&username)?; state.user_repo.update_username(&id, &username).await?; user.username = username; } // Update role if provided if let Some(role) = req.role { let role = role.to_lowercase(); if role != "admin" && role != "user" { return Err(AppError::BadRequest("Role must be 'admin' or 'user'".into())); } // Check if trying to demote the last admin if user.role == "admin" && role == "user" { let admin_count = state .user_repo .list_users() .await? .iter() .filter(|u| u.role == "admin") .count(); if admin_count <= 1 { return Err(AppError::BadRequest("Cannot remove the last admin".into())); } } state.user_repo.update_role(&id, &role).await?; user.role = role; } // Update email if provided if let Some(email) = req.email { user.email = if email.trim().is_empty() { None } else { Some(email.trim().to_owned()) }; } tracing::info!(admin_id = %current_user.id, user_id = %id, "Admin updated user"); // Fetch updated user let updated_user = state .user_repo .find_by_id(&id) .await? .ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?; Ok(Json(ApiResponse::ok(UserListItem { id: updated_user.id, username: updated_user.username, role: updated_user.role, email: updated_user.email, created_at: updated_user.created_at, last_login: updated_user.last_login, }))) } /// DELETE /api/users/:id — delete a user (admin-only) async fn delete_user_handler( State(state): State, Extension(current_user): Extension, Path(id): Path, ) -> Result>, AppError> { // Prevent admin from deleting themselves if id == current_user.id { return Err(AppError::BadRequest("Cannot delete your own account".into())); } // Fetch user to check role let user = state .user_repo .find_by_id(&id) .await? .ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?; // Check if trying to delete the last admin if user.role == "admin" { let admin_count = state .user_repo .list_users() .await? .iter() .filter(|u| u.role == "admin") .count(); if admin_count <= 1 { return Err(AppError::BadRequest("Cannot delete the last admin".into())); } } // Delete user state.user_repo.delete_user(&id).await?; tracing::info!(admin_id = %current_user.id, user_id = %id, "Admin deleted user"); Ok(Json(ApiResponse::success())) }