fix(ui): gate four sidebar pages on the roles their endpoints allow

Workflow Runs, Memory and Guardrails Monitor were visible to every role
while their page-load routes are proxy-admin-only, so a non-admin got a
page shell and a 401. Cost Optimization was half-broken the same way: its
Overall charts run on /user/daily/activity, which every role may call, but
tool spend, prompt caching, prompt compression and auto-router benchmarks
are all proxy-admin-only.

Add viewWorkflowRuns, viewMemory, viewGuardrailUsage and
viewProxyWideCostData, each gating the nav entry, the page and the request
together. The first three hide their page, including the direct-URL path,
since nothing on them works for a non-admin. Cost Optimization keeps its
page and drops only the parts a non-admin cannot read.

Gating both Agentic children left roles with no visible child rendering the
parent as a leaf link to ?page=agentic, which is not a route, so a parent
whose children are all filtered out is now dropped.

Role lists follow what the proxy actually grants: proxy_admin and
proxy_admin_viewer are served, and org admins are not, because
_user_is_org_admin needs an organization_id that a page-load GET never
carries.
This commit is contained in:
Yuneng Jiang 2026-08-10 15:35:54 -07:00
parent 444b275ac3
commit 255d65192e
No known key found for this signature in database
15 changed files with 514 additions and 71 deletions

View file

@ -1,5 +1,11 @@
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: useAuthorizedMock,
}));
vi.mock("./UsageTab", () => ({ __esModule: true, default: () => <div data-testid="usage-tab" /> }));
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div data-testid="compression-tab" /> }));
@ -11,9 +17,16 @@ vi.mock("./AutoRouterBenchmarksTab", () => ({
import CostOptimizationView from "./CostOptimizationView";
const renderView = () => render(<CostOptimizationView accessToken="test-token" userId="u1" userRole="proxy_admin" />);
const renderView = (userRole = "Admin") => {
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole });
return render(<CostOptimizationView accessToken="test-token" userId="u1" userRole={userRole} />);
};
describe("CostOptimizationView", () => {
beforeEach(() => {
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "Admin" });
});
it("renders the four cost-optimization tabs", () => {
const { getByText } = renderView();
@ -34,4 +47,29 @@ describe("CostOptimizationView", () => {
expect(getByRole("tab", { name: "Overall" })).toHaveAttribute("aria-selected", "false");
expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "true");
});
// Unlike the other three pages in this cleanup, Cost Optimization keeps its
// nav entry for internal users: the Overall tab runs on /user/daily/activity,
// which every role may call. Only the tabs reading proxy-wide config and
// telemetry (/config/list, /auto_router/benchmarks, guardrail management)
// are proxy-admin-only, so those are what disappear.
describe("proxy-admin-only tabs", () => {
it.each(["Internal User", "Internal Viewer", "Org Admin"])("shows %s the Overall tab only", (userRole) => {
const { getByRole, queryByRole } = renderView(userRole);
expect(getByRole("tab", { name: "Overall" })).toBeInTheDocument();
expect(queryByRole("tab", { name: "Prompt Compression" })).not.toBeInTheDocument();
expect(queryByRole("tab", { name: "Prompt Caching" })).not.toBeInTheDocument();
expect(queryByRole("tab", { name: "Auto-Router" })).not.toBeInTheDocument();
});
it("never mounts the panels behind the admin-only endpoints for an internal user", () => {
const { getByTestId, queryByTestId } = renderView("Internal User");
expect(getByTestId("usage-tab")).toBeInTheDocument();
expect(queryByTestId("compression-tab")).not.toBeInTheDocument();
expect(queryByTestId("caching-tab")).not.toBeInTheDocument();
expect(queryByTestId("autorouter-benchmarks-tab")).not.toBeInTheDocument();
});
});
});

View file

@ -4,6 +4,7 @@ import React from "react";
import { PiggyBank } from "lucide-react";
import { Alert, Tabs } from "antd";
import useCan from "@/app/(dashboard)/hooks/useCan";
import UsageTab from "./UsageTab";
import PromptCompressionTab from "./PromptCompressionTab";
import PromptCachingTab from "./PromptCachingTab";
@ -18,6 +19,7 @@ interface CostOptimizationViewProps {
const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken, userId, userRole }) => {
const activity = useDailyActivityRange(accessToken, userId, userRole);
const canViewProxyWideCostData = useCan("viewProxyWideCostData");
const items = [
{
@ -25,21 +27,25 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
label: "Overall",
children: <UsageTab accessToken={accessToken} activity={activity} />,
},
{
key: "compression",
label: "Prompt Compression",
children: <PromptCompressionTab accessToken={accessToken} />,
},
{
key: "caching",
label: "Prompt Caching",
children: <PromptCachingTab accessToken={accessToken} activity={activity} />,
},
{
key: "autorouter-usage",
label: "Auto-Router",
children: <AutoRouterBenchmarksTab accessToken={accessToken} />,
},
...(canViewProxyWideCostData
? [
{
key: "compression",
label: "Prompt Compression",
children: <PromptCompressionTab accessToken={accessToken} />,
},
{
key: "caching",
label: "Prompt Caching",
children: <PromptCachingTab accessToken={accessToken} activity={activity} />,
},
{
key: "autorouter-usage",
label: "Auto-Router",
children: <AutoRouterBenchmarksTab accessToken={accessToken} />,
},
]
: []),
];
return (

View file

@ -7,6 +7,12 @@ import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
const mockGetToolSpend = vi.fn();
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: useAuthorizedMock,
}));
vi.mock("@/components/networking", () => ({
getToolSpend: (...args: unknown[]) => mockGetToolSpend(...args),
}));
@ -88,11 +94,18 @@ interface RenderOptions {
toolSpend?: ToolSpendResponse;
from?: Date;
to?: Date;
userRole?: string;
}
const renderWith = (results: DailyData[], options: RenderOptions = {}) => {
const { toolSpend = emptyToolSpend, from = new Date(2026, 6, 1), to = new Date(2026, 6, 14) } = options;
const {
toolSpend = emptyToolSpend,
from = new Date(2026, 6, 1),
to = new Date(2026, 6, 14),
userRole = "Admin",
} = options;
mockGetToolSpend.mockResolvedValue(toolSpend);
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole });
return render(
<UsageTab
accessToken="test-token"
@ -374,4 +387,38 @@ describe("UsageTab", () => {
const toolLegends = getAllByTestId("chart-legend").filter((legend) => legend.textContent === "search,read_file");
expect(toolLegends).toHaveLength(1);
});
// `/v1/tool/spend` is proxy-admin-only while the daily-activity charts around
// it are not, so this one card is dropped rather than the whole tab.
describe("proxy-admin-only spend-by-tool card", () => {
const toolSpend = {
by_tool: [{ tool_name: "search", spend: 4.0, call_count: 3, total_tokens: 150 }],
daily: [{ date: "2026-07-12", tool_name: "search", spend: 4.0, call_count: 3 }],
start_date: "2026-07-12",
end_date: "2026-07-12",
};
it.each(["Internal User", "Internal Viewer", "Org Admin"])(
"hides the card and never calls the endpoint for %s",
async (userRole) => {
const { queryByText, getByTestId } = renderWith([day("2026-07-12", { compression_savings_spend: 0.04 })], {
toolSpend,
userRole,
});
// Liveness gate: the daily-activity charts still render for this role,
// so the absence below is the gate, not an empty tab.
expect(getByTestId("donut-chart")).toBeInTheDocument();
expect(queryByText("Spend by tool")).not.toBeInTheDocument();
await vi.waitFor(() => expect(mockGetToolSpend).not.toHaveBeenCalled());
},
);
it("keeps the card and the endpoint call for an admin", async () => {
const { findByText } = renderWith([day("2026-07-12", { compression_savings_spend: 0.04 })], { toolSpend });
expect(await findByText("Spend by tool")).toBeInTheDocument();
expect(mockGetToolSpend).toHaveBeenCalled();
});
});
});

View file

@ -8,6 +8,7 @@ import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { getToolSpend, ToolSpendResponse } from "@/components/networking";
import { SpendMetrics } from "@/components/UsagePage/types";
import { formatNumberWithCommas } from "@/utils/dataUtils";
@ -82,12 +83,13 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
const startTime = dateValue.from ?? null;
const endTime = dateValue.to ?? null;
const toolSpendEnabled = !!accessToken && !!startTime && !!endTime;
const canViewProxyWideCostData = useCan("viewProxyWideCostData");
const toolSpendEnabled = canViewProxyWideCostData && !!accessToken && !!startTime && !!endTime;
const rangeKey = startTime && endTime ? `${isoDay(startTime)}|${isoDay(endTime)}` : "";
const [toolSpendState, setToolSpendState] = useState<{ key: string; data: ToolSpendResponse } | null>(null);
useEffect(() => {
if (!accessToken || !startTime || !endTime) return;
if (!canViewProxyWideCostData || !accessToken || !startTime || !endTime) return;
let cancelled = false;
getToolSpend(accessToken, isoDay(startTime), isoDay(endTime))
.then((res) => {
@ -99,7 +101,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
return () => {
cancelled = true;
};
}, [accessToken, startTime, endTime, rangeKey]);
}, [canViewProxyWideCostData, accessToken, startTime, endTime, rangeKey]);
const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : null;
const toolSpendLoading = toolSpendEnabled && toolSpend === null;
@ -273,55 +275,57 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Spend by tool</CardTitle>
<p className="text-sm text-muted-foreground">
Spend on requests that invoked each tool (MCP and client-side tools); declaring a tool without invoking it
does not count. A request that invoked multiple tools counts its full spend toward each, so this attributes
rather than partitions spend.
</p>
</CardHeader>
<CardContent>
{topTools.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{toolSpendLoading ? "Loading..." : "No tool usage in this range."}
{canViewProxyWideCostData && (
<Card>
<CardHeader>
<CardTitle>Spend by tool</CardTitle>
<p className="text-sm text-muted-foreground">
Spend on requests that invoked each tool (MCP and client-side tools); declaring a tool without invoking it
does not count. A request that invoked multiple tools counts its full spend toward each, so this
attributes rather than partitions spend.
</p>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<p className="mb-2 text-sm font-medium text-muted-foreground">Total by tool</p>
<BarChart
data={topToolsChart}
index="tool_name"
categories={["spend"]}
colors={toolColors}
colorByDatum
layout="vertical"
yAxisWidth={140}
maxBarSize={64}
showLegend={false}
valueFormatter={usd}
/>
</CardHeader>
<CardContent>
{topTools.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{toolSpendLoading ? "Loading..." : "No tool usage in this range."}
</p>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<p className="mb-2 text-sm font-medium text-muted-foreground">Total by tool</p>
<BarChart
data={topToolsChart}
index="tool_name"
categories={["spend"]}
colors={toolColors}
colorByDatum
layout="vertical"
yAxisWidth={140}
maxBarSize={64}
showLegend={false}
valueFormatter={usd}
/>
</div>
<div>
<p className="mb-2 text-sm font-medium text-muted-foreground">Daily spend by tool</p>
<CustomLegend categories={topToolNames} colors={toolColors} />
<BarChart
data={dailyToolSeries}
index="date"
categories={topToolNames}
colors={toolColors}
stack
maxBarSize={64}
valueFormatter={usd}
showLegend={false}
/>
</div>
</div>
<div>
<p className="mb-2 text-sm font-medium text-muted-foreground">Daily spend by tool</p>
<CustomLegend categories={topToolNames} colors={toolColors} />
<BarChart
data={dailyToolSeries}
index="date"
categories={topToolNames}
colors={toolColors}
stack
maxBarSize={64}
valueFormatter={usd}
showLegend={false}
/>
</div>
</div>
)}
</CardContent>
</Card>
)}
</CardContent>
</Card>
)}
</div>
);
};

View file

@ -0,0 +1,53 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import GuardrailsMonitor from "./page";
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: useAuthorizedMock,
}));
const fetchMock = vi.fn();
const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
const renderAs = (userRole: string) => {
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
return renderWithProviders(<GuardrailsMonitor />);
};
// `/guardrails/usage/*` aggregates across tenants and is listed in
// admin_viewer_routes, so it is proxy-admin-only. Nothing on this page works
// for a non-admin, hence the whole page is gated rather than a section of it.
describe("Guardrails Monitor page access by role", () => {
beforeEach(() => {
testQueryClient.clear();
vi.clearAllMocks();
fetchMock.mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ rows: [], chart: [], totalRequests: 0, totalBlocked: 0, passRate: 100 }),
});
vi.stubGlobal("fetch", fetchMock);
});
it("fetches the guardrails usage overview for an admin", async () => {
renderAs("Admin");
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/guardrails/usage/overview"))).toBe(true));
});
it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])(
"renders the admin-only notice and fires no usage request for %s",
async (userRole) => {
renderAs(userRole);
expect(await screen.findByText("Guardrails Monitor is only available to admin users.")).toBeInTheDocument();
await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
expect(requestedUrls().filter((url) => url.includes("/guardrails/usage"))).toEqual([]);
},
);
});

View file

@ -1,9 +1,17 @@
"use client";
import GuardrailsMonitorView from "./_components/GuardrailsMonitorView";
import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
export default function GuardrailsMonitor() {
const { accessToken } = useAuthorized();
const canViewGuardrailUsage = useCan("viewGuardrailUsage");
if (!canViewGuardrailUsage) {
return <AdminOnlyNotice pageTitle="Guardrails Monitor" />;
}
return <GuardrailsMonitorView accessToken={accessToken} />;
}

View file

@ -0,0 +1,59 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Memory from "./page";
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: useAuthorizedMock,
}));
const fetchMock = vi.fn();
const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
const renderAs = (userRole: string) => {
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
return renderWithProviders(<Memory />);
};
// `/v1/memory` scopes rows per caller in the handler, but the route gate keeps
// it proxy-admin-only, so a non-admin deep-linking to /ui/memory gets a 401.
describe("Memory page access by role", () => {
beforeEach(() => {
testQueryClient.clear();
vi.clearAllMocks();
fetchMock.mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
text: async () => "",
json: async () => ({ memories: [], total: 0 }),
});
vi.stubGlobal("fetch", fetchMock);
});
it("lists memory entries for an admin", async () => {
renderAs("Admin");
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/v1/memory"))).toBe(true));
});
it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])(
"renders the admin-only notice and fires no memory request for %s",
async (userRole) => {
renderAs(userRole);
expect(await screen.findByText("Memory is only available to admin users.")).toBeInTheDocument();
await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
expect(requestedUrls().filter((url) => url.includes("/v1/memory"))).toEqual([]);
},
);
it("hides the deprecation banner along with the page body for a denied role", () => {
renderAs("Internal User");
expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument();
});
});

View file

@ -2,10 +2,18 @@
import { MemoryView } from "./_components/MemoryView";
import { DeprecationBanner } from "@/components/DeprecationBanner";
import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
export default function Memory() {
const { accessToken, userRole, userId } = useAuthorized();
const canViewMemory = useCan("viewMemory");
if (!canViewMemory) {
return <AdminOnlyNotice pageTitle="Memory" />;
}
return (
<>
<DeprecationBanner featureName="Memory" />

View file

@ -0,0 +1,59 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Workflows from "./page";
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: useAuthorizedMock,
}));
const fetchMock = vi.fn();
const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
const renderAs = (userRole: string) => {
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
return renderWithProviders(<Workflows />);
};
// Deep-linking to /ui/workflows bypasses the sidebar, so the page itself has to
// refuse the render. `/v1/workflows/runs` is proxy-admin-only, so any request
// from a non-admin is the 401 this gate exists to stop.
describe("Workflows page access by role", () => {
beforeEach(() => {
testQueryClient.clear();
vi.clearAllMocks();
fetchMock.mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ runs: [], count: 0 }),
});
vi.stubGlobal("fetch", fetchMock);
});
it("lists workflow runs for an admin", async () => {
renderAs("Admin");
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/v1/workflows/runs"))).toBe(true));
});
it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])(
"renders the admin-only notice and fires no workflow request for %s",
async (userRole) => {
renderAs(userRole);
expect(await screen.findByText("Workflow Runs is only available to admin users.")).toBeInTheDocument();
await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
expect(requestedUrls().filter((url) => url.includes("/v1/workflows"))).toEqual([]);
},
);
it("hides the deprecation banner along with the page body for a denied role", () => {
renderAs("Internal User");
expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument();
});
});

View file

@ -2,10 +2,18 @@
import WorkflowRuns from "./WorkflowRuns";
import { DeprecationBanner } from "@/components/DeprecationBanner";
import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
export default function Workflows() {
const { accessToken } = useAuthorized();
const canViewWorkflowRuns = useCan("viewWorkflowRuns");
if (!canViewWorkflowRuns) {
return <AdminOnlyNotice pageTitle="Workflow Runs" />;
}
return (
<>
<DeprecationBanner featureName="Workflows" />

View file

@ -268,6 +268,96 @@ describe("Sidebar (leftnav)", () => {
});
});
// Workflow Runs, Memory and Guardrails Monitor render a shell and then 401
// for every non-proxy-admin role, because their page-load routes sit outside
// internal_user_routes / self_managed_routes. Cost Optimization does not:
// its primary call is /user/daily/activity, which every role may make, so
// the entry stays and only its proxy-wide tabs are gated inside the page.
describe("capability-gated pages whose data is proxy-admin-only", () => {
const authFor = (userRole: string) => ({
userId: "some-user-id",
accessToken: "test-access-token",
userRole,
isViewOnly: false,
token: "test-token",
userEmail: "someone@example.com",
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
});
afterEach(() => {
mockUseAuthorized.mockReset();
});
it("hides Workflow Runs and Memory from an internal user under Agentic", async () => {
mockUseAuthorized.mockReturnValue(authFor("internal"));
renderWithProviders(<Sidebar {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByText("Agentic"));
});
// Liveness gate: the sibling Agents child stays visible to this role, so
// the absences below mean the gate fired, not that the group never opened.
await waitFor(() => {
expect(screen.getByText("Agents")).toBeInTheDocument();
});
expect(screen.queryByText("Workflow Runs")).not.toBeInTheDocument();
expect(screen.queryByText("Memory")).not.toBeInTheDocument();
});
// An org admin's session role is "Org Admin", which no capability list
// carries, and the proxy denies these routes to org admins too because
// `_user_is_org_admin` needs an organization_id the page-load GET never sends.
// Agents is already out of reach for this role, so gating the other two
// empties the Agentic group entirely and the parent must go with it rather
// than degrade into a leaf link to the non-route `?page=agentic`.
it("drops the whole Agentic group for an org admin once its last child is gated", () => {
mockUseAuthorized.mockReturnValue(authFor("org_admin"));
renderWithProviders(<Sidebar {...defaultProps} />);
// Liveness gate: Logs carries no role list, so it proves the sidebar rendered.
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.queryByText("Agentic")).not.toBeInTheDocument();
expect(screen.queryByText("Workflow Runs")).not.toBeInTheDocument();
expect(screen.queryByText("Memory")).not.toBeInTheDocument();
});
it("keeps the Agentic group for an internal user, who can still see Agents", () => {
mockUseAuthorized.mockReturnValue(authFor("internal"));
renderWithProviders(<Sidebar {...defaultProps} />);
expect(screen.getByText("Agentic")).toBeInTheDocument();
});
it("shows Workflow Runs and Memory to admins", async () => {
renderWithProviders(<Sidebar {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByText("Agentic"));
});
await waitFor(() => {
expect(screen.getByText("Workflow Runs")).toBeInTheDocument();
});
expect(screen.getByText("Memory")).toBeInTheDocument();
});
it("hides Guardrails Monitor from an internal user while keeping Usage and Cost Optimization", () => {
mockUseAuthorized.mockReturnValue(authFor("internal"));
renderWithProviders(<Sidebar {...defaultProps} />);
expect(screen.queryByText("Guardrails Monitor")).not.toBeInTheDocument();
expect(screen.getByText("Usage")).toBeInTheDocument();
expect(screen.getByText("Cost Optimization")).toBeInTheDocument();
});
it("shows Guardrails Monitor to admins", () => {
renderWithProviders(<Sidebar {...defaultProps} />);
expect(screen.getByText("Guardrails Monitor")).toBeInTheDocument();
});
});
it("should show Organizations tab for organization admins", () => {
mockUseAuthorized.mockReturnValueOnce({
userId: "org-admin-user-id",

View file

@ -146,8 +146,20 @@ const menuGroups: MenuGroup[] = [
icon: <Bot {...ICON} />,
roles: rolesAllowedToViewWriteScopedPages,
},
{ key: "workflows", page: "workflows", label: "Workflow Runs", icon: <Workflow {...ICON} /> },
{ key: "memory", page: "memory", label: "Memory", icon: <Database {...ICON} /> },
{
key: "workflows",
page: "workflows",
label: "Workflow Runs",
icon: <Workflow {...ICON} />,
roles: rolesWithCapability("viewWorkflowRuns"),
},
{
key: "memory",
page: "memory",
label: "Memory",
icon: <Database {...ICON} />,
roles: rolesWithCapability("viewMemory"),
},
],
},
{ key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: <Server {...ICON} /> },
@ -206,7 +218,7 @@ const menuGroups: MenuGroup[] = [
page: "guardrails-monitor",
label: "Guardrails Monitor",
icon: <HeartPulse {...ICON} />,
roles: [...all_admin_roles, ...internalUserRoles],
roles: rolesWithCapability("viewGuardrailUsage"),
},
],
},
@ -455,6 +467,9 @@ const Sidebar_: React.FC<SidebarProps> = ({
return items
.map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined }))
.filter((item) => {
// A parent whose children were all filtered out renders as a leaf link
// to its own page id, which is not a real route. Drop it instead.
if (item.children && item.children.length === 0) return false;
if (item.key === "llm-playground" && isViewOnly) return false;
if (item.key === "organizations" || item.key === "users") {
const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin;

View file

@ -0,0 +1,14 @@
"use client";
import React from "react";
interface AdminOnlyNoticeProps {
pageTitle: string;
}
export const AdminOnlyNotice: React.FC<AdminOnlyNoticeProps> = ({ pageTitle }) => (
<div className="p-6 w-full min-w-0 flex-1">
<h1 className="text-2xl font-semibold text-gray-900 mb-2">{pageTitle}</h1>
<p className="text-sm text-gray-500">{pageTitle} is only available to admin users.</p>
</div>
);

View file

@ -67,6 +67,36 @@ describe.each(["viewAuditLogs", "viewDeletedTeams"] as const)("hasCapability - %
);
});
// `useAuthorized` supplies `userRole` as the formatted session role from
// `effectiveSessionRole`, which collapses proxy_admin_viewer to "Admin" and
// renders an org admin as "Org Admin". The four sidebar pages behind these
// capabilities call proxy-admin-only routes: `_user_is_org_admin` needs an
// `organization_id` in the request data, which a page-load GET never carries,
// so an org admin is denied at the proxy exactly as it is here.
describe.each(["viewWorkflowRuns", "viewMemory", "viewGuardrailUsage", "viewProxyWideCostData"] as const)(
"hasCapability - %s",
(capability) => {
it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])("should grant it to %s", (role) => {
expect(hasCapability(role, capability)).toBe(true);
});
it.each([
"Internal User",
"Internal Viewer",
"internal_user",
"internal_user_viewer",
"Org Admin",
"App User",
"Unknown Role",
"",
null,
undefined,
])("should deny it to %s", (role) => {
expect(hasCapability(role, capability)).toBe(false);
});
},
);
describe("rolesWithCapability", () => {
it("should return a copy so callers cannot mutate the capability map", () => {
const roles = rolesWithCapability("viewToolPolicies");

View file

@ -6,6 +6,10 @@ const CAPABILITY_ROLES = {
viewDeletedTeams: all_admin_roles,
viewPolicies: all_admin_roles,
viewPrompts: all_admin_roles,
viewWorkflowRuns: all_admin_roles,
viewMemory: all_admin_roles,
viewGuardrailUsage: all_admin_roles,
viewProxyWideCostData: all_admin_roles,
} as const satisfies Record<string, readonly string[]>;
export type Capability = keyof typeof CAPABILITY_ROLES;