Skip to content
Merged
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 docs/ENV_VARIABLE_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ Complete reference for all environment variables across the YieldVault RWA stack
| `VITE_FF_ADVANCED_CHARTS` | `false` | ⬜ optional | `false` until stable |
| `VITE_FF_DEBUG_MODE` | `false` | ⬜ optional | Must be `false` |

### Role-Based Navigation

| Variable | Default | Required | Production Recommendation |
|---|---|---|---|
| `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`). |

### Sentry (Error Monitoring)

| Variable | Default | Required | Production Recommendation |
Expand Down
7 changes: 7 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ VITE_STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
VITE_VAULT_CONTRACT_ID=
VITE_FF_ANALYTICS_PAGE=true

# Comma-separated wallet addresses granted the "admin" role in the frontend
# nav/route guards (see frontend/src/lib/roles.ts). Optional – leave blank to
# disable the admin console for all wallets. Not a security boundary: this
# ships in the client bundle, so privileged backend actions must still be
# authorized server-side.
VITE_ADMIN_WALLETS=

# Sentry Error Monitoring (optional – leave blank to disable)
VITE_SENTRY_DSN=
SENTRY_AUTH_TOKEN=
3 changes: 3 additions & 0 deletions frontend/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ VITE_FF_DEBUG_MODE=true
# Backend API URL - LOCAL
VITE_API_BASE_URL=http://localhost:3000

# Admin role allowlist - LOCAL (comma-separated wallet addresses)
VITE_ADMIN_WALLETS=

# Optional: Sentry Configuration - LOCAL (leave VITE_SENTRY_DSN blank to disable)
# VITE_SENTRY_DSN=
# SENTRY_AUTH_TOKEN=
Expand Down
4 changes: 4 additions & 0 deletions frontend/.env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ VITE_FF_DEBUG_MODE=false
# Backend API URL - PRODUCTION
VITE_API_BASE_URL=https://api.yieldvault.finance

# Admin role allowlist - PRODUCTION (comma-separated wallet addresses)
# CRITICAL: Only list wallets that should see the /admin console and nav link.
VITE_ADMIN_WALLETS=

# Sentry Configuration - PRODUCTION
# CRITICAL: Use production Sentry DSN
VITE_SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id
Expand Down
68 changes: 68 additions & 0 deletions frontend/ROLE_BASED_NAVIGATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Role-Based Navigation & Route Guards

The app resolves a lightweight client-side `UserRole` from the connected
wallet address and uses it to (a) show or hide navigation links and (b)
gate routes that shouldn't be reachable by everyone.

> [!IMPORTANT]
> This is a UI convenience layer, **not** a security boundary. The admin
> wallet allowlist ships in the client bundle, so any privileged action it
> gates must still be authorized server-side (see
> `backend/src/middleware/rbac.ts` for the real RBAC enforcement on admin
> API endpoints).

## Roles

| Role | Resolved when | Notes |
| :--- | :--- | :--- |
| `guest` | No wallet connected | Default state before `WalletConnect` succeeds. |
| `investor` | Wallet connected, not on the admin list | Normal vault user. |
| `admin` | Wallet connected and address is in `VITE_ADMIN_WALLETS` | Sees the Admin nav link and can reach `/admin`. |

Role resolution lives in `src/lib/roles.ts`:

```ts
import { resolveUserRole } from "./lib/roles";

const role = resolveUserRole(walletAddress); // "guest" | "investor" | "admin"
```

`VITE_ADMIN_WALLETS` is a comma-separated list of Stellar wallet addresses
(case-insensitive, whitespace-trimmed). Leave it blank to disable the admin
role for everyone. See `.env.example` and `docs/ENV_VARIABLE_MATRIX.md`.

## Nav Visibility

`App.tsx` computes `role` from the connected wallet and passes it to
`<Navbar role={role} />`. `Navbar` only renders the Admin link (desktop,
mobile, and dropdown menus) when `role === "admin"`; every other existing
link is unaffected.

## Route Guards

`<ProtectedRoute>` (`src/components/ProtectedRoute.tsx`) wraps a route
element and redirects (via `<Navigate replace>`) when the current role
isn't in the `allow` list:

```tsx
<Route
path="/admin"
element={
<ProtectedRoute role={role} allow={["admin"]}>
<Admin walletAddress={walletAddress} />
</ProtectedRoute>
}
/>
```

- `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.

## Adding a New Gated Route

1. Add the role(s) allowed to `allow` when declaring the `<Route>` in `App.tsx`.
2. If the route should also be hidden from nav for disallowed roles, gate the
`NavLink` in `Navbar.tsx` on `role` the same way the Admin link is gated.
3. Add/extend tests in `src/lib/roles.test.ts` and
`src/components/ProtectedRoute.test.tsx` for new role combinations.
15 changes: 14 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import * as Sentry from "@sentry/react";
import Navbar from "./components/Navbar";
Expand Down Expand Up @@ -38,11 +38,14 @@ import {
import NetworkWarningBanner from "./components/NetworkWarningBanner";
import OfflineBanner from "./components/OfflineBanner";
import { useVault, VaultProvider } from "./context/VaultContext";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { resolveUserRole } from "./lib/roles";

const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);

const VaultComparison = lazy(() => import("./pages/VaultComparison"));
const TransactionReceipt = lazy(() => import("./pages/TransactionReceipt"));
const Admin = lazy(() => import("./pages/Admin"));

// Removed simple fallback in favor of components/ErrorFallback

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

useEffect(() => {
if ((window as Window & { Cypress?: unknown }).Cypress) {
Expand Down Expand Up @@ -152,6 +156,7 @@ function AppContent() {
usdcBalance={usdcBalance}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
role={role}
/>
<main id="main-content" className="container app-main" style={{ marginTop: "100px", paddingBottom: "60px" }}>
<Suspense fallback={<RouteLoadingFallback />}>
Expand Down Expand Up @@ -188,6 +193,14 @@ function AppContent() {
<Route path="/receipt/:txHash" element={<TransactionReceipt />} />
<Route path="/settings" element={<LazySettings />} />
<Route path="/ui-kit" element={<LazyUIPreview />} />
<Route
path="/admin"
element={
<ProtectedRoute role={role} allow={["admin"]}>
<Admin walletAddress={walletAddress} />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</SentryRoutes>
</Suspense>
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/components/Navbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,74 @@ describe('Navbar', () => {

expect(screen.getAllByText(/testnet|mainnet/i)[0]).toBeInTheDocument();
});

it('does not show the Admin link by default (guest role)', () => {
render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<PreferencesProvider>
<ToastProvider>
<ThemeProvider>
<Navbar
walletAddress={null}
onConnect={mockOnConnect}
onDisconnect={mockOnDisconnect}
/>
</ThemeProvider>
</ToastProvider>
</PreferencesProvider>
</QueryClientProvider>
</MemoryRouter>
);

expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});

it('shows the Admin link when role is admin', () => {
const fullAddress = 'GABC1234567890123456789012345678901234567890123456789012';
render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<PreferencesProvider>
<ToastProvider>
<ThemeProvider>
<Navbar
walletAddress={fullAddress}
onConnect={mockOnConnect}
onDisconnect={mockOnDisconnect}
role="admin"
/>
</ThemeProvider>
</ToastProvider>
</PreferencesProvider>
</QueryClientProvider>
</MemoryRouter>
);

expect(screen.getAllByText('Admin')[0]).toBeInTheDocument();
});

it('does not show the Admin link for a connected investor wallet', () => {
const fullAddress = 'GABC1234567890123456789012345678901234567890123456789012';
render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<PreferencesProvider>
<ToastProvider>
<ThemeProvider>
<Navbar
walletAddress={fullAddress}
onConnect={mockOnConnect}
onDisconnect={mockOnDisconnect}
role="investor"
/>
</ThemeProvider>
</ToastProvider>
</PreferencesProvider>
</QueryClientProvider>
</MemoryRouter>
);

expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
});
18 changes: 18 additions & 0 deletions frontend/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useWalletNetwork } from "../hooks/useWalletNetwork";
import Badge from "./Badge";
import { usePendingTransactionCount } from "../hooks/usePendingTransactionCount";
import { getRoutePrefetchHandlers } from "../lib/routePrefetch";
import type { UserRole } from "../lib/roles";

interface NavbarProps {
currentPath?: "/" | "/analytics" | "/portfolio";
Expand All @@ -20,13 +21,15 @@ interface NavbarProps {
usdcBalance?: number;
onConnect: (address: string) => void;
onDisconnect: (reason?: DisconnectReason) => void;
role?: UserRole;
}

const Navbar: FC<NavbarProps> = ({
walletAddress,
usdcBalance = 0,
onConnect,
onDisconnect,
role = "guest",
}) => {
const { t } = useTranslation();
const { walletNetwork, expectedNetwork } = useWalletNetwork(walletAddress);
Expand Down Expand Up @@ -131,6 +134,11 @@ const Navbar: FC<NavbarProps> = ({
</Badge>
)}
</NavLink>
{role === "admin" && (
<NavLink to="/admin" className="nav-link">
{t("nav.admin")}
</NavLink>
)}
</div>
</div>

Expand Down Expand Up @@ -214,6 +222,11 @@ const Navbar: FC<NavbarProps> = ({
</Badge>
)}
</NavLink>
{role === "admin" && (
<NavLink to="/admin" onClick={() => setIsMobileMenuOpen(false)}>
{t("nav.admin")}
</NavLink>
)}

<div className="flex items-center justify-between" style={{ marginTop: "24px" }}>
<ThemeToggle />
Expand Down Expand Up @@ -245,6 +258,11 @@ const Navbar: FC<NavbarProps> = ({
</Badge>
)}
</NavLink>
{role === "admin" && (
<NavLink to="/admin" role="menuitem" onClick={() => setMenuOpen(false)}>
{t("nav.admin")}
</NavLink>
)}
</div>
)}
</nav>
Expand Down
61 changes: 61 additions & 0 deletions frontend/src/components/ProtectedRoute.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ProtectedRoute } from "./ProtectedRoute";

function renderGuarded(role: "guest" | "investor" | "admin") {
return render(
<MemoryRouter initialEntries={["/admin"]}>
<Routes>
<Route
path="/admin"
element={
<ProtectedRoute role={role} allow={["admin"]}>
<div data-testid="admin-page">Admin Page</div>
</ProtectedRoute>
}
/>
<Route path="/" element={<div data-testid="home-page">Home</div>} />
</Routes>
</MemoryRouter>,
);
}

describe("ProtectedRoute", () => {
it("renders the protected content when the role is allowed", () => {
renderGuarded("admin");
expect(screen.getByTestId("admin-page")).toBeInTheDocument();
});

it("redirects to the default path when the role is not allowed", () => {
renderGuarded("investor");
expect(screen.queryByTestId("admin-page")).not.toBeInTheDocument();
expect(screen.getByTestId("home-page")).toBeInTheDocument();
});

it("redirects guests away from the protected route", () => {
renderGuarded("guest");
expect(screen.queryByTestId("admin-page")).not.toBeInTheDocument();
expect(screen.getByTestId("home-page")).toBeInTheDocument();
});

it("redirects to a custom path when provided", () => {
render(
<MemoryRouter initialEntries={["/admin"]}>
<Routes>
<Route
path="/admin"
element={
<ProtectedRoute role="guest" allow={["admin"]} redirectTo="/portfolio">
<div data-testid="admin-page">Admin Page</div>
</ProtectedRoute>
}
/>
<Route path="/portfolio" element={<div data-testid="portfolio-page">Portfolio</div>} />
</Routes>
</MemoryRouter>,
);

expect(screen.getByTestId("portfolio-page")).toBeInTheDocument();
});
});
32 changes: 32 additions & 0 deletions frontend/src/components/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from "react";
import { Navigate, useLocation } from "react-router-dom";
import { roleAllows, type UserRole } from "../lib/roles";

interface ProtectedRouteProps {
role: UserRole;
allow: readonly UserRole[];
redirectTo?: string;
children: React.ReactNode;
}

/**
* Route guard that redirects away when the current role isn't in `allow`.
* The attempted path is passed along in location state so the redirect
* target can restore it later (e.g. after connecting a wallet).
*/
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
role,
allow,
redirectTo = "/",
children,
}) => {
const location = useLocation();

if (!roleAllows(role, allow)) {
return <Navigate to={redirectTo} replace state={{ from: location.pathname }} />;
}

return <>{children}</>;
};

export default ProtectedRoute;
Loading
Loading