diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 30ec0766dbf..f626ad7eb14 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -83,6 +83,11 @@ class UISettings(BaseModel): description="List of page keys that internal users (non-admins) can see in the UI sidebar. If not set, all pages are visible based on role permissions.", ) + require_auth_for_public_ai_hub: bool = Field( + default=False, + description="If true, requires authentication for accessing the public AI Hub." + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -95,6 +100,7 @@ class UISettingsResponse(SettingsResponse): "disable_model_add_for_internal_users", "disable_team_admin_delete_team_user", "enabled_ui_pages_internal_users", + "require_auth_for_public_ai_hub", } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.test.ts index 785f003d2f8..0fc3bda27fc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.test.ts @@ -10,12 +10,6 @@ vi.mock("@/components/networking", () => ({ getUiSettings: vi.fn(), })); -// Mock useAuthorized hook - we can override this in individual tests -const mockUseAuthorized = vi.fn(); -vi.mock("../useAuthorized", () => ({ - default: () => mockUseAuthorized(), -})); - // Mock data const mockUISettings: Record = { theme: "dark", @@ -39,18 +33,6 @@ describe("useUISettings", () => { // Reset all mocks vi.clearAllMocks(); - - // Set default mock for useAuthorized (enabled state) - mockUseAuthorized.mockReturnValue({ - accessToken: "test-access-token", - userRole: "Admin", - userId: "test-user-id", - token: "test-token", - userEmail: "test@example.com", - premiumUser: false, - disabledPersonalKeyCreation: null, - showSSOBanner: false, - }); }); const wrapper = ({ children }: { children: ReactNode }) => @@ -74,7 +56,7 @@ describe("useUISettings", () => { expect(result.current.data).toEqual(mockUISettings); expect(result.current.error).toBeNull(); - expect(getUiSettings).toHaveBeenCalledWith("test-access-token"); + expect(getUiSettings).toHaveBeenCalledWith(); expect(getUiSettings).toHaveBeenCalledTimes(1); }); @@ -98,58 +80,10 @@ describe("useUISettings", () => { expect(result.current.error).toEqual(testError); expect(result.current.data).toBeUndefined(); - expect(getUiSettings).toHaveBeenCalledWith("test-access-token"); + expect(getUiSettings).toHaveBeenCalledWith(); expect(getUiSettings).toHaveBeenCalledTimes(1); }); - it("should not execute query when accessToken is missing", async () => { - // Mock missing accessToken - mockUseAuthorized.mockReturnValue({ - accessToken: null, - userRole: "Admin", - userId: "test-user-id", - token: null, - userEmail: "test@example.com", - premiumUser: false, - disabledPersonalKeyCreation: null, - showSSOBanner: false, - }); - - const { result } = renderHook(() => useUISettings(), { wrapper }); - - // Query should not execute - expect(result.current.isLoading).toBe(false); - expect(result.current.data).toBeUndefined(); - expect(result.current.isFetched).toBe(false); - - // API should not be called - expect(getUiSettings).not.toHaveBeenCalled(); - }); - - it("should not execute query when accessToken is empty string", async () => { - // Mock empty accessToken - mockUseAuthorized.mockReturnValue({ - accessToken: "", - userRole: "Admin", - userId: "test-user-id", - token: "", - userEmail: "test@example.com", - premiumUser: false, - disabledPersonalKeyCreation: null, - showSSOBanner: false, - }); - - const { result } = renderHook(() => useUISettings(), { wrapper }); - - // Query should not execute - expect(result.current.isLoading).toBe(false); - expect(result.current.data).toBeUndefined(); - expect(result.current.isFetched).toBe(false); - - // API should not be called - expect(getUiSettings).not.toHaveBeenCalled(); - }); - it("should return empty object when API returns empty settings", async () => { // Mock API returning empty object (getUiSettings as any).mockResolvedValue({}); @@ -163,7 +97,7 @@ describe("useUISettings", () => { }); expect(result.current.data).toEqual({}); - expect(getUiSettings).toHaveBeenCalledWith("test-access-token"); + expect(getUiSettings).toHaveBeenCalledWith(); }); it("should handle network timeout error", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts index 46a0254d0db..14c6c5e3888 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts @@ -1,16 +1,13 @@ import { getUiSettings } from "@/components/networking"; import { useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; -import useAuthorized from "../useAuthorized"; const uiSettingsKeys = createQueryKeys("uiSettings"); export const useUISettings = () => { - const { accessToken } = useAuthorized(); return useQuery>({ queryKey: uiSettingsKeys.list({}), - queryFn: async () => await getUiSettings(accessToken), - enabled: !!accessToken, + queryFn: async () => await getUiSettings(), staleTime: 60 * 60 * 1000, // 1 hour - data rarely changes gcTime: 60 * 60 * 1000, // 1 hour - keep in cache for 1 hour }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts index 78eddbd8d3c..ef4a779b50b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts @@ -8,12 +8,13 @@ import useAuthorized from "./useAuthorized"; // Unmock useAuthorized to test the actual implementation vi.unmock("@/app/(dashboard)/hooks/useAuthorized"); -const { replaceMock, clearTokenCookiesMock, getProxyBaseUrlMock, getUiConfigMock, isJwtExpiredMock } = vi.hoisted(() => ({ +const { replaceMock, clearTokenCookiesMock, getProxyBaseUrlMock, getUiConfigMock, decodeTokenMock, checkTokenValidityMock } = vi.hoisted(() => ({ replaceMock: vi.fn(), clearTokenCookiesMock: vi.fn(), getProxyBaseUrlMock: vi.fn(() => "http://proxy.example"), getUiConfigMock: vi.fn(), - isJwtExpiredMock: vi.fn(), + decodeTokenMock: vi.fn(), + checkTokenValidityMock: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -43,7 +44,8 @@ vi.mock("@/utils/jwtUtils", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - isJwtExpired: isJwtExpiredMock, + decodeToken: decodeTokenMock, + checkTokenValidity: checkTokenValidityMock, }; }); @@ -77,7 +79,8 @@ describe("useAuthorized", () => { clearTokenCookiesMock.mockReset(); getProxyBaseUrlMock.mockClear(); getUiConfigMock.mockReset(); - isJwtExpiredMock.mockReset(); + decodeTokenMock.mockReset(); + checkTokenValidityMock.mockReset(); clearCookie(); }); @@ -88,9 +91,8 @@ describe("useAuthorized", () => { auto_redirect_to_sso: false, admin_ui_disabled: false, }); - isJwtExpiredMock.mockReturnValue(false); - - const token = createJwt({ + + const decodedPayload = { key: "api-key-123", user_id: "user-1", user_email: "user@example.com", @@ -98,7 +100,12 @@ describe("useAuthorized", () => { premium_user: true, disabled_non_admin_personal_key_creation: false, login_method: "username_password", - }); + }; + + decodeTokenMock.mockReturnValue(decodedPayload); + checkTokenValidityMock.mockReturnValue(true); + + const token = createJwt(decodedPayload); document.cookie = `token=${token}; path=/;`; const { result } = renderHook(() => useAuthorized(), { wrapper }); @@ -126,6 +133,9 @@ describe("useAuthorized", () => { admin_ui_disabled: false, }); + decodeTokenMock.mockReturnValue(null); + checkTokenValidityMock.mockReturnValue(false); + document.cookie = "token=invalid-token; path=/;"; const { result } = renderHook(() => useAuthorized(), { wrapper }); @@ -146,9 +156,8 @@ describe("useAuthorized", () => { auto_redirect_to_sso: false, admin_ui_disabled: true, }); - isJwtExpiredMock.mockReturnValue(false); - const token = createJwt({ + const decodedPayload = { key: "api-key-123", user_id: "user-1", user_email: "user@example.com", @@ -156,7 +165,12 @@ describe("useAuthorized", () => { premium_user: true, disabled_non_admin_personal_key_creation: false, login_method: "username_password", - }); + }; + + decodeTokenMock.mockReturnValue(decodedPayload); + checkTokenValidityMock.mockReturnValue(true); + + const token = createJwt(decodedPayload); document.cookie = `token=${token}; path=/;`; const { result } = renderHook(() => useAuthorized(), { wrapper }); @@ -178,6 +192,9 @@ describe("useAuthorized", () => { admin_ui_disabled: false, }); + decodeTokenMock.mockReturnValue(null); + checkTokenValidityMock.mockReturnValue(false); + // No token cookie set const { result } = renderHook(() => useAuthorized(), { wrapper }); @@ -196,14 +213,18 @@ describe("useAuthorized", () => { auto_redirect_to_sso: false, admin_ui_disabled: false, }); - isJwtExpiredMock.mockReturnValue(true); - const token = createJwt({ + const decodedPayload = { key: "api-key-123", user_id: "user-1", user_email: "user@example.com", user_role: "app_admin", - }); + }; + + decodeTokenMock.mockReturnValue(decodedPayload); + checkTokenValidityMock.mockReturnValue(false); + + const token = createJwt(decodedPayload); document.cookie = `token=${token}; path=/;`; const { result } = renderHook(() => useAuthorized(), { wrapper }); @@ -213,6 +234,6 @@ describe("useAuthorized", () => { }); expect(replaceMock).toHaveBeenCalledWith("http://proxy.example/ui/login"); - expect(isJwtExpiredMock).toHaveBeenCalledWith(token); + expect(checkTokenValidityMock).toHaveBeenCalledWith(token); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts index 531a240a371..0b60971c1eb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -2,8 +2,7 @@ import { getProxyBaseUrl } from "@/components/networking"; import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; -import { isJwtExpired } from "@/utils/jwtUtils"; -import { jwtDecode } from "jwt-decode"; +import { checkTokenValidity, decodeToken } from "@/utils/jwtUtils"; import { useRouter } from "next/navigation"; import { useEffect, useMemo } from "react"; import { useUIConfig } from "./uiConfig/useUIConfig"; @@ -43,44 +42,31 @@ const useAuthorized = () => { const token = typeof document !== "undefined" ? getCookie("token") : null; - // Step 1: Check for missing token or expired JWT - kick out immediately (even if UI Config is loading) + const decoded = useMemo(() => decodeToken(token), [token]); + const isTokenValid = useMemo(() => checkTokenValidity(token), [token]); + const isLoading = isUIConfigLoading; + const isAuthorized = isTokenValid && !uiConfig?.admin_ui_disabled; + + // Single useEffect for all redirect logic useEffect(() => { - if (!token || (token && isJwtExpired(token))) { + if (isLoading) return; + + if (!isAuthorized) { if (token) { clearTokenCookies(); } router.replace(`${getProxyBaseUrl()}/ui/login`); } - }, [token, router]); - - useEffect(() => { - if (isUIConfigLoading) { - return; - } - if (uiConfig?.admin_ui_disabled) { - router.replace(`${getProxyBaseUrl()}/ui/login`); - } - }, [router, isUIConfigLoading, uiConfig]); - - // Decode safely - const decoded = useMemo(() => { - if (!token) return null; - try { - return jwtDecode(token) as Record; - } catch { - // Bad token in cookie — clear and bounce - clearTokenCookies(); - router.replace(`${getProxyBaseUrl()}/ui/login`); - return null; - } - }, [token, router]); + }, [isLoading, isAuthorized, token, router]); return { - token: token, + isLoading, + isAuthorized, + token: isAuthorized ? token : null, accessToken: decoded?.key ?? null, userId: decoded?.user_id ?? null, userEmail: decoded?.user_email ?? null, - userRole: formatUserRole(decoded?.user_role ?? null), + userRole: formatUserRole(decoded?.user_role), premiumUser: decoded?.premium_user ?? null, disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null, showSSOBanner: decoded?.login_method === "username_password", diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx index a88ce0d7938..0a5cd17e571 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx @@ -1,8 +1,13 @@ import * as networking from "@/components/networking"; -import { render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; import ModelHubTable from "./ModelHubTable"; +const mockUseUISettings = vi.hoisted(() => vi.fn()); +const mockGetCookie = vi.hoisted(() => vi.fn()); +const mockCheckTokenValidity = vi.hoisted(() => vi.fn()); +const mockRouterReplace = vi.hoisted(() => vi.fn()); + vi.mock("@/components/networking", () => ({ getUiConfig: vi.fn(), modelHubPublicModelsCall: vi.fn(), @@ -11,11 +16,13 @@ vi.mock("@/components/networking", () => ({ getProxyBaseUrl: vi.fn(() => "http://localhost:4000"), getAgentsList: vi.fn(), fetchMCPServers: vi.fn(), + getUiSettings: vi.fn(), + getClaudeCodeMarketplace: vi.fn(), })); vi.mock("next/navigation", () => ({ useRouter: () => ({ - replace: vi.fn(), + replace: mockRouterReplace, }), })); @@ -23,11 +30,81 @@ vi.mock("@/components/public_model_hub", () => ({ default: () =>
Public Model Hub
, })); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: mockUseUISettings, +})); + +vi.mock("@/utils/cookieUtils", () => ({ + getCookie: mockGetCookie, +})); + +vi.mock("@/utils/jwtUtils", () => ({ + checkTokenValidity: mockCheckTokenValidity, +})); + describe("ModelHubTable", () => { afterEach(() => { vi.clearAllMocks(); }); + // Reusable helper function to setup mocks for auth redirect tests + const setupAuthRedirectTest = ( + requireAuth: boolean, + tokenValue: string | null, + isTokenValid: boolean + ) => { + mockUseUISettings.mockReturnValue({ + data: { + values: { + require_auth_for_public_ai_hub: requireAuth, + }, + }, + isLoading: false, + }); + mockGetCookie.mockReturnValue(tokenValue); + mockCheckTokenValidity.mockReturnValue(isTokenValid); + mockRouterReplace.mockClear(); + + // Setup other required mocks + vi.mocked(networking.getUiConfig).mockResolvedValue({ + server_root_path: "/", + proxy_base_url: "http://localhost:4000", + auto_redirect_to_sso: false, + admin_ui_disabled: false, + }); + vi.mocked(networking.modelHubPublicModelsCall).mockResolvedValue([]); + vi.mocked(networking.getUiSettings).mockResolvedValue({ + values: { + require_auth_for_public_ai_hub: requireAuth, + }, + }); + }; + + // Reusable test function for auth redirect scenarios + const testAuthRedirect = ( + requireAuth: boolean, + tokenValue: string | null, + isTokenValid: boolean, + shouldRedirect: boolean, + description: string + ) => { + it(description, async () => { + setupAuthRedirectTest(requireAuth, tokenValue, isTokenValid); + + renderWithProviders( + + ); + + await waitFor(() => { + if (shouldRedirect) { + expect(mockRouterReplace).toHaveBeenCalledWith("http://localhost:4000/ui/login"); + } else { + expect(mockRouterReplace).not.toHaveBeenCalled(); + } + }); + }); + }; + it("should render", async () => { vi.mocked(networking.modelHubCall).mockResolvedValue({ data: [], @@ -39,8 +116,15 @@ describe("ModelHubTable", () => { agents: [], }); vi.mocked(networking.fetchMCPServers).mockResolvedValue([]); + vi.mocked(networking.getUiSettings).mockResolvedValue({ + values: {}, + }); + mockUseUISettings.mockReturnValue({ + data: { values: {} }, + isLoading: false, + }); - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("AI Hub")).toBeInTheDocument(); @@ -58,8 +142,15 @@ describe("ModelHubTable", () => { admin_ui_disabled: false, }); modelHubPublicModelsCallMock.mockResolvedValue([]); + vi.mocked(networking.getUiSettings).mockResolvedValue({ + values: {}, + }); + mockUseUISettings.mockReturnValue({ + data: { values: {} }, + isLoading: false, + }); - render(); + renderWithProviders(); await waitFor(() => { expect(getUiConfigMock).toHaveBeenCalled(); @@ -71,4 +162,56 @@ describe("ModelHubTable", () => { expect(getUiConfigCallOrder).toBeLessThan(modelHubPublicModelsCallOrder); }); + + describe("authentication redirect behavior", () => { + // Test cases where requireAuth is true - should redirect on invalid tokens + testAuthRedirect( + true, + null, + false, + true, + "should redirect to login when requireAuth is true and there is no token" + ); + + testAuthRedirect( + true, + "expired-token", + false, + true, + "should redirect to login when requireAuth is true and token is expired" + ); + + testAuthRedirect( + true, + "malformed-token", + false, + true, + "should redirect to login when requireAuth is true and token is malformed" + ); + + // Test cases where requireAuth is false - should NOT redirect regardless of token state + testAuthRedirect( + false, + null, + false, + false, + "should not redirect when requireAuth is false and there is no token" + ); + + testAuthRedirect( + false, + "expired-token", + false, + false, + "should not redirect when requireAuth is false and token is expired" + ); + + testAuthRedirect( + false, + "malformed-token", + false, + false, + "should not redirect when requireAuth is false and token is malformed" + ); + }); }); diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 4843713e5a6..71b84e281df 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -27,6 +27,9 @@ import { Copy } from "lucide-react"; import { useRouter } from "next/navigation"; import React, { useCallback, useEffect, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import { checkTokenValidity } from "@/utils/jwtUtils"; +import { getCookie } from "@/utils/cookieUtils"; interface ModelHubTableProps { accessToken: string | null; @@ -76,6 +79,30 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, const [isMcpModalVisible, setIsMcpModalVisible] = useState(false); const [isMakeMcpPublicModalVisible, setIsMakeMcpPublicModalVisible] = useState(false); const router = useRouter(); + const { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings(); + + // Check authentication requirement for public AI Hub + useEffect(() => { + // Only check when UI settings are loaded and this is a public page + if (isUISettingsLoading || !publicPage) { + return; + } + + const requireAuth = uiSettings?.values?.require_auth_for_public_ai_hub; + + // If require_auth_for_public_ai_hub is true, verify token + if (requireAuth === true) { + const token = getCookie("token"); + const isTokenValid = checkTokenValidity(token); + + // If token is invalid, redirect to login + if (!isTokenValid) { + router.replace(`${getProxyBaseUrl()}/ui/login`); + return; + } + } + // If require_auth_for_public_ai_hub is false, allow public access (no change) + }, [isUISettingsLoading, publicPage, uiSettings, router]); useEffect(() => { const fetchData = async (accessToken: string) => { diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx index 31dcfc102ec..639564bbd35 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx @@ -20,6 +20,13 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings", () => ({ useUpdateUISettings: mockUseUpdateUISettings, })); +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { + success: vi.fn(), + fromBackend: vi.fn(), + }, +})); + const buildSettingsResponse = (overrides?: Partial>) => ({ data: { field_schema: { @@ -28,10 +35,18 @@ const buildSettingsResponse = (overrides?: Partial>) => disable_model_add_for_internal_users: { description: "Disable model add for internal users", }, + disable_team_admin_delete_team_user: { + description: "Disable team admin delete team user", + }, + require_auth_for_public_ai_hub: { + description: "Require authentication for public AI Hub", + }, }, }, values: { disable_model_add_for_internal_users: false, + disable_team_admin_delete_team_user: false, + require_auth_for_public_ai_hub: false, }, }, isLoading: false, @@ -57,6 +72,8 @@ describe("UISettings", () => { expect(screen.getByText("UI Settings")).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Disable model add for internal users" })).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Disable team admin delete team user" })).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Require authentication for public AI Hub" })).toBeInTheDocument(); }); it("should toggle setting and call update", () => { @@ -87,4 +104,62 @@ describe("UISettings", () => { ); expect(NotificationManager.success).toHaveBeenCalledWith("UI settings updated successfully"); }); + + it("should toggle disable team admin delete team user setting and call update", () => { + const mutateMock = vi.fn((_settings, options) => { + options?.onSuccess?.(); + }); + + mockUseUpdateUISettings.mockReturnValue({ + mutate: mutateMock, + isPending: false, + error: null, + }); + + render(); + + const toggle = screen.getByRole("switch", { name: "Disable team admin delete team user" }); + + act(() => { + fireEvent.click(toggle); + }); + + expect(mutateMock).toHaveBeenCalledWith( + { disable_team_admin_delete_team_user: true }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + expect(NotificationManager.success).toHaveBeenCalledWith("UI settings updated successfully"); + }); + + it("should toggle require auth for public AI Hub setting and call update", () => { + const mutateMock = vi.fn((_settings, options) => { + options?.onSuccess?.(); + }); + + mockUseUpdateUISettings.mockReturnValue({ + mutate: mutateMock, + isPending: false, + error: null, + }); + + render(); + + const toggle = screen.getByRole("switch", { name: "Require authentication for public AI Hub" }); + + act(() => { + fireEvent.click(toggle); + }); + + expect(mutateMock).toHaveBeenCalledWith( + { require_auth_for_public_ai_hub: true }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + expect(NotificationManager.success).toHaveBeenCalledWith("UI settings updated successfully"); + }); }); 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 6cc9cbf4309..a43f0e9d42d 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -15,6 +15,7 @@ export default function UISettings() { const schema = data?.field_schema; const property = schema?.properties?.disable_model_add_for_internal_users; const disableTeamAdminDeleteProperty = schema?.properties?.disable_team_admin_delete_team_user; + const requireAuthForPublicAIHubProperty = schema?.properties?.require_auth_for_public_ai_hub; const enabledPagesProperty = schema?.properties?.enabled_ui_pages_internal_users; const values = data?.values ?? {}; const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); @@ -59,6 +60,20 @@ export default function UISettings() { }); }; + const handleToggleRequireAuthForPublicAIHub = (checked: boolean) => { + updateSettings( + { require_auth_for_public_ai_hub: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + return ( {isLoading ? ( @@ -113,6 +128,22 @@ export default function UISettings() { + + + + Require authentication for public AI Hub + {requireAuthForPublicAIHubProperty?.description && ( + {requireAuthForPublicAIHubProperty.description} + )} + + + {/* Page Visibility for Internal Users */} diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index ca4c16a781f..d5447e0d134 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -8736,14 +8736,11 @@ export const loginCall = async (username: string, password: string): Promise { +export const getUiSettings = async () => { const proxyBaseUrl = getProxyBaseUrl(); const url = proxyBaseUrl ? `${proxyBaseUrl}/get/ui_settings` : `/get/ui_settings`; const response = await fetch(url, { method: "GET", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - }, }); if (!response.ok) { const errorData = await response.json(); diff --git a/ui/litellm-dashboard/src/utils/jwtUtils.test.ts b/ui/litellm-dashboard/src/utils/jwtUtils.test.ts index d695a3c37bb..bad8d4f6653 100644 --- a/ui/litellm-dashboard/src/utils/jwtUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/jwtUtils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { isJwtExpired } from "./jwtUtils"; +import { isJwtExpired, decodeToken, checkTokenValidity } from "./jwtUtils"; import { jwtDecode } from "jwt-decode"; vi.mock("jwt-decode"); @@ -50,4 +50,81 @@ describe("jwtUtils", () => { expect(isJwtExpired("invalid-token")).toBe(true); }); + + describe("decodeToken", () => { + it("should return null if token is null", () => { + expect(decodeToken(null)).toBeNull(); + }); + + it("should return null if token is empty string", () => { + expect(decodeToken("")).toBeNull(); + }); + + it("should decode a valid token", () => { + const mockPayload = { + key: "api-key-123", + user_id: "user-1", + user_email: "user@example.com", + user_role: "app_admin", + }; + vi.mocked(jwtDecode).mockReturnValue(mockPayload); + + expect(decodeToken("valid-token")).toEqual(mockPayload); + expect(jwtDecode).toHaveBeenCalledWith("valid-token"); + }); + + it("should return null if jwtDecode throws an error", () => { + vi.mocked(jwtDecode).mockImplementation(() => { + throw new Error("Invalid token"); + }); + + expect(decodeToken("invalid-token")).toBeNull(); + }); + }); + + describe("checkTokenValidity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return false if token is null", () => { + expect(checkTokenValidity(null)).toBe(false); + }); + + it("should return false if token is empty string", () => { + expect(checkTokenValidity("")).toBe(false); + }); + + it("should return true for a valid, non-expired token", () => { + const mockDateNow = 1716838401000; + vi.spyOn(Date, "now").mockReturnValue(mockDateNow); + const mockPayload = { + exp: Math.floor(mockDateNow / 1000) + 1000, + user_id: "user-1", + }; + vi.mocked(jwtDecode).mockReturnValue(mockPayload); + + expect(checkTokenValidity("valid-token")).toBe(true); + }); + + it("should return false for an expired token", () => { + const mockDateNow = 1716838401000; + vi.spyOn(Date, "now").mockReturnValue(mockDateNow); + const mockPayload = { + exp: Math.floor(mockDateNow / 1000) - 1, + user_id: "user-1", + }; + vi.mocked(jwtDecode).mockReturnValue(mockPayload); + + expect(checkTokenValidity("expired-token")).toBe(false); + }); + + it("should return false if token cannot be decoded", () => { + vi.mocked(jwtDecode).mockImplementation(() => { + throw new Error("Invalid token"); + }); + + expect(checkTokenValidity("invalid-token")).toBe(false); + }); + }); }); diff --git a/ui/litellm-dashboard/src/utils/jwtUtils.ts b/ui/litellm-dashboard/src/utils/jwtUtils.ts index d3db41a411a..2a7972ad4cc 100644 --- a/ui/litellm-dashboard/src/utils/jwtUtils.ts +++ b/ui/litellm-dashboard/src/utils/jwtUtils.ts @@ -12,3 +12,18 @@ export function isJwtExpired(token: string): boolean { return true; } } + +export function decodeToken(token: string | null): Record | null { + if (!token) return null; + try { + return jwtDecode(token) as Record; + } catch { + return null; + } +} + +export function checkTokenValidity(token: string | null): boolean { + if (!token) return false; + const decoded = decodeToken(token); + return decoded !== null && !isJwtExpired(token); +}