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;
+}