diff --git a/frontend/src/components/auth/AuthGuard.tsx b/frontend/src/components/auth/AuthGuard.tsx index 6b5326a..f80f0e5 100644 --- a/frontend/src/components/auth/AuthGuard.tsx +++ b/frontend/src/components/auth/AuthGuard.tsx @@ -63,11 +63,16 @@ function LoadingSpinner() { export function AuthGuard({ children, requireAdmin = false, fallback }: AuthGuardProps) { const router = useRouter(); const pathname = usePathname(); - const { isAuthenticated, isLoading: authLoading, user } = useAuthStore(); + const { isAuthenticated, isLoading: authLoading, user, _hasHydrated } = useAuthStore(); // Fetch user data if authenticated but user not loaded const { isLoading: userLoading } = useMe(); + // Wait for store to hydrate from localStorage to prevent hook order issues + if (!_hasHydrated) { + return fallback ? <>{fallback} : ; + } + // Determine overall loading state const isLoading = authLoading || (isAuthenticated && !user && userLoading); diff --git a/frontend/src/lib/stores/authStore.ts b/frontend/src/lib/stores/authStore.ts index d4a34de..5b9b03d 100644 --- a/frontend/src/lib/stores/authStore.ts +++ b/frontend/src/lib/stores/authStore.ts @@ -31,6 +31,7 @@ interface AuthState { isAuthenticated: boolean; isLoading: boolean; tokenExpiresAt: number | null; // Unix timestamp + _hasHydrated: boolean; // Internal flag for persist middleware // Actions setAuth: (user: User, accessToken: string, refreshToken: string, expiresIn?: number) => Promise; @@ -39,6 +40,7 @@ interface AuthState { clearAuth: () => Promise; loadAuthFromStorage: () => Promise; isTokenExpired: () => boolean; + setHasHydrated: (hasHydrated: boolean) => void; // Internal method for persist } /** @@ -125,6 +127,7 @@ export const useAuthStore = create()( isAuthenticated: false, isLoading: false, // No longer needed - persist handles hydration tokenExpiresAt: null, + _hasHydrated: false, // Set complete auth state (user + tokens) setAuth: async (user, accessToken, refreshToken, expiresIn) => { @@ -223,6 +226,11 @@ export const useAuthStore = create()( if (!tokenExpiresAt) return true; return Date.now() >= tokenExpiresAt; }, + + // Internal method for persist middleware + setHasHydrated: (hasHydrated) => { + set({ _hasHydrated: hasHydrated }); + }, }), { name: 'auth_store', // Storage key @@ -238,6 +246,10 @@ export const useAuthStore = create()( if (error) { console.error('Failed to rehydrate auth store:', error); } + // Mark store as hydrated to prevent rendering issues + if (state) { + state.setHasHydrated(true); + } }; }, } diff --git a/frontend/tests/components/auth/AuthGuard.test.tsx b/frontend/tests/components/auth/AuthGuard.test.tsx index 6031b91..3771806 100644 --- a/frontend/tests/components/auth/AuthGuard.test.tsx +++ b/frontend/tests/components/auth/AuthGuard.test.tsx @@ -23,10 +23,12 @@ let mockAuthState: { isAuthenticated: boolean; isLoading: boolean; user: any; + _hasHydrated: boolean; } = { isAuthenticated: false, isLoading: false, user: null, + _hasHydrated: true, // In tests, assume store is always hydrated }; jest.mock('@/lib/stores/authStore', () => ({ @@ -69,6 +71,7 @@ describe('AuthGuard', () => { isAuthenticated: false, isLoading: false, user: null, + _hasHydrated: true, // In tests, assume store is always hydrated }; mockMeState = { isLoading: false, @@ -82,6 +85,7 @@ describe('AuthGuard', () => { isAuthenticated: false, isLoading: true, user: null, + _hasHydrated: true, }; render( @@ -100,6 +104,7 @@ describe('AuthGuard', () => { isAuthenticated: true, isLoading: false, user: null, + _hasHydrated: true, }; mockMeState = { isLoading: true, @@ -121,6 +126,7 @@ describe('AuthGuard', () => { isAuthenticated: false, isLoading: true, user: null, + _hasHydrated: true, }; render( @@ -142,6 +148,7 @@ describe('AuthGuard', () => { isAuthenticated: false, isLoading: false, user: null, + _hasHydrated: true, }; render( @@ -172,6 +179,7 @@ describe('AuthGuard', () => { created_at: '2024-01-01', updated_at: '2024-01-01', }, + _hasHydrated: true, }; render( @@ -197,10 +205,11 @@ describe('AuthGuard', () => { first_name: 'Admin', last_name: 'User', is_active: true, - is_superuser: true, + is_superuser: true, // Admin user must have is_superuser: true created_at: '2024-01-01', updated_at: '2024-01-01', }, + _hasHydrated: true, }; render( @@ -219,7 +228,7 @@ describe('AuthGuard', () => { isAuthenticated: true, isLoading: false, user: { - id: '1', + id: '1', email: 'user@example.com', first_name: 'Regular', last_name: 'User', @@ -228,6 +237,7 @@ describe('AuthGuard', () => { created_at: '2024-01-01', updated_at: '2024-01-01', }, + _hasHydrated: true, }; render( @@ -249,7 +259,7 @@ describe('AuthGuard', () => { isAuthenticated: true, isLoading: false, user: { - id: '1', + id: '1', email: 'user@example.com', first_name: 'Regular', last_name: 'User', @@ -258,6 +268,7 @@ describe('AuthGuard', () => { created_at: '2024-01-01', updated_at: '2024-01-01', }, + _hasHydrated: true, }; render( @@ -278,6 +289,7 @@ describe('AuthGuard', () => { isAuthenticated: false, isLoading: false, user: null, + _hasHydrated: true, }; render( @@ -301,6 +313,7 @@ describe('AuthGuard', () => { isAuthenticated: true, isLoading: false, user: null, + _hasHydrated: true, }; mockMeState = { isLoading: true, @@ -322,7 +335,7 @@ describe('AuthGuard', () => { isAuthenticated: true, isLoading: false, user: { - id: '1', + id: '1', email: 'user@example.com', first_name: 'Test', last_name: 'User', @@ -331,6 +344,7 @@ describe('AuthGuard', () => { created_at: '2024-01-01', updated_at: '2024-01-01', }, + _hasHydrated: true, }; mockMeState = { isLoading: false,