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:
@@ -56,3 +56,53 @@ pub struct OidcTestResultDto {
|
|||||||
/// Suggested provider name (derived from issuer hostname)
|
/// Suggested provider name (derived from issuer hostname)
|
||||||
pub provider_name_suggestion: Option<String>,
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ pub trait UserStoragePort: Send + Sync + 'static {
|
|||||||
|
|
||||||
/// Finds a user by OIDC provider + subject pair
|
/// Finds a user by OIDC provider + subject pair
|
||||||
async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> Result<User, DomainError>;
|
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())
|
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
|
// OIDC Methods
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -94,4 +94,24 @@ pub trait UserRepository: Send + Sync + 'static {
|
|||||||
|
|
||||||
/// Finds a user by OIDC provider + subject pair
|
/// Finds a user by OIDC provider + subject pair
|
||||||
async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> UserRepositoryResult<User>;
|
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 futures::future::BoxFuture;
|
||||||
|
|
||||||
use crate::domain::entities::user::{User, UserRole};
|
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::application::ports::auth_ports::UserStoragePort;
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::infrastructure::repositories::pg::transaction_utils::with_transaction;
|
use crate::infrastructure::repositories::pg::transaction_utils::with_transaction;
|
||||||
@@ -547,6 +547,67 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_subject"),
|
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
|
// Implementación del puerto de almacenamiento para la capa de aplicación
|
||||||
@@ -603,4 +664,32 @@ impl UserStoragePort for UserPgRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(DomainError::from)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,36 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
routing::{get, put, post},
|
routing::{get, put, post, delete},
|
||||||
extract::{State, Json},
|
extract::{State, Json, Path, Query},
|
||||||
http::{StatusCode, HeaderMap, header},
|
http::{StatusCode, HeaderMap, header},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::common::di::AppState;
|
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;
|
use crate::interfaces::errors::AppError;
|
||||||
|
|
||||||
/// Admin API routes — all require admin role.
|
/// Admin API routes — all require admin role.
|
||||||
pub fn admin_routes() -> Router<AppState> {
|
pub fn admin_routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
// OIDC settings
|
||||||
.route("/settings/oidc", get(get_oidc_settings))
|
.route("/settings/oidc", get(get_oidc_settings))
|
||||||
.route("/settings/oidc", put(save_oidc_settings))
|
.route("/settings/oidc", put(save_oidc_settings))
|
||||||
.route("/settings/oidc/test", post(test_oidc_connection))
|
.route("/settings/oidc/test", post(test_oidc_connection))
|
||||||
.route("/settings/general", get(get_general_settings))
|
.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).
|
/// Validate JWT and require admin role. Returns (user_id, role).
|
||||||
@@ -96,7 +110,7 @@ async fn test_oidc_connection(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/admin/settings/general — system overview
|
/// GET /api/admin/settings/general — system overview (backward compat)
|
||||||
async fn get_general_settings(
|
async fn get_general_settings(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -106,7 +120,7 @@ async fn get_general_settings(
|
|||||||
let auth = state.auth_service.as_ref()
|
let auth = state.auth_service.as_ref()
|
||||||
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
|
.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();
|
let oidc_configured = auth.auth_application_service.oidc_enabled();
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
@@ -116,3 +130,224 @@ async fn get_general_settings(
|
|||||||
"oidc_configured": oidc_configured,
|
"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,
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
|||||||
+435
-183
@@ -3,68 +3,145 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>OxiCloud — Admin Settings</title>
|
<title>OxiCloud — Admin Panel</title>
|
||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f7fa;color:#1a1a2e;line-height:1.6}
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f7fa;color:#1a1a2e;line-height:1.6}
|
||||||
.container{max-width:720px;margin:40px auto;padding:0 20px}
|
.container{max-width:960px;margin:0 auto;padding:20px}
|
||||||
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:32px}
|
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}
|
||||||
header h1{font-size:1.5rem;color:#1a1a2e}
|
header h1{font-size:1.5rem;color:#1a1a2e}
|
||||||
header a{color:#3b82f6;text-decoration:none;font-size:.9rem}
|
header a{color:#3b82f6;text-decoration:none;font-size:.9rem}
|
||||||
header a:hover{text-decoration:underline}
|
header a:hover{text-decoration:underline}
|
||||||
.card{background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);padding:28px;margin-bottom:24px}
|
|
||||||
.card h2{font-size:1.15rem;margin-bottom:20px;color:#1a1a2e;display:flex;align-items:center;gap:8px}
|
/* Tabs */
|
||||||
.form-group{margin-bottom:16px}
|
.tabs{display:flex;gap:4px;margin-bottom:24px;border-bottom:2px solid #e5e7eb;padding-bottom:0}
|
||||||
.form-group label{display:block;font-size:.85rem;font-weight:600;margin-bottom:4px;color:#374151}
|
.tab{padding:10px 20px;cursor:pointer;font-weight:600;font-size:.9rem;color:#6b7280;border:none;background:none;
|
||||||
.form-group input[type="text"],.form-group input[type="password"],.form-group input[type="url"]{
|
border-bottom:2px solid transparent;margin-bottom:-2px;transition:all .2s}
|
||||||
width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:.9rem;transition:border .2s}
|
.tab:hover{color:#3b82f6}
|
||||||
.form-group input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.1)}
|
.tab.active{color:#3b82f6;border-bottom-color:#3b82f6}
|
||||||
.form-group small{color:#6b7280;font-size:.78rem;display:block;margin-top:2px}
|
.tab-content{display:none}
|
||||||
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0}
|
.tab-content.active{display:block}
|
||||||
.toggle-row label{font-size:.9rem;font-weight:500}
|
|
||||||
.switch{position:relative;width:44px;height:24px;flex-shrink:0}
|
/* Cards */
|
||||||
.switch input{opacity:0;width:0;height:0}
|
.card{background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);padding:24px;margin-bottom:20px}
|
||||||
.slider{position:absolute;cursor:pointer;inset:0;background:#d1d5db;border-radius:24px;transition:.3s}
|
.card h2{font-size:1.1rem;margin-bottom:16px;color:#1a1a2e;display:flex;align-items:center;gap:8px}
|
||||||
.slider:before{content:"";position:absolute;height:18px;width:18px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.3s}
|
.card h3{font-size:.95rem;margin:16px 0 10px;color:#374151}
|
||||||
.switch input:checked+.slider{background:#3b82f6}
|
|
||||||
.switch input:checked+.slider:before{transform:translateX(20px)}
|
/* Stats Grid */
|
||||||
.readonly-field{display:flex;align-items:center;gap:8px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:8px 12px;font-family:monospace;font-size:.85rem;word-break:break-all}
|
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:20px}
|
||||||
.readonly-field button{flex-shrink:0;padding:4px 10px;border:1px solid #d1d5db;border-radius:6px;background:#fff;cursor:pointer;font-size:.78rem}
|
.stat-card{padding:16px;background:#f9fafb;border-radius:10px;text-align:center;border:1px solid #e5e7eb}
|
||||||
.readonly-field button:hover{background:#f3f4f6}
|
.stat-value{font-size:1.6rem;font-weight:700;color:#1a1a2e}
|
||||||
details{margin-top:16px;border-top:1px solid #e5e7eb;padding-top:12px}
|
.stat-label{font-size:.75rem;color:#6b7280;text-transform:uppercase;letter-spacing:.05em;margin-top:2px}
|
||||||
details summary{cursor:pointer;font-weight:600;font-size:.9rem;color:#6b7280;padding:4px 0;user-select:none}
|
.stat-card.warn{border-color:#fbbf24;background:#fffbeb}
|
||||||
details[open] summary{margin-bottom:12px}
|
.stat-card.danger{border-color:#ef4444;background:#fef2f2}
|
||||||
.actions{display:flex;gap:12px;margin-top:24px;justify-content:flex-end}
|
.stat-card .stat-value.text-blue{color:#3b82f6}
|
||||||
.btn{padding:10px 20px;border:none;border-radius:8px;font-size:.9rem;font-weight:600;cursor:pointer;transition:all .2s}
|
.stat-card .stat-value.text-green{color:#059669}
|
||||||
|
.stat-card .stat-value.text-orange{color:#d97706}
|
||||||
|
.stat-card .stat-value.text-red{color:#dc2626}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.progress-bar{width:100%;height:8px;background:#e5e7eb;border-radius:4px;overflow:hidden;margin-top:6px}
|
||||||
|
.progress-fill{height:100%;border-radius:4px;transition:width .5s}
|
||||||
|
.progress-fill.green{background:#059669}
|
||||||
|
.progress-fill.orange{background:#d97706}
|
||||||
|
.progress-fill.red{background:#dc2626}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-wrap{overflow-x:auto}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:.85rem}
|
||||||
|
th{text-align:left;padding:10px 12px;border-bottom:2px solid #e5e7eb;color:#6b7280;font-size:.75rem;
|
||||||
|
text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}
|
||||||
|
td{padding:10px 12px;border-bottom:1px solid #f3f4f6;vertical-align:middle}
|
||||||
|
tr:hover{background:#f9fafb}
|
||||||
|
.user-info{display:flex;flex-direction:column;gap:1px}
|
||||||
|
.user-name{font-weight:600;color:#1a1a2e}
|
||||||
|
.user-email{font-size:.8rem;color:#6b7280}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge{display:inline-block;font-size:.7rem;padding:2px 8px;border-radius:10px;font-weight:600}
|
||||||
|
.badge-admin{background:#dbeafe;color:#1d4ed8}
|
||||||
|
.badge-user{background:#f3f4f6;color:#6b7280}
|
||||||
|
.badge-active{background:#d1fae5;color:#065f46}
|
||||||
|
.badge-inactive{background:#fee2e2;color:#991b1b}
|
||||||
|
.badge-oidc{background:#ede9fe;color:#6d28d9}
|
||||||
|
.badge-env{background:#fef3c7;color:#92400e;font-size:.65rem;margin-left:4px}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn{padding:6px 14px;border:none;border-radius:6px;font-size:.8rem;font-weight:600;cursor:pointer;transition:all .15s;white-space:nowrap}
|
||||||
|
.btn-sm{padding:4px 10px;font-size:.75rem}
|
||||||
.btn-primary{background:#3b82f6;color:#fff}
|
.btn-primary{background:#3b82f6;color:#fff}
|
||||||
.btn-primary:hover{background:#2563eb}
|
.btn-primary:hover{background:#2563eb}
|
||||||
.btn-secondary{background:#fff;color:#374151;border:1px solid #d1d5db}
|
.btn-secondary{background:#fff;color:#374151;border:1px solid #d1d5db}
|
||||||
.btn-secondary:hover{background:#f9fafb}
|
.btn-secondary:hover{background:#f9fafb}
|
||||||
|
.btn-danger{background:#fee2e2;color:#991b1b;border:1px solid #fecaca}
|
||||||
|
.btn-danger:hover{background:#fecaca}
|
||||||
|
.btn-success{background:#d1fae5;color:#065f46;border:1px solid #a7f3d0}
|
||||||
|
.btn-success:hover{background:#a7f3d0}
|
||||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||||
.alert{padding:12px 16px;border-radius:8px;font-size:.85rem;margin-top:16px;display:none}
|
.actions-row{display:flex;gap:6px;flex-wrap:wrap}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group{margin-bottom:14px}
|
||||||
|
.form-group label{display:block;font-size:.82rem;font-weight:600;margin-bottom:3px;color:#374151}
|
||||||
|
.form-group input[type="text"],.form-group input[type="password"],.form-group input[type="url"],.form-group input[type="number"],.form-group select{
|
||||||
|
width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:.85rem;transition:border .2s}
|
||||||
|
.form-group input:focus,.form-group select:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.1)}
|
||||||
|
.form-group small{color:#6b7280;font-size:.75rem;display:block;margin-top:2px}
|
||||||
|
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0}
|
||||||
|
.toggle-row label{font-size:.85rem;font-weight:500}
|
||||||
|
.switch{position:relative;width:40px;height:22px;flex-shrink:0}
|
||||||
|
.switch input{opacity:0;width:0;height:0}
|
||||||
|
.slider{position:absolute;cursor:pointer;inset:0;background:#d1d5db;border-radius:22px;transition:.3s}
|
||||||
|
.slider:before{content:"";position:absolute;height:16px;width:16px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.3s}
|
||||||
|
.switch input:checked+.slider{background:#3b82f6}
|
||||||
|
.switch input:checked+.slider:before{transform:translateX(18px)}
|
||||||
|
.readonly-field{display:flex;align-items:center;gap:6px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:6px 12px;font-family:monospace;font-size:.82rem;word-break:break-all}
|
||||||
|
.readonly-field button{flex-shrink:0;padding:3px 8px;border:1px solid #d1d5db;border-radius:4px;background:#fff;cursor:pointer;font-size:.75rem}
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert{padding:10px 14px;border-radius:8px;font-size:.82rem;margin-top:12px;display:none}
|
||||||
.alert-success{background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0;display:block}
|
.alert-success{background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0;display:block}
|
||||||
.alert-error{background:#fef2f2;color:#991b1b;border:1px solid #fecaca;display:block}
|
.alert-error{background:#fef2f2;color:#991b1b;border:1px solid #fecaca;display:block}
|
||||||
.alert-info{background:#eff6ff;color:#1e40af;border:1px solid #bfdbfe;display:block}
|
.alert-info{background:#eff6ff;color:#1e40af;border:1px solid #bfdbfe;display:block}
|
||||||
.badge{display:inline-block;font-size:.7rem;padding:2px 6px;border-radius:4px;background:#fef3c7;color:#92400e;margin-left:6px;font-weight:600}
|
.warning{background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:8px 12px;font-size:.8rem;color:#92400e;margin-top:6px;display:flex;align-items:baseline;gap:4px}
|
||||||
.discovery-result{margin:12px 0;padding:12px;border-radius:8px;font-size:.82rem}
|
|
||||||
|
/* Discovery */
|
||||||
|
.discovery-result{margin:10px 0;padding:10px;border-radius:8px;font-size:.8rem}
|
||||||
.discovery-result.ok{background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46}
|
.discovery-result.ok{background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46}
|
||||||
.discovery-result.fail{background:#fef2f2;border:1px solid #fecaca;color:#991b1b}
|
.discovery-result.fail{background:#fef2f2;border:1px solid #fecaca;color:#991b1b}
|
||||||
.discovery-result dt{font-weight:600;margin-top:6px}
|
.discovery-result dt{font-weight:600;margin-top:4px}
|
||||||
.discovery-result dd{margin-left:0;word-break:break-all}
|
.discovery-result dd{margin-left:0;word-break:break-all}
|
||||||
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
|
||||||
.info-item{padding:12px;background:#f9fafb;border-radius:8px}
|
/* Details */
|
||||||
.info-item .label{font-size:.75rem;color:#6b7280;text-transform:uppercase;letter-spacing:.05em}
|
details{margin-top:12px;border-top:1px solid #e5e7eb;padding-top:10px}
|
||||||
.info-item .value{font-size:1.1rem;font-weight:600;margin-top:4px}
|
details summary{cursor:pointer;font-weight:600;font-size:.85rem;color:#6b7280;padding:4px 0;user-select:none}
|
||||||
.warning{background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:10px 14px;font-size:.82rem;color:#92400e;margin-top:8px;display:flex;align-items:baseline;gap:6px}
|
details[open] summary{margin-bottom:10px}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:1000}
|
||||||
|
.modal{background:#fff;border-radius:12px;padding:24px;width:400px;max-width:90vw;box-shadow:0 10px 40px rgba(0,0,0,.2)}
|
||||||
|
.modal h3{margin-bottom:16px}
|
||||||
|
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
||||||
|
|
||||||
|
/* Quota bar inline */
|
||||||
|
.quota-bar{display:flex;align-items:center;gap:8px}
|
||||||
|
.quota-bar .progress-bar{flex:1;height:6px}
|
||||||
|
.quota-text{font-size:.75rem;color:#6b7280;white-space:nowrap}
|
||||||
|
|
||||||
|
/* Access / loading */
|
||||||
#access-denied{display:none;text-align:center;padding:60px 20px}
|
#access-denied{display:none;text-align:center;padding:60px 20px}
|
||||||
#access-denied h2{color:#991b1b;margin-bottom:8px}
|
#access-denied h2{color:#991b1b;margin-bottom:8px}
|
||||||
#loading{text-align:center;padding:60px;color:#6b7280}
|
#loading{text-align:center;padding:60px;color:#6b7280}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination{display:flex;align-items:center;justify-content:space-between;margin-top:12px;font-size:.82rem;color:#6b7280}
|
||||||
|
.pagination button{padding:4px 12px}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>⚙️ Admin Settings</h1>
|
<h1>⚙️ Admin Panel</h1>
|
||||||
<a href="/">← Back to OxiCloud</a>
|
<a href="/">← Back to OxiCloud</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -72,97 +149,162 @@ details[open] summary{margin-bottom:12px}
|
|||||||
<div id="access-denied"><h2>Access Denied</h2><p>Administrator privileges required.</p><a href="/login.html">Sign in</a></div>
|
<div id="access-denied"><h2>Access Denied</h2><p>Administrator privileges required.</p><a href="/login.html">Sign in</a></div>
|
||||||
|
|
||||||
<div id="main-content" style="display:none">
|
<div id="main-content" style="display:none">
|
||||||
<!-- OIDC / SSO Settings -->
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" onclick="switchTab('dashboard')">📊 Dashboard</button>
|
||||||
|
<button class="tab" onclick="switchTab('users')">👥 Users</button>
|
||||||
|
<button class="tab" onclick="switchTab('oidc')">🔐 SSO / OIDC</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== DASHBOARD TAB ======== -->
|
||||||
|
<div id="tab-dashboard" class="tab-content active">
|
||||||
|
<div class="stats-grid" id="dashboard-stats">
|
||||||
|
<div class="stat-card"><div class="stat-value text-blue" id="ds-total-users">—</div><div class="stat-label">Total Users</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value text-green" id="ds-active-users">—</div><div class="stat-label">Active Users</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value text-blue" id="ds-admin-users">—</div><div class="stat-label">Admins</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value" id="ds-version">—</div><div class="stat-label">Version</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>💾 Storage Overview</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card"><div class="stat-value" id="ds-used">—</div><div class="stat-label">Used</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value" id="ds-quota">—</div><div class="stat-label">Total Quota</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value" id="ds-usage-pct">—</div><div class="stat-label">Usage %</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar"><div class="progress-fill green" id="ds-bar" style="width:0%"></div></div>
|
||||||
|
<div style="margin-top:12px">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card warn" id="ds-warn-card" style="display:none"><div class="stat-value text-orange" id="ds-over80">0</div><div class="stat-label">Users >80% quota</div></div>
|
||||||
|
<div class="stat-card danger" id="ds-danger-card" style="display:none"><div class="stat-value text-red" id="ds-overquota">0</div><div class="stat-label">Users over quota</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>ℹ️ System</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card"><div class="stat-value" id="ds-auth">—</div><div class="stat-label">Auth</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value" id="ds-oidc">—</div><div class="stat-label">OIDC</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value" id="ds-quotas-flag">—</div><div class="stat-label">Quotas</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== USERS TAB ======== -->
|
||||||
|
<div id="tab-users" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<h2>👥 User Management</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Storage</th>
|
||||||
|
<th>Last Login</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="users-tbody"><tr><td colspan="6" style="text-align:center;padding:20px;color:#6b7280">Loading users…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pagination">
|
||||||
|
<span id="users-info">—</span>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-secondary" id="prev-btn" onclick="prevPage()" disabled>← Prev</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" id="next-btn" onclick="nextPage()">Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== OIDC TAB ======== -->
|
||||||
|
<div id="tab-oidc" class="tab-content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>🔐 Single Sign-On (OIDC / SSO)</h2>
|
<h2>🔐 Single Sign-On (OIDC / SSO)</h2>
|
||||||
|
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
<label>Enable SSO Authentication</label>
|
<label>Enable SSO Authentication</label>
|
||||||
<label class="switch"><input type="checkbox" id="oidc-enabled"><span class="slider"></span></label>
|
<label class="switch"><input type="checkbox" id="oidc-enabled"><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="oidc-form" style="display:none">
|
<div id="oidc-form" style="display:none">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Provider Name <span id="badge-provider_name"></span></label>
|
<label>Provider Name <span id="badge-provider_name"></span></label>
|
||||||
<input type="text" id="provider-name" placeholder="e.g., Authentik, Keycloak">
|
<input type="text" id="provider-name" placeholder="e.g., Authentik, Keycloak">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Issuer URL <span id="badge-issuer_url"></span></label>
|
<label>Issuer URL <span id="badge-issuer_url"></span></label>
|
||||||
<input type="url" id="issuer-url" placeholder="https://auth.example.com/application/o/oxicloud/">
|
<input type="url" id="issuer-url" placeholder="https://auth.example.com/application/o/oxicloud/">
|
||||||
<small>The OpenID Connect issuer URL of your identity provider</small>
|
<small>OpenID Connect issuer URL of your identity provider</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-bottom:10px">
|
||||||
<div style="margin-bottom:12px">
|
<button class="btn btn-secondary btn-sm" id="discover-btn" onclick="testConnection()">🔍 Auto-discover</button>
|
||||||
<button class="btn btn-secondary" id="discover-btn" onclick="testConnection()">🔍 Auto-discover</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="discovery-result"></div>
|
<div id="discovery-result"></div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Client ID <span id="badge-client_id"></span></label>
|
<label>Client ID <span id="badge-client_id"></span></label>
|
||||||
<input type="text" id="client-id" placeholder="oxicloud">
|
<input type="text" id="client-id" placeholder="oxicloud">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Client Secret <span id="badge-client_secret"></span></label>
|
<label>Client Secret <span id="badge-client_secret"></span></label>
|
||||||
<input type="password" id="client-secret" placeholder="Leave empty to keep current value">
|
<input type="password" id="client-secret" placeholder="Leave empty to keep current value">
|
||||||
<small id="secret-hint" style="display:none">✓ A client secret is already configured</small>
|
<small id="secret-hint" style="display:none">✓ A client secret is already configured</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Callback URL <small>(copy this to your IdP configuration)</small></label>
|
<label>Callback URL <small>(register in your IdP)</small></label>
|
||||||
<div class="readonly-field">
|
<div class="readonly-field"><span id="callback-url">—</span><button onclick="copyCallback()">📋</button></div>
|
||||||
<span id="callback-url">—</span>
|
|
||||||
<button onclick="copyCallback()">📋 Copy</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Advanced Settings</summary>
|
<summary>Advanced Settings</summary>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Scopes <span id="badge-scopes"></span></label>
|
<label>Scopes <span id="badge-scopes"></span></label>
|
||||||
<input type="text" id="scopes" placeholder="openid profile email">
|
<input type="text" id="scopes" placeholder="openid profile email">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
<label>Auto-provision users on first login</label>
|
<label>Auto-provision users on first login</label>
|
||||||
<label class="switch"><input type="checkbox" id="auto-provision" checked><span class="slider"></span></label>
|
<label class="switch"><input type="checkbox" id="auto-provision" checked><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Admin Groups <span id="badge-admin_groups"></span></label>
|
<label>Admin Groups <span id="badge-admin_groups"></span></label>
|
||||||
<input type="text" id="admin-groups" placeholder="e.g., oxicloud-admins">
|
<input type="text" id="admin-groups" placeholder="e.g., oxicloud-admins">
|
||||||
<small>Comma-separated OIDC group names that map to admin role</small>
|
<small>Comma-separated OIDC group names that map to admin role</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
<label>Disable password login (OIDC only)</label>
|
<label>Disable password login (OIDC only)</label>
|
||||||
<label class="switch"><input type="checkbox" id="disable-password"><span class="slider"></span></label>
|
<label class="switch"><input type="checkbox" id="disable-password"><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="warning" id="password-warning" style="display:none">
|
<div class="warning" id="password-warning" style="display:none">⚠️ This will prevent ALL password-based logins!</div>
|
||||||
⚠️ Enabling this will prevent ALL password-based logins. Make sure OIDC is working first!
|
|
||||||
</div>
|
|
||||||
</details>
|
</details>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:20px;justify-content:flex-end">
|
||||||
<div class="actions">
|
<button class="btn btn-secondary" onclick="testConnection()">🧪 Test</button>
|
||||||
<button class="btn btn-secondary" onclick="testConnection()">🧪 Test Connection</button>
|
<button class="btn btn-primary" id="save-btn" onclick="saveOidcSettings()">💾 Save</button>
|
||||||
<button class="btn btn-primary" id="save-btn" onclick="saveSettings()">💾 Save Settings</button>
|
</div>
|
||||||
|
<div id="oidc-status" class="alert"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="status-message" class="alert"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Info -->
|
<!-- Quota Modal -->
|
||||||
<div class="card">
|
<div id="quota-modal" class="modal-overlay" style="display:none">
|
||||||
<h2>📊 System Information</h2>
|
<div class="modal">
|
||||||
<div class="info-grid" id="general-info">
|
<h3>Update Storage Quota</h3>
|
||||||
<div class="info-item"><div class="label">Version</div><div class="value" id="info-version">—</div></div>
|
<div class="form-group">
|
||||||
<div class="info-item"><div class="label">Users</div><div class="value" id="info-users">—</div></div>
|
<label>User: <strong id="qm-username"></strong></label>
|
||||||
<div class="info-item"><div class="label">Auth</div><div class="value" id="info-auth">—</div></div>
|
|
||||||
<div class="info-item"><div class="label">OIDC</div><div class="value" id="info-oidc">—</div></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>New Quota</label>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<input type="number" id="qm-value" min="0" step="1" style="flex:1">
|
||||||
|
<select id="qm-unit" style="width:80px"><option value="1073741824">GB</option><option value="1048576">MB</option><option value="1099511627776">TB</option></select>
|
||||||
|
</div>
|
||||||
|
<small>Set to 0 for unlimited</small>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="closeQuotaModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveQuota()">Save Quota</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,45 +312,241 @@ details[open] summary{margin-bottom:12px}
|
|||||||
<script>
|
<script>
|
||||||
const API = '/api';
|
const API = '/api';
|
||||||
const token = localStorage.getItem('token') || localStorage.getItem('access_token');
|
const token = localStorage.getItem('token') || localStorage.getItem('access_token');
|
||||||
|
let currentAdminId = '';
|
||||||
// Toggle OIDC form visibility
|
let usersPage = 0;
|
||||||
document.getElementById('oidc-enabled').addEventListener('change', function() {
|
const PAGE_SIZE = 50;
|
||||||
document.getElementById('oidc-form').style.display = this.checked ? 'block' : 'none';
|
let totalUsers = 0;
|
||||||
});
|
|
||||||
|
|
||||||
// Show password warning
|
|
||||||
document.getElementById('disable-password').addEventListener('change', function() {
|
|
||||||
document.getElementById('password-warning').style.display = this.checked ? 'flex' : 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
function headers() {
|
function headers() {
|
||||||
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function showStatus(msg, type) {
|
function formatBytes(bytes) {
|
||||||
const el = document.getElementById('status-message');
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr) {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const secs = Math.floor((now - d) / 1000);
|
||||||
|
if (secs < 60) return 'Just now';
|
||||||
|
if (secs < 3600) return Math.floor(secs/60) + 'm ago';
|
||||||
|
if (secs < 86400) return Math.floor(secs/3600) + 'h ago';
|
||||||
|
if (secs < 2592000) return Math.floor(secs/86400) + 'd ago';
|
||||||
|
return d.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab switching ──
|
||||||
|
function switchTab(name) {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||||
|
document.getElementById('tab-' + name).classList.add('active');
|
||||||
|
event.target.classList.add('active');
|
||||||
|
if (name === 'users') loadUsers();
|
||||||
|
if (name === 'dashboard') loadDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard ──
|
||||||
|
async function loadDashboard() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API + '/admin/dashboard', { headers: headers() });
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const d = await resp.json();
|
||||||
|
document.getElementById('ds-total-users').textContent = d.total_users;
|
||||||
|
document.getElementById('ds-active-users').textContent = d.active_users;
|
||||||
|
document.getElementById('ds-admin-users').textContent = d.admin_users;
|
||||||
|
document.getElementById('ds-version').textContent = 'v' + d.server_version;
|
||||||
|
document.getElementById('ds-used').textContent = formatBytes(d.total_used_bytes);
|
||||||
|
document.getElementById('ds-quota').textContent = formatBytes(d.total_quota_bytes);
|
||||||
|
document.getElementById('ds-usage-pct').textContent = d.storage_usage_percent.toFixed(1) + '%';
|
||||||
|
const bar = document.getElementById('ds-bar');
|
||||||
|
bar.style.width = Math.min(d.storage_usage_percent, 100) + '%';
|
||||||
|
bar.className = 'progress-fill ' + (d.storage_usage_percent > 90 ? 'red' : d.storage_usage_percent > 70 ? 'orange' : 'green');
|
||||||
|
document.getElementById('ds-auth').textContent = d.auth_enabled ? 'Enabled' : 'Disabled';
|
||||||
|
document.getElementById('ds-oidc').textContent = d.oidc_configured ? 'Active' : 'Off';
|
||||||
|
document.getElementById('ds-quotas-flag').textContent = d.quotas_enabled ? 'Enabled' : 'Disabled';
|
||||||
|
|
||||||
|
if (d.users_over_80_percent > 0) {
|
||||||
|
document.getElementById('ds-warn-card').style.display = '';
|
||||||
|
document.getElementById('ds-over80').textContent = d.users_over_80_percent;
|
||||||
|
}
|
||||||
|
if (d.users_over_quota > 0) {
|
||||||
|
document.getElementById('ds-danger-card').style.display = '';
|
||||||
|
document.getElementById('ds-overquota').textContent = d.users_over_quota;
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Dashboard error', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Users ──
|
||||||
|
async function loadUsers() {
|
||||||
|
const tbody = document.getElementById('users-tbody');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:20px;color:#6b7280">Loading…</td></tr>';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API + '/admin/users?limit=' + PAGE_SIZE + '&offset=' + (usersPage * PAGE_SIZE), { headers: headers() });
|
||||||
|
if (!resp.ok) { tbody.innerHTML = '<tr><td colspan="6" style="color:#991b1b">Failed to load users</td></tr>'; return; }
|
||||||
|
const data = await resp.json();
|
||||||
|
totalUsers = data.total;
|
||||||
|
const users = data.users;
|
||||||
|
if (users.length === 0) { tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:20px">No users found</td></tr>'; return; }
|
||||||
|
|
||||||
|
tbody.innerHTML = users.map(u => {
|
||||||
|
const quotaPct = u.storage_quota_bytes > 0 ? ((u.storage_used_bytes / u.storage_quota_bytes) * 100) : 0;
|
||||||
|
const quotaColor = quotaPct > 90 ? 'red' : quotaPct > 70 ? 'orange' : 'green';
|
||||||
|
const quotaText = u.storage_quota_bytes > 0 ? formatBytes(u.storage_used_bytes) + ' / ' + formatBytes(u.storage_quota_bytes) : formatBytes(u.storage_used_bytes) + ' / ∞';
|
||||||
|
const isSelf = u.id === currentAdminId;
|
||||||
|
return '<tr>' +
|
||||||
|
'<td><div class="user-info"><span class="user-name">' + u.username + (isSelf ? ' (you)' : '') + '</span><span class="user-email">' + u.email + '</span></div></td>' +
|
||||||
|
'<td><span class="badge badge-' + u.role + '">' + u.role + '</span></td>' +
|
||||||
|
'<td><span class="badge badge-' + (u.active ? 'active' : 'inactive') + '">' + (u.active ? 'Active' : 'Inactive') + '</span></td>' +
|
||||||
|
'<td><div class="quota-bar"><div class="progress-bar" style="width:80px"><div class="progress-fill ' + quotaColor + '" style="width:' + Math.min(quotaPct, 100) + '%"></div></div><span class="quota-text">' + quotaText + '</span></div></td>' +
|
||||||
|
'<td style="font-size:.8rem;color:#6b7280">' + timeAgo(u.last_login_at) + '</td>' +
|
||||||
|
'<td><div class="actions-row">' +
|
||||||
|
'<button class="btn btn-sm btn-secondary" onclick="openQuotaModal(\'' + u.id + '\',\'' + u.username + '\',' + u.storage_quota_bytes + ')" title="Edit quota">📦</button>' +
|
||||||
|
'<button class="btn btn-sm btn-secondary" onclick="toggleRole(\'' + u.id + '\',\'' + u.role + '\')" title="Toggle role"' + (isSelf ? ' disabled' : '') + '>' + (u.role === 'admin' ? '👤' : '👑') + '</button>' +
|
||||||
|
'<button class="btn btn-sm ' + (u.active ? 'btn-danger' : 'btn-success') + '" onclick="toggleActive(\'' + u.id + '\',' + u.active + ')" title="' + (u.active ? 'Deactivate' : 'Activate') + '"' + (isSelf && u.active ? ' disabled' : '') + '>' + (u.active ? '🚫' : '✅') + '</button>' +
|
||||||
|
'<button class="btn btn-sm btn-danger" onclick="deleteUser(\'' + u.id + '\',\'' + u.username + '\')" title="Delete"' + (isSelf ? ' disabled' : '') + '>🗑</button>' +
|
||||||
|
'</div></td></tr>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.getElementById('users-info').textContent = 'Showing ' + (usersPage * PAGE_SIZE + 1) + '-' + Math.min((usersPage + 1) * PAGE_SIZE, totalUsers) + ' of ' + totalUsers;
|
||||||
|
document.getElementById('prev-btn').disabled = usersPage === 0;
|
||||||
|
document.getElementById('next-btn').disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers;
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" style="color:#991b1b">Error: ' + e.message + '</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() { if (usersPage > 0) { usersPage--; loadUsers(); } }
|
||||||
|
function nextPage() { if ((usersPage + 1) * PAGE_SIZE < totalUsers) { usersPage++; loadUsers(); } }
|
||||||
|
|
||||||
|
async function toggleRole(userId, currentRole) {
|
||||||
|
const newRole = currentRole === 'admin' ? 'user' : 'admin';
|
||||||
|
if (!confirm('Change role to ' + newRole + '?')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API + '/admin/users/' + userId + '/role', {
|
||||||
|
method: 'PUT', headers: headers(), body: JSON.stringify({ role: newRole })
|
||||||
|
});
|
||||||
|
if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || 'Failed'); }
|
||||||
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(userId, currentActive) {
|
||||||
|
const action = currentActive ? 'deactivate' : 'activate';
|
||||||
|
if (!confirm('Are you sure you want to ' + action + ' this user?')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API + '/admin/users/' + userId + '/active', {
|
||||||
|
method: 'PUT', headers: headers(), body: JSON.stringify({ active: !currentActive })
|
||||||
|
});
|
||||||
|
if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || 'Failed'); }
|
||||||
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(userId, username) {
|
||||||
|
if (!confirm('DELETE user "' + username + '"? This cannot be undone!')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API + '/admin/users/' + userId, { method: 'DELETE', headers: headers() });
|
||||||
|
if (resp.ok) { loadUsers(); loadDashboard(); } else { const e = await resp.json(); alert(e.message || 'Failed'); }
|
||||||
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quota Modal ──
|
||||||
|
let quotaUserId = '';
|
||||||
|
function openQuotaModal(userId, username, currentQuota) {
|
||||||
|
quotaUserId = userId;
|
||||||
|
document.getElementById('qm-username').textContent = username;
|
||||||
|
const gb = currentQuota / 1073741824;
|
||||||
|
document.getElementById('qm-unit').value = '1073741824';
|
||||||
|
document.getElementById('qm-value').value = gb > 0 ? Math.round(gb * 10) / 10 : 0;
|
||||||
|
document.getElementById('quota-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
function closeQuotaModal() { document.getElementById('quota-modal').style.display = 'none'; }
|
||||||
|
|
||||||
|
async function saveQuota() {
|
||||||
|
const val = parseFloat(document.getElementById('qm-value').value) || 0;
|
||||||
|
const unit = parseInt(document.getElementById('qm-unit').value);
|
||||||
|
const bytes = Math.round(val * unit);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API + '/admin/users/' + quotaUserId + '/quota', {
|
||||||
|
method: 'PUT', headers: headers(), body: JSON.stringify({ quota_bytes: bytes })
|
||||||
|
});
|
||||||
|
if (resp.ok) { closeQuotaModal(); loadUsers(); loadDashboard(); }
|
||||||
|
else { const e = await resp.json(); alert(e.message || 'Failed'); }
|
||||||
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OIDC settings ──
|
||||||
|
document.getElementById('oidc-enabled').addEventListener('change', function() {
|
||||||
|
document.getElementById('oidc-form').style.display = this.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('disable-password').addEventListener('change', function() {
|
||||||
|
document.getElementById('password-warning').style.display = this.checked ? 'flex' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
function showOidcStatus(msg, type) {
|
||||||
|
const el = document.getElementById('oidc-status');
|
||||||
el.textContent = msg;
|
el.textContent = msg;
|
||||||
el.className = 'alert alert-' + type;
|
el.className = 'alert alert-' + type;
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyCallback() {
|
function copyCallback() {
|
||||||
const text = document.getElementById('callback-url').textContent;
|
const text = document.getElementById('callback-url').textContent;
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text);
|
||||||
const btn = document.querySelector('.readonly-field button');
|
|
||||||
btn.textContent = '✓ Copied!';
|
|
||||||
setTimeout(() => btn.textContent = '📋 Copy', 2000);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
const url = document.getElementById('issuer-url').value.trim();
|
||||||
|
if (!url) { showOidcStatus('Enter an Issuer URL first', 'error'); return; }
|
||||||
|
const btn = document.getElementById('discover-btn');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ …';
|
||||||
|
const resultDiv = document.getElementById('discovery-result');
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API + '/admin/settings/oidc/test', { method: 'POST', headers: headers(), body: JSON.stringify({ issuer_url: url }) });
|
||||||
|
const r = await resp.json();
|
||||||
|
if (r.success) {
|
||||||
|
resultDiv.innerHTML = '<div class="discovery-result ok"><strong>✓ ' + r.message + '</strong><dl><dt>Issuer</dt><dd>' + (r.issuer||'—') + '</dd><dt>Auth Endpoint</dt><dd>' + (r.authorization_endpoint||'—') + '</dd></dl></div>';
|
||||||
|
if (!document.getElementById('provider-name').value && r.provider_name_suggestion) document.getElementById('provider-name').value = r.provider_name_suggestion;
|
||||||
|
} else {
|
||||||
|
resultDiv.innerHTML = '<div class="discovery-result fail"><strong>✗ ' + r.message + '</strong></div>';
|
||||||
|
}
|
||||||
|
} catch (e) { resultDiv.innerHTML = '<div class="discovery-result fail">Error: ' + e.message + '</div>'; }
|
||||||
|
btn.disabled = false; btn.textContent = '🔍 Auto-discover';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOidcSettings() {
|
||||||
|
const btn = document.getElementById('save-btn');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ …';
|
||||||
|
const body = {
|
||||||
|
enabled: document.getElementById('oidc-enabled').checked,
|
||||||
|
issuer_url: document.getElementById('issuer-url').value.trim(),
|
||||||
|
client_id: document.getElementById('client-id').value.trim(),
|
||||||
|
client_secret: document.getElementById('client-secret').value || null,
|
||||||
|
scopes: document.getElementById('scopes').value.trim() || null,
|
||||||
|
auto_provision: document.getElementById('auto-provision').checked,
|
||||||
|
admin_groups: document.getElementById('admin-groups').value.trim() || null,
|
||||||
|
disable_password_login: document.getElementById('disable-password').checked,
|
||||||
|
provider_name: document.getElementById('provider-name').value.trim() || null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API + '/admin/settings/oidc', { method: 'PUT', headers: headers(), body: JSON.stringify(body) });
|
||||||
|
if (resp.ok) { showOidcStatus('Settings saved — OIDC is now ' + (body.enabled ? 'active' : 'disabled'), 'success'); loadDashboard(); }
|
||||||
|
else { const e = await resp.json().catch(()=>({})); showOidcStatus('Error: ' + (e.message || resp.statusText), 'error'); }
|
||||||
|
} catch (e) { showOidcStatus('Network error: ' + e.message, 'error'); }
|
||||||
|
btn.disabled = false; btn.textContent = '💾 Save';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──
|
||||||
async function init() {
|
async function init() {
|
||||||
if (!token) { showAccessDenied(); return; }
|
if (!token) { showAccessDenied(); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify admin access
|
|
||||||
const me = await fetch(API + '/auth/me', { headers: headers() });
|
const me = await fetch(API + '/auth/me', { headers: headers() });
|
||||||
if (!me.ok) { showAccessDenied(); return; }
|
if (!me.ok) { showAccessDenied(); return; }
|
||||||
const user = await me.json();
|
const user = await me.json();
|
||||||
if (user.role !== 'admin') { showAccessDenied(); return; }
|
if (user.role !== 'admin') { showAccessDenied(); return; }
|
||||||
|
currentAdminId = user.id;
|
||||||
|
|
||||||
// Load OIDC settings
|
// Load OIDC settings
|
||||||
const oidcResp = await fetch(API + '/admin/settings/oidc', { headers: headers() });
|
const oidcResp = await fetch(API + '/admin/settings/oidc', { headers: headers() });
|
||||||
@@ -226,30 +564,16 @@ async function init() {
|
|||||||
document.getElementById('password-warning').style.display = s.disable_password_login ? 'flex' : 'none';
|
document.getElementById('password-warning').style.display = s.disable_password_login ? 'flex' : 'none';
|
||||||
document.getElementById('callback-url').textContent = s.callback_url;
|
document.getElementById('callback-url').textContent = s.callback_url;
|
||||||
if (s.client_secret_set) document.getElementById('secret-hint').style.display = 'block';
|
if (s.client_secret_set) document.getElementById('secret-hint').style.display = 'block';
|
||||||
|
|
||||||
// Show env override badges
|
|
||||||
(s.env_overrides || []).forEach(field => {
|
(s.env_overrides || []).forEach(field => {
|
||||||
const badge = document.getElementById('badge-' + field);
|
const badge = document.getElementById('badge-' + field);
|
||||||
if (badge) { badge.innerHTML = '<span class="badge">ENV</span>'; }
|
if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load general info
|
await loadDashboard();
|
||||||
const genResp = await fetch(API + '/admin/settings/general', { headers: headers() });
|
|
||||||
if (genResp.ok) {
|
|
||||||
const g = await genResp.json();
|
|
||||||
document.getElementById('info-version').textContent = g.server_version;
|
|
||||||
document.getElementById('info-users').textContent = g.total_users;
|
|
||||||
document.getElementById('info-auth').textContent = g.auth_enabled ? 'Enabled' : 'Disabled';
|
|
||||||
document.getElementById('info-oidc').textContent = g.oidc_configured ? 'Active' : 'Not configured';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('loading').style.display = 'none';
|
document.getElementById('loading').style.display = 'none';
|
||||||
document.getElementById('main-content').style.display = 'block';
|
document.getElementById('main-content').style.display = 'block';
|
||||||
} catch (e) {
|
} catch (e) { console.error(e); showAccessDenied(); }
|
||||||
console.error(e);
|
|
||||||
showAccessDenied();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAccessDenied() {
|
function showAccessDenied() {
|
||||||
@@ -257,78 +581,6 @@ function showAccessDenied() {
|
|||||||
document.getElementById('access-denied').style.display = 'block';
|
document.getElementById('access-denied').style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testConnection() {
|
|
||||||
const url = document.getElementById('issuer-url').value.trim();
|
|
||||||
if (!url) { showStatus('Please enter an Issuer URL first', 'error'); return; }
|
|
||||||
|
|
||||||
const btn = document.getElementById('discover-btn');
|
|
||||||
btn.disabled = true; btn.textContent = '⏳ Testing…';
|
|
||||||
const resultDiv = document.getElementById('discovery-result');
|
|
||||||
resultDiv.innerHTML = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(API + '/admin/settings/oidc/test', {
|
|
||||||
method: 'POST', headers: headers(),
|
|
||||||
body: JSON.stringify({ issuer_url: url })
|
|
||||||
});
|
|
||||||
const r = await resp.json();
|
|
||||||
|
|
||||||
if (r.success) {
|
|
||||||
resultDiv.innerHTML = '<div class="discovery-result ok">' +
|
|
||||||
'<strong>✓ ' + r.message + '</strong>' +
|
|
||||||
'<dl><dt>Issuer</dt><dd>' + (r.issuer||'—') + '</dd>' +
|
|
||||||
'<dt>Auth Endpoint</dt><dd>' + (r.authorization_endpoint||'—') + '</dd>' +
|
|
||||||
'<dt>Token Endpoint</dt><dd>' + (r.token_endpoint||'—') + '</dd></dl></div>';
|
|
||||||
// Auto-fill provider name if empty
|
|
||||||
if (!document.getElementById('provider-name').value && r.provider_name_suggestion) {
|
|
||||||
document.getElementById('provider-name').value = r.provider_name_suggestion;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
resultDiv.innerHTML = '<div class="discovery-result fail"><strong>✗ ' + r.message + '</strong></div>';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
resultDiv.innerHTML = '<div class="discovery-result fail"><strong>✗ Network error: ' + e.message + '</strong></div>';
|
|
||||||
}
|
|
||||||
btn.disabled = false; btn.textContent = '🔍 Auto-discover';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings() {
|
|
||||||
const btn = document.getElementById('save-btn');
|
|
||||||
btn.disabled = true; btn.textContent = '⏳ Saving…';
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
enabled: document.getElementById('oidc-enabled').checked,
|
|
||||||
issuer_url: document.getElementById('issuer-url').value.trim(),
|
|
||||||
client_id: document.getElementById('client-id').value.trim(),
|
|
||||||
client_secret: document.getElementById('client-secret').value || null,
|
|
||||||
scopes: document.getElementById('scopes').value.trim() || null,
|
|
||||||
auto_provision: document.getElementById('auto-provision').checked,
|
|
||||||
admin_groups: document.getElementById('admin-groups').value.trim() || null,
|
|
||||||
disable_password_login: document.getElementById('disable-password').checked,
|
|
||||||
provider_name: document.getElementById('provider-name').value.trim() || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(API + '/admin/settings/oidc', {
|
|
||||||
method: 'PUT', headers: headers(),
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
if (resp.ok) {
|
|
||||||
showStatus('Settings saved and applied — OIDC is now ' + (body.enabled ? 'active' : 'disabled') + '.', 'success');
|
|
||||||
if (body.client_secret) document.getElementById('secret-hint').style.display = 'block';
|
|
||||||
// Refresh OIDC status
|
|
||||||
const genResp = await fetch(API + '/admin/settings/general', { headers: headers() });
|
|
||||||
if (genResp.ok) { const g = await genResp.json(); document.getElementById('info-oidc').textContent = g.oidc_configured ? 'Active' : 'Not configured'; }
|
|
||||||
} else {
|
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
showStatus('Error: ' + (err.message || resp.statusText), 'error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showStatus('Network error: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
btn.disabled = false; btn.textContent = '💾 Save Settings';
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
init();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user