From 1be3e4230a320f42a85ed326b3224ba7e996cc55 Mon Sep 17 00:00:00 2001 From: Dionisio Date: Fri, 13 Feb 2026 16:46:59 +0100 Subject: [PATCH] feat: admin can create users manually + disable registration (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - POST /api/admin/users — admin-only user creation endpoint - username & password required, email optional (auto-generated placeholder) - role, quota_bytes, active all configurable - creates personal folder automatically - PUT /api/admin/users/{id}/password — admin password reset - GET/PUT /api/admin/settings/registration — toggle public registration - Supports env var OXICLOUD_DISABLE_REGISTRATION override - Blocks POST /api/auth/register when disabled - AdminCreateUserDto, AdminResetPasswordDto added to settings DTOs - registration_enabled field added to DashboardStatsDto Frontend (admin.html): - 'Create User' button in Users tab with full modal form (username, password, email, role, quota) - 'Reset Password' button per user in actions column - 'Allow public self-registration' toggle in Dashboard > System with warning banner when disabled Closes #85 --- Cargo.lock | 2 +- src/application/dtos/settings_dto.rs | 22 +++ .../services/admin_settings_service.rs | 33 ++++ .../services/auth_application_service.rs | 118 ++++++++++++ src/interfaces/api/handlers/admin_handler.rs | 111 +++++++++++ src/interfaces/api/handlers/auth_handler.rs | 11 ++ static/admin.html | 178 +++++++++++++++++- 7 files changed, 473 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index baf0c6c8..1e67305b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1686,7 +1686,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "oxicloud" -version = "0.3.3" +version = "0.3.4" dependencies = [ "anyhow", "argon2", diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index c3c1a6fd..5a3fb42f 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -80,6 +80,27 @@ pub struct UpdateUserQuotaDto { pub quota_bytes: i64, } +/// Request body for admin-created users +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AdminCreateUserDto { + pub username: String, + pub password: String, + /// Optional — if omitted, a placeholder email is generated + pub email: Option, + /// "admin" or "user"; defaults to "user" + pub role: Option, + /// Storage quota in bytes; 0 = unlimited. If omitted, uses role default. + pub quota_bytes: Option, + /// Whether the account is active; defaults to true + pub active: Option, +} + +/// Request body for admin password reset +#[derive(Debug, Serialize, Deserialize)] +pub struct AdminResetPasswordDto { + pub new_password: String, +} + /// Query parameters for listing users #[derive(Debug, Serialize, Deserialize)] pub struct ListUsersQueryDto { @@ -105,4 +126,5 @@ pub struct DashboardStatsDto { pub storage_usage_percent: f64, pub users_over_80_percent: i64, pub users_over_quota: i64, + pub registration_enabled: bool, } diff --git a/src/application/services/admin_settings_service.rs b/src/application/services/admin_settings_service.rs index 1c64b6a9..a451aa54 100644 --- a/src/application/services/admin_settings_service.rs +++ b/src/application/services/admin_settings_service.rs @@ -267,4 +267,37 @@ impl AdminSettingsService { provider_name_suggestion: suggestion, }) } + + // ======================================================================== + // Registration Control + // ======================================================================== + + /// Check if public self-registration is enabled. + /// Priority: env var `OXICLOUD_DISABLE_REGISTRATION` > DB setting > default (true). + pub async fn get_registration_enabled(&self) -> bool { + // Env var override takes priority + if let Ok(val) = std::env::var("OXICLOUD_DISABLE_REGISTRATION") { + return !matches!(val.to_lowercase().as_str(), "true" | "1" | "yes"); + } + // Check DB setting + match self.settings_repo.get("registration_enabled").await { + Ok(Some(val)) => val == "true", + _ => true, // default: enabled + } + } + + /// Enable or disable public self-registration. + pub async fn set_registration_enabled( + &self, + enabled: bool, + updated_by: &str, + ) -> Result<(), DomainError> { + self.settings_repo.set( + "registration_enabled", + if enabled { "true" } else { "false" }, + "general", + false, + Some(updated_by), + ).await + } } diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index b4be1181..5d08c0c1 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -602,6 +602,124 @@ impl AuthApplicationService { // Admin User Management Methods // ======================================================================== + /// Admin-only: create a user bypassing registration guards. + pub async fn admin_create_user( + &self, + dto: crate::application::dtos::settings_dto::AdminCreateUserDto, + ) -> Result { + // Validate username length + if dto.username.len() < 3 || dto.username.len() > 32 { + return Err(DomainError::new( + ErrorKind::InvalidInput, "User", + "Username must be between 3 and 32 characters".to_string(), + )); + } + + // Check for duplicate username + if self.user_storage.get_user_by_username(&dto.username).await.is_ok() { + return Err(DomainError::new( + ErrorKind::AlreadyExists, "User", + format!("User '{}' already exists", dto.username), + )); + } + + // Email: use provided or generate placeholder + let email = dto.email + .filter(|e| !e.trim().is_empty()) + .unwrap_or_else(|| format!("{}@oxicloud.local", dto.username)); + + // Check email uniqueness + if self.user_storage.get_user_by_email(&email).await.is_ok() { + return Err(DomainError::new( + ErrorKind::AlreadyExists, "User", + format!("Email '{}' is already registered", email), + )); + } + + // Validate password + if dto.password.len() < 8 { + return Err(DomainError::new( + ErrorKind::InvalidInput, "User", + "Password must be at least 8 characters long".to_string(), + )); + } + + // Determine role + let role = match dto.role.as_deref() { + Some("admin") => UserRole::Admin, + _ => UserRole::User, + }; + + // Determine quota + let quota = dto.quota_bytes.unwrap_or_else(|| { + if role == UserRole::Admin { 107_374_182_400 } else { 1_073_741_824 } + }); + + // Hash password + let password_hash = self.password_hasher.hash_password(&dto.password)?; + + // Create domain entity + let user = User::new( + dto.username.clone(), + email, + password_hash, + role, + quota, + ).map_err(|e| DomainError::new( + ErrorKind::InvalidInput, "User", + format!("Error creating user: {}", e), + ))?; + + // Persist + let created = self.user_storage.create_user(user).await?; + + // Deactivate if requested (User::new always sets active=true) + if let Some(false) = dto.active { + self.user_storage.set_user_active_status(created.id(), false).await?; + } + + // Create personal folder + if let Some(folder_service) = &self.folder_service { + let folder_name = format!("My Folder - {}", dto.username); + match folder_service.create_folder(CreateFolderDto { + name: folder_name, + parent_id: None, + }).await { + Ok(folder) => { + tracing::info!( + "Personal folder created for admin-created user {}: {} (ID: {})", + created.id(), folder.name, folder.id + ); + }, + Err(e) => { + tracing::error!( + "Could not create personal folder for user {}: {}", + created.id(), e + ); + } + } + } + + tracing::info!("Admin created user: {} ({})", dto.username, created.id()); + Ok(UserDto::from(created)) + } + + /// Admin-only: reset a user's password. + pub async fn admin_reset_password( + &self, + user_id: &str, + new_password: &str, + ) -> Result<(), DomainError> { + if new_password.len() < 8 { + return Err(DomainError::new( + ErrorKind::InvalidInput, "User", + "Password must be at least 8 characters long".to_string(), + )); + } + let hash = self.password_hasher.hash_password(new_password)?; + self.user_storage.change_password(user_id, &hash).await + } + /// Get a single user by ID (for admin panel) pub async fn get_user_admin(&self, user_id: &str) -> Result { let user = self.user_storage.get_user_by_id(user_id).await?; diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index fbd73b25..f2bb4e0f 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -11,6 +11,7 @@ use crate::application::dtos::settings_dto::{ SaveOidcSettingsDto, TestOidcConnectionDto, UpdateUserRoleDto, UpdateUserActiveDto, UpdateUserQuotaDto, ListUsersQueryDto, DashboardStatsDto, + AdminCreateUserDto, AdminResetPasswordDto, }; use crate::interfaces::errors::AppError; @@ -26,11 +27,16 @@ pub fn admin_routes() -> Router { .route("/dashboard", get(get_dashboard_stats)) // User management .route("/users", get(list_users)) + .route("/users", post(create_user)) .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)) + .route("/users/{id}/password", put(reset_user_password)) + // Registration control + .route("/settings/registration", get(get_registration_setting)) + .route("/settings/registration", put(set_registration_setting)) } /// Validate JWT and require admin role. Returns (user_id, role). @@ -191,6 +197,13 @@ async fn get_dashboard_stats( 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"), + registration_enabled: { + if let Some(svc) = state.admin_settings_service.as_ref() { + svc.get_registration_enabled().await + } else { + true // default: enabled + } + }, }; Ok(Json(stats)) @@ -351,3 +364,101 @@ async fn update_user_quota( "quota_bytes": dto.quota_bytes, })))) } + +// ============================================================================ +// Admin User Creation & Password Reset +// ============================================================================ + +/// POST /api/admin/users — create a new user (admin only) +async fn create_user( + State(state): State, + headers: HeaderMap, + Json(dto): Json, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let user = auth.auth_application_service.admin_create_user(dto).await + .map_err(|e| AppError::new( + StatusCode::BAD_REQUEST, + &format!("Failed to create user: {}", e), + "CreateUserFailed", + ))?; + + Ok((StatusCode::CREATED, Json(user))) +} + +/// PUT /api/admin/users/:id/password — reset a user's password (admin only) +async fn reset_user_password( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Json(dto): Json, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + auth.auth_application_service.admin_reset_password(&id, &dto.new_password).await + .map_err(|e| AppError::new( + StatusCode::BAD_REQUEST, + &format!("Failed to reset password: {}", e), + "ResetPasswordFailed", + ))?; + + Ok((StatusCode::OK, Json(serde_json::json!({ + "message": "Password reset successfully" + })))) +} + +// ============================================================================ +// Registration Control +// ============================================================================ + +/// GET /api/admin/settings/registration — check if public registration is enabled +async fn get_registration_setting( + State(state): State, + headers: HeaderMap, +) -> Result { + admin_guard(&state, &headers).await?; + + let svc = state.admin_settings_service.as_ref() + .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; + + let val = svc.get_registration_enabled().await; + + Ok(Json(serde_json::json!({ + "registration_enabled": val, + }))) +} + +/// PUT /api/admin/settings/registration — enable/disable public registration +async fn set_registration_setting( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + + let enabled = body.get("registration_enabled") + .and_then(|v| v.as_bool()) + .ok_or_else(|| AppError::new( + StatusCode::BAD_REQUEST, + "Missing boolean field 'registration_enabled'", + "InvalidInput", + ))?; + + let svc = state.admin_settings_service.as_ref() + .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; + + svc.set_registration_enabled(enabled, &admin_id).await + .map_err(|e| AppError::internal_error(&format!("Failed to save setting: {}", e)))?; + + Ok((StatusCode::OK, Json(serde_json::json!({ + "message": format!("Public registration {}", if enabled { "enabled" } else { "disabled" }), + "registration_enabled": enabled, + })))) +} diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index c7f4df65..3782a8a3 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -65,6 +65,17 @@ async fn register( "PasswordRegistrationDisabled", )); } + + // Check if public registration has been disabled by the admin + if let Some(admin_svc) = state.admin_settings_service.as_ref() { + if !admin_svc.get_registration_enabled().await { + return Err(AppError::new( + StatusCode::FORBIDDEN, + "Public registration has been disabled by the administrator.", + "RegistrationDisabled", + )); + } + } // Registration logic (admin detection, fresh-install handling, duplicate // checks) is all inside the service layer. Call it directly. diff --git a/static/admin.html b/static/admin.html index 4d2f77e8..6a334879 100644 --- a/static/admin.html +++ b/static/admin.html @@ -262,13 +262,18 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
—
OIDC
—
Quotas
+
+ + +
+
-

User Management

+

User Management

@@ -383,6 +388,66 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a} + + + + + +