feat: admin can create users manually + disable registration (#85)

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
This commit is contained in:
Dionisio
2026-02-13 16:46:59 +01:00
parent 12ceea9e54
commit 1be3e4230a
7 changed files with 473 additions and 2 deletions
+22
View File
@@ -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<String>,
/// "admin" or "user"; defaults to "user"
pub role: Option<String>,
/// Storage quota in bytes; 0 = unlimited. If omitted, uses role default.
pub quota_bytes: Option<i64>,
/// Whether the account is active; defaults to true
pub active: Option<bool>,
}
/// 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,
}
@@ -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
}
}
@@ -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<UserDto, DomainError> {
// 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<UserDto, DomainError> {
let user = self.user_storage.get_user_by_id(user_id).await?;
@@ -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<AppState> {
.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<AppState>,
headers: HeaderMap,
Json(dto): Json<AdminCreateUserDto>,
) -> 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.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<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<AdminResetPasswordDto>,
) -> 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.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<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> Result<impl IntoResponse, AppError> {
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,
}))))
}
@@ -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.