[Feature] UI - Internal Users: Add/remove team membership from user info page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-14 09:37:44 -07:00
parent 82cdb5b0fb
commit 83e6096dae
2 changed files with 451 additions and 88 deletions

View file

@ -1,32 +1,47 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import UserInfoView from "./user_info_view";
vi.mock("../networking", () => {
const MOCK_USER_DATA = {
user_id: "user-123",
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: [],
};
const mockTeamMemberAddCall = vi.fn();
const mockTeamMemberDeleteCall = vi.fn();
const mockTeamListCall = vi.fn();
const mockUserGetInfoV2 = vi.fn();
const mockTeamInfoCall = vi.fn();
const MOCK_USER_DATA = {
user_id: "user-123",
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: ["team-1", "team-2"],
};
const MOCK_USER_DATA_NO_TEAMS = {
...MOCK_USER_DATA,
teams: [],
};
vi.mock("../networking", () => {
return {
userGetInfoV2: vi.fn().mockResolvedValue(MOCK_USER_DATA),
userGetInfoV2: (...args: any[]) => mockUserGetInfoV2(...args),
userDeleteCall: vi.fn(),
userUpdateUserCall: vi.fn(),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
invitationCreateCall: vi.fn(),
teamInfoCall: vi.fn().mockResolvedValue({ team_alias: "Test Team" }),
teamInfoCall: (...args: any[]) => mockTeamInfoCall(...args),
teamListCall: (...args: any[]) => mockTeamListCall(...args),
teamMemberAddCall: (...args: any[]) => mockTeamMemberAddCall(...args),
teamMemberDeleteCall: (...args: any[]) => mockTeamMemberDeleteCall(...args),
getProxyBaseUrl: () => "https://litellm.test",
};
});
@ -36,10 +51,30 @@ describe("UserInfoView", () => {
userId: "user-123",
onClose: vi.fn(),
accessToken: "test-token",
userRole: null,
userRole: null as string | null,
possibleUIRoles: null,
};
beforeEach(() => {
vi.clearAllMocks();
mockUserGetInfoV2.mockResolvedValue(MOCK_USER_DATA);
mockTeamInfoCall.mockImplementation((_token: string, teamId: string) => {
const teamMap: Record<string, any> = {
"team-1": { team_id: "team-1", team_info: { team_alias: "Alpha Team" } },
"team-2": { team_id: "team-2", team_info: { team_alias: "Beta Team" } },
"team-3": { team_id: "team-3", team_info: { team_alias: "Gamma Team" } },
};
return Promise.resolve(teamMap[teamId] || { team_id: teamId, team_info: { team_alias: null } });
});
mockTeamListCall.mockResolvedValue([
{ team_id: "team-1", team_alias: "Alpha Team" },
{ team_id: "team-2", team_alias: "Beta Team" },
{ team_id: "team-3", team_alias: "Gamma Team" },
]);
mockTeamMemberAddCall.mockResolvedValue({});
mockTeamMemberDeleteCall.mockResolvedValue({});
});
it("should render the loading state", () => {
render(<UserInfoView {...defaultProps} />);
@ -60,4 +95,125 @@ describe("UserInfoView", () => {
const aliases = await screen.findAllByText("Test Alias");
expect(aliases.length).toBeGreaterThan(0);
});
it("should render teams in a table with team names", async () => {
render(<UserInfoView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Alpha Team")).toBeInTheDocument();
expect(screen.getByText("Beta Team")).toBeInTheDocument();
});
});
it("should show 'No teams' when user has no teams", async () => {
mockUserGetInfoV2.mockResolvedValue(MOCK_USER_DATA_NO_TEAMS);
render(<UserInfoView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("No teams")).toBeInTheDocument();
});
});
it("should show Add Team button for proxy admins", async () => {
render(<UserInfoView {...defaultProps} userRole="proxy_admin" />);
await waitFor(() => {
expect(screen.getByText("Add Team")).toBeInTheDocument();
});
});
it("should not show Add Team button for non-proxy-admins", async () => {
render(<UserInfoView {...defaultProps} userRole="internal_user" />);
await waitFor(() => {
expect(screen.getByText("Alpha Team")).toBeInTheDocument();
});
expect(screen.queryByText("Add Team")).not.toBeInTheDocument();
});
it("should show delete buttons for proxy admins", async () => {
render(<UserInfoView {...defaultProps} userRole="proxy_admin" />);
await waitFor(() => {
expect(screen.getByText("Alpha Team")).toBeInTheDocument();
});
// Should have the Actions column header
expect(screen.getByText("Actions")).toBeInTheDocument();
});
it("should not show delete buttons for non-proxy-admins", async () => {
render(<UserInfoView {...defaultProps} userRole="internal_user" />);
await waitFor(() => {
expect(screen.getByText("Alpha Team")).toBeInTheDocument();
});
expect(screen.queryByText("Actions")).not.toBeInTheDocument();
});
it("should open the add team modal when Add Team is clicked", async () => {
const user = userEvent.setup();
render(<UserInfoView {...defaultProps} userRole="proxy_admin" />);
await waitFor(() => {
expect(screen.getByText("Add Team")).toBeInTheDocument();
});
await user.click(screen.getByText("Add Team"));
await waitFor(() => {
expect(screen.getByText("Add User to Team")).toBeInTheDocument();
});
expect(mockTeamListCall).toHaveBeenCalledWith("test-token", null);
});
it("should open remove confirmation modal when delete is clicked", async () => {
const user = userEvent.setup();
render(<UserInfoView {...defaultProps} userRole="proxy_admin" />);
await waitFor(() => {
expect(screen.getByText("Alpha Team")).toBeInTheDocument();
});
// Find the row with Alpha Team and click its delete button
const alphaRow = screen.getByText("Alpha Team").closest("tr")!;
const deleteButton = within(alphaRow).getByRole("button");
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText("Remove from Team")).toBeInTheDocument();
expect(screen.getByText(/Removing this user from the team will also delete any keys/)).toBeInTheDocument();
});
});
it("should call teamMemberDeleteCall when remove is confirmed", async () => {
const user = userEvent.setup();
render(<UserInfoView {...defaultProps} userRole="proxy_admin" />);
await waitFor(() => {
expect(screen.getByText("Alpha Team")).toBeInTheDocument();
});
// Click delete on Alpha Team
const alphaRow = screen.getByText("Alpha Team").closest("tr")!;
const deleteButton = within(alphaRow).getByRole("button");
await user.click(deleteButton);
// Confirm deletion
await waitFor(() => {
expect(screen.getByText("Remove from Team")).toBeInTheDocument();
});
// The DeleteResourceModal's OK button has text "Delete" - find it within the modal
const modal = screen.getByText("Remove from Team").closest(".ant-modal") as HTMLElement;
const deleteConfirmButton = within(modal).getByRole("button", { name: /delete/i });
await user.click(deleteConfirmButton);
await waitFor(() => {
expect(mockTeamMemberDeleteCall).toHaveBeenCalledWith(
"test-token",
"team-1",
{ role: "user", user_id: "user-123" }
);
});
});
});

View file

@ -1,6 +1,9 @@
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 {
Card, Text, Button, Grid, Tab, TabList, TabGroup, TabPanel, TabPanels, Title,
Table, TableHead, TableBody, TableRow, TableHeaderCell, TableCell,
} from "@tremor/react";
import { ArrowLeftIcon, TrashIcon, RefreshIcon, PlusIcon } from "@heroicons/react/outline";
import {
userGetInfoV2,
UserInfoV2Response,
@ -10,8 +13,12 @@ import {
invitationCreateCall,
getProxyBaseUrl,
teamInfoCall,
teamListCall,
teamMemberAddCall,
teamMemberDeleteCall,
Member,
} from "../networking";
import { Button as AntdButton } from "antd";
import { Button as AntdButton, Modal, Select as AntdSelect, Form, Tooltip } from "antd";
import { rolesWithWriteAccess } from "../../utils/roles";
import { UserEditView } from "../user_edit_view";
import OnboardingModal, { InvitationLink } from "../onboarding_link";
@ -61,6 +68,15 @@ export default function UserInfoView({
const [activeTab, setActiveTab] = useState(initialTab);
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const [isTeamsExpanded, setIsTeamsExpanded] = useState(false);
const [isAddTeamModalOpen, setIsAddTeamModalOpen] = useState(false);
const [isRemoveTeamModalOpen, setIsRemoveTeamModalOpen] = useState(false);
const [teamToRemove, setTeamToRemove] = useState<TeamDisplayInfo | null>(null);
const [isAddingTeam, setIsAddingTeam] = useState(false);
const [isRemovingTeam, setIsRemovingTeam] = useState(false);
const [allTeams, setAllTeams] = useState<Array<{ team_id: string; team_alias: string }>>([]);
const [selectedTeamId, setSelectedTeamId] = useState<string>("");
const [selectedRole, setSelectedRole] = useState<string>("user");
const [isLoadingTeams, setIsLoadingTeams] = useState(false);
React.useEffect(() => {
setBaseUrl(getProxyBaseUrl());
@ -82,7 +98,7 @@ export default function UserInfoView({
const teamData = await teamInfoCall(accessToken, teamId);
return {
team_id: teamId,
team_alias: teamData?.team_alias || null,
team_alias: teamData?.team_info?.team_alias || null,
};
} catch {
return { team_id: teamId, team_alias: null };
@ -111,6 +127,118 @@ export default function UserInfoView({
fetchData();
}, [accessToken, userId, userRole]);
const isProxyAdmin = userRole === "proxy_admin" || userRole === "Admin";
const fetchAllTeams = async () => {
if (!accessToken) return;
setIsLoadingTeams(true);
try {
const teams = await teamListCall(accessToken, null);
setAllTeams(
(teams || []).map((t: any) => ({
team_id: t.team_id,
team_alias: t.team_alias || t.team_id,
}))
);
} catch (error) {
console.error("Error fetching teams:", error);
} finally {
setIsLoadingTeams(false);
}
};
const handleOpenAddTeamModal = () => {
setSelectedTeamId("");
setSelectedRole("user");
setIsAddTeamModalOpen(true);
fetchAllTeams();
};
const handleAddTeamSubmit = async () => {
if (!accessToken || !selectedTeamId) return;
setIsAddingTeam(true);
try {
const member: Member = {
role: selectedRole,
user_id: userId,
};
await teamMemberAddCall(accessToken, selectedTeamId, member);
NotificationsManager.success("User added to team successfully");
setIsAddTeamModalOpen(false);
// Re-fetch user data to refresh teams
const data = await userGetInfoV2(accessToken, userId);
setUserData(data);
if (data.teams && data.teams.length > 0) {
const teamPromises = data.teams.map(async (teamId: string) => {
try {
const teamData = await teamInfoCall(accessToken, teamId);
return { team_id: teamId, team_alias: teamData?.team_info?.team_alias || null };
} catch {
return { team_id: teamId, team_alias: null };
}
});
setTeamDetails(await Promise.all(teamPromises));
} else {
setTeamDetails([]);
}
} catch (error: any) {
console.error("Error adding user to team:", error);
NotificationsManager.fromBackend(error?.message || "Failed to add user to team");
} finally {
setIsAddingTeam(false);
}
};
const handleOpenRemoveTeamModal = (team: TeamDisplayInfo) => {
setTeamToRemove(team);
setIsRemoveTeamModalOpen(true);
};
const handleRemoveTeamConfirm = async () => {
if (!accessToken || !teamToRemove) return;
setIsRemovingTeam(true);
try {
const member: Member = {
role: "user",
user_id: userId,
};
await teamMemberDeleteCall(accessToken, teamToRemove.team_id, member);
NotificationsManager.success("User removed from team successfully");
setIsRemoveTeamModalOpen(false);
setTeamToRemove(null);
// Re-fetch user data to refresh teams
const data = await userGetInfoV2(accessToken, userId);
setUserData(data);
if (data.teams && data.teams.length > 0) {
const teamPromises = data.teams.map(async (teamId: string) => {
try {
const teamData = await teamInfoCall(accessToken, teamId);
return { team_id: teamId, team_alias: teamData?.team_info?.team_alias || null };
} catch {
return { team_id: teamId, team_alias: null };
}
});
setTeamDetails(await Promise.all(teamPromises));
} else {
setTeamDetails([]);
}
} catch (error: any) {
console.error("Error removing user from team:", error);
NotificationsManager.fromBackend(error?.message || "Failed to remove user from team");
} finally {
setIsRemovingTeam(false);
}
};
const handleRemoveTeamCancel = () => {
setIsRemoveTeamModalOpen(false);
setTeamToRemove(null);
};
const availableTeamsForAdd = allTeams.filter(
(t) => !teamDetails.some((td) => td.team_id === t.team_id)
);
const handleResetPassword = async () => {
if (!accessToken) {
NotificationsManager.fromBackend("Access token not found");
@ -312,37 +440,72 @@ export default function UserInfoView({
</Card>
<Card>
<Text>Teams</Text>
<div className="flex justify-between items-center mb-2">
<Text>Teams</Text>
{isProxyAdmin && (
<Button
icon={PlusIcon}
variant="light"
size="xs"
onClick={handleOpenAddTeamModal}
>
Add Team
</Button>
)}
</div>
<div className="mt-2">
{teamDetails.length > 0 ? (
<div className="flex flex-wrap gap-2">
{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 && teamDetails.length > 20 && (
<Badge
color="gray"
className="cursor-pointer hover:bg-gray-200 transition-colors"
onClick={() => setIsTeamsExpanded(true)}
>
+{teamDetails.length - 20} more
</Badge>
)}
{isTeamsExpanded && teamDetails.length > 20 && (
<Badge
color="gray"
className="cursor-pointer hover:bg-gray-200 transition-colors"
onClick={() => setIsTeamsExpanded(false)}
>
Show Less
</Badge>
)}
<div className="max-h-60 overflow-y-auto">
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Team Name</TableHeaderCell>
{isProxyAdmin && <TableHeaderCell className="text-right">Actions</TableHeaderCell>}
</TableRow>
</TableHead>
<TableBody>
{teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team) => (
<TableRow key={team.team_id}>
<TableCell>{team.team_alias || team.team_id}</TableCell>
{isProxyAdmin && (
<TableCell className="text-right">
<Button
icon={TrashIcon}
variant="light"
size="xs"
color="red"
onClick={() => handleOpenRemoveTeamModal(team)}
/>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<Text>No teams</Text>
)}
{!isTeamsExpanded && teamDetails.length > 20 && (
<Button
variant="light"
size="xs"
className="mt-2"
onClick={() => setIsTeamsExpanded(true)}
>
+{teamDetails.length - 20} more
</Button>
)}
{isTeamsExpanded && teamDetails.length > 20 && (
<Button
variant="light"
size="xs"
className="mt-2"
onClick={() => setIsTeamsExpanded(false)}
>
Show Less
</Button>
)}
</div>
</Card>
@ -434,43 +597,6 @@ export default function UserInfoView({
</Text>
</div>
<div>
<Text className="font-medium">Teams</Text>
<div className="flex flex-wrap gap-2 mt-1">
{teamDetails.length > 0 ? (
<>
{teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team, index) => (
<span
key={index}
className="px-2 py-1 bg-blue-100 rounded text-xs"
title={team.team_alias || team.team_id}
>
{team.team_alias || team.team_id}
</span>
))}
{!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)}
>
+{teamDetails.length - 20} more
</span>
)}
{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)}
>
Show Less
</span>
)}
</>
) : (
<Text>No teams</Text>
)}
</div>
</div>
<div>
<Text className="font-medium">Personal Models</Text>
<div className="flex flex-wrap gap-2 mt-1">
@ -519,6 +645,87 @@ export default function UserInfoView({
invitationLinkData={invitationLinkData}
modalType="resetPassword"
/>
{/* Delete Team Member Modal */}
<DeleteResourceModal
isOpen={isRemoveTeamModalOpen}
title="Remove from Team"
alertMessage="Removing this user from the team will also delete any keys the user created for this team."
message="Are you sure you want to remove this user from the team? This action cannot be undone."
resourceInformationTitle="Team Membership"
resourceInformation={[
{ label: "Team", value: teamToRemove?.team_alias || teamToRemove?.team_id },
{ label: "User ID", value: userData?.user_id, code: true },
{ label: "Email", value: userData?.user_email },
]}
onCancel={handleRemoveTeamCancel}
onOk={handleRemoveTeamConfirm}
confirmLoading={isRemovingTeam}
/>
{/* Add to Team Modal */}
<Modal
title="Add User to Team"
open={isAddTeamModalOpen}
onCancel={() => setIsAddTeamModalOpen(false)}
footer={null}
width={500}
maskClosable={!isAddingTeam}
>
<Form
layout="vertical"
onFinish={handleAddTeamSubmit}
>
<Form.Item label="Team" required>
<AntdSelect
showSearch
value={selectedTeamId || undefined}
onChange={setSelectedTeamId}
placeholder="Select a team"
filterOption={(input, option) => {
const team = availableTeamsForAdd.find((t) => t.team_id === option?.value);
if (!team) return false;
return team.team_alias.toLowerCase().includes(input.toLowerCase());
}}
loading={isLoadingTeams}
>
{availableTeamsForAdd.map((team) => (
<AntdSelect.Option key={team.team_id} value={team.team_id}>
{team.team_alias}
</AntdSelect.Option>
))}
</AntdSelect>
</Form.Item>
<Form.Item label="Member Role">
<AntdSelect value={selectedRole} onChange={setSelectedRole}>
<AntdSelect.Option value="user">
<Tooltip title="Can view team info, but not manage it">
<span className="font-medium">user</span>
<span className="ml-2 text-gray-500 text-sm">- Can view team info, but not manage it</span>
</Tooltip>
</AntdSelect.Option>
<AntdSelect.Option value="admin">
<Tooltip title="Can create team keys, add members, and manage settings">
<span className="font-medium">admin</span>
<span className="ml-2 text-gray-500 text-sm">- Can create team keys, add members, and manage settings</span>
</Tooltip>
</AntdSelect.Option>
</AntdSelect>
</Form.Item>
<div className="text-right mt-4">
<AntdButton
type="primary"
htmlType="submit"
loading={isAddingTeam}
disabled={!selectedTeamId}
>
{isAddingTeam ? "Adding..." : "Add to Team"}
</AntdButton>
</div>
</Form>
</Modal>
</div>
);
}