Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions frontend/ROLE_BASED_NAVIGATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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) {
Expand Down
83 changes: 83 additions & 0 deletions frontend/src/hooks/useRestoreGuardedRoute.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<MemoryRouter initialEntries={initialEntries}>
<Harness role={role} />
<Routes>
<Route path="/" element={<div data-testid="home">Home</div>} />
<Route
path="/admin"
element={
<ProtectedRoute role={role} allow={["admin"]}>
<div data-testid="admin">Admin</div>
</ProtectedRoute>
}
/>
</Routes>
</MemoryRouter>
);
}

describe("useRestoreGuardedRoute", () => {
it("does nothing when there is no stashed redirect-back path", () => {
render(<TestApp role="guest" initialEntries={[{ pathname: "/" }]} />);
expect(screen.getByTestId("home")).toBeInTheDocument();
});

it("restores the stashed path on mount when the current role already allows it", () => {
render(
<TestApp
role="admin"
initialEntries={[{ pathname: "/", state: { from: "/admin" } }]}
/>,
);
expect(screen.getByTestId("admin")).toBeInTheDocument();
});

it("stays put (no crash or loop) when the current role still doesn't allow the stashed path", () => {
render(
<TestApp
role="investor"
initialEntries={[{ pathname: "/", state: { from: "/admin" } }]}
/>,
);
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(
<TestApp
role="investor"
initialEntries={[{ pathname: "/", state: { from: "/admin" } }]}
/>,
);
expect(screen.getByTestId("home")).toBeInTheDocument();

rerender(
<TestApp
role="admin"
initialEntries={[{ pathname: "/", state: { from: "/admin" } }]}
/>,
);
expect(screen.getByTestId("admin")).toBeInTheDocument();
});
});
36 changes: 36 additions & 0 deletions frontend/src/hooks/useRestoreGuardedRoute.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
Loading