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 c463ab8f4aa..107567c9a40 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 10d8051232c..f02c930ebf2 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"; @@ -30,14 +33,10 @@ import { useEffect, useId, useState } from "react"; import { z } from "zod"; 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) { @@ -48,13 +47,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. + }} + /> - @@ -63,6 +64,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(); @@ -72,6 +78,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); @@ -196,6 +208,9 @@ function LoginPageContent() {
+
+ +

🚅 LiteLLM

@@ -203,11 +218,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 @@ -225,6 +241,9 @@ function LoginPageContent() {

+
+ +
@@ -232,26 +251,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 - - . + + ), + }} + />

@@ -268,14 +295,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.integration.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx index d1a6e9d1cb4..9d47cdc0167 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx @@ -1,9 +1,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PluginModeProvider } from "@/contexts/PluginModeContext"; import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider"; +import LanguageProvider from "@/i18n/LanguageProvider"; +import { LANGUAGE_STORAGE_KEY } from "@/i18n"; import { DashboardHeader } from "./DashboardHeader"; vi.mock("next/navigation", () => ({ usePathname: () => "/ui/logs" })); @@ -56,13 +58,15 @@ async function openTools() { const user = userEvent.setup(); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( - - - - - - - , + + + + + + + + + , ); await user.click(screen.getByRole("button", { name: "More options" })); const tools = await screen.findByRole("dialog", { name: "Gateway tools" }); @@ -70,6 +74,25 @@ async function openTools() { } describe("mobile header nested controls", () => { + it("switches language inside mobile tools without losing the open menu or preference", async () => { + const { user, tools } = await openTools(); + await user.click(within(tools).getByRole("button", { name: "Language", exact: true })); + await user.click(await screen.findByRole("menuitemradio", { name: "简体中文" })); + + const translatedTools = await screen.findByRole("dialog", { name: "网关工具" }); + expect(within(translatedTools).getByRole("button", { name: "语言", exact: true })).toBeVisible(); + expect(within(translatedTools).getByRole("button", { name: "切换为深色模式" })).toBeVisible(); + expect(screen.getByText("请求日志")).toBeVisible(); + expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN"); + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); + expect(screen.getByRole("dialog", { name: "网关工具" })).toBeVisible(); + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "网关工具" })).not.toBeInTheDocument()); + await user.click(screen.getByRole("button", { name: "更多选项" })); + expect(await screen.findByRole("dialog", { name: "网关工具" })).toBeVisible(); + }); + it.each(["mouse", "touch"] as const)( "selects a portalled gateway item with %s without dismissing More", async (pointer) => { diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx index d9bed74c577..53a39a05560 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 { useTerminology } from "@/contexts/TerminologyContext"; import { Breadcrumb, @@ -41,6 +43,7 @@ export function DashboardHeader({ navigationTrigger?: ReactNode; deploymentHealth?: ReactNode; }) { + const { t } = useTranslation(); const term = useTerminology(); const { title } = getBreadcrumb(usePathname()); const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false }); @@ -68,7 +71,7 @@ export function DashboardHeader({ - {term(title)} + {term(t(title, { defaultValue: title }))} @@ -88,6 +91,7 @@ export function DashboardHeader({ {!hideCommunityLinks && } + {deploymentHealth} @@ -98,15 +102,16 @@ export function DashboardHeader({ } + render={
); } 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={ )}
@@ -94,7 +97,8 @@ interface NotificationsBellViewProps { } export const NotificationsBellView: React.FC = ({ currentVersion, latestRelease }) => { - const upgrade = upgradeAnnouncement(currentVersion, latestRelease); + const { t } = useTranslation(); + const upgrade = upgradeAnnouncement(currentVersion, latestRelease, t); const announcements = upgrade ? [upgrade, AUTO_ROUTER_ANNOUNCEMENT] : [AUTO_ROUTER_ANNOUNCEMENT]; const unreadCount = useUnreadAnnouncementCount(announcements.map((announcement) => announcement.readKey)); @@ -102,14 +106,14 @@ export const NotificationsBellView: React.FC = ({ cu 0 ? `Notifications, ${unreadCount} unread` : "Notifications"} + aria-label={unreadCount > 0 ? t("notificationsUnread", { count: unreadCount }) : t("Notifications")} > {unreadCount > 0 ? : null} - + {announcements.map((announcement, index) => ( {index > 0 ? : null} diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 69117073ced..a704978ddc9 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"; @@ -79,6 +80,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(); @@ -101,16 +103,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")} )} @@ -119,25 +121,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")} />
{premiumUser === true && canUseLiteAdmin && ( @@ -222,7 +224,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 ( @@ -235,7 +237,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} /> @@ -262,7 +267,10 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar
diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx index 8786d7df631..e117756f32d 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nextProvider } from "react-i18next"; +import { createDashboardI18n } from "@/i18n"; import userEvent from "@testing-library/user-event"; import { renderWithProviders, screen, testQueryClient, within } from "@/../tests/test-utils"; @@ -21,13 +23,15 @@ vi.mock("@/components/networking", () => ({ getUiConfig: getUiConfigMock, })); -function renderSwitcher() { +function renderSwitcher(language: "en" | "zh-CN" = "en") { return renderWithProviders( - - - - - , + + + + + + + , ); } @@ -65,6 +69,29 @@ describe("ViewSwitcher", () => { vi.unstubAllEnvs(); }); + it.each([ + ["/ui/chat", "聊天"], + ["/ui/moyai", "Moyai"], + ["/ui/", "AI 网关"], + ])("localizes the active workspace at %s while preserving product names", (pathname, label) => { + state.pathname = pathname; + renderSwitcher("zh-CN"); + expect(screen.getByRole("button", { name: `切换工作区,当前:${label}` })).toHaveTextContent(label); + }); + + it("localizes workspace descriptions and disabled Chat without changing navigation", async () => { + const user = userEvent.setup(); + renderSwitcher("zh-CN"); + await user.click(screen.getByRole("button", { name: "切换工作区,当前:AI 网关" })); + const chat = await screen.findByRole("menuitem", { + name: "聊天 使用 LLM、MCP 和智能体的聊天界面 管理员可在设置中启用", + }); + expect(chat).toHaveAttribute("aria-disabled", "true"); + expect(assignSpy).not.toHaveBeenCalled(); + await user.click(await screen.findByRole("menuitem", { name: "Moyai 云端编程智能体" })); + expect(assignSpy).toHaveBeenCalledWith("/ui/moyai"); + }); + it("should render the workspace selector and keep disabled Chat unavailable", async () => { const user = userEvent.setup(); renderSwitcher(); @@ -88,6 +115,16 @@ describe("ViewSwitcher", () => { expect(screen.queryByRole("menuitem", { name: "AI Gateway" })).not.toBeInTheDocument(); }); + it("preserves SI terminology when the workspace selector is in Chinese", async () => { + getUiConfigMock.mockResolvedValue({ use_si_terminology: true }); + const user = userEvent.setup(); + renderSwitcher("zh-CN"); + + await user.click(await screen.findByRole("button", { name: "切换工作区,当前:SI 网关" })); + expect(await screen.findByRole("menuitem", { name: "SI 网关" })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "AI 网关" })).not.toBeInTheDocument(); + }); + it("should show the active plugin and switch plugin modes within the gateway", async () => { state.plugins = [ { name: "metrics", display_name: "Metrics", url: "/metrics" }, diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index 8469a8edc35..f7b61f4a8d7 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 { useTerminology } from "@/contexts/TerminologyContext"; import React from "react"; import Image from "next/image"; @@ -31,11 +32,11 @@ function activeWorkspaceFor( isChatRoute: boolean, isMoyaiRoute: boolean, pluginLabel: string | undefined, - gatewayLabel: string, + labels: { gateway: string; chat: string }, ): { label: string; icon: WorkspaceIconName } { - if (isChatRoute) return { label: "Chat", icon: CHAT }; + if (isChatRoute) return { label: labels.chat, icon: CHAT }; if (isMoyaiRoute) return { label: "Moyai", icon: MOYAI }; - return { label: pluginLabel ?? gatewayLabel, icon: GATEWAY }; + return { label: pluginLabel ?? labels.gateway, icon: GATEWAY }; } function WorkspaceIcon({ workspace }: { workspace: WorkspaceIconName }) { @@ -81,6 +82,7 @@ interface ViewSwitcherProps { } export default function ViewSwitcher({ className, contentClassName }: ViewSwitcherProps) { + const { t } = useTranslation(); const term = useTerminology(); const { mode, setMode, plugins } = usePluginMode(); const { data: uiSettings } = useUISettings(); @@ -94,12 +96,12 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch const isMoyaiRoute = isWorkspaceRoute(normalizedPathname, MOYAI); const isStandaloneRoute = isChatRoute || isMoyaiRoute; - const gatewayLabel = term("AI Gateway"); + const gatewayLabel = term(t("AI Gateway")); const { label: activeLabel, icon: activeIcon } = activeWorkspaceFor( isChatRoute, isMoyaiRoute, plugins.find((p) => p.name === mode)?.display_name, - gatewayLabel, + { gateway: gatewayLabel, chat: t("Chat") }, ); const modeEntries = [ @@ -122,11 +124,13 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch - Chat{" "} - Chat UI for using LLMs, MCPs, Agents{" "} + {t("Chat")}{" "} + + {t("Chat UI for using LLMs, MCPs, Agents")} + {" "} {!chatEnabled && ( - Admins can enable in Settings + {t("Admins can enable in Settings")} )} @@ -160,7 +164,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch Moyai{" "} - Cloud Coding Agent + {t("Cloud Coding Agent")} {isMoyaiRoute && } @@ -173,7 +177,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch return ( { 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 173e3b3f07e..e5072175971 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"; @@ -98,6 +99,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(); @@ -119,29 +121,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), }, @@ -150,8 +152,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 ( @@ -192,7 +197,7 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla 🌴 @@ -211,27 +216,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")}> +
@@ -284,7 +289,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 eec764c4cd8..a697feb8154 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"; + const label = isDark ? t("Switch to light mode") : t("Switch to dark mode"); return (
); diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index f3bff85e2d5..c7bb67d26d8 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, within } 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 { TerminologyProvider } from "@/contexts/TerminologyContext"; import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav"; @@ -168,6 +170,21 @@ describe("Sidebar (leftnav)", () => { expect(screen.queryByText("AI Hub")).not.toBeInTheDocument(); }); + it("preserves SI terminology in Chinese labels and collapsed navigation titles", () => { + uiConfig.data = { admin_ui_disabled: false, use_si_terminology: true }; + renderWithProviders( + + + + + , + ); + + expect(screen.getByText("SI 网关")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "SI 资源中心" })).toHaveAttribute("title", "SI 资源中心"); + expect(screen.getByRole("link", { name: "SI 资源中心" })).toHaveAttribute("href", "/ui/model-hub-table"); + }); + it("should link the logo to the UI home route rather than the proxy origin", () => { renderWithProviders(); @@ -753,3 +770,57 @@ describe("getBreadcrumb", () => { expect(getBreadcrumb("/ui/some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" }); }); }); + +describe("localized navigation", () => { + it("translates new navigation labels while preserving their routes and Beta badges", async () => { + mockUseAuthorized.mockReset(); + const instance = createDashboardI18n("zh-CN"); + renderWithProviders( + + + , + ); + + const leaderboard = screen.getByRole("link", { name: /模型排行榜/ }); + expect(leaderboard).toHaveAttribute("href", "/ui/model-insights"); + expect(leaderboard).toHaveAttribute("title", "模型排行榜"); + expect(within(leaderboard).getByText("Beta")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "成本优化", exact: true })).not.toHaveTextContent("Beta"); + + await act(async () => { + await instance.changeLanguage("en"); + }); + expect(screen.getByRole("link", { name: /Model Leaderboard/ })).toHaveAttribute("title", "Model Leaderboard"); + }); + + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 2cf6f8219db..22fa497b5be 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 { canViewProjectsPage, projectReaderRoles } from "@/app/(dashboard)/hooks/projects/projectAccess"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; @@ -114,6 +115,7 @@ export interface MenuItem { roles?: string[]; children?: MenuItem[]; icon?: React.ReactNode; + badge?: React.ReactNode; external_url?: string; } @@ -302,11 +304,8 @@ const menuGroups: MenuGroup[] = [ page: "model-insights", icon: , roles: all_admin_roles, - label: ( - - Model Leaderboard - - ), + label: "Model Leaderboard", + badge: , }, { key: "cost-optimization", @@ -342,11 +341,8 @@ const menuGroups: MenuGroup[] = [ { key: "projects", page: "projects", - label: ( - - Projects - - ), + label: "Projects", + badge: , icon: , roles: [...projectReaderRoles], }, @@ -539,9 +535,9 @@ const Sidebar_: React.FC = ({ disableVectorStoresForInternalUsers, allowVectorStoresForTeamAdmins, }) => { + const { t } = useTranslation(); const { userId, accessToken, userRole, isViewOnly } = useAuthorized(); const term = useTerminology(); - const labelOf = (item: MenuItem) => (typeof item.label === "string" ? term(item.label) : item.label); const isOrgAdmin = useIsOrgAdmin(); const { data: teams } = useTeams(); const { logoUrl, logoUrlDark } = useTheme(); @@ -614,10 +610,24 @@ const Sidebar_: React.FC = ({ }); }; + const translatedLabel = (item: MenuItem) => { + const label = typeof item.label === "string" ? term(t(item.label, { defaultValue: 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 = {labelOf(item)}; + const label = ( + {translatedLabel(item)} + ); if (item.external_url) { return ( @@ -626,7 +636,7 @@ const Sidebar_: React.FC = ({ href={item.external_url} target="_blank" rel="noopener noreferrer" - title={collapsed ? term(labelText(item)) : undefined} + title={collapsed ? term(t(labelText(item), { defaultValue: labelText(item) })) : undefined} data-active={active || undefined} className={cn(sidebarMenuButtonVariants({ isActive: active, size }))} > @@ -641,7 +651,7 @@ const Sidebar_: React.FC = ({ @@ -665,10 +675,10 @@ const Sidebar_: React.FC = ({ isActive={active} aria-expanded={open} onClick={() => toggleGroup(item.key)} - title={collapsed ? term(labelText(item)) : undefined} + title={collapsed ? term(t(labelText(item), { defaultValue: labelText(item) })) : undefined} > {item.icon} - {labelOf(item)} + {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 ? : } @@ -735,7 +745,7 @@ const Sidebar_: React.FC = ({ {visibleGroups.map((group, gi) => ( {gi > 0 && } - {term(group.groupLabel)} + {term(t(group.groupLabel, { defaultValue: 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 cece15663ec..aff30ca8d55 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"; @@ -39,6 +41,7 @@ const Navbar: React.FC = ({ deploymentHealth, }) => { const baseUrl = getProxyBaseUrl(); + const { t } = useTranslation(); const { logoUrl, logoUrlDark } = useTheme(); const { data: healthData } = useHealthReadinessDetails(accessToken); const version = healthData?.litellm_version; @@ -67,7 +70,7 @@ const Navbar: React.FC = ({