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:
173
backend/crates/api/src/auth.rs
Normal file
173
backend/crates/api/src/auth.rs
Normal file
@ -0,0 +1,173 @@
|
||||
//! /api/auth: login, refresh, logout, me.
|
||||
use axum::extract::State;
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use domain::user::User;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::error::{simple, ApiError};
|
||||
use crate::extract::{AuthUser, ClientIp};
|
||||
use crate::AppState;
|
||||
|
||||
pub const REFRESH_COOKIE: &str = "refresh_token";
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/login", post(login))
|
||||
.route("/refresh", post(refresh))
|
||||
.route("/logout", post(logout))
|
||||
.route("/me", get(me))
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserDto {
|
||||
pub id: uuid::Uuid,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
#[schema(value_type = String, example = "admin")]
|
||||
pub role: domain::user::Role,
|
||||
pub is_active: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<User> for UserDto {
|
||||
fn from(u: User) -> Self {
|
||||
Self {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
display_name: u.display_name,
|
||||
role: u.role,
|
||||
is_active: u.is_active,
|
||||
created_at: u.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TokenResponse {
|
||||
pub access_token: String,
|
||||
pub user: UserDto,
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/auth/login", request_body = LoginRequest, tag = "auth",
|
||||
responses((status = 200, body = TokenResponse), (status = 401), (status = 403), (status = 429)))]
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
ClientIp(ip): ClientIp,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Response {
|
||||
if !state
|
||||
.login_limiter
|
||||
.check(ip.as_deref().unwrap_or("unknown"))
|
||||
{
|
||||
return simple(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limited",
|
||||
"too many login attempts, try again later",
|
||||
);
|
||||
}
|
||||
match state.auth.login(&req.email, &req.password, ip).await {
|
||||
Ok(pair) => token_response(&state, pair).await,
|
||||
Err(e) => ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/auth/refresh", tag = "auth",
|
||||
responses((status = 200, body = TokenResponse), (status = 401)))]
|
||||
async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
ClientIp(ip): ClientIp,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let Some(token) = cookie(&headers, REFRESH_COOKIE) else {
|
||||
return simple(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid_token",
|
||||
"missing refresh cookie",
|
||||
);
|
||||
};
|
||||
match state.auth.refresh(&token, ip).await {
|
||||
Ok(pair) => token_response(&state, pair).await,
|
||||
Err(e) => ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/auth/logout", tag = "auth", responses((status = 204)))]
|
||||
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Response, ApiError> {
|
||||
if let Some(token) = cookie(&headers, REFRESH_COOKIE) {
|
||||
state.auth.logout(&token).await?;
|
||||
}
|
||||
Ok((
|
||||
[(header::SET_COOKIE, clear_cookie(state.cfg.cookie_secure))],
|
||||
StatusCode::NO_CONTENT,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/auth/me", tag = "auth", security(("bearer" = [])),
|
||||
responses((status = 200, body = UserDto), (status = 401)))]
|
||||
async fn me(AuthUser(user): AuthUser) -> Json<UserDto> {
|
||||
Json(user.into())
|
||||
}
|
||||
|
||||
async fn token_response(state: &AppState, pair: domain::auth::TokenPair) -> Response {
|
||||
let claims = state.auth.authenticate(&pair.access_token).await;
|
||||
match claims {
|
||||
Ok(user) => (
|
||||
[(
|
||||
header::SET_COOKIE,
|
||||
set_cookie(&pair.refresh_token, state.cfg.cookie_secure),
|
||||
)],
|
||||
Json(TokenResponse {
|
||||
access_token: pair.access_token,
|
||||
user: user.into(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|v| v.split(';'))
|
||||
.filter_map(|kv| kv.trim().split_once('='))
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| v.to_string())
|
||||
}
|
||||
|
||||
fn cookie_attrs(secure: bool) -> String {
|
||||
format!(
|
||||
"Path=/api/auth; HttpOnly; SameSite=Strict{}",
|
||||
if secure { "; Secure" } else { "" }
|
||||
)
|
||||
}
|
||||
|
||||
fn set_cookie(token: &str, secure: bool) -> HeaderValue {
|
||||
let max_age = application::auth_service::REFRESH_TOKEN_TTL_DAYS * 24 * 3600;
|
||||
HeaderValue::from_str(&format!(
|
||||
"{REFRESH_COOKIE}={token}; Max-Age={max_age}; {}",
|
||||
cookie_attrs(secure)
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn clear_cookie(secure: bool) -> HeaderValue {
|
||||
HeaderValue::from_str(&format!(
|
||||
"{REFRESH_COOKIE}=; Max-Age=0; {}",
|
||||
cookie_attrs(secure)
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
51
backend/crates/api/src/error.rs
Normal file
51
backend/crates/api/src/error.rs
Normal file
@ -0,0 +1,51 @@
|
||||
//! Maps domain errors to HTTP responses of the shape `{"error": code, "message": text}`.
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use domain::DomainError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError(pub DomainError);
|
||||
|
||||
impl From<DomainError> for ApiError {
|
||||
fn from(e: DomainError) -> Self {
|
||||
ApiError(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
use DomainError::*;
|
||||
let (status, code) = match &self.0 {
|
||||
NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||
EmailTaken => (StatusCode::CONFLICT, "email_taken"),
|
||||
LastAdmin => (StatusCode::CONFLICT, "last_admin"),
|
||||
InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"),
|
||||
InvalidToken => (StatusCode::UNAUTHORIZED, "invalid_token"),
|
||||
InactiveUser => (StatusCode::FORBIDDEN, "inactive_user"),
|
||||
Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation"),
|
||||
Storage(msg) => {
|
||||
tracing::error!("storage error: {msg}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
|
||||
}
|
||||
};
|
||||
let message = if code == "internal" {
|
||||
"internal error".to_string()
|
||||
} else {
|
||||
self.0.to_string()
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({ "error": code, "message": message })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn simple(status: StatusCode, code: &str, message: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({ "error": code, "message": message })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
97
backend/crates/api/src/extract.rs
Normal file
97
backend/crates/api/src/extract.rs
Normal file
@ -0,0 +1,97 @@
|
||||
//! Request extractors: authenticated user, admin user, client IP.
|
||||
use axum::extract::{ConnectInfo, FromRequestParts};
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::Response;
|
||||
use domain::user::User;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::error::simple;
|
||||
use crate::AppState;
|
||||
|
||||
pub struct AuthUser(pub User);
|
||||
pub struct AdminUser(pub User);
|
||||
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let token = parts
|
||||
.headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| {
|
||||
simple(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unauthorized",
|
||||
"missing bearer token",
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.auth
|
||||
.authenticate(token)
|
||||
.await
|
||||
.map(AuthUser)
|
||||
.map_err(|e| crate::error::ApiError(e).into_response_401())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for AdminUser {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let AuthUser(user) = AuthUser::from_request_parts(parts, state).await?;
|
||||
if !user.is_admin() {
|
||||
return Err(simple(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden",
|
||||
"admin role required",
|
||||
));
|
||||
}
|
||||
Ok(AdminUser(user))
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::error::ApiError {
|
||||
/// Auth failures on protected routes are always reported as 401 (inactive users included).
|
||||
fn into_response_401(self) -> Response {
|
||||
simple(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unauthorized",
|
||||
&self.0.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort client IP: `X-Forwarded-For` first hop, else the socket address.
|
||||
pub fn client_ip(parts: &Parts) -> Option<String> {
|
||||
parts
|
||||
.headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.or_else(|| {
|
||||
parts
|
||||
.extensions
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|c| c.0.ip().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub struct ClientIp(pub Option<String>);
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for ClientIp {
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
|
||||
Ok(ClientIp(client_ip(parts)))
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,20 @@
|
||||
//! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests.
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
pub mod openapi;
|
||||
pub mod rate_limit;
|
||||
pub mod test_support;
|
||||
pub mod users;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{AuthService, UserService};
|
||||
use axum::{routing::get, Json, Router};
|
||||
use infrastructure::{
|
||||
Argon2Hasher, DbPool, JwtIssuer, SqliteAuditLog, SqliteRefreshTokens, SqliteUsers,
|
||||
};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
@ -11,6 +23,42 @@ pub use config::Config;
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub cfg: Config,
|
||||
pub auth: Arc<AuthService>,
|
||||
pub users: Arc<UserService>,
|
||||
pub login_limiter: Arc<rate_limit::RateLimiter>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Wire the services on top of a connected database.
|
||||
pub fn new(cfg: Config, pool: DbPool) -> Self {
|
||||
let users = Arc::new(SqliteUsers(pool.clone()));
|
||||
let hasher = Arc::new(Argon2Hasher);
|
||||
let auth = AuthService::new(
|
||||
users.clone(),
|
||||
Arc::new(SqliteRefreshTokens(pool.clone())),
|
||||
Arc::new(SqliteAuditLog(pool)),
|
||||
hasher.clone(),
|
||||
Arc::new(JwtIssuer::new(&cfg.jwt_secret)),
|
||||
);
|
||||
Self {
|
||||
cfg,
|
||||
auth: Arc::new(auth),
|
||||
users: Arc::new(UserService::new(users, hasher)),
|
||||
login_limiter: Arc::new(rate_limit::RateLimiter::new(
|
||||
10,
|
||||
std::time::Duration::from_secs(60),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn bootstrap(&self) -> anyhow::Result<()> {
|
||||
if let Some((email, password)) = &self.cfg.bootstrap_admin {
|
||||
if self.users.bootstrap_admin(email, password).await? {
|
||||
tracing::info!("created bootstrap admin {email}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_app(state: AppState) -> Router {
|
||||
@ -18,6 +66,9 @@ pub fn build_app(state: AppState) -> Router {
|
||||
let spa = ServeDir::new(&state.cfg.frontend_dir).not_found_service(ServeFile::new(index));
|
||||
Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/api/openapi.json", get(openapi::spec))
|
||||
.nest("/api/auth", auth::router())
|
||||
.nest("/api/users", users::router())
|
||||
.fallback_service(spa)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
|
||||
@ -8,8 +8,20 @@ async fn main() -> anyhow::Result<()> {
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
let cfg = Config::from_env()?;
|
||||
if let Some(dir) = cfg
|
||||
.database_url
|
||||
.strip_prefix("sqlite://")
|
||||
.and_then(|p| p.split('?').next())
|
||||
.and_then(|p| std::path::Path::new(p).parent())
|
||||
{
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let pool = infrastructure::connect(&cfg.database_url).await?;
|
||||
let state = AppState::new(cfg.clone(), pool);
|
||||
state.bootstrap().await?;
|
||||
let listener = tokio::net::TcpListener::bind(cfg.bind).await?;
|
||||
tracing::info!("listening on http://{}", cfg.bind);
|
||||
axum::serve(listener, build_app(AppState { cfg })).await?;
|
||||
let app = build_app(state).into_make_service_with_connect_info::<std::net::SocketAddr>();
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
34
backend/crates/api/src/openapi.rs
Normal file
34
backend/crates/api/src/openapi.rs
Normal file
@ -0,0 +1,34 @@
|
||||
use axum::Json;
|
||||
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
||||
use utoipa::{Modify, OpenApi};
|
||||
|
||||
struct BearerAuth;
|
||||
impl Modify for BearerAuth {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
let components = openapi.components.get_or_insert_with(Default::default);
|
||||
components.add_security_scheme(
|
||||
"bearer",
|
||||
SecurityScheme::Http(
|
||||
HttpBuilder::new()
|
||||
.scheme(HttpAuthScheme::Bearer)
|
||||
.bearer_format("JWT")
|
||||
.build(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(title = "SoftVisor Monitoring API", version = "0.1.0"),
|
||||
paths(
|
||||
crate::auth::login, crate::auth::refresh, crate::auth::logout, crate::auth::me,
|
||||
crate::users::list, crate::users::create, crate::users::get_one, crate::users::update, crate::users::reset_password,
|
||||
),
|
||||
modifiers(&BearerAuth)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
pub async fn spec() -> Json<utoipa::openapi::OpenApi> {
|
||||
Json(ApiDoc::openapi())
|
||||
}
|
||||
43
backend/crates/api/src/rate_limit.rs
Normal file
43
backend/crates/api/src/rate_limit.rs
Normal file
@ -0,0 +1,43 @@
|
||||
//! Minimal fixed-window rate limiter keyed by client identifier (IP).
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct RateLimiter {
|
||||
max: u32,
|
||||
window: Duration,
|
||||
hits: Mutex<HashMap<String, (Instant, u32)>>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(max: u32, window: Duration) -> Self {
|
||||
Self {
|
||||
max,
|
||||
window,
|
||||
hits: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the request is allowed.
|
||||
pub fn check(&self, key: &str) -> bool {
|
||||
let mut hits = self.hits.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
hits.retain(|_, (start, _)| now.duration_since(*start) < self.window);
|
||||
let entry = hits.entry(key.to_string()).or_insert((now, 0));
|
||||
entry.1 += 1;
|
||||
entry.1 <= self.max
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blocks_after_max_hits_per_key() {
|
||||
let l = RateLimiter::new(3, Duration::from_secs(60));
|
||||
assert!(l.check("a") && l.check("a") && l.check("a"));
|
||||
assert!(!l.check("a"));
|
||||
assert!(l.check("b"));
|
||||
}
|
||||
}
|
||||
@ -14,5 +14,22 @@ pub fn test_config() -> Config {
|
||||
}
|
||||
|
||||
pub async fn build_test_app() -> Router {
|
||||
build_app(AppState { cfg: test_config() })
|
||||
build_test_app_with(test_config()).await
|
||||
}
|
||||
|
||||
pub async fn build_test_app_with_admin(email: &str, password: &str) -> Router {
|
||||
let cfg = Config {
|
||||
bootstrap_admin: Some((email.into(), password.into())),
|
||||
..test_config()
|
||||
};
|
||||
build_test_app_with(cfg).await
|
||||
}
|
||||
|
||||
async fn build_test_app_with(cfg: Config) -> Router {
|
||||
let pool = infrastructure::connect(&cfg.database_url)
|
||||
.await
|
||||
.expect("db");
|
||||
let state = AppState::new(cfg, pool);
|
||||
state.bootstrap().await.expect("bootstrap");
|
||||
build_app(state)
|
||||
}
|
||||
|
||||
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