Merge pull request #20095 from BerriAI/litellm_user_dropdown_v2

[Refactor] UI - Navbar: User Dropdown
This commit is contained in:
yuneng-jiang 2026-01-30 18:12:25 -08:00 committed by GitHub
commit 267993b388
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 487 additions and 147 deletions

View file

@ -0,0 +1,289 @@
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
import UserDropdown from "./UserDropdown";
let mockUseAuthorizedImpl = () => ({
userId: "test-user-id",
userEmail: "test@example.com",
userRole: "Admin",
premiumUser: false,
});
let mockUseDisableShowPromptsImpl = () => false;
let mockGetLocalStorageItemImpl = (key: string): string | null => {
if (key === "disableShowNewBadge") return null;
if (key === "disableShowPrompts") return null;
return null;
};
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorizedImpl(),
}));
vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({
useDisableShowPrompts: () => mockUseDisableShowPromptsImpl(),
}));
vi.mock("@/utils/localStorageUtils", () => ({
LOCAL_STORAGE_EVENT: "local-storage-change",
getLocalStorageItem: (key: string) => mockGetLocalStorageItemImpl(key),
setLocalStorageItem: vi.fn(),
removeLocalStorageItem: vi.fn(),
emitLocalStorageChange: vi.fn(),
}));
describe("UserDropdown", () => {
const mockOnLogout = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockUseAuthorizedImpl = () => ({
userId: "test-user-id",
userEmail: "test@example.com",
userRole: "Admin",
premiumUser: false,
});
mockUseDisableShowPromptsImpl = () => false;
mockGetLocalStorageItemImpl = (key: string): string | null => {
if (key === "disableShowNewBadge") return null;
if (key === "disableShowPrompts") return null;
return null;
};
});
it("should render", () => {
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("should display user button with User text", () => {
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
expect(screen.getByText("User")).toBeInTheDocument();
});
it("should show user email when dropdown is opened", async () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
});
});
it("should show user ID when dropdown is opened", async () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("test-user-id")).toBeInTheDocument();
});
});
it("should show user role when dropdown is opened", async () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("Admin")).toBeInTheDocument();
});
});
it("should display Standard badge for non-premium users", async () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("Standard")).toBeInTheDocument();
});
});
it("should display Premium badge for premium users", async () => {
const user = userEvent.setup();
mockUseAuthorizedImpl = () => ({
userId: "test-user-id",
userEmail: "test@example.com",
userRole: "Admin",
premiumUser: true,
});
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("Premium")).toBeInTheDocument();
});
});
it("should call onLogout when logout is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
});
await user.click(screen.getByText("Logout"));
expect(mockOnLogout).toHaveBeenCalledTimes(1);
});
it("should toggle hide new feature indicators switch", async () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
});
const toggle = screen.getByLabelText("Toggle hide new feature indicators");
expect(toggle).not.toBeChecked();
await user.click(toggle);
const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils"));
expect(localStorageUtils.setLocalStorageItem).toHaveBeenCalledWith("disableShowNewBadge", "true");
expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowNewBadge");
});
it("should toggle hide new feature indicators switch off", async () => {
const user = userEvent.setup();
mockGetLocalStorageItemImpl = (key: string): string | null => {
if (key === "disableShowNewBadge") return "true";
return null;
};
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
});
const toggle = screen.getByLabelText("Toggle hide new feature indicators");
expect(toggle).toBeChecked();
await user.click(toggle);
const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils"));
expect(localStorageUtils.removeLocalStorageItem).toHaveBeenCalledWith("disableShowNewBadge");
expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowNewBadge");
});
it("should toggle hide all prompts switch", async () => {
const user = userEvent.setup();
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
});
const toggle = screen.getByLabelText("Toggle hide all prompts");
expect(toggle).not.toBeChecked();
await user.click(toggle);
const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils"));
expect(localStorageUtils.setLocalStorageItem).toHaveBeenCalledWith("disableShowPrompts", "true");
expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowPrompts");
});
it("should toggle hide all prompts switch off", async () => {
const user = userEvent.setup();
mockUseDisableShowPromptsImpl = () => true;
mockGetLocalStorageItemImpl = (key: string): string | null => {
if (key === "disableShowPrompts") return "true";
return null;
};
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
});
const toggle = screen.getByLabelText("Toggle hide all prompts");
expect(toggle).toBeChecked();
await user.click(toggle);
const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils"));
expect(localStorageUtils.removeLocalStorageItem).toHaveBeenCalledWith("disableShowPrompts");
expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowPrompts");
});
it("should display dash when user email is not available", async () => {
const user = userEvent.setup();
mockUseAuthorizedImpl = () => ({
userId: "test-user-id",
userEmail: null as any,
userRole: "Admin",
premiumUser: false,
});
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("-")).toBeInTheDocument();
});
});
it("should display dash when user ID is not available", async () => {
const user = userEvent.setup();
mockUseAuthorizedImpl = () => ({
userId: null as any,
userEmail: "test@example.com",
userRole: "Admin",
premiumUser: false,
});
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
const dashElements = screen.getAllByText("-");
expect(dashElements.length).toBeGreaterThan(0);
});
});
it("should initialize hide new feature indicators from localStorage", async () => {
const user = userEvent.setup();
mockGetLocalStorageItemImpl = (key: string): string | null => {
if (key === "disableShowNewBadge") return "true";
return null;
};
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("test@example.com")).toBeInTheDocument();
});
const toggle = screen.getByLabelText("Toggle hide new feature indicators");
expect(toggle).toBeChecked();
});
});

View file

@ -0,0 +1,161 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import {
emitLocalStorageChange,
getLocalStorageItem,
removeLocalStorageItem,
setLocalStorageItem,
} from "@/utils/localStorageUtils";
import {
CrownOutlined,
DownOutlined,
LogoutOutlined,
MailOutlined,
SafetyOutlined,
UserOutlined,
} from "@ant-design/icons";
import type { MenuProps } from "antd";
import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd";
import React, { useEffect, useState } from "react";
const { Text } = Typography;
interface UserDropdownProps {
onLogout: () => void;
}
const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
const { userId, userEmail, userRole, premiumUser } = useAuthorized();
const disableShowPrompts = useDisableShowPrompts();
const [disableShowNewBadge, setDisableShowNewBadge] = useState(false);
useEffect(() => {
const storedValue = getLocalStorageItem("disableShowNewBadge");
setDisableShowNewBadge(storedValue === "true");
}, []);
const userItems: MenuProps["items"] = [
{
key: "logout",
label: (
<Space>
<LogoutOutlined />
Logout
</Space>
),
onClick: onLogout,
},
];
const renderUserInfoSection = () => (
<Space direction="vertical" size="small" style={{ width: "100%", padding: "12px" }}>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Space>
<MailOutlined />
<Text type="secondary">{userEmail || "-"}</Text>
</Space>
{premiumUser ? (
<Tag
icon={<CrownOutlined />}
color="gold"
>
Premium
</Tag>
) : (
<Tooltip title="Upgrade to Premium for advanced features" placement="left">
<Tag
icon={<CrownOutlined />}
>
Standard
</Tag>
</Tooltip>
)}
</Space>
<Divider style={{ margin: "8px 0" }} />
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Space>
<UserOutlined />
<Text type="secondary">User ID</Text>
</Space>
<Text
copyable
ellipsis
style={{ maxWidth: "150px" }}
title={userId || "-"}
>
{userId || "-"}
</Text>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Space>
<SafetyOutlined />
<Text type="secondary">Role</Text>
</Space>
<Text>{userRole}</Text>
</Space>
<Divider style={{ margin: "8px 0" }} />
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide New Feature Indicators</Text>
<Switch
size="small"
checked={disableShowNewBadge}
onChange={(checked) => {
setDisableShowNewBadge(checked);
if (checked) {
setLocalStorageItem("disableShowNewBadge", "true");
emitLocalStorageChange("disableShowNewBadge");
} else {
removeLocalStorageItem("disableShowNewBadge");
emitLocalStorageChange("disableShowNewBadge");
}
}}
aria-label="Toggle hide new feature indicators"
/>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide All Prompts</Text>
<Switch
size="small"
checked={disableShowPrompts}
onChange={(checked) => {
if (checked) {
setLocalStorageItem("disableShowPrompts", "true");
emitLocalStorageChange("disableShowPrompts");
} else {
removeLocalStorageItem("disableShowPrompts");
emitLocalStorageChange("disableShowPrompts");
}
}}
aria-label="Toggle hide all prompts"
/>
</Space>
</Space>
);
return (
<Dropdown
menu={{ items: userItems }}
popupRender={(menu) => (
<div
className="bg-white rounded-lg shadow-lg"
>
{renderUserInfoSection()}
<Divider style={{ margin: 0 }} />
{React.cloneElement(menu as React.ReactElement, {
style: { boxShadow: "none" },
})}
</div>
)}
>
<Button type="text" >
<Space>
<UserOutlined />
<Text>User</Text>
<DownOutlined />
</Space>
</Button>
</Dropdown>
);
};
export default UserDropdown;

View file

@ -15,8 +15,14 @@ vi.mock("@/utils/proxyUtils", () => ({
// Create mock functions that can be controlled in tests
let mockUseThemeImpl = () => ({ logoUrl: null as string | null });
let mockUseHealthReadinessImpl = () => ({ data: null as any });
let mockGetLocalStorageItemImpl = () => null as string | null;
let mockGetLocalStorageItemImpl = (key: string) => null as string | null;
let mockUseDisableShowPromptsImpl = () => false;
let mockUseAuthorizedImpl = () => ({
userId: "test-user",
userEmail: "test@example.com",
userRole: "Admin",
premiumUser: false,
});
vi.mock("@/contexts/ThemeContext", () => ({
useTheme: () => mockUseThemeImpl(),
@ -30,9 +36,13 @@ vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({
useDisableShowPrompts: () => mockUseDisableShowPromptsImpl(),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorizedImpl(),
}));
vi.mock("@/utils/localStorageUtils", () => ({
LOCAL_STORAGE_EVENT: "local-storage-change",
getLocalStorageItem: () => mockGetLocalStorageItemImpl(),
getLocalStorageItem: (key: string) => mockGetLocalStorageItemImpl(key),
setLocalStorageItem: vi.fn(),
removeLocalStorageItem: vi.fn(),
emitLocalStorageChange: vi.fn(),
@ -123,14 +133,27 @@ describe("Navbar", () => {
it("should show premium user badge when premiumUser is true", async () => {
const user = userEvent.setup();
const premiumProps = { ...defaultProps, premiumUser: true };
renderWithProviders(<Navbar {...premiumProps} />);
mockUseAuthorizedImpl = () => ({
userId: "test-user",
userEmail: "test@example.com",
userRole: "Admin",
premiumUser: true,
});
renderWithProviders(<Navbar {...defaultProps} />);
await user.click(screen.getByText("User"));
await waitFor(() => {
expect(screen.getByText("Premium")).toBeInTheDocument();
});
// Reset mock
mockUseAuthorizedImpl = () => ({
userId: "test-user",
userEmail: "test@example.com",
userRole: "Admin",
premiumUser: false,
});
});
it("should show version badge when health data contains version", () => {
@ -167,7 +190,10 @@ describe("Navbar", () => {
const user = userEvent.setup();
// Initially disabled
mockGetLocalStorageItemImpl = () => "false";
mockGetLocalStorageItemImpl = (key: string) => {
if (key === "disableShowNewBadge") return "false";
return null;
};
renderWithProviders(<Navbar {...defaultProps} />);
@ -186,6 +212,9 @@ describe("Navbar", () => {
const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils"));
expect(localStorageUtils.setLocalStorageItem).toHaveBeenCalledWith("disableShowNewBadge", "true");
expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowNewBadge");
// Reset mock
mockGetLocalStorageItemImpl = (key: string) => null;
});
it("should handle logout functionality", async () => {

View file

@ -1,32 +1,20 @@
import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import { getProxyBaseUrl } from "@/components/networking";
import { useTheme } from "@/contexts/ThemeContext";
import { clearTokenCookies } from "@/utils/cookieUtils";
import {
emitLocalStorageChange,
getLocalStorageItem,
removeLocalStorageItem,
setLocalStorageItem,
} from "@/utils/localStorageUtils";
import { fetchProxySettings } from "@/utils/proxyUtils";
import {
CrownOutlined,
GithubOutlined,
LogoutOutlined,
MailOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MoonOutlined,
SafetyOutlined,
SlackOutlined,
SunOutlined,
UserOutlined,
} from "@ant-design/icons";
import type { MenuProps } from "antd";
import { Button, Dropdown, Switch, Tag, Tooltip } from "antd";
import { Button, Switch, Tag } from "antd";
import Link from "next/link";
import React, { useEffect, useState } from "react";
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
interface NavbarProps {
userID: string | null;
@ -59,8 +47,6 @@ const Navbar: React.FC<NavbarProps> = ({
}) => {
const baseUrl = getProxyBaseUrl();
const [logoutUrl, setLogoutUrl] = useState("");
const [disableShowNewBadge, setDisableShowNewBadge] = useState(false);
const disableShowPrompts = useDisableShowPrompts();
const { logoUrl } = useTheme();
const { data: healthData } = useHealthReadiness();
const version = healthData?.litellm_version;
@ -82,11 +68,6 @@ const Navbar: React.FC<NavbarProps> = ({
initializeProxySettings();
}, [accessToken]);
useEffect(() => {
const storedValue = getLocalStorageItem("disableShowNewBadge");
setDisableShowNewBadge(storedValue === "true");
}, []);
useEffect(() => {
setLogoutUrl(proxySettings?.PROXY_LOGOUT_URL || "");
}, [proxySettings]);
@ -96,105 +77,6 @@ const Navbar: React.FC<NavbarProps> = ({
window.location.href = logoutUrl;
};
const userItems: MenuProps["items"] = [
{
key: "user-info",
// Prevent dropdown from closing when interacting with the toggle
onClick: (info) => info.domEvent?.stopPropagation(),
label: (
<div className="px-3 py-3 border-b border-gray-100">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center">
<UserOutlined className="mr-2 text-gray-700" />
<span className="text-sm font-semibold text-gray-900">{userID}</span>
</div>
{premiumUser ? (
<Tooltip title="Premium User" placement="left">
<div className="flex items-center bg-gradient-to-r from-amber-500 to-yellow-500 text-white px-2 py-0.5 rounded-full cursor-help">
<CrownOutlined className="mr-1 text-xs" />
<span className="text-xs font-medium">Premium</span>
</div>
</Tooltip>
) : (
<Tooltip title="Upgrade to Premium for advanced features" placement="left">
<div className="flex items-center bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full cursor-help">
<CrownOutlined className="mr-1 text-xs" />
<span className="text-xs font-medium">Standard</span>
</div>
</Tooltip>
)}
</div>
<div className="space-y-2">
<div className="flex items-center text-sm">
<SafetyOutlined className="mr-2 text-gray-400 text-xs" />
<span className="text-gray-500 text-xs">Role</span>
<span className="ml-auto text-gray-700 font-medium">{userRole}</span>
</div>
<div className="flex items-center text-sm">
<MailOutlined className="mr-2 text-gray-400 text-xs" />
<span className="text-gray-500 text-xs">Email</span>
<span className="ml-auto text-gray-700 font-medium truncate max-w-[150px]" title={userEmail || "Unknown"}>
{userEmail || "Unknown"}
</span>
</div>
<div
className="flex items-center text-sm pt-2 mt-2 border-t border-gray-100"
onClick={(e) => e.stopPropagation()}
>
<span className="text-gray-500 text-xs">Hide New Feature Indicators</span>
<Switch
className="ml-auto"
size="small"
checked={disableShowNewBadge}
onChange={(checked) => {
setDisableShowNewBadge(checked);
if (checked) {
setLocalStorageItem("disableShowNewBadge", "true");
emitLocalStorageChange("disableShowNewBadge");
} else {
removeLocalStorageItem("disableShowNewBadge");
emitLocalStorageChange("disableShowNewBadge");
}
}}
aria-label="Toggle hide new feature indicators"
/>
</div>
<div
className="flex items-center text-sm pt-2 mt-2 border-t border-gray-100"
onClick={(e) => e.stopPropagation()}
>
<span className="text-gray-500 text-xs">Hide All Prompts</span>
<Switch
className="ml-auto"
size="small"
checked={disableShowPrompts}
onChange={(checked) => {
if (checked) {
setLocalStorageItem("disableShowPrompts", "true");
emitLocalStorageChange("disableShowPrompts");
} else {
removeLocalStorageItem("disableShowPrompts");
emitLocalStorageChange("disableShowPrompts");
}
}}
aria-label="Toggle hide all prompts"
/>
</div>
</div>
</div>
),
},
{
key: "logout",
label: (
<div className="flex items-center py-2 px-3 hover:bg-gray-50 rounded-md mx-1 my-1" onClick={handleLogout}>
<LogoutOutlined className="mr-3 text-gray-600" />
<span className="text-gray-800">Logout</span>
</div>
),
},
];
return (
<nav className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="w-full">
@ -284,28 +166,7 @@ const Navbar: React.FC<NavbarProps> = ({
</a>
{!isPublicPage && (
<Dropdown
menu={{
items: userItems,
className: "min-w-[200px]",
style: {
padding: "8px",
marginTop: "8px",
borderRadius: "12px",
boxShadow: "0 4px 24px rgba(0, 0, 0, 0.08)",
},
}}
overlayStyle={{
minWidth: "200px",
}}
>
<button className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 transition-colors">
User
<svg className="ml-1 w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 9l-7 7-7-7" />
</svg>
</button>
</Dropdown>
<UserDropdown onLogout={handleLogout} />
)}
</div>
</div>