diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index a1fdf7d073..26fc734dae 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -751,8 +751,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; @@ -766,7 +768,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 99ae2c979e..f1b05e5cbe 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -594,6 +594,8 @@ const ViewUserDashboard: React.FC = ({ data={userListResponse.users || []} columns={tableColumns} isLoading={!userListResponse} + accessToken={accessToken} + userRole={userRole} /> diff --git a/ui/litellm-dashboard/src/components/view_users/table.tsx b/ui/litellm-dashboard/src/components/view_users/table.tsx index c426b42127..f02a0fb0d6 100644 --- a/ui/litellm-dashboard/src/components/view_users/table.tsx +++ b/ui/litellm-dashboard/src/components/view_users/table.tsx @@ -18,21 +18,27 @@ 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[]; columns: ColumnDef[]; isLoading?: boolean; + accessToken: string | null; + userRole: string | null; } export function UserDataTable({ data = [], columns, isLoading = false, + accessToken, + userRole, }: UserDataTableProps) { const [sorting, setSorting] = React.useState([ { id: "created_at", desc: true } ]); + const [selectedUserId, setSelectedUserId] = React.useState(null); const table = useReactTable({ data, @@ -46,6 +52,25 @@ export function UserDataTable({ enableSorting: true, }); + const handleUserClick = (userId: string) => { + setSelectedUserId(userId); + }; + + const handleCloseUserInfo = () => { + setSelectedUserId(null); + }; + + if (selectedUserId) { + return ( + + ); + } + return (
@@ -110,6 +135,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..1366a03f08 --- /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} teams +
+
+ + + API Keys +
+ {userData.keys.length} 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 > 0 ? ( + userData.teams.map((team, index) => ( + + {team.team_alias || team.team_id} + + )) + ) : ( + No teams + )} +
+
+ +
+ API Keys +
+ {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