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:
Cursor Agent
2026-03-12 07:50:36 +00:00
co-authored by yuneng-jiang
parent d03404d21e
commit 7737e9c313
4 changed files with 118 additions and 128 deletions
@@ -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>