//! HTTP handlers for user account management. //! //! These endpoints allow users to manage their account settings: //! - Change password //! - Change email //! - Delete account use axum::{ extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::server::auth::UserOnly; use crate::server::messages::ApiError; use crate::server::state::SharedState; // ============================================================================= // Request/Response Types // ============================================================================= /// Request to change password. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ChangePasswordRequest { /// The user's current password for verification pub current_password: String, /// The new password to set pub new_password: String, } /// Response after changing password. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ChangePasswordResponse { pub success: bool, pub message: String, } /// Request to change email. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ChangeEmailRequest { /// The user's password for verification pub password: String, /// The new email address to set pub new_email: String, } /// Response after changing email. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ChangeEmailResponse { pub success: bool, pub message: String, /// Whether a verification email was sent to the new address pub verification_sent: bool, } /// Request to delete account. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct DeleteAccountRequest { /// The user's password for verification pub password: String, /// Confirmation text - must match the user's email pub confirmation: String, } /// Response after deleting account. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct DeleteAccountResponse { pub success: bool, pub message: String, } // ============================================================================= // Password Validation // ============================================================================= /// Password strength validation result. #[derive(Debug)] pub struct PasswordValidation { pub is_valid: bool, pub errors: Vec, } /// Validate password strength. /// Requirements: /// - At least 6 characters (matches login form) fn validate_password_strength(password: &str) -> PasswordValidation { let mut errors = Vec::new(); if password.len() < 6 { errors.push("Password must be at least 6 characters long".to_string()); } PasswordValidation { is_valid: errors.is_empty(), errors, } } /// Validate email format. fn validate_email(email: &str) -> bool { // Basic email validation - must contain @ and at least one . after @ let parts: Vec<&str> = email.split('@').collect(); if parts.len() != 2 { return false; } let local = parts[0]; let domain = parts[1]; // Local part must not be empty if local.is_empty() { return false; } // Domain must have at least one dot and not start/end with dot domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.') } // ============================================================================= // Supabase Admin Client // ============================================================================= /// Supabase Admin API client for user management operations. /// Uses the service role key for admin-level operations. pub struct SupabaseAdminClient { base_url: String, secret_api_key: String, client: reqwest::Client, } impl SupabaseAdminClient { /// Create a new Supabase admin client from environment variables. pub fn from_env() -> Option { let base_url = std::env::var("SUPABASE_URL").ok()?; let secret_api_key = std::env::var("SUPABASE_SECRET_API_KEY").ok()?; Some(Self { base_url, secret_api_key, client: reqwest::Client::new(), }) } /// Verify a user's password by attempting to sign in. pub async fn verify_password(&self, email: &str, password: &str) -> Result { let url = format!("{}/auth/v1/token?grant_type=password", self.base_url); let response = self .client .post(&url) .header("apikey", &self.secret_api_key) .header("Content-Type", "application/json") .json(&serde_json::json!({ "email": email, "password": password })) .send() .await .map_err(|e| format!("Failed to verify password: {}", e))?; Ok(response.status().is_success()) } /// Update a user's password. pub async fn update_password( &self, user_id: &str, new_password: &str, ) -> Result<(), String> { let url = format!("{}/auth/v1/admin/users/{}", self.base_url, user_id); let response = self .client .put(&url) .header("apikey", &self.secret_api_key) .header("Authorization", format!("Bearer {}", self.secret_api_key)) .header("Content-Type", "application/json") .json(&serde_json::json!({ "password": new_password })) .send() .await .map_err(|e| format!("Failed to update password: {}", e))?; if response.status().is_success() { Ok(()) } else { let error_text = response.text().await.unwrap_or_default(); Err(format!("Failed to update password: {}", error_text)) } } /// Update a user's email. pub async fn update_email( &self, user_id: &str, new_email: &str, ) -> Result<(), String> { let url = format!("{}/auth/v1/admin/users/{}", self.base_url, user_id); let response = self .client .put(&url) .header("apikey", &self.secret_api_key) .header("Authorization", format!("Bearer {}", self.secret_api_key)) .header("Content-Type", "application/json") .json(&serde_json::json!({ "email": new_email, "email_confirm": true })) .send() .await .map_err(|e| format!("Failed to update email: {}", e))?; if response.status().is_success() { Ok(()) } else { let error_text = response.text().await.unwrap_or_default(); Err(format!("Failed to update email: {}", error_text)) } } /// Delete a user from Supabase Auth. pub async fn delete_user(&self, user_id: &str) -> Result<(), String> { let url = format!("{}/auth/v1/admin/users/{}", self.base_url, user_id); let response = self .client .delete(&url) .header("apikey", &self.secret_api_key) .header("Authorization", format!("Bearer {}", self.secret_api_key)) .send() .await .map_err(|e| format!("Failed to delete user: {}", e))?; if response.status().is_success() { Ok(()) } else { let error_text = response.text().await.unwrap_or_default(); Err(format!("Failed to delete user: {}", error_text)) } } /// Get user info including email. pub async fn get_user(&self, user_id: &str) -> Result, String> { let url = format!("{}/auth/v1/admin/users/{}", self.base_url, user_id); let response = self .client .get(&url) .header("apikey", &self.secret_api_key) .header("Authorization", format!("Bearer {}", self.secret_api_key)) .send() .await .map_err(|e| format!("Failed to get user: {}", e))?; if response.status().is_success() { let json: serde_json::Value = response .json() .await .map_err(|e| format!("Failed to parse user data: {}", e))?; Ok(json.get("email").and_then(|e| e.as_str()).map(String::from)) } else if response.status() == reqwest::StatusCode::NOT_FOUND { Ok(None) } else { let error_text = response.text().await.unwrap_or_default(); Err(format!("Failed to get user: {}", error_text)) } } } // ============================================================================= // Supabase User Client (uses user's JWT, no admin required) // ============================================================================= /// Supabase User API client for self-service operations. /// Uses the user's JWT token - no admin/service role key required. pub struct SupabaseUserClient { base_url: String, anon_key: String, jwt_token: String, client: reqwest::Client, } impl SupabaseUserClient { /// Create a new Supabase user client from environment and JWT token. pub fn new(jwt_token: String) -> Option { let base_url = std::env::var("SUPABASE_URL").ok()?; let anon_key = std::env::var("SUPABASE_ANON_KEY").ok()?; Some(Self { base_url, anon_key, jwt_token, client: reqwest::Client::new(), }) } /// Update the user's password using their own JWT. pub async fn update_password(&self, new_password: &str) -> Result<(), String> { let url = format!("{}/auth/v1/user", self.base_url); let response = self .client .put(&url) .header("apikey", &self.anon_key) .header("Authorization", format!("Bearer {}", self.jwt_token)) .header("Content-Type", "application/json") .json(&serde_json::json!({ "password": new_password })) .send() .await .map_err(|e| format!("Failed to update password: {}", e))?; if response.status().is_success() { Ok(()) } else { let error_text = response.text().await.unwrap_or_default(); Err(format!("Failed to update password: {}", error_text)) } } /// Update the user's email using their own JWT. pub async fn update_email(&self, new_email: &str) -> Result<(), String> { let url = format!("{}/auth/v1/user", self.base_url); let response = self .client .put(&url) .header("apikey", &self.anon_key) .header("Authorization", format!("Bearer {}", self.jwt_token)) .header("Content-Type", "application/json") .json(&serde_json::json!({ "email": new_email })) .send() .await .map_err(|e| format!("Failed to update email: {}", e))?; if response.status().is_success() { Ok(()) } else { let error_text = response.text().await.unwrap_or_default(); Err(format!("Failed to update email: {}", error_text)) } } /// Verify current password by attempting to sign in. pub async fn verify_password(&self, email: &str, password: &str) -> Result { let url = format!("{}/auth/v1/token?grant_type=password", self.base_url); let response = self .client .post(&url) .header("apikey", &self.anon_key) .header("Content-Type", "application/json") .json(&serde_json::json!({ "email": email, "password": password })) .send() .await .map_err(|e| format!("Failed to verify password: {}", e))?; Ok(response.status().is_success()) } } // ============================================================================= // Handlers // ============================================================================= /// Change the authenticated user's password. /// /// Requires verification of the current password before allowing the change. /// The new password must meet strength requirements. #[utoipa::path( put, path = "/api/v1/users/me/password", request_body = ChangePasswordRequest, responses( (status = 200, description = "Password changed successfully", body = ChangePasswordResponse), (status = 400, description = "Invalid request (weak password, wrong current password)", body = ApiError), (status = 401, description = "Not authenticated", body = ApiError), (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError), (status = 503, description = "Supabase not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []) ), tag = "Users" )] pub async fn change_password_handler( State(_state): State, headers: HeaderMap, UserOnly(user): UserOnly, Json(req): Json, ) -> impl IntoResponse { // Validate new password strength let validation = validate_password_strength(&req.new_password); if !validation.is_valid { return ( StatusCode::BAD_REQUEST, Json(ApiError::new( "WEAK_PASSWORD", &validation.errors.join("; "), )), ) .into_response(); } // Get user's email (required for password verification) let email = match &user.email { Some(email) => email.clone(), None => { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("EMAIL_REQUIRED", "User email not available")), ) .into_response(); } }; // Extract JWT from Authorization header for user-level API calls let jwt_token = headers .get("Authorization") .and_then(|v| v.to_str().ok()) .and_then(|s| s.strip_prefix("Bearer ")) .map(|s| s.to_string()); // Try user client first (uses JWT, no admin required), fall back to admin client if let Some(token) = jwt_token { if let Some(user_client) = SupabaseUserClient::new(token) { // Verify current password match user_client.verify_password(&email, &req.current_password).await { Ok(true) => {} Ok(false) => { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("INVALID_PASSWORD", "Current password is incorrect")), ) .into_response(); } Err(e) => { tracing::error!("Failed to verify password: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")), ) .into_response(); } } // Update password using user's JWT return match user_client.update_password(&req.new_password).await { Ok(()) => { tracing::info!("Password changed for user {}", user.user_id); Json(ChangePasswordResponse { success: true, message: "Password changed successfully".to_string(), }) .into_response() } Err(e) => { tracing::error!("Failed to update password: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("INTERNAL_ERROR", "Failed to update password")), ) .into_response() } }; } } // Fall back to admin client if user client not available let admin_client = match SupabaseAdminClient::from_env() { Some(client) => client, None => { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new( "SUPABASE_NOT_CONFIGURED", "Supabase not configured. Ensure SUPABASE_URL and SUPABASE_ANON_KEY are set.", )), ) .into_response(); } }; // Verify current password match admin_client.verify_password(&email, &req.current_password).await { Ok(true) => {} Ok(false) => { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("INVALID_PASSWORD", "Current password is incorrect")), ) .into_response(); } Err(e) => { tracing::error!("Failed to verify password: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")), ) .into_response(); } } // Update password in Supabase match admin_client .update_password(&user.user_id.to_string(), &req.new_password) .await { Ok(()) => { tracing::info!("Password changed for user {}", user.user_id); Json(ChangePasswordResponse { success: true, message: "Password changed successfully".to_string(), }) .into_response() } Err(e) => { tracing::error!("Failed to update password: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("INTERNAL_ERROR", "Failed to update password")), ) .into_response() } } } /// Change the authenticated user's email address. /// /// Requires password verification before allowing the change. /// The new email will be updated directly (Supabase handles verification if configured). #[utoipa::path( put, path = "/api/v1/users/me/email", request_body = ChangeEmailRequest, responses( (status = 200, description = "Email changed successfully", body = ChangeEmailResponse), (status = 400, description = "Invalid request (invalid email, wrong password)", body = ApiError), (status = 401, description = "Not authenticated", body = ApiError), (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError), (status = 503, description = "Supabase not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []) ), tag = "Users" )] pub async fn change_email_handler( State(state): State, headers: HeaderMap, UserOnly(user): UserOnly, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Validate new email format if !validate_email(&req.new_email) { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("INVALID_EMAIL", "Invalid email format")), ) .into_response(); } // Get user's current email (required for password verification) let current_email = match &user.email { Some(email) => email.clone(), None => { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("EMAIL_REQUIRED", "User email not available")), ) .into_response(); } }; // Extract JWT from Authorization header for user-level API calls let jwt_token = headers .get("Authorization") .and_then(|v| v.to_str().ok()) .and_then(|s| s.strip_prefix("Bearer ")) .map(|s| s.to_string()); // Try user client first (uses JWT, no admin required), fall back to admin client if let Some(token) = jwt_token { if let Some(user_client) = SupabaseUserClient::new(token) { // Verify password match user_client.verify_password(¤t_email, &req.password).await { Ok(true) => {} Ok(false) => { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("INVALID_PASSWORD", "Password is incorrect")), ) .into_response(); } Err(e) => { tracing::error!("Failed to verify password: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")), ) .into_response(); } } // Update email using user's JWT if let Err(e) = user_client.update_email(&req.new_email).await { tracing::error!("Failed to update email in Supabase: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("INTERNAL_ERROR", "Failed to update email")), ) .into_response(); } // Update email in our database if let Err(e) = sqlx::query("UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2") .bind(&req.new_email) .bind(user.user_id) .execute(pool) .await { tracing::error!("Failed to update email in database: {}", e); } tracing::info!( "Email changed for user {} from {} to {}", user.user_id, current_email, req.new_email ); return Json(ChangeEmailResponse { success: true, message: "Email changed successfully".to_string(), verification_sent: false, }) .into_response(); } } // Fall back to admin client if user client not available let admin_client = match SupabaseAdminClient::from_env() { Some(client) => client, None => { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new( "SUPABASE_NOT_CONFIGURED", "Supabase not configured. Ensure SUPABASE_URL and SUPABASE_ANON_KEY are set.", )), ) .into_response(); } }; // Verify password match admin_client.verify_password(¤t_email, &req.password).await { Ok(true) => {} Ok(false) => { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("INVALID_PASSWORD", "Password is incorrect")), ) .into_response(); } Err(e) => { tracing::error!("Failed to verify password: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")), ) .into_response(); } } // Update email in Supabase if let Err(e) = admin_client .update_email(&user.user_id.to_string(), &req.new_email) .await { tracing::error!("Failed to update email in Supabase: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("INTERNAL_ERROR", "Failed to update email")), ) .into_response(); } // Update email in our database if let Err(e) = sqlx::query("UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2") .bind(&req.new_email) .bind(user.user_id) .execute(pool) .await { tracing::error!("Failed to update email in database: {}", e); } tracing::info!( "Email changed for user {} from {} to {}", user.user_id, current_email, req.new_email ); Json(ChangeEmailResponse { success: true, message: "Email changed successfully".to_string(), verification_sent: false, }) .into_response() } /// Delete the authenticated user's account. /// /// This permanently deletes: /// - The user's Supabase Auth account /// - The user's record in our database /// - All associated data (API keys, files, tasks, etc. via CASCADE) /// /// Requires password verification and confirmation text matching the user's email. #[utoipa::path( delete, path = "/api/v1/users/me", request_body = DeleteAccountRequest, responses( (status = 200, description = "Account deleted successfully", body = DeleteAccountResponse), (status = 400, description = "Invalid request (wrong password, wrong confirmation)", body = ApiError), (status = 401, description = "Not authenticated", body = ApiError), (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError), (status = 503, description = "Supabase not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []) ), tag = "Users" )] pub async fn delete_account_handler( State(state): State, UserOnly(user): UserOnly, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Get Supabase admin client - required for full account deletion let admin_client = match SupabaseAdminClient::from_env() { Some(client) => client, None => { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new( "SUPABASE_ADMIN_NOT_CONFIGURED", "Account deletion requires SUPABASE_SECRET_API_KEY to be configured", )), ) .into_response(); } }; // Verify confirmation is "DELETE MY ACCOUNT" const REQUIRED_CONFIRMATION: &str = "DELETE MY ACCOUNT"; if req.confirmation != REQUIRED_CONFIRMATION { return ( StatusCode::BAD_REQUEST, Json(ApiError::new( "INVALID_CONFIRMATION", format!("Confirmation text must be exactly: {}", REQUIRED_CONFIRMATION), )), ) .into_response(); } // Get user's email (required for password verification) let email = match &user.email { Some(e) => e.clone(), None => { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("EMAIL_REQUIRED", "User email not available")), ) .into_response(); } }; // Verify password match admin_client.verify_password(&email, &req.password).await { Ok(true) => {} Ok(false) => { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("INVALID_PASSWORD", "Password is incorrect")), ) .into_response(); } Err(e) => { tracing::error!("Failed to verify password: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")), ) .into_response(); } } // Delete from our database first (this will cascade to related records) // Get the owner_id before deleting let owner_id = user.owner_id; // Delete API keys for this user (explicit deletion for audit purposes) if let Err(e) = sqlx::query("UPDATE api_keys SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL") .bind(user.user_id) .execute(pool) .await { tracing::warn!("Failed to revoke API keys during account deletion: {}", e); } // Delete user record if let Err(e) = sqlx::query("DELETE FROM users WHERE id = $1") .bind(user.user_id) .execute(pool) .await { tracing::error!("Failed to delete user from database: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("INTERNAL_ERROR", "Failed to delete account")), ) .into_response(); } // Delete files owned by this user if let Err(e) = sqlx::query("DELETE FROM files WHERE owner_id = $1") .bind(owner_id) .execute(pool) .await { tracing::warn!("Failed to delete user files: {}", e); } // Delete tasks owned by this user if let Err(e) = sqlx::query("DELETE FROM tasks WHERE owner_id = $1") .bind(owner_id) .execute(pool) .await { tracing::warn!("Failed to delete user tasks: {}", e); } // Delete mesh chat conversations owned by this user if let Err(e) = sqlx::query("DELETE FROM mesh_chat_conversations WHERE owner_id = $1") .bind(owner_id) .execute(pool) .await { tracing::warn!("Failed to delete mesh chat conversations: {}", e); } // Delete daemons owned by this user if let Err(e) = sqlx::query("DELETE FROM daemons WHERE owner_id = $1") .bind(owner_id) .execute(pool) .await { tracing::warn!("Failed to delete user daemons: {}", e); } // Delete owner record if let Err(e) = sqlx::query("DELETE FROM owners WHERE id = $1") .bind(owner_id) .execute(pool) .await { tracing::warn!("Failed to delete owner record: {}", e); } // Delete from Supabase Auth if let Err(e) = admin_client.delete_user(&user.user_id.to_string()).await { tracing::error!("Failed to delete user from Supabase Auth: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new( "SUPABASE_DELETE_FAILED", "Failed to delete user from authentication system", )), ) .into_response(); } tracing::info!("Account deleted for user {} ({})", user.user_id, email); Json(DeleteAccountResponse { success: true, message: "Account deleted successfully".to_string(), }) .into_response() } // ============================================================================= // User Settings (per-user feature flags) // ============================================================================= /// User settings response. #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UserSettingsResponse { /// Whether the new "document mode" UI is enabled for this user. pub document_mode_enabled: bool, } /// Update user settings request. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateUserSettingsRequest { /// Whether to enable the new "document mode" UI for this user. pub document_mode_enabled: bool, } /// Get the authenticated user's settings (feature flags). /// /// Returns the user's per-user settings, currently consisting of feature flags /// used by the frontend to decide which UI to show. #[utoipa::path( get, path = "/api/v1/users/me/settings", responses( (status = 200, description = "User settings", body = UserSettingsResponse), (status = 401, description = "Not authenticated", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []) ), tag = "Users" )] pub async fn get_user_settings_handler( State(state): State, UserOnly(user): UserOnly, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match sqlx::query_scalar::<_, bool>( "SELECT document_mode_enabled FROM users WHERE id = $1", ) .bind(user.user_id) .fetch_optional(pool) .await { Ok(Some(document_mode_enabled)) => Json(UserSettingsResponse { document_mode_enabled, }) .into_response(), Ok(None) => { // User row missing — fall back to defaults rather than 404 since the // user is authenticated. tracing::warn!(user_id = %user.user_id, "User row not found when fetching settings — returning defaults"); Json(UserSettingsResponse { document_mode_enabled: false, }) .into_response() } Err(e) => { tracing::error!("Failed to fetch user settings: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Update the authenticated user's settings (feature flags). /// /// Replaces the user's settings record with the provided values. #[utoipa::path( put, path = "/api/v1/users/me/settings", request_body = UpdateUserSettingsRequest, responses( (status = 200, description = "Updated user settings", body = UserSettingsResponse), (status = 401, description = "Not authenticated", body = ApiError), (status = 404, description = "User not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []) ), tag = "Users" )] pub async fn update_user_settings_handler( State(state): State, UserOnly(user): UserOnly, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; let result = sqlx::query_scalar::<_, bool>( r#" UPDATE users SET document_mode_enabled = $1, updated_at = NOW() WHERE id = $2 RETURNING document_mode_enabled "#, ) .bind(req.document_mode_enabled) .bind(user.user_id) .fetch_optional(pool) .await; match result { Ok(Some(document_mode_enabled)) => { tracing::info!( user_id = %user.user_id, document_mode_enabled = document_mode_enabled, "Updated user settings" ); Json(UserSettingsResponse { document_mode_enabled, }) .into_response() } Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("USER_NOT_FOUND", "User not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to update user settings: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } // ============================================================================= // Tests // ============================================================================= #[cfg(test)] mod tests { use super::*; #[test] fn test_password_validation_success() { // Minimum 6 characters let result = validate_password_strength("abcdef"); assert!(result.is_valid); assert!(result.errors.is_empty()); let result = validate_password_strength("Password123"); assert!(result.is_valid); assert!(result.errors.is_empty()); } #[test] fn test_password_validation_too_short() { let result = validate_password_strength("12345"); assert!(!result.is_valid); assert!(result.errors.iter().any(|e| e.contains("6 characters"))); } #[test] fn test_email_validation_valid() { assert!(validate_email("user@example.com")); assert!(validate_email("user.name@example.co.uk")); assert!(validate_email("user+tag@example.org")); } #[test] fn test_email_validation_invalid() { assert!(!validate_email("userexample.com")); assert!(!validate_email("user@")); assert!(!validate_email("@example.com")); assert!(!validate_email("user@.com")); assert!(!validate_email("user@example.")); } }