Files
thrilltrack-explorer/src/hooks/useAuth.tsx
2025-10-14 16:47:49 +00:00

319 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';
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<void>;
verifySession: () => Promise<boolean>;
clearPendingEmail: () => void;
checkAalStepUp: () => Promise<CheckAalResult>;
}
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 [aal, setAal] = useState<AALLevel | 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 novuUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const previousEmailRef = useRef<string | null>(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<CheckAalResult> => {
return checkAalStepUpService(session);
};
const value = {
user,
session,
aal,
loading,
pendingEmail,
sessionError,
signOut,
verifySession,
clearPendingEmail,
checkAalStepUp,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
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;
}