diff --git a/docs/ENV_VARIABLE_MATRIX.md b/docs/ENV_VARIABLE_MATRIX.md
index 45ca9ee2..5e407158 100644
--- a/docs/ENV_VARIABLE_MATRIX.md
+++ b/docs/ENV_VARIABLE_MATRIX.md
@@ -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 |
diff --git a/frontend/.env.example b/frontend/.env.example
index 775533a5..a9b4710a 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -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=
diff --git a/frontend/.env.local.example b/frontend/.env.local.example
index 46dd8e6e..8f486b21 100644
--- a/frontend/.env.local.example
+++ b/frontend/.env.local.example
@@ -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=
diff --git a/frontend/.env.production.example b/frontend/.env.production.example
index 3bcdc73b..978793de 100644
--- a/frontend/.env.production.example
+++ b/frontend/.env.production.example
@@ -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
diff --git a/frontend/ROLE_BASED_NAVIGATION.md b/frontend/ROLE_BASED_NAVIGATION.md
new file mode 100644
index 00000000..86ca1ff3
--- /dev/null
+++ b/frontend/ROLE_BASED_NAVIGATION.md
@@ -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` only renders the Admin link (desktop,
+mobile, and dropdown menus) when `role === "admin"`; every other existing
+link is unaffected.
+
+## Route Guards
+
+`` (`src/components/ProtectedRoute.tsx`) wraps a route
+element and redirects (via ``) when the current role
+isn't in the `allow` list:
+
+```tsx
+
+
+
+ }
+/>
+```
+
+- `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 `` 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.
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0f15d8b9..3949fd2a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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";
@@ -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
@@ -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) {
@@ -152,6 +156,7 @@ function AppContent() {
usdcBalance={usdcBalance}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
+ role={role}
/>
}>
@@ -188,6 +193,14 @@ function AppContent() {
} />
} />
} />
+
+
+
+ }
+ />
} />
diff --git a/frontend/src/components/Navbar.test.tsx b/frontend/src/components/Navbar.test.tsx
index 1c2bfdf1..83443282 100644
--- a/frontend/src/components/Navbar.test.tsx
+++ b/frontend/src/components/Navbar.test.tsx
@@ -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(
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ expect(screen.queryByText('Admin')).not.toBeInTheDocument();
+ });
+
+ it('shows the Admin link when role is admin', () => {
+ const fullAddress = 'GABC1234567890123456789012345678901234567890123456789012';
+ render(
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ expect(screen.getAllByText('Admin')[0]).toBeInTheDocument();
+ });
+
+ it('does not show the Admin link for a connected investor wallet', () => {
+ const fullAddress = 'GABC1234567890123456789012345678901234567890123456789012';
+ render(
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ expect(screen.queryByText('Admin')).not.toBeInTheDocument();
+ });
});
diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx
index 7d9ae754..ee3f4bf9 100644
--- a/frontend/src/components/Navbar.tsx
+++ b/frontend/src/components/Navbar.tsx
@@ -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";
@@ -20,6 +21,7 @@ interface NavbarProps {
usdcBalance?: number;
onConnect: (address: string) => void;
onDisconnect: (reason?: DisconnectReason) => void;
+ role?: UserRole;
}
const Navbar: FC = ({
@@ -27,6 +29,7 @@ const Navbar: FC = ({
usdcBalance = 0,
onConnect,
onDisconnect,
+ role = "guest",
}) => {
const { t } = useTranslation();
const { walletNetwork, expectedNetwork } = useWalletNetwork(walletAddress);
@@ -131,6 +134,11 @@ const Navbar: FC = ({
)}
+ {role === "admin" && (
+
+ {t("nav.admin")}
+
+ )}
@@ -214,6 +222,11 @@ const Navbar: FC = ({
)}
+ {role === "admin" && (
+ setIsMobileMenuOpen(false)}>
+ {t("nav.admin")}
+
+ )}