mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
commit
4de0ed7a9e
12 changed files with 435 additions and 126 deletions
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue