Skip to content

Commit 7b51e51

Browse files
committed
feat: add role-aware navigation and protected route guards
Resolve a client-side UserRole (guest/investor/admin) from the connected wallet address via a VITE_ADMIN_WALLETS allowlist, gate the nav's Admin link and a new /admin route behind it with a reusable ProtectedRoute guard, and document the pattern for adding future gated routes. Closes #981
1 parent 53b74a7 commit 7b51e51

15 files changed

Lines changed: 435 additions & 1 deletion

docs/ENV_VARIABLE_MATRIX.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@ Complete reference for all environment variables across the YieldVault RWA stack
154154
| `VITE_FF_ADVANCED_CHARTS` | `false` | ⬜ optional | `false` until stable |
155155
| `VITE_FF_DEBUG_MODE` | `false` | ⬜ optional | Must be `false` |
156156

157+
### Role-Based Navigation
158+
159+
| Variable | Default | Required | Production Recommendation |
160+
|---|---|---|---|
161+
| `VITE_ADMIN_WALLETS` | _(empty)_ | ⬜ optional | Comma-separated wallet addresses granted the admin nav link and `/admin` route. Not a security boundary — ships in the client bundle (see `frontend/src/lib/roles.ts`). |
162+
157163
### Sentry (Error Monitoring)
158164

159165
| Variable | Default | Required | Production Recommendation |

frontend/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ VITE_STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
66
VITE_VAULT_CONTRACT_ID=
77
VITE_FF_ANALYTICS_PAGE=true
88

9+
# Comma-separated wallet addresses granted the "admin" role in the frontend
10+
# nav/route guards (see frontend/src/lib/roles.ts). Optional – leave blank to
11+
# disable the admin console for all wallets. Not a security boundary: this
12+
# ships in the client bundle, so privileged backend actions must still be
13+
# authorized server-side.
14+
VITE_ADMIN_WALLETS=
15+
916
# Sentry Error Monitoring (optional – leave blank to disable)
1017
VITE_SENTRY_DSN=
1118
SENTRY_AUTH_TOKEN=

frontend/.env.local.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ VITE_FF_DEBUG_MODE=true
1616
# Backend API URL - LOCAL
1717
VITE_API_BASE_URL=http://localhost:3000
1818

19+
# Admin role allowlist - LOCAL (comma-separated wallet addresses)
20+
VITE_ADMIN_WALLETS=
21+
1922
# Optional: Sentry Configuration - LOCAL (leave VITE_SENTRY_DSN blank to disable)
2023
# VITE_SENTRY_DSN=
2124
# SENTRY_AUTH_TOKEN=

frontend/.env.production.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ VITE_FF_DEBUG_MODE=false
1919
# Backend API URL - PRODUCTION
2020
VITE_API_BASE_URL=https://api.yieldvault.finance
2121

22+
# Admin role allowlist - PRODUCTION (comma-separated wallet addresses)
23+
# CRITICAL: Only list wallets that should see the /admin console and nav link.
24+
VITE_ADMIN_WALLETS=
25+
2226
# Sentry Configuration - PRODUCTION
2327
# CRITICAL: Use production Sentry DSN
2428
VITE_SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id

frontend/ROLE_BASED_NAVIGATION.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Role-Based Navigation & Route Guards
2+
3+
The app resolves a lightweight client-side `UserRole` from the connected
4+
wallet address and uses it to (a) show or hide navigation links and (b)
5+
gate routes that shouldn't be reachable by everyone.
6+
7+
> [!IMPORTANT]
8+
> This is a UI convenience layer, **not** a security boundary. The admin
9+
> wallet allowlist ships in the client bundle, so any privileged action it
10+
> gates must still be authorized server-side (see
11+
> `backend/src/middleware/rbac.ts` for the real RBAC enforcement on admin
12+
> API endpoints).
13+
14+
## Roles
15+
16+
| Role | Resolved when | Notes |
17+
| :--- | :--- | :--- |
18+
| `guest` | No wallet connected | Default state before `WalletConnect` succeeds. |
19+
| `investor` | Wallet connected, not on the admin list | Normal vault user. |
20+
| `admin` | Wallet connected and address is in `VITE_ADMIN_WALLETS` | Sees the Admin nav link and can reach `/admin`. |
21+
22+
Role resolution lives in `src/lib/roles.ts`:
23+
24+
```ts
25+
import { resolveUserRole } from "./lib/roles";
26+
27+
const role = resolveUserRole(walletAddress); // "guest" | "investor" | "admin"
28+
```
29+
30+
`VITE_ADMIN_WALLETS` is a comma-separated list of Stellar wallet addresses
31+
(case-insensitive, whitespace-trimmed). Leave it blank to disable the admin
32+
role for everyone. See `.env.example` and `docs/ENV_VARIABLE_MATRIX.md`.
33+
34+
## Nav Visibility
35+
36+
`App.tsx` computes `role` from the connected wallet and passes it to
37+
`<Navbar role={role} />`. `Navbar` only renders the Admin link (desktop,
38+
mobile, and dropdown menus) when `role === "admin"`; every other existing
39+
link is unaffected.
40+
41+
## Route Guards
42+
43+
`<ProtectedRoute>` (`src/components/ProtectedRoute.tsx`) wraps a route
44+
element and redirects (via `<Navigate replace>`) when the current role
45+
isn't in the `allow` list:
46+
47+
```tsx
48+
<Route
49+
path="/admin"
50+
element={
51+
<ProtectedRoute role={role} allow={["admin"]}>
52+
<Admin walletAddress={walletAddress} />
53+
</ProtectedRoute>
54+
}
55+
/>
56+
```
57+
58+
- `redirectTo` defaults to `/` and can be overridden per route.
59+
- The attempted path is passed through `location.state.from` so a future
60+
redirect target (e.g. after connecting a wallet) can restore it.
61+
62+
## Adding a New Gated Route
63+
64+
1. Add the role(s) allowed to `allow` when declaring the `<Route>` in `App.tsx`.
65+
2. If the route should also be hidden from nav for disallowed roles, gate the
66+
`NavLink` in `Navbar.tsx` on `role` the same way the Admin link is gated.
67+
3. Add/extend tests in `src/lib/roles.test.ts` and
68+
`src/components/ProtectedRoute.test.tsx` for new role combinations.

frontend/src/App.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
1+
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
22
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
33
import * as Sentry from "@sentry/react";
44
import Navbar from "./components/Navbar";
@@ -38,11 +38,14 @@ import {
3838
import NetworkWarningBanner from "./components/NetworkWarningBanner";
3939
import OfflineBanner from "./components/OfflineBanner";
4040
import { useVault, VaultProvider } from "./context/VaultContext";
41+
import { ProtectedRoute } from "./components/ProtectedRoute";
42+
import { resolveUserRole } from "./lib/roles";
4143

4244
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
4345

4446
const VaultComparison = lazy(() => import("./pages/VaultComparison"));
4547
const TransactionReceipt = lazy(() => import("./pages/TransactionReceipt"));
48+
const Admin = lazy(() => import("./pages/Admin"));
4649

4750
// Removed simple fallback in favor of components/ErrorFallback
4851

@@ -55,6 +58,7 @@ function AppContent() {
5558
const { data: usdcBalance = 0 } = useUsdcBalance(walletAddress);
5659
const { data: xlmBalance = 0 } = useXlmBalance(walletAddress);
5760
const { tvl } = useVault();
61+
const role = useMemo(() => resolveUserRole(walletAddress), [walletAddress]);
5862

5963
useEffect(() => {
6064
if ((window as Window & { Cypress?: unknown }).Cypress) {
@@ -152,6 +156,7 @@ function AppContent() {
152156
usdcBalance={usdcBalance}
153157
onConnect={handleConnect}
154158
onDisconnect={handleDisconnect}
159+
role={role}
155160
/>
156161
<main id="main-content" className="container app-main" style={{ marginTop: "100px", paddingBottom: "60px" }}>
157162
<Suspense fallback={<RouteLoadingFallback />}>
@@ -188,6 +193,14 @@ function AppContent() {
188193
<Route path="/receipt/:txHash" element={<TransactionReceipt />} />
189194
<Route path="/settings" element={<LazySettings />} />
190195
<Route path="/ui-kit" element={<LazyUIPreview />} />
196+
<Route
197+
path="/admin"
198+
element={
199+
<ProtectedRoute role={role} allow={["admin"]}>
200+
<Admin walletAddress={walletAddress} />
201+
</ProtectedRoute>
202+
}
203+
/>
191204
<Route path="*" element={<Navigate to="/" replace />} />
192205
</SentryRoutes>
193206
</Suspense>

frontend/src/components/Navbar.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,74 @@ describe('Navbar', () => {
108108

109109
expect(screen.getAllByText(/testnet|mainnet/i)[0]).toBeInTheDocument();
110110
});
111+
112+
it('does not show the Admin link by default (guest role)', () => {
113+
render(
114+
<MemoryRouter>
115+
<QueryClientProvider client={queryClient}>
116+
<PreferencesProvider>
117+
<ToastProvider>
118+
<ThemeProvider>
119+
<Navbar
120+
walletAddress={null}
121+
onConnect={mockOnConnect}
122+
onDisconnect={mockOnDisconnect}
123+
/>
124+
</ThemeProvider>
125+
</ToastProvider>
126+
</PreferencesProvider>
127+
</QueryClientProvider>
128+
</MemoryRouter>
129+
);
130+
131+
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
132+
});
133+
134+
it('shows the Admin link when role is admin', () => {
135+
const fullAddress = 'GABC1234567890123456789012345678901234567890123456789012';
136+
render(
137+
<MemoryRouter>
138+
<QueryClientProvider client={queryClient}>
139+
<PreferencesProvider>
140+
<ToastProvider>
141+
<ThemeProvider>
142+
<Navbar
143+
walletAddress={fullAddress}
144+
onConnect={mockOnConnect}
145+
onDisconnect={mockOnDisconnect}
146+
role="admin"
147+
/>
148+
</ThemeProvider>
149+
</ToastProvider>
150+
</PreferencesProvider>
151+
</QueryClientProvider>
152+
</MemoryRouter>
153+
);
154+
155+
expect(screen.getAllByText('Admin')[0]).toBeInTheDocument();
156+
});
157+
158+
it('does not show the Admin link for a connected investor wallet', () => {
159+
const fullAddress = 'GABC1234567890123456789012345678901234567890123456789012';
160+
render(
161+
<MemoryRouter>
162+
<QueryClientProvider client={queryClient}>
163+
<PreferencesProvider>
164+
<ToastProvider>
165+
<ThemeProvider>
166+
<Navbar
167+
walletAddress={fullAddress}
168+
onConnect={mockOnConnect}
169+
onDisconnect={mockOnDisconnect}
170+
role="investor"
171+
/>
172+
</ThemeProvider>
173+
</ToastProvider>
174+
</PreferencesProvider>
175+
</QueryClientProvider>
176+
</MemoryRouter>
177+
);
178+
179+
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
180+
});
111181
});

frontend/src/components/Navbar.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useWalletNetwork } from "../hooks/useWalletNetwork";
1212
import Badge from "./Badge";
1313
import { usePendingTransactionCount } from "../hooks/usePendingTransactionCount";
1414
import { getRoutePrefetchHandlers } from "../lib/routePrefetch";
15+
import type { UserRole } from "../lib/roles";
1516

1617
interface NavbarProps {
1718
currentPath?: "/" | "/analytics" | "/portfolio";
@@ -20,13 +21,15 @@ interface NavbarProps {
2021
usdcBalance?: number;
2122
onConnect: (address: string) => void;
2223
onDisconnect: (reason?: DisconnectReason) => void;
24+
role?: UserRole;
2325
}
2426

2527
const Navbar: FC<NavbarProps> = ({
2628
walletAddress,
2729
usdcBalance = 0,
2830
onConnect,
2931
onDisconnect,
32+
role = "guest",
3033
}) => {
3134
const { t } = useTranslation();
3235
const { walletNetwork, expectedNetwork } = useWalletNetwork(walletAddress);
@@ -131,6 +134,11 @@ const Navbar: FC<NavbarProps> = ({
131134
</Badge>
132135
)}
133136
</NavLink>
137+
{role === "admin" && (
138+
<NavLink to="/admin" className="nav-link">
139+
{t("nav.admin")}
140+
</NavLink>
141+
)}
134142
</div>
135143
</div>
136144

@@ -214,6 +222,11 @@ const Navbar: FC<NavbarProps> = ({
214222
</Badge>
215223
)}
216224
</NavLink>
225+
{role === "admin" && (
226+
<NavLink to="/admin" onClick={() => setIsMobileMenuOpen(false)}>
227+
{t("nav.admin")}
228+
</NavLink>
229+
)}
217230

218231
<div className="flex items-center justify-between" style={{ marginTop: "24px" }}>
219232
<ThemeToggle />
@@ -245,6 +258,11 @@ const Navbar: FC<NavbarProps> = ({
245258
</Badge>
246259
)}
247260
</NavLink>
261+
{role === "admin" && (
262+
<NavLink to="/admin" role="menuitem" onClick={() => setMenuOpen(false)}>
263+
{t("nav.admin")}
264+
</NavLink>
265+
)}
248266
</div>
249267
)}
250268
</nav>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import { MemoryRouter, Route, Routes } from "react-router-dom";
4+
import { ProtectedRoute } from "./ProtectedRoute";
5+
6+
function renderGuarded(role: "guest" | "investor" | "admin") {
7+
return render(
8+
<MemoryRouter initialEntries={["/admin"]}>
9+
<Routes>
10+
<Route
11+
path="/admin"
12+
element={
13+
<ProtectedRoute role={role} allow={["admin"]}>
14+
<div data-testid="admin-page">Admin Page</div>
15+
</ProtectedRoute>
16+
}
17+
/>
18+
<Route path="/" element={<div data-testid="home-page">Home</div>} />
19+
</Routes>
20+
</MemoryRouter>,
21+
);
22+
}
23+
24+
describe("ProtectedRoute", () => {
25+
it("renders the protected content when the role is allowed", () => {
26+
renderGuarded("admin");
27+
expect(screen.getByTestId("admin-page")).toBeInTheDocument();
28+
});
29+
30+
it("redirects to the default path when the role is not allowed", () => {
31+
renderGuarded("investor");
32+
expect(screen.queryByTestId("admin-page")).not.toBeInTheDocument();
33+
expect(screen.getByTestId("home-page")).toBeInTheDocument();
34+
});
35+
36+
it("redirects guests away from the protected route", () => {
37+
renderGuarded("guest");
38+
expect(screen.queryByTestId("admin-page")).not.toBeInTheDocument();
39+
expect(screen.getByTestId("home-page")).toBeInTheDocument();
40+
});
41+
42+
it("redirects to a custom path when provided", () => {
43+
render(
44+
<MemoryRouter initialEntries={["/admin"]}>
45+
<Routes>
46+
<Route
47+
path="/admin"
48+
element={
49+
<ProtectedRoute role="guest" allow={["admin"]} redirectTo="/portfolio">
50+
<div data-testid="admin-page">Admin Page</div>
51+
</ProtectedRoute>
52+
}
53+
/>
54+
<Route path="/portfolio" element={<div data-testid="portfolio-page">Portfolio</div>} />
55+
</Routes>
56+
</MemoryRouter>,
57+
);
58+
59+
expect(screen.getByTestId("portfolio-page")).toBeInTheDocument();
60+
});
61+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from "react";
2+
import { Navigate, useLocation } from "react-router-dom";
3+
import { roleAllows, type UserRole } from "../lib/roles";
4+
5+
interface ProtectedRouteProps {
6+
role: UserRole;
7+
allow: readonly UserRole[];
8+
redirectTo?: string;
9+
children: React.ReactNode;
10+
}
11+
12+
/**
13+
* Route guard that redirects away when the current role isn't in `allow`.
14+
* The attempted path is passed along in location state so the redirect
15+
* target can restore it later (e.g. after connecting a wallet).
16+
*/
17+
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
18+
role,
19+
allow,
20+
redirectTo = "/",
21+
children,
22+
}) => {
23+
const location = useLocation();
24+
25+
if (!roleAllows(role, allow)) {
26+
return <Navigate to={redirectTo} replace state={{ from: location.pathname }} />;
27+
}
28+
29+
return <>{children}</>;
30+
};
31+
32+
export default ProtectedRoute;

0 commit comments

Comments
 (0)