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: |
|
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:
|
||||||
|
|||||||
@@ -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/)."
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
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";
|
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user