element
const link = screen.getByRole("link", { name: "Back to Login" });
- expect(link).toHaveAttribute("href", "/ui/login");
+ expect(link).toHaveAttribute("href", "/ui/login/");
});
});
diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx
index ca0f57c56ce..3de9a9ffaae 100644
--- a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx
+++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { Alert, Button } from "antd";
+import { getLoginUrl } from "@/utils/returnUrlUtils";
export function OnboardingErrorView() {
return (
@@ -11,7 +12,7 @@ export function OnboardingErrorView() {
showIcon
/>
-
+
);
diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx
index 3a22a55298e..dfe307c7edf 100644
--- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx
+++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx
@@ -1,5 +1,5 @@
import * as networking from "@/components/networking";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import ModelHubTable from "./ModelHubTable";
@@ -7,6 +7,7 @@ const mockUseUISettings = vi.hoisted(() => vi.fn());
const mockGetCookie = vi.hoisted(() => vi.fn());
const mockCheckTokenValidity = vi.hoisted(() => vi.fn());
const mockRouterReplace = vi.hoisted(() => vi.fn());
+const mockLocationReplace = vi.hoisted(() => vi.fn());
vi.mock("@/components/networking", () => ({
getUiConfig: vi.fn(),
@@ -43,7 +44,28 @@ vi.mock("@/utils/jwtUtils", () => ({
}));
describe("ModelHubTable", () => {
+ const originalLocation = window.location;
+
+ beforeEach(() => {
+ Object.defineProperty(window, "location", {
+ value: {
+ href: "http://localhost:4000/ui/model_hub_table",
+ origin: "http://localhost:4000",
+ hostname: "localhost",
+ pathname: "/ui/model_hub_table",
+ search: "",
+ protocol: "http:",
+ replace: mockLocationReplace,
+ },
+ writable: true,
+ });
+ });
+
afterEach(() => {
+ Object.defineProperty(window, "location", {
+ value: originalLocation,
+ writable: true,
+ });
vi.clearAllMocks();
});
@@ -60,6 +82,7 @@ describe("ModelHubTable", () => {
mockGetCookie.mockReturnValue(tokenValue);
mockCheckTokenValidity.mockReturnValue(isTokenValid);
mockRouterReplace.mockClear();
+ mockLocationReplace.mockClear();
// Setup other required mocks
vi.mocked(networking.getUiConfig).mockResolvedValue({
@@ -92,9 +115,10 @@ describe("ModelHubTable", () => {
await waitFor(() => {
if (shouldRedirect) {
- expect(mockRouterReplace).toHaveBeenCalledWith("http://localhost:4000/ui/login");
- } else {
+ expect(mockLocationReplace).toHaveBeenCalledWith("http://localhost:4000/ui/login/");
expect(mockRouterReplace).not.toHaveBeenCalled();
+ } else {
+ expect(mockLocationReplace).not.toHaveBeenCalled();
}
});
});
diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx
index eb47f775d90..1f64d175052 100644
--- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx
+++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx
@@ -33,6 +33,7 @@ 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";
+import { getLoginUrl } from "@/utils/returnUrlUtils";
interface ModelHubTableProps {
accessToken: string | null;
@@ -108,12 +109,12 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage,
// If token is invalid, redirect to login
if (!isTokenValid) {
- router.replace(`${getProxyBaseUrl()}/ui/login`);
+ window.location.replace(getLoginUrl(getProxyBaseUrl()));
return;
}
}
// If require_auth_for_public_ai_hub is false, allow public access (no change)
- }, [isUISettingsLoading, publicPage, uiSettings, router]);
+ }, [isUISettingsLoading, publicPage, uiSettings]);
useEffect(() => {
const fetchData = async (accessToken: string) => {
diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx
index 8b928f8e6fe..babf007d475 100644
--- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx
+++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx
@@ -18,7 +18,7 @@ import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown";
import { useWorker } from "@/hooks/useWorker";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import { clearTokenCookies } from "@/utils/cookieUtils";
-import { clearStoredReturnUrl } from "@/utils/returnUrlUtils";
+import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils";
interface DashboardHeaderProps {
page: string;
@@ -37,7 +37,7 @@ export function DashboardHeader({ page }: DashboardHeaderProps) {
clearStoredReturnUrl();
localStorage.removeItem("litellm_selected_worker_id");
localStorage.removeItem("litellm_worker_url");
- window.location.href = `/ui/login?worker=${encodeURIComponent(workerId)}`;
+ window.location.href = `${getLoginUrl()}?worker=${encodeURIComponent(workerId)}`;
};
return (
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
index 04766aad7b4..dd7ed2bfbe4 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
@@ -81,6 +81,7 @@ export const getOAuthAuthorizationIdentity = (values: Record):
client_id: credentials.client_id ?? null,
client_secret: credentials.client_secret ?? null,
scopes: credentials.scopes ?? null,
+ issuer: values.issuer ?? null,
authorization_url: values.authorization_url ?? null,
token_url: values.token_url ?? null,
registration_url: values.registration_url ?? null,
@@ -341,6 +342,7 @@ export interface MCPServer {
transport?: string | null;
auth_type?: string | null;
oauth2_flow?: string | null;
+ issuer?: string | null;
authorization_url?: string | null;
token_url?: string | null;
registration_url?: string | null;
diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx
index 1a0b1c38520..40638e7b8ba 100644
--- a/ui/litellm-dashboard/src/components/navbar.tsx
+++ b/ui/litellm-dashboard/src/components/navbar.tsx
@@ -5,7 +5,7 @@ import { useWorker } from "@/hooks/useWorker";
import { getProxyBaseUrl } from "@/components/networking";
import { useTheme } from "@/contexts/ThemeContext";
import { clearTokenCookies } from "@/utils/cookieUtils";
-import { clearStoredReturnUrl } from "@/utils/returnUrlUtils";
+import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
import { DownOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
import { Tag } from "antd";
@@ -56,7 +56,7 @@ const Navbar: React.FC = ({
clearStoredReturnUrl();
localStorage.removeItem("litellm_selected_worker_id");
localStorage.removeItem("litellm_worker_url");
- window.location.href = `/ui/login?worker=${encodeURIComponent(workerId)}`;
+ window.location.href = `${getLoginUrl()}?worker=${encodeURIComponent(workerId)}`;
};
return (
diff --git a/ui/litellm-dashboard/src/utils/returnUrlUtils.test.ts b/ui/litellm-dashboard/src/utils/returnUrlUtils.test.ts
index 56de299049c..0f3a8b4bf69 100644
--- a/ui/litellm-dashboard/src/utils/returnUrlUtils.test.ts
+++ b/ui/litellm-dashboard/src/utils/returnUrlUtils.test.ts
@@ -3,6 +3,7 @@ import {
clearStoredReturnUrl,
consumeReturnUrl,
getCurrentUrl,
+ getLoginUrl,
getReturnUrl,
getReturnUrlFromParams,
getStoredReturnUrl,
@@ -98,6 +99,29 @@ describe("returnUrlUtils", () => {
});
});
+ describe("getLoginUrl", () => {
+ it("should build a relative login URL with a trailing slash", () => {
+ expect(getLoginUrl()).toBe("/ui/login/");
+ });
+
+ it("should prepend the given base URL and keep the trailing slash", () => {
+ expect(getLoginUrl("http://proxy.example")).toBe("http://proxy.example/ui/login/");
+ });
+
+ it("should keep the trailing slash before the query when composed with buildLoginUrlWithReturn", () => {
+ Object.defineProperty(window, "location", {
+ value: {
+ ...window.location,
+ href: "http://localhost:3000/ui?page=api-keys",
+ },
+ writable: true,
+ });
+
+ const loginUrl = buildLoginUrlWithReturn(getLoginUrl());
+ expect(loginUrl).toBe("/ui/login/?redirect_to=http%3A%2F%2Flocalhost%3A3000%2Fui%3Fpage%3Dapi-keys");
+ });
+ });
+
describe("buildLoginUrlWithReturn", () => {
it("should build login URL with return URL parameter", () => {
Object.defineProperty(window, "location", {
diff --git a/ui/litellm-dashboard/src/utils/returnUrlUtils.ts b/ui/litellm-dashboard/src/utils/returnUrlUtils.ts
index 76562a0122a..b3cc5345bb1 100644
--- a/ui/litellm-dashboard/src/utils/returnUrlUtils.ts
+++ b/ui/litellm-dashboard/src/utils/returnUrlUtils.ts
@@ -13,6 +13,10 @@
const RETURN_URL_COOKIE_NAME = "litellm_return_url";
const RETURN_URL_PARAM = "redirect_to";
+export function getLoginUrl(baseUrl: string = ""): string {
+ return `${baseUrl}/ui/login/`;
+}
+
/**
* Gets the current URL with all query parameters.
* Returns null if running on server-side.
diff --git a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx
index 62346eff057..211a754dad4 100644
--- a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx
+++ b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx
@@ -232,7 +232,7 @@ describe("CreateKeyPage auth behavior", () => {
// Assert: we eventually redirect to SSO login with return URL (single replace, not assign/href)
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith(
- expect.stringContaining("https://example.com/ui/login?redirect_to="),
+ expect.stringContaining("https://example.com/ui/login/?redirect_to="),
);
});