diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx
new file mode 100644
index 00000000000..67c6d7d6cc9
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx
@@ -0,0 +1,505 @@
+import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import MakeAgentPublicForm from "./MakeAgentPublicForm";
+import { AgentHubData } from "@/components/AIHub/AgentHubTableColumns";
+
+// Mock the networking function
+vi.mock("../../networking", () => ({
+ makeAgentsPublicCall: vi.fn(),
+}));
+
+// Import the mocked function
+import { makeAgentsPublicCall } from "../../networking";
+const mockMakeAgentsPublicCall = vi.mocked(makeAgentsPublicCall);
+
+// Mock antd components
+vi.mock("antd", () => ({
+ Modal: ({ open, title, children, onCancel, footer }: any) =>
+ open ? (
+
+
{title}
+ {children}
+ {footer}
+
+ ) : null,
+ Form: Object.assign(({ children, form }: any) =>
, {
+ useForm: () => [
+ {
+ resetFields: vi.fn(),
+ validateFields: vi.fn(),
+ getFieldsValue: vi.fn(),
+ setFieldsValue: vi.fn(),
+ },
+ vi.fn(),
+ ],
+ Item: ({ children }: any) =>
{children}
,
+ }),
+ Steps: Object.assign(
+ ({ children, current, className }: any) => (
+
+ {children}
+
+ ),
+ {
+ Step: ({ title }: any) =>
{title}
,
+ },
+ ),
+ Button: ({ children, onClick, disabled, loading, ...props }: any) => (
+
+ {children}
+
+ ),
+ Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => (
+
+ onChange({ target: { checked: e.target.checked } })}
+ disabled={disabled}
+ data-indeterminate={indeterminate}
+ />
+ {children}
+
+ ),
+}));
+
+// Mock @tremor/react components
+vi.mock("@tremor/react", () => ({
+ Text: ({ children, className }: any) =>
{children} ,
+ Title: ({ children }: any) =>
{children} ,
+ Badge: ({ children, color, size }: any) => (
+
+ {children}
+
+ ),
+}));
+
+describe("MakeAgentPublicForm", () => {
+ const mockProps = {
+ visible: true,
+ onClose: vi.fn(),
+ accessToken: "test-token",
+ agentHubData: [
+ {
+ agent_id: "agent-1",
+ name: "Test Agent 1",
+ description: "Description 1",
+ version: "1.0",
+ is_public: false,
+ skills: [
+ { id: "skill-1", name: "Skill 1", description: "Skill desc" },
+ { id: "skill-2", name: "Skill 2", description: "Skill desc" },
+ ],
+ protocolVersion: "1.0",
+ },
+ {
+ agent_id: "agent-2",
+ name: "Test Agent 2",
+ description: "Description 2",
+ version: "2.0",
+ is_public: true,
+ skills: [],
+ protocolVersion: "1.0",
+ },
+ ] as AgentHubData[],
+ onSuccess: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it("should render the component", () => {
+ render(
);
+
+ expect(screen.getByText("Make Agents Public")).toBeInTheDocument();
+ expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument();
+ });
+
+ it("should initialize with correct state", () => {
+ render(
);
+
+ // Check that the component renders with the correct title and content
+ expect(screen.getByText("Make Agents Public")).toBeInTheDocument();
+ expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument();
+
+ // Check that all agent checkboxes are present
+ const checkboxes = screen.getAllByRole("checkbox");
+ expect(checkboxes).toHaveLength(3); // Select all + 2 agents
+
+ // Check that the Next button is enabled (agents are preselected)
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ expect(nextButton).not.toBeDisabled();
+ });
+
+ it("should handle agent selection and navigation", async () => {
+ render(
);
+
+ // Initially on step 1
+ expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument();
+
+ // Select all agents using the select all checkbox
+ const selectAllCheckbox = screen.getByLabelText("Select All (2)");
+ await act(async () => {
+ fireEvent.click(selectAllCheckbox);
+ });
+
+ // Verify Next button is enabled
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ expect(nextButton).not.toBeDisabled();
+
+ // Click Next
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ // Should move to step 2
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
+ });
+ });
+
+ it("should submit selected agents successfully", async () => {
+ mockMakeAgentsPublicCall.mockResolvedValueOnce({});
+
+ render(
);
+
+ // Select all agents
+ const selectAllCheckbox = screen.getByLabelText("Select All (2)");
+ await act(async () => {
+ fireEvent.click(selectAllCheckbox);
+ });
+
+ // Navigate to confirm step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ // Wait for navigation to complete
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
+ });
+
+ // Submit
+ const submitButton = screen.getByRole("button", { name: "Make Public" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ await waitFor(() => {
+ expect(mockMakeAgentsPublicCall).toHaveBeenCalledWith("test-token", ["agent-1", "agent-2"]);
+ expect(mockProps.onSuccess).toHaveBeenCalled();
+ expect(mockProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it("should handle select all functionality", async () => {
+ render(
);
+
+ const checkboxes = screen.getAllByRole("checkbox");
+ const selectAllCheckbox = checkboxes[0];
+
+ // Select all
+ await act(async () => {
+ fireEvent.click(selectAllCheckbox);
+ });
+
+ // All checkboxes should be checked
+ checkboxes.forEach((checkbox) => {
+ expect(checkbox).toBeChecked();
+ });
+
+ // Deselect all
+ await act(async () => {
+ fireEvent.click(selectAllCheckbox);
+ });
+
+ // All checkboxes should be unchecked except the indeterminate state
+ expect(checkboxes[0]).not.toBeChecked();
+ expect(checkboxes[1]).not.toBeChecked();
+ expect(checkboxes[2]).not.toBeChecked();
+ });
+
+ it("should show error when no agents selected", async () => {
+ render(
);
+
+ // Deselect all agents first
+ const checkboxes = screen.getAllByRole("checkbox");
+ await act(async () => {
+ fireEvent.click(checkboxes[0]); // Click select all to select all
+ fireEvent.click(checkboxes[0]); // Click select all again to deselect all
+ });
+
+ // Try to go to next step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ // Should stay on same step
+ expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument();
+ });
+
+ it("should display empty state when no agents are available", () => {
+ const emptyProps = {
+ ...mockProps,
+ agentHubData: [] as AgentHubData[],
+ };
+
+ render(
);
+
+ expect(screen.getByText("No agents available.")).toBeInTheDocument();
+
+ // Select All checkbox should be disabled
+ const selectAllCheckbox = screen.getByLabelText("Select All");
+ expect(selectAllCheckbox).toBeDisabled();
+
+ // Next button should be disabled
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ expect(nextButton).toBeDisabled();
+ });
+
+ it("should handle Cancel button functionality", async () => {
+ render(
);
+
+ // Click Cancel button
+ const cancelButton = screen.getByRole("button", { name: "Cancel" });
+ await act(async () => {
+ fireEvent.click(cancelButton);
+ });
+
+ // Should call onClose
+ expect(mockProps.onClose).toHaveBeenCalled();
+ });
+
+ it("should handle Previous button functionality", async () => {
+ render(
);
+
+ // Navigate to step 1
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ // Verify we're on step 1
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
+ });
+
+ // Click Previous button
+ const previousButton = screen.getByRole("button", { name: "Previous" });
+ await act(async () => {
+ fireEvent.click(previousButton);
+ });
+
+ // Should go back to step 0
+ expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument();
+ });
+
+ it("should handle individual agent selection", async () => {
+ render(
);
+
+ // Get all checkboxes (select all + individual agents)
+ const checkboxes = screen.getAllByRole("checkbox");
+ expect(checkboxes).toHaveLength(3); // Select all + 2 agents
+
+ // Initially, agent-2 should be selected (it's already public)
+ const agent1Checkbox = checkboxes[1]; // First agent checkbox
+ const agent2Checkbox = checkboxes[2]; // Second agent checkbox
+
+ expect(agent2Checkbox).toBeChecked(); // agent-2 is already public
+
+ // Select agent-1
+ await act(async () => {
+ fireEvent.click(agent1Checkbox);
+ });
+
+ expect(agent1Checkbox).toBeChecked();
+ expect(agent2Checkbox).toBeChecked();
+
+ // Deselect agent-2
+ await act(async () => {
+ fireEvent.click(agent2Checkbox);
+ });
+
+ expect(agent1Checkbox).toBeChecked();
+ expect(agent2Checkbox).not.toBeChecked();
+
+ // Select all should be indeterminate now
+ const selectAllCheckbox = checkboxes[0];
+ expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
+ });
+
+ it("should display skills overflow text when agent has more than 3 skills", () => {
+ const agentWithManySkills = {
+ ...mockProps.agentHubData[0],
+ skills: [
+ { id: "skill-1", name: "Skill 1", description: "Skill desc" },
+ { id: "skill-2", name: "Skill 2", description: "Skill desc" },
+ { id: "skill-3", name: "Skill 3", description: "Skill desc" },
+ { id: "skill-4", name: "Skill 4", description: "Skill desc" },
+ { id: "skill-5", name: "Skill 5", description: "Skill desc" },
+ ],
+ };
+
+ const propsWithManySkills = {
+ ...mockProps,
+ agentHubData: [agentWithManySkills],
+ };
+
+ render(
);
+
+ // Should show first 3 skills as badges
+ expect(screen.getByText("Skill 1")).toBeInTheDocument();
+ expect(screen.getByText("Skill 2")).toBeInTheDocument();
+ expect(screen.getByText("Skill 3")).toBeInTheDocument();
+
+ // Should show "+2 more" text for the remaining skills
+ expect(screen.getByText("+2 more")).toBeInTheDocument();
+ });
+
+ it("should handle submit error properly", async () => {
+ const errorMessage = "Network error";
+ mockMakeAgentsPublicCall.mockRejectedValueOnce(new Error(errorMessage));
+
+ render(
);
+
+ // Navigate to confirm step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
+ });
+
+ // Submit
+ const submitButton = screen.getByRole("button", { name: "Make Public" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Should handle error and show error notification
+ await waitFor(() => {
+ expect(mockMakeAgentsPublicCall).toHaveBeenCalledWith("test-token", ["agent-2"]);
+ });
+
+ // Should not call onSuccess or onClose on error
+ expect(mockProps.onSuccess).not.toHaveBeenCalled();
+ expect(mockProps.onClose).not.toHaveBeenCalled();
+ });
+
+ it("should show loading state during submit", async () => {
+ let resolvePromise: (value: any) => void = () => {};
+ const pendingPromise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+ mockMakeAgentsPublicCall.mockReturnValueOnce(pendingPromise);
+
+ render(
);
+
+ // Navigate to confirm step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
+ });
+
+ // Submit
+ const submitButton = screen.getByRole("button", { name: "Make Public" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Check loading state
+ expect(submitButton).toHaveAttribute("data-loading", "true");
+ expect(submitButton).toBeDisabled();
+
+ // Resolve the promise
+ resolvePromise({});
+ await waitFor(() => {
+ expect(mockProps.onSuccess).toHaveBeenCalled();
+ expect(mockProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it("should not render modal when visible is false", () => {
+ const invisibleProps = {
+ ...mockProps,
+ visible: false,
+ };
+
+ render(
);
+
+ // Modal should not be rendered
+ expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
+ expect(screen.queryByText("Make Agents Public")).not.toBeInTheDocument();
+ });
+
+ it("should preselect already public agents when modal opens", () => {
+ // Test data where one agent is public and one is not
+ const mixedPublicProps = {
+ ...mockProps,
+ agentHubData: [
+ {
+ agent_id: "agent-1",
+ name: "Test Agent 1",
+ description: "Description 1",
+ url: "http://example.com/agent1",
+ version: "1.0",
+ is_public: false, // Not public
+ skills: [],
+ protocolVersion: "1.0",
+ },
+ {
+ agent_id: "agent-2",
+ name: "Test Agent 2",
+ description: "Description 2",
+ url: "http://example.com/agent2",
+ version: "2.0",
+ is_public: true, // Already public
+ skills: [],
+ protocolVersion: "1.0",
+ },
+ {
+ agent_id: "agent-3",
+ name: "Test Agent 3",
+ description: "Description 3",
+ url: "http://example.com/agent3",
+ version: "3.0",
+ is_public: true, // Already public
+ skills: [],
+ protocolVersion: "1.0",
+ },
+ ] as AgentHubData[],
+ };
+
+ render(
);
+
+ // Check that the correct checkboxes are selected
+ const checkboxes = screen.getAllByRole("checkbox");
+ expect(checkboxes).toHaveLength(4); // Select all + 3 agents
+
+ // agent-2 and agent-3 should be checked (they're already public)
+ const agent1Checkbox = checkboxes[1];
+ const agent2Checkbox = checkboxes[2];
+ const agent3Checkbox = checkboxes[3];
+
+ expect(agent1Checkbox).not.toBeChecked(); // agent-1 is not public
+ expect(agent2Checkbox).toBeChecked(); // agent-2 is public
+ expect(agent3Checkbox).toBeChecked(); // agent-3 is public
+
+ // Select all should be indeterminate
+ const selectAllCheckbox = checkboxes[0];
+ expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/make_agent_public_form.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx
similarity index 97%
rename from ui/litellm-dashboard/src/components/make_agent_public_form.tsx
rename to ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx
index 54548ddba07..a38950b8fb7 100644
--- a/ui/litellm-dashboard/src/components/make_agent_public_form.tsx
+++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx
@@ -1,9 +1,9 @@
import React, { useState, useEffect } from "react";
import { Modal, Form, Steps, Button, Checkbox } from "antd";
import { Text, Title, Badge } from "@tremor/react";
-import { makeAgentsPublicCall } from "./networking";
-import NotificationsManager from "./molecules/notifications_manager";
-import { AgentHubData } from "./agent_hub_table_columns";
+import { makeAgentsPublicCall } from "../../networking";
+import NotificationsManager from "../../molecules/notifications_manager";
+import { AgentHubData } from "@/components/AIHub/AgentHubTableColumns";
const { Step } = Steps;
diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx
new file mode 100644
index 00000000000..b0228e9e868
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx
@@ -0,0 +1,562 @@
+import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import MakeMCPPublicForm from "./MakeMCPPublicForm";
+import { MCPServerData } from "../../mcp_hub_table_columns";
+
+// Mock the networking function
+vi.mock("../../networking", () => ({
+ makeMCPPublicCall: vi.fn(),
+}));
+
+// Import the mocked function
+import { makeMCPPublicCall } from "../../networking";
+const mockMakeMCPPublicCall = vi.mocked(makeMCPPublicCall);
+
+// Mock antd components
+vi.mock("antd", () => ({
+ Modal: ({ open, title, children, onCancel, footer }: any) =>
+ open ? (
+
+
{title}
+ {children}
+ {footer}
+
+ ) : null,
+ Form: Object.assign(({ children, form }: any) =>
, {
+ useForm: () => [
+ {
+ resetFields: vi.fn(),
+ validateFields: vi.fn(),
+ getFieldsValue: vi.fn(),
+ setFieldsValue: vi.fn(),
+ },
+ vi.fn(),
+ ],
+ Item: ({ children }: any) =>
{children}
,
+ }),
+ Steps: Object.assign(
+ ({ children, current, className }: any) => (
+
+ {children}
+
+ ),
+ {
+ Step: ({ title }: any) =>
{title}
,
+ },
+ ),
+ Button: ({ children, onClick, disabled, loading, ...props }: any) => (
+
+ {children}
+
+ ),
+ Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => (
+
+ onChange({ target: { checked: e.target.checked } })}
+ disabled={disabled}
+ data-indeterminate={indeterminate}
+ />
+ {children}
+
+ ),
+}));
+
+// Additional @tremor/react mocks (Button is already mocked globally)
+vi.mock("@tremor/react", async (importOriginal) => {
+ const actual = await importOriginal
();
+ return {
+ ...actual,
+ Text: ({ children, className }: any) => {children} ,
+ Title: ({ children }: any) => {children} ,
+ Badge: ({ children, color, size }: any) => (
+
+ {children}
+
+ ),
+ };
+});
+
+describe("MakeMCPPublicForm", () => {
+ const mockProps = {
+ visible: true,
+ onClose: vi.fn(),
+ accessToken: "test-token",
+ mcpHubData: [
+ {
+ server_id: "server-1",
+ server_name: "Test Server 1",
+ description: "Description 1",
+ url: "http://example.com/server1",
+ transport: "http",
+ status: "active",
+ mcp_info: { is_public: false },
+ allowed_tools: ["tool-1", "tool-2"],
+ auth_type: "bearer",
+ credentials: {},
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user1",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user1",
+ teams: [],
+ mcp_access_groups: [],
+ extra_headers: [],
+ static_headers: {},
+ args: [],
+ env: {},
+ },
+ {
+ server_id: "server-2",
+ server_name: "Test Server 2",
+ description: "Description 2",
+ url: "http://example.com/server2",
+ transport: "websocket",
+ status: "inactive",
+ mcp_info: { is_public: true },
+ allowed_tools: [],
+ auth_type: "none",
+ credentials: {},
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user2",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user2",
+ teams: [],
+ mcp_access_groups: [],
+ extra_headers: [],
+ static_headers: {},
+ args: [],
+ env: {},
+ },
+ ] as MCPServerData[],
+ onSuccess: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it("should render the component", () => {
+ render( );
+
+ expect(screen.getByText("Make MCP Servers Public")).toBeInTheDocument();
+ expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument();
+ });
+
+ it("should initialize with correct state", () => {
+ render( );
+
+ // Check that the component renders with the correct title and content
+ expect(screen.getByText("Make MCP Servers Public")).toBeInTheDocument();
+ expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument();
+
+ // Check that all server checkboxes are present
+ const checkboxes = screen.getAllByRole("checkbox");
+ expect(checkboxes).toHaveLength(3); // Select all + 2 servers
+
+ // Check that the Next button is enabled (servers are preselected)
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ expect(nextButton).not.toBeDisabled();
+ });
+
+ it("should handle server selection and navigation", async () => {
+ render( );
+
+ // Initially on step 1
+ expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument();
+
+ // Select all servers using the select all checkbox
+ const selectAllCheckbox = screen.getByLabelText("Select All (2)");
+ await act(async () => {
+ fireEvent.click(selectAllCheckbox);
+ });
+
+ // Verify Next button is enabled
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ expect(nextButton).not.toBeDisabled();
+
+ // Click Next
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ // Should move to step 2
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
+ });
+ });
+
+ it("should submit selected servers successfully", async () => {
+ mockMakeMCPPublicCall.mockResolvedValueOnce({});
+
+ render( );
+
+ // Select all servers
+ const selectAllCheckbox = screen.getByLabelText("Select All (2)");
+ await act(async () => {
+ fireEvent.click(selectAllCheckbox);
+ });
+
+ // Navigate to confirm step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ // Wait for navigation to complete
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
+ });
+
+ // Submit
+ const submitButton = screen.getByRole("button", { name: "Make Public" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ await waitFor(() => {
+ expect(mockMakeMCPPublicCall).toHaveBeenCalledWith("test-token", ["server-1", "server-2"]);
+ expect(mockProps.onSuccess).toHaveBeenCalled();
+ expect(mockProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it("should handle select all functionality", async () => {
+ render( );
+
+ const checkboxes = screen.getAllByRole("checkbox");
+ const selectAllCheckbox = checkboxes[0];
+
+ // Select all
+ await act(async () => {
+ fireEvent.click(selectAllCheckbox);
+ });
+
+ // All checkboxes should be checked
+ checkboxes.forEach((checkbox) => {
+ expect(checkbox).toBeChecked();
+ });
+
+ // Deselect all
+ await act(async () => {
+ fireEvent.click(selectAllCheckbox);
+ });
+
+ // All checkboxes should be unchecked except the indeterminate state
+ expect(checkboxes[0]).not.toBeChecked();
+ expect(checkboxes[1]).not.toBeChecked();
+ expect(checkboxes[2]).not.toBeChecked();
+ });
+
+ it("should show error when no servers selected", async () => {
+ render( );
+
+ // Deselect all servers first
+ const checkboxes = screen.getAllByRole("checkbox");
+ await act(async () => {
+ fireEvent.click(checkboxes[0]); // Click select all to select all
+ fireEvent.click(checkboxes[0]); // Click select all again to deselect all
+ });
+
+ // Try to go to next step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ // Should stay on same step
+ expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument();
+ });
+
+ it("should display empty state when no servers are available", () => {
+ const emptyProps = {
+ ...mockProps,
+ mcpHubData: [] as MCPServerData[],
+ };
+
+ render( );
+
+ expect(screen.getByText("No MCP servers available.")).toBeInTheDocument();
+
+ // Select All checkbox should be disabled
+ const selectAllCheckbox = screen.getByLabelText("Select All");
+ expect(selectAllCheckbox).toBeDisabled();
+
+ // Next button should be disabled
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ expect(nextButton).toBeDisabled();
+ });
+
+ it("should handle Cancel button functionality", async () => {
+ render( );
+
+ // Click Cancel button
+ const cancelButton = screen.getByRole("button", { name: "Cancel" });
+ await act(async () => {
+ fireEvent.click(cancelButton);
+ });
+
+ // Should call onClose
+ expect(mockProps.onClose).toHaveBeenCalled();
+ });
+
+ it("should handle Previous button functionality", async () => {
+ render( );
+
+ // Navigate to step 1
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ // Verify we're on step 1
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
+ });
+
+ // Click Previous button
+ const previousButton = screen.getByRole("button", { name: "Previous" });
+ await act(async () => {
+ fireEvent.click(previousButton);
+ });
+
+ // Should go back to step 0
+ expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument();
+ });
+
+ it("should handle individual server selection", async () => {
+ render( );
+
+ // Get all checkboxes (select all + individual servers)
+ const checkboxes = screen.getAllByRole("checkbox");
+ expect(checkboxes).toHaveLength(3); // Select all + 2 servers
+
+ // Initially, server-2 should be selected (it's already public)
+ const server1Checkbox = checkboxes[1]; // First server checkbox
+ const server2Checkbox = checkboxes[2]; // Second server checkbox
+
+ expect(server2Checkbox).toBeChecked(); // server-2 is already public
+
+ // Select server-1
+ await act(async () => {
+ fireEvent.click(server1Checkbox);
+ });
+
+ expect(server1Checkbox).toBeChecked();
+ expect(server2Checkbox).toBeChecked();
+
+ // Deselect server-2
+ await act(async () => {
+ fireEvent.click(server2Checkbox);
+ });
+
+ expect(server1Checkbox).toBeChecked();
+ expect(server2Checkbox).not.toBeChecked();
+
+ // Select all should be indeterminate now
+ const selectAllCheckbox = checkboxes[0];
+ expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
+ });
+
+ it("should display tools overflow text when server has more than 3 tools", () => {
+ const serverWithManyTools = {
+ ...mockProps.mcpHubData[0],
+ allowed_tools: ["tool-1", "tool-2", "tool-3", "tool-4", "tool-5"],
+ };
+
+ const propsWithManyTools = {
+ ...mockProps,
+ mcpHubData: [serverWithManyTools],
+ };
+
+ render( );
+
+ // Should show first 3 tools as badges
+ expect(screen.getByText("tool-1")).toBeInTheDocument();
+ expect(screen.getByText("tool-2")).toBeInTheDocument();
+ expect(screen.getByText("tool-3")).toBeInTheDocument();
+
+ // Should show "+2 more" text for the remaining tools
+ expect(screen.getByText("+2 more")).toBeInTheDocument();
+ });
+
+ it("should handle submit error properly", async () => {
+ const errorMessage = "Network error";
+ mockMakeMCPPublicCall.mockRejectedValueOnce(new Error(errorMessage));
+
+ render( );
+
+ // Navigate to confirm step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
+ });
+
+ // Submit
+ const submitButton = screen.getByRole("button", { name: "Make Public" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Should handle error and show error notification
+ await waitFor(() => {
+ expect(mockMakeMCPPublicCall).toHaveBeenCalledWith("test-token", ["server-2"]);
+ });
+
+ // Should not call onSuccess or onClose on error
+ expect(mockProps.onSuccess).not.toHaveBeenCalled();
+ expect(mockProps.onClose).not.toHaveBeenCalled();
+ });
+
+ it("should show loading state during submit", async () => {
+ let resolvePromise: (value: any) => void = () => {};
+ const pendingPromise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+ mockMakeMCPPublicCall.mockReturnValueOnce(pendingPromise);
+
+ render( );
+
+ // Navigate to confirm step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
+ });
+
+ // Submit
+ const submitButton = screen.getByRole("button", { name: "Make Public" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Check loading state
+ expect(submitButton).toHaveAttribute("data-loading", "true");
+ expect(submitButton).toBeDisabled();
+
+ // Resolve the promise
+ resolvePromise({});
+ await waitFor(() => {
+ expect(mockProps.onSuccess).toHaveBeenCalled();
+ expect(mockProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it("should not render modal when visible is false", () => {
+ const invisibleProps = {
+ ...mockProps,
+ visible: false,
+ };
+
+ render( );
+
+ // Modal should not be rendered
+ expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
+ expect(screen.queryByText("Make MCP Servers Public")).not.toBeInTheDocument();
+ });
+
+ it("should preselect already public servers when modal opens", () => {
+ // Test data where one server is public and one is not
+ const mixedPublicProps = {
+ ...mockProps,
+ mcpHubData: [
+ {
+ server_id: "server-1",
+ server_name: "Test Server 1",
+ description: "Description 1",
+ url: "http://example.com/server1",
+ transport: "http",
+ status: "active",
+ mcp_info: { is_public: false }, // Not public
+ allowed_tools: [],
+ auth_type: "bearer",
+ credentials: {},
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user1",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user1",
+ teams: [],
+ mcp_access_groups: [],
+ extra_headers: [],
+ static_headers: {},
+ args: [],
+ env: {},
+ },
+ {
+ server_id: "server-2",
+ server_name: "Test Server 2",
+ description: "Description 2",
+ url: "http://example.com/server2",
+ transport: "websocket",
+ status: "inactive",
+ mcp_info: { is_public: true }, // Already public
+ allowed_tools: [],
+ auth_type: "none",
+ credentials: {},
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user2",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user2",
+ teams: [],
+ mcp_access_groups: [],
+ extra_headers: [],
+ static_headers: {},
+ args: [],
+ env: {},
+ },
+ {
+ server_id: "server-3",
+ server_name: "Test Server 3",
+ description: "Description 3",
+ url: "http://example.com/server3",
+ transport: "sse",
+ status: "healthy",
+ mcp_info: { is_public: true }, // Already public
+ allowed_tools: [],
+ auth_type: "oauth",
+ credentials: {},
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user3",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user3",
+ teams: [],
+ mcp_access_groups: [],
+ extra_headers: [],
+ static_headers: {},
+ args: [],
+ env: {},
+ },
+ ] as MCPServerData[],
+ };
+
+ render( );
+
+ // Check that the correct checkboxes are selected
+ const checkboxes = screen.getAllByRole("checkbox");
+ expect(checkboxes).toHaveLength(4); // Select all + 3 servers
+
+ // server-2 and server-3 should be checked (they're already public)
+ const server1Checkbox = checkboxes[1];
+ const server2Checkbox = checkboxes[2];
+ const server3Checkbox = checkboxes[3];
+
+ expect(server1Checkbox).not.toBeChecked(); // server-1 is not public
+ expect(server2Checkbox).toBeChecked(); // server-2 is public
+ expect(server3Checkbox).toBeChecked(); // server-3 is public
+
+ // Select all should be indeterminate
+ const selectAllCheckbox = checkboxes[0];
+ expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx
similarity index 98%
rename from ui/litellm-dashboard/src/components/make_mcp_public_form.tsx
rename to ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx
index f7bba175800..d7103da9ed7 100644
--- a/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx
+++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx
@@ -1,9 +1,9 @@
import React, { useState, useEffect } from "react";
import { Modal, Form, Steps, Button, Checkbox } from "antd";
import { Text, Title, Badge } from "@tremor/react";
-import { makeMCPPublicCall } from "./networking";
-import NotificationsManager from "./molecules/notifications_manager";
-import { MCPServerData } from "./mcp_hub_table_columns";
+import { makeMCPPublicCall } from "../../networking";
+import NotificationsManager from "../../molecules/notifications_manager";
+import { MCPServerData } from "@/components/mcp_hub_table_columns";
const { Step } = Steps;
diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx
new file mode 100644
index 00000000000..2b57535f3ad
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx
@@ -0,0 +1,557 @@
+import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import MakeModelPublicForm from "./MakeModelPublicForm";
+
+interface ModelGroupInfo {
+ model_group: string;
+ providers: string[];
+ max_input_tokens?: number;
+ max_output_tokens?: number;
+ input_cost_per_token?: number;
+ output_cost_per_token?: number;
+ mode?: string;
+ tpm?: number;
+ rpm?: number;
+ supports_parallel_function_calling: boolean;
+ supports_vision: boolean;
+ supports_function_calling: boolean;
+ supported_openai_params?: string[];
+ is_public_model_group: boolean;
+ [key: string]: any;
+}
+
+// Mock the networking function
+vi.mock("../../networking", () => ({
+ makeModelGroupPublic: vi.fn(),
+}));
+
+// Import the mocked function
+import { makeModelGroupPublic } from "../../networking";
+const mockMakeModelGroupPublic = vi.mocked(makeModelGroupPublic);
+
+// Mock antd components
+vi.mock("antd", () => ({
+ Modal: ({ open, title, children, onCancel, footer }: any) =>
+ open ? (
+
+
{title}
+ {children}
+ {footer}
+
+ ) : null,
+ Form: Object.assign(({ children, form }: any) => , {
+ useForm: () => [
+ {
+ resetFields: vi.fn(),
+ validateFields: vi.fn(),
+ getFieldsValue: vi.fn(),
+ setFieldsValue: vi.fn(),
+ },
+ vi.fn(),
+ ],
+ Item: ({ children }: any) => {children}
,
+ }),
+ Steps: Object.assign(
+ ({ children, current, className }: any) => (
+
+ {children}
+
+ ),
+ {
+ Step: ({ title }: any) => {title}
,
+ },
+ ),
+ Button: ({ children, onClick, disabled, loading, ...props }: any) => (
+
+ {children}
+
+ ),
+ Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => (
+
+ onChange({ target: { checked: e.target.checked } })}
+ disabled={disabled}
+ data-indeterminate={indeterminate}
+ />
+ {children}
+
+ ),
+}));
+
+// Mock @tremor/react components
+vi.mock("@tremor/react", () => ({
+ Text: ({ children, className }: any) => {children} ,
+ Title: ({ children }: any) => {children} ,
+ Badge: ({ children, color, size }: any) => (
+
+ {children}
+
+ ),
+}));
+
+// Mock ModelFilters component
+vi.mock("../../model_filters", () => ({
+ default: ({ onFilteredDataChange, modelHubData }: any) => (
+
+ onFilteredDataChange(modelHubData)}>
+ Apply Filters
+
+
+ ),
+}));
+
+// Mock NotificationsManager
+vi.mock("../../molecules/notifications_manager", () => ({
+ default: {
+ fromBackend: vi.fn(),
+ success: vi.fn(),
+ },
+}));
+
+describe("MakeModelPublicForm", () => {
+ const mockProps = {
+ visible: true,
+ onClose: vi.fn(),
+ accessToken: "test-token",
+ modelHubData: [
+ {
+ model_group: "gpt-4",
+ providers: ["openai"],
+ max_input_tokens: 8192,
+ max_output_tokens: 4096,
+ input_cost_per_token: 0.03,
+ output_cost_per_token: 0.06,
+ mode: "chat",
+ tpm: 10000,
+ rpm: 200,
+ supports_parallel_function_calling: true,
+ supports_vision: false,
+ supports_function_calling: true,
+ supported_openai_params: ["temperature", "max_tokens"],
+ is_public_model_group: false,
+ },
+ {
+ model_group: "gpt-3.5-turbo",
+ providers: ["openai"],
+ max_input_tokens: 4096,
+ max_output_tokens: 2048,
+ input_cost_per_token: 0.0015,
+ output_cost_per_token: 0.002,
+ mode: "chat",
+ tpm: 60000,
+ rpm: 3500,
+ supports_parallel_function_calling: false,
+ supports_vision: false,
+ supports_function_calling: true,
+ supported_openai_params: ["temperature", "max_tokens"],
+ is_public_model_group: true,
+ },
+ ] as ModelGroupInfo[],
+ onSuccess: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it("should render the component", () => {
+ render( );
+
+ expect(screen.getByText("Make Models Public")).toBeInTheDocument();
+ expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument();
+ });
+
+ it("should initialize with correct state", () => {
+ render( );
+
+ // Check that the component renders with the correct title and content
+ expect(screen.getByText("Make Models Public")).toBeInTheDocument();
+ expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument();
+
+ // Check that all model checkboxes are present
+ const checkboxes = screen.getAllByRole("checkbox");
+ expect(checkboxes).toHaveLength(3); // Select all + 2 models
+
+ // Check that the Next button is enabled (models are preselected)
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ expect(nextButton).not.toBeDisabled();
+ });
+
+ it("should handle model selection and navigation", async () => {
+ render( );
+
+ // Initially on step 1
+ expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument();
+
+ // Select all models using the select all checkbox
+ const selectAllCheckbox = screen.getByLabelText("Select All (2)");
+ await act(async () => {
+ fireEvent.click(selectAllCheckbox);
+ });
+
+ // Verify Next button is enabled
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ expect(nextButton).not.toBeDisabled();
+
+ // Click Next
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ // Should move to step 2
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
+ });
+ });
+
+ it("should submit selected models successfully", async () => {
+ mockMakeModelGroupPublic.mockResolvedValueOnce({});
+
+ render( );
+
+ // Select all models
+ const selectAllCheckbox = screen.getByLabelText("Select All (2)");
+ await act(async () => {
+ fireEvent.click(selectAllCheckbox);
+ });
+
+ // Navigate to confirm step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ // Wait for navigation to complete
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
+ });
+
+ // Submit
+ const submitButton = screen.getByRole("button", { name: "Make Public" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ await waitFor(() => {
+ expect(mockMakeModelGroupPublic).toHaveBeenCalledWith("test-token", ["gpt-4", "gpt-3.5-turbo"]);
+ expect(mockProps.onSuccess).toHaveBeenCalled();
+ expect(mockProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it("should handle select all functionality", async () => {
+ render( );
+
+ const checkboxes = screen.getAllByRole("checkbox");
+ const selectAllCheckbox = checkboxes[0];
+
+ // Select all
+ await act(async () => {
+ fireEvent.click(selectAllCheckbox);
+ });
+
+ // All checkboxes should be checked
+ checkboxes.forEach((checkbox) => {
+ expect(checkbox).toBeChecked();
+ });
+
+ // Deselect all
+ await act(async () => {
+ fireEvent.click(selectAllCheckbox);
+ });
+
+ // All checkboxes should be unchecked except the indeterminate state
+ expect(checkboxes[0]).not.toBeChecked();
+ expect(checkboxes[1]).not.toBeChecked();
+ expect(checkboxes[2]).not.toBeChecked();
+ });
+
+ it("should show error when no models selected", async () => {
+ render( );
+
+ // Deselect all models first
+ const checkboxes = screen.getAllByRole("checkbox");
+ await act(async () => {
+ fireEvent.click(checkboxes[0]); // Click select all to select all
+ fireEvent.click(checkboxes[0]); // Click select all again to deselect all
+ });
+
+ // Try to go to next step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ // Should stay on same step
+ expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument();
+ });
+
+ it("should display empty state when no models are available", () => {
+ const emptyProps = {
+ ...mockProps,
+ modelHubData: [] as ModelGroupInfo[],
+ };
+
+ render( );
+
+ expect(screen.getByText("No models match the current filters.")).toBeInTheDocument();
+
+ // Select All checkbox should be disabled
+ const selectAllCheckbox = screen.getByLabelText("Select All");
+ expect(selectAllCheckbox).toBeDisabled();
+
+ // Next button should be disabled
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ expect(nextButton).toBeDisabled();
+ });
+
+ it("should handle Cancel button functionality", async () => {
+ render( );
+
+ // Click Cancel button
+ const cancelButton = screen.getByRole("button", { name: "Cancel" });
+ await act(async () => {
+ fireEvent.click(cancelButton);
+ });
+
+ // Should call onClose
+ expect(mockProps.onClose).toHaveBeenCalled();
+ });
+
+ it("should handle Previous button functionality", async () => {
+ render( );
+
+ // Navigate to step 1
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ // Verify we're on step 1
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
+ });
+
+ // Click Previous button
+ const previousButton = screen.getByRole("button", { name: "Previous" });
+ await act(async () => {
+ fireEvent.click(previousButton);
+ });
+
+ // Should go back to step 0
+ expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument();
+ });
+
+ it("should handle individual model selection", async () => {
+ render( );
+
+ // Get all checkboxes (select all + individual models)
+ const checkboxes = screen.getAllByRole("checkbox");
+ expect(checkboxes).toHaveLength(3); // Select all + 2 models
+
+ // Initially, gpt-3.5-turbo should be selected (it's already public)
+ const gpt4Checkbox = checkboxes[1]; // First model checkbox
+ const gpt35Checkbox = checkboxes[2]; // Second model checkbox
+
+ expect(gpt35Checkbox).toBeChecked(); // gpt-3.5-turbo is already public
+
+ // Select gpt-4
+ await act(async () => {
+ fireEvent.click(gpt4Checkbox);
+ });
+
+ expect(gpt4Checkbox).toBeChecked();
+ expect(gpt35Checkbox).toBeChecked();
+
+ // Deselect gpt-3.5-turbo
+ await act(async () => {
+ fireEvent.click(gpt35Checkbox);
+ });
+
+ expect(gpt4Checkbox).toBeChecked();
+ expect(gpt35Checkbox).not.toBeChecked();
+
+ // Select all should be indeterminate now
+ const selectAllCheckbox = checkboxes[0];
+ expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
+ });
+
+ it("should display model badges and information", () => {
+ render( );
+
+ // Should show model names
+ expect(screen.getByText("gpt-4")).toBeInTheDocument();
+ expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument();
+
+ // Should show mode badges
+ expect(screen.getAllByText("chat")).toHaveLength(2);
+
+ // Should show provider badges
+ expect(screen.getAllByText("openai")).toHaveLength(2);
+ });
+
+ it("should handle submit error properly", async () => {
+ const errorMessage = "Network error";
+ mockMakeModelGroupPublic.mockRejectedValueOnce(new Error(errorMessage));
+
+ render( );
+
+ // Navigate to confirm step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
+ });
+
+ // Submit
+ const submitButton = screen.getByRole("button", { name: "Make Public" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Should handle error and show error notification
+ await waitFor(() => {
+ expect(mockMakeModelGroupPublic).toHaveBeenCalledWith("test-token", ["gpt-3.5-turbo"]);
+ });
+
+ // Should not call onSuccess or onClose on error
+ expect(mockProps.onSuccess).not.toHaveBeenCalled();
+ expect(mockProps.onClose).not.toHaveBeenCalled();
+ });
+
+ it("should show loading state during submit", async () => {
+ let resolvePromise: (value: any) => void = () => {};
+ const pendingPromise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+ mockMakeModelGroupPublic.mockReturnValueOnce(pendingPromise);
+
+ render( );
+
+ // Navigate to confirm step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
+ });
+
+ // Submit
+ const submitButton = screen.getByRole("button", { name: "Make Public" });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Check loading state
+ expect(submitButton).toHaveAttribute("data-loading", "true");
+ expect(submitButton).toBeDisabled();
+
+ // Resolve the promise
+ resolvePromise({});
+ await waitFor(() => {
+ expect(mockProps.onSuccess).toHaveBeenCalled();
+ expect(mockProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it("should not render modal when visible is false", () => {
+ const invisibleProps = {
+ ...mockProps,
+ visible: false,
+ };
+
+ render( );
+
+ // Modal should not be rendered
+ expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
+ expect(screen.queryByText("Make Models Public")).not.toBeInTheDocument();
+ });
+
+ it("should preselect already public models when modal opens", () => {
+ // Test data where one model is public and one is not
+ const mixedPublicProps = {
+ ...mockProps,
+ modelHubData: [
+ {
+ model_group: "private-model",
+ providers: ["openai"],
+ is_public_model_group: false,
+ mode: "chat",
+ },
+ {
+ model_group: "public-model",
+ providers: ["anthropic"],
+ is_public_model_group: true,
+ mode: "completion",
+ },
+ {
+ model_group: "another-public-model",
+ providers: ["cohere"],
+ is_public_model_group: true,
+ mode: "chat",
+ },
+ ] as ModelGroupInfo[],
+ };
+
+ render( );
+
+ // Check that the correct checkboxes are selected
+ const checkboxes = screen.getAllByRole("checkbox");
+ expect(checkboxes).toHaveLength(4); // Select all + 3 models
+
+ // private-model should not be checked, public models should be checked
+ const privateModelCheckbox = checkboxes[1];
+ const publicModelCheckbox = checkboxes[2];
+ const anotherPublicModelCheckbox = checkboxes[3];
+
+ expect(privateModelCheckbox).not.toBeChecked(); // private-model is not public
+ expect(publicModelCheckbox).toBeChecked(); // public-model is public
+ expect(anotherPublicModelCheckbox).toBeChecked(); // another-public-model is public
+
+ // Select all should be indeterminate
+ const selectAllCheckbox = checkboxes[0];
+ expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
+ });
+
+ it("should show selected count", () => {
+ render( );
+
+ // Should show that 1 model is selected (gpt-3.5-turbo is preselected)
+ expect(screen.getByText("1")).toBeInTheDocument();
+ expect(screen.getByText("model selected")).toBeInTheDocument();
+ });
+
+ it("should show confirmation step with selected models", async () => {
+ render( );
+
+ // Navigate to confirm step
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
+ });
+
+ // Should show the selected model
+ expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument();
+
+ // Should show the warning message
+ expect(screen.getByText(/Warning:/)).toBeInTheDocument();
+ expect(screen.getByText(/model_hub_table/)).toBeInTheDocument();
+
+ // Should show total count (already verified by checking the presence of the confirmation step)
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/make_model_public_form.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx
similarity index 98%
rename from ui/litellm-dashboard/src/components/make_model_public_form.tsx
rename to ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx
index 750bdc24eeb..16ed04c1779 100644
--- a/ui/litellm-dashboard/src/components/make_model_public_form.tsx
+++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx
@@ -1,9 +1,9 @@
import React, { useState, useCallback, useEffect } from "react";
import { Modal, Form, Steps, Button, Checkbox } from "antd";
import { Text, Title, Badge } from "@tremor/react";
-import { makeModelGroupPublic } from "./networking";
-import ModelFilters from "./model_filters";
-import NotificationsManager from "./molecules/notifications_manager";
+import { makeModelGroupPublic } from "../../networking";
+import ModelFilters from "../../model_filters";
+import NotificationsManager from "../../molecules/notifications_manager";
const { Step } = Steps;
diff --git a/ui/litellm-dashboard/src/components/SSOModals.test.tsx b/ui/litellm-dashboard/src/components/SSOModals.test.tsx
index e9d2389b69e..365d23f4036 100644
--- a/ui/litellm-dashboard/src/components/SSOModals.test.tsx
+++ b/ui/litellm-dashboard/src/components/SSOModals.test.tsx
@@ -1,25 +1,21 @@
-import { render, fireEvent, waitFor } from "@testing-library/react";
-import { describe, expect, it, beforeAll } from "vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Form } from "antd";
+import { describe, expect, it, vi } from "vitest";
import SSOModals from "./SSOModals";
-import React from "react";
-// Mock window.matchMedia for Ant Design components
-beforeAll(() => {
- Object.defineProperty(window, "matchMedia", {
- writable: true,
- value: (query: string) => ({
- matches: false,
- media: query,
- onchange: null,
- addListener: () => {}, // deprecated
- removeListener: () => {}, // deprecated
- addEventListener: () => {},
- removeEventListener: () => {},
- dispatchEvent: () => true,
- }),
- });
-});
+// Mock the networking functions
+vi.mock("./networking", () => ({
+ getSSOSettings: vi.fn(),
+ updateSSOSettings: vi.fn(),
+}));
+
+// Mock parseErrorMessage
+vi.mock("./shared/errorUtils", () => ({
+ parseErrorMessage: vi.fn((error) => error?.message || "An error occurred"),
+}));
+
+import NotificationsManager from "./molecules/notifications_manager";
+import { getSSOSettings, updateSSOSettings } from "./networking";
describe("SSOModals", () => {
it("should render the SSOModals component", () => {
@@ -42,11 +38,11 @@ describe("SSOModals", () => {
);
};
- const { getByText } = render( );
- expect(getByText("Add SSO")).toBeInTheDocument();
+ render( );
+ expect(screen.getByText("Add SSO")).toBeInTheDocument();
});
- it("should have a validation error if the proxy base url is not a valid URL", async () => {
+ it("should show validation error if proxy base url is not a valid URL", async () => {
const TestWrapper = () => {
const [form] = Form.useForm();
return (
@@ -65,42 +61,40 @@ describe("SSOModals", () => {
);
};
- const { getByLabelText, getByText, container } = render( );
+ render( );
// Find and interact with the SSO provider select
- const ssoProviderSelect = container.querySelector("#sso_provider");
- if (ssoProviderSelect) {
- fireEvent.mouseDown(ssoProviderSelect);
- // Wait for dropdown and select Google
- await waitFor(() => {
- const googleOption = getByText("Google SSO");
- fireEvent.click(googleOption);
- });
- }
+ const ssoProviderSelect = screen.getByLabelText("SSO Provider");
+ fireEvent.mouseDown(ssoProviderSelect);
+ // Wait for dropdown and select Google
+ await waitFor(() => {
+ const googleOption = screen.getByText("Google SSO");
+ fireEvent.click(googleOption);
+ });
// Fill in the email field
- const emailInput = getByLabelText("Proxy Admin Email");
+ const emailInput = screen.getByLabelText("Proxy Admin Email");
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
// Fill in an invalid URL
- const urlInput = getByLabelText("Proxy Base URL");
+ const urlInput = screen.getByLabelText("Proxy Base URL");
fireEvent.change(urlInput, { target: { value: "invalid-url" } });
// Submit the form
- const saveButton = getByText("Save");
+ const saveButton = screen.getByText("Save");
fireEvent.click(saveButton);
// Check for validation error
await waitFor(
() => {
- expect(getByText("URL must start with http:// or https://")).toBeInTheDocument();
+ expect(screen.getByText("URL must start with http:// or https://")).toBeInTheDocument();
},
// The validation is based on a Promise, so we need to wait for it to resolve
{ timeout: 5000 },
);
});
- it("should show validation error if the proxy base url ends with a trailing slash", async () => {
+ it("should show validation error if proxy base url ends with trailing slash", async () => {
const TestWrapper = () => {
const [form] = Form.useForm();
return (
@@ -119,33 +113,31 @@ describe("SSOModals", () => {
);
};
- const { getByLabelText, getByText, findByText, container } = render( );
+ render( );
// Find and interact with the SSO provider select
- const ssoProviderSelect = container.querySelector("#sso_provider");
- if (ssoProviderSelect) {
- fireEvent.mouseDown(ssoProviderSelect);
- // Wait for dropdown and select Google
- await waitFor(() => {
- const googleOption = getByText("Google SSO");
- fireEvent.click(googleOption);
- });
- }
+ const ssoProviderSelect = screen.getByLabelText("SSO Provider");
+ fireEvent.mouseDown(ssoProviderSelect);
+ // Wait for dropdown and select Google
+ await waitFor(() => {
+ const googleOption = screen.getByText("Google SSO");
+ fireEvent.click(googleOption);
+ });
// Fill in the email field
- const emailInput = getByLabelText("Proxy Admin Email");
+ const emailInput = screen.getByLabelText("Proxy Admin Email");
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
// Fill in a URL with trailing slash
- const urlInput = getByLabelText("Proxy Base URL") as HTMLInputElement;
+ const urlInput = screen.getByLabelText("Proxy Base URL") as HTMLInputElement;
fireEvent.change(urlInput, { target: { value: "https://example.com/" } });
// Submit the form
- const saveButton = getByText("Save");
+ const saveButton = screen.getByText("Save");
fireEvent.click(saveButton);
// Check for validation error using findByText for async rendering
- const errorMessage = await findByText("URL must not end with a trailing slash", {}, { timeout: 5000 });
+ const errorMessage = await screen.findByText("URL must not end with a trailing slash", {}, { timeout: 5000 });
expect(errorMessage).toBeInTheDocument();
});
@@ -168,9 +160,9 @@ describe("SSOModals", () => {
);
};
- const { getByLabelText } = render( );
+ render( );
- const urlInput = getByLabelText("Proxy Base URL") as HTMLInputElement;
+ const urlInput = screen.getByLabelText("Proxy Base URL") as HTMLInputElement;
// Simulate user typing "https://"
fireEvent.change(urlInput, { target: { value: "h" } });
@@ -218,36 +210,266 @@ describe("SSOModals", () => {
);
};
- const { getByLabelText, getByText, queryByText, container, findByText } = render( );
+ render( );
// Find and interact with the SSO provider select
- const ssoProviderSelect = container.querySelector("#sso_provider");
- if (ssoProviderSelect) {
- fireEvent.mouseDown(ssoProviderSelect);
- // Wait for dropdown and select Google
- await waitFor(() => {
- const googleOption = getByText("Google SSO");
- fireEvent.click(googleOption);
- });
- }
+ const ssoProviderSelect = screen.getByLabelText("SSO Provider");
+ fireEvent.mouseDown(ssoProviderSelect);
+ // Wait for dropdown and select Google
+ await waitFor(() => {
+ const googleOption = screen.getByText("Google SSO");
+ fireEvent.click(googleOption);
+ });
// Fill in the email field
- const emailInput = getByLabelText("Proxy Admin Email");
+ const emailInput = screen.getByLabelText("Proxy Admin Email");
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
// Fill in an incomplete URL like "http:"
- const urlInput = getByLabelText("Proxy Base URL");
+ const urlInput = screen.getByLabelText("Proxy Base URL");
fireEvent.change(urlInput, { target: { value: "http:" } });
// Submit the form
- const saveButton = getByText("Save");
+ const saveButton = screen.getByText("Save");
fireEvent.click(saveButton);
// Check that only the URL format error appears (use findByText for async rendering)
- const errorMessage = await findByText("URL must start with http:// or https://", {}, { timeout: 3000 });
+ const errorMessage = await screen.findByText("URL must start with http:// or https://", {}, { timeout: 3000 });
expect(errorMessage).toBeInTheDocument();
// Verify the trailing slash error does NOT appear
- expect(queryByText("URL must not end with a trailing slash")).not.toBeInTheDocument();
+ expect(screen.queryByText("URL must not end with a trailing slash")).not.toBeInTheDocument();
+ });
+
+ it("should load existing SSO settings when modal opens", async () => {
+ const mockSSOData = {
+ values: {
+ google_client_id: "test-client-id",
+ google_client_secret: "test-client-secret",
+ proxy_base_url: "https://example.com",
+ user_email: "admin@example.com",
+ role_mappings: {
+ group_claim: "groups",
+ default_role: "internal_user",
+ roles: {
+ proxy_admin: ["admin-group"],
+ proxy_admin_viewer: ["viewer-group"],
+ internal_user: ["user-group"],
+ internal_user_viewer: ["readonly-group"],
+ },
+ },
+ },
+ };
+
+ (getSSOSettings as any).mockResolvedValue(mockSSOData);
+
+ const TestWrapper = () => {
+ const [form] = Form.useForm();
+
+ return (
+ {}}
+ handleAddSSOCancel={() => {}}
+ handleShowInstructions={() => {}}
+ handleInstructionsOk={() => {}}
+ handleInstructionsCancel={() => {}}
+ form={form}
+ accessToken="test-token"
+ ssoConfigured={false}
+ />
+ );
+ };
+
+ render( );
+
+ // Wait for the useEffect to load data and populate form
+ await waitFor(() => {
+ expect(getSSOSettings).toHaveBeenCalledWith("test-token");
+ });
+
+ // Check that form fields are populated with loaded data
+ await waitFor(() => {
+ const emailInput = screen.getByLabelText("Proxy Admin Email") as HTMLInputElement;
+ expect(emailInput.value).toBe("admin@example.com");
+ });
+
+ const urlInput = screen.getByLabelText("Proxy Base URL") as HTMLInputElement;
+ expect(urlInput.value).toBe("https://example.com");
+
+ // Check that role mappings are populated
+ const groupClaimInput = screen.getByLabelText("Group Claim") as HTMLInputElement;
+ expect(groupClaimInput.value).toBe("groups");
+ });
+
+ it("should submit form with role mappings enabled", async () => {
+ const mockHandleShowInstructions = vi.fn();
+ (updateSSOSettings as any).mockResolvedValue({});
+ // Mock getSSOSettings to return empty data so form starts clean
+ (getSSOSettings as any).mockResolvedValue({ values: {} });
+
+ let formInstance: any = null;
+
+ const TestWrapper = () => {
+ const [form] = Form.useForm();
+ formInstance = form;
+
+ return (
+ {}}
+ handleAddSSOCancel={() => {}}
+ handleShowInstructions={mockHandleShowInstructions}
+ handleInstructionsOk={() => {}}
+ handleInstructionsCancel={() => {}}
+ form={form}
+ accessToken="test-token"
+ ssoConfigured={false}
+ />
+ );
+ };
+
+ render( );
+
+ // Wait for any initial loading to complete
+ await waitFor(() => {
+ expect(getSSOSettings).toHaveBeenCalledWith("test-token");
+ });
+
+ // Set the provider directly using the form to trigger conditional rendering
+ formInstance.setFieldsValue({ sso_provider: "okta" });
+
+ // Wait for the "Use Role Mappings" checkbox to appear
+ await waitFor(() => {
+ expect(screen.getByLabelText("Use Role Mappings")).toBeInTheDocument();
+ });
+
+ // Enable role mappings
+ const roleMappingsCheckbox = screen.getByLabelText("Use Role Mappings");
+ fireEvent.click(roleMappingsCheckbox);
+
+ // Fill required fields
+ const emailInput = screen.getByLabelText("Proxy Admin Email");
+ fireEvent.change(emailInput, { target: { value: "admin@example.com" } });
+
+ const urlInput = screen.getByLabelText("Proxy Base URL");
+ fireEvent.change(urlInput, { target: { value: "https://example.com" } });
+
+ // Fill Okta specific fields
+ const clientIdInput = screen.getByLabelText("Generic Client ID");
+ fireEvent.change(clientIdInput, { target: { value: "test-client-id" } });
+
+ const clientSecretInput = screen.getByLabelText("Generic Client Secret");
+ fireEvent.change(clientSecretInput, { target: { value: "test-client-secret" } });
+
+ const authEndpointInput = screen.getByLabelText("Authorization Endpoint");
+ fireEvent.change(authEndpointInput, { target: { value: "https://example.okta.com/authorize" } });
+
+ const tokenEndpointInput = screen.getByLabelText("Token Endpoint");
+ fireEvent.change(tokenEndpointInput, { target: { value: "https://example.okta.com/token" } });
+
+ const userinfoEndpointInput = screen.getByLabelText("Userinfo Endpoint");
+ fireEvent.change(userinfoEndpointInput, { target: { value: "https://example.okta.com/userinfo" } });
+
+ // Fill role mapping fields
+ const groupClaimInput = screen.getByLabelText("Group Claim");
+ fireEvent.change(groupClaimInput, { target: { value: "groups" } });
+
+ const proxyAdminTeamsInput = screen.getByLabelText("Proxy Admin Teams");
+ fireEvent.change(proxyAdminTeamsInput, { target: { value: "admin-group, super-admin" } });
+
+ // Submit the form
+ const saveButton = screen.getByText("Save");
+ fireEvent.click(saveButton);
+
+ // Verify the API was called with correct payload including role mappings
+ await waitFor(() => {
+ expect(updateSSOSettings).toHaveBeenCalledWith("test-token", {
+ sso_provider: "okta",
+ user_email: "admin@example.com",
+ proxy_base_url: "https://example.com",
+ generic_client_id: "test-client-id",
+ generic_client_secret: "test-client-secret",
+ generic_authorization_endpoint: "https://example.okta.com/authorize",
+ generic_token_endpoint: "https://example.okta.com/token",
+ generic_userinfo_endpoint: "https://example.okta.com/userinfo",
+ role_mappings: {
+ provider: "generic",
+ group_claim: "groups",
+ default_role: "internal_user",
+ roles: {
+ proxy_admin: ["admin-group", "super-admin"],
+ proxy_admin_viewer: [],
+ internal_user: [],
+ internal_user_viewer: [],
+ },
+ },
+ });
+ });
+
+ expect(mockHandleShowInstructions).toHaveBeenCalled();
+ });
+
+ it("should show Clear button and clear SSO settings when configured", async () => {
+ const mockHandleAddSSOOk = vi.fn();
+ (updateSSOSettings as any).mockResolvedValue({});
+ (NotificationsManager.success as any).mockImplementation(() => {});
+
+ const TestWrapper = () => {
+ const [form] = Form.useForm();
+
+ return (
+ {}}
+ handleShowInstructions={() => {}}
+ handleInstructionsOk={() => {}}
+ handleInstructionsCancel={() => {}}
+ form={form}
+ accessToken="test-token"
+ ssoConfigured={true}
+ />
+ );
+ };
+
+ render( );
+
+ // Check that Clear button is visible when SSO is configured
+ const clearButton = screen.getByText("Clear");
+ expect(clearButton).toBeInTheDocument();
+
+ // Click Clear button to open confirmation modal
+ fireEvent.click(clearButton);
+
+ // Confirm the clear action in the modal
+ const confirmButton = screen.getByText("Yes, Clear");
+ fireEvent.click(confirmButton);
+
+ // Verify the clear API was called with null values
+ await waitFor(() => {
+ expect(updateSSOSettings).toHaveBeenCalledWith("test-token", {
+ google_client_id: null,
+ google_client_secret: null,
+ microsoft_client_id: null,
+ microsoft_client_secret: null,
+ microsoft_tenant: null,
+ generic_client_id: null,
+ generic_client_secret: null,
+ generic_authorization_endpoint: null,
+ generic_token_endpoint: null,
+ generic_userinfo_endpoint: null,
+ proxy_base_url: null,
+ user_email: null,
+ sso_provider: null,
+ role_mappings: null,
+ });
+ });
+
+ expect(NotificationsManager.success).toHaveBeenCalledWith("SSO settings cleared successfully");
+ expect(mockHandleAddSSOOk).toHaveBeenCalled();
});
});
diff --git a/ui/litellm-dashboard/src/components/SSOModals.tsx b/ui/litellm-dashboard/src/components/SSOModals.tsx
index 26e33ace2d7..6cb57f41736 100644
--- a/ui/litellm-dashboard/src/components/SSOModals.tsx
+++ b/ui/litellm-dashboard/src/components/SSOModals.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
-import { Modal, Form, Input, Button as Button2, Select } from "antd";
+import { Modal, Form, Input, Button as Button2, Select, Checkbox } from "antd";
import { Text, TextInput } from "@tremor/react";
import { getSSOSettings, updateSSOSettings } from "./networking";
import NotificationsManager from "./molecules/notifications_manager";
@@ -144,12 +144,35 @@ const SSOModals: React.FC = ({
}
}
+ // Extract role mappings if they exist
+ let roleMappingFields = {};
+ if (ssoData.values.role_mappings) {
+ const roleMappings = ssoData.values.role_mappings;
+
+ // Helper function to join arrays into comma-separated strings
+ const joinTeams = (teams: string[] | undefined): string => {
+ if (!teams || teams.length === 0) return "";
+ return teams.join(", ");
+ };
+
+ roleMappingFields = {
+ use_role_mappings: true,
+ group_claim: roleMappings.group_claim,
+ default_role: roleMappings.default_role || "internal_user",
+ proxy_admin_teams: joinTeams(roleMappings.roles?.proxy_admin),
+ admin_viewer_teams: joinTeams(roleMappings.roles?.proxy_admin_viewer),
+ internal_user_teams: joinTeams(roleMappings.roles?.internal_user),
+ internal_viewer_teams: joinTeams(roleMappings.roles?.internal_user_viewer),
+ };
+ }
+
// Set form values with existing data (excluding UI access control fields)
const formValues = {
sso_provider: selectedProvider,
proxy_base_url: ssoData.values.proxy_base_url,
user_email: ssoData.values.user_email,
...ssoData.values,
+ ...roleMappingFields,
};
console.log("Setting form values:", formValues); // Debug log
@@ -178,8 +201,55 @@ const SSOModals: React.FC = ({
}
try {
+ const {
+ proxy_admin_teams,
+ admin_viewer_teams,
+ internal_user_teams,
+ internal_viewer_teams,
+ default_role,
+ group_claim,
+ use_role_mappings,
+ ...rest
+ } = formValues;
+
+ const payload: any = {
+ ...rest,
+ };
+
+ // Add role mappings if use_role_mappings is checked
+ if (use_role_mappings) {
+ // Helper function to split comma-separated string into array
+ const splitTeams = (teams: string | undefined): string[] => {
+ if (!teams || teams.trim() === "") return [];
+ return teams
+ .split(",")
+ .map((team) => team.trim())
+ .filter((team) => team.length > 0);
+ };
+
+ // Map default role display values to backend values
+ const defaultRoleMapping: Record = {
+ internal_user_viewer: "internal_user_viewer",
+ internal_user: "internal_user",
+ proxy_admin_viewer: "proxy_admin_viewer",
+ proxy_admin: "proxy_admin",
+ };
+
+ payload.role_mappings = {
+ provider: "generic",
+ group_claim,
+ default_role: defaultRoleMapping[default_role] || "internal_user",
+ roles: {
+ proxy_admin: splitTeams(proxy_admin_teams),
+ proxy_admin_viewer: splitTeams(admin_viewer_teams),
+ internal_user: splitTeams(internal_user_teams),
+ internal_user_viewer: splitTeams(internal_viewer_teams),
+ },
+ };
+ }
+
// Save SSO settings using the new API
- await updateSSOSettings(accessToken, formValues);
+ await updateSSOSettings(accessToken, payload);
// Continue with the original flow (show instructions)
handleShowInstructions(formValues);
@@ -211,6 +281,7 @@ const SSOModals: React.FC = ({
proxy_base_url: null,
user_email: null,
sso_provider: null,
+ role_mappings: null,
};
await updateSSOSettings(accessToken, clearSettings);
@@ -334,6 +405,79 @@ const SSOModals: React.FC = ({
>
+
+ prevValues.sso_provider !== currentValues.sso_provider}
+ >
+ {({ getFieldValue }) => {
+ const provider = getFieldValue("sso_provider");
+ return provider === "okta" || provider === "generic" ? (
+
+
+
+ ) : null;
+ }}
+
+
+
+ prevValues.use_role_mappings !== currentValues.use_role_mappings
+ }
+ >
+ {({ getFieldValue }) => {
+ const useRoleMappings = getFieldValue("use_role_mappings");
+ return useRoleMappings ? (
+
+
+
+ ) : null;
+ }}
+
+
+
+ prevValues.use_role_mappings !== currentValues.use_role_mappings
+ }
+ >
+ {({ getFieldValue }) => {
+ const useRoleMappings = getFieldValue("use_role_mappings");
+ return useRoleMappings ? (
+ <>
+
+
+ Internal Viewer
+ Internal User
+ Admin Viewer
+ Proxy Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : null;
+ }}
+
>
({
+ updateSSOSettings: vi.fn(),
+}));
+
+// Mock error utils
+vi.mock("@/components/shared/errorUtils", () => ({
+ parseErrorMessage: vi.fn((error) => error?.message || "Unknown error"),
+}));
+
+// Mock the useAuthorized hook
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: () => ({
+ accessToken: "test-access-token",
+ userId: "test-user-id",
+ userEmail: "test@example.com",
+ userRole: "admin",
+ }),
+}));
+
+// Mock NotificationsManager
+vi.mock("@/components/molecules/notifications_manager", () => ({
+ default: {
+ success: vi.fn(),
+ fromBackend: vi.fn(),
+ },
+}));
+
+describe("AddSSOSettingsModal", () => {
+ it("should render", () => {
+ const onCancel = vi.fn();
+ const onSuccess = vi.fn();
+
+ renderWithProviders(
);
+
+ expect(screen.getByText("SSO Provider")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ expect(screen.getAllByText("Add SSO")).toHaveLength(2); // Title and button
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/AddSSOSettingsModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/AddSSOSettingsModal.tsx
new file mode 100644
index 00000000000..7af6240b19e
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/AddSSOSettingsModal.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import NotificationsManager from "@/components/molecules/notifications_manager";
+import { parseErrorMessage } from "@/components/shared/errorUtils";
+import { Button, Form, Modal, Space } from "antd";
+import React from "react";
+import BaseSSOSettingsForm from "./BaseSSOSettingsForm";
+import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
+import { processSSOSettingsPayload } from "../utils";
+
+interface AddSSOSettingsModalProps {
+ isVisible: boolean;
+ onCancel: () => void;
+ onSuccess: () => void;
+}
+
+const AddSSOSettingsModal: React.FC
= ({ isVisible, onCancel, onSuccess }) => {
+ const [form] = Form.useForm();
+ const { mutateAsync, isPending } = useEditSSOSettings();
+
+ // Enhanced form submission handler
+ const handleFormSubmit = async (formValues: Record) => {
+ const payload = processSSOSettingsPayload(formValues);
+
+ await mutateAsync(payload, {
+ onSuccess: () => {
+ NotificationsManager.success("SSO settings added successfully");
+ onSuccess();
+ },
+ onError: (error) => {
+ NotificationsManager.fromBackend("Failed to save SSO settings: " + parseErrorMessage(error));
+ },
+ });
+ };
+
+ const handleCancel = () => {
+ form.resetFields();
+ onCancel();
+ };
+
+ return (
+
+
+ Cancel
+
+ form.submit()}>
+ {isPending ? "Adding..." : "Add SSO"}
+
+
+ }
+ onCancel={handleCancel}
+ >
+
+
+ );
+};
+
+export default AddSSOSettingsModal;
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx
new file mode 100644
index 00000000000..a4b36e5190e
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx
@@ -0,0 +1,249 @@
+"use client";
+
+import { TextInput } from "@tremor/react";
+import { Checkbox, Form, Input, Select } from "antd";
+import React from "react";
+import { ssoProviderLogoMap, ssoProviderDisplayNames } from "../constants";
+
+export interface BaseSSOSettingsFormProps {
+ form: any; // Replace with proper Form type if available
+ onFormSubmit: (formValues: Record) => Promise;
+}
+
+// Define the SSO provider configuration type
+export interface SSOProviderConfig {
+ envVarMap: Record;
+ fields: Array<{
+ label: string;
+ name: string;
+ placeholder?: string;
+ }>;
+}
+
+// Define configurations for each SSO provider
+export const ssoProviderConfigs: Record = {
+ google: {
+ envVarMap: {
+ google_client_id: "GOOGLE_CLIENT_ID",
+ google_client_secret: "GOOGLE_CLIENT_SECRET",
+ },
+ fields: [
+ { label: "Google Client ID", name: "google_client_id" },
+ { label: "Google Client Secret", name: "google_client_secret" },
+ ],
+ },
+ microsoft: {
+ envVarMap: {
+ microsoft_client_id: "MICROSOFT_CLIENT_ID",
+ microsoft_client_secret: "MICROSOFT_CLIENT_SECRET",
+ microsoft_tenant: "MICROSOFT_TENANT",
+ },
+ fields: [
+ { label: "Microsoft Client ID", name: "microsoft_client_id" },
+ { label: "Microsoft Client Secret", name: "microsoft_client_secret" },
+ { label: "Microsoft Tenant", name: "microsoft_tenant" },
+ ],
+ },
+ okta: {
+ envVarMap: {
+ generic_client_id: "GENERIC_CLIENT_ID",
+ generic_client_secret: "GENERIC_CLIENT_SECRET",
+ generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
+ generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
+ generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
+ },
+ fields: [
+ { label: "Generic Client ID", name: "generic_client_id" },
+ { label: "Generic Client Secret", name: "generic_client_secret" },
+ {
+ label: "Authorization Endpoint",
+ name: "generic_authorization_endpoint",
+ placeholder: "https://your-domain/authorize",
+ },
+ { label: "Token Endpoint", name: "generic_token_endpoint", placeholder: "https://your-domain/token" },
+ {
+ label: "Userinfo Endpoint",
+ name: "generic_userinfo_endpoint",
+ placeholder: "https://your-domain/userinfo",
+ },
+ ],
+ },
+ generic: {
+ envVarMap: {
+ generic_client_id: "GENERIC_CLIENT_ID",
+ generic_client_secret: "GENERIC_CLIENT_SECRET",
+ generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
+ generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
+ generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
+ },
+ fields: [
+ { label: "Generic Client ID", name: "generic_client_id" },
+ { label: "Generic Client Secret", name: "generic_client_secret" },
+ { label: "Authorization Endpoint", name: "generic_authorization_endpoint" },
+ { label: "Token Endpoint", name: "generic_token_endpoint" },
+ { label: "Userinfo Endpoint", name: "generic_userinfo_endpoint" },
+ ],
+ },
+};
+
+// Helper function to render provider fields
+export const renderProviderFields = (provider: string) => {
+ const config = ssoProviderConfigs[provider];
+ if (!config) return null;
+
+ return config.fields.map((field) => (
+
+ {field.name.includes("client") ? : }
+
+ ));
+};
+
+const BaseSSOSettingsForm: React.FC = ({ form, onFormSubmit }) => {
+ return (
+
+
+
+ {Object.entries(ssoProviderLogoMap).map(([value, logo]) => (
+
+
+ {logo && (
+
+ )}
+
+ {ssoProviderDisplayNames[value] || value.charAt(0).toUpperCase() + value.slice(1) + " SSO"}
+
+
+
+ ))}
+
+
+
+
prevValues.sso_provider !== currentValues.sso_provider}
+ >
+ {({ getFieldValue }) => {
+ const provider = getFieldValue("sso_provider");
+ return provider ? renderProviderFields(provider) : null;
+ }}
+
+
+
+
+
+
value?.trim()}
+ rules={[
+ { required: true, message: "Please enter the proxy base url" },
+ {
+ pattern: /^https?:\/\/.+/,
+ message: "URL must start with http:// or https://",
+ },
+ {
+ validator: (_, value) => {
+ // Only check for trailing slash if the URL starts with http:// or https://
+ if (value && /^https?:\/\/.+/.test(value) && value.endsWith("/")) {
+ return Promise.reject("URL must not end with a trailing slash");
+ }
+ return Promise.resolve();
+ },
+ },
+ ]}
+ >
+
+
+
+
prevValues.sso_provider !== currentValues.sso_provider}
+ >
+ {({ getFieldValue }) => {
+ const provider = getFieldValue("sso_provider");
+ return provider === "okta" || provider === "generic" ? (
+
+
+
+ ) : null;
+ }}
+
+
+
prevValues.use_role_mappings !== currentValues.use_role_mappings}
+ >
+ {({ getFieldValue }) => {
+ const useRoleMappings = getFieldValue("use_role_mappings");
+ return useRoleMappings ? (
+
+
+
+ ) : null;
+ }}
+
+
+
prevValues.use_role_mappings !== currentValues.use_role_mappings}
+ >
+ {({ getFieldValue }) => {
+ const useRoleMappings = getFieldValue("use_role_mappings");
+ return useRoleMappings ? (
+ <>
+
+
+ Internal Viewer
+ Internal User
+ Admin Viewer
+ Proxy Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : null;
+ }}
+
+
+
+ );
+};
+
+export default BaseSSOSettingsForm;
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.test.tsx
new file mode 100644
index 00000000000..ef6ec6c7055
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.test.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import DeleteSSOSettingsModal from "./DeleteSSOSettingsModal";
+
+describe("DeleteSSOSettingsModal", () => {
+ it("should render", () => {
+ const onCancel = vi.fn();
+ const onSuccess = vi.fn();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Confirm Clear SSO Settings")).toBeInTheDocument();
+ expect(
+ screen.getByText("Are you sure you want to clear all SSO settings? This action cannot be undone."),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Users will no longer be able to login using SSO after this change.")).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx
new file mode 100644
index 00000000000..6a28b6490e0
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx
@@ -0,0 +1,80 @@
+import { Modal } from "antd";
+import React from "react";
+import NotificationsManager from "../../../../molecules/notifications_manager";
+import { updateSSOSettings } from "../../../../networking";
+import { parseErrorMessage } from "../../../../shared/errorUtils";
+
+interface DeleteSSOSettingsModalProps {
+ isVisible: boolean;
+ onCancel: () => void;
+ onSuccess: () => void;
+ accessToken: string | null;
+}
+
+const DeleteSSOSettingsModal: React.FC = ({
+ isVisible,
+ onCancel,
+ onSuccess,
+ accessToken,
+}) => {
+ // Handle clearing SSO settings
+ const handleClearSSO = async () => {
+ if (!accessToken) {
+ NotificationsManager.fromBackend("No access token available");
+ return;
+ }
+
+ try {
+ // Clear all SSO settings
+ const clearSettings = {
+ google_client_id: null,
+ google_client_secret: null,
+ microsoft_client_id: null,
+ microsoft_client_secret: null,
+ microsoft_tenant: null,
+ generic_client_id: null,
+ generic_client_secret: null,
+ generic_authorization_endpoint: null,
+ generic_token_endpoint: null,
+ generic_userinfo_endpoint: null,
+ proxy_base_url: null,
+ user_email: null,
+ sso_provider: null,
+ };
+
+ await updateSSOSettings(accessToken, clearSettings);
+
+ NotificationsManager.success("SSO settings cleared successfully");
+
+ // Close modal and trigger success callback
+ onCancel();
+ onSuccess();
+ } catch (error) {
+ console.error("Failed to clear SSO settings:", error);
+ NotificationsManager.fromBackend("Failed to clear SSO settings: " + parseErrorMessage(error));
+ }
+ };
+
+ return (
+
+ Are you sure you want to clear all SSO settings? This action cannot be undone.
+ Users will no longer be able to login using SSO after this change.
+
+ );
+};
+
+export default DeleteSSOSettingsModal;
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx
new file mode 100644
index 00000000000..559d837b409
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx
@@ -0,0 +1,620 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, Mock } from "vitest";
+import EditSSOSettingsModal from "./EditSSOSettingsModal";
+import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
+import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
+import NotificationsManager from "@/components/molecules/notifications_manager";
+import { parseErrorMessage } from "@/components/shared/errorUtils";
+import { processSSOSettingsPayload } from "../utils";
+
+// Constants
+const SSO_PROVIDERS = {
+ GOOGLE: "google",
+ MICROSOFT: "microsoft",
+ OKTA: "okta",
+ AUTH0: "auth0",
+ GENERIC: "generic",
+} as const;
+
+const TEST_DATA = {
+ MODAL_TITLE: "Edit SSO Settings",
+ MODAL_WIDTH: "800",
+ SUCCESS_MESSAGE: "SSO settings updated successfully",
+ ERROR_MESSAGE_PREFIX: "Failed to save SSO settings:",
+ BUTTON_TEXT: {
+ CANCEL: "Cancel",
+ SAVE: "Save",
+ SAVING: "Saving...",
+ },
+} as const;
+
+const TEST_IDS = {
+ MODAL: "modal",
+ BUTTON: "button",
+ BASE_SSO_FORM: "base-sso-form",
+ TRIGGER_FORM_SUBMIT: "trigger-form-submit",
+} as const;
+
+// Mock form instance
+const mockForm = {
+ resetFields: vi.fn(),
+ setFieldsValue: vi.fn(),
+ getFieldsValue: vi.fn(),
+ submit: vi.fn(),
+};
+
+// Types
+type SSOData = {
+ values: Record;
+} & Record;
+
+type SSOSettingsHookReturn = {
+ data: SSOData | null;
+ isLoading: boolean;
+ error: any;
+};
+
+type EditSSOSettingsHookReturn = {
+ mutateAsync: ReturnType;
+ isPending: boolean;
+};
+
+// Test data factories
+const createSSOData = (overrides: Record = {}): SSOData => ({
+ values: {
+ user_email: "test@example.com",
+ ...overrides,
+ },
+});
+
+const createGoogleSSOData = (overrides: Record = {}) =>
+ createSSOData({
+ google_client_id: "test-google-id",
+ google_client_secret: "test-google-secret",
+ ...overrides,
+ });
+
+const createMicrosoftSSOData = (overrides: Record = {}) =>
+ createSSOData({
+ microsoft_client_id: "test-microsoft-id",
+ microsoft_client_secret: "test-microsoft-secret",
+ microsoft_tenant: "test-tenant",
+ ...overrides,
+ });
+
+const createGenericSSOData = (overrides: Record = {}) =>
+ createSSOData({
+ generic_client_id: "test-generic-id",
+ generic_client_secret: "test-generic-secret",
+ generic_authorization_endpoint: overrides.authorization_endpoint || "https://custom.example.com/oauth",
+ ...overrides,
+ });
+
+const createRoleMappingsSSOData = (overrides: Record = {}) =>
+ createGoogleSSOData({
+ role_mappings: {
+ group_claim: "groups",
+ default_role: "internal_user",
+ roles: {
+ proxy_admin: overrides.proxy_admin || ["admin-group"],
+ proxy_admin_viewer: overrides.proxy_admin_viewer || ["viewer-group"],
+ internal_user: overrides.internal_user || ["user-group"],
+ internal_user_viewer: overrides.internal_user_viewer || ["readonly-group"],
+ },
+ },
+ ...overrides,
+ });
+
+// Mock utilities
+const createMockHooks = (): {
+ useSSOSettings: SSOSettingsHookReturn;
+ useEditSSOSettings: EditSSOSettingsHookReturn;
+} => ({
+ useSSOSettings: {
+ data: null,
+ isLoading: false,
+ error: null,
+ },
+ useEditSSOSettings: {
+ mutateAsync: vi.fn(),
+ isPending: false,
+ },
+});
+
+vi.mock("antd", () => ({
+ Modal: ({ children, open, title, footer, onCancel, width, ...props }: any) => (
+
+
{children}
+
{footer}
+
+
+ ),
+ Button: ({ children, onClick, loading, disabled, ...props }: any) => (
+
+ {children}
+
+ ),
+ Form: {
+ useForm: () => [mockForm],
+ },
+ Space: ({ children, ...props }: any) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock("./BaseSSOSettingsForm", () => ({
+ default: ({ form, onFormSubmit }: any) => (
+
+ onFormSubmit({ testField: "testValue" })}>
+ Trigger Form Submit
+
+
+ ),
+}));
+
+vi.mock("@/app/(dashboard)/hooks/sso/useSSOSettings", () => ({
+ useSSOSettings: vi.fn(),
+}));
+
+vi.mock("@/app/(dashboard)/hooks/sso/useEditSSOSettings", () => ({
+ useEditSSOSettings: vi.fn(),
+}));
+
+vi.mock("@/components/molecules/notifications_manager", () => ({
+ default: {
+ success: vi.fn(),
+ fromBackend: vi.fn(),
+ },
+}));
+
+vi.mock("@/components/shared/errorUtils", () => ({
+ parseErrorMessage: vi.fn(),
+}));
+
+vi.mock("../utils", () => ({
+ processSSOSettingsPayload: vi.fn(),
+}));
+
+// Test helpers
+const setupMocks = (
+ overrides: Partial<{
+ useSSOSettings: Partial;
+ useEditSSOSettings: Partial;
+ }> = {},
+) => {
+ const defaultMocks = createMockHooks();
+ const mocks = {
+ useSSOSettings: { ...defaultMocks.useSSOSettings, ...overrides.useSSOSettings },
+ useEditSSOSettings: { ...defaultMocks.useEditSSOSettings, ...overrides.useEditSSOSettings },
+ };
+
+ (useSSOSettings as Mock).mockReturnValue(mocks.useSSOSettings);
+ (useEditSSOSettings as Mock).mockReturnValue(mocks.useEditSSOSettings);
+
+ return mocks;
+};
+
+const renderComponent = (props: Partial> = {}) => {
+ const defaultProps = {
+ isVisible: true,
+ onCancel: vi.fn(),
+ onSuccess: vi.fn(),
+ };
+
+ return {
+ ...render( ),
+ mockOnCancel: defaultProps.onCancel,
+ mockOnSuccess: defaultProps.onSuccess,
+ };
+};
+
+const getButtons = () => screen.getAllByTestId(TEST_IDS.BUTTON);
+const getCancelButton = () => getButtons()[0];
+const getSaveButton = () => getButtons()[1];
+
+describe("EditSSOSettingsModal", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMocks();
+ });
+
+ describe("Rendering", () => {
+ it("renders without crashing", () => {
+ expect(() => renderComponent()).not.toThrow();
+ });
+
+ it("displays modal with correct configuration", () => {
+ renderComponent();
+
+ const modal = screen.getByTestId(TEST_IDS.MODAL);
+ expect(modal).toHaveAttribute("data-open", "true");
+ expect(modal).toHaveAttribute("data-title", TEST_DATA.MODAL_TITLE);
+ expect(modal).toHaveAttribute("data-width", TEST_DATA.MODAL_WIDTH);
+ });
+
+ it("displays modal as closed when not visible", () => {
+ renderComponent({ isVisible: false });
+
+ const modal = screen.getByTestId(TEST_IDS.MODAL);
+ expect(modal).toHaveAttribute("data-open", "false");
+ });
+ });
+
+ describe("Footer Actions", () => {
+ it("renders cancel and save buttons", () => {
+ renderComponent();
+
+ const buttons = getButtons();
+ expect(buttons).toHaveLength(2);
+ expect(buttons[0]).toHaveTextContent(TEST_DATA.BUTTON_TEXT.CANCEL);
+ expect(buttons[1]).toHaveTextContent(TEST_DATA.BUTTON_TEXT.SAVE);
+ });
+
+ it("calls onCancel and resets form when cancel button is clicked", () => {
+ const { mockOnCancel } = renderComponent();
+
+ fireEvent.click(getCancelButton());
+
+ expect(mockForm.resetFields).toHaveBeenCalled();
+ expect(mockOnCancel).toHaveBeenCalled();
+ });
+
+ it("calls form.submit when save button is clicked", () => {
+ renderComponent();
+
+ fireEvent.click(getSaveButton());
+
+ expect(mockForm.submit).toHaveBeenCalled();
+ });
+
+ describe("Loading States", () => {
+ it("disables cancel button during submission", () => {
+ setupMocks({
+ useEditSSOSettings: { mutateAsync: vi.fn(), isPending: true },
+ });
+
+ renderComponent();
+
+ expect(getCancelButton()).toBeDisabled();
+ });
+
+ it("shows loading state on save button during submission", () => {
+ setupMocks({
+ useEditSSOSettings: { mutateAsync: vi.fn(), isPending: true },
+ });
+
+ renderComponent();
+
+ expect(getSaveButton()).toHaveAttribute("data-loading", "true");
+ expect(getSaveButton()).toHaveTextContent(TEST_DATA.BUTTON_TEXT.SAVING);
+ });
+ });
+ });
+
+ describe("Form Submission", () => {
+ const formValues = { testField: "testValue" };
+ const processedPayload = { processed: "payload" };
+
+ beforeEach(() => {
+ (processSSOSettingsPayload as any).mockReturnValue(processedPayload);
+ });
+
+ it("processes form values and submits successfully", async () => {
+ const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
+ options.onSuccess();
+ return Promise.resolve({ success: true });
+ });
+
+ setupMocks({
+ useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
+ });
+
+ const { mockOnSuccess } = renderComponent();
+
+ fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
+
+ expect(processSSOSettingsPayload).toHaveBeenCalledWith(formValues);
+ expect(mockMutateAsync).toHaveBeenCalledWith(
+ processedPayload,
+ expect.objectContaining({
+ onSuccess: expect.any(Function),
+ onError: expect.any(Function),
+ }),
+ );
+ });
+
+ it("shows success notification and calls onSuccess callback", async () => {
+ const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
+ options.onSuccess();
+ return Promise.resolve({ success: true });
+ });
+
+ setupMocks({
+ useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
+ });
+
+ const { mockOnSuccess } = renderComponent();
+
+ fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
+
+ expect(NotificationsManager.success).toHaveBeenCalledWith(TEST_DATA.SUCCESS_MESSAGE);
+ expect(mockOnSuccess).toHaveBeenCalled();
+ });
+
+ it("handles submission errors gracefully", async () => {
+ const error = new Error("Submission failed");
+ const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
+ options.onError(error);
+ return Promise.reject(error);
+ });
+
+ setupMocks({
+ useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
+ });
+
+ (parseErrorMessage as any).mockReturnValue("Parsed error message");
+
+ renderComponent();
+
+ fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
+
+ expect(parseErrorMessage).toHaveBeenCalledWith(error);
+ expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
+ `${TEST_DATA.ERROR_MESSAGE_PREFIX} Parsed error message`,
+ );
+ });
+ });
+
+ describe("Form Initialization", () => {
+ describe("Provider Detection", () => {
+ const testProviderDetection = (testName: string, ssoData: SSOData, expectedProvider: string) => {
+ it(`detects ${testName} provider`, async () => {
+ setupMocks({
+ useSSOSettings: { data: ssoData, isLoading: false, error: null },
+ });
+
+ renderComponent();
+
+ await waitFor(() => {
+ expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ sso_provider: expectedProvider,
+ ...ssoData.values,
+ });
+ });
+ });
+ };
+
+ testProviderDetection("Google", createGoogleSSOData(), SSO_PROVIDERS.GOOGLE);
+
+ testProviderDetection("Microsoft", createMicrosoftSSOData(), SSO_PROVIDERS.MICROSOFT);
+
+ testProviderDetection(
+ "Okta",
+ createGenericSSOData({
+ authorization_endpoint: "https://okta.example.com/oauth2/authorize",
+ }),
+ SSO_PROVIDERS.OKTA,
+ );
+
+ testProviderDetection(
+ "Auth0 (detected as Okta)",
+ createGenericSSOData({
+ authorization_endpoint: "https://auth0.example.com/authorize",
+ }),
+ SSO_PROVIDERS.OKTA, // Auth0 URLs are detected as Okta provider
+ );
+
+ testProviderDetection("generic", createGenericSSOData(), SSO_PROVIDERS.GENERIC);
+ });
+
+ describe("Role Mappings", () => {
+ it("processes role mappings with all roles assigned", async () => {
+ const ssoData = createRoleMappingsSSOData();
+
+ setupMocks({
+ useSSOSettings: { data: ssoData, isLoading: false, error: null },
+ });
+
+ renderComponent();
+
+ await waitFor(() => {
+ expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ sso_provider: SSO_PROVIDERS.GOOGLE,
+ ...ssoData.values,
+ use_role_mappings: true,
+ group_claim: "groups",
+ default_role: "internal_user",
+ proxy_admin_teams: "admin-group",
+ admin_viewer_teams: "viewer-group",
+ internal_user_teams: "user-group",
+ internal_viewer_teams: "readonly-group",
+ });
+ });
+ });
+
+ it("handles empty role mapping arrays", async () => {
+ const ssoData = createRoleMappingsSSOData({
+ proxy_admin: [],
+ proxy_admin_viewer: [],
+ internal_user_viewer: [],
+ });
+
+ setupMocks({
+ useSSOSettings: { data: ssoData, isLoading: false, error: null },
+ });
+
+ renderComponent();
+
+ await waitFor(() => {
+ expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ sso_provider: SSO_PROVIDERS.GOOGLE,
+ ...ssoData.values,
+ use_role_mappings: true,
+ group_claim: "groups",
+ default_role: "internal_user",
+ proxy_admin_teams: "",
+ admin_viewer_teams: "",
+ internal_user_teams: "user-group",
+ internal_viewer_teams: "",
+ });
+ });
+ });
+ });
+
+ describe("Initialization Guards", () => {
+ it("resets form before setting values", async () => {
+ const ssoData = createGoogleSSOData();
+
+ setupMocks({
+ useSSOSettings: { data: ssoData, isLoading: false, error: null },
+ });
+
+ renderComponent();
+
+ await waitFor(() => {
+ expect(mockForm.resetFields).toHaveBeenCalled();
+ expect(mockForm.setFieldsValue).toHaveBeenCalled();
+ });
+ });
+
+ it("skips initialization when modal is not visible", () => {
+ const ssoData = createGoogleSSOData();
+
+ setupMocks({
+ useSSOSettings: { data: ssoData, isLoading: false, error: null },
+ });
+
+ renderComponent({ isVisible: false });
+
+ expect(mockForm.setFieldsValue).not.toHaveBeenCalled();
+ });
+
+ it("skips initialization when SSO data is unavailable", () => {
+ setupMocks({
+ useSSOSettings: { data: null, isLoading: false, error: null },
+ });
+
+ renderComponent();
+
+ expect(mockForm.setFieldsValue).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe("Error Handling", () => {
+ it("handles form submission errors with undefined error message", async () => {
+ const error = new Error("Network error");
+ const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
+ options.onError(error);
+ return Promise.reject(error);
+ });
+
+ setupMocks({
+ useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
+ });
+
+ (parseErrorMessage as any).mockReturnValue(undefined);
+
+ renderComponent();
+
+ fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
+
+ expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(`${TEST_DATA.ERROR_MESSAGE_PREFIX} undefined`);
+ });
+
+ it("handles form submission with malformed data", async () => {
+ const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
+ options.onError(new Error("Invalid data"));
+ return Promise.reject(new Error("Invalid data"));
+ });
+
+ setupMocks({
+ useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
+ });
+
+ (processSSOSettingsPayload as any).mockImplementation(() => {
+ throw new Error("Processing failed");
+ });
+
+ renderComponent();
+
+ fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
+
+ expect(processSSOSettingsPayload).toHaveBeenCalled();
+ expect(mockMutateAsync).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("handles role mappings with undefined roles object", async () => {
+ const ssoData = createGoogleSSOData({
+ role_mappings: {
+ group_claim: "groups",
+ default_role: "internal_user",
+ // roles is undefined
+ },
+ });
+
+ setupMocks({
+ useSSOSettings: { data: ssoData, isLoading: false, error: null },
+ });
+
+ renderComponent();
+
+ await waitFor(() => {
+ expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ sso_provider: SSO_PROVIDERS.GOOGLE,
+ ...ssoData.values,
+ use_role_mappings: true,
+ group_claim: "groups",
+ default_role: "internal_user",
+ proxy_admin_teams: "",
+ admin_viewer_teams: "",
+ internal_user_teams: "",
+ internal_viewer_teams: "",
+ });
+ });
+ });
+
+ it("handles provider detection with partial SSO data", async () => {
+ const ssoData = createSSOData({
+ // Only has generic fields, no specific provider identifiers
+ generic_client_id: "test-id",
+ generic_authorization_endpoint: "https://unknown.provider.com/auth",
+ });
+
+ setupMocks({
+ useSSOSettings: { data: ssoData, isLoading: false, error: null },
+ });
+
+ renderComponent();
+
+ await waitFor(() => {
+ expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ sso_provider: SSO_PROVIDERS.GENERIC,
+ ...ssoData.values,
+ });
+ });
+ });
+
+ it("handles form submission when processing throws error", async () => {
+ setupMocks({
+ useEditSSOSettings: { mutateAsync: vi.fn(), isPending: false },
+ });
+
+ (processSSOSettingsPayload as any).mockImplementation(() => {
+ throw new Error("Processing error");
+ });
+
+ renderComponent();
+
+ expect(() => {
+ fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
+ }).not.toThrow();
+
+ expect(processSSOSettingsPayload).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx
new file mode 100644
index 00000000000..a731af68ff1
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { Button, Form, Modal, Space } from "antd";
+import React, { useEffect } from "react";
+import BaseSSOSettingsForm from "./BaseSSOSettingsForm";
+import NotificationsManager from "@/components/molecules/notifications_manager";
+import { parseErrorMessage } from "@/components/shared/errorUtils";
+import { processSSOSettingsPayload } from "../utils";
+import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
+import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
+
+interface EditSSOSettingsModalProps {
+ isVisible: boolean;
+ onCancel: () => void;
+ onSuccess: () => void;
+}
+
+const EditSSOSettingsModal: React.FC = ({ isVisible, onCancel, onSuccess }) => {
+ const [form] = Form.useForm();
+
+ // Use react-query hooks for SSO settings
+ const ssoSettings = useSSOSettings();
+ const { mutateAsync, isPending } = useEditSSOSettings();
+ useEffect(() => {
+ if (isVisible && ssoSettings.data && ssoSettings.data.values) {
+ const ssoData = ssoSettings.data;
+ console.log("Raw SSO data received:", ssoData); // Debug log
+ console.log("SSO values:", ssoData.values); // Debug log
+ console.log("user_email from API:", ssoData.values.user_email); // Debug log
+
+ // Determine which SSO provider is configured
+ let selectedProvider = null;
+ if (ssoData.values.google_client_id) {
+ selectedProvider = "google";
+ } else if (ssoData.values.microsoft_client_id) {
+ selectedProvider = "microsoft";
+ } else if (ssoData.values.generic_client_id) {
+ // Check if it looks like Okta based on endpoints
+ if (
+ ssoData.values.generic_authorization_endpoint?.includes("okta") ||
+ ssoData.values.generic_authorization_endpoint?.includes("auth0")
+ ) {
+ selectedProvider = "okta";
+ } else {
+ selectedProvider = "generic";
+ }
+ }
+
+ // Extract role mappings if they exist
+ let roleMappingFields = {};
+ if (ssoData.values.role_mappings) {
+ const roleMappings = ssoData.values.role_mappings;
+
+ // Helper function to join arrays into comma-separated strings
+ const joinTeams = (teams: string[] | undefined): string => {
+ if (!teams || teams.length === 0) return "";
+ return teams.join(", ");
+ };
+
+ roleMappingFields = {
+ use_role_mappings: true,
+ group_claim: roleMappings.group_claim,
+ default_role: roleMappings.default_role || "internal_user",
+ proxy_admin_teams: joinTeams(roleMappings.roles?.proxy_admin),
+ admin_viewer_teams: joinTeams(roleMappings.roles?.proxy_admin_viewer),
+ internal_user_teams: joinTeams(roleMappings.roles?.internal_user),
+ internal_viewer_teams: joinTeams(roleMappings.roles?.internal_user_viewer),
+ };
+ }
+
+ // Set form values with existing data (excluding UI access control fields)
+ const formValues = {
+ sso_provider: selectedProvider,
+ ...ssoData.values,
+ ...roleMappingFields,
+ };
+
+ console.log("Setting form values:", formValues); // Debug log
+
+ // Clear form first, then set values with a small delay to ensure proper initialization
+ form.resetFields();
+ setTimeout(() => {
+ form.setFieldsValue(formValues);
+ console.log("Form values set, current form values:", form.getFieldsValue()); // Debug log
+ }, 100);
+ }
+ }, [isVisible, ssoSettings.data, form]);
+
+ // Enhanced form submission handler
+ const handleFormSubmit = async (formValues: Record) => {
+ try {
+ const payload = processSSOSettingsPayload(formValues);
+
+ await mutateAsync(payload, {
+ onSuccess: () => {
+ NotificationsManager.success("SSO settings updated successfully");
+ onSuccess();
+ },
+ onError: (error) => {
+ NotificationsManager.fromBackend("Failed to save SSO settings: " + parseErrorMessage(error));
+ },
+ });
+ } catch (error) {
+ // Handle processing errors gracefully
+ NotificationsManager.fromBackend("Failed to process SSO settings: " + parseErrorMessage(error));
+ }
+ };
+
+ const handleCancel = () => {
+ form.resetFields();
+ onCancel();
+ };
+
+ return (
+
+
+ Cancel
+
+ form.submit()}>
+ {isPending ? "Saving..." : "Save"}
+
+
+ }
+ onCancel={handleCancel}
+ >
+
+
+ );
+};
+
+export default EditSSOSettingsModal;
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RedactableField.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RedactableField.test.tsx
new file mode 100644
index 00000000000..a047d7aea4f
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RedactableField.test.tsx
@@ -0,0 +1,108 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import RedactableField from "./RedactableField";
+
+describe("RedactableField", () => {
+ describe("when value is null", () => {
+ it("should display 'Not configured' text", () => {
+ render( );
+
+ expect(screen.getByText("Not configured")).toBeInTheDocument();
+ });
+
+ it("should not display toggle button", () => {
+ render( );
+
+ // There should be no button elements
+ const buttons = screen.queryAllByRole("button");
+ expect(buttons).toHaveLength(0);
+ });
+ });
+
+ describe("when value is provided", () => {
+ const testValue = "secret-password";
+
+ it("should be hidden by default and show redacted dots", () => {
+ render( );
+
+ // Should show dots equal to the length of the value
+ expect(screen.getByText("•".repeat(testValue.length))).toBeInTheDocument();
+ expect(screen.queryByText(testValue)).not.toBeInTheDocument();
+ });
+
+ it("should show actual value when defaultHidden is false", () => {
+ render( );
+
+ expect(screen.getByText(testValue)).toBeInTheDocument();
+ expect(screen.queryByText("•".repeat(testValue.length))).not.toBeInTheDocument();
+ });
+
+ it("should display toggle button with eye icon when hidden", () => {
+ render( );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeInTheDocument();
+
+ // Check that the Eye icon is rendered (we can check by title or by the presence of the icon)
+ // The button should contain the Eye icon when hidden
+ const eyeIcon = button.querySelector("svg");
+ expect(eyeIcon).toBeInTheDocument();
+ });
+
+ it("should display toggle button with eye-off icon when shown", () => {
+ render( );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeInTheDocument();
+
+ // The button should contain the EyeOff icon when shown
+ const eyeOffIcon = button.querySelector("svg");
+ expect(eyeOffIcon).toBeInTheDocument();
+ });
+
+ it("should toggle visibility when button is clicked", () => {
+ render( );
+
+ // Initially hidden
+ expect(screen.getByText("•".repeat(testValue.length))).toBeInTheDocument();
+ expect(screen.queryByText(testValue)).not.toBeInTheDocument();
+
+ // Click to show
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ // Should now show the actual value
+ expect(screen.getByText(testValue)).toBeInTheDocument();
+ expect(screen.queryByText("•".repeat(testValue.length))).not.toBeInTheDocument();
+
+ // Click again to hide
+ fireEvent.click(button);
+
+ // Should be hidden again
+ expect(screen.getByText("•".repeat(testValue.length))).toBeInTheDocument();
+ expect(screen.queryByText(testValue)).not.toBeInTheDocument();
+ });
+
+ it("should handle empty string value", () => {
+ render( );
+
+ // Empty string should show "Not configured" since value is falsy
+ expect(screen.getByText("Not configured")).toBeInTheDocument();
+
+ // No toggle button for empty string
+ const buttons = screen.queryAllByRole("button");
+ expect(buttons).toHaveLength(0);
+ });
+
+ it("should handle different value lengths correctly", () => {
+ const shortValue = "hi";
+ const longValue = "this-is-a-very-long-secret-value";
+
+ const { rerender } = render( );
+ expect(screen.getByText("••")).toBeInTheDocument();
+
+ rerender( );
+ expect(screen.getByText("•".repeat(longValue.length))).toBeInTheDocument();
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RedactableField.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RedactableField.tsx
new file mode 100644
index 00000000000..44fef5cc7f8
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RedactableField.tsx
@@ -0,0 +1,38 @@
+import { useState } from "react";
+import { Button } from "antd";
+import { Eye, EyeOff } from "lucide-react";
+
+export default function RedactableField({
+ defaultHidden = true,
+ value,
+}: {
+ defaultHidden?: boolean;
+ value: string | null;
+}) {
+ const [isHidden, setIsHidden] = useState(defaultHidden);
+
+ return (
+
+
+ {value ? (
+ isHidden ? (
+ "•".repeat(value.length)
+ ) : (
+ value
+ )
+ ) : (
+ Not configured
+ )}
+
+ {value && (
+ : }
+ onClick={() => setIsHidden(!isHidden)}
+ className="text-gray-400 hover:text-gray-600"
+ />
+ )}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.test.tsx
new file mode 100644
index 00000000000..5e7908a872b
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.test.tsx
@@ -0,0 +1,37 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import SSOSettings from "./SSOSettings";
+
+// Mock the useSSOSettings hook
+vi.mock("@/app/(dashboard)/hooks/sso/useSSOSettings", () => ({
+ useSSOSettings: () => ({
+ data: null,
+ refetch: vi.fn(),
+ }),
+}));
+
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ gcTime: 0,
+ },
+ },
+ });
+
+describe("SSOSettings", () => {
+ it("should render", () => {
+ const queryClient = createQueryClient();
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("SSO Configuration")).toBeInTheDocument();
+ expect(screen.getByText("Manage Single Sign-On authentication settings")).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx
new file mode 100644
index 00000000000..27ff96af05f
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx
@@ -0,0 +1,249 @@
+"use client";
+
+import { useSSOSettings, type SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
+import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import { Button, Card, Descriptions, Space, Typography } from "antd";
+import { Edit, Shield, Trash2 } from "lucide-react";
+import { useState } from "react";
+import AddSSOSettingsModal from "./Modals/AddSSOSettingsModal";
+import DeleteSSOSettingsModal from "./Modals/DeleteSSOSettingsModal";
+import EditSSOSettingsModal from "./Modals/EditSSOSettingsModal";
+import RedactableField from "./RedactableField";
+import SSOSettingsEmptyPlaceholder from "./SSOSettingsEmptyPlaceholder";
+import SSOSettingsLoadingSkeleton from "./SSOSettingsLoadingSkeleton";
+import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./constants";
+
+const { Title, Text } = Typography;
+
+export default function SSOSettings() {
+ const { data: ssoSettings, refetch, isLoading } = useSSOSettings();
+ const { accessToken } = useAuthorized();
+ const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
+ const [isAddModalVisible, setIsAddModalVisible] = useState(false);
+ const [isEditModalVisible, setIsEditModalVisible] = useState(false);
+ const isSSOConfigured =
+ Boolean(ssoSettings?.values.google_client_id) ||
+ Boolean(ssoSettings?.values.microsoft_client_id) ||
+ Boolean(ssoSettings?.values.generic_client_id);
+
+ // Determine the SSO provider based on the configuration
+ const detectSSOProvider = (values: SSOSettingsValues): string | null => {
+ if (values.google_client_id) return "google";
+ if (values.microsoft_client_id) return "microsoft";
+ if (values.generic_client_id) {
+ // Check if it looks like Okta/Auth0 based on endpoints
+ if (
+ values.generic_authorization_endpoint?.includes("okta") ||
+ values.generic_authorization_endpoint?.includes("auth0")
+ ) {
+ return "okta";
+ }
+ return "generic";
+ }
+ return null;
+ };
+
+ const selectedProvider = ssoSettings?.values ? detectSSOProvider(ssoSettings.values) : null;
+
+ const renderEndpointValue = (value?: string | null) => (
+
+ {value || "-"}
+
+ );
+
+ const renderSimpleValue = (value?: string | null) =>
+ value ? value : Not configured ;
+
+ const descriptionsConfig = {
+ column: {
+ xxl: 1,
+ xl: 1,
+ lg: 1,
+ md: 1,
+ sm: 1,
+ xs: 1,
+ },
+ };
+
+ const providerConfigs = {
+ google: {
+ providerText: ssoProviderDisplayNames.google,
+ fields: [
+ {
+ label: "Client ID",
+ render: (values: SSOSettingsValues) => ,
+ },
+ {
+ label: "Client Secret",
+ render: (values: SSOSettingsValues) => ,
+ },
+ { label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
+ ],
+ },
+ microsoft: {
+ providerText: ssoProviderDisplayNames.microsoft,
+ fields: [
+ {
+ label: "Client ID",
+ render: (values: SSOSettingsValues) => ,
+ },
+ {
+ label: "Client Secret",
+ render: (values: SSOSettingsValues) => ,
+ },
+ { label: "Tenant", render: (values: any) => renderSimpleValue(values.microsoft_tenant) },
+ { label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
+ ],
+ },
+ okta: {
+ providerText: ssoProviderDisplayNames.okta,
+ fields: [
+ {
+ label: "Client ID",
+ render: (values: SSOSettingsValues) => ,
+ },
+ {
+ label: "Client Secret",
+ render: (values: SSOSettingsValues) => ,
+ },
+ {
+ label: "Authorization Endpoint",
+ render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_authorization_endpoint),
+ },
+ {
+ label: "Token Endpoint",
+ render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_token_endpoint),
+ },
+ {
+ label: "User Info Endpoint",
+ render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint),
+ },
+ { label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
+ ],
+ },
+ generic: {
+ providerText: ssoProviderDisplayNames.generic,
+ fields: [
+ {
+ label: "Client ID",
+ render: (values: SSOSettingsValues) => ,
+ },
+ {
+ label: "Client Secret",
+ render: (values: SSOSettingsValues) => ,
+ },
+ {
+ label: "Authorization Endpoint",
+ render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_authorization_endpoint),
+ },
+ {
+ label: "Token Endpoint",
+ render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_token_endpoint),
+ },
+ {
+ label: "User Info Endpoint",
+ render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint),
+ },
+ { label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
+ ],
+ },
+ };
+
+ const renderSSOSettings = () => {
+ if (!ssoSettings?.values || !selectedProvider) return null;
+
+ const { values } = ssoSettings;
+ const config = providerConfigs[selectedProvider as keyof typeof providerConfigs];
+
+ if (!config) return null;
+
+ return (
+
+
+
+ {ssoProviderLogoMap[selectedProvider] && (
+
+ )}
+
{config.providerText}
+
+
+ {config.fields.map((field, index) => (
+
+ {field.render(values)}
+
+ ))}
+
+ );
+ };
+
+ return (
+ <>
+ {isLoading ? (
+
+ ) : (
+
+
+ {/* Header Section */}
+
+
+
+
+
SSO Configuration
+ Manage Single Sign-On authentication settings
+
+
+
+
+ {isSSOConfigured && (
+ <>
+ } onClick={() => setIsEditModalVisible(true)}>
+ Edit SSO Settings
+
+ } onClick={() => setIsDeleteModalVisible(true)}>
+ Delete SSO Settings
+
+ >
+ )}
+
+
+
+ {isSSOConfigured ? (
+ renderSSOSettings()
+ ) : (
+ setIsAddModalVisible(true)} />
+ )}
+
+
+ )}
+
+ setIsDeleteModalVisible(false)}
+ onSuccess={() => refetch()}
+ accessToken={accessToken}
+ />
+
+ setIsAddModalVisible(false)}
+ onSuccess={() => {
+ setIsAddModalVisible(false);
+ refetch();
+ }}
+ />
+
+ setIsEditModalVisible(false)}
+ onSuccess={() => {
+ setIsEditModalVisible(false);
+ refetch();
+ }}
+ />
+ >
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.test.tsx
new file mode 100644
index 00000000000..6676ba1c2c9
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.test.tsx
@@ -0,0 +1,14 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import SSOSettingsEmptyPlaceholder from "./SSOSettingsEmptyPlaceholder";
+
+describe("SSOSettingsEmptyPlaceholder", () => {
+ it("should render", () => {
+ const onAdd = vi.fn();
+
+ render( );
+
+ expect(screen.getByText("No SSO Configuration Found")).toBeInTheDocument();
+ expect(screen.getByText("Configure SSO")).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.tsx
new file mode 100644
index 00000000000..fc315493a54
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.tsx
@@ -0,0 +1,30 @@
+import { Empty, Typography, Button } from "antd";
+
+const { Title, Paragraph } = Typography;
+
+interface SSOSettingsEmptyPlaceholderProps {
+ onAdd: () => void;
+}
+
+export default function SSOSettingsEmptyPlaceholder({ onAdd }: SSOSettingsEmptyPlaceholderProps) {
+ return (
+
+
+ No SSO Configuration Found
+
+ Configure Single Sign-On (SSO) to enable seamless authentication for your team members using your identity
+ provider.
+
+
+ }
+ >
+
+ Configure SSO
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.test.tsx
new file mode 100644
index 00000000000..fd4fde69588
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.test.tsx
@@ -0,0 +1,222 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import SSOSettingsLoadingSkeleton from "./SSOSettingsLoadingSkeleton";
+
+// Mock lucide-react icons
+vi.mock("lucide-react", () => ({
+ Shield: ({ className }: any) =>
,
+}));
+
+// Mock Ant Design components
+vi.mock("antd", () => ({
+ Card: ({ children, ...props }: any) => (
+
+ {children}
+
+ ),
+ Descriptions: Object.assign(
+ ({ children, bordered, column, ...props }: any) => (
+
+ {children}
+
+ ),
+ {
+ Item: ({ children, label, ...props }: any) => (
+
+ ),
+ },
+ ),
+ Typography: {
+ Title: ({ children, level, ...props }: any) => (
+
+ {children}
+
+ ),
+ Text: ({ children, type, ...props }: any) => (
+
+ {children}
+
+ ),
+ },
+ Space: ({ children, direction, size, className, ...props }: any) => (
+
+ {children}
+
+ ),
+ Skeleton: {
+ Button: ({ active, size, style, ...props }: any) => (
+
+ Button Skeleton
+
+ ),
+ Node: ({ active, style, ...props }: any) => (
+
+ Node Skeleton
+
+ ),
+ },
+}));
+
+describe("SSOSettingsLoadingSkeleton", () => {
+ it("should render without crashing", () => {
+ expect(() => render( )).not.toThrow();
+ });
+
+ it("should render Card component", () => {
+ render( );
+ expect(screen.getByTestId("card")).toBeInTheDocument();
+ });
+
+ it("should render Space component with correct props", () => {
+ render( );
+ const space = screen.getByTestId("space");
+ expect(space).toBeInTheDocument();
+ expect(space).toHaveAttribute("data-direction", "vertical");
+ expect(space).toHaveAttribute("data-size", "large");
+ expect(space).toHaveClass("w-full");
+ });
+
+ describe("Header Section", () => {
+ it("should render Shield icon", () => {
+ render( );
+ const shieldIcon = screen.getByTestId("shield-icon");
+ expect(shieldIcon).toBeInTheDocument();
+ expect(shieldIcon).toHaveClass("w-6 h-6 text-gray-400");
+ });
+
+ it("should render title with correct text and level", () => {
+ render( );
+ const title = screen.getByTestId("typography-title");
+ expect(title).toBeInTheDocument();
+ expect(title).toHaveAttribute("data-level", "3");
+ expect(title).toHaveTextContent("SSO Configuration");
+ });
+
+ it("should render subtitle text", () => {
+ render( );
+ const text = screen.getByTestId("typography-text");
+ expect(text).toBeInTheDocument();
+ expect(text).toHaveAttribute("data-type", "secondary");
+ expect(text).toHaveTextContent("Manage Single Sign-On authentication settings");
+ });
+
+ it("should render two skeleton buttons with correct styles", () => {
+ render( );
+ const buttons = screen.getAllByTestId("skeleton-button");
+ expect(buttons).toHaveLength(2);
+
+ // First button
+ expect(buttons[0]).toHaveAttribute("data-active", "true");
+ expect(buttons[0]).toHaveAttribute("data-size", "default");
+ expect(buttons[0]).toHaveAttribute("data-style", JSON.stringify({ width: 170, height: 32 }));
+
+ // Second button
+ expect(buttons[1]).toHaveAttribute("data-active", "true");
+ expect(buttons[1]).toHaveAttribute("data-size", "default");
+ expect(buttons[1]).toHaveAttribute("data-style", JSON.stringify({ width: 190, height: 32 }));
+ });
+ });
+
+ describe("Descriptions Table", () => {
+ it("should render Descriptions component with bordered prop", () => {
+ render( );
+ const descriptions = screen.getByTestId("descriptions");
+ expect(descriptions).toBeInTheDocument();
+ expect(descriptions).toHaveAttribute("data-bordered", "true");
+ });
+
+ it("should apply correct column configuration", () => {
+ render( );
+ const descriptions = screen.getByTestId("descriptions");
+ const expectedColumn = {
+ xxl: 1,
+ xl: 1,
+ lg: 1,
+ md: 1,
+ sm: 1,
+ xs: 1,
+ };
+ expect(descriptions).toHaveAttribute("data-column", JSON.stringify(expectedColumn));
+ });
+
+ it("should render exactly 5 description items", () => {
+ render( );
+ const items = screen.getAllByTestId("descriptions-item");
+ expect(items).toHaveLength(5);
+ });
+
+ describe("Description Items Structure", () => {
+ it("should render exactly 10 skeleton nodes total", () => {
+ render( );
+ const skeletonNodes = screen.getAllByTestId("skeleton-node");
+ expect(skeletonNodes).toHaveLength(10);
+ });
+
+ it("should render 5 skeleton nodes for labels with width 80", () => {
+ render( );
+ const skeletonNodes = screen.getAllByTestId("skeleton-node");
+
+ const labelNodes = skeletonNodes.filter(
+ (node) => node.getAttribute("data-style") === JSON.stringify({ width: 80, height: 16 }),
+ );
+ expect(labelNodes).toHaveLength(5);
+
+ labelNodes.forEach((node) => {
+ expect(node).toHaveAttribute("data-active", "true");
+ });
+ });
+
+ it("should render skeleton nodes for content with correct widths", () => {
+ render( );
+ const skeletonNodes = screen.getAllByTestId("skeleton-node");
+
+ // Expected content widths: [100, 200, 250, 180, 220]
+ const expectedWidths = [100, 200, 250, 180, 220];
+ expectedWidths.forEach((width) => {
+ const contentNode = skeletonNodes.find(
+ (node) => node.getAttribute("data-style") === JSON.stringify({ width, height: 16 }),
+ );
+ expect(contentNode).toBeInTheDocument();
+ expect(contentNode).toHaveAttribute("data-active", "true");
+ });
+ });
+ });
+ });
+
+ describe("Accessibility and Structure", () => {
+ it("should have proper semantic structure", () => {
+ render( );
+ // Card contains Space
+ const card = screen.getByTestId("card");
+ const space = screen.getByTestId("space");
+ expect(card).toContainElement(space);
+
+ // Space contains header section and descriptions
+ const descriptions = screen.getByTestId("descriptions");
+ expect(space).toContainElement(descriptions);
+ });
+
+ it("should render all skeleton elements as active", () => {
+ render( );
+ const skeletonNodes = screen.getAllByTestId("skeleton-node");
+ const skeletonButtons = screen.getAllByTestId("skeleton-button");
+
+ skeletonNodes.forEach((node) => {
+ expect(node).toHaveAttribute("data-active", "true");
+ });
+
+ skeletonButtons.forEach((button) => {
+ expect(button).toHaveAttribute("data-active", "true");
+ });
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.tsx
new file mode 100644
index 00000000000..59e34f255e3
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { Card, Descriptions, Skeleton, Space, Typography } from "antd";
+import { Shield } from "lucide-react";
+
+const { Title, Text } = Typography;
+export default function SSOSettingsLoadingSkeleton() {
+ const descriptionsConfig = {
+ column: {
+ xxl: 1,
+ xl: 1,
+ lg: 1,
+ md: 1,
+ sm: 1,
+ xs: 1,
+ },
+ };
+
+ return (
+
+
+ {/* Header Section */}
+
+
+
+
+
SSO Configuration
+ Manage Single Sign-On authentication settings
+
+
+
+
+
+
+
+
+
+ {/* Descriptions Table Skeleton */}
+
+ {/* Provider Row */}
+ }>
+
+
+
+
+
+ }>
+
+
+
+ }>
+
+
+
+ }>
+
+
+
+ }>
+
+
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/constants.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/constants.ts
new file mode 100644
index 00000000000..595a961401a
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/constants.ts
@@ -0,0 +1,15 @@
+// SSO Provider logos
+export const ssoProviderLogoMap: Record = {
+ google: "https://artificialanalysis.ai/img/logos/google_small.svg",
+ microsoft: "https://upload.wikimedia.org/wikipedia/commons/a/a8/Microsoft_Azure_Logo.svg",
+ okta: "https://www.okta.com/sites/default/files/Okta_Logo_BrightBlue_Medium.png",
+ generic: "",
+};
+
+// SSO Provider display names (consistent between select dropdown and table)
+export const ssoProviderDisplayNames: Record = {
+ google: "Google SSO",
+ microsoft: "Microsoft SSO",
+ okta: "Okta / Auth0 SSO",
+ generic: "Generic SSO",
+};
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts
new file mode 100644
index 00000000000..1c878d7b7b3
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts
@@ -0,0 +1,274 @@
+import { processSSOSettingsPayload } from "./utils";
+import { describe, it, expect } from "vitest";
+
+describe("processSSOSettingsPayload", () => {
+ describe("without role mappings", () => {
+ it("should return all fields except role mapping fields when use_role_mappings is false", () => {
+ const formValues = {
+ proxy_admin_teams: "team1, team2",
+ admin_viewer_teams: "viewer1",
+ internal_user_teams: "user1",
+ internal_viewer_teams: "viewer1",
+ default_role: "proxy_admin",
+ group_claim: "groups",
+ use_role_mappings: false,
+ other_field: "value",
+ another_field: 123,
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result).toEqual({
+ other_field: "value",
+ another_field: 123,
+ });
+ expect(result.role_mappings).toBeUndefined();
+ });
+
+ it("should return all fields except role mapping fields when use_role_mappings is not present", () => {
+ const formValues = {
+ proxy_admin_teams: "team1",
+ admin_viewer_teams: "viewer1",
+ internal_user_teams: "user1",
+ internal_viewer_teams: "viewer1",
+ default_role: "proxy_admin",
+ group_claim: "groups",
+ other_field: "value",
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result).toEqual({
+ other_field: "value",
+ });
+ expect(result.role_mappings).toBeUndefined();
+ });
+ });
+
+ describe("with role mappings enabled", () => {
+ it("should create role mappings with all team types populated", () => {
+ const formValues = {
+ proxy_admin_teams: "admin1, admin2",
+ admin_viewer_teams: "viewer1, viewer2, viewer3",
+ internal_user_teams: "user1",
+ internal_viewer_teams: "internal_viewer1, internal_viewer2",
+ default_role: "proxy_admin",
+ group_claim: "groups",
+ use_role_mappings: true,
+ other_field: "value",
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result.other_field).toBe("value");
+ expect(result.role_mappings).toEqual({
+ provider: "generic",
+ group_claim: "groups",
+ default_role: "proxy_admin",
+ roles: {
+ proxy_admin: ["admin1", "admin2"],
+ proxy_admin_viewer: ["viewer1", "viewer2", "viewer3"],
+ internal_user: ["user1"],
+ internal_user_viewer: ["internal_viewer1", "internal_viewer2"],
+ },
+ });
+ });
+
+ it("should handle empty team strings", () => {
+ const formValues = {
+ proxy_admin_teams: "",
+ admin_viewer_teams: "",
+ internal_user_teams: "",
+ internal_viewer_teams: "",
+ default_role: "internal_user",
+ group_claim: "groups",
+ use_role_mappings: true,
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result.role_mappings.roles).toEqual({
+ proxy_admin: [],
+ proxy_admin_viewer: [],
+ internal_user: [],
+ internal_user_viewer: [],
+ });
+ });
+
+ it("should handle undefined team fields", () => {
+ const formValues = {
+ default_role: "internal_user_viewer",
+ group_claim: "groups",
+ use_role_mappings: true,
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result.role_mappings.roles).toEqual({
+ proxy_admin: [],
+ proxy_admin_viewer: [],
+ internal_user: [],
+ internal_user_viewer: [],
+ });
+ });
+
+ it("should handle whitespace-only team strings", () => {
+ const formValues = {
+ proxy_admin_teams: " ",
+ admin_viewer_teams: ", , ,",
+ internal_user_teams: "user1, , user2",
+ internal_viewer_teams: "viewer1, ,viewer2",
+ default_role: "proxy_admin_viewer",
+ group_claim: "groups",
+ use_role_mappings: true,
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result.role_mappings.roles).toEqual({
+ proxy_admin: [],
+ proxy_admin_viewer: [],
+ internal_user: ["user1", "user2"],
+ internal_user_viewer: ["viewer1", "viewer2"],
+ });
+ });
+
+ it("should trim whitespace from team names", () => {
+ const formValues = {
+ proxy_admin_teams: " admin1 , admin2 ",
+ admin_viewer_teams: " viewer1 ",
+ internal_user_teams: " user1 , user2 ",
+ internal_viewer_teams: "viewer1,viewer2",
+ default_role: "internal_user",
+ group_claim: "groups",
+ use_role_mappings: true,
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result.role_mappings.roles).toEqual({
+ proxy_admin: ["admin1", "admin2"],
+ proxy_admin_viewer: ["viewer1"],
+ internal_user: ["user1", "user2"],
+ internal_user_viewer: ["viewer1", "viewer2"],
+ });
+ });
+
+ it("should filter out empty strings after trimming", () => {
+ const formValues = {
+ proxy_admin_teams: "admin1,,admin2, , admin3",
+ default_role: "internal_user",
+ group_claim: "groups",
+ use_role_mappings: true,
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result.role_mappings.roles.proxy_admin).toEqual(["admin1", "admin2", "admin3"]);
+ });
+ });
+
+ describe("default role mapping", () => {
+ it("should map internal_user_viewer correctly", () => {
+ const formValues = {
+ default_role: "internal_user_viewer",
+ group_claim: "groups",
+ use_role_mappings: true,
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result.role_mappings.default_role).toBe("internal_user_viewer");
+ });
+
+ it("should map internal_user correctly", () => {
+ const formValues = {
+ default_role: "internal_user",
+ group_claim: "groups",
+ use_role_mappings: true,
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result.role_mappings.default_role).toBe("internal_user");
+ });
+
+ it("should map proxy_admin_viewer correctly", () => {
+ const formValues = {
+ default_role: "proxy_admin_viewer",
+ group_claim: "groups",
+ use_role_mappings: true,
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result.role_mappings.default_role).toBe("proxy_admin_viewer");
+ });
+
+ it("should map proxy_admin correctly", () => {
+ const formValues = {
+ default_role: "proxy_admin",
+ group_claim: "groups",
+ use_role_mappings: true,
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result.role_mappings.default_role).toBe("proxy_admin");
+ });
+
+ it("should default to internal_user for unknown roles", () => {
+ const formValues = {
+ default_role: "unknown_role",
+ group_claim: "groups",
+ use_role_mappings: true,
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result.role_mappings.default_role).toBe("internal_user");
+ });
+
+ it("should default to internal_user for undefined default_role", () => {
+ const formValues = {
+ group_claim: "groups",
+ use_role_mappings: true,
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result.role_mappings.default_role).toBe("internal_user");
+ });
+ });
+
+ describe("edge cases", () => {
+ it("should handle empty form values", () => {
+ const result = processSSOSettingsPayload({});
+
+ expect(result).toEqual({});
+ });
+
+ it("should preserve other fields in the payload", () => {
+ const formValues = {
+ use_role_mappings: false,
+ sso_provider: "google",
+ client_id: "123",
+ client_secret: "secret",
+ redirect_url: "http://example.com",
+ custom_field: { nested: "value" },
+ array_field: [1, 2, 3],
+ };
+
+ const result = processSSOSettingsPayload(formValues);
+
+ expect(result).toEqual({
+ sso_provider: "google",
+ client_id: "123",
+ client_secret: "secret",
+ redirect_url: "http://example.com",
+ custom_field: { nested: "value" },
+ array_field: [1, 2, 3],
+ });
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts
new file mode 100644
index 00000000000..3533e1226c2
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts
@@ -0,0 +1,54 @@
+/**
+ * Processes SSO settings form values and transforms them into the payload format expected by the API
+ * Handles role mappings transformation and field extraction
+ */
+export const processSSOSettingsPayload = (formValues: Record): Record => {
+ const {
+ proxy_admin_teams,
+ admin_viewer_teams,
+ internal_user_teams,
+ internal_viewer_teams,
+ default_role,
+ group_claim,
+ use_role_mappings,
+ ...rest
+ } = formValues;
+
+ const payload: any = {
+ ...rest,
+ };
+
+ // Add role mappings if use_role_mappings is checked
+ if (use_role_mappings) {
+ // Helper function to split comma-separated string into array
+ const splitTeams = (teams: string | undefined): string[] => {
+ if (!teams || teams.trim() === "") return [];
+ return teams
+ .split(",")
+ .map((team) => team.trim())
+ .filter((team) => team.length > 0);
+ };
+
+ // Map default role display values to backend values
+ const defaultRoleMapping: Record = {
+ internal_user_viewer: "internal_user_viewer",
+ internal_user: "internal_user",
+ proxy_admin_viewer: "proxy_admin_viewer",
+ proxy_admin: "proxy_admin",
+ };
+
+ payload.role_mappings = {
+ provider: "generic",
+ group_claim,
+ default_role: defaultRoleMapping[default_role] || "internal_user",
+ roles: {
+ proxy_admin: splitTeams(proxy_admin_teams),
+ proxy_admin_viewer: splitTeams(admin_viewer_teams),
+ internal_user: splitTeams(internal_user_teams),
+ internal_user_viewer: splitTeams(internal_viewer_teams),
+ },
+ };
+ }
+
+ return payload;
+};
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
index 7680232ef0b..c9078383489 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
@@ -8,7 +8,7 @@ import { Alert, Card, Skeleton, Space, Switch, Typography } from "antd";
export default function UISettings() {
const { accessToken } = useAuthorized();
- const { data, isLoading, isError, error } = useUISettings(accessToken);
+ const { data, isLoading, isError, error } = useUISettings();
const { mutate: updateSettings, isPending: isUpdating, error: updateError } = useUpdateUISettings(accessToken);
const schema = data?.field_schema;
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
index 8f6bc411630..db268286007 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
@@ -268,16 +268,7 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals
{/* Content */}
-
+