Merge pull request #20444 from BerriAI/litellm_ui_config_req_auth_mh

[Feature] UI - Admin Settings: Add option for Authentication for public AI Hub
This commit is contained in:
yuneng-jiang 2026-02-06 12:34:27 -08:00 • committed by GitHub
commit 4de0ed7a9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 435 additions and 126 deletions

View file

@ -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 @@ ALLOWED_UI_SETTINGS_FIELDS = {
"disable_model_add_for_internal_users",
"disable_team_admin_delete_team_user",
"enabled_ui_pages_internal_users",
"require_auth_for_public_ai_hub",
}

View file

@ -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<string, any> = {
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 () => {

View file

@ -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<Record<string, any>>({
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
});

View file

@ -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<typeof import("@/utils/jwtUtils")>();
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);
});
});

View file

@ -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<string, any>;
} 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",

View file

@ -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: () => <div>Public Model Hub</div>,
}));
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(
<ModelHubTable accessToken={null} publicPage={true} premiumUser={false} userRole={null} />
);
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(<ModelHubTable accessToken="test-token" publicPage={false} premiumUser={false} userRole={null} />);
renderWithProviders(<ModelHubTable accessToken="test-token" publicPage={false} premiumUser={false} userRole={null} />);
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(<ModelHubTable accessToken={null} publicPage={true} premiumUser={false} userRole={null} />);
renderWithProviders(<ModelHubTable accessToken={null} publicPage={true} premiumUser={false} userRole={null} />);
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"
);
});
});

View file

@ -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<ModelHubTableProps> = ({ 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) => {

View file

@ -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<Record<string, unknown>>) => ({
data: {
field_schema: {
@ -28,10 +35,18 @@ const buildSettingsResponse = (overrides?: Partial<Record<string, unknown>>) =>
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(<UISettings />);
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(<UISettings />);
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");
});
});

View file

@ -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 (
<Card title="UI Settings">
{isLoading ? (
@ -113,6 +128,22 @@ export default function UISettings() {
</Space>
</Space>
<Space align="start" size="middle">
<Switch
checked={values.require_auth_for_public_ai_hub}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleRequireAuthForPublicAIHub}
aria-label={requireAuthForPublicAIHubProperty?.description ?? "Require authentication for public AI Hub"}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Require authentication for public AI Hub</Typography.Text>
{requireAuthForPublicAIHubProperty?.description && (
<Typography.Text type="secondary">{requireAuthForPublicAIHubProperty.description}</Typography.Text>
)}
</Space>
</Space>
<Divider />
{/* Page Visibility for Internal Users */}

View file

@ -8899,14 +8899,11 @@ export const loginCall = async (username: string, password: string): Promise<Log
return data;
};
export const getUiSettings = async (accessToken: string) => {
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();

View file

@ -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);
});
});
});

View file

@ -12,3 +12,18 @@ export function isJwtExpired(token: string): boolean {
return true;
}
}
export function decodeToken(token: string | null): Record<string, any> | null {
if (!token) return null;
try {
return jwtDecode(token) as Record<string, any>;
} catch {
return null;
}
}
export function checkTokenValidity(token: string | null): boolean {
if (!token) return false;
const decoded = decodeToken(token);
return decoded !== null && !isJwtExpired(token);
}