use anyhow::Result; use serde_json::Value; use sqlx::PgPool; use uuid::Uuid; use crate::models::{OauthAccount, User}; pub struct OAuthProfile { pub provider: String, pub provider_id: String, pub email: Option, pub name: Option, pub avatar_url: Option, } /// Find or create a user from an OAuth profile. /// Returns (user, is_new) where is_new indicates first-time registration. pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result<(User, bool)> { // Check if this OAuth account already exists let existing: Option = sqlx::query_as( "SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \ FROM oauth_accounts WHERE provider = $1 AND provider_id = $2", ) .bind(&profile.provider) .bind(&profile.provider_id) .fetch_optional(pool) .await?; if let Some(oa) = existing { let user: User = sqlx::query_as( "SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \ FROM users WHERE id = $1", ) .bind(oa.user_id) .fetch_one(pool) .await?; return Ok((user, false)); } // New user — create records (no key yet; user sets passphrase on dashboard) let display_name = profile .name .clone() .unwrap_or_else(|| profile.email.clone().unwrap_or_else(|| "User".to_string())); let mut tx = pool.begin().await?; let user: User = sqlx::query_as( "INSERT INTO users (email, name, avatar_url) \ VALUES ($1, $2, $3) \ RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at", ) .bind(&profile.email) .bind(&display_name) .bind(&profile.avatar_url) .fetch_one(&mut *tx) .await?; sqlx::query( "INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \ VALUES ($1, $2, $3, $4, $5, $6)", ) .bind(user.id) .bind(&profile.provider) .bind(&profile.provider_id) .bind(&profile.email) .bind(&profile.name) .bind(&profile.avatar_url) .execute(&mut *tx) .await?; tx.commit().await?; Ok((user, true)) } /// Store the PBKDF2 salt, key_check, and params for a user's passphrase setup. pub async fn update_user_key_setup( pool: &PgPool, user_id: Uuid, key_salt: &[u8], key_check: &[u8], key_params: &Value, ) -> Result<()> { sqlx::query( "UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, updated_at = NOW() \ WHERE id = $4", ) .bind(key_salt) .bind(key_check) .bind(key_params) .bind(user_id) .execute(pool) .await?; Ok(()) } /// Fetch a user by ID. pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result> { let user = sqlx::query_as( "SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \ FROM users WHERE id = $1", ) .bind(user_id) .fetch_optional(pool) .await?; Ok(user) } /// List all OAuth accounts linked to a user. pub async fn list_oauth_accounts(pool: &PgPool, user_id: Uuid) -> Result> { let accounts = sqlx::query_as( "SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \ FROM oauth_accounts WHERE user_id = $1 ORDER BY created_at", ) .bind(user_id) .fetch_all(pool) .await?; Ok(accounts) } /// Bind an additional OAuth account to an existing user. pub async fn bind_oauth_account( pool: &PgPool, user_id: Uuid, profile: OAuthProfile, ) -> Result { // Check if this provider_id is already linked to someone else let conflict: Option<(Uuid,)> = sqlx::query_as( "SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_id = $2", ) .bind(&profile.provider) .bind(&profile.provider_id) .fetch_optional(pool) .await?; if let Some((existing_user_id,)) = conflict { if existing_user_id != user_id { anyhow::bail!( "This {} account is already linked to a different user", profile.provider ); } anyhow::bail!( "This {} account is already linked to your account", profile.provider ); } let existing_provider_for_user: Option<(String,)> = sqlx::query_as( "SELECT provider_id FROM oauth_accounts WHERE user_id = $1 AND provider = $2", ) .bind(user_id) .bind(&profile.provider) .fetch_optional(pool) .await?; if existing_provider_for_user.is_some() { anyhow::bail!( "You already linked a {} account. Unlink the other provider instead of binding multiple {} accounts.", profile.provider, profile.provider ); } let account: OauthAccount = sqlx::query_as( "INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \ VALUES ($1, $2, $3, $4, $5, $6) \ RETURNING id, user_id, provider, provider_id, email, name, avatar_url, created_at", ) .bind(user_id) .bind(&profile.provider) .bind(&profile.provider_id) .bind(&profile.email) .bind(&profile.name) .bind(&profile.avatar_url) .fetch_one(pool) .await?; Ok(account) } /// Unbind an OAuth account. Ensures at least one remains and blocks unlinking the current login provider. pub async fn unbind_oauth_account( pool: &PgPool, user_id: Uuid, provider: &str, current_login_provider: Option<&str>, ) -> Result<()> { if current_login_provider == Some(provider) { anyhow::bail!( "Cannot unlink the {} account you are currently using to sign in", provider ); } let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_accounts WHERE user_id = $1") .bind(user_id) .fetch_one(pool) .await?; if count <= 1 { anyhow::bail!("Cannot unbind the last OAuth account. Please link another account first."); } sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1 AND provider = $2") .bind(user_id) .bind(provider) .execute(pool) .await?; Ok(()) }