/** * Authentication Store (Zustand) * Manages authentication state, user, and account information */ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { fetchAPI } from '../services/api'; type AuthErrorCode = 'ACCOUNT_REQUIRED' | 'PLAN_REQUIRED' | 'AUTH_FAILED'; function createAuthError(message: string, code: AuthErrorCode): Error & { code: AuthErrorCode } { const error = new Error(message) as Error & { code: AuthErrorCode }; error.code = code; return error; } interface User { id: number; email: string; username: string; role: string; account?: { id: number; name: string; slug: string; credits: number; status: string; plan?: any; // plan info is optional but required for access gating }; } interface AuthState { user: User | null; token: string | null; refreshToken: string | null; isAuthenticated: boolean; loading: boolean; // Actions login: (email: string, password: string) => Promise; logout: () => void; register: (data: any) => Promise; setUser: (user: User | null) => void; setToken: (token: string | null) => void; refreshToken: () => Promise; refreshUser: () => Promise; } export const useAuthStore = create()( persist( (set, get) => ({ user: null, token: null, isAuthenticated: false, loading: false, // Always start with loading false - will be set true only during login/register login: async (email, password, rememberMe = false) => { set({ loading: true }); try { const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api'; const response = await fetch(`${API_BASE_URL}/v1/auth/login/`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email, password, remember_me: rememberMe }), }); const data = await response.json(); if (!response.ok || !data.success) { const message = data.error || data.message || 'Login failed'; if (response.status === 402) { throw createAuthError(message, 'PLAN_REQUIRED'); } if (response.status === 403) { throw createAuthError(message, 'ACCOUNT_REQUIRED'); } throw createAuthError(message, 'AUTH_FAILED'); } // Store user and JWT tokens (handle both old and new API formats) const responseData = data.data || data; // Support both formats: new (access/refresh at top level) and old (tokens.access/refresh) const tokens = responseData.tokens || {}; const userData = responseData.user || data.user; if (!userData?.account) { throw createAuthError('Account not configured for this user. Please contact support.', 'ACCOUNT_REQUIRED'); } if (!userData.account.plan) { throw createAuthError('Active subscription required. Visit igny8.com/pricing to subscribe.', 'PLAN_REQUIRED'); } const newToken = responseData.access || tokens.access || data.access || null; const newRefreshToken = responseData.refresh || tokens.refresh || data.refresh || null; // CRITICAL: Set auth state AND immediately persist to localStorage // This prevents race conditions where API calls happen before persist middleware writes set({ user: userData, token: newToken, refreshToken: newRefreshToken, isAuthenticated: true, loading: false }); // Force immediate persist to localStorage (don't wait for Zustand middleware) try { const authState = { state: { user: userData, token: newToken, refreshToken: newRefreshToken, isAuthenticated: true, loading: false }, version: 0 }; localStorage.setItem('auth-storage', JSON.stringify(authState)); // CRITICAL: Also set tokens as separate items for API interceptor if (newToken) { localStorage.setItem('access_token', newToken); } if (newRefreshToken) { localStorage.setItem('refresh_token', newRefreshToken); } } catch (e) { console.warn('Failed to persist auth state to localStorage:', e); } } catch (error: any) { // ALWAYS reset loading on error - critical to prevent stuck state set({ loading: false }); throw new Error(error.message || 'Login failed'); } finally { // Extra safety: ensure loading is ALWAYS false after login attempt completes // This handles edge cases like network timeouts, browser crashes, etc. const current = get(); if (current.loading) { set({ loading: false }); } } }, logout: () => { // Check if there's already a logout reason from automatic logout const existingReason = localStorage.getItem('logout_reason'); if (!existingReason) { // Only store manual logout reason if no automatic reason exists const currentPath = typeof window !== 'undefined' ? window.location.pathname : 'unknown'; const logoutContext = { code: 'MANUAL_LOGOUT', message: 'You have been logged out', path: currentPath, context: { user: get().user?.email || 'unknown', timestamp: new Date().toISOString(), }, timestamp: new Date().toISOString(), source: 'manual_user_action' }; console.error('🚪 MANUAL LOGOUT from page:', currentPath); try { localStorage.setItem('logout_reason', JSON.stringify(logoutContext)); console.error('✅ Stored manual logout_reason'); } catch (e) { console.error('❌ Failed to store logout reason:', e); } } else { console.error('⚠️ Automatic logout reason already exists, not overwriting with manual logout'); console.error('Existing reason:', existingReason); } // CRITICAL: Properly clear ALL cookies to prevent session contamination const cookies = document.cookie.split(";"); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i]; const eqPos = cookie.indexOf("="); const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim(); // Clear cookie for all possible domains and paths document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/"; document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=" + window.location.hostname; document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=." + window.location.hostname; } // IMPORTANT: Selectively clear auth-related localStorage items // DO NOT clear 'logout_reason' - it needs to persist for signin page display! // DO NOT use localStorage.clear() as it breaks Zustand persist middleware const authKeys = ['auth-storage', 'auth-store', 'site-storage', 'sector-storage', 'billing-storage', 'access_token', 'refresh_token']; authKeys.forEach(key => { try { localStorage.removeItem(key); } catch (e) { console.warn(`Failed to remove ${key}:`, e); } }); // Clear sessionStorage sessionStorage.clear(); // Reset auth state to initial values set({ user: null, token: null, refreshToken: null, isAuthenticated: false, loading: false }); // Reset other stores that depend on auth try { // Dynamically import and reset site store import('./siteStore').then(({ useSiteStore }) => { useSiteStore.setState({ activeSite: null, loading: false, error: null }); }); // Dynamically import and reset sector store import('./sectorStore').then(({ useSectorStore }) => { useSectorStore.setState({ activeSector: null, sectors: [], loading: false, error: null }); }); // Dynamically import and reset billing store import('./billingStore').then(({ useBillingStore }) => { useBillingStore.setState({ balance: null, loading: false, error: null }); }); } catch (e) { console.warn('Failed to reset stores on logout:', e); } }, register: async (registerData) => { set({ loading: true }); try { const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api'; const response = await fetch(`${API_BASE_URL}/v1/auth/register/`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ ...registerData, password_confirm: registerData.password, // Add password_confirm plan_slug: registerData.plan_slug, }), }); const data = await response.json(); if (!response.ok || !data.success) { // Handle validation errors const errorMessage = data.message || (data.errors && typeof data.errors === 'object' ? JSON.stringify(data.errors) : data.errors) || 'Registration failed'; throw new Error(errorMessage); } // Store user and JWT tokens (handle nested tokens structure) const responseData = data.data || data; const tokens = responseData.tokens || {}; const userData = responseData.user || data.user; // Extract tokens with multiple fallbacks // Response format: { success: true, data: { user: {...}, tokens: { access, refresh } } } const newToken = tokens.access || responseData.access || data.access || data.data?.tokens?.access || data.tokens?.access || null; const newRefreshToken = tokens.refresh || responseData.refresh || data.refresh || data.data?.tokens?.refresh || data.tokens?.refresh || null; console.log('Registration response parsed:', { hasUserData: !!userData, hasAccessToken: !!newToken, hasRefreshToken: !!newRefreshToken, userEmail: userData?.email, accountId: userData?.account?.id, tokensLocation: tokens.access ? 'tokens.access' : responseData.access ? 'responseData.access' : data.data?.tokens?.access ? 'data.data.tokens.access' : 'not found' }); if (!newToken || !userData) { console.error('Registration succeeded but missing critical data:', { token: newToken, user: userData, fullResponse: data, parsedTokens: tokens, parsedResponseData: responseData }); throw new Error('Registration completed but authentication failed. Please try logging in.'); } // CRITICAL: Set auth state AND immediately persist to localStorage // This prevents race conditions where navigation happens before persist set({ user: userData, token: newToken, refreshToken: newRefreshToken, isAuthenticated: true, loading: false }); // Force immediate persist to localStorage (don't wait for Zustand middleware) try { const authState = { state: { user: userData, token: newToken, refreshToken: newRefreshToken, isAuthenticated: true, loading: false }, version: 0 }; localStorage.setItem('auth-storage', JSON.stringify(authState)); // CRITICAL: Also set tokens as separate items for API interceptor // This ensures fetchAPI can access tokens immediately if (newToken) { localStorage.setItem('access_token', newToken); } if (newRefreshToken) { localStorage.setItem('refresh_token', newRefreshToken); } console.log('Auth state persisted to localStorage successfully'); } catch (e) { console.error('CRITICAL: Failed to persist auth state to localStorage:', e); throw new Error('Failed to save login session. Please try again.'); } // Return user data for success handling return userData; } catch (error: any) { // ALWAYS reset loading on error - critical to prevent stuck state set({ loading: false }); throw new Error(error.message || 'Registration failed'); } finally { // Extra safety: ensure loading is ALWAYS false after register attempt completes const current = get(); if (current.loading) { set({ loading: false }); } } }, setUser: (user) => { set({ user, isAuthenticated: !!user }); }, setToken: (token) => { set({ token }); }, refreshToken: async () => { const state = get(); if (!state.refreshToken) { throw new Error('No refresh token available'); } try { const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api'; const response = await fetch(`${API_BASE_URL}/v1/auth/refresh/`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ refresh: state.refreshToken }), }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.message || 'Token refresh failed'); } // Update access token (API returns access at top level of data) set({ token: data.data?.access || data.access }); // Also refresh user data to get latest account/plan information // This ensures account/plan changes are reflected immediately try { await get().refreshUser(); } catch (userRefreshError) { // If user refresh fails, don't fail the token refresh console.warn('Failed to refresh user data during token refresh:', userRefreshError); } } catch (error: any) { // If refresh fails, logout user set({ user: null, token: null, refreshToken: null, isAuthenticated: false }); throw new Error(error.message || 'Token refresh failed'); } }, refreshUser: async () => { const state = get(); if (!state.token && !state.isAuthenticated) { throw new Error('Not authenticated'); } try { const response = await fetchAPI('/v1/auth/me/'); if (!response || !response.user) { throw new Error('Failed to refresh user data'); } const refreshedUser = response.user; if (!refreshedUser.account) { const logoutReasonData = { code: 'ACCOUNT_REQUIRED', message: 'Account not configured for this user. Please contact support.', path: typeof window !== 'undefined' ? window.location.pathname : 'unknown', context: { user_email: refreshedUser.email, user_id: refreshedUser.id }, timestamp: new Date().toISOString(), source: 'refresh_user_validation' }; console.error('🚨 LOGOUT TRIGGERED - Account Required:', logoutReasonData); localStorage.setItem('logout_reason', JSON.stringify(logoutReasonData)); throw createAuthError('Account not configured for this user. Please contact support.', 'ACCOUNT_REQUIRED'); } if (!refreshedUser.account.plan) { const logoutReasonData = { code: 'PLAN_REQUIRED', message: 'Active subscription required. Visit igny8.com/pricing to subscribe.', path: typeof window !== 'undefined' ? window.location.pathname : 'unknown', context: { user_email: refreshedUser.email, account_name: refreshedUser.account.name, account_id: refreshedUser.account.id }, timestamp: new Date().toISOString(), source: 'refresh_user_validation' }; console.error('🚨 LOGOUT TRIGGERED - Plan Required:', logoutReasonData); localStorage.setItem('logout_reason', JSON.stringify(logoutReasonData)); throw createAuthError('Active subscription required. Visit igny8.com/pricing to subscribe.', 'PLAN_REQUIRED'); } set({ user: refreshedUser, isAuthenticated: true }); } catch (error: any) { // Only logout on specific authentication/authorization errors // Do NOT logout on 401/403 - fetchAPI handles token refresh automatically // A 401 that reaches here means refresh token is invalid, which fetchAPI handles by logging out const isAuthError = error?.code === 'ACCOUNT_REQUIRED' || error?.code === 'PLAN_REQUIRED' || (error?.message && error.message.includes('Not authenticated')); if (isAuthError) { // Real authentication error - logout user console.warn('Authentication error during refresh, logging out:', error); set({ user: null, token: null, refreshToken: null, isAuthenticated: false }); } else { // Network/server error or 401/403 (handled by fetchAPI) - don't logout // The caller (AppLayout) will handle it gracefully console.debug('Non-auth error during refresh (will retry):', error); } throw error; } }, }), { name: 'auth-storage', // NEVER persist loading - it should always be false on app start partialize: (state) => ({ user: state.user, token: state.token, refreshToken: state.refreshToken, isAuthenticated: state.isAuthenticated, // Explicitly exclude loading from persistence }), // On rehydration, ensure loading is always false onRehydrateStorage: () => { return (state, error) => { // Always reset loading to false on rehydration - critical fix for stuck loading state if (state) { state.loading = false; } if (error) { console.error('Auth store rehydration error:', error); // If rehydration fails, ensure loading is false if (state) { state.loading = false; } } }; }, } ) );