Migrate top-level pages to shadcn (Phase 1)

- LoginPage.tsx: antd Form/Input/Select/Popover/Card/Alert/Space/Typography -> shadcn/Tailwind equivalents. Preserves SSO popover, worker selector, admin-disabled view, and credentials info banner. All 8 existing tests still pass.
- OnboardingFormBody.tsx: antd Form.useForm -> react-hook-form; antd Input.Password -> shadcn Input type=password. Retains aria-label='loading' spinner so existing test selector continues to work.
- src/app/page.tsx: drop antd ConfigProvider/theme wrapper; dark-mode state now toggles the 'dark' class on <html> so shadcn CSS variables switch. Removed unused QueryClient imports.

Adds src/app/onboarding/OnboardingFormBody.tsx to the raw-tailwind-colors lint exception list (SSO info-banner uses bg-blue-50 per existing design pattern).

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-24 12:47:36 +00:00
parent 0fd4379e32
commit 43337912e1
No known key found for this signature in database
4 changed files with 240 additions and 174 deletions

View file

@ -136,6 +136,7 @@
"src/components/prompts/prompt_editor_view/VersionHistorySidePanel.tsx",
"src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx",
"src/app/(dashboard)/teams/components/TeamsTable/TeamsTable.tsx",
"src/app/onboarding/OnboardingFormBody.tsx",
"src/components/prompts/prompt_editor_view/conversation_panel/MessageBubble.tsx",
"src/components/playground/chat_ui/SessionManagement.tsx",
"src/components/playground/chat_ui/SearchResultsDisplay.tsx",

View file

@ -7,8 +7,24 @@ import { exchangeLoginCode, getProxyBaseUrl, switchToWorkerUrl } from "@/compone
import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
import { isJwtExpired } from "@/utils/jwtUtils";
import { consumeReturnUrl, getReturnUrl, isValidReturnUrl } from "@/utils/returnUrlUtils";
import { Info as InfoCircleOutlined, Server as CloudServerOutlined } from "lucide-react";
import { Alert, Button, Card, Form, Input, Popover, Select, Space, Typography } from "antd";
import { Info, Server, LoaderCircle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useWorker } from "@/hooks/useWorker";
@ -115,7 +131,8 @@ function LoginPageContent() {
setIsLoading(false);
}, [isConfigLoading, router, uiConfig]);
const handleSubmit = () => {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
// If a worker is selected, point proxyBaseUrl at it before login
const selectedWorker = workers.find((w) => w.worker_id === selectedWorkerId);
if (selectedWorker) {
@ -154,8 +171,6 @@ function LoginPageContent() {
const error = loginMutation.error instanceof Error ? loginMutation.error.message : null;
const isLoginLoading = loginMutation.isPending;
const { Title, Text, Paragraph } = Typography;
if (isConfigLoading || isLoading) {
return <LoadingScreen />;
}
@ -163,175 +178,192 @@ function LoginPageContent() {
// Show disabled message if admin UI is disabled
if (uiConfig && uiConfig.admin_ui_disabled) {
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="min-h-screen flex items-center justify-center bg-muted/30">
<Card className="w-full max-w-lg shadow-md p-6">
<div className="flex flex-col gap-4 w-full">
<div className="text-center">
<Title level={2}>🚅 LiteLLM</Title>
<h2 className="text-2xl font-semibold">🚅 LiteLLM</h2>
</div>
<Alert
message="Admin UI Disabled"
description={
<>
<Paragraph className="text-sm">
The Admin UI has been disabled by the administrator. To re-enable it, please update the following
environment variable:
</Paragraph>
<Paragraph className="text-sm">
<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">DISABLE_ADMIN_UI=False</code>
</Paragraph>
</>
}
type="warning"
showIcon
/>
</Space>
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>Admin UI Disabled</AlertTitle>
<AlertDescription>
<p className="text-sm">
The Admin UI has been disabled by the administrator. To re-enable it, please update the following
environment variable:
</p>
<p className="text-sm mt-2">
<code className="bg-muted px-1 py-0.5 rounded text-xs">DISABLE_ADMIN_UI=False</code>
</p>
</AlertDescription>
</Alert>
</div>
</Card>
</div>
);
}
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="min-h-screen flex items-center justify-center bg-muted/30">
<Card className="w-full max-w-lg shadow-md p-6">
<div className="flex flex-col gap-4 w-full">
<div className="text-center">
<Title level={2}>🚅 LiteLLM</Title>
<h2 className="text-2xl font-semibold">🚅 LiteLLM</h2>
</div>
<div className="text-center">
<Title level={3}>Login</Title>
<Text type="secondary">Access your LiteLLM Admin UI.</Text>
<h3 className="text-xl font-semibold">Login</h3>
<p className="text-sm text-muted-foreground">Access your LiteLLM Admin UI.</p>
</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
/>
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>Default Credentials</AlertTitle>
<AlertDescription>
<p className="text-sm">
By default, Username is <code className="bg-muted px-1 py-0.5 rounded text-xs">admin</code> and
Password is your set LiteLLM Proxy{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">MASTER_KEY</code>.
</p>
<p className="text-sm mt-2">
Need to set UI credentials or SSO?{" "}
<a
href="https://docs.litellm.ai/docs/proxy/ui"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Check the documentation
</a>
.
</p>
</AlertDescription>
</Alert>
{error && <Alert message={error} type="error" showIcon />}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Form onFinish={handleSubmit} layout="vertical" requiredMark={false}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{uiConfig?.is_control_plane && workers.length > 0 && (
<Form.Item label="Worker" style={{ marginBottom: 16 }}>
<div className="space-y-2">
<Label htmlFor="worker">Worker</Label>
<Select
value={selectedWorkerId || undefined}
onChange={(value) => setSelectedWorkerId(value)}
placeholder="Choose a worker to connect to"
size="large"
suffixIcon={<CloudServerOutlined />}
options={workers.map((w) => ({
label: w.name,
value: w.worker_id,
}))}
/>
</Form.Item>
onValueChange={(value) => setSelectedWorkerId(value)}
>
<SelectTrigger id="worker" className="h-11">
<div className="flex items-center gap-2 flex-1">
<SelectValue placeholder="Choose a worker to connect to" />
</div>
<Server className="h-4 w-4 text-muted-foreground" />
</SelectTrigger>
<SelectContent>
{workers.map((w) => (
<SelectItem key={w.worker_id} value={w.worker_id}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<Form.Item
label="Username"
name="username"
rules={[{ required: true, message: "Please enter your username" }]}
>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
placeholder="Enter your username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoginLoading}
size="large"
className="rounded-md border-gray-300"
required
className="h-11"
/>
</Form.Item>
</div>
<Form.Item
label="Password"
name="password"
rules={[{ required: true, message: "Please enter your password" }]}
>
<Input.Password
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoginLoading}
size="large"
required
className="h-11"
/>
</Form.Item>
</div>
<Form.Item>
<Button
type="submit"
disabled={isLoginLoading}
className="w-full h-11"
>
{isLoginLoading && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
{isLoginLoading ? "Logging in..." : "Login"}
</Button>
{!uiConfig?.sso_configured ? (
<Popover>
<PopoverTrigger asChild>
<span className="w-full">
<Button
type="button"
variant="outline"
disabled
className="w-full h-11"
>
Login with SSO
</Button>
</span>
</PopoverTrigger>
<PopoverContent>
Please configure SSO to log in with SSO.
</PopoverContent>
</Popover>
) : (
<Button
type="primary"
htmlType="submit"
loading={isLoginLoading}
disabled={isLoginLoading}
block
size="large"
type="button"
variant="outline"
disabled={isLoginLoading || (!!selectedWorkerId && workers.length === 0)}
onClick={() => {
const selectedWorker = workers.find((w) => w.worker_id === selectedWorkerId);
if (selectedWorker) {
// Store worker selection so useWorker hook restores it after redirect
localStorage.setItem("litellm_selected_worker_id", selectedWorkerId!);
switchToWorkerUrl(selectedWorker.url);
}
// SSO on the worker (or this instance if no worker), always
// include return_to so the callback redirects back here
const ssoBase = selectedWorker?.url ?? getProxyBaseUrl();
const returnTo = encodeURIComponent(window.location.origin + "/ui/login");
router.push(`${ssoBase}/sso/key/generate?return_to=${returnTo}`);
}}
className="w-full h-11"
>
{isLoginLoading ? "Logging in..." : "Login"}
Login with SSO
</Button>
</Form.Item>
<Form.Item>
{!uiConfig?.sso_configured ? (
<Popover
content="Please configure SSO to log in with SSO."
trigger="hover"
>
<Button disabled block size="large">
Login with SSO
</Button>
</Popover>
) : (
<Button
disabled={isLoginLoading || (!!selectedWorkerId && workers.length === 0)}
onClick={() => {
const selectedWorker = workers.find((w) => w.worker_id === selectedWorkerId);
if (selectedWorker) {
// Store worker selection so useWorker hook restores it after redirect
localStorage.setItem("litellm_selected_worker_id", selectedWorkerId!);
switchToWorkerUrl(selectedWorker.url);
}
// SSO on the worker (or this instance if no worker), always
// include return_to so the callback redirects back here
const ssoBase = selectedWorker?.url ?? getProxyBaseUrl();
const returnTo = encodeURIComponent(window.location.origin + "/ui/login");
router.push(`${ssoBase}/sso/key/generate?return_to=${returnTo}`);
}}
block
size="large"
>
Login with SSO
</Button>
)}
</Form.Item>
</Form>
</Space>
)}
</form>
</div>
{uiConfig?.sso_configured && (
<Alert
type="info"
showIcon
closable
message={<Text>Single Sign-On (SSO) is enabled. LiteLLM no longer automatically redirects to the SSO login flow upon loading this page. To re-enable auto-redirect-to-SSO, set <Text code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</Text> in your environment configuration.</Text>}
/>
<Alert className="mt-4">
<Info className="h-4 w-4" />
<AlertDescription>
Single Sign-On (SSO) is enabled. LiteLLM no longer automatically redirects to the SSO login flow upon
loading this page. To re-enable auto-redirect-to-SSO, set{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</code> in your
environment configuration.
</AlertDescription>
</Alert>
)}
</Card>
</div>

View file

@ -1,9 +1,10 @@
import React from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Info } from "lucide-react";
import { Form, Input as AntInput } from "antd";
import { Label } from "@/components/ui/label";
import { Info, LoaderCircle } from "lucide-react";
type OnboardingFormBodyProps = {
variant: "signup" | "reset_password";
@ -13,6 +14,11 @@ type OnboardingFormBodyProps = {
onSubmit: (values: { password: string }) => void;
};
type FormValues = {
user_email: string;
password: string;
};
export function OnboardingFormBody({
variant,
userEmail,
@ -20,11 +26,18 @@ export function OnboardingFormBody({
claimError,
onSubmit,
}: OnboardingFormBodyProps) {
const [form] = Form.useForm();
const {
register,
handleSubmit,
formState: { errors },
setValue,
} = useForm<FormValues>({
defaultValues: { user_email: userEmail, password: "" },
});
React.useEffect(() => {
if (userEmail) form.setFieldValue("user_email", userEmail);
}, [userEmail, form]);
if (userEmail) setValue("user_email", userEmail);
}, [userEmail, setValue]);
return (
<div className="mx-auto w-full max-w-md mt-10">
@ -62,48 +75,64 @@ export function OnboardingFormBody({
</div>
)}
<Form
className="mt-10 mb-5"
layout="vertical"
form={form}
onFinish={(values) => onSubmit({ password: values.password })}
<form
className="mt-10 mb-5 flex flex-col gap-4"
onSubmit={handleSubmit((values) =>
onSubmit({ password: values.password }),
)}
>
<Form.Item label="Email Address" name="user_email">
<Input type="email" disabled />
</Form.Item>
<div className="space-y-2">
<Label htmlFor="user_email">Email Address</Label>
<Input
id="user_email"
type="email"
disabled
{...register("user_email")}
/>
</div>
<Form.Item
label="Password"
name="password"
rules={[
{ required: true, message: "password required to sign up" },
]}
help={
variant === "reset_password"
? "Enter your new password"
: "Create a password for your account"
}
>
<AntInput.Password />
</Form.Item>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
{...register("password", {
required: "password required to sign up",
})}
/>
{errors.password ? (
<p className="text-sm text-destructive">
{errors.password.message as string}
</p>
) : (
<p className="text-sm text-muted-foreground">
{variant === "reset_password"
? "Enter your new password"
: "Create a password for your account"}
</p>
)}
</div>
{claimError && (
<div className="flex gap-2 items-start p-3 rounded-md bg-destructive/10 border border-destructive/30 text-destructive mb-4">
<div className="flex gap-2 items-start p-3 rounded-md bg-destructive/10 border border-destructive/30 text-destructive">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<div className="text-sm">{claimError}</div>
</div>
)}
<div className="mt-10">
<div className="mt-6">
<Button type="submit" disabled={isPending}>
{isPending
? "…"
: variant === "reset_password"
? "Reset Password"
: "Sign Up"}
{isPending && (
<LoaderCircle
className="h-4 w-4 animate-spin mr-2"
aria-label="loading"
role="img"
/>
)}
{variant === "reset_password" ? "Reset Password" : "Sign Up"}
</Button>
</div>
</Form>
</form>
</Card>
</div>
);

View file

@ -46,11 +46,9 @@ import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
import { isJwtExpired } from "@/utils/jwtUtils";
import { buildLoginUrlWithReturn, consumeReturnUrl, isValidReturnUrl, normalizeUrlForCompare, storeReturnUrl } from "@/utils/returnUrlUtils";
import { formatUserRole, isAdminRole } from "@/utils/roles";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { jwtDecode } from "jwt-decode";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { ConfigProvider, theme } from "antd";
function deleteCookie(name: string, path = "/") {
// Best-effort client-side clear (works for non-HttpOnly cookies without Domain)
@ -114,6 +112,16 @@ function CreateKeyPageContent() {
setIsDarkMode(!isDarkMode);
};
// Apply dark-mode class to the root element so shadcn CSS variables switch.
useEffect(() => {
const root = document.documentElement;
if (isDarkMode) {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
}, [isDarkMode]);
const invitation_id = searchParams.get("invitation_id");
// Parse URL query parameters for pre-filling the create key form
@ -451,9 +459,6 @@ function CreateKeyPageContent() {
return (
<Suspense fallback={<LoadingScreen />}>
<ConfigProvider theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}>
<ThemeProvider accessToken={accessToken}>
{invitation_id ? (
<UserDashboard
@ -675,7 +680,6 @@ function CreateKeyPageContent() {
</div>
)}
</ThemeProvider>
</ConfigProvider>
</Suspense>
);
}