mirror of
https://github.com/BerriAI/litellm.git
synced 2025-12-06 11:33:26 +08:00
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:
@@ -3496,8 +3496,13 @@ jobs:
|
||||
command: |
|
||||
npx playwright test e2e_ui_tests/ --reporter=html --output=test-results
|
||||
no_output_timeout: 120m
|
||||
- store_test_results:
|
||||
- store_artifacts:
|
||||
path: test-results
|
||||
destination: playwright-results
|
||||
|
||||
- store_artifacts:
|
||||
path: playwright-report
|
||||
destination: playwright-report
|
||||
|
||||
test_nonroot_image:
|
||||
machine:
|
||||
|
||||
@@ -556,10 +556,11 @@ else:
|
||||
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"
|
||||
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/)."
|
||||
|
||||
|
||||
@@ -11,15 +11,20 @@ import { test, expect } from "@playwright/test";
|
||||
test("admin login test", async ({ page }) => {
|
||||
// Go to the specified URL
|
||||
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
|
||||
await page.fill('input[name="username"]', "admin");
|
||||
await page.fill('input[placeholder="Enter your username"]', "admin");
|
||||
|
||||
// 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
|
||||
const loginButton = page.locator('input[type="submit"]');
|
||||
const loginButton = page.getByRole("button", { name: "Login" });
|
||||
await expect(loginButton).toBeEnabled();
|
||||
|
||||
// Optionally, you can click the login button to submit the form
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
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);
|
||||
|
||||
page.on("console", (msg) => console.log("PAGE LOG:", msg.text()));
|
||||
|
||||
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);
|
||||
|
||||
@@ -20,7 +24,9 @@ test.describe("Authentication Checks", () => {
|
||||
await page.waitForURL(expectedRedirectUrl, { timeout: 10000 });
|
||||
console.log(`Waited for URL. Current URL is now: ${page.url()}`);
|
||||
} 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" });
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -21,17 +21,22 @@ test("user search test", async ({ page }) => {
|
||||
|
||||
// Login first
|
||||
await page.goto("http://localhost:4000/ui");
|
||||
await page.waitForLoadState("networkidle");
|
||||
console.log("Navigated to login page");
|
||||
|
||||
page.screenshot({ path: "test-results/search_users_before_login.png" });
|
||||
|
||||
// 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");
|
||||
|
||||
await page.fill('input[name="username"]', "admin");
|
||||
await page.fill('input[name="password"]', "gm");
|
||||
await page.fill('input[placeholder="Enter your username"]', "admin");
|
||||
await page.fill('input[placeholder="Enter your password"]', "gm");
|
||||
console.log("Filled login credentials");
|
||||
|
||||
const loginButton = page.locator('input[type="submit"]');
|
||||
const loginButton = page.getByRole("button", { name: "Login" });
|
||||
await expect(loginButton).toBeEnabled();
|
||||
await loginButton.click();
|
||||
console.log("Clicked login button");
|
||||
@@ -128,17 +133,20 @@ test("user filter test", async ({ page }) => {
|
||||
|
||||
// Login first
|
||||
await page.goto("http://localhost:4000/ui");
|
||||
await page.waitForLoadState("networkidle");
|
||||
console.log("Navigated to login page");
|
||||
|
||||
// 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");
|
||||
|
||||
await page.fill('input[name="username"]', "admin");
|
||||
await page.fill('input[name="password"]', "gm");
|
||||
await page.fill('input[placeholder="Enter your username"]', "admin");
|
||||
await page.fill('input[placeholder="Enter your password"]', "gm");
|
||||
console.log("Filled login credentials");
|
||||
|
||||
const loginButton = page.locator('input[type="submit"]');
|
||||
const loginButton = page.getByRole("button", { name: "Login" });
|
||||
await expect(loginButton).toBeEnabled();
|
||||
await loginButton.click();
|
||||
console.log("Clicked login button");
|
||||
|
||||
@@ -7,15 +7,18 @@ import { test, expect } from "@playwright/test";
|
||||
test("view internal user page", async ({ page }) => {
|
||||
// Go to the specified URL
|
||||
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
|
||||
await page.fill('input[name="username"]', "admin");
|
||||
await page.fill('input[placeholder="Enter your username"]', "admin");
|
||||
|
||||
// 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
|
||||
const loginButton = page.locator('input[type="submit"]');
|
||||
const loginButton = page.getByRole("button", { name: "Login" });
|
||||
await expect(loginButton).toBeEnabled();
|
||||
await loginButton.click();
|
||||
|
||||
@@ -35,9 +38,9 @@ test("view internal user page", async ({ page }) => {
|
||||
const rowCount = await page.locator("tbody tr").count();
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
|
||||
// Verify table headers are present (including Virtual Keys column)
|
||||
const virtualKeysHeader = page.locator("th", { hasText: "Virtual Keys" });
|
||||
await expect(virtualKeysHeader).toBeVisible();
|
||||
const userIdHeader = page.locator("th", { hasText: "User ID" });
|
||||
page.screenshot({ path: "user_id_header.png" });
|
||||
await expect(userIdHeader).toBeVisible();
|
||||
|
||||
// test pagination
|
||||
// Wait for pagination controls to be visible
|
||||
|
||||
@@ -2,22 +2,56 @@ import { test, expect } from "@playwright/test";
|
||||
import { loginToUI } from "../utils/login";
|
||||
|
||||
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 ({
|
||||
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
|
||||
await page.waitForSelector('text="🚅 Loading users..."', {
|
||||
state: "hidden",
|
||||
timeout: 10000,
|
||||
});
|
||||
page.screenshot({ path: "test-results/view_user_info_after_loading.png" });
|
||||
// Wait for users table to load
|
||||
await page.waitForSelector("table");
|
||||
|
||||
page.screenshot({
|
||||
path: "test-results/view_user_info_after_table_load.png",
|
||||
});
|
||||
// Get the first user ID cell
|
||||
const firstUserIdCell = page.locator(
|
||||
"table tbody tr:first-child td:first-child"
|
||||
@@ -27,6 +61,7 @@ test.describe("User Info View", () => {
|
||||
|
||||
// Click on the user ID
|
||||
await firstUserIdCell.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check for tabs
|
||||
await expect(page.locator('button:has-text("Overview")')).toBeVisible({
|
||||
|
||||
@@ -3,17 +3,21 @@ import { Page, expect } from "@playwright/test";
|
||||
export async function loginToUI(page: Page) {
|
||||
// Login first
|
||||
await page.goto("http://localhost:4000/ui");
|
||||
await page.waitForLoadState("networkidle");
|
||||
console.log("Navigated to login page");
|
||||
|
||||
page.screenshot({ path: "test-results/login_utils_before.png" });
|
||||
// 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");
|
||||
|
||||
await page.fill('input[name="username"]', "admin");
|
||||
await page.fill('input[name="password"]', "gm");
|
||||
await page.fill('input[placeholder="Enter your username"]', "admin");
|
||||
await page.fill('input[placeholder="Enter your password"]', "gm");
|
||||
console.log("Filled login credentials");
|
||||
|
||||
const loginButton = page.locator('input[type="submit"]');
|
||||
const loginButton = page.getByRole("button", { name: "Login" });
|
||||
await expect(loginButton).toBeEnabled();
|
||||
await loginButton.click();
|
||||
console.log("Clicked login button");
|
||||
|
||||
172
ui/litellm-dashboard/src/app/login/LoginPage.test.tsx
Normal file
172
ui/litellm-dashboard/src/app/login/LoginPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
159
ui/litellm-dashboard/src/app/login/LoginPage.tsx
Normal file
159
ui/litellm-dashboard/src/app/login/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +1,5 @@
|
||||
"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";
|
||||
import LoginPage from "./LoginPage";
|
||||
|
||||
function LoginPageContent() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
export default LoginPage;
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function CreateKeyPage() {
|
||||
useEffect(() => {
|
||||
if (redirectToLogin) {
|
||||
// Replace instead of assigning to avoid back-button loops
|
||||
const dest = (proxyBaseUrl || "") + "/sso/key/generate";
|
||||
const dest = (proxyBaseUrl || "") + "/ui/login";
|
||||
window.location.replace(dest);
|
||||
}
|
||||
}, [redirectToLogin]);
|
||||
|
||||
@@ -205,6 +205,7 @@ export interface PublicModelHubInfo {
|
||||
export interface LiteLLMWellKnownUiConfig {
|
||||
server_root_path: string;
|
||||
proxy_base_url: string | null;
|
||||
auto_redirect_to_sso: boolean;
|
||||
}
|
||||
|
||||
export interface CredentialsResponse {
|
||||
|
||||
@@ -193,7 +193,7 @@ describe("CreateKeyPage auth behavior", () => {
|
||||
|
||||
// Assert: we eventually redirect to SSO login (single replace, not assign/href)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user