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