use axum::{ Router, extract::{DefaultBodyLimit, Json, Multipart, Path, Query, State}, http::{HeaderMap, StatusCode}, response::{ IntoResponse, sse::{Event, KeepAlive, Sse}, }, routing::{delete, get, post, put}, }; use crate::application::dtos::drive_dto::DriveDto; use crate::application::dtos::grant_dto::{GrantDto, RoleDto, SubjectDto, SubjectTypeDto}; use crate::application::dtos::plugin_dto::{ PluginInfoDto, PluginLogEntryDto, PluginLogPageDto, PluginLogQueryDto, PluginRetentionDto, SetEnabledDto, }; use crate::application::dtos::settings_dto::{ AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto, SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto, }; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError}; use crate::common::di::AppState; use crate::domain::repositories::drive_repository::DriveRepository; use crate::domain::services::authorization::{Resource, Subject}; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::admin::require_admin; use std::sync::Arc; use uuid::Uuid; /// Admin API routes — all require admin role. pub fn admin_routes() -> Router> { Router::new() // OIDC settings .route("/settings/oidc", get(get_oidc_settings)) .route("/settings/oidc", put(save_oidc_settings)) .route("/settings/oidc/test", post(test_oidc_connection)) // Storage settings .route("/settings/storage", get(get_storage_settings)) .route("/settings/storage", put(save_storage_settings)) .route("/settings/storage/test", post(test_storage_connection)) // Storage migration .route("/storage/migration", get(get_migration_status)) .route("/storage/migration/start", post(start_migration)) .route("/storage/migration/pause", post(pause_migration)) .route("/storage/migration/resume", post(resume_migration)) .route("/storage/migration/complete", post(complete_migration)) .route("/storage/migration/verify", post(verify_migration)) // Encryption key generation .route( "/settings/storage/generate-key", post(generate_encryption_key), ) // Dashboard / stats .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", put(set_registration_setting)) // Audio metadata .route("/audio/metadata/reextract", post(reextract_audio_metadata)) // Image/video capture metadata (Photos timeline backfill) .route("/photos/metadata/reextract", post(reextract_image_metadata)) // Plugin management .route("/plugins", get(list_plugins)) // Install caps the request body at 32 MiB (overriding the global // multi-GB upload limit) — a plugin bundle is small; the unpack also // enforces a 64 MiB decompressed ceiling. .route( "/plugins", post(install_plugin).layer(DefaultBodyLimit::max(32 * 1024 * 1024)), ) .route("/plugins/{id}/enabled", put(set_plugin_enabled)) .route("/plugins/{id}", delete(delete_plugin)) // Plugin logs + per-plugin retention .route("/plugins/{id}/logs", get(get_plugin_logs)) .route("/plugins/{id}/logs", delete(clear_plugin_logs)) .route("/plugins/{id}/logs/stream", get(stream_plugin_logs)) .route("/plugins/{id}/retention", get(get_plugin_retention)) .route("/plugins/{id}/retention", put(set_plugin_retention)) // SMTP diagnostics .route("/smtp/info", get(get_smtp_info)) .route("/smtp/test", post(send_smtp_test)) // Test-only capture endpoint. The handler short-circuits to 404 // when `OXICLOUD_SMTP_MOCK` is off, so production deployments // can route the path freely without leaking inboxes. .route("/smtp/test/captured", get(get_captured_email)) // Drives — admin-wide view (distinct from `/api/drives` which // is filtered to the caller's role grants). .route("/drives", get(list_all_drives)) .route( "/drives/{id}/members", get(list_drive_members_admin).post(add_drive_member_admin), ) .route( "/drives/{id}/members/{kind}/{sid}", axum::routing::patch(update_drive_member_admin).delete(remove_drive_member_admin), ) } /// Validate JWT and require admin role. Returns (user_id, role). /// /// Thin wrapper over the shared `require_admin` middleware helper so this /// handler keeps a stable signature while the implementation lives next to /// the new `subject_group_handler` that also needs it. async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, String), AppError> { require_admin(state, headers).await } /// GET /api/admin/settings/oidc — get OIDC settings for the admin panel #[utoipa::path( get, path = "/api/admin/settings/oidc", responses( (status = 200, description = "OIDC settings"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn get_oidc_settings( 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 settings = svc .get_oidc_settings() .await .map_err(|e| AppError::internal_error(format!("Failed to load settings: {}", e)))?; Ok(Json(settings)) } /// PUT /api/admin/settings/oidc — save OIDC settings + hot-reload #[utoipa::path( put, path = "/api/admin/settings/oidc", responses( (status = 200, description = "OIDC settings saved"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn save_oidc_settings( State(state): State>, headers: HeaderMap, Json(dto): Json, ) -> Result { let (user_id, _) = admin_guard(&state, &headers).await?; let svc = state .admin_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; svc.save_oidc_settings(dto, user_id) .await .map_err(|e| AppError::internal_error(format!("Failed to save settings: {}", e)))?; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "OIDC settings saved and applied successfully" })), )) } /// POST /api/admin/settings/oidc/test — test OIDC discovery async fn test_oidc_connection( State(state): State>, headers: HeaderMap, Json(dto): Json, ) -> 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 result = svc .test_oidc_connection(dto) .await .map_err(|e| AppError::internal_error(format!("Connection test failed: {}", e)))?; Ok(Json(result)) } // ───────────────────────────────────────────────────── // Storage settings handlers // ───────────────────────────────────────────────────── /// GET /api/admin/settings/storage — get storage backend settings #[utoipa::path( get, path = "/api/admin/settings/storage", responses( (status = 200, description = "Storage settings"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn get_storage_settings( State(state): State>, headers: HeaderMap, ) -> Result { admin_guard(&state, &headers).await?; let svc = state .storage_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Storage settings service not available"))?; let settings = svc .get_storage_settings() .await .map_err(|e| AppError::internal_error(format!("Failed to load storage settings: {}", e)))?; Ok(Json(settings)) } /// PUT /api/admin/settings/storage — save storage backend settings #[utoipa::path( put, path = "/api/admin/settings/storage", responses( (status = 200, description = "Storage settings saved"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn save_storage_settings( State(state): State>, headers: HeaderMap, Json(dto): Json, ) -> Result { let (user_id, _) = admin_guard(&state, &headers).await?; let svc = state .storage_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Storage settings service not available"))?; svc.save_storage_settings(dto, user_id) .await .map_err(|e| AppError::internal_error(format!("Failed to save storage settings: {}", e)))?; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "Storage settings saved successfully" })), )) } /// POST /api/admin/settings/storage/test — test storage backend connection async fn test_storage_connection( State(state): State>, headers: HeaderMap, Json(dto): Json, ) -> Result { admin_guard(&state, &headers).await?; let svc = state .storage_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Storage settings service not available"))?; let result = svc .test_storage_connection(dto) .await .map_err(|e| AppError::internal_error(format!("Storage connection test failed: {}", e)))?; Ok(Json(result)) } // ───────────────────────────────────────────────────── // Storage migration handlers // ───────────────────────────────────────────────────── /// GET /api/admin/storage/migration — current migration progress #[utoipa::path( get, path = "/api/admin/storage/migration", responses( (status = 200, description = "Current migration status"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn get_migration_status( State(state): State>, headers: HeaderMap, ) -> Result { admin_guard(&state, &headers).await?; let s = state.migration_state.read().await; Ok(Json(migration_state_to_dto(&s))) } /// POST /api/admin/storage/migration/start — begin background migration #[utoipa::path( post, path = "/api/admin/storage/migration/start", responses( (status = 200, description = "Migration started"), (status = 400, description = "Migration already running"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn start_migration( State(state): State>, headers: HeaderMap, Json(dto): Json, ) -> Result { use crate::infrastructure::services::migration_blob_backend::MigrationStatus; admin_guard(&state, &headers).await?; // Check not already running. { let s = state.migration_state.read().await; if s.status == MigrationStatus::Running { return Err(AppError::bad_request("A migration is already running")); } } let pool = state .db_pool .clone() .ok_or_else(|| AppError::internal_error("Database not available"))?; let source = state.core.dedup_service.backend().clone(); let svc = state .storage_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Storage settings service not available"))?; // Build target backend from saved settings. let effective = svc .load_effective_storage_config() .await .map_err(|e| AppError::internal_error(format!("Failed to load storage config: {}", e)))?; let target = build_backend_from_config(&effective) .map_err(|e| AppError::internal_error(format!("Failed to build target backend: {}", e)))?; target .initialize() .await .map_err(|e| AppError::internal_error(format!("Target backend init failed: {}", e)))?; let concurrency = dto.concurrency.unwrap_or(4).clamp(1, 16); let migration_state = state.migration_state.clone(); // Spawn the background migration job. tokio::spawn(async move { if let Err(e) = crate::infrastructure::services::migration_job::run_migration( source, target, pool, migration_state, concurrency, ) .await { tracing::error!("Migration job error: {}", e); } }); Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "Migration started" })), )) } /// POST /api/admin/storage/migration/pause — pause running migration #[utoipa::path( post, path = "/api/admin/storage/migration/pause", responses( (status = 200, description = "Migration paused"), (status = 400, description = "No running migration"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn pause_migration( State(state): State>, headers: HeaderMap, ) -> Result { use crate::infrastructure::services::migration_blob_backend::MigrationStatus; admin_guard(&state, &headers).await?; let mut s = state.migration_state.write().await; if s.status != MigrationStatus::Running { return Err(AppError::bad_request("No running migration to pause")); } s.status = MigrationStatus::Paused; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "Migration paused" })), )) } /// POST /api/admin/storage/migration/resume — resume paused migration #[utoipa::path( post, path = "/api/admin/storage/migration/resume", responses( (status = 200, description = "Migration resumed"), (status = 400, description = "No paused migration"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn resume_migration( State(state): State>, headers: HeaderMap, ) -> Result { use crate::infrastructure::services::migration_blob_backend::MigrationStatus; admin_guard(&state, &headers).await?; // Set status back to Running — the background task checks on each blob. let mut s = state.migration_state.write().await; if s.status != MigrationStatus::Paused { return Err(AppError::bad_request("No paused migration to resume")); } s.status = MigrationStatus::Running; Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "Migration resumed" })), )) } /// POST /api/admin/storage/migration/complete — finalize migration #[utoipa::path( post, path = "/api/admin/storage/migration/complete", responses( (status = 200, description = "Migration finalized"), (status = 400, description = "Migration not completed"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn complete_migration( State(state): State>, headers: HeaderMap, ) -> Result { use crate::infrastructure::services::migration_blob_backend::MigrationStatus; admin_guard(&state, &headers).await?; let s = state.migration_state.read().await; if s.status != MigrationStatus::Completed { return Err(AppError::bad_request( "Migration must be completed (100%) before finalizing", )); } drop(s); // Mark as idle — the admin has acknowledged completion. let mut s = state.migration_state.write().await; s.status = MigrationStatus::Idle; Ok(( StatusCode::OK, Json( serde_json::json!({ "message": "Migration finalized. Restart the server to use the new backend." }), ), )) } /// POST /api/admin/storage/migration/verify — run integrity check #[utoipa::path( post, path = "/api/admin/storage/migration/verify", responses( (status = 200, description = "Verification result"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 500, description = "Verification failed") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn verify_migration( State(state): State>, headers: HeaderMap, Json(dto): Json, ) -> Result { admin_guard(&state, &headers).await?; let pool = state .db_pool .clone() .ok_or_else(|| AppError::internal_error("Database not available"))?; let svc = state .storage_settings_service .as_ref() .ok_or_else(|| AppError::internal_error("Storage settings service not available"))?; let effective = svc .load_effective_storage_config() .await .map_err(|e| AppError::internal_error(format!("Failed to load storage config: {}", e)))?; let target = build_backend_from_config(&effective) .map_err(|e| AppError::internal_error(format!("Failed to build target backend: {}", e)))?; target .initialize() .await .map_err(|e| AppError::internal_error(format!("Target backend init failed: {}", e)))?; let sample_size = dto.sample_size.unwrap_or(100).clamp(1, 1000); let result = crate::infrastructure::services::migration_job::verify_migration(target, pool, sample_size) .await .map_err(|e| AppError::internal_error(format!("Verification failed: {}", e)))?; Ok(Json(result)) } /// Helper: convert MigrationState to DTO for JSON serialization. fn migration_state_to_dto( s: &crate::infrastructure::services::migration_blob_backend::MigrationState, ) -> MigrationStateDto { let throughput = match (s.started_at, s.migrated_bytes) { (Some(start), bytes) if bytes > 0 => { let elapsed = chrono::Utc::now() .signed_duration_since(start) .num_seconds() .max(1) as f64; Some(bytes as f64 / elapsed) } _ => None, }; MigrationStateDto { status: format!("{:?}", s.status).to_lowercase(), total_blobs: s.total_blobs, migrated_blobs: s.migrated_blobs, migrated_bytes: s.migrated_bytes, failed_blobs: s.failed_blobs.clone(), started_at: s.started_at.map(|d| d.to_rfc3339()), completed_at: s.completed_at.map(|d| d.to_rfc3339()), throughput_bytes_per_sec: throughput, } } /// POST /api/admin/settings/storage/generate-key — generate a random AES-256 key. #[utoipa::path( post, path = "/api/admin/settings/storage/generate-key", responses( (status = 200, description = "Generated AES-256 key"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn generate_encryption_key( State(state): State>, headers: HeaderMap, ) -> Result { admin_guard(&state, &headers).await?; let key = crate::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend::generate_key( ); let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key); Ok(Json(serde_json::json!({ "key": key_b64, "warning": "Store this key securely. If lost, encrypted data is IRRECOVERABLY LOST." }))) } /// Helper: build a BlobStorageBackend from StorageConfig. fn build_backend_from_config( config: &crate::common::config::StorageConfig, ) -> Result< std::sync::Arc, String, > { match config.backend { crate::common::config::StorageBackendType::Local => Ok(std::sync::Arc::new( crate::infrastructure::services::local_blob_backend::LocalBlobBackend::new( std::path::Path::new(&config.root_dir), ), )), crate::common::config::StorageBackendType::S3 => { let s3 = config.s3.as_ref().ok_or("S3 config missing")?; Ok(std::sync::Arc::new( crate::infrastructure::services::s3_blob_backend::S3BlobBackend::new(s3), )) } crate::common::config::StorageBackendType::Azure => { let az = config.azure.as_ref().ok_or("Azure config missing")?; Ok(std::sync::Arc::new( crate::infrastructure::services::azure_blob_backend::AzureBlobBackend::new(az), )) } } } // ============================================================================ // Dashboard / Stats // ============================================================================ /// GET /api/admin/dashboard — full dashboard statistics #[utoipa::path( get, path = "/api/admin/dashboard", responses( (status = 200, description = "Dashboard statistics"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn get_dashboard_stats( State(state): State>, headers: HeaderMap, ) -> Result { 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"), registration_enabled: { if let Some(svc) = state.admin_settings_service.as_ref() { svc.get_registration_enabled().await } else { true // default: enabled } }, }; Ok(Json(stats)) } // ============================================================================ // User Management // ============================================================================ /// GET /api/admin/users?limit=50&offset=0 — list all users #[utoipa::path( get, path = "/api/admin/users", params( ("limit" = Option, Query, description = "Max users to return (default 100, max 500)"), ("offset" = Option, Query, description = "Pagination offset") ), responses( (status = 200, description = "List of users"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn list_users( State(state): State>, headers: HeaderMap, Query(query): Query, ) -> Result { 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 #[utoipa::path( get, path = "/api/admin/users/{id}", params(("id" = String, Path, description = "User UUID")), responses( (status = 200, description = "User details"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 404, description = "User not found") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn get_user( State(state): State>, headers: HeaderMap, Path(id): Path, ) -> Result { admin_guard(&state, &headers).await?; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; 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 #[utoipa::path( delete, path = "/api/admin/users/{id}", params(("id" = String, Path, description = "User UUID")), responses( (status = 200, description = "User deleted"), (status = 400, description = "Cannot delete own account"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn delete_user( State(state): State>, headers: HeaderMap, Path(id): Path, ) -> Result { let (admin_id, _) = admin_guard(&state, &headers).await?; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; // 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 #[utoipa::path( put, path = "/api/admin/users/{id}/role", params(("id" = String, Path, description = "User UUID")), responses( (status = 200, description = "Role updated"), (status = 400, description = "Cannot change own role"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn update_user_role( State(state): State>, headers: HeaderMap, Path(id): Path, Json(dto): Json, ) -> Result { let (admin_id, _) = admin_guard(&state, &headers).await?; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; // 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 #[utoipa::path( put, path = "/api/admin/users/{id}/active", params(("id" = String, Path, description = "User UUID")), responses( (status = 200, description = "User active status updated"), (status = 400, description = "Cannot deactivate own account"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn update_user_active( State(state): State>, headers: HeaderMap, Path(id): Path, Json(dto): Json, ) -> Result { let (admin_id, _) = admin_guard(&state, &headers).await?; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; // 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 #[utoipa::path( put, path = "/api/admin/users/{id}/quota", params(("id" = String, Path, description = "User UUID")), responses( (status = 200, description = "Quota updated"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn update_user_quota( State(state): State>, headers: HeaderMap, Path(id): Path, Json(dto): Json, ) -> Result { admin_guard(&state, &headers).await?; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; 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, })), )) } // ============================================================================ // Admin User Creation & Password Reset // ============================================================================ /// POST /api/admin/users — create a new user (admin only) #[utoipa::path( post, path = "/api/admin/users", responses( (status = 201, description = "User created"), (status = 400, description = "Invalid user data"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub 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) #[utoipa::path( put, path = "/api/admin/users/{id}/password", params(("id" = String, Path, description = "User UUID")), responses( (status = 200, description = "Password reset"), (status = 400, description = "Invalid password"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn reset_user_password( State(state): State>, headers: HeaderMap, Path(id): Path, Json(dto): Json, ) -> Result { admin_guard(&state, &headers).await?; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; 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 // ============================================================================ /// PUT /api/admin/settings/registration — enable/disable public registration #[utoipa::path( put, path = "/api/admin/settings/registration", responses( (status = 200, description = "Registration setting updated"), (status = 400, description = "Missing field"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required") ), security(("bearerAuth" = [])), tag = "admin" )] pub 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, })), )) } async fn reextract_audio_metadata( State(state): State>, headers: HeaderMap, ) -> Result { admin_guard(&state, &headers).await?; let audio_service = state .applications .audio_metadata_service .as_ref() .ok_or_else(|| AppError::internal_error("Audio metadata service not available"))?; let result = audio_service .reextract_all_audio_metadata() .await .map_err(|e| { AppError::internal_error(format!("Failed to re-extract audio metadata: {}", e)) })?; Ok(Json(serde_json::json!({ "message": "Audio metadata extraction complete", "total": result.total, "processed": result.processed, "failed": result.failed, }))) } /// Backfill image/video capture dates (EXIF / container creation time) into /// `storage.file_metadata` for every existing media file, re-bucketing the /// Photos timeline by real capture date. Safe to re-run (idempotent upsert). async fn reextract_image_metadata( State(state): State>, headers: HeaderMap, ) -> Result { admin_guard(&state, &headers).await?; let result = state .applications .media_metadata_service .reextract_all_image_metadata() .await .map_err(|e| { AppError::internal_error(format!("Failed to re-extract capture metadata: {}", e)) })?; Ok(Json(serde_json::json!({ "message": "Image/video capture-metadata extraction complete", "total": result.total, "processed": result.processed, "failed": result.failed, }))) } // ───────────────────────────────────────────────────── // SMTP diagnostics // ───────────────────────────────────────────────────── // // The SMTP backend is configured exclusively via OXICLOUD_SMTP_* env // vars (see docs/config/env.md). The admin UI uses these two endpoints // purely for diagnostics: // - `get_smtp_info` shows the current runtime config (read-only — no // write endpoint exists; operators edit `.env` and restart). // - `send_smtp_test` sends a hardcoded confirmation mail to a // recipient supplied by the admin, returning the SMTP server's // response so the operator can correlate it with their relay logs. /// GET /api/admin/smtp/info — read-only view of the running SMTP config. #[utoipa::path( get, path = "/api/admin/smtp/info", responses( (status = 200, description = "Current SMTP settings", body = SmtpInfoDto), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), ), security(("bearerAuth" = [])), tag = "admin" )] async fn get_smtp_info( State(state): State>, headers: HeaderMap, ) -> Result { admin_guard(&state, &headers).await?; let smtp = &state.core.config.smtp; let info = SmtpInfoDto { enabled: smtp.is_enabled() && state.email_sender.is_some(), host: smtp.host.clone(), port: smtp.port, tls: match smtp.tls { crate::common::config::SmtpTlsMode::Starttls => "starttls".to_string(), crate::common::config::SmtpTlsMode::Tls => "tls".to_string(), crate::common::config::SmtpTlsMode::None => "none".to_string(), }, from: smtp.from.clone(), user_state: if smtp.user.is_empty() { "" } else { "" }, }; Ok(Json(info)) } /// GET /api/admin/smtp/test/captured?to= — test-only inbox lookup. /// /// Returns the most recently captured outbound message for `to` when /// `OXICLOUD_SMTP_MOCK=true`. In production / non-mock mode this /// returns 404 to keep the endpoint inert. async fn get_captured_email( State(state): State>, headers: HeaderMap, Query(params): Query, ) -> Result { admin_guard(&state, &headers).await?; if !std::env::var("OXICLOUD_SMTP_MOCK") .map(|v| v == "true" || v == "1") .unwrap_or(false) { return Err(AppError::not_found( "Capture endpoint is only available when OXICLOUD_SMTP_MOCK=true", )); } let recipient = params.to.trim(); if recipient.is_empty() { return Err(AppError::bad_request("`to` query parameter is required")); } let Some(mock) = state.mock_email_sender.as_ref() else { return Err(AppError::not_found( "Mock sender is not active (set OXICLOUD_SMTP_MOCK=true)", )); }; match mock.last_for(recipient).await { Some(captured) => Ok(Json((*captured).clone())), None => Err(AppError::not_found(format!( "No captured message for '{}'", recipient ))), } } #[derive(Debug, serde::Deserialize)] struct CapturedEmailQuery { to: String, } /// POST /api/admin/smtp/test — send a diagnostic email to `dto.to`. /// /// Returns 200 regardless of SMTP outcome; the body's `success` flag /// + `code`/`message` (or `error`) tell the frontend what to render. /// This keeps SMTP-level failures (4xx/5xx replies, connection /// timeouts) as ordinary diagnostic data rather than HTTP errors. #[utoipa::path( post, path = "/api/admin/smtp/test", request_body = SendSmtpTestDto, responses( (status = 200, description = "Send attempt completed", body = SmtpTestResultDto), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 503, description = "SMTP not configured"), ), security(("bearerAuth" = [])), tag = "admin" )] async fn send_smtp_test( State(state): State>, headers: HeaderMap, Json(dto): Json, ) -> Result { let (admin_id, _) = admin_guard(&state, &headers).await?; let recipient = dto.to.trim().to_string(); if recipient.is_empty() { return Err(AppError::bad_request("Recipient address is required")); } let sender = state.email_sender.as_ref().ok_or_else(|| { AppError::new( StatusCode::SERVICE_UNAVAILABLE, "SMTP is not configured (set OXICLOUD_SMTP_HOST in .env to enable)", "ServiceUnavailable", ) })?; let message = crate::application::ports::email_sender::EmailMessage { to: recipient.clone(), subject: "OxiCloud SMTP test".to_string(), text_body: format!( "This is a diagnostic message sent from your OxiCloud instance.\n\ \n\ If you are reading this, your SMTP relay accepted the message — \ outbound email is wired up correctly.\n\ \n\ Triggered by admin user id {} on {}.\n", admin_id, chrono::Utc::now().to_rfc3339(), ), html_body: None, }; tracing::info!( target: "audit", event = "smtp.test_send", admin_id = %admin_id, recipient = %recipient, ); let result = match sender.send(message).await { Ok(outcome) => { tracing::info!( target: "audit", event = "smtp.test_send_ok", admin_id = %admin_id, recipient = %recipient, code = outcome.code, message = %outcome.message, ); SmtpTestResultDto { success: true, code: Some(outcome.code), message: Some(outcome.message), error: None, } } Err(e) => { tracing::warn!( target: "audit", event = "smtp.test_send_failed", admin_id = %admin_id, recipient = %recipient, error = %e.message, ); SmtpTestResultDto { success: false, code: None, message: None, error: Some(e.message), } } }; Ok(Json(result)) } // ---- Plugin management ----------------------------------------------------- /// Resolve the plugin-management port, or 503 when plugins are compiled out or /// disabled via `OXICLOUD_ENABLE_PLUGINS`. The admin UI treats this 503 as the /// "plugins disabled" state rather than an error. fn plugin_mgmt(state: &AppState) -> Result<&Arc, AppError> { state.plugin_management.as_ref().ok_or_else(|| { AppError::new( StatusCode::SERVICE_UNAVAILABLE, "Plugins are disabled", "PluginsDisabled", ) }) } /// Map a management-layer error to an HTTP error. NotFound → 404, IdExists → /// 409, Rejected → 400 (with the stable reason key in the message), Io → 500. fn map_mgmt_err(err: &PluginMgmtError) -> AppError { match err { PluginMgmtError::NotFound => AppError::not_found("Plugin not found"), PluginMgmtError::IdExists => { AppError::conflict("A plugin with this id is already installed") } PluginMgmtError::Rejected(reason) => AppError::new( StatusCode::BAD_REQUEST, format!("Plugin rejected: {reason}"), "PluginRejected", ), PluginMgmtError::Io(msg) => { AppError::internal_error(format!("Plugin operation failed: {msg}")) } } } /// GET /api/admin/plugins — list installed plugins. pub async fn list_plugins( State(state): State>, headers: HeaderMap, ) -> Result { admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; let plugins: Vec = mgmt.list().into_iter().map(PluginInfoDto::from).collect(); // `enabled` reports that the plugin *subsystem* is active (reaching here // means it is — `plugin_mgmt` returns 503 otherwise, which the UI reads as // the disabled state). Per-plugin enablement is each entry's own `enabled`. Ok(( StatusCode::OK, Json(serde_json::json!({ "enabled": true, "plugins": plugins })), )) } /// PUT /api/admin/plugins/{id}/enabled — enable or disable a plugin. pub async fn set_plugin_enabled( State(state): State>, headers: HeaderMap, Path(id): Path, Json(dto): Json, ) -> Result { let (admin_id, _) = admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; mgmt.set_enabled(&id, dto.enabled) .map_err(|e| map_mgmt_err(&e))?; if dto.enabled { tracing::info!( target: "audit", event = "plugin.enabled", plugin_id = %id, admin_id = %admin_id, "👮🏻‍♂️ plugin enabled by admin" ); } else { tracing::info!( target: "audit", event = "plugin.disabled", plugin_id = %id, admin_id = %admin_id, "👮🏻‍♂️ plugin disabled by admin" ); } Ok(( StatusCode::OK, Json(serde_json::json!({ "message": if dto.enabled { "Plugin enabled" } else { "Plugin disabled" }, "id": id, "enabled": dto.enabled, })), )) } /// POST /api/admin/plugins — install a plugin from a multipart body with a /// single `bundle` part: a `.zip` containing `plugin.toml` and its `.wasm`. pub async fn install_plugin( State(state): State>, headers: HeaderMap, mut multipart: Multipart, ) -> Result { let (admin_id, _) = admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; let mut bundle: Option> = None; while let Some(field) = multipart .next_field() .await .map_err(|e| AppError::bad_request(format!("Invalid multipart body: {e}")))? { if field.name() == Some("bundle") { bundle = Some( field .bytes() .await .map_err(|e| AppError::bad_request(format!("Invalid bundle part: {e}")))? .to_vec(), ); } } let bundle = match bundle { Some(b) => b, None => { tracing::warn!( target: "audit", event = "plugin.install_rejected", reason = "missing_part", admin_id = %admin_id, "👮🏻‍♂️ plugin install rejected: missing 'bundle' part" ); return Err(AppError::bad_request("A 'bundle' (.zip) part is required")); } }; match mgmt.install_bundle(bundle) { Ok(info) => { tracing::info!( target: "audit", event = "plugin.installed", plugin_id = %info.id, admin_id = %admin_id, "👮🏻‍♂️ plugin installed by admin" ); Ok((StatusCode::CREATED, Json(PluginInfoDto::from(info)))) } Err(e) => { tracing::warn!( target: "audit", event = "plugin.install_rejected", reason = e.reason(), admin_id = %admin_id, "👮🏻‍♂️ plugin install rejected" ); Err(map_mgmt_err(&e)) } } } /// DELETE /api/admin/plugins/{id} — uninstall a plugin and delete its files. pub async fn delete_plugin( State(state): State>, headers: HeaderMap, Path(id): Path, ) -> Result { let (admin_id, _) = admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; mgmt.remove(&id).map_err(|e| map_mgmt_err(&e))?; tracing::info!( target: "audit", event = "plugin.removed", plugin_id = %id, admin_id = %admin_id, "👮🏻‍♂️ plugin removed by admin" ); Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "Plugin removed", "id": id })), )) } /// GET /api/admin/plugins/{id}/logs — a filtered, paginated page of a plugin's /// structured log entries (newest first). pub async fn get_plugin_logs( State(state): State>, headers: HeaderMap, Path(id): Path, Query(q): Query, ) -> Result { admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; let limit = q.limit.unwrap_or(50).clamp(1, 500); let offset = q.offset.unwrap_or(0); let page = mgmt .read_logs( &id, LogQuery { level: q.level, search: q.search, offset, limit, }, ) .await .map_err(|e| map_mgmt_err(&e))?; Ok(Json(PluginLogPageDto::from_page(page, limit, offset))) } /// DELETE /api/admin/plugins/{id}/logs — wipe a plugin's persisted logs. pub async fn clear_plugin_logs( State(state): State>, headers: HeaderMap, Path(id): Path, ) -> Result { let (admin_id, _) = admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; mgmt.clear_logs(&id).await.map_err(|e| map_mgmt_err(&e))?; tracing::info!( target: "audit", event = "plugin.logs_cleared", plugin_id = %id, admin_id = %admin_id, "👮🏻‍♂️ plugin logs cleared by admin" ); Ok(( StatusCode::OK, Json(serde_json::json!({ "message": "Plugin logs cleared", "id": id })), )) } /// GET /api/admin/plugins/{id}/logs/stream — Server-Sent Events live tail. Each /// `message` event carries one new log entry (JSON); a `lagged` event signals /// the client should resync after falling behind. Auth rides the access cookie, /// so `EventSource` works without setting headers. pub async fn stream_plugin_logs( State(state): State>, headers: HeaderMap, Path(id): Path, ) -> Result { use tokio_stream::StreamExt; use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; if !mgmt.list().iter().any(|p| p.id == id) { return Err(AppError::not_found("Plugin not found")); } let rx = mgmt.subscribe_logs(); let want = id; let stream = BroadcastStream::new(rx).filter_map(move |res| match res { Ok(ev) if ev.plugin_id == want => { let dto = PluginLogEntryDto::from(ev.entry); let event = Event::default() .json_data(&dto) .unwrap_or_else(|_| Event::default().comment("serialize error")); Some(Ok::(event)) } Ok(_) => None, Err(BroadcastStreamRecvError::Lagged(n)) => { Some(Ok(Event::default().event("lagged").data(n.to_string()))) } }); Ok(Sse::new(stream).keep_alive(KeepAlive::default())) } /// GET /api/admin/plugins/{id}/retention — the plugin's effective retention. pub async fn get_plugin_retention( State(state): State>, headers: HeaderMap, Path(id): Path, ) -> Result { admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; let settings = mgmt .get_retention(&id) .await .map_err(|e| map_mgmt_err(&e))?; Ok(Json(PluginRetentionDto::from(settings))) } /// PUT /api/admin/plugins/{id}/retention — set the plugin's retention policy. pub async fn set_plugin_retention( State(state): State>, headers: HeaderMap, Path(id): Path, Json(dto): Json, ) -> Result { let (admin_id, _) = admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; mgmt.set_retention(&id, dto.into()) .await .map_err(|e| map_mgmt_err(&e))?; tracing::info!( target: "audit", event = "plugin.retention_updated", plugin_id = %id, admin_id = %admin_id, retention_days = dto.retention_days, max_bytes = dto.max_bytes, "👮🏻‍♂️ plugin log retention updated by admin" ); Ok((StatusCode::OK, Json(dto))) } /// GET /api/admin/drives — list every drive on the system, admin-only. /// /// Distinct from `GET /api/drives`, which is the caller's own listing /// filtered through `role_grants`. An admin who creates a shared drive /// for someone else has no grant on it — but the admin panel still /// needs to see the drive (to audit, to manage, to delete). The admin /// guard at the handler edge is the access control; no role filtering /// happens in the repo (see `drive_repository::list_all`). /// /// Returns rows ordered by display name. `caller_role` is omitted — /// the admin is not necessarily a drive member, so the field would be /// misleading here. #[utoipa::path( get, path = "/api/admin/drives", responses( (status = 200, description = "Every drive on the system", body = Vec), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn list_all_drives( State(state): State>, headers: HeaderMap, ) -> Result { admin_guard(&state, &headers).await?; let drives = state .drive_repo .list_all() .await .map_err(|e| AppError::internal_error(format!("Failed to list drives: {e}")))?; let dtos: Vec = drives.into_iter().map(DriveDto::from).collect(); Ok((StatusCode::OK, Json(dtos))) } /// GET /api/admin/drives/{id}/members — list every role grant on a drive, /// admin-only. /// /// Distinct from `GET /api/drives/{id}/members` which goes through /// `DriveManagementService::list_members` and requires `Permission::Read` /// on the drive. The admin who created the drive for someone else has /// no role on it, so the user-facing endpoint would 404 for them. /// /// This endpoint reuses the engine's `list_grants_on_resource` directly /// — same query, same shape, just gated by the admin middleware instead /// of by `authz.require`. Returns the same `Vec` so the /// frontend renders it through the existing grant types. #[utoipa::path( get, path = "/api/admin/drives/{id}/members", params(("id" = Uuid, Path, description = "Drive UUID")), responses( (status = 200, description = "Role grants on the drive", body = Vec), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn list_drive_members_admin( State(state): State>, headers: HeaderMap, axum::extract::Path(drive_id): axum::extract::Path, ) -> Result { admin_guard(&state, &headers).await?; let grants = state .authorization .list_grants_on_resource(Resource::Drive(drive_id)) .await .map_err(AppError::from)?; let dtos: Vec = grants.into_iter().map(GrantDto::from).collect(); Ok((StatusCode::OK, Json(dtos))) } /// Body for `POST /api/admin/drives/{id}/members` and /// `PATCH /api/admin/drives/{id}/members/{kind}/{sid}` — same wire shape /// as the user-facing endpoints, kept here so this handler doesn't pull /// in the regular drive-handler module's DTOs (which would create a /// circular feel between admin and user-facing surfaces). #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] pub struct AdminAddDriveMemberDto { pub subject: SubjectDto, pub role: RoleDto, #[serde(default)] pub expires_at: Option>, } #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] pub struct AdminUpdateDriveMemberDto { pub role: RoleDto, #[serde(default)] pub expires_at: Option>, } fn admin_parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject { match kind { SubjectTypeDto::User => Subject::User(id), SubjectTypeDto::Group => Subject::Group(id), SubjectTypeDto::Token => Subject::Token(id), } } /// POST /api/admin/drives/{id}/members — add or refresh a member's role /// without holding `Manage` on the drive. Admin-only; bypasses the /// per-drive authz check via the `caller_is_admin = true` argument on /// `DriveManagementService::set_member_role`. Personal-drive guard and /// last-owner protection still apply. #[utoipa::path( post, path = "/api/admin/drives/{id}/members", params(("id" = Uuid, Path, description = "Drive UUID")), request_body = AdminAddDriveMemberDto, responses( (status = 201, description = "Member added", body = GrantDto), (status = 400, description = "Validation error (e.g. last-owner constraint)"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 405, description = "Personal drive — membership is immutable"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn add_drive_member_admin( State(state): State>, headers: HeaderMap, axum::extract::Path(drive_id): axum::extract::Path, Json(dto): Json, ) -> Result { let (admin_id, _) = admin_guard(&state, &headers).await?; let subject = admin_parse_subject(dto.subject.kind, dto.subject.id); let grant = state .drive_management_service .set_member_role( admin_id, true, drive_id, subject, dto.role.into(), dto.expires_at, ) .await .map_err(AppError::from)?; Ok((StatusCode::CREATED, Json(GrantDto::from(grant)))) } /// PATCH /api/admin/drives/{id}/members/{kind}/{sid} — change a member's /// role / expiry as an admin. Same admin-bypass shape as /// `add_drive_member_admin`. #[utoipa::path( patch, path = "/api/admin/drives/{id}/members/{kind}/{sid}", params( ("id" = Uuid, Path, description = "Drive UUID"), ("kind" = String, Path, description = "Subject kind: user|group|token"), ("sid" = Uuid, Path, description = "Subject UUID"), ), request_body = AdminUpdateDriveMemberDto, responses( (status = 200, description = "Member role updated", body = GrantDto), (status = 400, description = "Validation error (e.g. last-owner demotion)"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 405, description = "Personal drive — membership is immutable"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn update_drive_member_admin( State(state): State>, headers: HeaderMap, axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<( Uuid, SubjectTypeDto, Uuid, )>, Json(dto): Json, ) -> Result { let (admin_id, _) = admin_guard(&state, &headers).await?; let subject = admin_parse_subject(kind, subject_id); let grant = state .drive_management_service .set_member_role( admin_id, true, drive_id, subject, dto.role.into(), dto.expires_at, ) .await .map_err(AppError::from)?; Ok((StatusCode::OK, Json(GrantDto::from(grant)))) } /// DELETE /api/admin/drives/{id}/members/{kind}/{sid} — remove a /// member as an admin. Bypasses `Manage`; keeps last-owner protection. #[utoipa::path( delete, path = "/api/admin/drives/{id}/members/{kind}/{sid}", params( ("id" = Uuid, Path, description = "Drive UUID"), ("kind" = String, Path, description = "Subject kind: user|group|token"), ("sid" = Uuid, Path, description = "Subject UUID"), ), responses( (status = 204, description = "Member removed (or wasn't a member — idempotent)"), (status = 400, description = "Last-owner protection — promote another member first"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 405, description = "Personal drive — membership is immutable"), ), security(("bearerAuth" = [])), tag = "admin" )] pub async fn remove_drive_member_admin( State(state): State>, headers: HeaderMap, axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<( Uuid, SubjectTypeDto, Uuid, )>, ) -> Result { let (admin_id, _) = admin_guard(&state, &headers).await?; let subject = admin_parse_subject(kind, subject_id); state .drive_management_service .remove_member(admin_id, true, drive_id, subject) .await .map_err(AppError::from)?; Ok(StatusCode::NO_CONTENT) }