diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index 8c348224..c3c1a6fd 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -56,3 +56,53 @@ pub struct OidcTestResultDto { /// Suggested provider name (derived from issuer hostname) pub provider_name_suggestion: Option, } + +// ============================================================================ +// Admin User Management DTOs +// ============================================================================ + +/// Request body for updating a user's role +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateUserRoleDto { + pub role: String, +} + +/// Request body for updating a user's active status +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateUserActiveDto { + pub active: bool, +} + +/// Request body for updating a user's storage quota +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateUserQuotaDto { + /// Quota in bytes. Use 0 for unlimited. + pub quota_bytes: i64, +} + +/// Query parameters for listing users +#[derive(Debug, Serialize, Deserialize)] +pub struct ListUsersQueryDto { + pub limit: Option, + pub offset: Option, +} + +/// Dashboard statistics +#[derive(Debug, Serialize, Deserialize)] +pub struct DashboardStatsDto { + // System info + pub server_version: String, + pub auth_enabled: bool, + pub oidc_configured: bool, + pub quotas_enabled: bool, + // User stats + pub total_users: i64, + pub active_users: i64, + pub admin_users: i64, + // Storage stats + pub total_quota_bytes: i64, + pub total_used_bytes: i64, + pub storage_usage_percent: f64, + pub users_over_80_percent: i64, + pub users_over_quota: i64, +} diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 2bda849f..58c94bc8 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -97,6 +97,18 @@ pub trait UserStoragePort: Send + Sync + 'static { /// Finds a user by OIDC provider + subject pair async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> Result; + + /// Activa o desactiva un usuario + async fn set_user_active_status(&self, user_id: &str, active: bool) -> Result<(), DomainError>; + + /// Cambia el rol de un usuario + async fn change_role(&self, user_id: &str, role: &str) -> Result<(), DomainError>; + + /// Actualiza la cuota de almacenamiento de un usuario + async fn update_storage_quota(&self, user_id: &str, quota_bytes: i64) -> Result<(), DomainError>; + + /// Cuenta el número total de usuarios + async fn count_users(&self) -> Result; } // ============================================================================ diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 4f8091b3..66a83bc3 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -605,6 +605,69 @@ impl AuthApplicationService { Ok(users.into_iter().map(UserDto::from).collect()) } + // ======================================================================== + // Admin User Management Methods + // ======================================================================== + + /// Get a single user by ID (for admin panel) + pub async fn get_user_admin(&self, user_id: &str) -> Result { + let user = self.user_storage.get_user_by_id(user_id).await?; + Ok(UserDto::from(user)) + } + + /// Delete a user by ID (admin only) + pub async fn delete_user_admin(&self, user_id: &str) -> Result<(), DomainError> { + // Prevent deleting yourself + let user = self.user_storage.get_user_by_id(user_id).await?; + tracing::info!("Admin deleting user: {} ({})", user.username(), user_id); + self.user_storage.delete_user(user_id).await + } + + /// Activate or deactivate a user (admin only) + pub async fn set_user_active(&self, user_id: &str, active: bool) -> Result<(), DomainError> { + self.user_storage.set_user_active_status(user_id, active).await + } + + /// Change user role (admin only) + pub async fn change_user_role(&self, user_id: &str, role: &str) -> Result<(), DomainError> { + if role != "admin" && role != "user" { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "User", + format!("Invalid role: {}. Must be 'admin' or 'user'", role), + )); + } + self.user_storage.change_role(user_id, role).await + } + + /// Update user's storage quota (admin only) + pub async fn update_user_quota(&self, user_id: &str, quota_bytes: i64) -> Result<(), DomainError> { + if quota_bytes < 0 { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "User", + "Quota must be non-negative".to_string(), + )); + } + self.user_storage.update_storage_quota(user_id, quota_bytes).await + } + + /// Check if a user has enough quota for an upload of the given size + pub async fn check_quota(&self, user_id: &str, additional_bytes: i64) -> Result { + let user = self.user_storage.get_user_by_id(user_id).await?; + let quota = user.storage_quota_bytes(); + if quota <= 0 { + // 0 or negative means unlimited + return Ok(true); + } + Ok(user.storage_used_bytes() + additional_bytes <= quota) + } + + /// Count users efficiently + pub async fn count_users_efficient(&self) -> Result { + self.user_storage.count_users().await + } + // ======================================================================== // OIDC Methods // ======================================================================== diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs index 9a9ebc6e..d6b0b6c3 100644 --- a/src/domain/repositories/user_repository.rs +++ b/src/domain/repositories/user_repository.rs @@ -94,4 +94,24 @@ pub trait UserRepository: Send + Sync + 'static { /// Finds a user by OIDC provider + subject pair async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> UserRepositoryResult; + + /// Actualiza la cuota de almacenamiento de un usuario + async fn update_storage_quota(&self, user_id: &str, quota_bytes: i64) -> UserRepositoryResult<()>; + + /// Cuenta el número total de usuarios + async fn count_users(&self) -> UserRepositoryResult; + + /// Obtiene estadísticas de almacenamiento agregadas + async fn get_storage_stats(&self) -> UserRepositoryResult; +} + +/// Estadísticas de almacenamiento agregadas +#[derive(Debug, Clone)] +pub struct StorageStats { + pub total_users: i64, + pub active_users: i64, + pub total_quota_bytes: i64, + pub total_used_bytes: i64, + pub users_over_80_percent: i64, + pub users_over_quota: i64, } \ No newline at end of file diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index b827dee3..bae15a06 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use futures::future::BoxFuture; use crate::domain::entities::user::{User, UserRole}; -use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError, UserRepositoryResult}; +use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError, UserRepositoryResult, StorageStats}; use crate::application::ports::auth_ports::UserStoragePort; use crate::common::errors::DomainError; use crate::infrastructure::repositories::pg::transaction_utils::with_transaction; @@ -547,6 +547,67 @@ impl UserRepository for UserPgRepository { row.get("oidc_subject"), )) } + + /// Actualiza la cuota de almacenamiento de un usuario + async fn update_storage_quota(&self, user_id: &str, quota_bytes: i64) -> UserRepositoryResult<()> { + sqlx::query( + r#" + UPDATE auth.users + SET + storage_quota_bytes = $2, + updated_at = NOW() + WHERE id = $1 + "# + ) + .bind(user_id) + .bind(quota_bytes) + .execute(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + Ok(()) + } + + /// Cuenta el número total de usuarios + async fn count_users(&self) -> UserRepositoryResult { + let row = sqlx::query( + "SELECT COUNT(*) as count FROM auth.users" + ) + .fetch_one(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + let count: i64 = row.get("count"); + Ok(count) + } + + /// Obtiene estadísticas de almacenamiento agregadas + async fn get_storage_stats(&self) -> UserRepositoryResult { + let row = sqlx::query( + r#" + SELECT + COUNT(*) as total_users, + COUNT(*) FILTER (WHERE active = true) as active_users, + COALESCE(SUM(storage_quota_bytes), 0) as total_quota_bytes, + COALESCE(SUM(storage_used_bytes), 0) as total_used_bytes, + COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes * 0.8) as users_over_80_percent, + COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes) as users_over_quota + FROM auth.users + "# + ) + .fetch_one(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + Ok(StorageStats { + total_users: row.get("total_users"), + active_users: row.get("active_users"), + total_quota_bytes: row.get("total_quota_bytes"), + total_used_bytes: row.get("total_used_bytes"), + users_over_80_percent: row.get("users_over_80_percent"), + users_over_quota: row.get("users_over_quota"), + }) + } } // Implementación del puerto de almacenamiento para la capa de aplicación @@ -603,4 +664,32 @@ impl UserStoragePort for UserPgRepository { .await .map_err(DomainError::from) } + + async fn set_user_active_status(&self, user_id: &str, active: bool) -> Result<(), DomainError> { + UserRepository::set_user_active_status(self, user_id, active) + .await + .map_err(DomainError::from) + } + + async fn change_role(&self, user_id: &str, role: &str) -> Result<(), DomainError> { + let user_role = match role { + "admin" => UserRole::Admin, + _ => UserRole::User, + }; + UserRepository::change_role(self, user_id, user_role) + .await + .map_err(DomainError::from) + } + + async fn update_storage_quota(&self, user_id: &str, quota_bytes: i64) -> Result<(), DomainError> { + UserRepository::update_storage_quota(self, user_id, quota_bytes) + .await + .map_err(DomainError::from) + } + + async fn count_users(&self) -> Result { + UserRepository::count_users(self) + .await + .map_err(DomainError::from) + } } \ No newline at end of file diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 2d671357..fbd73b25 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -1,22 +1,36 @@ use axum::{ Router, - routing::{get, put, post}, - extract::{State, Json}, + routing::{get, put, post, delete}, + extract::{State, Json, Path, Query}, http::{StatusCode, HeaderMap, header}, response::IntoResponse, }; use crate::common::di::AppState; -use crate::application::dtos::settings_dto::{SaveOidcSettingsDto, TestOidcConnectionDto}; +use crate::application::dtos::settings_dto::{ + SaveOidcSettingsDto, TestOidcConnectionDto, + UpdateUserRoleDto, UpdateUserActiveDto, UpdateUserQuotaDto, + ListUsersQueryDto, DashboardStatsDto, +}; use crate::interfaces::errors::AppError; /// Admin API routes — all require admin role. pub fn admin_routes() -> Router { Router::new() + // OIDC settings .route("/settings/oidc", get(get_oidc_settings)) .route("/settings/oidc", put(save_oidc_settings)) .route("/settings/oidc/test", post(test_oidc_connection)) .route("/settings/general", get(get_general_settings)) + // Dashboard / stats + .route("/dashboard", get(get_dashboard_stats)) + // User management + .route("/users", get(list_users)) + .route("/users/{id}", get(get_user)) + .route("/users/{id}", delete(delete_user)) + .route("/users/{id}/role", put(update_user_role)) + .route("/users/{id}/active", put(update_user_active)) + .route("/users/{id}/quota", put(update_user_quota)) } /// Validate JWT and require admin role. Returns (user_id, role). @@ -96,7 +110,7 @@ async fn test_oidc_connection( Ok(Json(result)) } -/// GET /api/admin/settings/general — system overview +/// GET /api/admin/settings/general — system overview (backward compat) async fn get_general_settings( State(state): State, headers: HeaderMap, @@ -106,7 +120,7 @@ async fn get_general_settings( let auth = state.auth_service.as_ref() .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; - let user_count = auth.auth_application_service.count_all_users().await.unwrap_or(0); + let user_count = auth.auth_application_service.count_users_efficient().await.unwrap_or(0); let oidc_configured = auth.auth_application_service.oidc_enabled(); Ok(Json(serde_json::json!({ @@ -116,3 +130,224 @@ async fn get_general_settings( "oidc_configured": oidc_configured, }))) } + +// ============================================================================ +// Dashboard / Stats +// ============================================================================ + +/// GET /api/admin/dashboard — full dashboard statistics +async fn get_dashboard_stats( + State(state): State, + headers: HeaderMap, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let auth_app = &auth.auth_application_service; + + // Get storage stats from repository (single efficient query) + let db_pool = state.db_pool.as_ref() + .ok_or_else(|| AppError::internal_error("Database not available"))?; + + // Use direct SQL for aggregated stats — more efficient than loading all users + let stats_row = sqlx::query( + r#" + SELECT + COUNT(*)::INT8 as total_users, + COUNT(*) FILTER (WHERE active = true)::INT8 as active_users, + COUNT(*) FILTER (WHERE role::text = 'admin')::INT8 as admin_users, + COALESCE(SUM(storage_quota_bytes)::INT8, 0) as total_quota_bytes, + COALESCE(SUM(storage_used_bytes)::INT8, 0) as total_used_bytes, + COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes * 0.8)::INT8 as users_over_80, + COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes)::INT8 as users_over_quota + FROM auth.users + "# + ) + .fetch_one(db_pool.as_ref()) + .await + .map_err(|e| AppError::internal_error(&format!("Database query failed: {}", e)))?; + + use sqlx::Row; + let total_quota: i64 = stats_row.get("total_quota_bytes"); + let total_used: i64 = stats_row.get("total_used_bytes"); + let usage_percent = if total_quota > 0 { + (total_used as f64 / total_quota as f64) * 100.0 + } else { + 0.0 + }; + + let stats = DashboardStatsDto { + server_version: env!("CARGO_PKG_VERSION").to_string(), + auth_enabled: true, + oidc_configured: auth_app.oidc_enabled(), + quotas_enabled: true, // Feature flag could be checked here + total_users: stats_row.get("total_users"), + active_users: stats_row.get("active_users"), + admin_users: stats_row.get("admin_users"), + total_quota_bytes: total_quota, + total_used_bytes: total_used, + storage_usage_percent: (usage_percent * 100.0).round() / 100.0, + users_over_80_percent: stats_row.get("users_over_80"), + users_over_quota: stats_row.get("users_over_quota"), + }; + + Ok(Json(stats)) +} + +// ============================================================================ +// User Management +// ============================================================================ + +/// GET /api/admin/users?limit=50&offset=0 — list all users +async fn list_users( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let limit = query.limit.unwrap_or(100).min(500); + let offset = query.offset.unwrap_or(0); + + let users = auth.auth_application_service.list_users(limit, offset).await + .map_err(|e| AppError::internal_error(&format!("Failed to list users: {}", e)))?; + + let total = auth.auth_application_service.count_users_efficient().await.unwrap_or(0); + + Ok(Json(serde_json::json!({ + "users": users, + "total": total, + "limit": limit, + "offset": offset, + }))) +} + +/// GET /api/admin/users/:id — get single user +async fn get_user( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let user = auth.auth_application_service.get_user_admin(&id).await + .map_err(|e| AppError::not_found(&format!("User not found: {}", e)))?; + + Ok(Json(user)) +} + +/// DELETE /api/admin/users/:id — delete a user +async fn delete_user( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + + // Prevent self-deletion + if admin_id == id { + return Err(AppError::new( + StatusCode::BAD_REQUEST, + "Cannot delete your own account", + "SelfDeletion", + )); + } + + let auth = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + auth.auth_application_service.delete_user_admin(&id).await + .map_err(|e| AppError::internal_error(&format!("Failed to delete user: {}", e)))?; + + Ok((StatusCode::OK, Json(serde_json::json!({ + "message": "User deleted successfully" + })))) +} + +/// PUT /api/admin/users/:id/role — change user role +async fn update_user_role( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Json(dto): Json, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + + // Prevent changing own role + if admin_id == id { + return Err(AppError::new( + StatusCode::BAD_REQUEST, + "Cannot change your own role", + "SelfRoleChange", + )); + } + + let auth = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + auth.auth_application_service.change_user_role(&id, &dto.role).await + .map_err(|e| AppError::internal_error(&format!("Failed to change role: {}", e)))?; + + Ok((StatusCode::OK, Json(serde_json::json!({ + "message": format!("User role updated to '{}'", dto.role) + })))) +} + +/// PUT /api/admin/users/:id/active — activate/deactivate user +async fn update_user_active( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Json(dto): Json, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + + // Prevent deactivating yourself + if admin_id == id && !dto.active { + return Err(AppError::new( + StatusCode::BAD_REQUEST, + "Cannot deactivate your own account", + "SelfDeactivation", + )); + } + + let auth = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + auth.auth_application_service.set_user_active(&id, dto.active).await + .map_err(|e| AppError::internal_error(&format!("Failed to update user status: {}", e)))?; + + let status = if dto.active { "activated" } else { "deactivated" }; + Ok((StatusCode::OK, Json(serde_json::json!({ + "message": format!("User {}", status) + })))) +} + +/// PUT /api/admin/users/:id/quota — update user storage quota +async fn update_user_quota( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Json(dto): Json, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + auth.auth_application_service.update_user_quota(&id, dto.quota_bytes).await + .map_err(|e| AppError::internal_error(&format!("Failed to update quota: {}", e)))?; + + Ok((StatusCode::OK, Json(serde_json::json!({ + "message": "User quota updated", + "quota_bytes": dto.quota_bytes, + })))) +} diff --git a/static/admin.html b/static/admin.html index cc6408e4..68cc39e0 100644 --- a/static/admin.html +++ b/static/admin.html @@ -3,68 +3,145 @@ -OxiCloud — Admin Settings +OxiCloud — Admin Panel
-

⚙️ Admin Settings

+

⚙️ Admin Panel

← Back to OxiCloud
@@ -72,143 +149,404 @@ details[open] summary{margin-bottom:12px}

Access Denied

Administrator privileges required.

Sign in