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