import React, { createContext, useContext, useEffect, useState, useRef } from 'react'; import type { User, Session } from '@supabase/supabase-js'; import { supabase } from '@/lib/supabaseClient'; 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'; import { logger } from '@/lib/logger'; 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: unknown) { 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); // Check if user is banned const { data: profile } = await supabase .from('profiles') .select('banned') .eq('user_id', session.user.id) .maybeSingle(); if (profile?.banned) { authWarn('[Auth] Banned user detected, signing out'); await supabase.auth.signOut(); return; } } else { setAal(null); } }, 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: unknown) { 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) { logger.error('AuthContext is undefined - component may be rendering outside AuthProvider', { component: 'useAuth' }); throw new Error('useAuth must be used within an AuthProvider'); } return context; }