Overview

diff --git a/frontend/src/components/Dashboard/Sidebar.test.tsx b/frontend/src/components/Dashboard/Sidebar.test.tsx new file mode 100644 index 0000000..511893e --- /dev/null +++ b/frontend/src/components/Dashboard/Sidebar.test.tsx @@ -0,0 +1,111 @@ +/** + * Tests for Sidebar role-based visibility (Issue #3). + * + * Admin-only links (Treasury, Signers, Settings) must be hidden for wallets + * that are not registered signers, and visible for wallets that are. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import Sidebar from "./Sidebar"; + +// ---- Mock useSigners ------------------------------------------------------- + +const mockSigners = [ + { address: "GADMIN1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", weight: 1 }, + { address: "GADMIN2XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", weight: 1 }, +]; + +let signersLoading = false; + +vi.mock("../../hooks/useSigners", () => ({ + useSigners: () => ({ + signers: mockSigners, + loading: signersLoading, + error: null, + addSigner: vi.fn(), + removeSigner: vi.fn(), + rotateSigners: vi.fn(), + refresh: vi.fn(), + }), +})); + +// ---- Helpers --------------------------------------------------------------- + +function renderSidebar(connectedAddress?: string | null) { + render( + + + , + ); +} + +// ---- Tests ----------------------------------------------------------------- + +describe("Sidebar — role-based visibility", () => { + beforeEach(() => { + signersLoading = false; + }); + + // Public links always visible + const publicLinks = ["Invoices", "Settlements", "On-Hold", "Disputes"]; + // Admin-only links + const adminLinks = ["Treasury", "Signers", "Settings"]; + + it("shows public links for a non-admin wallet", () => { + renderSidebar("GUSER1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + for (const label of publicLinks) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it("hides admin-only links for a non-admin wallet", () => { + renderSidebar("GUSER1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + for (const label of adminLinks) { + expect(screen.queryByText(label)).not.toBeInTheDocument(); + } + }); + + it("hides admin-only links when no wallet is connected", () => { + renderSidebar(null); + for (const label of adminLinks) { + expect(screen.queryByText(label)).not.toBeInTheDocument(); + } + }); + + it("hides admin-only links when connectedAddress is undefined", () => { + renderSidebar(undefined); + for (const label of adminLinks) { + expect(screen.queryByText(label)).not.toBeInTheDocument(); + } + }); + + it("shows all links for a registered signer", () => { + renderSidebar("GADMIN1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + for (const label of [...publicLinks, ...adminLinks]) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it("hides admin-only links while the signer list is still loading (fail-closed)", () => { + signersLoading = true; + renderSidebar("GADMIN1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + for (const label of adminLinks) { + expect(screen.queryByText(label)).not.toBeInTheDocument(); + } + // Public links still appear + for (const label of publicLinks) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it("performs case-insensitive address comparison", () => { + // Provide the address in lowercase; the signer list uses uppercase + const lower = "GADMIN1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".toLowerCase(); + renderSidebar(lower); + for (const label of adminLinks) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); +}); diff --git a/frontend/src/components/Dashboard/Sidebar.tsx b/frontend/src/components/Dashboard/Sidebar.tsx index 6a826f1..770321e 100644 --- a/frontend/src/components/Dashboard/Sidebar.tsx +++ b/frontend/src/components/Dashboard/Sidebar.tsx @@ -1,14 +1,24 @@ import { NavLink } from "react-router-dom"; +import { useSigners } from "../../hooks/useSigners"; import "./Sidebar.css"; -const links = [ +interface NavItem { + to: string; + label: string; + icon: string; + /** When true, the link is only shown to wallets that are registered signers. */ + adminOnly?: boolean; +} + +const links: NavItem[] = [ { to: "/invoices", label: "Invoices", icon: "receipt" }, { to: "/settlements", label: "Settlements", icon: "account_balance" }, { to: "/on-hold", label: "On-Hold", icon: "hold" }, - { to: "/treasury", label: "Treasury", icon: "treasury" }, { to: "/disputes", label: "Disputes", icon: "gavel" }, - { to: "/signers", label: "Signers", icon: "signers" }, - { to: "/settings", label: "Settings", icon: "settings" }, + // Admin-only: visible only to registered signers / protocol operators + { to: "/treasury", label: "Treasury", icon: "treasury", adminOnly: true }, + { to: "/signers", label: "Signers", icon: "signers", adminOnly: true }, + { to: "/settings", label: "Settings", icon: "settings", adminOnly: true }, ]; const iconMap: Record = { @@ -21,7 +31,35 @@ const iconMap: Record = { settings: "\u{2699}\u{FE0F}", }; -export default function Sidebar() { +interface SidebarProps { + /** + * The Stellar public key of the currently-connected wallet. + * When undefined/null the component treats the visitor as unauthenticated + * and hides all admin-only links. + */ + connectedAddress?: string | null; +} + +export default function Sidebar({ connectedAddress }: SidebarProps) { + const { signers, loading: signersLoading } = useSigners(); + + /** + * Determine whether the connected wallet is a registered signer. + * + * We resolve this from the treasury signer list fetched via useSigners. + * While the signer list is still loading we conservatively hide admin links + * (fail-closed) to prevent a momentary flash of privileged navigation items + * to non-admin users. + */ + const isAdmin = + !signersLoading && + !!connectedAddress && + signers.some( + (s) => s.address.toLowerCase() === connectedAddress.toLowerCase(), + ); + + const visibleLinks = links.filter((link) => !link.adminOnly || isAdmin); + return (