From 8b17a175c3e7e27b789812eba4e3cd760beadb10 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 6 Jan 2026 04:08:11 +0000 Subject: Initial Control system --- makima/src/server/handlers/users.rs | 972 ++++++++++++++++++++++++++++++++++++ 1 file changed, 972 insertions(+) create mode 100644 makima/src/server/handlers/users.rs (limited to 'makima/src/server/handlers/users.rs') diff --git a/makima/src/server/handlers/users.rs b/makima/src/server/handlers/users.rs new file mode 100644 index 0000000..0b2ccdd --- /dev/null +++ b/makima/src/server/handlers/users.rs @@ -0,0 +1,972 @@ +//! 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() +} + +// ============================================================================= +// 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.")); + } +} -- cgit v1.2.3