mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
[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:
parent
82cdb5b0fb
commit
83e6096dae
2 changed files with 451 additions and 88 deletions
|
|
@ -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" }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue