mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-08 22:25:25 +00:00
feat(ui): migrate user_dashboard.tsx and user_info_view.tsx to /v2/user/info
- user_dashboard.tsx: Replace userInfoCall with userGetInfoV2 for spend data,
remove keys/teams logic (keys come from props/useKeys hook, teams from fetchTeams)
- user_info_view.tsx: Replace userInfoCall with userGetInfoV2, flatten data
structure from nested {user_info: {...}} to flat response, fetch team details
separately using teamInfoCall, remove keys display (Virtual Keys section)
- Update user_dashboard.test.tsx and user_info_view.test.tsx mocks
Co-authored-by: yuneng-jiang <yuneng-jiang@users.noreply.github.com>
This commit is contained in:
co-authored by
yuneng-jiang
parent
d03404d21e
commit
7737e9c313
@@ -21,7 +21,14 @@ vi.mock("./networking", async (importOriginal) => {
|
||||
getProxyUISettings: vi.fn().mockResolvedValue({}),
|
||||
keyInfoCall: vi.fn().mockResolvedValue({}),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
userInfoCall: vi.fn().mockResolvedValue({ user_info: {}, keys: [], teams: [] }),
|
||||
userGetInfoV2: vi.fn().mockResolvedValue({
|
||||
user_id: "user-1",
|
||||
user_email: "test@example.com",
|
||||
spend: 0,
|
||||
max_budget: null,
|
||||
models: [],
|
||||
teams: [],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
keyInfoCall,
|
||||
modelAvailableCall,
|
||||
Organization,
|
||||
userInfoCall,
|
||||
userGetInfoV2,
|
||||
} from "./networking";
|
||||
import CreateKey, { CreateKeyPrefillData } from "./organisms/create_key_button";
|
||||
import { VirtualKeysTable } from "./VirtualKeysPage/VirtualKeysTable";
|
||||
@@ -165,7 +165,7 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
if (userID && accessToken && userRole && !keys && !userSpendData) {
|
||||
if (userID && accessToken && userRole && !userSpendData) {
|
||||
const cachedUserModels = sessionStorage.getItem("userModels" + userID);
|
||||
if (cachedUserModels) {
|
||||
setUserModels(JSON.parse(cachedUserModels));
|
||||
@@ -176,26 +176,11 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
||||
const proxy_settings: ProxySettings = await getProxyUISettings(accessToken);
|
||||
setProxySettings(proxy_settings);
|
||||
|
||||
const response = await userInfoCall(accessToken, userID, userRole, false, null, null);
|
||||
const response = await userGetInfoV2(accessToken, userID);
|
||||
|
||||
setUserSpendData(response["user_info"]);
|
||||
console.log(`userSpendData: ${JSON.stringify(userSpendData)}`);
|
||||
setUserSpendData(response);
|
||||
|
||||
// set keys for admin and users
|
||||
if (!response?.teams[0].keys) {
|
||||
setKeys(response["keys"]);
|
||||
} else {
|
||||
setKeys(
|
||||
response["keys"].concat(
|
||||
response.teams
|
||||
.filter((team: any) => userRole === "Admin" || team.user_id === userID)
|
||||
.flatMap((team: any) => team.keys),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
sessionStorage.setItem("userData" + userID, JSON.stringify(response["keys"]));
|
||||
sessionStorage.setItem("userSpendData" + userID, JSON.stringify(response["user_info"]));
|
||||
sessionStorage.setItem("userSpendData" + userID, JSON.stringify(response));
|
||||
|
||||
const model_available = await modelAvailableCall(accessToken, userID, userRole);
|
||||
// loop through model_info["data"] and create an array of element.model_name
|
||||
@@ -218,7 +203,7 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
||||
fetchTeams(accessToken, userID, userRole, currentOrg, setTeams);
|
||||
}
|
||||
}
|
||||
}, [userID, token, accessToken, keys, userRole]);
|
||||
}, [userID, token, accessToken, userRole]);
|
||||
|
||||
useEffect(() => {
|
||||
// check key health - if it's invalid, redirect to login
|
||||
|
||||
@@ -5,29 +5,28 @@ import UserInfoView from "./user_info_view";
|
||||
vi.mock("../networking", () => {
|
||||
const MOCK_USER_DATA = {
|
||||
user_id: "user-123",
|
||||
user_info: {
|
||||
user_email: "test@example.com",
|
||||
user_alias: "Test Alias",
|
||||
user_role: "admin",
|
||||
teams: [],
|
||||
models: [],
|
||||
max_budget: 100,
|
||||
budget_duration: "30d",
|
||||
spend: 0,
|
||||
metadata: {},
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
updated_at: "2025-01-02T00:00:00.000Z",
|
||||
},
|
||||
keys: [],
|
||||
user_email: "test@example.com",
|
||||
user_alias: "Test Alias",
|
||||
user_role: "admin",
|
||||
spend: 0,
|
||||
max_budget: 100,
|
||||
models: [],
|
||||
budget_duration: "30d",
|
||||
budget_reset_at: null,
|
||||
metadata: {},
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
updated_at: "2025-01-02T00:00:00.000Z",
|
||||
sso_user_id: null,
|
||||
teams: [],
|
||||
};
|
||||
|
||||
return {
|
||||
userInfoCall: vi.fn().mockResolvedValue(MOCK_USER_DATA),
|
||||
userGetInfoV2: vi.fn().mockResolvedValue(MOCK_USER_DATA),
|
||||
userDeleteCall: vi.fn(),
|
||||
userUpdateUserCall: vi.fn(),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
invitationCreateCall: vi.fn(),
|
||||
teamInfoCall: vi.fn().mockResolvedValue({ team_alias: "Test Team" }),
|
||||
getProxyBaseUrl: () => "https://litellm.test",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@ import React, { useState } from "react";
|
||||
import { Card, Text, Button, Grid, Tab, TabList, TabGroup, TabPanel, TabPanels, Title, Badge } from "@tremor/react";
|
||||
import { ArrowLeftIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
userInfoCall,
|
||||
userGetInfoV2,
|
||||
UserInfoV2Response,
|
||||
userDeleteCall,
|
||||
userUpdateUserCall,
|
||||
modelAvailableCall,
|
||||
invitationCreateCall,
|
||||
getProxyBaseUrl,
|
||||
teamInfoCall,
|
||||
} from "../networking";
|
||||
import { Button as AntdButton } from "antd";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
@@ -30,23 +32,10 @@ interface UserInfoViewProps {
|
||||
startInEditMode?: boolean;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
user_id: string;
|
||||
user_info: {
|
||||
user_email: string | null;
|
||||
user_alias: string | null;
|
||||
user_role: string | null;
|
||||
teams: any[] | null;
|
||||
models: string[] | null;
|
||||
max_budget: number | null;
|
||||
budget_duration: string | null;
|
||||
spend: number | null;
|
||||
metadata: Record<string, any> | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
keys: any[] | null;
|
||||
teams: any[] | null;
|
||||
/** Team info used for display in user detail view */
|
||||
interface TeamDisplayInfo {
|
||||
team_id: string;
|
||||
team_alias: string | null;
|
||||
}
|
||||
|
||||
export default function UserInfoView({
|
||||
@@ -59,7 +48,8 @@ export default function UserInfoView({
|
||||
initialTab = 0,
|
||||
startInEditMode = false,
|
||||
}: UserInfoViewProps) {
|
||||
const [userData, setUserData] = useState<UserInfo | null>(null);
|
||||
const [userData, setUserData] = useState<UserInfoV2Response | null>(null);
|
||||
const [teamDetails, setTeamDetails] = useState<TeamDisplayInfo[]>([]);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isDeletingUser, setIsDeletingUser] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -81,9 +71,31 @@ export default function UserInfoView({
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
const data = await userInfoCall(accessToken, userId, userRole || "", false, null, null, true);
|
||||
const data = await userGetInfoV2(accessToken, userId);
|
||||
setUserData(data);
|
||||
|
||||
// Fetch team details for display (team aliases)
|
||||
if (data.teams && data.teams.length > 0) {
|
||||
try {
|
||||
const teamPromises = data.teams.map(async (teamId: string) => {
|
||||
try {
|
||||
const teamData = await teamInfoCall(accessToken, teamId);
|
||||
return {
|
||||
team_id: teamId,
|
||||
team_alias: teamData?.team_alias || null,
|
||||
};
|
||||
} catch {
|
||||
return { team_id: teamId, team_alias: null };
|
||||
}
|
||||
});
|
||||
const teams = await Promise.all(teamPromises);
|
||||
setTeamDetails(teams);
|
||||
} catch {
|
||||
// Fall back to just team IDs
|
||||
setTeamDetails(data.teams.map((id: string) => ({ team_id: id, team_alias: null })));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch available models
|
||||
const modelDataResponse = await modelAvailableCall(accessToken, userId, userRole || "");
|
||||
const availableModels = modelDataResponse.data.map((model: any) => model.id);
|
||||
@@ -146,15 +158,12 @@ export default function UserInfoView({
|
||||
// Update local state with new values
|
||||
setUserData({
|
||||
...userData,
|
||||
user_info: {
|
||||
...userData.user_info,
|
||||
user_email: formValues.user_email,
|
||||
user_alias: formValues.user_alias,
|
||||
models: formValues.models,
|
||||
max_budget: formValues.max_budget,
|
||||
budget_duration: formValues.budget_duration,
|
||||
metadata: formValues.metadata,
|
||||
},
|
||||
user_email: formValues.user_email ?? userData.user_email,
|
||||
user_alias: formValues.user_alias ?? userData.user_alias,
|
||||
models: formValues.models ?? userData.models,
|
||||
max_budget: formValues.max_budget ?? userData.max_budget,
|
||||
budget_duration: formValues.budget_duration ?? userData.budget_duration,
|
||||
metadata: formValues.metadata ?? userData.metadata,
|
||||
});
|
||||
|
||||
NotificationsManager.success("User updated successfully");
|
||||
@@ -197,6 +206,20 @@ export default function UserInfoView({
|
||||
}
|
||||
};
|
||||
|
||||
// Build a legacy-compatible shape for UserEditView
|
||||
const userDataForEdit = {
|
||||
user_id: userData.user_id,
|
||||
user_info: {
|
||||
user_email: userData.user_email,
|
||||
user_alias: userData.user_alias,
|
||||
user_role: userData.user_role,
|
||||
models: userData.models,
|
||||
max_budget: userData.max_budget,
|
||||
budget_duration: userData.budget_duration,
|
||||
metadata: userData.metadata,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
@@ -204,7 +227,7 @@ export default function UserInfoView({
|
||||
<Button icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
|
||||
Back to Users
|
||||
</Button>
|
||||
<Title>{userData.user_info?.user_email || "User"}</Title>
|
||||
<Title>{userData.user_email || "User"}</Title>
|
||||
<div className="flex items-center cursor-pointer">
|
||||
<Text className="text-gray-500 font-mono">{userData.user_id}</Text>
|
||||
<AntdButton
|
||||
@@ -243,20 +266,20 @@ export default function UserInfoView({
|
||||
message="Are you sure you want to delete this user? This action cannot be undone."
|
||||
resourceInformationTitle="User Information"
|
||||
resourceInformation={[
|
||||
{ label: "Email", value: userData.user_info?.user_email },
|
||||
{ label: "Email", value: userData.user_email },
|
||||
{ label: "User ID", value: userData.user_id, code: true },
|
||||
{
|
||||
label: "Global Proxy Role",
|
||||
value:
|
||||
(userData.user_info?.user_role && possibleUIRoles?.[userData.user_info.user_role]?.ui_label) ||
|
||||
userData.user_info?.user_role ||
|
||||
(userData.user_role && possibleUIRoles?.[userData.user_role]?.ui_label) ||
|
||||
userData.user_role ||
|
||||
"-",
|
||||
},
|
||||
{
|
||||
label: "Total Spend (USD)",
|
||||
value:
|
||||
userData.user_info?.spend !== null && userData.user_info?.spend !== undefined
|
||||
? userData.user_info.spend.toFixed(2)
|
||||
userData.spend !== null && userData.spend !== undefined
|
||||
? userData.spend.toFixed(2)
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
@@ -278,11 +301,11 @@ export default function UserInfoView({
|
||||
<Card>
|
||||
<Text>Spend</Text>
|
||||
<div className="mt-2">
|
||||
<Title>${formatNumberWithCommas(userData.user_info?.spend || 0, 4)}</Title>
|
||||
<Title>${formatNumberWithCommas(userData.spend || 0, 4)}</Title>
|
||||
<Text>
|
||||
of{" "}
|
||||
{userData.user_info?.max_budget !== null
|
||||
? `$${formatNumberWithCommas(userData.user_info.max_budget, 4)}`
|
||||
{userData.max_budget !== null
|
||||
? `$${formatNumberWithCommas(userData.max_budget, 4)}`
|
||||
: "Unlimited"}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -291,23 +314,23 @@ export default function UserInfoView({
|
||||
<Card>
|
||||
<Text>Teams</Text>
|
||||
<div className="mt-2">
|
||||
{userData.teams?.length && userData.teams?.length > 0 ? (
|
||||
{teamDetails.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{userData.teams?.slice(0, isTeamsExpanded ? userData.teams.length : 20).map((team, index) => (
|
||||
<Badge key={index} color="blue" title={team.team_alias}>
|
||||
{team.team_alias}
|
||||
{teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team, index) => (
|
||||
<Badge key={index} color="blue" title={team.team_alias || team.team_id}>
|
||||
{team.team_alias || team.team_id}
|
||||
</Badge>
|
||||
))}
|
||||
{!isTeamsExpanded && userData.teams?.length > 20 && (
|
||||
{!isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<Badge
|
||||
color="gray"
|
||||
className="cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
onClick={() => setIsTeamsExpanded(true)}
|
||||
>
|
||||
+{userData.teams.length - 20} more
|
||||
+{teamDetails.length - 20} more
|
||||
</Badge>
|
||||
)}
|
||||
{isTeamsExpanded && userData.teams?.length > 20 && (
|
||||
{isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<Badge
|
||||
color="gray"
|
||||
className="cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
@@ -323,20 +346,11 @@ export default function UserInfoView({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Virtual Keys</Text>
|
||||
<div className="mt-2">
|
||||
<Text>
|
||||
{userData.keys?.length || 0} {userData.keys?.length === 1 ? "Key" : "Keys"}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Personal Models</Text>
|
||||
<div className="mt-2">
|
||||
{userData.user_info?.models?.length && userData.user_info?.models?.length > 0 ? (
|
||||
userData.user_info?.models?.map((model, index) => <Text key={index}>{model}</Text>)
|
||||
{userData.models?.length && userData.models?.length > 0 ? (
|
||||
userData.models?.map((model, index) => <Text key={index}>{model}</Text>)
|
||||
) : (
|
||||
<Text>All proxy models</Text>
|
||||
)}
|
||||
@@ -357,10 +371,10 @@ export default function UserInfoView({
|
||||
|
||||
{isEditing && userData ? (
|
||||
<UserEditView
|
||||
userData={userData}
|
||||
userData={userDataForEdit}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSubmit={handleUserUpdate}
|
||||
teams={userData.teams}
|
||||
teams={teamDetails}
|
||||
accessToken={accessToken}
|
||||
userID={userId}
|
||||
userRole={userRole}
|
||||
@@ -389,24 +403,24 @@ export default function UserInfoView({
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Email</Text>
|
||||
<Text>{userData.user_info?.user_email || "Not Set"}</Text>
|
||||
<Text>{userData.user_email || "Not Set"}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">User Alias</Text>
|
||||
<Text>{userData.user_info?.user_alias || "Not Set"}</Text>
|
||||
<Text>{userData.user_alias || "Not Set"}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Global Proxy Role</Text>
|
||||
<Text>{userData.user_info?.user_role || "Not Set"}</Text>
|
||||
<Text>{userData.user_role || "Not Set"}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Created</Text>
|
||||
<Text>
|
||||
{userData.user_info?.created_at
|
||||
? new Date(userData.user_info.created_at).toLocaleString()
|
||||
{userData.created_at
|
||||
? new Date(userData.created_at).toLocaleString()
|
||||
: "Unknown"}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -414,8 +428,8 @@ export default function UserInfoView({
|
||||
<div>
|
||||
<Text className="font-medium">Last Updated</Text>
|
||||
<Text>
|
||||
{userData.user_info?.updated_at
|
||||
? new Date(userData.user_info.updated_at).toLocaleString()
|
||||
{userData.updated_at
|
||||
? new Date(userData.updated_at).toLocaleString()
|
||||
: "Unknown"}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -423,9 +437,9 @@ export default function UserInfoView({
|
||||
<div>
|
||||
<Text className="font-medium">Teams</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{userData.teams?.length && userData.teams?.length > 0 ? (
|
||||
{teamDetails.length > 0 ? (
|
||||
<>
|
||||
{userData.teams?.slice(0, isTeamsExpanded ? userData.teams.length : 20).map((team, index) => (
|
||||
{teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 rounded text-xs"
|
||||
@@ -434,15 +448,15 @@ export default function UserInfoView({
|
||||
{team.team_alias || team.team_id}
|
||||
</span>
|
||||
))}
|
||||
{!isTeamsExpanded && userData.teams?.length > 20 && (
|
||||
{!isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<span
|
||||
className="px-2 py-1 bg-gray-100 rounded text-xs cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
onClick={() => setIsTeamsExpanded(true)}
|
||||
>
|
||||
+{userData.teams.length - 20} more
|
||||
+{teamDetails.length - 20} more
|
||||
</span>
|
||||
)}
|
||||
{isTeamsExpanded && userData.teams?.length > 20 && (
|
||||
{isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<span
|
||||
className="px-2 py-1 bg-gray-100 rounded text-xs cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
onClick={() => setIsTeamsExpanded(false)}
|
||||
@@ -460,8 +474,8 @@ export default function UserInfoView({
|
||||
<div>
|
||||
<Text className="font-medium">Personal Models</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{userData.user_info?.models?.length && userData.user_info?.models?.length > 0 ? (
|
||||
userData.user_info?.models?.map((model, index) => (
|
||||
{userData.models?.length && userData.models?.length > 0 ? (
|
||||
userData.models?.map((model, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{model}
|
||||
</span>
|
||||
@@ -472,39 +486,24 @@ export default function UserInfoView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Virtual Keys</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{userData.keys?.length && userData.keys?.length > 0 ? (
|
||||
userData.keys.map((key, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-green-100 rounded text-xs">
|
||||
{key.key_alias || key.token}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<Text>No Virtual Keys</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Max Budget</Text>
|
||||
<Text>
|
||||
{userData.user_info?.max_budget !== null && userData.user_info?.max_budget !== undefined
|
||||
? `$${formatNumberWithCommas(userData.user_info.max_budget, 4)}`
|
||||
{userData.max_budget !== null && userData.max_budget !== undefined
|
||||
? `$${formatNumberWithCommas(userData.max_budget, 4)}`
|
||||
: "Unlimited"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Budget Reset</Text>
|
||||
<Text>{getBudgetDurationLabel(userData.user_info?.budget_duration ?? null)}</Text>
|
||||
<Text>{getBudgetDurationLabel(userData.budget_duration ?? null)}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Metadata</Text>
|
||||
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto mt-1">
|
||||
{JSON.stringify(userData.user_info?.metadata || {}, null, 2)}
|
||||
{JSON.stringify(userData.metadata || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user