final
This commit is contained in:
@@ -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<number | null>(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',
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const isLoadingSite = useRef(false);
|
||||
const isLoadingSector = useRef(false);
|
||||
const [debugEnabled, setDebugEnabled] = useState(false);
|
||||
const lastUserRefresh = useRef<number>(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
|
||||
|
||||
@@ -624,6 +624,7 @@ export default function PlansAndBillingPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing History Tab */}
|
||||
@@ -810,4 +811,4 @@ export default function PlansAndBillingPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -92,13 +92,35 @@ export const useAuthStore = create<AuthState>()(
|
||||
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<AuthState>()(
|
||||
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<AuthState>()(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user