//! Admin user management: list, delete, promote/demote. //! //! All handlers are gated by `RequireAdmin` and rely on //! `repo::user::admin_safe_*` for self-protection and the last-admin //! invariant. Audit rows are written inside the same DB transaction as //! the action they record. use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::routing::{delete, get}; use axum::{Json, Router}; use serde::Deserialize; use uuid::Uuid; use crate::api::auth::{validate_password, validate_username}; use crate::api::pagination::PagedResponse; use crate::app::AppState; use crate::auth::extractor::RequireAdmin; use crate::auth::password::hash_password_async; use crate::domain::User; use crate::error::{AppError, AppResult}; use crate::repo; pub fn routes() -> Router { Router::new() .route("/admin/users", get(list_users).post(create_user)) .route( "/admin/users/:id", delete(delete_user).patch(update_user), ) } #[derive(Debug, Deserialize, Default)] pub struct ListUsersParams { #[serde(default)] pub search: Option, #[serde(default = "default_limit")] pub limit: i64, #[serde(default)] pub offset: i64, } fn default_limit() -> i64 { 50 } async fn list_users( State(state): State, _admin: RequireAdmin, Query(params): Query, ) -> AppResult>> { let limit = params.limit.clamp(1, 200); let offset = params.offset.max(0); let (items, total) = repo::user::list_with_total( &state.db, &repo::user::ListUsersQuery { search: params.search.filter(|s| !s.trim().is_empty()), limit, offset, }, ) .await?; Ok(Json(PagedResponse::with_total(items, limit, offset, total))) } #[derive(Debug, Deserialize)] pub struct UpdateUserInput { pub is_admin: Option, } async fn update_user( State(state): State, RequireAdmin(actor): RequireAdmin, Path(id): Path, Json(input): Json, ) -> AppResult> { let Some(is_admin) = input.is_admin else { return Err(AppError::InvalidInput( "no updatable fields supplied".into(), )); }; let updated = repo::user::admin_safe_set_is_admin(&state.db, actor.id, id, is_admin).await?; Ok(Json(updated)) } async fn delete_user( State(state): State, RequireAdmin(actor): RequireAdmin, Path(id): Path, ) -> AppResult { repo::user::admin_safe_delete(&state.db, actor.id, id).await?; Ok(StatusCode::NO_CONTENT) } #[derive(Debug, Deserialize)] pub struct CreateUserInput { pub username: String, pub password: String, /// Defaults to false; admins may mint other admins in a single /// call. Doing it as one POST avoids a second audit row for the /// common "invite a co-admin" flow. #[serde(default)] pub is_admin: bool, } async fn create_user( State(state): State, RequireAdmin(actor): RequireAdmin, Json(input): Json, ) -> AppResult<(StatusCode, Json)> { let username = input.username.trim(); // Reuse the canonical self-register validators so the admin-create // path can never produce a username that self-register would // reject (and vice versa). validate_username(username)?; validate_password(&input.password)?; let pwhash = hash_password_async(input.password.clone()).await?; let user = repo::user::admin_create_user( &state.db, actor.id, username, &pwhash, input.is_admin, ) .await?; Ok((StatusCode::CREATED, Json(user))) }