feat(admin): add user management, quotas, and stats dashboard to admin panel

- Dashboard tab: total users, active users, admins, storage usage, quota warnings
- User management tab: list, edit role, activate/deactivate, update quota, delete
- Self-protection: cannot delete/deactivate/demote yourself
- Paginated user listing (default 50 per page)
- Efficient single-query dashboard stats with direct SQL aggregation
- Quota modal with GB/MB/TB unit selector
- Backend: added methods to UserStoragePort, UserRepository, PgRepository
- Backend: added 7 admin methods to AuthApplicationService
- Backend: added 5 new DTOs for admin operations
- Backend: 7 new endpoints under /api/admin/

Files modified:
- src/application/ports/auth_ports.rs (4 new UserStoragePort methods)
- src/domain/repositories/user_repository.rs (StorageStats + 3 methods)
- src/infrastructure/repositories/pg/user_pg_repository.rs (implementations)
- src/application/services/auth_application_service.rs (admin methods)
- src/application/dtos/settings_dto.rs (5 new DTOs)
- src/interfaces/api/handlers/admin_handler.rs (7 new endpoints)
- static/admin.html (complete UI with 3 tabs: Dashboard, Users, OIDC)
This commit is contained in:
Dionisio
2026-02-11 01:08:00 +01:00
parent 1a1dee9179
commit ccd071911d
7 changed files with 951 additions and 230 deletions
+50
View File
@@ -56,3 +56,53 @@ pub struct OidcTestResultDto {
/// Suggested provider name (derived from issuer hostname)
pub provider_name_suggestion: Option<String>,
}
// ============================================================================
// 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<i64>,
pub offset: Option<i64>,
}
/// 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,
}
+12
View File
@@ -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<User, DomainError>;
/// 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<i64, DomainError>;
}
// ============================================================================
@@ -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<UserDto, DomainError> {
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<bool, DomainError> {
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<i64, DomainError> {
self.user_storage.count_users().await
}
// ========================================================================
// OIDC Methods
// ========================================================================
@@ -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<User>;
/// 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<i64>;
/// Obtiene estadísticas de almacenamiento agregadas
async fn get_storage_stats(&self) -> UserRepositoryResult<StorageStats>;
}
/// 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,
}
@@ -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<i64> {
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<StorageStats> {
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<i64, DomainError> {
UserRepository::count_users(self)
.await
.map_err(DomainError::from)
}
}
+240 -5
View File
@@ -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<AppState> {
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<AppState>,
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<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
headers: HeaderMap,
Query(query): Query<ListUsersQueryDto>,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<UpdateUserRoleDto>,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<UpdateUserActiveDto>,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<UpdateUserQuotaDto>,
) -> Result<impl IntoResponse, AppError> {
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,
}))))
}