From f670ebeb2ff4d40c59fd6859fd12309cb34f4a49 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Tue, 22 Apr 2025 21:55:47 -0700 Subject: [PATCH] Users page - new user info pane (#10213) * feat(user_info_view.tsx): be able to click in and see all teams user is part of makes it easy to see which teams a user belongs to * test(ui/): add unit testing for user info view * fix(user_info_view.tsx): fix linting errors * fix(login.ts): fix login * fix: fix linting error --- .../e2e_ui_tests/view_user_info.spec.ts | 82 +++++ tests/proxy_admin_ui_tests/utils/login.ts | 23 ++ .../src/components/networking.tsx | 6 +- .../src/components/view_users.tsx | 2 + .../src/components/view_users/table.tsx | 34 ++ .../components/view_users/user_info_view.tsx | 297 ++++++++++++++++++ 6 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts create mode 100644 tests/proxy_admin_ui_tests/utils/login.ts create mode 100644 ui/litellm-dashboard/src/components/view_users/user_info_view.tsx diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts new file mode 100644 index 0000000000..cc138f0946 --- /dev/null +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from "@playwright/test"; +import { loginToUI } from "../utils/login"; + +test.describe("User Info View", () => { + test.beforeEach(async ({ page }) => { + await loginToUI(page); + // Navigate to users page + await page.goto("http://localhost:4000/ui?page=users"); + }); + + test("should display user info when clicking on user ID", async ({ + page, + }) => { + // Wait for users table to load + await page.waitForSelector("table"); + + // Get the first user ID cell + const firstUserIdCell = page.locator( + "table tbody tr:first-child td:first-child" + ); + const userId = await firstUserIdCell.textContent(); + console.log("Found user ID:", userId); + + // Click on the user ID + await firstUserIdCell.click(); + + // Wait for user info view to load + await page.waitForSelector('h1:has-text("User")'); + console.log("User info view loaded"); + + // Check for tabs + await expect(page.locator('button:has-text("Overview")')).toBeVisible(); + await expect(page.locator('button:has-text("Details")')).toBeVisible(); + + // Switch to details tab + await page.locator('button:has-text("Details")').click(); + + // Check details section + await expect(page.locator("text=User ID")).toBeVisible(); + await expect(page.locator("text=Email")).toBeVisible(); + + // Go back to users list + await page.locator('button:has-text("Back to Users")').click(); + + // Verify we're back on the users page + await expect(page.locator('h1:has-text("Users")')).toBeVisible(); + }); + + // test("should handle user deletion", async ({ page }) => { + // // Wait for users table to load + // await page.waitForSelector("table"); + + // // Get the first user ID cell + // const firstUserIdCell = page.locator( + // "table tbody tr:first-child td:first-child" + // ); + // const userId = await firstUserIdCell.textContent(); + + // // Click on the user ID + // await firstUserIdCell.click(); + + // // Wait for user info view to load + // await page.waitForSelector('h1:has-text("User")'); + + // // Click delete button + // await page.locator('button:has-text("Delete User")').click(); + + // // Confirm deletion in modal + // await page.locator('button:has-text("Delete")').click(); + + // // Verify success message + // await expect(page.locator("text=User deleted successfully")).toBeVisible(); + + // // Verify we're back on the users page + // await expect(page.locator('h1:has-text("Users")')).toBeVisible(); + + // // Verify user is no longer in the table + // if (userId) { + // await expect(page.locator(`text=${userId}`)).not.toBeVisible(); + // } + // }); +}); diff --git a/tests/proxy_admin_ui_tests/utils/login.ts b/tests/proxy_admin_ui_tests/utils/login.ts new file mode 100644 index 0000000000..e875508997 --- /dev/null +++ b/tests/proxy_admin_ui_tests/utils/login.ts @@ -0,0 +1,23 @@ +import { Page, expect } from "@playwright/test"; + +export async function loginToUI(page: Page) { + // Login first + await page.goto("http://localhost:4000/ui"); + console.log("Navigated to login page"); + + // Wait for login form to be visible + await page.waitForSelector('input[name="username"]', { timeout: 10000 }); + console.log("Login form is visible"); + + await page.fill('input[name="username"]', "admin"); + await page.fill('input[name="password"]', "gm"); + console.log("Filled login credentials"); + + const loginButton = page.locator('input[type="submit"]'); + await expect(loginButton).toBeEnabled(); + await loginButton.click(); + console.log("Clicked login button"); + + // Wait for navigation to complete + await page.waitForURL("**/*"); +} diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 8049899179..e518314e64 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -766,8 +766,10 @@ export const userInfoCall = async ( userRole: String, viewAll: Boolean = false, page: number | null, - page_size: number | null + page_size: number | null, + lookup_user_id: boolean = false ) => { + console.log(`userInfoCall: ${userID}, ${userRole}, ${viewAll}, ${page}, ${page_size}, ${lookup_user_id}`) try { let url: string; @@ -781,7 +783,7 @@ export const userInfoCall = async ( } else { // Use /user/info endpoint for individual user info url = proxyBaseUrl ? `${proxyBaseUrl}/user/info` : `/user/info`; - if (userRole === "Admin" || userRole === "Admin Viewer") { + if ((userRole === "Admin" || userRole === "Admin Viewer") && !lookup_user_id) { // do nothing } else if (userID) { url += `?user_id=${userID}`; diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index e50aa1d5b1..1fdcbc5a09 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -644,6 +644,8 @@ const ViewUserDashboard: React.FC = ({ data={userListResponse?.users || []} columns={tableColumns} isLoading={!userListResponse} + accessToken={accessToken} + userRole={userRole} onSortChange={handleSortChange} currentSort={{ sortBy: filters.sort_by, diff --git a/ui/litellm-dashboard/src/components/view_users/table.tsx b/ui/litellm-dashboard/src/components/view_users/table.tsx index e35852e67a..7674a6491f 100644 --- a/ui/litellm-dashboard/src/components/view_users/table.tsx +++ b/ui/litellm-dashboard/src/components/view_users/table.tsx @@ -18,6 +18,7 @@ import { } from "@tremor/react"; import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline"; import { UserInfo } from "./types"; +import UserInfoView from "./user_info_view"; interface UserDataTableProps { data: UserInfo[]; @@ -28,6 +29,8 @@ interface UserDataTableProps { sortBy: string; sortOrder: 'asc' | 'desc'; }; + accessToken: string | null; + userRole: string | null; } export function UserDataTable({ @@ -36,6 +39,8 @@ export function UserDataTable({ isLoading = false, onSortChange, currentSort, + accessToken, + userRole, }: UserDataTableProps) { const [sorting, setSorting] = React.useState([ { @@ -43,6 +48,7 @@ export function UserDataTable({ desc: currentSort?.sortOrder === "desc" } ]); + const [selectedUserId, setSelectedUserId] = React.useState(null); const table = useReactTable({ data, @@ -64,6 +70,14 @@ export function UserDataTable({ enableSorting: true, }); + const handleUserClick = (userId: string) => { + setSelectedUserId(userId); + }; + + const handleCloseUserInfo = () => { + setSelectedUserId(null); + }; + // Update local sorting state when currentSort prop changes React.useEffect(() => { if (currentSort) { @@ -74,6 +88,17 @@ export function UserDataTable({ } }, [currentSort]); + if (selectedUserId) { + return ( + + ); + } + return (
@@ -138,6 +163,15 @@ export function UserDataTable({ ? 'sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]' : '' }`} + onClick={() => { + if (cell.column.id === 'user_id') { + handleUserClick(cell.getValue() as string); + } + }} + style={{ + cursor: cell.column.id === 'user_id' ? 'pointer' : 'default', + color: cell.column.id === 'user_id' ? '#3b82f6' : 'inherit', + }} > {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx new file mode 100644 index 0000000000..4e82f20821 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx @@ -0,0 +1,297 @@ +import React, { useState } from "react"; +import { + Card, + Text, + Button, + Grid, + Col, + Tab, + TabList, + TabGroup, + TabPanel, + TabPanels, + Title, + Badge, +} from "@tremor/react"; +import { ArrowLeftIcon, TrashIcon } from "@heroicons/react/outline"; +import { userInfoCall, userDeleteCall } from "../networking"; +import { message } from "antd"; +import { rolesWithWriteAccess } from '../../utils/roles'; + +interface UserInfoViewProps { + userId: string; + onClose: () => void; + accessToken: string | null; + userRole: string | null; + onDelete?: () => void; +} + +interface UserInfo { + user_id: string; + user_info: { + user_email: string | null; + user_role: string | null; + teams: any[] | null; + models: string[] | null; + max_budget: number | null; + spend: number | null; + metadata: Record | null; + created_at: string | null; + updated_at: string | null; + }; + keys: any[] | null; + teams: any[] | null; +} + +export default function UserInfoView({ userId, onClose, accessToken, userRole, onDelete }: UserInfoViewProps) { + const [userData, setUserData] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + React.useEffect(() => { + console.log(`userId: ${userId}, userRole: ${userRole}, accessToken: ${accessToken}`) + const fetchUserData = async () => { + try { + if (!accessToken) return; + const data = await userInfoCall(accessToken, userId, userRole || "", false, null, null, true); + setUserData(data); + } catch (error) { + console.error("Error fetching user data:", error); + message.error("Failed to fetch user data"); + } finally { + setIsLoading(false); + } + }; + + fetchUserData(); + }, [accessToken, userId, userRole]); + + const handleDelete = async () => { + try { + if (!accessToken) return; + await userDeleteCall(accessToken, [userId]); + message.success("User deleted successfully"); + if (onDelete) { + onDelete(); + } + onClose(); + } catch (error) { + console.error("Error deleting user:", error); + message.error("Failed to delete user"); + } + }; + + if (isLoading) { + return ( +
+ + Loading user data... +
+ ); + } + + if (!userData) { + return ( +
+ + User not found +
+ ); + } + + return ( +
+
+
+ + {userData.user_info?.user_email || "User"} + {userData.user_id} +
+ {userRole && rolesWithWriteAccess.includes(userRole) && ( + + )} +
+ + {/* Delete Confirmation Modal */} + {isDeleteModalOpen && ( +
+
+ + + + +
+
+
+
+

+ Delete User +

+
+

+ Are you sure you want to delete this user? +

+
+
+
+
+
+ + +
+
+
+
+ )} + + + + Overview + Details + + + + {/* Overview Panel */} + + + + Spend +
+ ${Number(userData.user_info?.spend || 0).toFixed(4)} + of {userData.user_info?.max_budget !== null ? `$${userData.user_info.max_budget}` : "Unlimited"} +
+
+ + + Teams +
+ {userData.teams?.length || 0} teams +
+
+ + + API Keys +
+ {userData.keys?.length || 0} keys +
+
+
+
+ + {/* Details Panel */} + + +
+
+ User ID + {userData.user_id} +
+ +
+ Email + {userData.user_info?.user_email || "Not Set"} +
+ +
+ Role + {userData.user_info?.user_role || "Not Set"} +
+ +
+ Created + {userData.user_info?.created_at ? new Date(userData.user_info.created_at).toLocaleString() : "Unknown"} +
+ +
+ Last Updated + {userData.user_info?.updated_at ? new Date(userData.user_info.updated_at).toLocaleString() : "Unknown"} +
+ +
+ Teams +
+ {userData.teams?.length && userData.teams?.length > 0 ? ( + userData.teams?.map((team, index) => ( + + {team.team_alias || team.team_id} + + )) + ) : ( + No teams + )} +
+
+ +
+ API Keys +
+ {userData.keys?.length && userData.keys?.length > 0 ? ( + userData.keys.map((key, index) => ( + + {key.key_alias || key.token} + + )) + ) : ( + No API keys + )} +
+
+ +
+ Metadata +
+                    {JSON.stringify(userData.user_info?.metadata || {}, null, 2)}
+                  
+
+
+
+
+
+
+
+ ); +} \ No newline at end of file