Files
thrilltrack-explorer/src/hooks/useAuth.tsx
gpt-engineer-app[bot] 0330fbd1f3 Fix: Implement complete fix
2025-10-11 16:12:45 +00:00

343 lines
11 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 fetchProfile = async (userId: string, retryCount = 0) => {
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);
}
}, delay);
return;
}
// Show user-friendly error notification only after all retries
if (isMountedRef.current && retryCount >= 3) {
toast({
title: "Profile Loading Error",
description: "Unable to load your profile. Please refresh the page or try again later.",
variant: "destructive",
});
}
return;
}
// Only update state if component is still mounted
if (isMountedRef.current) {
setProfile(data as Profile);
profileRetryCountRef.current = 0; // Reset retry count on success
}
} 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);
}
}, delay);
}
}
};
const refreshProfile = async () => {
if (user) {
await fetchProfile(user.id);
}
};
// Verify session is still valid
const verifySession = async () => {
try {
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
console.error('[Auth] Session verification failed:', error);
setSessionError(error.message);
return false;
}
if (!session) {
console.log('[Auth] No active session found');
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);
fetchProfile(session.user.id);
}
return true;
} catch (error) {
console.error('[Auth] Session verification error:', error);
return false;
}
};
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 event for iframe compatibility
if (event === 'SIGNED_IN' && session) {
console.log('[Auth] SIGNED_IN detected, setting session and user');
setSession(session);
setUser(session.user);
sessionVerifiedRef.current = true;
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
profileFetchTimeoutRef.current = setTimeout(() => {
if (!isMountedRef.current) return;
fetchProfile(session.user.id);
profileFetchTimeoutRef.current = null;
}, 0);
} else {
if (isMountedRef.current) {
setProfile(null);
}
}
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);
}
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
fetchProfile(session.user.id);
sessionVerifiedRef.current = true;
}
setLoading(false);
});
// 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);
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;
}