diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 7e2d9d82a8e..5d9d6075335 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2239,14 +2239,6 @@ "src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx": { "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.tsx": { - "no-restricted-imports": { - "count": 1 } }, "src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx": { @@ -2298,9 +2290,6 @@ "src/components/Settings/AdminSettings/SSOSettings/RedactableField.tsx": { "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx": { @@ -2308,40 +2297,14 @@ "count": 1 } }, - "src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.test.tsx": { - "max-nested-callbacks": { - "count": 4 - } - }, - "src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-render": { - "count": 2 + "count": 1 } }, "src/components/Settings/AdminSettings/UISettings/UISettings.tsx": { "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx": { diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.test.tsx new file mode 100644 index 00000000000..cb75de4478f --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.test.tsx @@ -0,0 +1,74 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../../../../../tests/test-utils"; +import HashicorpVault from "./HashicorpVault"; + +const mockUseAuthorized = vi.hoisted(() => vi.fn()); +const mockUseHashicorpVaultConfig = vi.hoisted(() => vi.fn()); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: mockUseAuthorized, +})); + +vi.mock("@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig", () => ({ + useHashicorpVaultConfig: mockUseHashicorpVaultConfig, +})); + +vi.mock("@/app/(dashboard)/hooks/configOverrides/useDeleteHashicorpVaultConfig", () => ({ + useDeleteHashicorpVaultConfig: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig", () => ({ + useUpdateHashicorpVaultConfig: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("./EditHashicorpVaultModal", () => ({ + default: ({ isVisible }: { isVisible: boolean }) => (isVisible ?
Edit Vault Configuration
: null), +})); + +vi.mock("@/components/common_components/DeleteResourceModal", () => ({ + default: () => null, +})); + +describe("HashicorpVault", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token" }); + mockUseHashicorpVaultConfig.mockReturnValue({ + data: { values: {} }, + isLoading: false, + isError: false, + error: null, + }); + }); + + it("should render", () => { + renderWithProviders(); + + expect(screen.getByRole("heading", { name: "Hashicorp Vault" })).toBeInTheDocument(); + }); + + it("should open the configuration editor from the empty state", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /configure vault/i })); + + expect(screen.getByText("Edit Vault Configuration")).toBeInTheDocument(); + }); + + it("should display configured values and management actions", () => { + mockUseHashicorpVaultConfig.mockReturnValue({ + data: { values: { vault_addr: "https://vault.example.com", vault_token: "secret" } }, + isLoading: false, + isError: false, + error: null, + }); + + renderWithProviders(); + + expect(screen.getByText("https://vault.example.com")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /test connection/i })).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx index 569ea49198b..79a12c19571 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx @@ -1,43 +1,49 @@ "use client"; +import { Edit, ExternalLink, Info, KeyRound, PlugZap, Trash2 } from "lucide-react"; import { useState } from "react"; -import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig"; + +import { testHashicorpVaultConnection } from "@/app/(dashboard)/hooks/configOverrides/hashicorpVaultApi"; import { useDeleteHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useDeleteHashicorpVaultConfig"; +import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig"; import { useUpdateHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import NotificationManager from "@/components/molecules/notifications_manager"; -import { testHashicorpVaultConnection } from "@/app/(dashboard)/hooks/configOverrides/hashicorpVaultApi"; -import { Alert, Button, Card, Descriptions, Flex, Skeleton, Space, Typography } from "antd"; -import { Edit, KeyRound, PlugZap, Trash2 } from "lucide-react"; -import { SENSITIVE_FIELDS, FIELD_LABELS } from "./constants"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + import EditHashicorpVaultModal from "./EditHashicorpVaultModal"; import HashicorpVaultEmptyPlaceholder from "./HashicorpVaultEmptyPlaceholder"; +import { FIELD_LABELS, SENSITIVE_FIELDS } from "./constants"; -const { Title, Text } = Typography; - -function detectAuthMethod(values: Record): string { +function detectAuthMethod(values: Record): string { if (values.approle_role_id || values.approle_secret_id) return "AppRole"; if (values.client_cert && values.client_key) return "TLS Certificate"; if (values.vault_token) return "Token"; return "None"; } -const descriptionsConfig = { - column: { xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }, -}; +function DetailRow({ children, label }: { children: React.ReactNode; label: string }) { + return ( +
+
{label}
+
{children}
+
+ ); +} export default function HashicorpVault() { const { accessToken } = useAuthorized(); const { data, isLoading, isError, error } = useHashicorpVaultConfig(); const { mutate: deleteConfig, isPending: isDeleting } = useDeleteHashicorpVaultConfig(accessToken); const { mutate: updateConfig, isPending: isClearingField } = useUpdateHashicorpVaultConfig(accessToken); - const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [clearingField, setClearingField] = useState(null); const [isTesting, setIsTesting] = useState(false); - const rawValues = data?.values ?? {}; const isConfigured = Boolean(rawValues.vault_addr); @@ -60,9 +66,7 @@ export default function HashicorpVault() { NotificationManager.success("Hashicorp Vault configuration deleted"); setIsDeleteModalOpen(false); }, - onError: (err) => { - NotificationManager.fromBackend(err); - }, + onError: (err) => NotificationManager.fromBackend(err), }); }; @@ -75,127 +79,116 @@ export default function HashicorpVault() { NotificationManager.success(`${FIELD_LABELS[clearingField] ?? clearingField} cleared`); setClearingField(null); }, - onError: (err) => { - NotificationManager.fromBackend(err); - }, + onError: (err) => NotificationManager.fromBackend(err), }, ); }; const renderValue = (key: string) => { const value = rawValues[key]; - if (!value) { - return Not configured; - } - if (SENSITIVE_FIELDS.has(key)) { - return ( - - {value} - + ); }; + const fieldsToShow = Object.entries(rawValues).filter(([, value]) => value != null && value !== ""); + return ( <> {isLoading ? ( - - + + + + + ) : isError ? ( - + + + Could not load Hashicorp Vault configuration + {error instanceof Error && {error.message}} + + ) : ( - - {/* Header */} - - - -
- - Hashicorp Vault - - Manage secret manager configuration -
-
- - - {isConfigured && ( - <> - - - - - )} - -
- + +
+ +
+ +

Hashicorp Vault

+
+ Manage secret manager configuration +
+
{isConfigured && ( - - vault kv put secret/SECRET_NAME key=secret_value -
- - View documentation - - - } - /> + + + + + + )} +
+ + {isConfigured && ( + + + Secrets must be stored with the field name "key" + + vault kv put secret/SECRET_NAME key=secret_value + + View documentation + + + + )} {isConfigured ? ( - renderSettings() + fieldsToShow.length > 0 && ( +
+ {detectAuthMethod(rawValues)} + {fieldsToShow.map(([key]) => ( + + {renderValue(key)} + + ))} +
+ ) ) : ( setIsEditModalVisible(true)} /> )} -
+
)} @@ -204,7 +197,6 @@ export default function HashicorpVault() { onCancel={() => setIsEditModalVisible(false)} onSuccess={() => setIsEditModalVisible(false)} /> - - void; @@ -8,22 +8,17 @@ interface HashicorpVaultEmptyPlaceholderProps { export default function HashicorpVaultEmptyPlaceholder({ onAdd }: HashicorpVaultEmptyPlaceholderProps) { return ( -
- - No Vault Configuration Found - - Configure Hashicorp Vault to securely manage provider API keys and secrets for your LiteLLM deployment. - -
- } - > - - +
+
+ +
+

No Vault Configuration Found

+

+ Configure Hashicorp Vault to securely manage provider API keys and secrets for your LiteLLM deployment. +

+
); } 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 index a047d7aea4f..f03b160665d 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RedactableField.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RedactableField.test.tsx @@ -1,19 +1,19 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; +import { renderWithProviders } from "../../../../../tests/test-utils"; import RedactableField from "./RedactableField"; describe("RedactableField", () => { describe("when value is null", () => { it("should display 'Not configured' text", () => { - render(); + renderWithProviders(); expect(screen.getByText("Not configured")).toBeInTheDocument(); }); it("should not display toggle button", () => { - render(); - - // There should be no button elements + renderWithProviders(); const buttons = screen.queryAllByRole("button"); expect(buttons).toHaveLength(0); }); @@ -23,73 +23,49 @@ describe("RedactableField", () => { 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 + renderWithProviders(); expect(screen.getByText("•".repeat(testValue.length))).toBeInTheDocument(); expect(screen.queryByText(testValue)).not.toBeInTheDocument(); }); it("should show actual value when defaultHidden is false", () => { - render(); + renderWithProviders(); expect(screen.getByText(testValue)).toBeInTheDocument(); expect(screen.queryByText("•".repeat(testValue.length))).not.toBeInTheDocument(); }); - it("should display toggle button with eye icon when hidden", () => { - render(); + it("should identify the hidden-value control and render its icon", () => { + renderWithProviders(); - 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(); + const button = screen.getByRole("button", { name: "Show value" }); + expect(button.querySelector("svg")).toBeInTheDocument(); }); - it("should display toggle button with eye-off icon when shown", () => { - render(); + it("should identify the visible-value control and render its icon", () => { + renderWithProviders(); - 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(); + const button = screen.getByRole("button", { name: "Hide value" }); + expect(button.querySelector("svg")).toBeInTheDocument(); }); - it("should toggle visibility when button is clicked", () => { - render(); + it("should toggle visibility when button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); - // 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 + await user.click(screen.getByRole("button", { name: "Show 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 + await user.click(screen.getByRole("button", { name: "Hide value" })); 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 + renderWithProviders(); expect(screen.getByText("Not configured")).toBeInTheDocument(); - // No toggle button for empty string const buttons = screen.queryAllByRole("button"); expect(buttons).toHaveLength(0); }); @@ -98,7 +74,7 @@ describe("RedactableField", () => { const shortValue = "hi"; const longValue = "this-is-a-very-long-secret-value"; - const { rerender } = render(); + const { rerender } = renderWithProviders(); expect(screen.getByText("••")).toBeInTheDocument(); rerender(); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RedactableField.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RedactableField.tsx index 44fef5cc7f8..04ef3309e5a 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RedactableField.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RedactableField.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; -import { Button } from "antd"; import { Eye, EyeOff } from "lucide-react"; +import { Button } from "@/components/ui/button"; + export default function RedactableField({ defaultHidden = true, value, @@ -13,7 +14,7 @@ export default function RedactableField({ return (
- + {value ? ( isHidden ? ( "•".repeat(value.length) @@ -21,17 +22,20 @@ export default function RedactableField({ value ) ) : ( - Not configured + Not configured )} {value && ( )}
); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx index 0c83994cde6..90d78c0ce72 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx @@ -1,11 +1,15 @@ "use client"; -import { useSSOSettings, type SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings"; -import { Button, Card, Descriptions, Space, Tag, Typography } from "antd"; -import { Edit, Shield, Trash2 } from "lucide-react"; +import { Copy, Edit, Shield, Trash2 } from "lucide-react"; import { useState } from "react"; + +import { useSSOSettings, type SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings"; import { Logo } from "@/components/molecules/logo/Logo"; -import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./constants"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { copyToClipboard } from "@/utils/dataUtils"; + import AddSSOSettingsModal from "./Modals/AddSSOSettingsModal"; import DeleteSSOSettingsModal from "./Modals/DeleteSSOSettingsModal"; import EditSSOSettingsModal from "./Modals/EditSSOSettingsModal"; @@ -13,9 +17,40 @@ import RedactableField from "./RedactableField"; import RoleMappings from "./RoleMappings"; import SSOSettingsEmptyPlaceholder from "./SSOSettingsEmptyPlaceholder"; import SSOSettingsLoadingSkeleton from "./SSOSettingsLoadingSkeleton"; +import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./constants"; import { detectSSOProvider } from "./utils"; -const { Title, Text } = Typography; +function NotConfigured() { + return Not configured; +} + +function DetailRow({ children, label }: { children: React.ReactNode; label: string }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function EndpointValue({ value }: { value?: string | null }) { + if (!value) return -; + + return ( +
+ {value} + +
+ ); +} export default function SSOSettings() { const { data: ssoSettings, refetch, isLoading } = useSSOSettings(); @@ -29,37 +64,17 @@ export default function SSOSettings() { ssoSettings?.values.saml_idp_metadata_url, ssoSettings?.values.saml_idp_metadata_xml, ].some(Boolean); - const selectedProvider = ssoSettings?.values ? detectSSOProvider(ssoSettings.values) : null; const isRoleMappingsEnabled = Boolean(ssoSettings?.values.role_mappings); const isTeamMappingsEnabled = Boolean(ssoSettings?.values.team_mappings); - const renderEndpointValue = (value?: string | null) => ( - - {value || "-"} - - ); - - const renderSimpleValue = (value?: string | null) => - value ? value : Not configured; - - const renderTeamMappingsField = (values: SSOSettingsValues) => { - if (!values.team_mappings?.team_ids_jwt_field) { - return Not configured; - } - return {values.team_mappings.team_ids_jwt_field}; - }; - - const descriptionsConfig = { - column: { - xxl: 1, - xl: 1, - lg: 1, - md: 1, - sm: 1, - xs: 1, - }, - }; + const renderSimpleValue = (value?: string | null) => value || ; + const renderTeamMappingsField = (values: SSOSettingsValues) => + values.team_mappings?.team_ids_jwt_field ? ( + {values.team_mappings.team_ids_jwt_field} + ) : ( + + ); const providerConfigs = { google: { @@ -87,7 +102,7 @@ export default function SSOSettings() { label: "Client Secret", render: (values: SSOSettingsValues) => , }, - { label: "Tenant", render: (values: any) => renderSimpleValue(values.microsoft_tenant) }, + { label: "Tenant", render: (values: SSOSettingsValues) => renderSimpleValue(values.microsoft_tenant) }, { label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) }, ], }, @@ -104,23 +119,20 @@ export default function SSOSettings() { }, { label: "Authorization Endpoint", - render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_authorization_endpoint), + render: (values: SSOSettingsValues) => , }, { label: "Token Endpoint", - render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_token_endpoint), + render: (values: SSOSettingsValues) => , }, { label: "User Info Endpoint", - render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint), + render: (values: SSOSettingsValues) => , }, { label: "Scopes", render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope) }, { label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) }, isTeamMappingsEnabled - ? { - label: "Team IDs JWT Field", - render: (values: SSOSettingsValues) => renderTeamMappingsField(values), - } + ? { label: "Team IDs JWT Field", render: (values: SSOSettingsValues) => renderTeamMappingsField(values) } : null, ], }, @@ -137,23 +149,20 @@ export default function SSOSettings() { }, { label: "Authorization Endpoint", - render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_authorization_endpoint), + render: (values: SSOSettingsValues) => , }, { label: "Token Endpoint", - render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_token_endpoint), + render: (values: SSOSettingsValues) => , }, { label: "User Info Endpoint", - render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint), + render: (values: SSOSettingsValues) => , }, { label: "Scopes", render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope) }, { label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) }, isTeamMappingsEnabled - ? { - label: "Team IDs JWT Field", - render: (values: SSOSettingsValues) => renderTeamMappingsField(values), - } + ? { label: "Team IDs JWT Field", render: (values: SSOSettingsValues) => renderTeamMappingsField(values) } : null, ], }, @@ -162,27 +171,23 @@ export default function SSOSettings() { fields: [ { label: "IdP Metadata URL", - render: (values: SSOSettingsValues) => renderEndpointValue(values.saml_idp_metadata_url), + render: (values: SSOSettingsValues) => , }, { label: "IdP Metadata XML", render: (values: SSOSettingsValues) => - values.saml_idp_metadata_xml ? ( - Provided - ) : ( - Not configured - ), + values.saml_idp_metadata_xml ? Provided : , }, { label: "SP Entity ID", - render: (values: SSOSettingsValues) => renderEndpointValue(values.saml_sp_entity_id), + render: (values: SSOSettingsValues) => , }, { label: "Allow IdP-initiated (unsolicited) responses", render: (values: SSOSettingsValues) => ( - + {values.saml_allow_unsolicited === "true" ? "Enabled" : "Disabled"} - + ), }, { label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) }, @@ -192,35 +197,32 @@ export default function SSOSettings() { 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) => field && ( - - {field.render(values)} - + + {field.render(ssoSettings.values)} + ), )} - +
); }; @@ -229,46 +231,41 @@ export default function SSOSettings() { {isLoading ? ( ) : ( - +
- - {/* Header Section */} -
-
- -
- SSO Configuration - Manage Single Sign-On authentication settings -
-
- -
- {isSSOConfigured && ( - <> - - - - )} + +
+ +
+ +

SSO Configuration

+
+ Manage Single Sign-On authentication settings
- + {isSSOConfigured && ( + + + + + )} +
+ {isSSOConfigured ? ( renderSSOSettings() ) : ( setIsAddModalVisible(true)} /> )} - + {isRoleMappingsEnabled && } - +
)} setIsDeleteModalVisible(false)} onSuccess={() => refetch()} /> - setIsAddModalVisible(false)} @@ -285,7 +281,6 @@ export default function SSOSettings() { refetch(); }} /> - setIsEditModalVisible(false)} diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.tsx index fc315493a54..3afc2014125 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.tsx @@ -1,6 +1,6 @@ -import { Empty, Typography, Button } from "antd"; +import { Shield } from "lucide-react"; -const { Title, Paragraph } = Typography; +import { Button } from "@/components/ui/button"; interface SSOSettingsEmptyPlaceholderProps { onAdd: () => void; @@ -8,23 +8,18 @@ interface SSOSettingsEmptyPlaceholderProps { 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. - -
- } - > - - +
+
+ +
+

No SSO Configuration Found

+

+ Configure Single Sign-On (SSO) to enable seamless authentication for your team members using your identity + provider. +

+
); } 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 index fd4fde69588..6c3595bb791 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.test.tsx @@ -1,222 +1,31 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { renderWithProviders } from "../../../../../tests/test-utils"; 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) => ( -
-
{label}
-
{children}
-
- ), - }, - ), - 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", () => { + renderWithProviders(); + + expect(screen.getByRole("heading", { name: "SSO Configuration" })).toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Loading SSO configuration" })).toBeInTheDocument(); }); - it("should render Card component", () => { - render(); - expect(screen.getByTestId("card")).toBeInTheDocument(); + it("should explain which configuration is loading", () => { + renderWithProviders(); + + expect(screen.getByText("Manage Single Sign-On authentication settings")).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"); - }); + it("should render the complete action and configuration skeleton", () => { + const { container } = renderWithProviders(); - 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"); - }); + const skeletons = container.querySelectorAll('[data-slot="skeleton"]'); + expect(skeletons).toHaveLength(12); + expect(container.querySelectorAll('[data-slot="skeleton"].h-8')).toHaveLength(2); + expect(container.querySelectorAll('[data-slot="skeleton"].h-4.w-20')).toHaveLength(5); + ["w-24", "w-48", "w-60", "w-44", "w-52"].forEach((width) => { + expect(container.querySelector(`[data-slot="skeleton"].h-4.${width}`)).toBeInTheDocument(); }); }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.tsx index 59e34f255e3..ad1db99638b 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.tsx @@ -1,66 +1,42 @@ "use client"; -import { Card, Descriptions, Skeleton, Space, Typography } from "antd"; import { Shield } from "lucide-react"; -const { Title, Text } = Typography; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +const CONTENT_WIDTHS = ["w-24", "w-48", "w-60", "w-44", "w-52"]; + 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 -
-
- -
- - + + +
+ +
+

SSO Configuration

+

Manage Single Sign-On authentication settings

- - {/* Descriptions Table Skeleton */} - - {/* Provider Row */} - }> -
- +
+ + +
+ + +
+ {CONTENT_WIDTHS.map((width) => ( +
+
+ +
+
+ +
- - - }> - - - - }> - - - - }> - - - - }> - - - - + ))} +
+
); } diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.test.tsx index a3245b7e76b..3c64938d06a 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.test.tsx @@ -1,6 +1,8 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; + +import { renderWithProviders, screen } from "@/../tests/test-utils"; + import PageVisibilitySettings from "./PageVisibilitySettings"; vi.mock("@/components/page_utils", () => ({ @@ -13,26 +15,32 @@ vi.mock("@/components/page_utils", () => ({ describe("PageVisibilitySettings", () => { it("should render the not-set tag when enabledPagesInternalUsers is null", () => { - render(); + renderWithProviders( + , + ); expect(screen.getByText("Not set (all pages visible)")).toBeInTheDocument(); }); it("should show the selected page count tag when pages are configured", () => { - render( + renderWithProviders( , ); expect(screen.getByText("2 pages selected")).toBeInTheDocument(); }); it("should show singular 'page' when exactly one page is selected", () => { - render(); + renderWithProviders( + , + ); expect(screen.getByText("1 page selected")).toBeInTheDocument(); }); it("should call onUpdate with null when reset button is clicked", async () => { const onUpdate = vi.fn(); const user = userEvent.setup(); - render(); + renderWithProviders( + , + ); // Expand the collapse panel first to reveal the reset button await user.click(screen.getByRole("button", { name: /configure page visibility/i })); @@ -41,8 +49,34 @@ describe("PageVisibilitySettings", () => { expect(onUpdate).toHaveBeenCalledWith({ enabled_ui_pages_internal_users: null }); }); + it("should render every page under its original group when Object.groupBy is unavailable", async () => { + const groupByDescriptor = Object.getOwnPropertyDescriptor(Object, "groupBy"); + Object.defineProperty(Object, "groupBy", { configurable: true, value: undefined }); + + try { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: /configure page visibility/i })); + + expect(screen.getByRole("group", { name: "Analytics" })).toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Access" })).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: /usage/i })).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: /models/i })).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: /api keys/i })).toBeInTheDocument(); + } finally { + if (groupByDescriptor) { + Object.defineProperty(Object, "groupBy", groupByDescriptor); + } else { + Reflect.deleteProperty(Object, "groupBy"); + } + } + }); + it("should display the property description when provided", () => { - render( + renderWithProviders( getAvailablePages(), []); - - // Group pages by their group for better UI const pagesByGroup = useMemo(() => { const grouped: Record = {}; availablePages.forEach((page) => { @@ -34,19 +34,16 @@ export default function PageVisibilitySettings({ }); return grouped; }, [availablePages]); - - // Local state for page selection const [selectedPages, setSelectedPages] = useState(enabledPagesInternalUsers || []); - // Update local state when data changes useMemo(() => { - if (enabledPagesInternalUsers) { - setSelectedPages(enabledPagesInternalUsers); - } else { - setSelectedPages([]); - } + setSelectedPages(enabledPagesInternalUsers || []); }, [enabledPagesInternalUsers]); + const togglePage = (page: string, checked: boolean) => { + setSelectedPages((current) => (checked ? [...current, page] : current.filter((item) => item !== page))); + }; + const handleSavePageVisibility = () => { onUpdate({ enabled_ui_pages_internal_users: selectedPages.length > 0 ? selectedPages : null }); }; @@ -57,90 +54,74 @@ export default function PageVisibilitySettings({ }; return ( - - - - Internal User Page Visibility - {!isPageVisibilitySet && ( - - Not set (all pages visible) - - )} - {isPageVisibilitySet && ( - - {selectedPages.length} page{selectedPages.length !== 1 ? "s" : ""} selected - - )} - +
+
+
+

Internal User Page Visibility

+ + {isPageVisibilitySet + ? `${selectedPages.length} page${selectedPages.length !== 1 ? "s" : ""} selected` + : "Not set (all pages visible)"} + +
{enabledPagesPropertyDescription && ( - {enabledPagesPropertyDescription} +

{enabledPagesPropertyDescription}

)} - +

By default, all pages are visible to internal users. Select specific pages to restrict visibility. - - +

+

Note: Only pages accessible to internal user roles are shown here. Admin-only pages are excluded as they cannot be made visible to internal users regardless of this setting. - - +

+
- - - - {Object.entries(pagesByGroup).map(([groupName, pages]) => ( -
- - {groupName} - - - {pages.map((page) => ( -
- - - {page.label} - - {page.description} - - - -
- ))} -
-
- ))} -
-
+ + + Configure Page Visibility + + + +
+ {Object.entries(pagesByGroup).map(([groupName, pages]) => ( +
+ + {groupName} + +
+ {pages.map((page) => { + const checkboxId = `page-visibility-${page.page}`; + return ( + + ); + })} +
+
+ ))} - - - {isPageVisibilitySet && ( - - )} - - - ), - }, - ]} - /> - +
+ + {isPageVisibilitySet && ( + + )} +
+
+
+
+
); } 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 ec970c34873..3959fd9f012 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -4,8 +4,46 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings" import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import NotificationManager from "@/components/molecules/notifications_manager"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; import PageVisibilitySettings from "./PageVisibilitySettings"; -import { Alert, Card, Divider, Skeleton, Space, Switch, Typography } from "antd"; + +interface SettingRowProps { + ariaLabel: string; + checked: boolean; + description?: string; + disabled: boolean; + indented?: boolean; + label: string; + muted?: boolean; + onCheckedChange: (checked: boolean) => void; +} + +function SettingRow({ + ariaLabel, + checked, + description, + disabled, + indented = false, + label, + muted = false, + onCheckedChange, +}: SettingRowProps) { + return ( +
+ +
+

+ {label} +

+ {description &&

{description}

} +
+
+ ); +} export default function UISettings() { const { accessToken } = useAuthorized(); @@ -229,270 +267,181 @@ export default function UISettings() { }; return ( - - {isLoading ? ( - - ) : isError ? ( - - ) : ( - - {schema?.description && ( - {schema.description} - )} + + + +

UI Settings

+
+
+ + {isLoading ? ( +
+ + + +
+ ) : isError ? ( + + Could not load UI settings + {error instanceof Error && {error.message}} + + ) : ( +
+ {schema?.description &&

{schema.description}

} + {updateError && ( + + Could not update UI settings + {updateError instanceof Error && {updateError.message}} + + )} - {updateError && ( - - )} - - - - - Disable model add for internal users - {property?.description && {property.description}} - - - - - - - Disable team admin delete team user - {disableTeamAdminDeleteProperty?.description && ( - {disableTeamAdminDeleteProperty.description} - )} - - - - - - - Require authentication for public AI Hub - {requireAuthForPublicAIHubProperty?.description && ( - {requireAuthForPublicAIHubProperty.description} - )} - - - - - - - Forward client headers to LLM API - - {forwardClientHeadersProperty?.description ?? - "Forwards client headers (Authorization, anthropic-beta, and x-* custom headers) to the upstream LLM. Enable for Claude Code with a Max subscription (forwards the OAuth token) or to pass custom/tracing headers through to the provider. Independent of the BYOK toggle — enable only the one(s) you need."} - - - - - - - - Forward LLM provider auth headers - - {forwardLLMProviderAuthHeadersProperty?.description ?? - "Forwards provider auth headers (x-api-key, x-goog-api-key, api-key, ocp-apim-subscription-key) to the upstream LLM, overriding any deployment-configured key for that request. Enable for Claude Code BYOK (clients bring their own API key). Independent of the client-headers toggle — enable only the one(s) you need."} - - - - - {enableProjectsUIProperty && ( - - - - [BETA] Enable Projects (page will refresh) - - {enableProjectsUIProperty.description ?? - "If enabled, shows the Projects feature in the UI sidebar and the project field in key management."} - - - - )} - - - - - [BETA] Enable Chat page (page will refresh) - - {enableChatUIProperty?.description ?? - "If enabled, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth."} - - - - - - {/* Agents access control */} - - + - - Disable agents for internal users - {disableAgentsProperty?.description && ( - {disableAgentsProperty.description} - )} - - - - - - - - Allow agents for team admins - - {allowAgentsTeamAdminsProperty?.description && ( - {allowAgentsTeamAdminsProperty.description} - )} - - - - - {/* Vector Stores access control */} - - + - - Disable vector stores for internal users - {disableVectorStoresProperty?.description && ( - {disableVectorStoresProperty.description} - )} - - - - - - - - Allow vector stores for team admins - - {allowVectorStoresTeamAdminsProperty?.description && ( - {allowVectorStoresTeamAdminsProperty.description} - )} - - - - - {/* Scope user search to organization */} - - + - - Scope user search to organization - - {scopeUserSearchProperty?.description ?? - "If enabled, the user search endpoint restricts results by organization. When off, any authenticated user can search all users."} - - - - - - {/* Disable custom Virtual key values */} - - + - - Disable custom Virtual key values - - {disableCustomApiKeysProperty?.description ?? - "If true, users cannot specify custom key values. All keys must be auto-generated."} - - - - - - {/* Page Visibility for Internal Users */} - - - )} + + +
+ )} +
); }