/** * 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) => { 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 }), }); 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'); } set({ user: userData, token: responseData.access || tokens.access || data.access || null, refreshToken: responseData.refresh || tokens.refresh || data.refresh || null, isAuthenticated: true, loading: false }); } 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: () => { // Clear cookies (session contamination protection) document.cookie.split(";").forEach((c) => { document.cookie = `${c.split("=")[0].trim()}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/`; }); localStorage.clear(); set({ user: null, token: null, refreshToken: null, isAuthenticated: false, loading: false }); }, 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 }), }); 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; set({ user: userData, token: tokens.access || responseData.access || data.access || null, refreshToken: tokens.refresh || responseData.refresh || data.refresh || null, isAuthenticated: true, loading: false }); } 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 // This handles edge cases like network timeouts, browser crashes, etc. 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) { throw createAuthError('Account not configured for this user. Please contact support.', 'ACCOUNT_REQUIRED'); } if (!refreshedUser.account.plan) { 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; } } }; }, } ) );