Files
guru-connect/server/src/api/users.rs
Mike Swanson 3fc4e1f96a Add user management system with JWT authentication
- Database schema: users, permissions, client_access tables
- Auth: JWT tokens with Argon2 password hashing
- API: login, user CRUD, permission management
- Dashboard: login required, admin Users tab
- Auto-creates initial admin user on first run
2025-12-29 21:00:20 -07:00

593 lines
16 KiB
Rust

//! User management API endpoints (admin only)
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::{hash_password, AdminUser};
use crate::db;
use crate::AppState;
use super::auth::ErrorResponse;
/// User info response
#[derive(Debug, Serialize)]
pub struct UserInfo {
pub id: String,
pub username: String,
pub email: Option<String>,
pub role: String,
pub enabled: bool,
pub created_at: String,
pub last_login: Option<String>,
pub permissions: Vec<String>,
}
/// Create user request
#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub password: String,
pub email: Option<String>,
pub role: String,
pub permissions: Option<Vec<String>>,
}
/// Update user request
#[derive(Debug, Deserialize)]
pub struct UpdateUserRequest {
pub email: Option<String>,
pub role: String,
pub enabled: bool,
pub password: Option<String>,
}
/// Set permissions request
#[derive(Debug, Deserialize)]
pub struct SetPermissionsRequest {
pub permissions: Vec<String>,
}
/// Set client access request
#[derive(Debug, Deserialize)]
pub struct SetClientAccessRequest {
pub client_ids: Vec<String>,
}
/// GET /api/users - List all users
pub async fn list_users(
State(state): State<AppState>,
_admin: AdminUser,
) -> Result<Json<Vec<UserInfo>>, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
let users = db::get_all_users(db.pool())
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to fetch users".to_string(),
}),
)
})?;
let mut result = Vec::new();
for user in users {
let permissions = db::get_user_permissions(db.pool(), user.id)
.await
.unwrap_or_default();
result.push(UserInfo {
id: user.id.to_string(),
username: user.username,
email: user.email,
role: user.role,
enabled: user.enabled,
created_at: user.created_at.to_rfc3339(),
last_login: user.last_login.map(|t| t.to_rfc3339()),
permissions,
});
}
Ok(Json(result))
}
/// POST /api/users - Create new user
pub async fn create_user(
State(state): State<AppState>,
_admin: AdminUser,
Json(request): Json<CreateUserRequest>,
) -> Result<Json<UserInfo>, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
// Validate role
let valid_roles = ["admin", "operator", "viewer"];
if !valid_roles.contains(&request.role.as_str()) {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: format!("Invalid role. Must be one of: {:?}", valid_roles),
}),
));
}
// Validate password
if request.password.len() < 8 {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Password must be at least 8 characters".to_string(),
}),
));
}
// Check if username exists
if db::get_user_by_username(db.pool(), &request.username)
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Database error".to_string(),
}),
)
})?
.is_some()
{
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: "Username already exists".to_string(),
}),
));
}
// Hash password
let password_hash = hash_password(&request.password).map_err(|e| {
tracing::error!("Password hashing error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to hash password".to_string(),
}),
)
})?;
// Create user
let user = db::create_user(
db.pool(),
&request.username,
&password_hash,
request.email.as_deref(),
&request.role,
)
.await
.map_err(|e| {
tracing::error!("Failed to create user: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to create user".to_string(),
}),
)
})?;
// Set initial permissions if provided
let permissions = if let Some(perms) = request.permissions {
db::set_user_permissions(db.pool(), user.id, &perms)
.await
.map_err(|e| {
tracing::error!("Failed to set permissions: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to set permissions".to_string(),
}),
)
})?;
perms
} else {
// Default permissions based on role
let default_perms = match request.role.as_str() {
"admin" => vec!["view", "control", "transfer", "manage_users", "manage_clients"],
"operator" => vec!["view", "control", "transfer"],
"viewer" => vec!["view"],
_ => vec!["view"],
};
let perms: Vec<String> = default_perms.into_iter().map(String::from).collect();
db::set_user_permissions(db.pool(), user.id, &perms)
.await
.ok();
perms
};
tracing::info!("Created user: {} ({})", user.username, user.role);
Ok(Json(UserInfo {
id: user.id.to_string(),
username: user.username,
email: user.email,
role: user.role,
enabled: user.enabled,
created_at: user.created_at.to_rfc3339(),
last_login: None,
permissions,
}))
}
/// GET /api/users/:id - Get user details
pub async fn get_user(
State(state): State<AppState>,
_admin: AdminUser,
Path(id): Path<String>,
) -> Result<Json<UserInfo>, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
let user_id = Uuid::parse_str(&id).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Invalid user ID".to_string(),
}),
)
})?;
let user = db::get_user_by_id(db.pool(), user_id)
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Database error".to_string(),
}),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "User not found".to_string(),
}),
)
})?;
let permissions = db::get_user_permissions(db.pool(), user.id)
.await
.unwrap_or_default();
Ok(Json(UserInfo {
id: user.id.to_string(),
username: user.username,
email: user.email,
role: user.role,
enabled: user.enabled,
created_at: user.created_at.to_rfc3339(),
last_login: user.last_login.map(|t| t.to_rfc3339()),
permissions,
}))
}
/// PUT /api/users/:id - Update user
pub async fn update_user(
State(state): State<AppState>,
admin: AdminUser,
Path(id): Path<String>,
Json(request): Json<UpdateUserRequest>,
) -> Result<Json<UserInfo>, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
let user_id = Uuid::parse_str(&id).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Invalid user ID".to_string(),
}),
)
})?;
// Prevent admin from disabling themselves
if user_id.to_string() == admin.0.user_id && !request.enabled {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Cannot disable your own account".to_string(),
}),
));
}
// Validate role
let valid_roles = ["admin", "operator", "viewer"];
if !valid_roles.contains(&request.role.as_str()) {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: format!("Invalid role. Must be one of: {:?}", valid_roles),
}),
));
}
// Update user
let user = db::update_user(
db.pool(),
user_id,
request.email.as_deref(),
&request.role,
request.enabled,
)
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to update user".to_string(),
}),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "User not found".to_string(),
}),
)
})?;
// Update password if provided
if let Some(password) = request.password {
if password.len() < 8 {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Password must be at least 8 characters".to_string(),
}),
));
}
let password_hash = hash_password(&password).map_err(|e| {
tracing::error!("Password hashing error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to hash password".to_string(),
}),
)
})?;
db::update_user_password(db.pool(), user_id, &password_hash)
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to update password".to_string(),
}),
)
})?;
}
let permissions = db::get_user_permissions(db.pool(), user.id)
.await
.unwrap_or_default();
tracing::info!("Updated user: {}", user.username);
Ok(Json(UserInfo {
id: user.id.to_string(),
username: user.username,
email: user.email,
role: user.role,
enabled: user.enabled,
created_at: user.created_at.to_rfc3339(),
last_login: user.last_login.map(|t| t.to_rfc3339()),
permissions,
}))
}
/// DELETE /api/users/:id - Delete user
pub async fn delete_user(
State(state): State<AppState>,
admin: AdminUser,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
let user_id = Uuid::parse_str(&id).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Invalid user ID".to_string(),
}),
)
})?;
// Prevent admin from deleting themselves
if user_id.to_string() == admin.0.user_id {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Cannot delete your own account".to_string(),
}),
));
}
let deleted = db::delete_user(db.pool(), user_id)
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to delete user".to_string(),
}),
)
})?;
if deleted {
tracing::info!("Deleted user: {}", id);
Ok(StatusCode::NO_CONTENT)
} else {
Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "User not found".to_string(),
}),
))
}
}
/// PUT /api/users/:id/permissions - Set user permissions
pub async fn set_permissions(
State(state): State<AppState>,
_admin: AdminUser,
Path(id): Path<String>,
Json(request): Json<SetPermissionsRequest>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
let user_id = Uuid::parse_str(&id).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Invalid user ID".to_string(),
}),
)
})?;
// Validate permissions
let valid_permissions = ["view", "control", "transfer", "manage_users", "manage_clients"];
for perm in &request.permissions {
if !valid_permissions.contains(&perm.as_str()) {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: format!("Invalid permission: {}. Valid: {:?}", perm, valid_permissions),
}),
));
}
}
db::set_user_permissions(db.pool(), user_id, &request.permissions)
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to set permissions".to_string(),
}),
)
})?;
tracing::info!("Updated permissions for user: {}", id);
Ok(StatusCode::OK)
}
/// PUT /api/users/:id/clients - Set user client access
pub async fn set_client_access(
State(state): State<AppState>,
_admin: AdminUser,
Path(id): Path<String>,
Json(request): Json<SetClientAccessRequest>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
let user_id = Uuid::parse_str(&id).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Invalid user ID".to_string(),
}),
)
})?;
// Parse client IDs
let client_ids: Result<Vec<Uuid>, _> = request
.client_ids
.iter()
.map(|s| Uuid::parse_str(s))
.collect();
let client_ids = client_ids.map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Invalid client ID format".to_string(),
}),
)
})?;
db::set_user_client_access(db.pool(), user_id, &client_ids)
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to set client access".to_string(),
}),
)
})?;
tracing::info!("Updated client access for user: {}", id);
Ok(StatusCode::OK)
}