feat(web): soft-redirect to login on 401 via a navigate bridge (#48)
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
@@ -1,7 +1,23 @@
|
|||||||
/** Hard-navigate to login. Isolated so it can be spied/mocked in tests and swapped
|
type NavigateFn = (to: string, opts?: { replace?: boolean }) => void;
|
||||||
* for a router navigation if needed. */
|
|
||||||
|
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 {
|
export function redirectToLogin(): void {
|
||||||
if (window.location.pathname !== "/login") {
|
const { pathname, search } = window.location;
|
||||||
window.location.assign("/login");
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -1,6 +1,7 @@
|
|||||||
import { lazy, Suspense } from "react";
|
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 { RequireAuth } from "./auth/require-auth";
|
||||||
import { LoginPage } from "./auth/login-page";
|
import { LoginPage } from "./auth/login-page";
|
||||||
import { AppShell } from "./shell/app-shell";
|
import { AppShell } from "./shell/app-shell";
|
||||||
@@ -26,9 +27,18 @@ const FieldsPage = lazy(() =>
|
|||||||
import("./fields/fields-page").then((m) => ({ default: m.FieldsPage })),
|
import("./fields/fields-page").then((m) => ({ default: m.FieldsPage })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function RootLayout() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NavigationBridge />
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const router = createBrowserRouter(
|
const router = createBrowserRouter(
|
||||||
createRoutesFromElements(
|
createRoutesFromElements(
|
||||||
<>
|
<Route element={<RootLayout />}>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
@@ -73,7 +83,7 @@ const router = createBrowserRouter(
|
|||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/objects" replace />} />
|
<Route path="*" element={<Navigate to="/objects" replace />} />
|
||||||
</>,
|
</Route>,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user