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
@@ -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)
}
}