diff --git a/web/src/api/auth-redirect.test.ts b/web/src/api/auth-redirect.test.ts new file mode 100644 index 0000000..c51d130 --- /dev/null +++ b/web/src/api/auth-redirect.test.ts @@ -0,0 +1,46 @@ +import { afterEach, expect, test, vi } from "vitest"; + +import { redirectToLogin, setNavigate } from "./auth-redirect"; + +function stubLocation(pathname: string, search = "") { + const assign = vi.fn(); + vi.stubGlobal("location", { pathname, search, assign }); + return assign; +} + +afterEach(() => { + setNavigate(null); + vi.unstubAllGlobals(); +}); + +test("uses the registered navigate to soft-redirect with reason + from", () => { + const assign = stubLocation("/objects/abc", "?x=1"); + const navigate = vi.fn(); + setNavigate(navigate); + + redirectToLogin(); + + expect(navigate).toHaveBeenCalledWith( + "/login?reason=expired&from=%2Fobjects%2Fabc%3Fx%3D1", + { replace: true }, + ); + expect(assign).not.toHaveBeenCalled(); +}); + +test("falls back to a hard navigation when no navigate is registered", () => { + const assign = stubLocation("/objects/abc"); + + redirectToLogin(); + + expect(assign).toHaveBeenCalledWith("/login?reason=expired&from=%2Fobjects%2Fabc"); +}); + +test("does nothing when already on /login", () => { + stubLocation("/login"); + const navigate = vi.fn(); + setNavigate(navigate); + + redirectToLogin(); + + expect(navigate).not.toHaveBeenCalled(); +}); diff --git a/web/src/api/auth-redirect.ts b/web/src/api/auth-redirect.ts index 269a4bf..77f52c5 100644 --- a/web/src/api/auth-redirect.ts +++ b/web/src/api/auth-redirect.ts @@ -1,7 +1,23 @@ -/** Hard-navigate to login. Isolated so it can be spied/mocked in tests and swapped - * for a router navigation if needed. */ +type NavigateFn = (to: string, opts?: { replace?: boolean }) => void; + +let navigateFn: NavigateFn | null = null; + +/** Register (or clear) the router's navigate fn. Called by NavigationBridge. */ +export function setNavigate(fn: NavigateFn | null): void { + navigateFn = fn; +} + +/** Soft-redirect to login on a 401, preserving SPA state and the return path. + * Falls back to a hard navigation when no router navigate is registered yet + * (e.g. a 401 during the very first load). No-op when already on /login. */ export function redirectToLogin(): void { - if (window.location.pathname !== "/login") { - window.location.assign("/login"); + const { pathname, search } = window.location; + if (pathname === "/login") return; + const from = encodeURIComponent(pathname + search); + const target = `/login?reason=expired&from=${from}`; + if (navigateFn) { + navigateFn(target, { replace: true }); + } else { + window.location.assign(target); } } diff --git a/web/src/app.tsx b/web/src/app.tsx index 907a7cc..5f227b1 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from "react"; -import { createBrowserRouter, createRoutesFromElements, Navigate, Route, RouterProvider } from "react-router-dom"; +import { createBrowserRouter, createRoutesFromElements, Navigate, Outlet, Route, RouterProvider } from "react-router-dom"; +import { NavigationBridge } from "./shell/navigation-bridge"; import { RequireAuth } from "./auth/require-auth"; import { LoginPage } from "./auth/login-page"; import { AppShell } from "./shell/app-shell"; @@ -26,9 +27,18 @@ const FieldsPage = lazy(() => import("./fields/fields-page").then((m) => ({ default: m.FieldsPage })), ); +function RootLayout() { + return ( + <> + + + + ); +} + const router = createBrowserRouter( createRoutesFromElements( - <> + }> } /> }> }> @@ -73,7 +83,7 @@ const router = createBrowserRouter( } /> - , + , ), ); diff --git a/web/src/shell/navigation-bridge.tsx b/web/src/shell/navigation-bridge.tsx new file mode 100644 index 0000000..f920826 --- /dev/null +++ b/web/src/shell/navigation-bridge.tsx @@ -0,0 +1,16 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +import { setNavigate } from "../api/auth-redirect"; + +/** Bridges React Router's navigate to the non-React 401 handler. Renders nothing. */ +export function NavigationBridge() { + const navigate = useNavigate(); + + useEffect(() => { + setNavigate(navigate); + return () => setNavigate(null); + }, [navigate]); + + return null; +}