Merge pull request #17451 from BerriAI/litellm_new_login_page_sso_changes

[Feature] Add Auto Redirect to SSO to New Login Page
This commit is contained in:
yuneng-jiang
2025-12-03 23:53:21 -08:00
committed by GitHub
14 changed files with 438 additions and 188 deletions

View File

@@ -3496,8 +3496,13 @@ jobs:
command: | command: |
npx playwright test e2e_ui_tests/ --reporter=html --output=test-results npx playwright test e2e_ui_tests/ --reporter=html --output=test-results
no_output_timeout: 120m no_output_timeout: 120m
- store_test_results: - store_artifacts:
path: test-results path: test-results
destination: playwright-results
- store_artifacts:
path: playwright-report
destination: playwright-report
test_nonroot_image: test_nonroot_image:
machine: machine:

View File

@@ -556,10 +556,11 @@ else:
global_max_parallel_request_retry_timeout_env global_max_parallel_request_retry_timeout_env
) )
ui_link = f"{server_root_path}/ui/" ui_link = f"{server_root_path}/ui"
fallback_login_link = f"{server_root_path}/fallback/login"
model_hub_link = f"{server_root_path}/ui/model_hub_table" model_hub_link = f"{server_root_path}/ui/model_hub_table"
ui_message = ( ui_message = (
f"👉 [```LiteLLM Admin Panel on /ui```]({ui_link}). Create, Edit Keys with SSO" f"👉 [```LiteLLM Admin Panel on /ui```]({ui_link}). Create, Edit Keys with SSO. Having issues? Try [```Fallback Login```]({fallback_login_link})"
) )
ui_message += "\n\n💸 [```LiteLLM Model Cost Map```](https://models.litellm.ai/)." ui_message += "\n\n💸 [```LiteLLM Model Cost Map```](https://models.litellm.ai/)."

View File

@@ -11,15 +11,20 @@ import { test, expect } from "@playwright/test";
test("admin login test", async ({ page }) => { test("admin login test", async ({ page }) => {
// Go to the specified URL // Go to the specified URL
await page.goto("http://localhost:4000/ui"); await page.goto("http://localhost:4000/ui");
await page.waitForLoadState("networkidle");
await page.screenshot({ path: "test-results/login_before.png" });
// Enter "admin" in the username input field // Enter "admin" in the username input field
await page.fill('input[name="username"]', "admin"); await page.fill('input[placeholder="Enter your username"]', "admin");
// Enter "gm" in the password input field // Enter "gm" in the password input field
await page.fill('input[name="password"]', "gm"); await page.fill('input[placeholder="Enter your password"]', "gm");
page.screenshot({ path: "test-results/login_after_inputs.png" });
// Optionally, you can add an assertion to verify the login button is enabled // Optionally, you can add an assertion to verify the login button is enabled
const loginButton = page.locator('input[type="submit"]'); const loginButton = page.getByRole("button", { name: "Login" });
await expect(loginButton).toBeEnabled(); await expect(loginButton).toBeEnabled();
// Optionally, you can click the login button to submit the form // Optionally, you can click the login button to submit the form

View File

@@ -2,15 +2,19 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
test.describe("Authentication Checks", () => { test.describe("Authentication Checks", () => {
test("should redirect unauthenticated user from a protected page", async ({ page }) => { test("should redirect unauthenticated user from a protected page", async ({
page,
}) => {
test.setTimeout(30000); test.setTimeout(30000);
page.on("console", (msg) => console.log("PAGE LOG:", msg.text())); page.on("console", (msg) => console.log("PAGE LOG:", msg.text()));
const protectedPageUrl = "http://localhost:4000/ui?page=llm-playground"; const protectedPageUrl = "http://localhost:4000/ui?page=llm-playground";
const expectedRedirectUrl = "http://localhost:4000/sso/key/generate"; const expectedRedirectUrl = "http://localhost:4000/ui/login/";
console.log(`Attempting to navigate to protected page: ${protectedPageUrl}`); console.log(
`Attempting to navigate to protected page: ${protectedPageUrl}`
);
await page.goto(protectedPageUrl); await page.goto(protectedPageUrl);
@@ -20,7 +24,9 @@ test.describe("Authentication Checks", () => {
await page.waitForURL(expectedRedirectUrl, { timeout: 10000 }); await page.waitForURL(expectedRedirectUrl, { timeout: 10000 });
console.log(`Waited for URL. Current URL is now: ${page.url()}`); console.log(`Waited for URL. Current URL is now: ${page.url()}`);
} catch (error) { } catch (error) {
console.error(`Timeout waiting for URL: ${expectedRedirectUrl}. Current URL: ${page.url()}`); console.error(
`Timeout waiting for URL: ${expectedRedirectUrl}. Current URL: ${page.url()}`
);
await page.screenshot({ path: "redirect-fail-screenshot.png" }); await page.screenshot({ path: "redirect-fail-screenshot.png" });
throw error; throw error;
} }

View File

@@ -21,17 +21,22 @@ test("user search test", async ({ page }) => {
// Login first // Login first
await page.goto("http://localhost:4000/ui"); await page.goto("http://localhost:4000/ui");
await page.waitForLoadState("networkidle");
console.log("Navigated to login page"); console.log("Navigated to login page");
page.screenshot({ path: "test-results/search_users_before_login.png" });
// Wait for login form to be visible // Wait for login form to be visible
await page.waitForSelector('input[name="username"]', { timeout: 10000 }); await page.waitForSelector('input[placeholder="Enter your username"]', {
timeout: 10000,
});
console.log("Login form is visible"); console.log("Login form is visible");
await page.fill('input[name="username"]', "admin"); await page.fill('input[placeholder="Enter your username"]', "admin");
await page.fill('input[name="password"]', "gm"); await page.fill('input[placeholder="Enter your password"]', "gm");
console.log("Filled login credentials"); console.log("Filled login credentials");
const loginButton = page.locator('input[type="submit"]'); const loginButton = page.getByRole("button", { name: "Login" });
await expect(loginButton).toBeEnabled(); await expect(loginButton).toBeEnabled();
await loginButton.click(); await loginButton.click();
console.log("Clicked login button"); console.log("Clicked login button");
@@ -128,17 +133,20 @@ test("user filter test", async ({ page }) => {
// Login first // Login first
await page.goto("http://localhost:4000/ui"); await page.goto("http://localhost:4000/ui");
await page.waitForLoadState("networkidle");
console.log("Navigated to login page"); console.log("Navigated to login page");
// Wait for login form to be visible // Wait for login form to be visible
await page.waitForSelector('input[name="username"]', { timeout: 10000 }); await page.waitForSelector('input[placeholder="Enter your username"]', {
timeout: 10000,
});
console.log("Login form is visible"); console.log("Login form is visible");
await page.fill('input[name="username"]', "admin"); await page.fill('input[placeholder="Enter your username"]', "admin");
await page.fill('input[name="password"]', "gm"); await page.fill('input[placeholder="Enter your password"]', "gm");
console.log("Filled login credentials"); console.log("Filled login credentials");
const loginButton = page.locator('input[type="submit"]'); const loginButton = page.getByRole("button", { name: "Login" });
await expect(loginButton).toBeEnabled(); await expect(loginButton).toBeEnabled();
await loginButton.click(); await loginButton.click();
console.log("Clicked login button"); console.log("Clicked login button");

View File

@@ -7,15 +7,18 @@ import { test, expect } from "@playwright/test";
test("view internal user page", async ({ page }) => { test("view internal user page", async ({ page }) => {
// Go to the specified URL // Go to the specified URL
await page.goto("http://localhost:4000/ui"); await page.goto("http://localhost:4000/ui");
await page.waitForLoadState("networkidle");
page.screenshot({ path: "test-results/view_internal_user_before_login.png" });
// Enter "admin" in the username input field // Enter "admin" in the username input field
await page.fill('input[name="username"]', "admin"); await page.fill('input[placeholder="Enter your username"]', "admin");
// Enter "gm" in the password input field // Enter "gm" in the password input field
await page.fill('input[name="password"]', "gm"); await page.fill('input[placeholder="Enter your password"]', "gm");
// Click the login button // Click the login button
const loginButton = page.locator('input[type="submit"]'); const loginButton = page.getByRole("button", { name: "Login" });
await expect(loginButton).toBeEnabled(); await expect(loginButton).toBeEnabled();
await loginButton.click(); await loginButton.click();
@@ -35,9 +38,9 @@ test("view internal user page", async ({ page }) => {
const rowCount = await page.locator("tbody tr").count(); const rowCount = await page.locator("tbody tr").count();
expect(rowCount).toBeGreaterThan(0); expect(rowCount).toBeGreaterThan(0);
// Verify table headers are present (including Virtual Keys column) const userIdHeader = page.locator("th", { hasText: "User ID" });
const virtualKeysHeader = page.locator("th", { hasText: "Virtual Keys" }); page.screenshot({ path: "user_id_header.png" });
await expect(virtualKeysHeader).toBeVisible(); await expect(userIdHeader).toBeVisible();
// test pagination // test pagination
// Wait for pagination controls to be visible // Wait for pagination controls to be visible

View File

@@ -2,22 +2,56 @@ import { test, expect } from "@playwright/test";
import { loginToUI } from "../utils/login"; import { loginToUI } from "../utils/login";
test.describe("User Info View", () => { test.describe("User Info View", () => {
test.beforeEach(async ({ page }) => {
await loginToUI(page);
// Navigate to users page
await page.goto("http://localhost:4000/ui?page=users");
});
test("should display user info when clicking on user ID", async ({ test("should display user info when clicking on user ID", async ({
page, page,
}) => { }) => {
await page.goto("http://localhost:4000/ui");
await page.waitForLoadState("networkidle");
page.screenshot({
path: "test-results/view_user_info_before_login.png",
});
// Enter "admin" in the username input field
await page.fill('input[placeholder="Enter your username"]', "admin");
page.screenshot({
path: "test-results/view_user_info_after_username_input.png",
});
// Enter "gm" in the password input field
await page.fill('input[placeholder="Enter your password"]', "gm");
page.screenshot({
path: "test-results/view_user_info_after_password_input.png",
});
// Click the login button
const loginButton = page.getByRole("button", { name: "Login" });
await expect(loginButton).toBeEnabled();
await loginButton.click();
page.screenshot({
path: "test-results/view_user_info_after_login_button_click.png",
});
// Wait for navigation to complete and dashboard to load
await page.waitForLoadState("networkidle");
const tabElement = page.locator("span.ant-menu-title-content", {
hasText: "Internal User",
});
await tabElement.click();
page.screenshot({
path: "test-results/view_user_info_after_internal_user_tab_click.png",
});
// Wait for loading state to disappear // Wait for loading state to disappear
await page.waitForSelector('text="🚅 Loading users..."', { await page.waitForSelector('text="🚅 Loading users..."', {
state: "hidden", state: "hidden",
timeout: 10000,
}); });
page.screenshot({ path: "test-results/view_user_info_after_loading.png" });
// Wait for users table to load // Wait for users table to load
await page.waitForSelector("table"); await page.waitForSelector("table");
page.screenshot({
path: "test-results/view_user_info_after_table_load.png",
});
// Get the first user ID cell // Get the first user ID cell
const firstUserIdCell = page.locator( const firstUserIdCell = page.locator(
"table tbody tr:first-child td:first-child" "table tbody tr:first-child td:first-child"
@@ -27,6 +61,7 @@ test.describe("User Info View", () => {
// Click on the user ID // Click on the user ID
await firstUserIdCell.click(); await firstUserIdCell.click();
await page.waitForLoadState("networkidle");
// Check for tabs // Check for tabs
await expect(page.locator('button:has-text("Overview")')).toBeVisible({ await expect(page.locator('button:has-text("Overview")')).toBeVisible({

View File

@@ -3,17 +3,21 @@ import { Page, expect } from "@playwright/test";
export async function loginToUI(page: Page) { export async function loginToUI(page: Page) {
// Login first // Login first
await page.goto("http://localhost:4000/ui"); await page.goto("http://localhost:4000/ui");
await page.waitForLoadState("networkidle");
console.log("Navigated to login page"); console.log("Navigated to login page");
page.screenshot({ path: "test-results/login_utils_before.png" });
// Wait for login form to be visible // Wait for login form to be visible
await page.waitForSelector('input[name="username"]', { timeout: 10000 }); await page.waitForSelector('input[placeholder="Enter your username"]', {
timeout: 10000,
});
console.log("Login form is visible"); console.log("Login form is visible");
await page.fill('input[name="username"]', "admin"); await page.fill('input[placeholder="Enter your username"]', "admin");
await page.fill('input[name="password"]', "gm"); await page.fill('input[placeholder="Enter your password"]', "gm");
console.log("Filled login credentials"); console.log("Filled login credentials");
const loginButton = page.locator('input[type="submit"]'); const loginButton = page.getByRole("button", { name: "Login" });
await expect(loginButton).toBeEnabled(); await expect(loginButton).toBeEnabled();
await loginButton.click(); await loginButton.click();
console.log("Clicked login button"); console.log("Clicked login button");

View File

@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import LoginPage from "./LoginPage";
const mockPush = vi.fn();
const mockReplace = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({
push: mockPush,
replace: mockReplace,
})),
}));
vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => ({
useUIConfig: vi.fn(),
}));
vi.mock("@/utils/cookieUtils", () => ({
getCookie: vi.fn(),
}));
vi.mock("@/utils/jwtUtils", () => ({
isJwtExpired: vi.fn(),
}));
vi.mock("@/components/networking", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/components/networking")>();
return {
...actual,
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"),
};
});
vi.mock("@/app/(dashboard)/hooks/login/useLogin", () => ({
useLogin: vi.fn(() => ({
mutate: vi.fn(),
isPending: false,
error: null,
})),
}));
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
import { getCookie } from "@/utils/cookieUtils";
import { isJwtExpired } from "@/utils/jwtUtils";
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
describe("LoginPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockPush.mockClear();
mockReplace.mockClear();
});
it("should render", async () => {
(useUIConfig as ReturnType<typeof vi.fn>).mockReturnValue({
data: { auto_redirect_to_sso: false, server_root_path: "/", proxy_base_url: null },
isLoading: false,
});
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(null);
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<LoginPage />
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Login" })).toBeInTheDocument();
});
});
it("should call router.replace to dashboard when jwt is valid", async () => {
const validToken = "valid-token";
(useUIConfig as ReturnType<typeof vi.fn>).mockReturnValue({
data: { auto_redirect_to_sso: false, server_root_path: "/", proxy_base_url: null },
isLoading: false,
});
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(validToken);
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(false);
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<LoginPage />
</QueryClientProvider>,
);
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith("http://localhost:4000/ui");
});
});
it("should call router.push to SSO when jwt is invalid and auto_redirect_to_sso is true", async () => {
const invalidToken = "invalid-token";
(useUIConfig as ReturnType<typeof vi.fn>).mockReturnValue({
data: { auto_redirect_to_sso: true, server_root_path: "/", proxy_base_url: null },
isLoading: false,
});
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(invalidToken);
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(true);
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<LoginPage />
</QueryClientProvider>,
);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("http://localhost:4000/sso/key/generate");
});
});
it("should not call router when jwt is invalid and auto_redirect_to_sso is false", async () => {
const invalidToken = "invalid-token";
(useUIConfig as ReturnType<typeof vi.fn>).mockReturnValue({
data: { auto_redirect_to_sso: false, server_root_path: "/", proxy_base_url: null },
isLoading: false,
});
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(invalidToken);
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(true);
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<LoginPage />
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Login" })).toBeInTheDocument();
});
expect(mockPush).not.toHaveBeenCalled();
expect(mockReplace).not.toHaveBeenCalled();
});
it("should send user to dashboard when jwt is valid even if auto_redirect_to_sso is true", async () => {
const validToken = "valid-token";
(useUIConfig as ReturnType<typeof vi.fn>).mockReturnValue({
data: { auto_redirect_to_sso: true, server_root_path: "/", proxy_base_url: null },
isLoading: false,
});
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(validToken);
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(false);
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<LoginPage />
</QueryClientProvider>,
);
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith("http://localhost:4000/ui");
});
expect(mockPush).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,159 @@
"use client";
import { useLogin } from "@/app/(dashboard)/hooks/login/useLogin";
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import { getProxyBaseUrl } from "@/components/networking";
import { getCookie } from "@/utils/cookieUtils";
import { isJwtExpired } from "@/utils/jwtUtils";
import { InfoCircleOutlined } from "@ant-design/icons";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Alert, Button, Card, Form, Input, Space, Typography } from "antd";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
function LoginPageContent() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(true);
const { data: uiConfig, isLoading: isConfigLoading } = useUIConfig();
const loginMutation = useLogin();
const router = useRouter();
useEffect(() => {
if (isConfigLoading) {
return;
}
const rawToken = getCookie("token");
if (rawToken && !isJwtExpired(rawToken)) {
router.replace(`${getProxyBaseUrl()}/ui`);
return;
}
if (uiConfig && uiConfig.auto_redirect_to_sso) {
router.push(`${getProxyBaseUrl()}/sso/key/generate`);
return;
}
setIsLoading(false);
}, [isConfigLoading, router, uiConfig]);
const handleSubmit = () => {
loginMutation.mutate(
{ username, password },
{
onSuccess: (data) => {
router.push(data.redirect_url);
},
},
);
};
const error = loginMutation.error instanceof Error ? loginMutation.error.message : null;
const isLoginLoading = loginMutation.isPending;
const { Title, Text, Paragraph } = Typography;
if (isConfigLoading || isLoading) {
return <LoadingScreen />;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-lg shadow-md">
<Space direction="vertical" size="middle" className="w-full">
<div className="text-center">
<Title level={2}>🚅 LiteLLM</Title>
</div>
<div className="text-center">
<Title level={3}>Login</Title>
<Text type="secondary">Access your LiteLLM Admin UI.</Text>
</div>
<Alert
message="Default Credentials"
description={
<>
<Paragraph className="text-sm">
By default, Username is <code className="bg-gray-100 px-1 py-0.5 rounded text-xs">admin</code> and
Password is your set LiteLLM Proxy
<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">MASTER_KEY</code>.
</Paragraph>
<Paragraph className="text-sm">
Need to set UI credentials or SSO?{" "}
<a href="https://docs.litellm.ai/docs/proxy/ui" target="_blank" rel="noopener noreferrer">
Check the documentation
</a>
.
</Paragraph>
</>
}
type="info"
icon={<InfoCircleOutlined />}
showIcon
/>
{error && <Alert message={error} type="error" showIcon />}
<Form onFinish={handleSubmit} layout="vertical" requiredMark={true}>
<Form.Item
label="Username"
name="username"
rules={[{ required: true, message: "Please enter your username" }]}
>
<Input
placeholder="Enter your username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoginLoading}
size="large"
className="rounded-md border-gray-300"
/>
</Form.Item>
<Form.Item
label="Password"
name="password"
rules={[{ required: true, message: "Please enter your password" }]}
>
<Input.Password
placeholder="Enter your password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoginLoading}
size="large"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={isLoginLoading}
disabled={isLoginLoading}
block
size="large"
>
{isLoginLoading ? "Logging in..." : "Login"}
</Button>
</Form.Item>
</Form>
</Space>
</Card>
</div>
);
}
export default function LoginPage() {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<LoginPageContent />
</QueryClientProvider>
);
}

View File

@@ -1,154 +1,5 @@
"use client"; "use client";
import { useLogin } from "@/app/(dashboard)/hooks/login/useLogin"; import LoginPage from "./LoginPage";
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import { getProxyBaseUrl } from "@/components/networking";
import { getCookie } from "@/utils/cookieUtils";
import { isJwtExpired } from "@/utils/jwtUtils";
import { InfoCircleOutlined } from "@ant-design/icons";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Alert, Button, Card, Form, Input, Space, Typography } from "antd";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
function LoginPageContent() { export default LoginPage;
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(true);
const { isLoading: isConfigLoading } = useUIConfig();
const loginMutation = useLogin();
const router = useRouter();
useEffect(() => {
if (isConfigLoading) {
return;
}
const rawToken = getCookie("token");
if (rawToken && !isJwtExpired(rawToken)) {
router.replace(`${getProxyBaseUrl()}/ui`);
return;
}
setIsLoading(false);
}, [isConfigLoading, router]);
const handleSubmit = () => {
loginMutation.mutate(
{ username, password },
{
onSuccess: (data) => {
router.push(data.redirect_url);
},
},
);
};
const error = loginMutation.error instanceof Error ? loginMutation.error.message : null;
const isLoginLoading = loginMutation.isPending;
const { Title, Text, Paragraph } = Typography;
if (isConfigLoading || isLoading) {
return <LoadingScreen />;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-lg shadow-md">
<Space direction="vertical" size="middle" className="w-full">
<div className="text-center">
<Title level={2}>🚅 LiteLLM</Title>
</div>
<div className="text-center">
<Title level={3}>Login</Title>
<Text type="secondary">Access your LiteLLM Admin UI.</Text>
</div>
<Alert
message="Default Credentials"
description={
<>
<Paragraph className="text-sm">
By default, Username is <code className="bg-gray-100 px-1 py-0.5 rounded text-xs">admin</code> and
Password is your set LiteLLM Proxy
<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">MASTER_KEY</code>.
</Paragraph>
<Paragraph className="text-sm">
Need to set UI credentials or SSO?{" "}
<a href="https://docs.litellm.ai/docs/proxy/ui" target="_blank" rel="noopener noreferrer">
Check the documentation
</a>
.
</Paragraph>
</>
}
type="info"
icon={<InfoCircleOutlined />}
showIcon
/>
{error && <Alert message={error} type="error" showIcon />}
<Form onFinish={handleSubmit} layout="vertical" requiredMark={true}>
<Form.Item
label="Username"
name="username"
rules={[{ required: true, message: "Please enter your username" }]}
>
<Input
placeholder="Enter your username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoginLoading}
size="large"
className="rounded-md border-gray-300"
/>
</Form.Item>
<Form.Item
label="Password"
name="password"
rules={[{ required: true, message: "Please enter your password" }]}
>
<Input.Password
placeholder="Enter your password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoginLoading}
size="large"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={isLoginLoading}
disabled={isLoginLoading}
block
size="large"
>
{isLoginLoading ? "Logging in..." : "Login"}
</Button>
</Form.Item>
</Form>
</Space>
</Card>
</div>
);
}
export default function LoginPage() {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<LoginPageContent />
</QueryClientProvider>
);
}

View File

@@ -186,7 +186,7 @@ export default function CreateKeyPage() {
useEffect(() => { useEffect(() => {
if (redirectToLogin) { if (redirectToLogin) {
// Replace instead of assigning to avoid back-button loops // Replace instead of assigning to avoid back-button loops
const dest = (proxyBaseUrl || "") + "/sso/key/generate"; const dest = (proxyBaseUrl || "") + "/ui/login";
window.location.replace(dest); window.location.replace(dest);
} }
}, [redirectToLogin]); }, [redirectToLogin]);

View File

@@ -205,6 +205,7 @@ export interface PublicModelHubInfo {
export interface LiteLLMWellKnownUiConfig { export interface LiteLLMWellKnownUiConfig {
server_root_path: string; server_root_path: string;
proxy_base_url: string | null; proxy_base_url: string | null;
auto_redirect_to_sso: boolean;
} }
export interface CredentialsResponse { export interface CredentialsResponse {

View File

@@ -193,7 +193,7 @@ describe("CreateKeyPage auth behavior", () => {
// Assert: we eventually redirect to SSO login (single replace, not assign/href) // Assert: we eventually redirect to SSO login (single replace, not assign/href)
await waitFor(() => { await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith("https://example.com/sso/key/generate"); expect(window.location.replace).toHaveBeenCalledWith("https://example.com/ui/login");
}); });
// And we attempted to clear the cookie (defensive deletion) // And we attempted to clear the cookie (defensive deletion)