refactor(ui): move MCP permission panels onto shadcn primitives

Replaces antd Radio, Checkbox and Tooltip, plus Tremor Text and Badge,
with the in-repo shadcn equivalents across the three MCP permission
panels, and drops the no-restricted-imports suppressions they no longer
need. Also removes the stale suppression on settings.test.tsx, which
imports neither library.

The tool rows keep their existing click-to-toggle behaviour: the row
owns the toggle and the checkbox no longer carries its own change
handler, since Base UI replays the click through a hidden input that
reaches the row on its own.

Adds payload-level tests for the risk-group view covering group clear,
mixed-state re-arm, single-tool toggles from both the box and the row,
and a controlled round trip proving each control re-renders from the
permissions it emitted.
This commit is contained in:
Yuneng Jiang 2026-08-14 11:08:09 -07:00
parent c9917cbf99
commit caf305f732
No known key found for this signature in database
5 changed files with 175 additions and 61 deletions

View file

@ -2504,9 +2504,6 @@
"src/components/mcp_server_management/MCPToolPermissions.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/mcp_tools/ByokCredentialModal.tsx": {
@ -2525,9 +2522,6 @@
"src/components/mcp_tools/McpCrudPermissionPanel.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/mcp_tools/types.tsx": {
@ -2720,9 +2714,6 @@
"src/components/permissions/MCPServerPermissions.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/policies/PolicySelector.tsx": {
@ -2816,11 +2807,6 @@
"count": 1
}
},
"src/components/settings.test.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/settings.tsx": {
"local/filename-pascal-case": {
"count": 1

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@ -63,13 +64,14 @@ describe("MCPToolPermissions", () => {
expect(screen.getByText("read_wiki_structure")).toBeInTheDocument();
});
// Switch to Flat List view for predictable checkbox ordering
const flatListOption = screen.getByText("Flat List");
await userEvent.click(flatListOption);
// Switch to Flat List view, and prove the view actually switched: the flat
// list is the only view that renders the description inline after a dash.
await userEvent.click(screen.getByText("Flat List"));
expect(screen.getByRole("radio", { name: "Flat List" })).toBeChecked();
expect(await screen.findByText("- Get documentation topics")).toBeInTheDocument();
// Click the first checkbox to deselect read_wiki_structure
const checkboxes = screen.getAllByRole("checkbox");
await userEvent.click(checkboxes[0]);
// Deselect read_wiki_structure
await userEvent.click(screen.getByRole("checkbox", { name: "read_wiki_structure" }));
// Verify onChange was called with read_wiki_structure removed
expect(mockOnChange).toHaveBeenCalledWith({
@ -184,4 +186,123 @@ describe("MCPToolPermissions", () => {
[mockServerId]: [],
});
});
describe("risk-group (CRUD) view", () => {
const crudTools = [
{ name: "list_documents", description: "List every document" },
{ name: "get_document", description: "Fetch one document" },
{ name: "delete_document", description: "Destroy a document" },
];
const allCrudToolNames = crudTools.map((t) => t.name);
const renderCrudView = (toolPermissions: Record<string, string[]>, onChange: () => void) => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
{ server_id: mockServerId, server_name: mockServerName, alias: mockServerName },
]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: crudTools, error: false });
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[mockServerId]}
toolPermissions={toolPermissions}
onChange={onChange}
/>,
);
};
it("removes a whole risk group from the saved payload when its group toggle is cleared", async () => {
const mockOnChange = vi.fn();
renderCrudView({ [mockServerId]: allCrudToolNames }, mockOnChange);
const readGroupToggle = await screen.findByRole("checkbox", { name: "Allow all Read tools" });
expect(readGroupToggle).toBeChecked();
await userEvent.click(readGroupToggle);
// Both read-classified tools drop out; the delete-classified one survives.
expect(mockOnChange).toHaveBeenCalledWith({ [mockServerId]: ["delete_document"] });
});
it("adds the rest of a partially-allowed risk group when its mixed toggle is clicked", async () => {
const mockOnChange = vi.fn();
renderCrudView({ [mockServerId]: ["list_documents"] }, mockOnChange);
const readGroupToggle = await screen.findByRole("checkbox", { name: "Allow all Read tools" });
expect(readGroupToggle).toBePartiallyChecked();
await userEvent.click(readGroupToggle);
expect(mockOnChange).toHaveBeenCalledWith({ [mockServerId]: ["list_documents", "get_document"] });
});
it("toggles a single tool exactly once when its checkbox is clicked inside the clickable row", async () => {
const mockOnChange = vi.fn();
renderCrudView({ [mockServerId]: allCrudToolNames }, mockOnChange);
await userEvent.click(await screen.findByRole("checkbox", { name: "delete_document" }));
// The surrounding row is itself clickable, so a click that bubbles would
// toggle twice and the permission would silently stay allowed.
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange).toHaveBeenCalledWith({ [mockServerId]: ["list_documents", "get_document"] });
});
it("toggles a single tool when the row around its checkbox is clicked", async () => {
const mockOnChange = vi.fn();
renderCrudView({ [mockServerId]: allCrudToolNames }, mockOnChange);
await userEvent.click(await screen.findByText("Destroy a document"));
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange).toHaveBeenCalledWith({ [mockServerId]: ["list_documents", "get_document"] });
});
it("re-renders each checkbox from the permissions it emitted", async () => {
// Drives the panel from real parent state so the assertions cover the full
// round trip: click, emitted payload, then the state the panel renders back.
const Harness = () => {
const [permissions, setPermissions] = useState<Record<string, string[]>>({
[mockServerId]: allCrudToolNames,
});
return (
<>
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[mockServerId]}
toolPermissions={permissions}
onChange={setPermissions}
/>
<output>{(permissions[mockServerId] ?? []).join(",")}</output>
</>
);
};
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
{ server_id: mockServerId, server_name: mockServerName, alias: mockServerName },
]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: crudTools, error: false });
renderWithProviders(<Harness />);
const deleteTool = await screen.findByRole("checkbox", { name: "delete_document" });
const readGroupToggle = screen.getByRole("checkbox", { name: "Allow all Read tools" });
expect(deleteTool).toBeChecked();
expect(readGroupToggle).toBeChecked();
await userEvent.click(deleteTool);
expect(deleteTool).not.toBeChecked();
expect(screen.getByRole("status")).toHaveTextContent("list_documents,get_document");
// Clearing one tool of the Read group must leave that group's toggle mixed.
await userEvent.click(screen.getByRole("checkbox", { name: "get_document" }));
expect(readGroupToggle).toBePartiallyChecked();
expect(screen.getByRole("status")).toHaveTextContent("list_documents");
// Re-arming the group restores both read tools and leaves delete blocked.
await userEvent.click(readGroupToggle);
expect(readGroupToggle).toBeChecked();
expect(screen.getByRole("status")).toHaveTextContent("list_documents,get_document");
expect(screen.getByRole("checkbox", { name: "delete_document" })).not.toBeChecked();
});
});
});

View file

@ -1,8 +1,8 @@
import React, { useEffect, useRef, useState, useMemo } from "react";
import { listMCPTools } from "../networking";
import { MCPTool, MCPServer } from "../mcp_tools/types";
import { Text } from "@tremor/react";
import { Spin, Radio } from "antd";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers";
import McpCrudPermissionPanel from "../mcp_tools/McpCrudPermissionPanel";
import { classifyToolOp } from "../../utils/mcpToolCrudClassification";
@ -121,22 +121,27 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-white rounded-t-lg">
<div>
<Text className="font-semibold text-gray-900">{serverName}</Text>
{server.description && <Text className="text-sm text-gray-500">{server.description}</Text>}
<p className="text-sm font-semibold text-gray-900">{serverName}</p>
{server.description && <p className="text-sm text-gray-500">{server.description}</p>}
</div>
<div className="flex items-center gap-3">
{!disabled && tools.length > 0 && (
<Radio.Group
<RadioGroup
value={viewMode}
onChange={(e) => setViewModes((prev) => ({ ...prev, [server.server_id]: e.target.value }))}
size="small"
optionType="button"
buttonStyle="solid"
options={[
{ label: "Risk Groups", value: "crud" },
{ label: "Flat List", value: "flat" },
]}
/>
onValueChange={(next) =>
setViewModes((prev) => ({ ...prev, [server.server_id]: next as "crud" | "flat" }))
}
className="flex w-auto items-center gap-4"
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="crud" />
Risk Groups
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="flat" />
Flat List
</label>
</RadioGroup>
)}
{!disabled && (
<>
@ -166,16 +171,16 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-8">
<Spin size="large" />
<Text className="ml-3 text-gray-500">Loading tools...</Text>
<UiLoadingSpinner />
<p className="ml-3 text-sm text-gray-500">Loading tools...</p>
</div>
)}
{/* Error */}
{error && !isLoading && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-center">
<Text className="text-red-600 font-medium">Unable to load tools</Text>
<Text className="text-sm text-red-500 mt-1">{error}</Text>
<p className="text-sm text-red-600 font-medium">Unable to load tools</p>
<p className="text-sm text-red-500 mt-1">{error}</p>
</div>
)}
@ -198,6 +203,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
<div key={tool.name} className="flex items-start gap-2">
<input
type="checkbox"
aria-label={tool.name}
checked={isSelected}
onChange={() => {
if (disabled) return;
@ -211,8 +217,8 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Text className="font-medium text-gray-900">{tool.name}</Text>
<Text className="text-sm text-gray-500">- {tool.description || "No description"}</Text>
<p className="text-sm font-medium text-gray-900">{tool.name}</p>
<p className="text-sm text-gray-500">- {tool.description || "No description"}</p>
</div>
</div>
</div>
@ -224,7 +230,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
{/* Empty State */}
{!isLoading && !error && tools.length === 0 && (
<div className="text-center py-6">
<Text className="text-gray-500">No tools available</Text>
<p className="text-sm text-gray-500">No tools available</p>
</div>
)}
</div>

View file

@ -10,8 +10,7 @@
*/
import React, { useMemo, useState } from "react";
import { Checkbox } from "antd";
import { Text } from "@tremor/react";
import { Checkbox } from "@/components/ui/checkbox";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { CrudOp, MCPToolEntry, CRUD_GROUP_META, groupToolsByCrud } from "../../utils/mcpToolCrudClassification";
@ -187,14 +186,13 @@ const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
{!readOnly && (
<div className="flex items-center gap-2 ml-4">
<Text className="text-xs text-gray-500">
{fullyAllowed ? "All on" : partial ? "Partial" : "All off"}
</Text>
<p className="text-xs text-gray-500">{fullyAllowed ? "All on" : partial ? "Partial" : "All off"}</p>
{/* Checkbox supports `indeterminate`; Switch does not. */}
<Checkbox
aria-label={`Allow all ${meta.label} tools`}
checked={fullyAllowed}
indeterminate={partial}
onChange={(e) => toggleGroup(op, e.target.checked)}
onCheckedChange={(checked) => toggleGroup(op, checked)}
onClick={(e) => e.stopPropagation()}
/>
</div>
@ -228,16 +226,18 @@ const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
} ${allowed ? "" : "opacity-60"}`}
onClick={() => toggleTool(tool.name)}
>
{/* The row's onClick is the single toggle path. Giving this checkbox its
own change handler as well would toggle twice per click on the box. */}
<Checkbox
aria-label={tool.name}
checked={allowed}
onChange={() => toggleTool(tool.name)}
disabled={readOnly}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">
<Text className="font-medium text-gray-900 text-sm">{tool.name}</Text>
<p className="font-medium text-gray-900 text-sm">{tool.name}</p>
{tool.description && (
<Text className="text-xs text-gray-500 mt-0.5 leading-snug">{tool.description}</Text>
<p className="text-xs text-gray-500 mt-0.5 leading-snug">{tool.description}</p>
)}
</div>
<span

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import { Text, Badge } from "@tremor/react";
import { ServerIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
import { Tooltip } from "antd";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { fetchMCPServers, fetchMCPToolsets } from "../networking";
import { MCPServer, MCPToolset } from "../mcp_tools/types";
import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
@ -111,8 +111,8 @@ export function MCPServerPermissions({
<div className="space-y-3">
<div className="flex items-center gap-2">
<ServerIcon className="h-4 w-4 text-blue-600" />
<Text className="font-semibold text-gray-900">MCP Servers</Text>
<Badge color={blocksAllMcpServers ? "red" : "blue"} size="xs">
<p className="text-sm font-semibold text-gray-900">MCP Servers</p>
<Badge variant={blocksAllMcpServers ? "destructive" : "default"}>
{blocksAllMcpServers ? "Blocked" : grantsAllProxyMcpServers ? "All" : totalCount}
</Badge>
</div>
@ -120,14 +120,14 @@ export function MCPServerPermissions({
{blocksAllMcpServers ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-50 border border-red-200">
<ServerIcon className="h-4 w-4 text-red-400" />
<Text className="text-red-700 text-sm">
<p className="text-red-700 text-sm">
No MCP servers this key is blocked from all MCP servers, including its team&apos;s servers
</Text>
</p>
</div>
) : grantsAllProxyMcpServers ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-50 border border-blue-200">
<ServerIcon className="h-4 w-4 text-blue-400" />
<Text className="text-blue-700 text-sm">All Proxy MCP Servers</Text>
<p className="text-blue-700 text-sm">All Proxy MCP Servers</p>
</div>
) : totalCount > 0 ? (
<div className="max-h-[400px] overflow-y-auto space-y-2 pr-1">
@ -146,13 +146,14 @@ export function MCPServerPermissions({
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{item.type === "server" ? (
<Tooltip title={`Full ID: ${item.value}`} placement="top">
<div className="inline-flex items-center gap-2 min-w-0">
<Tooltip>
<TooltipTrigger render={<div className="inline-flex items-center gap-2 min-w-0" />}>
<span className="inline-block w-1.5 h-1.5 bg-blue-500 rounded-full shrink-0"></span>
<span className="text-sm font-medium text-gray-900 truncate">
{getMCPServerDisplayName(item.value)}
</span>
</div>
</TooltipTrigger>
<TooltipContent>{`Full ID: ${item.value}`}</TooltipContent>
</Tooltip>
) : (
<div className="inline-flex items-center gap-2 min-w-0">
@ -256,7 +257,7 @@ export function MCPServerPermissions({
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
<ServerIcon className="h-4 w-4 text-gray-400" />
<Text className="text-gray-500 text-sm">No MCP servers, access groups, or toolsets configured</Text>
<p className="text-gray-500 text-sm">No MCP servers, access groups, or toolsets configured</p>
</div>
)}
</div>