diff --git a/frontend/src/components/Dashboard/DashboardLayout.tsx b/frontend/src/components/Dashboard/DashboardLayout.tsx
index e576cb7..0bd4014 100644
--- a/frontend/src/components/Dashboard/DashboardLayout.tsx
+++ b/frontend/src/components/Dashboard/DashboardLayout.tsx
@@ -4,6 +4,7 @@ import Sidebar from "./Sidebar";
import StatsCard from "./StatsCard";
import NetworkSelector from "./NetworkSelector";
import { useTheme } from "../../theme";
+import { useWalletAddress } from "../../hooks/useWalletAddress";
import "./DashboardLayout.css";
const stats = [
@@ -15,6 +16,7 @@ const stats = [
export default function DashboardLayout() {
const { theme, toggleTheme } = useTheme();
const [statsLoading, setStatsLoading] = useState(true);
+ const connectedAddress = useWalletAddress();
const nextTheme = theme === "dark" ? "light" : "dark";
useEffect(() => {
@@ -24,7 +26,7 @@ export default function DashboardLayout() {
return (
-
+
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 (