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
4 changes: 3 additions & 1 deletion frontend/src/components/Dashboard/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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(() => {
Expand All @@ -24,7 +26,7 @@ export default function DashboardLayout() {

return (
<div className="dashboard">
<Sidebar />
<Sidebar connectedAddress={connectedAddress} />
<main className="dashboard-main" role="main">
<header className="dashboard-header" role="banner">
<h2 className="dashboard-heading">Overview</h2>
Expand Down
111 changes: 111 additions & 0 deletions frontend/src/components/Dashboard/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<Sidebar connectedAddress={connectedAddress} />
</MemoryRouter>,
);
}

// ---- 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();
}
});
});
50 changes: 44 additions & 6 deletions frontend/src/components/Dashboard/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
Expand All @@ -21,15 +31,43 @@ const iconMap: Record<string, string> = {
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 (
<aside className="sidebar" role="complementary" aria-label="Sidebar navigation">
<div className="sidebar-header">
<h1 className="sidebar-logo">COMEBACKHERE</h1>
<p className="sidebar-subtitle">Merchant Dashboard</p>
</div>
<nav className="sidebar-nav" aria-label="Dashboard navigation">
{links.map((link) => (
{visibleLinks.map((link) => (
<NavLink
key={link.to}
to={link.to}
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/hooks/useWalletAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useState, useEffect } from "react";

/**
* Returns the public key of the currently-connected Freighter wallet, or null
* if no wallet is connected / Freighter is not installed.
*
* Polls every 5 seconds so the Sidebar updates if the user connects or
* disconnects their wallet while the page is open.
*/
export function useWalletAddress(): string | null {
const [address, setAddress] = useState<string | null>(null);

useEffect(() => {
async function check() {
try {
if (
typeof window === "undefined" ||
!(window as any).freighterApi?.getPublicKey
) {
setAddress(null);
return;
}
const key: string = await (window as any).freighterApi.getPublicKey();
setAddress(key || null);
} catch {
setAddress(null);
}
}

check();
const interval = setInterval(check, 5_000);
return () => clearInterval(interval);
}, []);

return address;
}