From 180d1650fb7259aa35267aab0a8fff748f9646d1 Mon Sep 17 00:00:00 2001 From: J_Sinclair <56301946+E-R-Butch@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:17:13 +0800 Subject: [PATCH 1/3] feat(ui): add Chinese language switching for login and navigation --- ui/litellm-dashboard/package-lock.json | 74 ++++++++++- ui/litellm-dashboard/package.json | 2 + ui/litellm-dashboard/src/app/layout.tsx | 15 ++- .../app/login/LoginPage.integration.test.tsx | 40 +++++- .../src/app/login/LoginPage.tsx | 99 +++++++++------ .../src/components/DashboardHeader.tsx | 6 +- .../src/components/LanguageSwitcher.tsx | 47 +++++++ .../Navbar/BlogDropdown/BlogDropdown.tsx | 13 +- .../components/Navbar/DocsLink/DocsLink.tsx | 15 ++- .../NotificationsBell/NotificationsBell.tsx | 12 +- .../Navbar/UserDropdown/UserDropdown.tsx | 44 ++++--- .../src/components/Navbar/ViewSwitcher.tsx | 12 +- .../components/Navbar/navDisplayName.test.ts | 6 + .../src/components/Navbar/navDisplayName.ts | 6 +- .../SidebarAccountMenu/SidebarAccountMenu.tsx | 47 +++---- .../components/ThemeToggle/ThemeToggle.tsx | 4 +- .../common_components/LoadingScreen.tsx | 4 +- .../src/components/leftnav.test.tsx | 36 ++++++ .../src/components/leftnav.tsx | 47 ++++--- .../src/components/navbar.tsx | 10 +- .../src/components/shared/PasswordInput.tsx | 4 +- .../LanguageProvider.integration.test.tsx | 82 +++++++++++++ .../src/i18n/LanguageProvider.tsx | 28 +++++ ui/litellm-dashboard/src/i18n/README.md | 13 ++ ui/litellm-dashboard/src/i18n/index.test.ts | 35 ++++++ ui/litellm-dashboard/src/i18n/index.ts | 27 ++++ ui/litellm-dashboard/src/i18n/locales/en.json | 115 ++++++++++++++++++ .../src/i18n/locales/zh-CN.json | 115 ++++++++++++++++++ .../src/i18n/useTranslation.ts | 12 ++ 29 files changed, 834 insertions(+), 136 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/LanguageSwitcher.tsx create mode 100644 ui/litellm-dashboard/src/i18n/LanguageProvider.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/i18n/LanguageProvider.tsx create mode 100644 ui/litellm-dashboard/src/i18n/README.md create mode 100644 ui/litellm-dashboard/src/i18n/index.test.ts create mode 100644 ui/litellm-dashboard/src/i18n/index.ts create mode 100644 ui/litellm-dashboard/src/i18n/locales/en.json create mode 100644 ui/litellm-dashboard/src/i18n/locales/zh-CN.json create mode 100644 ui/litellm-dashboard/src/i18n/useTranslation.ts diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 4e5b0c1dda6..6e84a605827 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -21,6 +21,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", @@ -35,6 +36,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", @@ -414,9 +416,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" @@ -7290,6 +7292,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", @@ -7337,6 +7348,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", @@ -10650,6 +10689,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", @@ -12171,7 +12237,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 ededdfb4606..f331200e3e0 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -37,6 +37,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", @@ -51,6 +52,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..7a101a3ce8c 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", exact: true })); + 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: "登录", exact: true })); + 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..a81a34fcb44 --- /dev/null +++ b/ui/litellm-dashboard/src/components/LanguageSwitcher.tsx @@ -0,0 +1,47 @@ +"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 { + // Storage can be disabled by the browser; keep the current selection usable + } + }; + + return ( + + } + > + + + + + + English + + + 简体中文 + + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx index eaaf3301a8b..aa9eeb93cba 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"; @@ -22,6 +23,7 @@ function formatDate(dateStr: string): string { } export const BlogDropdown: React.FC = () => { + const { t } = useTranslation(); const disableBlogPosts = useDisableBlogPosts(); const { data, isLoading, isError, refetch } = useBlogPosts(); @@ -34,7 +36,7 @@ export const BlogDropdown: React.FC = () => { if (isLoading) { return (
- +
); } @@ -42,7 +44,7 @@ export const BlogDropdown: React.FC = () => { if (isError) { return (
- Failed to load posts + {t("Failed to load posts")} @@ -51,7 +53,7 @@ export const BlogDropdown: React.FC = () => { } if (!data || data.posts.length === 0) { - return
No posts available
; + return
{t("No posts available")}
; } return ( @@ -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 95f76dbb2cc..3c207579303 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"; @@ -64,6 +65,7 @@ interface UserDropdownProps { const UserDropdown: React.FC = ({ onLogout, variant = "navbar", collapsed = false }) => { const { userId, userEmail, userRoleLabel: userRole, premiumUser } = useAuthorized(); + const { t } = useTranslation(); const disableShowPrompts = useDisableShowPrompts(); const disableBlogPosts = useDisableBlogPosts(); const disableBouncingIcon = useDisableBouncingIcon(); @@ -84,16 +86,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")} )} @@ -102,25 +104,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")} />
@@ -194,7 +196,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 ( @@ -207,7 +209,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} /> @@ -234,7 +239,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 ea0b82869cd..335e3457121 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"; @@ -82,6 +83,7 @@ interface SidebarAccountMenuProps { const SidebarAccountMenu: React.FC = ({ onLogout, collapsed = false }) => { const { userId, userEmail, userRoleLabel: userRole, premiumUser, accessToken } = useAuthorized(); + const { t } = useTranslation(); const { data: healthData } = useHealthReadinessDetails(accessToken); const version = healthData?.litellm_version; const disableShowPrompts = useDisableShowPrompts(); @@ -101,29 +103,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), }, @@ -132,8 +134,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 ( @@ -174,7 +179,7 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla 🌴 @@ -193,27 +198,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")}> +
@@ -241,7 +246,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 6eb0218c41d..665aa182616 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"; @@ -622,3 +624,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 feba3e16d4c..62dea018ccb 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"; @@ -38,6 +40,7 @@ const Navbar: React.FC = ({ onToggleSidebar, }) => { const baseUrl = getProxyBaseUrl(); + const { t } = useTranslation(); const proxySettings = useProxySettings(accessToken); const { logoUrl } = useTheme(); const { data: healthData } = useHealthReadinessDetails(accessToken); @@ -74,7 +77,7 @@ const Navbar: React.FC = ({
); @@ -65,7 +65,7 @@ export const BlogDropdown: React.FC = () => { {post.title} - {formatDate(post.date)} + {formatDate(post.date, i18n.language)}

{post.description}

diff --git a/ui/litellm-dashboard/src/i18n/LanguageProvider.tsx b/ui/litellm-dashboard/src/i18n/LanguageProvider.tsx index de9efbb532c..c68a018b044 100644 --- a/ui/litellm-dashboard/src/i18n/LanguageProvider.tsx +++ b/ui/litellm-dashboard/src/i18n/LanguageProvider.tsx @@ -15,9 +15,7 @@ export default function LanguageProvider({ children }: { children: ReactNode }) try { const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY); if (isLanguage(stored)) void instance.changeLanguage(stored); - } catch { - // Storage can be disabled by the browser; language selection still works in memory - } + } catch {} updateDocumentLanguage(instance.language); return () => { instance.off("languageChanged", updateDocumentLanguage); diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index 4886c58c131..67ea7246186 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -73,6 +73,7 @@ "Prompts": "Prompts", "Read the docs": "Read the docs", "Response Cache": "Response Cache", + "Retry": "Retry", "Role": "Role", "Route every request to the cheapest model that can handle it, no prompt changes needed.": "Route every request to the cheapest model that can handle it, no prompt changes needed.", "Router Settings": "Router Settings", diff --git a/ui/litellm-dashboard/src/i18n/locales/zh-CN.json b/ui/litellm-dashboard/src/i18n/locales/zh-CN.json index bf2706ad15c..0bb1cf75bb7 100644 --- a/ui/litellm-dashboard/src/i18n/locales/zh-CN.json +++ b/ui/litellm-dashboard/src/i18n/locales/zh-CN.json @@ -73,6 +73,7 @@ "Prompts": "提示词", "Read the docs": "查看文档", "Response Cache": "响应缓存", + "Retry": "重试", "Role": "角色", "Route every request to the cheapest model that can handle it, no prompt changes needed.": "自动将每个请求分配给能够处理它的最低成本模型,无需修改提示词。", "Router Settings": "路由设置", From f2b414d420f7f8a3b9d5000c47850198b4c881aa Mon Sep 17 00:00:00 2001 From: J_Sinclair <56301946+E-R-Butch@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:26:39 +0800 Subject: [PATCH 3/3] style(ui): format blog localization regression test --- .../Navbar/BlogDropdown/BlogDropdown.integration.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index fc69c1a59ea..f3ef3f267d8 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.integration.test.tsx @@ -15,7 +15,9 @@ vi.mock("@/app/(dashboard)/hooks/useDisableBlogPosts", () => ({ 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" }] }, + data: { + posts: [{ title: "Post One", date: "2026-02-01", description: "Description one", url: "https://example.com/1" }], + }, isLoading: false, isError: mockIsError, refetch: mockRefetch,