Argon2id password hashing, JWT access tokens with rotating HttpOnly refresh cookies and reuse detection, login rate limiting, auth audit log, bootstrap admin, admin-only user CRUD and password reset, OpenAPI spec. Vue login page, auth store with automatic token refresh, route guards, sidebar shell with toasts and placeholder pages, user management page. All tests green: 40 backend, 12 Vitest, 4 Playwright. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
115 lines
3.5 KiB
Rust
115 lines
3.5 KiB
Rust
//! /api/users: admin-only user management.
|
|
use axum::extract::{Path, State};
|
|
use axum::http::StatusCode;
|
|
use axum::routing::{get, post};
|
|
use axum::{Json, Router};
|
|
use domain::user::{NewUser, Role, UserUpdate};
|
|
use serde::Deserialize;
|
|
use utoipa::ToSchema;
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::UserDto;
|
|
use crate::error::ApiError;
|
|
use crate::extract::AdminUser;
|
|
use crate::AppState;
|
|
|
|
pub fn router() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/", get(list).post(create))
|
|
.route("/{id}", get(get_one).patch(update))
|
|
.route("/{id}/password", post(reset_password))
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct CreateUserRequest {
|
|
pub email: String,
|
|
pub display_name: String,
|
|
pub password: String,
|
|
#[schema(value_type = String, example = "admin")]
|
|
pub role: Role,
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct UpdateUserRequest {
|
|
pub display_name: Option<String>,
|
|
#[schema(value_type = String, example = "admin")]
|
|
pub role: Option<Role>,
|
|
pub is_active: Option<bool>,
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct PasswordRequest {
|
|
pub password: String,
|
|
}
|
|
|
|
#[utoipa::path(get, path = "/api/users", tag = "users", security(("bearer" = [])),
|
|
responses((status = 200, body = Vec<UserDto>), (status = 401), (status = 403)))]
|
|
async fn list(State(state): State<AppState>, _: AdminUser) -> Result<Json<Vec<UserDto>>, ApiError> {
|
|
Ok(Json(
|
|
state
|
|
.users
|
|
.list()
|
|
.await?
|
|
.into_iter()
|
|
.map(Into::into)
|
|
.collect(),
|
|
))
|
|
}
|
|
|
|
#[utoipa::path(post, path = "/api/users", tag = "users", security(("bearer" = [])), request_body = CreateUserRequest,
|
|
responses((status = 201, body = UserDto), (status = 409), (status = 422)))]
|
|
async fn create(
|
|
State(state): State<AppState>,
|
|
_: AdminUser,
|
|
Json(req): Json<CreateUserRequest>,
|
|
) -> Result<(StatusCode, Json<UserDto>), ApiError> {
|
|
let user = state
|
|
.users
|
|
.create(NewUser {
|
|
email: req.email,
|
|
display_name: req.display_name,
|
|
password: req.password,
|
|
role: req.role,
|
|
})
|
|
.await?;
|
|
Ok((StatusCode::CREATED, Json(user.into())))
|
|
}
|
|
|
|
#[utoipa::path(get, path = "/api/users/{id}", tag = "users", security(("bearer" = [])),
|
|
responses((status = 200, body = UserDto), (status = 404)))]
|
|
async fn get_one(
|
|
State(state): State<AppState>,
|
|
_: AdminUser,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<UserDto>, ApiError> {
|
|
Ok(Json(state.users.get(id).await?.into()))
|
|
}
|
|
|
|
#[utoipa::path(patch, path = "/api/users/{id}", tag = "users", security(("bearer" = [])), request_body = UpdateUserRequest,
|
|
responses((status = 200, body = UserDto), (status = 404), (status = 409)))]
|
|
async fn update(
|
|
State(state): State<AppState>,
|
|
_: AdminUser,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<UpdateUserRequest>,
|
|
) -> Result<Json<UserDto>, ApiError> {
|
|
let update = UserUpdate {
|
|
display_name: req.display_name,
|
|
role: req.role,
|
|
is_active: req.is_active,
|
|
};
|
|
Ok(Json(state.users.update(id, update).await?.into()))
|
|
}
|
|
|
|
#[utoipa::path(post, path = "/api/users/{id}/password", tag = "users", security(("bearer" = [])), request_body = PasswordRequest,
|
|
responses((status = 204), (status = 404), (status = 422)))]
|
|
async fn reset_password(
|
|
State(state): State<AppState>,
|
|
_: AdminUser,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<PasswordRequest>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
state.users.reset_password(id, &req.password).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|