diff --git a/frontend/ROLE_BASED_NAVIGATION.md b/frontend/ROLE_BASED_NAVIGATION.md index 86ca1ff3..40b76f54 100644 --- a/frontend/ROLE_BASED_NAVIGATION.md +++ b/frontend/ROLE_BASED_NAVIGATION.md @@ -58,6 +58,12 @@ isn't in the `allow` list: - `redirectTo` defaults to `/` and can be overridden per route. - The attempted path is passed through `location.state.from` so a future redirect target (e.g. after connecting a wallet) can restore it. +- `useRestoreGuardedRoute` (`src/hooks/useRestoreGuardedRoute.ts`), called + from `App.tsx` with the current `role`, consumes that `location.state.from`: + whenever `role` changes, it tries the stashed path once. If the new role + is allowed, the user lands back where they originally tried to go; if not, + `ProtectedRoute` guards it again and the hook won't retry the same value, + so there's no redirect loop. ## Adding a New Gated Route diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 346ee323..0e312272 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,6 +44,7 @@ import { useVault, VaultProvider } from "./context/VaultContext"; import { usePageViewTracking } from "./hooks/useAnalytics"; import { ProtectedRoute } from "./components/ProtectedRoute"; import { resolveUserRole } from "./lib/roles"; +import { useRestoreGuardedRoute } from "./hooks/useRestoreGuardedRoute"; const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); @@ -63,6 +64,7 @@ function AppContent() { const { data: xlmBalance = 0 } = useXlmBalance(walletAddress); const { tvl } = useVault(); const role = useMemo(() => resolveUserRole(walletAddress), [walletAddress]); + useRestoreGuardedRoute(role); useEffect(() => { if ((window as Window & { Cypress?: unknown }).Cypress) { diff --git a/frontend/src/hooks/useRestoreGuardedRoute.test.tsx b/frontend/src/hooks/useRestoreGuardedRoute.test.tsx new file mode 100644 index 00000000..11eaf305 --- /dev/null +++ b/frontend/src/hooks/useRestoreGuardedRoute.test.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { useRestoreGuardedRoute } from "./useRestoreGuardedRoute"; +import { ProtectedRoute } from "../components/ProtectedRoute"; +import type { UserRole } from "../lib/roles"; + +function Harness({ role }: { role: UserRole }) { + useRestoreGuardedRoute(role); + return null; +} + +function TestApp({ + role, + initialEntries, +}: { + role: UserRole; + initialEntries: Array<{ pathname: string; state?: unknown }>; +}) { + return ( + + + + Home} /> + +
Admin
+ + } + /> +
+
+ ); +} + +describe("useRestoreGuardedRoute", () => { + it("does nothing when there is no stashed redirect-back path", () => { + render(); + expect(screen.getByTestId("home")).toBeInTheDocument(); + }); + + it("restores the stashed path on mount when the current role already allows it", () => { + render( + , + ); + expect(screen.getByTestId("admin")).toBeInTheDocument(); + }); + + it("stays put (no crash or loop) when the current role still doesn't allow the stashed path", () => { + render( + , + ); + expect(screen.queryByTestId("admin")).not.toBeInTheDocument(); + expect(screen.getByTestId("home")).toBeInTheDocument(); + }); + + it("restores the stashed path once the role changes to one that allows it", () => { + const { rerender } = render( + , + ); + expect(screen.getByTestId("home")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByTestId("admin")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/hooks/useRestoreGuardedRoute.ts b/frontend/src/hooks/useRestoreGuardedRoute.ts new file mode 100644 index 00000000..37b82aa8 --- /dev/null +++ b/frontend/src/hooks/useRestoreGuardedRoute.ts @@ -0,0 +1,36 @@ +import { useEffect } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +interface GuardedRouteState { + from?: string; +} + +/** + * Completes the redirect-back half of ProtectedRoute's guard: when a + * disallowed role hits a guarded route, ProtectedRoute redirects away and + * stashes the attempted path in `location.state.from` so it can be restored + * later (e.g. once the user connects the wallet that grants access) — but + * nothing consumed that state, so the user was never actually sent back. + * + * Call this once per role change. It attempts the stored `from` path exactly + * once per role value (mount counts as the first "value"): if the new role + * still isn't allowed, ProtectedRoute immediately guards it again, which + * doesn't change `role`, so this hook won't fire again and there's no + * redirect loop. If role changes again later (e.g. the user connects the + * wallet that grants access), it gets a fresh attempt at the same `from`. + */ +export function useRestoreGuardedRoute(role: unknown): void { + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + const from = (location.state as GuardedRouteState | null)?.from; + if (!from || from === location.pathname) return; + + navigate(from, { replace: true }); + // Intentionally scoped to `role` only: this should fire exactly once per + // role transition (mount included), not on every location/navigate + // identity change, so a guard-bounce for an unchanged role can't loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [role]); +}