diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 72e5eae9a2..c144f9726e 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -113,6 +113,16 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf")); } + @Test + void testIsFrontendRoute_editorRouteOwnedByFrontend() { + // /editor (and its tool routes) is an SPA route: a direct-nav/refresh must + // serve index.html, not the auth filter's 302-to-/login. Regression test for + // the editor moving from / to /editor, whose refresh bounced processor users + // to the processor because the redirect dropped the return path. + assertTrue(RequestUriUtils.isFrontendRoute("", "/editor")); + assertTrue(RequestUriUtils.isFrontendRoute("/app", "/app/editor")); + } + @Test void testIsFrontendRoute_filesRouteOwnedByFrontend() { // /files and /files/ are FileManagerView routes - they diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 9fc4428f73..0f0c7315d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -357,12 +357,12 @@ public class SecurityConfiguration { req -> { String uri = req.getRequestURI(); String contextPath = req.getContextPath(); - // Check if it's a public auth endpoint or static - // resource return RequestUriUtils.isStaticResource( contextPath, uri) || RequestUriUtils.isPublicAuthEndpoint( - uri, contextPath); + uri, contextPath) + || RequestUriUtils.isFrontendRoute( + contextPath, uri); }) .permitAll() .anyRequest() diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index 29ae9d9d79..5553f202fb 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -234,7 +234,10 @@ describe("Login", () => { ); }; - afterEach(() => window.history.replaceState({}, "", "/")); + afterEach(() => { + window.history.replaceState({}, "", "/"); + sessionStorage.clear(); + }); it("returns to where the user came from", async () => { signedIn(); @@ -247,6 +250,24 @@ describe("Login", () => { }); }); + // A full-page 401 redirect drops router state and Spring can strip ?from=, + // leaving the return path only in the sessionStorage stash. Without reading + // it here a processor user refreshing /editor falls through to the role + // router and lands on the processor. + it("returns to the stashed path when there is no ?from=", async () => { + signedIn(); + sessionStorage.setItem("stirling_post_login_path", "/compress"); + renderAtLogin(""); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith("/compress", { + replace: true, + }); + }); + // Consumed, so a later sign-in can't reuse a stale path. + expect(sessionStorage.getItem("stirling_post_login_path")).toBeNull(); + }); + // Delegated to the shared isSafePostLoginRedirect, so the backslash form // (browsers normalise "\" to "/") and auth routes are covered too. it.each([ diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index b134d9ee89..7fd3e86670 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -7,7 +7,10 @@ import { } from "react-router-dom"; import { Button } from "@app/ui/Button"; import { isSafePostLoginRedirect } from "@app/auth"; -import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; +import { + setPostLoginRedirectPath, + consumePostLoginRedirectPath, +} from "@app/auth/spring/springAuthClient"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useTranslation } from "react-i18next"; @@ -207,7 +210,8 @@ export default function Login() { useEffect(() => { if (loading) return; if (!session) return; - const returnPath = resolveReturnPath(); + const stashed = consumePostLoginRedirectPath(); + const returnPath = resolveReturnPath() ?? stashed; if (returnPath) { navigate(returnPath, { replace: true }); return; diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts index 8615156e9a..3f50e1bb4c 100644 --- a/frontend/editor/src/proprietary/services/apiClientSetup.ts +++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts @@ -1,6 +1,7 @@ import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; import { withBasePath } from "@app/constants/app"; import { getBrowserId } from "@app/utils/browserIdentifier"; +import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; let isRefreshing = false; let failedQueue: Array<{ @@ -93,6 +94,9 @@ async function refreshAuthToken(client: AxiosInstance): Promise { // Redirect to login const loginPath = withBasePath("/login"); if (window.location.pathname !== loginPath) { + setPostLoginRedirectPath( + window.location.pathname + window.location.search, + ); console.log("[API Client] Redirecting to login page..."); window.location.href = loginPath; }