/** * Identity Management Service * Handles OAuth provider connections, disconnections, and password fallback */ import { supabase } from '@/integrations/supabase/client'; import type { UserIdentity as SupabaseUserIdentity } from '@supabase/supabase-js'; import type { UserIdentity, OAuthProvider, IdentitySafetyCheck, IdentityOperationResult } from '@/types/identity'; /** * Get all identities for the current user */ export async function getUserIdentities(): Promise { try { const { data, error } = await supabase.auth.getUserIdentities(); if (error) throw error; return (data?.identities || []) as UserIdentity[]; } catch (error: any) { console.error('[IdentityService] Failed to get identities:', error); return []; } } /** * Check if user has password authentication (email provider) */ export async function hasPasswordAuth(): Promise { const identities = await getUserIdentities(); return identities.some(identity => identity.provider === 'email'); } /** * Check if it's safe to disconnect a provider * Returns safety information and reason if unsafe */ export async function checkDisconnectSafety( provider: OAuthProvider ): Promise { const identities = await getUserIdentities(); const hasPassword = identities.some(i => i.provider === 'email'); const oauthIdentities = identities.filter(i => i.provider !== 'email' && i.provider !== 'phone' ); const totalIdentities = identities.length; // Can't disconnect if it's the only identity if (totalIdentities === 1) { return { canDisconnect: false, reason: 'last_identity', hasPasswordAuth: hasPassword, totalIdentities, oauthIdentities: oauthIdentities.length }; } // Can't disconnect last OAuth provider if no password backup if (oauthIdentities.length === 1 && !hasPassword) { return { canDisconnect: false, reason: 'no_password_backup', hasPasswordAuth: hasPassword, totalIdentities, oauthIdentities: oauthIdentities.length }; } return { canDisconnect: true, reason: 'safe', hasPasswordAuth: hasPassword, totalIdentities, oauthIdentities: oauthIdentities.length }; } /** * Disconnect an OAuth identity from the user's account */ export async function disconnectIdentity( provider: OAuthProvider ): Promise { try { // Safety check first const safetyCheck = await checkDisconnectSafety(provider); if (!safetyCheck.canDisconnect) { return { success: false, error: safetyCheck.reason === 'last_identity' ? 'Cannot disconnect your only login method' : 'Please set a password before disconnecting your last social login' }; } // Get all identities to find the one to unlink const identities = await getUserIdentities(); const identity = identities.find(i => i.provider === provider); if (!identity) { return { success: false, error: `No ${provider} identity found` }; } // Unlink the identity - cast to Supabase's expected type const { error } = await supabase.auth.unlinkIdentity(identity as SupabaseUserIdentity); if (error) throw error; // Log audit event const { data: { user } } = await supabase.auth.getUser(); if (user) { await logIdentityChange(user.id, 'identity_disconnected', { provider }); } return { success: true }; } catch (error: any) { console.error('[IdentityService] Failed to disconnect identity:', error); return { success: false, error: error.message || 'Failed to disconnect identity' }; } } /** * Connect an OAuth identity to the user's account */ export async function connectIdentity( provider: OAuthProvider, redirectTo?: string ): Promise { try { const { error } = await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: redirectTo || `${window.location.origin}/settings?tab=security`, skipBrowserRedirect: false } }); if (error) throw error; return { success: true }; } catch (error: any) { console.error('[IdentityService] Failed to connect identity:', error); return { success: false, error: error.message || `Failed to connect ${provider} account` }; } } /** * Wait for email provider to be created after password addition * Supabase takes time to create the email identity, so we poll with retries */ async function waitForEmailProvider(maxRetries = 6): Promise { const delays = [500, 1000, 1500, 2000, 2500, 3000]; // ~10.5s total for (let i = 0; i < maxRetries; i++) { const identities = await getUserIdentities(); const hasEmail = identities.some(id => id.provider === 'email'); if (hasEmail) { console.log(`[IdentityService] Email provider found after ${i + 1} attempts`); return true; } console.log(`[IdentityService] Email provider not found, attempt ${i + 1}/${maxRetries}`); if (i < maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, delays[i])); } } console.error('[IdentityService] Email provider not found after max retries'); return false; } /** * Add password authentication to an OAuth-only account * Automatically creates email identity by signing in immediately after setting password */ export async function addPasswordToAccount( password: string ): Promise { try { // Validate password strength if (password.length < 8) { return { success: false, error: 'Password must be at least 8 characters long' }; } const { data: { user } } = await supabase.auth.getUser(); const userEmail = user?.email; if (!userEmail) { return { success: false, error: 'No email address found on your account' }; } // Step 1: Update password console.log('[IdentityService] Setting password for user'); const { error: updateError } = await supabase.auth.updateUser({ password }); if (updateError) throw updateError; // Step 2: Log the password addition await logIdentityChange(user!.id, 'password_added', { method: 'oauth_with_relogin_required' }); // Step 3: Sign the user out so they can sign back in with email/password console.log('[IdentityService] Signing user out to force re-login'); await supabase.auth.signOut(); // Return success with relogin flag return { success: true, needsRelogin: true, email: userEmail }; } catch (error: any) { console.error('[IdentityService] Failed to add password:', error); return { success: false, error: error.message || 'Failed to set password' }; } } /** * Check if user has an orphaned password (password exists but no email identity) */ export async function hasOrphanedPassword(): Promise { const identities = await getUserIdentities(); const hasEmailIdentity = identities.some(i => i.provider === 'email'); if (hasEmailIdentity) return false; // If user has OAuth identities but no email identity, they might have an orphaned password return identities.length > 0 && !hasEmailIdentity; } /** * Re-verify password authentication by attempting sign-in * This forces Supabase to create the email identity if it's missing */ export async function reverifyPasswordAuth( email: string, password: string ): Promise { try { const { error } = await supabase.auth.signInWithPassword({ email, password }); if (error) throw error; // Check if email identity was created const emailCreated = await waitForEmailProvider(3); if (!emailCreated) { return { success: false, error: 'Sign-in successful but identity verification failed. Please contact support.' }; } return { success: true }; } catch (error: any) { return { success: false, error: error.message || 'Failed to verify password authentication' }; } } /** * Log identity changes to audit log */ async function logIdentityChange( userId: string, action: string, details: Record ): Promise { try { await supabase.rpc('log_admin_action', { _admin_user_id: userId, _target_user_id: userId, _action: action, _details: details }); } catch (error) { console.error('[IdentityService] Failed to log audit event:', error); // Don't fail the operation if audit logging fails } }