WP-01: authentication, user management and application shell
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>
This commit is contained in:
114
backend/crates/api/src/users.rs
Normal file
114
backend/crates/api/src/users.rs
Normal file
@ -0,0 +1,114 @@
|
||||
//! /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)
|
||||
}
|
||||
Reference in New Issue
Block a user