From 0902e4bf7ee20f7d15dabacfafa16896533cbd81 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:06:39 -0600 Subject: [PATCH] fix redirect race condition Coordinate 401 redirect logic between ApiProvider and ProtectedRoute using a shared flag to prevent multiple simultaneous redirects that caused UI flashing. Ensure both auth error paths check and set the redirect flag before navigating to login, eliminating race conditions where both mechanisms could trigger at once --- web/src/api/auth-redirect.ts | 12 +++++++++++ web/src/api/index.tsx | 9 +++----- web/src/components/auth/ProtectedRoute.tsx | 24 ++++++++++++++++++++-- 3 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 web/src/api/auth-redirect.ts diff --git a/web/src/api/auth-redirect.ts b/web/src/api/auth-redirect.ts new file mode 100644 index 000000000..f19e2b8a5 --- /dev/null +++ b/web/src/api/auth-redirect.ts @@ -0,0 +1,12 @@ +// Module-level flag to prevent multiple simultaneous redirects +// (eg, when multiple SWR queries fail with 401 at once, or when +// both ApiProvider and ProtectedRoute try to redirect) +let _isRedirectingToLogin = false; + +export function isRedirectingToLogin(): boolean { + return _isRedirectingToLogin; +} + +export function setRedirectingToLogin(value: boolean): void { + _isRedirectingToLogin = value; +} diff --git a/web/src/api/index.tsx b/web/src/api/index.tsx index 540a328eb..e5c5617ab 100644 --- a/web/src/api/index.tsx +++ b/web/src/api/index.tsx @@ -3,13 +3,10 @@ import { SWRConfig } from "swr"; import { WsProvider } from "./ws"; import axios from "axios"; import { ReactNode } from "react"; +import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect"; axios.defaults.baseURL = `${baseUrl}api/`; -// Module-level flag to prevent multiple simultaneous redirects -// (eg, when multiple SWR queries fail with 401 at once) -let isRedirectingToLogin = false; - type ApiProviderType = { children?: ReactNode; options?: Record; @@ -35,8 +32,8 @@ export function ApiProvider({ children, options }: ApiProviderType) { ) { // redirect to the login page if not already there const loginPage = error.response.headers.get("location") ?? "login"; - if (window.location.href !== loginPage && !isRedirectingToLogin) { - isRedirectingToLogin = true; + if (window.location.href !== loginPage && !isRedirectingToLogin()) { + setRedirectingToLogin(true); window.location.href = loginPage; } } diff --git a/web/src/components/auth/ProtectedRoute.tsx b/web/src/components/auth/ProtectedRoute.tsx index 18dc50d53..55edc60bd 100644 --- a/web/src/components/auth/ProtectedRoute.tsx +++ b/web/src/components/auth/ProtectedRoute.tsx @@ -1,7 +1,11 @@ -import { useContext } from "react"; +import { useContext, useEffect } from "react"; import { Navigate, Outlet } from "react-router-dom"; import { AuthContext } from "@/context/auth-context"; import ActivityIndicator from "../indicators/activity-indicator"; +import { + isRedirectingToLogin, + setRedirectingToLogin, +} from "@/api/auth-redirect"; export default function ProtectedRoute({ requiredRoles, @@ -10,6 +14,20 @@ export default function ProtectedRoute({ }) { const { auth } = useContext(AuthContext); + // Redirect to login page when not authenticated + // don't use because we need a full page load to reset state + useEffect(() => { + if ( + !auth.isLoading && + auth.isAuthenticated && + !auth.user && + !isRedirectingToLogin() + ) { + setRedirectingToLogin(true); + window.location.href = "/login"; + } + }, [auth.isLoading, auth.isAuthenticated, auth.user]); + if (auth.isLoading) { return ( @@ -23,7 +41,9 @@ export default function ProtectedRoute({ // Authenticated mode (8971): require login if (!auth.user) { - return ; + return ( + + ); } // If role is null (shouldn’t happen if isAuthenticated, but type safety), fallback