import React, { createContext, useContext, useEffect, useState, useRef } from 'react'; import type { User, Session } from '@supabase/supabase-js'; import { supabase } from '@/integrations/supabase/client'; import type { Profile } from '@/types/database'; import { toast } from '@/hooks/use-toast'; import { authLog, authWarn, authError } from '@/lib/authLogger'; import type { AALLevel, CheckAalResult } from '@/types/auth'; import { getSessionAal, checkAalStepUp as checkAalStepUpService, signOutUser } from '@/lib/authService'; import { clearAllAuthFlags } from '@/lib/sessionFlags'; interface AuthContextType { user: User | null; session: Session | null; aal: AALLevel | null; loading: boolean; pendingEmail: string | null; sessionError: string | null; signOut: () => Promise; verifySession: () => Promise; clearPendingEmail: () => void; checkAalStepUp: () => Promise; } const AuthContext = createContext(undefined); function AuthProviderComponent({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [session, setSession] = useState(null); const [aal, setAal] = useState(null); const [loading, setLoading] = useState(true); const [pendingEmail, setPendingEmail] = useState(null); const [sessionError, setSessionError] = useState(null); // Refs for lifecycle and cleanup management const novuUpdateTimeoutRef = useRef(null); const previousEmailRef = useRef(null); const orphanedPasswordToastShownRef = useRef(false); // Verify session is still valid - simplified const verifySession = async () => { try { const { data: { session }, error } = await supabase.auth.getSession(); if (error) { authError('[Auth] Session verification failed:', error); setSessionError(error.message); return false; } if (!session) { authLog('[Auth] No active session found'); return false; } authLog('[Auth] Session verified:', session.user.email); // Update state if session was found but not set if (!user) { setSession(session); setUser(session.user); } return true; } catch (error) { authError('[Auth] Session verification error:', error); return false; } }; useEffect(() => { authLog('[Auth] Initializing auth provider'); // CRITICAL: Set up listener FIRST to catch all events const { data: { subscription }, } = supabase.auth.onAuthStateChange((event, session) => { authLog('[Auth] State change:', event, 'User:', session?.user?.email || 'none', 'Has session:', !!session); // Extract email info early for cleanup const currentEmail = session?.user?.email; const newEmailPending = session?.user?.new_email; // CRITICAL: Always clear/update pending email state BEFORE any early returns setPendingEmail(newEmailPending ?? null); // Clear any error setSessionError(null); // Synchronous state updates only setSession(session); setUser(session?.user ?? null); // Handle loading state if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') { setLoading(false); } else if (event === 'SIGNED_OUT') { authLog('[Auth] SIGNED_OUT - clearing state'); setSession(null); setUser(null); setAal(null); setLoading(false); orphanedPasswordToastShownRef.current = false; return; } // Defer async operations to avoid blocking the auth state change callback setTimeout(async () => { // Get AAL level from Supabase API (ground truth, not cached session data) if (session) { const currentAal = await getSessionAal(session); setAal(currentAal); authLog('[Auth] Current AAL:', currentAal); } else { setAal(null); } // Check for orphaned password on SIGNED_IN events if (event === 'SIGNED_IN' && session?.user) { try { // Import sessionFlags const { isOrphanedPasswordDismissed, setOrphanedPasswordDismissed } = await import('@/lib/sessionFlags'); // Skip if already shown in this auth cycle or dismissed this session if (orphanedPasswordToastShownRef.current || isOrphanedPasswordDismissed()) { authLog('[Auth] Skipping orphaned password toast - already shown or dismissed'); return; } // Import identityService functions const { getUserIdentities, hasOrphanedPassword, triggerOrphanedPasswordConfirmation } = await import('@/lib/identityService'); // Check if user has email identity const identities = await getUserIdentities(); const hasEmailIdentity = identities.some(i => i.provider === 'email'); // If no email identity but has other identities, check for orphaned password if (!hasEmailIdentity && identities.length > 0) { const isOrphaned = await hasOrphanedPassword(); if (isOrphaned) { // Mark as shown to prevent duplicates orphanedPasswordToastShownRef.current = true; // Show persistent toast with Resend button const { toast: sonnerToast } = await import('sonner'); sonnerToast.warning("Password Activation Pending", { description: "Your password needs email confirmation to be fully activated.", duration: Infinity, action: { label: "Resend Email", onClick: async () => { const result = await triggerOrphanedPasswordConfirmation('signin_toast'); if (result.success) { sonnerToast.success("Confirmation Email Sent!", { description: `Check ${result.email} for the confirmation link.`, duration: 10000, }); } else { sonnerToast.error("Failed to Send Email", { description: result.error, duration: 8000, }); } } }, cancel: { label: "Dismiss", onClick: () => { setOrphanedPasswordDismissed(); authLog('[Auth] User dismissed orphaned password warning'); } } }); } } } catch (error) { authError('[Auth] Failed to check for orphaned password:', error); } } }, 0); // Detect confirmed email change: email changed AND no longer pending if ( session?.user && previousEmailRef.current && currentEmail && currentEmail !== previousEmailRef.current && !newEmailPending ) { // Clear any existing Novu update timeout if (novuUpdateTimeoutRef.current) { clearTimeout(novuUpdateTimeoutRef.current); } // Defer Novu update and notifications to avoid blocking auth const oldEmail = previousEmailRef.current; novuUpdateTimeoutRef.current = setTimeout(async () => { try { // Update Novu subscriber with confirmed email const { notificationService } = await import('@/lib/notificationService'); if (notificationService.isEnabled()) { await notificationService.updateSubscriber({ subscriberId: session.user.id, email: currentEmail, }); } // Log the confirmed email change await supabase.from('admin_audit_log').insert({ admin_user_id: session.user.id, target_user_id: session.user.id, action: 'email_change_completed', details: { old_email: oldEmail, new_email: currentEmail, timestamp: new Date().toISOString(), }, }); // Send final security notification if (notificationService.isEnabled()) { await notificationService.trigger({ workflowId: 'email-changed', subscriberId: session.user.id, payload: { oldEmail: oldEmail, newEmail: currentEmail, timestamp: new Date().toISOString(), }, }); } } catch (error) { authError('Error updating Novu after email confirmation:', error); } finally { novuUpdateTimeoutRef.current = null; } }, 0); } // Update tracked email if (currentEmail) { previousEmailRef.current = currentEmail; } }); // THEN get initial session (this may trigger INITIAL_SESSION event) supabase.auth.getSession().then(({ data: { session }, error }) => { if (error) { authError('[Auth] Initial session fetch error:', error); setSessionError(error.message); setLoading(false); return; } // Note: onAuthStateChange will handle the INITIAL_SESSION event // This is just a backup in case the event doesn't fire authLog('[Auth] getSession completed, session exists:', !!session); }); return () => { authLog('[Auth] Cleaning up auth provider'); subscription.unsubscribe(); // Clear any pending timeouts if (novuUpdateTimeoutRef.current) { clearTimeout(novuUpdateTimeoutRef.current); novuUpdateTimeoutRef.current = null; } }; }, []); const signOut = async () => { authLog('[Auth] Signing out...'); const result = await signOutUser(); if (!result.success) { authError('Error signing out:', result.error); throw new Error(result.error); } }; const clearPendingEmail = () => { setPendingEmail(null); }; const checkAalStepUp = async (): Promise => { return checkAalStepUpService(session); }; const value = { user, session, aal, loading, pendingEmail, sessionError, signOut, verifySession, clearPendingEmail, checkAalStepUp, }; return {children}; } export const AuthProvider = AuthProviderComponent; export function useAuth() { const context = useContext(AuthContext); if (context === undefined) { console.error('[useAuth] AuthContext is undefined - component may be rendering outside AuthProvider'); throw new Error('useAuth must be used within an AuthProvider'); } return context; }