use axum::{ Json, extract::{Path, State}, }; use pinakes_core::users::{CreateUserRequest, UpdateUserRequest, UserId}; use crate::{ dto::{ GrantLibraryAccessRequest, RevokeLibraryAccessRequest, UserLibraryResponse, UserResponse, }, error::ApiError, state::AppState, }; /// List all users (admin only) pub async fn list_users( State(state): State, ) -> Result>, ApiError> { let users = state.storage.list_users().await?; Ok(Json(users.into_iter().map(UserResponse::from).collect())) } /// Create a new user (admin only) pub async fn create_user( State(state): State, Json(req): Json, ) -> Result, ApiError> { // Validate username if req.username.is_empty() || req.username.len() > 255 { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "username must be 1-255 characters".into(), ), )); } // Validate password if req.password.len() < 8 { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "password must be at least 8 characters".into(), ), )); } // Hash password let password_hash = pinakes_core::users::auth::hash_password(&req.password)?; // Create user - rely on DB unique constraint for username to avoid TOCTOU // race let user = state .storage .create_user(&req.username, &password_hash, req.role, req.profile) .await .map_err(|e| { // Map unique constraint violations to a user-friendly conflict error let err_str = e.to_string(); if err_str.contains("UNIQUE") || err_str.contains("unique") || err_str.contains("duplicate key") { ApiError(pinakes_core::error::PinakesError::DuplicateHash( "username already exists".into(), )) } else { ApiError(e) } })?; Ok(Json(UserResponse::from(user))) } /// Get a specific user by ID pub async fn get_user( State(state): State, Path(id): Path, ) -> Result, ApiError> { let user_id: UserId = id.parse::().map(UserId::from).map_err(|_| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( "Invalid user ID".into(), )) })?; let user = state.storage.get_user(user_id).await?; Ok(Json(UserResponse::from(user))) } /// Update a user pub async fn update_user( State(state): State, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { let user_id: UserId = id.parse::().map(UserId::from).map_err(|_| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( "Invalid user ID".into(), )) })?; // Hash password if provided let password_hash = if let Some(ref password) = req.password { if password.len() < 8 { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "password must be at least 8 characters".into(), ), )); } Some(pinakes_core::users::auth::hash_password(password)?) } else { None }; let user = state .storage .update_user(user_id, password_hash.as_deref(), req.role, req.profile) .await?; Ok(Json(UserResponse::from(user))) } /// Delete a user (admin only) pub async fn delete_user( State(state): State, Path(id): Path, ) -> Result, ApiError> { let user_id: UserId = id.parse::().map(UserId::from).map_err(|_| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( "Invalid user ID".into(), )) })?; state.storage.delete_user(user_id).await?; Ok(Json(serde_json::json!({"deleted": true}))) } /// Get user's accessible libraries pub async fn get_user_libraries( State(state): State, Path(id): Path, ) -> Result>, ApiError> { let user_id: UserId = id.parse::().map(UserId::from).map_err(|_| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( "Invalid user ID".into(), )) })?; let libraries = state.storage.get_user_libraries(user_id).await?; Ok(Json( libraries .into_iter() .map(UserLibraryResponse::from) .collect(), )) } /// Grant library access to a user (admin only) pub async fn grant_library_access( State(state): State, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { let user_id: UserId = id.parse::().map(UserId::from).map_err(|_| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( "Invalid user ID".into(), )) })?; state .storage .grant_library_access(user_id, &req.root_path, req.permission) .await?; Ok(Json(serde_json::json!({"granted": true}))) } /// Revoke library access from a user (admin only) /// /// Uses a JSON body instead of a path parameter because `root_path` may contain /// slashes that conflict with URL routing. pub async fn revoke_library_access( State(state): State, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { let user_id: UserId = id.parse::().map(UserId::from).map_err(|_| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( "Invalid user ID".into(), )) })?; state .storage .revoke_library_access(user_id, &req.root_path) .await?; Ok(Json(serde_json::json!({"revoked": true}))) }