From 7b51e5145f02f939e5538249efb85edc57cda29f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 15:54:23 +0000 Subject: [PATCH] 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 --- docs/ENV_VARIABLE_MATRIX.md | 6 ++ frontend/.env.example | 7 ++ frontend/.env.local.example | 3 + frontend/.env.production.example | 4 ++ frontend/ROLE_BASED_NAVIGATION.md | 68 ++++++++++++++++++ frontend/src/App.tsx | 15 +++- frontend/src/components/Navbar.test.tsx | 70 +++++++++++++++++++ frontend/src/components/Navbar.tsx | 18 +++++ .../src/components/ProtectedRoute.test.tsx | 61 ++++++++++++++++ frontend/src/components/ProtectedRoute.tsx | 32 +++++++++ frontend/src/i18n/locales/en.ts | 7 ++ frontend/src/i18n/locales/es.ts | 7 ++ frontend/src/lib/roles.test.ts | 44 ++++++++++++ frontend/src/lib/roles.ts | 37 ++++++++++ frontend/src/pages/Admin.tsx | 57 +++++++++++++++ 15 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 frontend/ROLE_BASED_NAVIGATION.md create mode 100644 frontend/src/components/ProtectedRoute.test.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/lib/roles.test.ts create mode 100644 frontend/src/lib/roles.ts create mode 100644 frontend/src/pages/Admin.tsx 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")} + + )}
@@ -245,6 +258,11 @@ const Navbar: FC = ({ )} + {role === "admin" && ( + setMenuOpen(false)}> + {t("nav.admin")} + + )}
)} diff --git a/frontend/src/components/ProtectedRoute.test.tsx b/frontend/src/components/ProtectedRoute.test.tsx new file mode 100644 index 00000000..a1e7bd7c --- /dev/null +++ b/frontend/src/components/ProtectedRoute.test.tsx @@ -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( + + + +
Admin Page
+ + } + /> + Home} /> +
+
, + ); +} + +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( + + + +
Admin Page
+ + } + /> + Portfolio} /> +
+
, + ); + + expect(screen.getByTestId("portfolio-page")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 00000000..5fecfb42 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -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 = ({ + role, + allow, + redirectTo = "/", + children, +}) => { + const location = useLocation(); + + if (!roleAllows(role, allow)) { + return ; + } + + return <>{children}; +}; + +export default ProtectedRoute; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 543b4af5..595b1a63 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -20,6 +20,13 @@ export const en = { compare: "Compare", analytics: "Analytics", transactions: "Transactions", + admin: "Admin", + }, + admin: { + title: "Admin Console", + description: "Operational tools restricted to admin wallets.", + badge: "Admin Access", + accessGranted: "Your connected wallet has admin access to this vault deployment.", }, theme: { toggleToDark: "Toggle to dark mode", diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 944b8672..a618fb92 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -20,6 +20,13 @@ export const es = { compare: "Comparar", analytics: "Analica", transactions: "Transacciones", + admin: "Administración", + }, + admin: { + title: "Consola de Administración", + description: "Herramientas operativas restringidas a billeteras administradoras.", + badge: "Acceso de Administrador", + accessGranted: "Su billetera conectada tiene acceso de administrador a este despliegue de la bóveda.", }, theme: { toggleToDark: "Cambiar al modo oscuro", diff --git a/frontend/src/lib/roles.test.ts b/frontend/src/lib/roles.test.ts new file mode 100644 index 00000000..1ab9620b --- /dev/null +++ b/frontend/src/lib/roles.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveUserRole, roleAllows } from "./roles"; + +describe("resolveUserRole", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns guest when no wallet is connected", () => { + expect(resolveUserRole(null)).toBe("guest"); + expect(resolveUserRole(undefined)).toBe("guest"); + expect(resolveUserRole("")).toBe("guest"); + }); + + it("returns investor for a connected wallet not on the admin list", () => { + vi.stubEnv("VITE_ADMIN_WALLETS", "GADMIN1,GADMIN2"); + expect(resolveUserRole("GORDINARYWALLET")).toBe("investor"); + }); + + it("returns admin for a wallet on the admin list", () => { + vi.stubEnv("VITE_ADMIN_WALLETS", "GADMIN1,GADMIN2"); + expect(resolveUserRole("GADMIN2")).toBe("admin"); + }); + + it("matches admin wallets case-insensitively and ignores whitespace", () => { + vi.stubEnv("VITE_ADMIN_WALLETS", " gAdmin1 , GADMIN2"); + expect(resolveUserRole("gadmin1")).toBe("admin"); + }); + + it("treats an empty admin list as no admins", () => { + vi.stubEnv("VITE_ADMIN_WALLETS", ""); + expect(resolveUserRole("GANYWALLET")).toBe("investor"); + }); +}); + +describe("roleAllows", () => { + it("returns true when the role is in the allow list", () => { + expect(roleAllows("admin", ["investor", "admin"])).toBe(true); + }); + + it("returns false when the role is not in the allow list", () => { + expect(roleAllows("guest", ["investor", "admin"])).toBe(false); + }); +}); diff --git a/frontend/src/lib/roles.ts b/frontend/src/lib/roles.ts new file mode 100644 index 00000000..c48ed2d6 --- /dev/null +++ b/frontend/src/lib/roles.ts @@ -0,0 +1,37 @@ +/** + * Client-side user roles for nav visibility and route gating. + * + * This is a UI convenience layer, not a security boundary: the admin + * wallet list ships in the client bundle, so any privileged action it + * gates must still be authorized server-side (see backend/src/middleware/rbac.ts). + */ +export const USER_ROLES = ["guest", "investor", "admin"] as const; +export type UserRole = (typeof USER_ROLES)[number]; + +function normalizeAddress(address: string): string { + return address.trim().toUpperCase(); +} + +function getAdminWallets(): string[] { + const raw: string = import.meta.env.VITE_ADMIN_WALLETS || ""; + return raw + .split(",") + .map((address) => address.trim()) + .filter(Boolean) + .map(normalizeAddress); +} + +/** + * Resolves the current user's role from their connected wallet address. + * - No wallet connected -> "guest" + * - Wallet connected and listed in VITE_ADMIN_WALLETS -> "admin" + * - Any other connected wallet -> "investor" + */ +export function resolveUserRole(walletAddress: string | null | undefined): UserRole { + if (!walletAddress) return "guest"; + return getAdminWallets().includes(normalizeAddress(walletAddress)) ? "admin" : "investor"; +} + +export function roleAllows(role: UserRole, allow: readonly UserRole[]): boolean { + return allow.includes(role); +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx new file mode 100644 index 00000000..64ed7c81 --- /dev/null +++ b/frontend/src/pages/Admin.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { ShieldCheck } from "../components/icons"; +import { useTranslation } from "../i18n"; +import PageHeader from "../components/PageHeader"; + +interface AdminProps { + walletAddress: string | null; +} + +const Admin: React.FC = ({ walletAddress }) => { + const { t } = useTranslation(); + + return ( +
+ {t("admin.title")}} + description={t("admin.description")} + breadcrumbs={[ + { label: t("analytics.homeLabel"), href: "/" }, + { label: t("admin.title") }, + ]} + statusChips={[{ label: t("admin.badge"), variant: "success" }]} + /> + +
+
+
+ ); +}; + +export default Admin;