Skip to content

Commit 6ff7717

Browse files
feat: restore the originally-attempted route after a ProtectedRoute redirect
ProtectedRoute (#1071, #981) already redirects a disallowed role away from a guarded route and stashes the attempted path in location.state.from "so a future redirect target can restore it later" — but nothing ever read that state, so a user who tried /admin and later connected the admin wallet just stayed on the page they'd been bounced to instead of landing back on /admin. Add useRestoreGuardedRoute(role), called from App.tsx whenever role changes. It tries the stashed `from` path once per role transition: if the new role is allowed, the user lands where they originally tried to go; if not, ProtectedRoute guards it again (role hasn't changed, so the hook won't re-fire), avoiding a redirect loop. Related to #1040, which duplicates #981, already resolved by #1071 for the role/nav/guard scaffolding this builds on.
1 parent 3f74507 commit 6ff7717

4 files changed

Lines changed: 127 additions & 0 deletions

File tree

frontend/ROLE_BASED_NAVIGATION.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ isn't in the `allow` list:
5858
- `redirectTo` defaults to `/` and can be overridden per route.
5959
- The attempted path is passed through `location.state.from` so a future
6060
redirect target (e.g. after connecting a wallet) can restore it.
61+
- `useRestoreGuardedRoute` (`src/hooks/useRestoreGuardedRoute.ts`), called
62+
from `App.tsx` with the current `role`, consumes that `location.state.from`:
63+
whenever `role` changes, it tries the stashed path once. If the new role
64+
is allowed, the user lands back where they originally tried to go; if not,
65+
`ProtectedRoute` guards it again and the hook won't retry the same value,
66+
so there's no redirect loop.
6167

6268
## Adding a New Gated Route
6369

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { useVault, VaultProvider } from "./context/VaultContext";
4444
import { usePageViewTracking } from "./hooks/useAnalytics";
4545
import { ProtectedRoute } from "./components/ProtectedRoute";
4646
import { resolveUserRole } from "./lib/roles";
47+
import { useRestoreGuardedRoute } from "./hooks/useRestoreGuardedRoute";
4748

4849
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
4950

@@ -63,6 +64,7 @@ function AppContent() {
6364
const { data: xlmBalance = 0 } = useXlmBalance(walletAddress);
6465
const { tvl } = useVault();
6566
const role = useMemo(() => resolveUserRole(walletAddress), [walletAddress]);
67+
useRestoreGuardedRoute(role);
6668

6769
useEffect(() => {
6870
if ((window as Window & { Cypress?: unknown }).Cypress) {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import React from "react";
2+
import { render, screen } from "@testing-library/react";
3+
import { describe, it, expect } from "vitest";
4+
import { MemoryRouter, Routes, Route } from "react-router-dom";
5+
import { useRestoreGuardedRoute } from "./useRestoreGuardedRoute";
6+
import { ProtectedRoute } from "../components/ProtectedRoute";
7+
import type { UserRole } from "../lib/roles";
8+
9+
function Harness({ role }: { role: UserRole }) {
10+
useRestoreGuardedRoute(role);
11+
return null;
12+
}
13+
14+
function TestApp({
15+
role,
16+
initialEntries,
17+
}: {
18+
role: UserRole;
19+
initialEntries: Array<{ pathname: string; state?: unknown }>;
20+
}) {
21+
return (
22+
<MemoryRouter initialEntries={initialEntries}>
23+
<Harness role={role} />
24+
<Routes>
25+
<Route path="/" element={<div data-testid="home">Home</div>} />
26+
<Route
27+
path="/admin"
28+
element={
29+
<ProtectedRoute role={role} allow={["admin"]}>
30+
<div data-testid="admin">Admin</div>
31+
</ProtectedRoute>
32+
}
33+
/>
34+
</Routes>
35+
</MemoryRouter>
36+
);
37+
}
38+
39+
describe("useRestoreGuardedRoute", () => {
40+
it("does nothing when there is no stashed redirect-back path", () => {
41+
render(<TestApp role="guest" initialEntries={[{ pathname: "/" }]} />);
42+
expect(screen.getByTestId("home")).toBeInTheDocument();
43+
});
44+
45+
it("restores the stashed path on mount when the current role already allows it", () => {
46+
render(
47+
<TestApp
48+
role="admin"
49+
initialEntries={[{ pathname: "/", state: { from: "/admin" } }]}
50+
/>,
51+
);
52+
expect(screen.getByTestId("admin")).toBeInTheDocument();
53+
});
54+
55+
it("stays put (no crash or loop) when the current role still doesn't allow the stashed path", () => {
56+
render(
57+
<TestApp
58+
role="investor"
59+
initialEntries={[{ pathname: "/", state: { from: "/admin" } }]}
60+
/>,
61+
);
62+
expect(screen.queryByTestId("admin")).not.toBeInTheDocument();
63+
expect(screen.getByTestId("home")).toBeInTheDocument();
64+
});
65+
66+
it("restores the stashed path once the role changes to one that allows it", () => {
67+
const { rerender } = render(
68+
<TestApp
69+
role="investor"
70+
initialEntries={[{ pathname: "/", state: { from: "/admin" } }]}
71+
/>,
72+
);
73+
expect(screen.getByTestId("home")).toBeInTheDocument();
74+
75+
rerender(
76+
<TestApp
77+
role="admin"
78+
initialEntries={[{ pathname: "/", state: { from: "/admin" } }]}
79+
/>,
80+
);
81+
expect(screen.getByTestId("admin")).toBeInTheDocument();
82+
});
83+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useEffect } from "react";
2+
import { useLocation, useNavigate } from "react-router-dom";
3+
4+
interface GuardedRouteState {
5+
from?: string;
6+
}
7+
8+
/**
9+
* Completes the redirect-back half of ProtectedRoute's guard: when a
10+
* disallowed role hits a guarded route, ProtectedRoute redirects away and
11+
* stashes the attempted path in `location.state.from` so it can be restored
12+
* later (e.g. once the user connects the wallet that grants access) — but
13+
* nothing consumed that state, so the user was never actually sent back.
14+
*
15+
* Call this once per role change. It attempts the stored `from` path exactly
16+
* once per role value (mount counts as the first "value"): if the new role
17+
* still isn't allowed, ProtectedRoute immediately guards it again, which
18+
* doesn't change `role`, so this hook won't fire again and there's no
19+
* redirect loop. If role changes again later (e.g. the user connects the
20+
* wallet that grants access), it gets a fresh attempt at the same `from`.
21+
*/
22+
export function useRestoreGuardedRoute(role: unknown): void {
23+
const location = useLocation();
24+
const navigate = useNavigate();
25+
26+
useEffect(() => {
27+
const from = (location.state as GuardedRouteState | null)?.from;
28+
if (!from || from === location.pathname) return;
29+
30+
navigate(from, { replace: true });
31+
// Intentionally scoped to `role` only: this should fire exactly once per
32+
// role transition (mount included), not on every location/navigate
33+
// identity change, so a guard-bounce for an unchanged role can't loop.
34+
// eslint-disable-next-line react-hooks/exhaustive-deps
35+
}, [role]);
36+
}

0 commit comments

Comments
 (0)