mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 07:31:14 -05:00
- Updated vite from version 5.4.19 to 7.1.9 in package.json for better performance and features. - Enhanced session verification logic in useAuth hook to check for session.user before setting user state.
422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
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';
|
|
|
|
interface AuthContextType {
|
|
user: User | null;
|
|
session: Session | null;
|
|
profile: Profile | null;
|
|
loading: boolean;
|
|
pendingEmail: string | null;
|
|
sessionError: string | null;
|
|
signOut: () => Promise<void>;
|
|
refreshProfile: () => Promise<void>;
|
|
verifySession: () => Promise<boolean>;
|
|
clearPendingEmail: () => void;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
|
|
function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [session, setSession] = useState<Session | null>(null);
|
|
const [profile, setProfile] = useState<Profile | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
|
|
const [sessionError, setSessionError] = useState<string | null>(null);
|
|
|
|
// Refs for lifecycle and cleanup management
|
|
const isMountedRef = useRef(true);
|
|
const profileFetchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const profileRetryCountRef = useRef(0);
|
|
const sessionVerifiedRef = useRef(false);
|
|
const novuUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const previousEmailRef = useRef<string | null>(null);
|
|
const loadingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const loadingStateRef = useRef(loading);
|
|
|
|
const fetchProfile = async (userId: string, retryCount = 0, onComplete?: () => void) => {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('profiles')
|
|
.select(`*, location:locations(*)`)
|
|
.eq('user_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (error && error.code !== 'PGRST116') {
|
|
console.error('[Auth] Error fetching profile:', error);
|
|
|
|
// Retry up to 3 times with exponential backoff
|
|
if (retryCount < 3 && isMountedRef.current) {
|
|
const delay = Math.pow(2, retryCount) * 1000; // 1s, 2s, 4s
|
|
console.log(`[Auth] Retrying profile fetch in ${delay}ms (attempt ${retryCount + 1}/3)`);
|
|
setTimeout(() => {
|
|
if (isMountedRef.current) {
|
|
fetchProfile(userId, retryCount + 1, onComplete);
|
|
}
|
|
}, delay);
|
|
return;
|
|
}
|
|
|
|
// All retries exhausted - complete anyway
|
|
if (isMountedRef.current) {
|
|
console.warn('[Auth] Profile fetch failed after 3 retries');
|
|
setProfile(null);
|
|
onComplete?.();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Success - update state and complete
|
|
if (isMountedRef.current) {
|
|
setProfile(data as Profile);
|
|
profileRetryCountRef.current = 0;
|
|
onComplete?.();
|
|
}
|
|
} catch (error) {
|
|
console.error('[Auth] Error fetching profile:', error);
|
|
|
|
// Retry logic for network errors
|
|
if (retryCount < 3 && isMountedRef.current) {
|
|
const delay = Math.pow(2, retryCount) * 1000;
|
|
setTimeout(() => {
|
|
if (isMountedRef.current) {
|
|
fetchProfile(userId, retryCount + 1, onComplete);
|
|
}
|
|
}, delay);
|
|
} else {
|
|
// All retries exhausted
|
|
if (isMountedRef.current) {
|
|
setProfile(null);
|
|
onComplete?.();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const refreshProfile = async () => {
|
|
if (user) {
|
|
await fetchProfile(user.id);
|
|
}
|
|
};
|
|
|
|
// Verify session is still valid
|
|
const verifySession = async (updateLoadingState = false) => {
|
|
if (updateLoadingState && isMountedRef.current) {
|
|
setLoading(true);
|
|
}
|
|
|
|
try {
|
|
const { data: { session }, error } = await supabase.auth.getSession();
|
|
|
|
if (error) {
|
|
console.error('[Auth] Session verification failed:', error);
|
|
setSessionError(error.message);
|
|
if (updateLoadingState && isMountedRef.current) {
|
|
setLoading(false);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (!session) {
|
|
console.log('[Auth] No active session found');
|
|
if (updateLoadingState && isMountedRef.current) {
|
|
setLoading(false);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
console.log('[Auth] Session verified:', session.user.email);
|
|
sessionVerifiedRef.current = true;
|
|
|
|
// Update state if session was found but not set
|
|
if (!user && isMountedRef.current) {
|
|
setSession(session);
|
|
setUser(session.user);
|
|
sessionVerifiedRef.current = true;
|
|
} else if (updateLoadingState && isMountedRef.current) {
|
|
setLoading(false);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[Auth] Session verification error:', error);
|
|
if (updateLoadingState && isMountedRef.current) {
|
|
setLoading(false);
|
|
}
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Keep loading state ref in sync
|
|
useEffect(() => {
|
|
loadingStateRef.current = loading;
|
|
console.log('[Auth] Loading state changed:', loading);
|
|
}, [loading]);
|
|
|
|
useEffect(() => {
|
|
// CRITICAL: Set up listener FIRST to catch all events
|
|
const {
|
|
data: { subscription },
|
|
} = supabase.auth.onAuthStateChange((event, session) => {
|
|
console.log('[Auth] State change:', event, 'User:', session?.user?.email || 'none');
|
|
|
|
if (!isMountedRef.current) return;
|
|
|
|
const currentEmail = session?.user?.email;
|
|
const newEmailPending = session?.user?.new_email;
|
|
|
|
// Clear any error
|
|
setSessionError(null);
|
|
|
|
// Explicitly handle SIGNED_IN and INITIAL_SESSION events
|
|
if (event === 'SIGNED_IN' && session) {
|
|
console.log('[Auth] SIGNED_IN detected, setting session and user');
|
|
setSession(session);
|
|
setUser(session.user);
|
|
sessionVerifiedRef.current = true;
|
|
} else if (event === 'INITIAL_SESSION') {
|
|
if (session?.user) {
|
|
console.log('[Auth] INITIAL_SESSION detected with session, setting user');
|
|
setSession(session);
|
|
setUser(session.user);
|
|
sessionVerifiedRef.current = true;
|
|
} else {
|
|
console.log('[Auth] INITIAL_SESSION detected with NO session - user not logged in');
|
|
setSession(null);
|
|
setUser(null);
|
|
setProfile(null);
|
|
sessionVerifiedRef.current = false;
|
|
// CRITICAL: Set loading to false immediately when no session
|
|
setLoading(false);
|
|
}
|
|
} else if (event === 'SIGNED_OUT') {
|
|
console.log('[Auth] SIGNED_OUT detected, clearing session');
|
|
setSession(null);
|
|
setUser(null);
|
|
setProfile(null);
|
|
sessionVerifiedRef.current = false;
|
|
} else {
|
|
setSession(session);
|
|
setUser(session?.user ?? null);
|
|
}
|
|
|
|
// Track pending email changes
|
|
setPendingEmail(newEmailPending ?? null);
|
|
|
|
// 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 () => {
|
|
if (!isMountedRef.current) return;
|
|
|
|
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) {
|
|
console.error('Error updating Novu after email confirmation:', error);
|
|
} finally {
|
|
novuUpdateTimeoutRef.current = null;
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
// Update tracked email
|
|
if (currentEmail) {
|
|
previousEmailRef.current = currentEmail;
|
|
}
|
|
|
|
if (session?.user) {
|
|
// Clear any existing profile fetch timeout
|
|
if (profileFetchTimeoutRef.current) {
|
|
clearTimeout(profileFetchTimeoutRef.current);
|
|
}
|
|
|
|
// Defer profile fetch to avoid deadlock
|
|
const shouldWaitForProfile = (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') && session !== null;
|
|
console.log('[Auth] Profile fetch deferred, shouldWaitForProfile:', shouldWaitForProfile, 'event:', event);
|
|
profileFetchTimeoutRef.current = setTimeout(() => {
|
|
if (!isMountedRef.current) return;
|
|
fetchProfile(session.user.id, 0, () => {
|
|
if (isMountedRef.current && shouldWaitForProfile) {
|
|
console.log('[Auth] Profile fetch complete, setting loading to false');
|
|
setLoading(false);
|
|
}
|
|
});
|
|
profileFetchTimeoutRef.current = null;
|
|
}, 0);
|
|
} else {
|
|
if (isMountedRef.current) {
|
|
setProfile(null);
|
|
// CRITICAL: Always resolve loading when there's no session
|
|
if (event === 'INITIAL_SESSION' || event === 'SIGNED_OUT') {
|
|
console.log('[Auth] No session for event:', event, '- setting loading to false');
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// CRITICAL: Always set loading false for non-session events
|
|
if (event !== 'SIGNED_IN' && event !== 'INITIAL_SESSION') {
|
|
console.log('[Auth] Setting loading to false immediately for event:', event);
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
// THEN get initial session
|
|
supabase.auth.getSession().then(({ data: { session }, error }) => {
|
|
if (!isMountedRef.current) return;
|
|
|
|
if (error) {
|
|
console.error('[Auth] Initial session fetch error:', error);
|
|
setSessionError(error.message);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setSession(session);
|
|
setUser(session?.user ?? null);
|
|
|
|
if (session?.user) {
|
|
sessionVerifiedRef.current = true;
|
|
// Fetch profile with completion callback
|
|
fetchProfile(session.user.id, 0, () => {
|
|
if (isMountedRef.current) {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
} else {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
// Add a safety timeout to force loading to resolve
|
|
loadingTimeoutRef.current = setTimeout(() => {
|
|
if (isMountedRef.current && loadingStateRef.current) {
|
|
console.warn('[Auth] Loading timeout reached after 5 seconds, forcing loading to false');
|
|
setLoading(false);
|
|
}
|
|
}, 5000);
|
|
|
|
// Session verification fallback after 2 seconds
|
|
const verificationTimeout = setTimeout(() => {
|
|
if (!sessionVerifiedRef.current && isMountedRef.current) {
|
|
console.log('[Auth] Session not verified, attempting manual verification');
|
|
verifySession();
|
|
}
|
|
}, 2000);
|
|
|
|
// Handle page visibility changes
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === 'visible' && isMountedRef.current) {
|
|
console.log('[Auth] Tab became visible, verifying session');
|
|
verifySession();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
return () => {
|
|
isMountedRef.current = false;
|
|
subscription.unsubscribe();
|
|
clearTimeout(verificationTimeout);
|
|
if (loadingTimeoutRef.current) {
|
|
clearTimeout(loadingTimeoutRef.current);
|
|
}
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
// Clear any pending timeouts
|
|
if (profileFetchTimeoutRef.current) {
|
|
clearTimeout(profileFetchTimeoutRef.current);
|
|
profileFetchTimeoutRef.current = null;
|
|
}
|
|
if (novuUpdateTimeoutRef.current) {
|
|
clearTimeout(novuUpdateTimeoutRef.current);
|
|
novuUpdateTimeoutRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const signOut = async () => {
|
|
const { error } = await supabase.auth.signOut();
|
|
if (error) {
|
|
console.error('Error signing out:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const clearPendingEmail = () => {
|
|
setPendingEmail(null);
|
|
};
|
|
|
|
const value = {
|
|
user,
|
|
session,
|
|
profile,
|
|
loading,
|
|
pendingEmail,
|
|
sessionError,
|
|
signOut,
|
|
refreshProfile,
|
|
verifySession,
|
|
clearPendingEmail,
|
|
};
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
export const AuthProvider = AuthProviderComponent;
|
|
|
|
export function useAuth() {
|
|
const context = useContext(AuthContext);
|
|
if (context === undefined) {
|
|
throw new Error('useAuth must be used within an AuthProvider');
|
|
}
|
|
return context;
|
|
}
|