diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 0b71b51dc3f..ebdc7f52ed4 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -22,6 +22,7 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "dayjs": "1.11.19", + "i18next": "26.4.2", "jwt-decode": "4.0.0", "lucide-react": "0.513.0", "moment": "2.30.1", @@ -36,6 +37,7 @@ "react-copy-to-clipboard": "5.1.1", "react-dom": "19.2.8", "react-hook-form": "7.82.0", + "react-i18next": "17.0.13", "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", "react-syntax-highlighter": "15.6.6", @@ -402,9 +404,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -7287,6 +7289,15 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -7334,6 +7345,34 @@ "ms": "^2.0.0" } }, + "node_modules/i18next": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -10602,6 +10641,33 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "17.0.13", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz", + "integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -12071,7 +12137,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 233a0e63881..41c275039ab 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -38,6 +38,7 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "dayjs": "1.11.19", + "i18next": "26.4.2", "jwt-decode": "4.0.0", "lucide-react": "0.513.0", "moment": "2.30.1", @@ -52,6 +53,7 @@ "react-copy-to-clipboard": "5.1.1", "react-dom": "19.2.8", "react-hook-form": "7.82.0", + "react-i18next": "17.0.13", "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", "react-syntax-highlighter": "15.6.6", diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx index 60db7104a03..81a4109b9c6 100644 --- a/ui/litellm-dashboard/src/app/layout.tsx +++ b/ui/litellm-dashboard/src/app/layout.tsx @@ -1,3 +1,4 @@ +import LanguageProvider from "@/i18n/LanguageProvider"; import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; @@ -28,12 +29,14 @@ export default function RootLayout({ - - - {children} - - - + + + + {children} + + + + diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.integration.test.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.integration.test.tsx index bfcc895e579..e552e8edf70 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.integration.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import LoginPage from "./LoginPage"; +import LanguageProvider from "@/i18n/LanguageProvider"; const mockPush = vi.fn(); const mockReplace = vi.fn(); @@ -60,7 +61,9 @@ const renderLoginPage = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); return render( - + + + , ); }; @@ -77,6 +80,7 @@ const originalLocation = window.location; describe("LoginPage submit payload", () => { beforeEach(() => { vi.clearAllMocks(); + window.localStorage.removeItem("litellm_ui_language"); WORKERS = []; setSearch(""); (useUIConfig as ReturnType).mockReturnValue({ @@ -91,6 +95,7 @@ describe("LoginPage submit payload", () => { }); afterEach(() => { + window.localStorage.removeItem("litellm_ui_language"); Object.defineProperty(window, "location", { value: originalLocation, writable: true }); }); @@ -190,3 +195,36 @@ describe("LoginPage submit payload", () => { expect(switchToWorkerUrl).toHaveBeenCalledWith("http://worker-a:4000"); }); }); + +describe("localized login", () => { + afterEach(() => { + window.localStorage.removeItem("litellm_ui_language"); + }); + + it("updates existing validation errors when switching language and keeps the submitted payload unchanged", async () => { + vi.clearAllMocks(); + WORKERS = []; + setSearch(""); + window.localStorage.removeItem("litellm_ui_language"); + (useUIConfig as ReturnType).mockReturnValue({ data: { sso_configured: false }, isLoading: false }); + const user = userEvent.setup(); + renderLoginPage(); + await user.click(await screen.findByRole("button", { name: "Login" })); + expect(await screen.findByText("Please enter your username")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Language" })); + await user.click(await screen.findByRole("menuitemradio", { name: "简体中文" })); + expect(await screen.findByText("请输入用户名")).toBeInTheDocument(); + expect(screen.getByText("请输入密码")).toBeInTheDocument(); + expect(mockMutate).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("用户名"), { target: { value: "admin" } }); + fireEvent.change(screen.getByLabelText("密码"), { target: { value: "opaque-password" } }); + await user.click(screen.getByRole("button", { name: "登录" })); + await waitFor(() => expect(mockMutate).toHaveBeenCalledTimes(1)); + expect(mockMutate.mock.calls[0][0]).toStrictEqual({ username: "admin", password: "opaque-password", useV3: false }); + expect(screen.getByText("MASTER_KEY")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "查看文档" })).toHaveAttribute( + "href", + "https://docs.litellm.ai/docs/proxy/ui", + ); + }); +}); diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.tsx index 11cf5ffad3d..dcdf1dbef71 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.tsx @@ -1,5 +1,8 @@ "use client"; +import LanguageSwitcher from "@/components/LanguageSwitcher"; +import { Trans } from "react-i18next"; +import { useTranslation } from "@/i18n/useTranslation"; import { useLogin } from "@/app/(dashboard)/hooks/login/useLogin"; import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig"; import LoadingScreen from "@/components/common_components/LoadingScreen"; @@ -24,14 +27,10 @@ import { useEffect, useId, useState } from "react"; import { z } from "zod/v4"; import { useWorker } from "@/hooks/useWorker"; -const loginSchema = z.object({ - username: z.string().min(1, "Please enter your username"), - password: z.string().min(1, "Please enter your password"), -}); - -type LoginFormValues = z.infer; +type LoginFormValues = { username: string; password: string }; function SsoEnabledNotice() { + const { t, i18n } = useTranslation(); const [dismissed, setDismissed] = useState(false); if (dismissed) { @@ -42,13 +41,15 @@ function SsoEnabledNotice() { - 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{" "} - AUTO_REDIRECT_UI_LOGIN_TO_SSO=true in your - environment configuration. + }} + /> - @@ -57,6 +58,11 @@ function SsoEnabledNotice() { } function LoginPageContent() { + const { t, i18n } = useTranslation(); + const loginSchema = z.object({ + username: z.string().min(1, t("Please enter your username")), + password: z.string().min(1, t("Please enter your password")), + }); const [isLoading, setIsLoading] = useState(true); const { data: uiConfig, isLoading: isConfigLoading } = useUIConfig(); const loginMutation = useLogin(); @@ -66,6 +72,12 @@ function LoginPageContent() { const workerFieldId = useId(); const form = useZodForm(loginSchema, { defaultValues: { username: "", password: "" } }); + useEffect(() => { + if (form.getFieldState("username").error || form.getFieldState("password").error) { + void form.trigger(); + } + }, [form, t]); + // Pre-select worker from URL param (e.g. /ui/login?worker=team-b) useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -190,6 +202,9 @@ function LoginPageContent() {
+
+ +

🚅 LiteLLM

@@ -197,11 +212,12 @@ function LoginPageContent() { - Admin UI Disabled + {t("Admin UI Disabled")}

- The Admin UI has been disabled by the administrator. To re-enable it, please update the following - environment variable: + {t( + "The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:", + )}{" "}

DISABLE_ADMIN_UI=False @@ -219,6 +235,9 @@ function LoginPageContent() {

+
+ +
@@ -226,26 +245,34 @@ function LoginPageContent() {
-

Login

-

Access your LiteLLM Admin UI.

+

{t("Login")}

+

{t("Access your LiteLLM Admin UI.")}

{!uiConfig?.hide_default_credentials_hint && ( - Default Credentials + {t("Default Credentials")}

- By default, Username is admin and - Password is your set LiteLLM Proxy - MASTER_KEY. + }} + />

- Need to set UI credentials or SSO?{" "} - - Check the documentation - - . + + ), + }} + />

@@ -262,14 +289,14 @@ function LoginPageContent() { {uiConfig?.is_control_plane && workers.length > 0 && ( - Worker + {t("Worker")} - + {({ ref, ...field }) => ( {!uiConfig?.sso_configured ? ( }> - Please configure SSO to log in with SSO. + {t("Please configure SSO to log in with SSO.")} ) : ( )} diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx index 57d734d05b3..5fb63c08397 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -1,5 +1,7 @@ "use client"; +import LanguageSwitcher from "@/components/LanguageSwitcher"; +import { useTranslation } from "@/i18n/useTranslation"; import { Breadcrumb, BreadcrumbItem, @@ -25,6 +27,7 @@ import { usePathname } from "next/navigation"; // Top bar for the dashboard shell. Sits only over the content column (the brand // lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout. export function DashboardHeader() { + const { t } = useTranslation(); const { title } = getBreadcrumb(usePathname()); const { isControlPlane, selectedWorker } = useWorker(); const showWorkerSwitch = isControlPlane && selectedWorker !== null; @@ -47,7 +50,7 @@ export function DashboardHeader() { - {title} + {t(title)} @@ -63,6 +66,7 @@ export function DashboardHeader() { {!hideCommunityLinks && } +
diff --git a/ui/litellm-dashboard/src/components/LanguageSwitcher.tsx b/ui/litellm-dashboard/src/components/LanguageSwitcher.tsx new file mode 100644 index 00000000000..c95565e1869 --- /dev/null +++ b/ui/litellm-dashboard/src/components/LanguageSwitcher.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Languages } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { isLanguage, LANGUAGE_STORAGE_KEY } from "@/i18n"; +import { useTranslation } from "@/i18n/useTranslation"; + +export default function LanguageSwitcher() { + const { t, i18n } = useTranslation(); + + const selectLanguage = (language: string) => { + if (!isLanguage(language)) return; + void i18n.changeLanguage(language); + try { + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language); + } catch {} + }; + + return ( + + } + > + + + + + + English + + + 简体中文 + + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.integration.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.integration.test.tsx new file mode 100644 index 00000000000..f3ef3f267d8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.integration.test.tsx @@ -0,0 +1,80 @@ +import { act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { I18nextProvider } from "react-i18next"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createDashboardI18n } from "@/i18n"; +import { renderWithProviders, screen } from "../../../../tests/test-utils"; +import { BlogDropdown } from "./BlogDropdown"; + +const mockRefetch = vi.fn(); +let mockIsError = false; + +vi.mock("@/app/(dashboard)/hooks/useDisableBlogPosts", () => ({ + useDisableBlogPosts: () => false, +})); + +vi.mock("@/app/(dashboard)/hooks/blogPosts/useBlogPosts", () => ({ + useBlogPosts: () => ({ + data: { + posts: [{ title: "Post One", date: "2026-02-01", description: "Description one", url: "https://example.com/1" }], + }, + isLoading: false, + isError: mockIsError, + refetch: mockRefetch, + }), +})); + +async function openDropdown() { + await userEvent.setup().hover(screen.getByRole("button", { name: "Blog" })); +} + +describe("blog dropdown language changes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsError = false; + }); + + it("should translate Retry after switching languages and still refetch posts", async () => { + mockIsError = true; + const user = userEvent.setup(); + const i18n = createDashboardI18n(); + renderWithProviders( + + + , + ); + + await openDropdown(); + expect(await screen.findByRole("button", { name: "Retry" })).toBeInTheDocument(); + await act(async () => { + await i18n.changeLanguage("zh-CN"); + }); + expect(screen.getByText("加载文章失败")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "重试" })); + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + it("should update visible dates when switching between English and Chinese", async () => { + const i18n = createDashboardI18n(); + renderWithProviders( + + + , + ); + + await openDropdown(); + expect(await screen.findByText("Feb 1, 2026")).toBeInTheDocument(); + await act(async () => { + await i18n.changeLanguage("zh-CN"); + }); + expect(screen.getByText("2026年2月1日")).toBeInTheDocument(); + expect(screen.queryByText("Feb 1, 2026")).not.toBeInTheDocument(); + expect(screen.getByText("Post One")).toBeInTheDocument(); + await act(async () => { + await i18n.changeLanguage("en"); + }); + expect(screen.getByText("Feb 1, 2026")).toBeInTheDocument(); + expect(screen.queryByText("2026年2月1日")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx index eaaf3301a8b..19880e07b16 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "@/i18n/useTranslation"; import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass"; @@ -12,9 +13,9 @@ import { import { ChevronDown, LoaderCircle } from "lucide-react"; import React from "react"; -function formatDate(dateStr: string): string { +function formatDate(dateStr: string, language: string): string { const date = new Date(dateStr + "T00:00:00"); - return date.toLocaleDateString("en-US", { + return date.toLocaleDateString(language === "zh-CN" ? "zh-CN" : "en-US", { month: "short", day: "numeric", year: "numeric", @@ -22,6 +23,7 @@ function formatDate(dateStr: string): string { } export const BlogDropdown: React.FC = () => { + const { t, i18n } = useTranslation(); const disableBlogPosts = useDisableBlogPosts(); const { data, isLoading, isError, refetch } = useBlogPosts(); @@ -34,7 +36,7 @@ export const BlogDropdown: React.FC = () => { if (isLoading) { return (
- +
); } @@ -42,16 +44,16 @@ export const BlogDropdown: React.FC = () => { if (isError) { return (
- Failed to load posts + {t("Failed to load posts")}
); } if (!data || data.posts.length === 0) { - return
No posts available
; + return
{t("No posts available")}
; } return ( @@ -63,7 +65,7 @@ export const BlogDropdown: React.FC = () => { {post.title} - {formatDate(post.date)} + {formatDate(post.date, i18n.language)}

{post.description}

@@ -72,7 +74,7 @@ export const BlogDropdown: React.FC = () => { - View all posts + {t("View all posts")} @@ -87,8 +89,7 @@ export const BlogDropdown: React.FC = () => { closeDelay={100} render={ ) : null}
@@ -53,7 +55,7 @@ export const NotificationsBell: React.FC = () => { diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index ab9a723e57a..8dd504ac347 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "@/i18n/useTranslation"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon"; @@ -78,6 +79,7 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar } = useAuthorized(); const router = useRouter(); const [open, setOpen] = useState(false); + const { t } = useTranslation(); const disableShowPrompts = useDisableShowPrompts(); const disableBlogPosts = useDisableBlogPosts(); const disableBouncingIcon = useDisableBouncingIcon(); @@ -100,16 +102,16 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar {premiumUser ? ( - Premium + {t("Premium")} ) : ( }> - Standard + {t("Standard")} - Upgrade to Premium for advanced features + {t("Upgrade to Premium for advanced features")} )} @@ -118,25 +120,25 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar
- User ID + {t("User ID")}
{userId || "-"} - +
- Role + {t("Role")}
{userRole}
- Hide New Feature Indicators + {t("Hide New Feature Indicators")} = ({ onLogout, variant = "navbar emitLocalStorageChange("disableShowNewBadge"); } }} - aria-label="Toggle hide new feature indicators" + aria-label={t("Toggle hide new feature indicators")} />
- Hide All Prompts + {t("Hide All Prompts")} = ({ onLogout, variant = "navbar emitLocalStorageChange("disableShowPrompts"); } }} - aria-label="Toggle hide all prompts" + aria-label={t("Toggle hide all prompts")} />
- Hide Blog Posts + {t("Hide Blog Posts")} = ({ onLogout, variant = "navbar emitLocalStorageChange("disableBlogPosts"); } }} - aria-label="Toggle hide blog posts" + aria-label={t("Toggle hide blog posts")} />
- Hide Bouncing Icon + {t("Hide Bouncing Icon")} = ({ onLogout, variant = "navbar emitLocalStorageChange("disableBouncingIcon"); } }} - aria-label="Toggle hide bouncing icon" + aria-label={t("Toggle hide bouncing icon")} />
{canUseLiteAdmin && ( @@ -221,7 +223,7 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar const seed = userEmail || userId || "user"; const initials = initialsFromIdentity(userEmail, userId); const hue = hueFromString(seed); - const displayName = navAccountDisplayName(userEmail, userId); + const displayName = navAccountDisplayName(userEmail, userId, t("Account")); return ( @@ -234,7 +236,10 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar "flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent", collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left", )} - aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`} + aria-label={t("accountMenu", { + role: userRole ?? t("Unknown role"), + identity: userEmail || userId || t("unknown"), + })} aria-haspopup="dialog" title={collapsed ? displayName : undefined} /> @@ -261,7 +266,10 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index b3aba7155d1..5de3420f72b 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "@/i18n/useTranslation"; import React from "react"; import { usePathname } from "next/navigation"; import { @@ -22,6 +23,7 @@ interface ViewSwitcherItem { } export default function ViewSwitcher() { + const { t } = useTranslation(); const { mode, setMode, plugins } = usePluginMode(); const { data: uiSettings } = useUISettings(); const pathname = usePathname(); @@ -32,10 +34,10 @@ export default function ViewSwitcher() { const normalizedPathname = (pathname ?? "").replace(/\/+$/, ""); const isChatRoute = chatEnabled && (normalizedPathname === chatHref || normalizedPathname.startsWith(`${chatHref}/`)); - const activeLabel = isChatRoute ? "Chat" : plugins.find((p) => p.name === mode)?.display_name ?? "AI Gateway"; + const activeLabel = isChatRoute ? t("Chat") : plugins.find((p) => p.name === mode)?.display_name ?? t("AI Gateway"); const modeEntries = [ - { key: GATEWAY, label: "AI Gateway" }, + { key: GATEWAY, label: t("AI Gateway") }, ...plugins.map((p) => ({ key: p.name, label: p.display_name })), ]; @@ -53,7 +55,7 @@ export default function ViewSwitcher() { key: CHAT, label: (
- Chat + {t("Chat")} {isChatRoute && }
), @@ -64,9 +66,9 @@ export default function ViewSwitcher() { disabled: true, label: (
- Chat + {t("Chat")} - Admins can enable in Settings + {t("Admins can enable in Settings")}
), diff --git a/ui/litellm-dashboard/src/components/Navbar/navDisplayName.test.ts b/ui/litellm-dashboard/src/components/Navbar/navDisplayName.test.ts index 96e768c5d31..f66a4b75ad0 100644 --- a/ui/litellm-dashboard/src/components/Navbar/navDisplayName.test.ts +++ b/ui/litellm-dashboard/src/components/Navbar/navDisplayName.test.ts @@ -15,3 +15,9 @@ describe("navAccountDisplayName", () => { expect(navAccountDisplayName(null, "user-uuid-123")).toBe("user-uuid-123"); }); }); + +it("localizes only the placeholder account label, preserving real account identities", () => { + expect(navAccountDisplayName(null, "default_user_id", "账户")).toBe("账户"); + expect(navAccountDisplayName(null, "Account", "账户")).toBe("Account"); + expect(navAccountDisplayName("admin@example.com", null, "账户")).toBe("admin@example.com"); +}); diff --git a/ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts b/ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts index d6f51dc37a8..deb90ec53fb 100644 --- a/ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts +++ b/ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts @@ -1,15 +1,15 @@ /** Primary label for the navbar account control — avoids raw placeholder JWT/user IDs in the UI. */ -export function navAccountDisplayName(userEmail: string | null, userId: string | null): string { +export function navAccountDisplayName(userEmail: string | null, userId: string | null, fallback = "Account"): string { const email = userEmail?.trim(); if (email) { return email; } const id = userId?.trim(); if (!id) { - return "Account"; + return fallback; } if (/^default[_\s-]?user[_\s-]?id$/i.test(id)) { - return "Account"; + return fallback; } return id; } diff --git a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx index e580b8610d0..28615a36fe9 100644 --- a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx +++ b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "@/i18n/useTranslation"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; @@ -97,6 +98,7 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla } = useAuthorized(); const router = useRouter(); const [open, setOpen] = React.useState(false); + const { t } = useTranslation(); const { data: healthData } = useHealthReadinessDetails(accessToken); const version = healthData?.litellm_version; const disableShowPrompts = useDisableShowPrompts(); @@ -118,29 +120,29 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla const toggles = [ { key: "disableShowNewBadge", - label: "Hide New Feature Indicators", - ariaLabel: "Toggle hide new feature indicators", + label: t("Hide New Feature Indicators"), + ariaLabel: t("Toggle hide new feature indicators"), checked: disableShowNewBadge, onCheckedChange: (checked: boolean) => setFlag("disableShowNewBadge", checked), }, { key: "disableShowPrompts", - label: "Hide All Prompts", - ariaLabel: "Toggle hide all prompts", + label: t("Hide All Prompts"), + ariaLabel: t("Toggle hide all prompts"), checked: disableShowPrompts, onCheckedChange: (checked: boolean) => setFlag("disableShowPrompts", checked), }, { key: "disableBlogPosts", - label: "Hide Blog Posts", - ariaLabel: "Toggle hide blog posts", + label: t("Hide Blog Posts"), + ariaLabel: t("Toggle hide blog posts"), checked: disableBlogPosts, onCheckedChange: (checked: boolean) => setFlag("disableBlogPosts", checked), }, { key: "disableBouncingIcon", - label: "Hide Bouncing Icon", - ariaLabel: "Toggle hide bouncing icon", + label: t("Hide Bouncing Icon"), + ariaLabel: t("Toggle hide bouncing icon"), checked: disableBouncingIcon, onCheckedChange: (checked: boolean) => setFlag("disableBouncingIcon", checked), }, @@ -149,8 +151,11 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla const seed = userEmail || userId || "user"; const initials = initialsFromIdentity(userEmail, userId); const hue = hueFromString(seed); - const displayName = navAccountDisplayName(userEmail, userId); - const triggerLabel = `Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`; + const displayName = navAccountDisplayName(userEmail, userId, t("Account")); + const triggerLabel = t("accountMenu", { + role: userRole ?? t("Unknown role"), + identity: userEmail || userId || t("unknown"), + }); return ( @@ -191,7 +196,7 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla 🌴 @@ -210,27 +215,27 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla
- } label="Tier"> + } label={t("Tier")}> {premiumUser ? ( - Premium + {t("Premium")} ) : ( - + - Standard + {t("Standard")} )} - } label="Role"> + } label={t("Role")}> {userRole} - } label="Email"> - + } label={t("Email")}> + - } label="User ID"> - + } label={t("User ID")}> +
@@ -283,7 +288,7 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla className="h-[42px] w-full justify-start gap-2.5 rounded-none px-3 text-sm font-medium text-foreground" > - Logout + {t("Logout")} diff --git a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx index 2f941dbc2d2..ac1a6342356 100644 --- a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx +++ b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslation } from "@/i18n/useTranslation"; import { Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; import React from "react"; @@ -7,9 +8,10 @@ import React from "react"; import { Button } from "@/components/ui/button"; const ThemeToggle: React.FC = () => { + const { t } = useTranslation(); const { setTheme, resolvedTheme } = useTheme(); const isDark = resolvedTheme === "dark"; - const label = isDark ? "Switch to light mode" : "Switch to dark mode (beta)"; + const label = isDark ? t("Switch to light mode") : t("Switch to dark mode (beta)"); return (
); diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index 5fa6e9728cf..6b098566671 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -1,5 +1,7 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { I18nextProvider } from "react-i18next"; +import { createDashboardI18n } from "@/i18n"; import { renderWithProviders } from "../../tests/test-utils"; import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav"; @@ -623,3 +625,37 @@ describe("getBreadcrumb", () => { expect(getBreadcrumb("/ui/some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" }); }); }); + +describe("localized navigation", () => { + it("changes visible and collapsed labels while retaining routes and permission filtering", async () => { + const internalUser = { + userId: "test-user-id", + accessToken: "test-access-token", + userRole: "internal", + isViewOnly: false, + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + }; + mockUseAuthorized.mockReturnValue(internalUser); + const instance = createDashboardI18n("zh-CN"); + renderWithProviders( + + + , + ); + expect(screen.getByRole("link", { name: "虚拟密钥" })).toHaveAttribute("href", "/ui/api-keys"); + expect(screen.getByRole("link", { name: "虚拟密钥" })).toHaveAttribute("title", "虚拟密钥"); + expect(screen.getByRole("link", { name: "模型与端点" })).toHaveAttribute("href", "/ui/models-and-endpoints"); + expect(screen.queryByRole("link", { name: "内部用户" })).not.toBeInTheDocument(); + await act(async () => { + await instance.changeLanguage("en"); + }); + expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("href", "/ui/api-keys"); + expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("title", "Virtual Keys"); + expect(screen.queryByRole("link", { name: "Internal Users" })).not.toBeInTheDocument(); + expect(menuGroups[0].items[0].label).toBe("Virtual Keys"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 9d772f45153..0b4f833a9d2 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "@/i18n/useTranslation"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; @@ -102,6 +103,7 @@ interface MenuItem { roles?: string[]; children?: MenuItem[]; icon?: React.ReactNode; + badge?: React.ReactNode; external_url?: string; } @@ -209,11 +211,8 @@ const menuGroups: MenuGroup[] = [ page: "cost-optimization", icon: , roles: [...all_admin_roles, ...internalUserRoles], - label: ( - - Cost Optimization - - ), + label: "Cost Optimization", + badge: , }, { key: "logs", page: "logs", label: "Logs", icon: }, { @@ -232,11 +231,8 @@ const menuGroups: MenuGroup[] = [ { key: "projects", page: "projects", - label: ( - - Projects - - ), + label: "Projects", + badge: , icon: , roles: all_admin_roles, }, @@ -428,6 +424,7 @@ const Sidebar_: React.FC = ({ disableVectorStoresForInternalUsers, allowVectorStoresForTeamAdmins, }) => { + const { t } = useTranslation(); const { userId, accessToken, userRole, isViewOnly } = useAuthorized(); const isOrgAdmin = useIsOrgAdmin(); const { data: teams } = useTeams(); @@ -521,10 +518,24 @@ const Sidebar_: React.FC = ({ }); }; + const translatedLabel = (item: MenuItem) => { + const label = typeof item.label === "string" ? t(item.label) : item.label; + return item.badge ? ( + + {label} + {item.badge} + + ) : ( + label + ); + }; + const renderLeaf = (item: MenuItem, isChild: boolean) => { const active = selectedKey === item.key; const size = isChild ? "sub" : "default"; - const label = {item.label}; + const label = ( + {translatedLabel(item)} + ); if (item.external_url) { return ( @@ -533,7 +544,7 @@ const Sidebar_: React.FC = ({ href={item.external_url} target="_blank" rel="noopener noreferrer" - title={collapsed ? labelText(item) : undefined} + title={collapsed ? t(labelText(item)) : undefined} data-active={active || undefined} className={cn(sidebarMenuButtonVariants({ isActive: active, size }))} > @@ -548,7 +559,7 @@ const Sidebar_: React.FC = ({ @@ -572,10 +583,10 @@ const Sidebar_: React.FC = ({ isActive={active} aria-expanded={open} onClick={() => toggleGroup(item.key)} - title={collapsed ? labelText(item) : undefined} + title={collapsed ? t(labelText(item)) : undefined} > {item.icon} - {item.label} + {translatedLabel(item)} = ({
- + LiteLLM = ({ variant="ghost" size="icon-sm" onClick={onToggleCollapsed} - aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"} + aria-label={collapsed ? t("Expand sidebar") : t("Collapse sidebar")} className="flex-none text-muted-foreground" > {collapsed ? : } @@ -642,7 +653,7 @@ const Sidebar_: React.FC = ({ {visibleGroups.map((group, gi) => ( {gi > 0 && } - {group.groupLabel} + {t(group.groupLabel)} {group.items.map((item) => renderItem(item))} ))} diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 8bc9b06969e..64d05a43dfe 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -1,3 +1,5 @@ +import LanguageSwitcher from "@/components/LanguageSwitcher"; +import { useTranslation } from "@/i18n/useTranslation"; import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon"; import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; @@ -37,6 +39,7 @@ const Navbar: React.FC = ({ onToggleSidebar, }) => { const baseUrl = getProxyBaseUrl(); + const { t } = useTranslation(); const { logoUrl } = useTheme(); const { data: healthData } = useHealthReadinessDetails(accessToken); const version = healthData?.litellm_version; @@ -65,7 +68,7 @@ const Navbar: React.FC = ({