From c09c6cf7ebf492c2cdf58c4440f08069467ac0f6 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Mon, 8 Dec 2025 14:57:36 +0000 Subject: [PATCH] final --- frontend/src/components/common/PageHeader.tsx | 57 ++++++++++- frontend/src/layout/AppLayout.tsx | 53 +--------- .../src/pages/account/PlansAndBillingPage.tsx | 3 +- frontend/src/services/api.ts | 75 +++++++++----- frontend/src/store/authStore.ts | 99 +++++++++++++++++-- 5 files changed, 201 insertions(+), 86 deletions(-) diff --git a/frontend/src/components/common/PageHeader.tsx b/frontend/src/components/common/PageHeader.tsx index 86b9ba75..7182a030 100644 --- a/frontend/src/components/common/PageHeader.tsx +++ b/frontend/src/components/common/PageHeader.tsx @@ -3,10 +3,12 @@ * Used across all Planner and Writer module pages * Includes: Page title, last updated, site/sector info, and selectors */ -import React, { ReactNode } from 'react'; +import React, { ReactNode, useEffect, useRef } from 'react'; import { useSiteStore } from '../../store/siteStore'; import { useSectorStore } from '../../store/sectorStore'; import SiteAndSectorSelector from './SiteAndSectorSelector'; +import { trackLoading } from './LoadingStateMonitor'; +import { useErrorHandler } from '../../hooks/useErrorHandler'; interface PageHeaderProps { title: string; @@ -33,7 +35,58 @@ export default function PageHeader({ navigation, }: PageHeaderProps) { const { activeSite } = useSiteStore(); - const { activeSector } = useSectorStore(); + const { activeSector, loadSectorsForSite } = useSectorStore(); + const { addError } = useErrorHandler('PageHeader'); + const lastSiteId = useRef(null); + const isLoadingSector = useRef(false); + + // Load sectors when active site changes - only for pages that need site/sector context + useEffect(() => { + // Skip sector loading for pages that hide site/sector selector (account/billing pages) + if (hideSiteSector) return; + + const currentSiteId = activeSite?.id ?? null; + + // Only load if: + // 1. We have a site ID + // 2. The site is active (inactive sites can't have accessible sectors) + // 3. It's different from the last one we loaded + // 4. We're not already loading + if (currentSiteId && activeSite?.is_active && currentSiteId !== lastSiteId.current && !isLoadingSector.current) { + lastSiteId.current = currentSiteId; + isLoadingSector.current = true; + trackLoading('sector-loading', true); + + // Add timeout to prevent infinite loading + const timeoutId = setTimeout(() => { + if (isLoadingSector.current) { + console.error('PageHeader: Sector loading timeout after 35 seconds'); + trackLoading('sector-loading', false); + isLoadingSector.current = false; + addError(new Error('Sector loading timeout - check network connection'), 'PageHeader.loadSectorsForSite'); + } + }, 35000); + + loadSectorsForSite(currentSiteId) + .catch((error) => { + // Don't log 403/404 errors as they're expected for inactive sites + if (error.status !== 403 && error.status !== 404) { + console.error('PageHeader: Error loading sectors:', error); + addError(error, 'PageHeader.loadSectorsForSite'); + } + }) + .finally(() => { + clearTimeout(timeoutId); + trackLoading('sector-loading', false); + isLoadingSector.current = false; + }); + } else if (currentSiteId && !activeSite?.is_active) { + // Site is inactive - clear sectors and reset lastSiteId + lastSiteId.current = null; + const { useSectorStore } = require('../../store/sectorStore'); + useSectorStore.getState().clearActiveSector(); + } + }, [activeSite?.id, activeSite?.is_active, hideSiteSector, loadSectorsForSite, addError]); const badgeColors = { blue: 'bg-blue-600 dark:bg-blue-500', diff --git a/frontend/src/layout/AppLayout.tsx b/frontend/src/layout/AppLayout.tsx index 2811644c..8f43e81e 100644 --- a/frontend/src/layout/AppLayout.tsx +++ b/frontend/src/layout/AppLayout.tsx @@ -5,7 +5,6 @@ import AppHeader from "./AppHeader"; import Backdrop from "./Backdrop"; import AppSidebar from "./AppSidebar"; import { useSiteStore } from "../store/siteStore"; -import { useSectorStore } from "../store/sectorStore"; import { useAuthStore } from "../store/authStore"; import { useBillingStore } from "../store/billingStore"; import { useHeaderMetrics } from "../context/HeaderMetricsContext"; @@ -16,15 +15,12 @@ import ResourceDebugOverlay from "../components/debug/ResourceDebugOverlay"; const LayoutContent: React.FC = () => { const { isExpanded, isHovered, isMobileOpen } = useSidebar(); const { loadActiveSite, activeSite } = useSiteStore(); - const { loadSectorsForSite } = useSectorStore(); const { refreshUser, isAuthenticated } = useAuthStore(); const { balance, loadBalance } = useBillingStore(); const { setMetrics } = useHeaderMetrics(); const { addError } = useErrorHandler('AppLayout'); const hasLoadedSite = useRef(false); - const lastSiteId = useRef(null); const isLoadingSite = useRef(false); - const isLoadingSector = useRef(false); const [debugEnabled, setDebugEnabled] = useState(false); const lastUserRefresh = useRef(0); @@ -86,51 +82,10 @@ const LayoutContent: React.FC = () => { checkTokenAndLoad(); }, [isAuthenticated]); // Run when authentication state changes - // Load sectors when active site changes (by ID, not object reference) - useEffect(() => { - const currentSiteId = activeSite?.id ?? null; - - // Only load if: - // 1. We have a site ID - // 2. The site is active (inactive sites can't have accessible sectors) - // 3. It's different from the last one we loaded - // 4. We're not already loading - if (currentSiteId && activeSite?.is_active && currentSiteId !== lastSiteId.current && !isLoadingSector.current) { - lastSiteId.current = currentSiteId; - isLoadingSector.current = true; - trackLoading('sector-loading', true); - - // Add timeout to prevent infinite loading - // Match API timeout (30s) + buffer for network delays - const timeoutId = setTimeout(() => { - if (isLoadingSector.current) { - console.error('AppLayout: Sector loading timeout after 35 seconds'); - trackLoading('sector-loading', false); - isLoadingSector.current = false; - addError(new Error('Sector loading timeout - check network connection'), 'AppLayout.loadSectorsForSite'); - } - }, 35000); // 35 seconds to match API timeout (30s) + buffer - - loadSectorsForSite(currentSiteId) - .catch((error) => { - // Don't log 403/404 errors as they're expected for inactive sites - if (error.status !== 403 && error.status !== 404) { - console.error('AppLayout: Error loading sectors:', error); - addError(error, 'AppLayout.loadSectorsForSite'); - } - }) - .finally(() => { - clearTimeout(timeoutId); - trackLoading('sector-loading', false); - isLoadingSector.current = false; - }); - } else if (currentSiteId && !activeSite?.is_active) { - // Site is inactive - clear sectors and reset lastSiteId - lastSiteId.current = null; - const { useSectorStore } = require('../store/sectorStore'); - useSectorStore.getState().clearActiveSector(); - } - }, [activeSite?.id, activeSite?.is_active]); // Depend on both ID and is_active + // Sector loading moved to PageHeader component + // This ensures sectors are only loaded when content pages (Planner/Writer/Optimizer) mount + // Account/billing pages don't use PageHeader with site/sector selector, so they won't trigger sector loading + // This prevents unnecessary 404 errors on /account/plans and similar routes // Refresh user data on mount and when app version changes (after code updates) // This ensures changes are reflected immediately without requiring re-login diff --git a/frontend/src/pages/account/PlansAndBillingPage.tsx b/frontend/src/pages/account/PlansAndBillingPage.tsx index 7d86e213..91fc3a5a 100644 --- a/frontend/src/pages/account/PlansAndBillingPage.tsx +++ b/frontend/src/pages/account/PlansAndBillingPage.tsx @@ -624,6 +624,7 @@ export default function PlansAndBillingPage() { + )} {/* Billing History Tab */} @@ -810,4 +811,4 @@ export default function PlansAndBillingPage() { ); -} +} \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 44e8a64e..96add5b6 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -154,8 +154,56 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo // Read response body once (can only be consumed once) const text = await response.text(); - // Handle 402/403 for plan/limits gracefully by tagging error - if (response.status === 402 || response.status === 403) { + // Handle 403 Forbidden - check for authentication errors FIRST before throwing + if (response.status === 403) { + try { + const errorData = text ? JSON.parse(text) : null; + const errorMessage = errorData?.detail || errorData?.message || errorData?.error || response.statusText; + + // Check if it's an authentication credentials error + if (errorMessage?.includes?.('Authentication credentials') || + errorMessage?.includes?.('not authenticated') || + errorMessage?.includes?.('Invalid token') || + errorMessage?.includes?.('Token has expired')) { + + // CRITICAL: Authentication token is invalid/missing - force logout + const authState = useAuthStore.getState(); + if (authState?.isAuthenticated || authState?.token) { + console.warn('Authentication token invalid - forcing logout'); + const { logout } = useAuthStore.getState(); + logout(); + + // Redirect to login page + if (typeof window !== 'undefined') { + window.location.href = '/signin'; + } + } + + // Throw authentication error + let err: any = new Error(errorMessage); + err.status = 403; + err.data = errorData; + throw err; + } + + // Not an auth error - could be permissions/plan issue + let err: any = new Error(errorMessage); + err.status = 403; + err.data = errorData; + throw err; + } catch (e: any) { + // If it's the error we just threw, re-throw it + if (e.status === 403) throw e; + + // Parsing failed - throw generic 403 error + let err: any = new Error(text || response.statusText); + err.status = 403; + throw err; + } + } + + // Handle 402 Payment Required - plan/limits issue + if (response.status === 402) { let err: any = new Error(response.statusText); err.status = response.status; try { @@ -168,29 +216,6 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo throw err; } - // Handle 403 Forbidden with authentication error - clear invalid tokens - if (response.status === 403) { - try { - const errorData = text ? JSON.parse(text) : null; - // Check if it's an authentication credentials error - if (errorData?.detail?.includes('Authentication credentials') || - errorData?.message?.includes('Authentication credentials') || - errorData?.error?.includes('Authentication credentials')) { - // Only logout if we actually have a token stored (means it's invalid) - // If no token, it might be a race condition after login - don't logout - const authState = useAuthStore.getState(); - if (authState?.token || authState?.isAuthenticated) { - // Token exists but is invalid - clear auth state and force re-login - const { logout } = useAuthStore.getState(); - logout(); - } - // Don't throw here - let the error handling below show the error - } - } catch (e) { - // If parsing fails, continue with normal error handling - } - } - // Handle 401 Unauthorized - try to refresh token if (response.status === 401) { const refreshToken = getRefreshToken(); diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 36cdbcb9..0148f794 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -92,13 +92,35 @@ export const useAuthStore = create()( 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: responseData.access || tokens.access || data.access || null, - refreshToken: responseData.refresh || tokens.refresh || data.refresh || null, + 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)); + } 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 }); @@ -125,12 +147,49 @@ export const useAuthStore = create()( 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; } - // Clear all localStorage to prevent state contamination - localStorage.clear(); - // Clear sessionStorage as well + + // IMPORTANT: Selectively clear auth-related localStorage items + // DO NOT use localStorage.clear() as it breaks Zustand persist middleware + const authKeys = ['auth-storage', 'auth-store', 'site-storage', 'sector-storage', 'billing-storage']; + authKeys.forEach(key => { + try { + localStorage.removeItem(key); + } catch (e) { + console.warn(`Failed to remove ${key}:`, e); + } + }); + + // Clear sessionStorage sessionStorage.clear(); - // Reset auth state - set({ user: null, token: null, refreshToken: null, isAuthenticated: false, loading: false }); + + // 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) => { @@ -166,13 +225,35 @@ export const useAuthStore = create()( const tokens = responseData.tokens || {}; const userData = responseData.user || data.user; + const newToken = tokens.access || responseData.access || data.access || null; + const newRefreshToken = tokens.refresh || responseData.refresh || data.refresh || null; + + // CRITICAL: Set auth state AND immediately persist to localStorage set({ user: userData, - token: tokens.access || responseData.access || data.access || null, - refreshToken: tokens.refresh || responseData.refresh || data.refresh || null, + 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)); + } catch (e) { + console.warn('Failed to persist auth state to localStorage:', e); + } + return userData; } catch (error: any) { // ALWAYS reset loading on error - critical to prevent stuck state