use axum::{ extract::{Path, State}, response::Json, }; use serde::Deserialize; use sqlx::Row; use tracing::warn; use super::{ApiResponse, DashboardState, auth}; // ── User management endpoints (admin-only) ────────────────────────── pub(super) async fn handle_get_users( State(state): State, headers: axum::http::HeaderMap, ) -> Json> { let (_session, _) = match auth::require_admin(&state, &headers).await { Ok((session, new_token)) => (session, new_token), Err(e) => return e, }; let pool = &state.app_state.db_pool; let result = sqlx::query( "SELECT id, username, display_name, role, must_change_password, created_at FROM users ORDER BY created_at ASC", ) .fetch_all(pool) .await; match result { Ok(rows) => { let users: Vec = rows .into_iter() .map(|row| { let username: String = row.get("username"); let display_name: Option = row.get("display_name"); serde_json::json!({ "id": row.get::("id"), "username": &username, "display_name": display_name.as_deref().unwrap_or(&username), "role": row.get::("role"), "must_change_password": row.get::("must_change_password"), "created_at": row.get::, _>("created_at"), }) }) .collect(); Json(ApiResponse::success(serde_json::json!(users))) } Err(e) => { warn!("Failed to fetch users: {}", e); Json(ApiResponse::error("Failed to fetch users".to_string())) } } } #[derive(Deserialize)] pub(super) struct CreateUserRequest { pub(super) username: String, pub(super) password: String, pub(super) display_name: Option, pub(super) role: Option, } pub(super) async fn handle_create_user( State(state): State, headers: axum::http::HeaderMap, Json(payload): Json, ) -> Json> { let (_session, _) = match auth::require_admin(&state, &headers).await { Ok((session, new_token)) => (session, new_token), Err(e) => return e, }; let pool = &state.app_state.db_pool; // Validate role let role = payload.role.as_deref().unwrap_or("viewer"); if role != "admin" && role != "viewer" { return Json(ApiResponse::error("Role must be 'admin' or 'viewer'".to_string())); } // Validate username let username = payload.username.trim(); if username.is_empty() || username.len() > 64 { return Json(ApiResponse::error("Username must be 1-64 characters".to_string())); } // Validate password if payload.password.len() < 4 { return Json(ApiResponse::error("Password must be at least 4 characters".to_string())); } let password_hash = match bcrypt::hash(&payload.password, 12) { Ok(h) => h, Err(_) => return Json(ApiResponse::error("Failed to hash password".to_string())), }; let result = sqlx::query( r#" INSERT INTO users (username, password_hash, display_name, role, must_change_password) VALUES (?, ?, ?, ?, TRUE) RETURNING id, username, display_name, role, must_change_password, created_at "#, ) .bind(username) .bind(&password_hash) .bind(&payload.display_name) .bind(role) .fetch_one(pool) .await; match result { Ok(row) => { let uname: String = row.get("username"); let display_name: Option = row.get("display_name"); Json(ApiResponse::success(serde_json::json!({ "id": row.get::("id"), "username": &uname, "display_name": display_name.as_deref().unwrap_or(&uname), "role": row.get::("role"), "must_change_password": row.get::("must_change_password"), "created_at": row.get::, _>("created_at"), }))) } Err(e) => { let msg = if e.to_string().contains("UNIQUE") { format!("Username '{}' already exists", username) } else { format!("Failed to create user: {}", e) }; warn!("Failed to create user: {}", e); Json(ApiResponse::error(msg)) } } } #[derive(Deserialize)] pub(super) struct UpdateUserRequest { pub(super) display_name: Option, pub(super) role: Option, pub(super) password: Option, pub(super) must_change_password: Option, } pub(super) async fn handle_update_user( State(state): State, headers: axum::http::HeaderMap, Path(id): Path, Json(payload): Json, ) -> Json> { let (_session, _) = match auth::require_admin(&state, &headers).await { Ok((session, new_token)) => (session, new_token), Err(e) => return e, }; let pool = &state.app_state.db_pool; // Validate role if provided if let Some(ref role) = payload.role { if role != "admin" && role != "viewer" { return Json(ApiResponse::error("Role must be 'admin' or 'viewer'".to_string())); } } // Build dynamic update let mut sets = Vec::new(); let mut string_binds: Vec = Vec::new(); let mut has_password = false; let mut has_must_change = false; if let Some(ref display_name) = payload.display_name { sets.push("display_name = ?"); string_binds.push(display_name.clone()); } if let Some(ref role) = payload.role { sets.push("role = ?"); string_binds.push(role.clone()); } if let Some(ref password) = payload.password { if password.len() < 4 { return Json(ApiResponse::error("Password must be at least 4 characters".to_string())); } let hash = match bcrypt::hash(password, 12) { Ok(h) => h, Err(_) => return Json(ApiResponse::error("Failed to hash password".to_string())), }; sets.push("password_hash = ?"); string_binds.push(hash); has_password = true; } if let Some(mcp) = payload.must_change_password { sets.push("must_change_password = ?"); has_must_change = true; let _ = mcp; // used below in bind } if sets.is_empty() { return Json(ApiResponse::error("No fields to update".to_string())); } let sql = format!("UPDATE users SET {} WHERE id = ?", sets.join(", ")); let mut query = sqlx::query(&sql); for b in &string_binds { query = query.bind(b); } if has_must_change { query = query.bind(payload.must_change_password.unwrap()); } let _ = has_password; // consumed above via string_binds query = query.bind(id); match query.execute(pool).await { Ok(result) => { if result.rows_affected() == 0 { return Json(ApiResponse::error("User not found".to_string())); } // Fetch updated user let row = sqlx::query( "SELECT id, username, display_name, role, must_change_password, created_at FROM users WHERE id = ?", ) .bind(id) .fetch_one(pool) .await; match row { Ok(row) => { let uname: String = row.get("username"); let display_name: Option = row.get("display_name"); Json(ApiResponse::success(serde_json::json!({ "id": row.get::("id"), "username": &uname, "display_name": display_name.as_deref().unwrap_or(&uname), "role": row.get::("role"), "must_change_password": row.get::("must_change_password"), "created_at": row.get::, _>("created_at"), }))) } Err(_) => Json(ApiResponse::success(serde_json::json!({ "message": "User updated" }))), } } Err(e) => { warn!("Failed to update user: {}", e); Json(ApiResponse::error(format!("Failed to update user: {}", e))) } } } pub(super) async fn handle_delete_user( State(state): State, headers: axum::http::HeaderMap, Path(id): Path, ) -> Json> { let (session, _) = match auth::require_admin(&state, &headers).await { Ok((session, new_token)) => (session, new_token), Err(e) => return e, }; let pool = &state.app_state.db_pool; // Don't allow deleting yourself let target_username: Option = sqlx::query_scalar::<_, String>("SELECT username FROM users WHERE id = ?") .bind(id) .fetch_optional(pool) .await .unwrap_or(None); match target_username { None => return Json(ApiResponse::error("User not found".to_string())), Some(ref uname) if uname == &session.username => { return Json(ApiResponse::error("Cannot delete your own account".to_string())); } _ => {} } let result = sqlx::query("DELETE FROM users WHERE id = ?") .bind(id) .execute(pool) .await; match result { Ok(_) => Json(ApiResponse::success(serde_json::json!({ "message": "User deleted" }))), Err(e) => { warn!("Failed to delete user: {}", e); Json(ApiResponse::error(format!("Failed to delete user: {}", e))) } } }