diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts index 435000f9c88..0ce0cab183f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -5,6 +5,35 @@ import { useRouter } from "next/navigation"; import { jwtDecode } from "jwt-decode"; import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; +function formatUserRole(userRole: string) { + if (!userRole) { + return "Undefined Role"; + } + switch (userRole.toLowerCase()) { + case "app_owner": + return "App Owner"; + case "demo_app_owner": + return "App Owner"; + case "app_admin": + return "Admin"; + case "proxy_admin": + return "Admin"; + case "proxy_admin_viewer": + return "Admin Viewer"; + case "org_admin": + return "Org Admin"; + case "internal_user": + return "Internal User"; + case "internal_user_viewer": + case "internal_viewer": // TODO:remove if deprecated + return "Internal Viewer"; + case "app_user": + return "App User"; + default: + return "Unknown Role"; + } +} + const useAuthorized = () => { const router = useRouter(); @@ -35,7 +64,7 @@ const useAuthorized = () => { accessToken: decoded?.key ?? null, userId: decoded?.user_id ?? null, userEmail: decoded?.user_email ?? null, - userRole: decoded?.user_role ?? null, + userRole: formatUserRole(decoded?.user_role ?? null), premiumUser: decoded?.premium_user ?? null, disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null, showSSOBanner: decoded?.login_method === "username_password" ?? false, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 68ab361356f..97837ff8e0a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -25,7 +25,7 @@ function withBase(path: string): string { export default function Layout({ children }: { children: React.ReactNode }) { const router = useRouter(); const searchParams = useSearchParams(); - const { accessToken, userRole } = useAuthorized(); + const { accessToken, userRole, userId, userEmail, premiumUser } = useAuthorized(); const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false); const [page, setPage] = useState(() => { return searchParams.get("page") || "api-keys"; @@ -51,15 +51,13 @@ export default function Layout({ children }: { children: React.ReactNode }) { isPublicPage={false} sidebarCollapsed={sidebarCollapsed} onToggleSidebar={toggleSidebar} - userID={null} - userEmail={null} - userRole={null} - premiumUser={false} + userID={userId} + userEmail={userEmail} + userRole={userRole} + premiumUser={premiumUser} proxySettings={undefined} - setProxySettings={function (value: any): void { - throw new Error("Function not implemented."); - }} - accessToken={null} + setProxySettings={() => {}} + accessToken={accessToken} />
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx new file mode 100644 index 00000000000..fa0ec060946 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx @@ -0,0 +1,359 @@ +import React, { useState, useEffect } from "react"; +import { teamDeleteCall, Organization } from "@/components/networking"; +import { fetchTeams } from "@/components/common_components/fetch_teams"; +import { Form } from "antd"; +import TeamInfoView from "@/components/team/team_info"; +import TeamSSOSettings from "@/components/TeamSSOSettings"; +import { isAdminRole } from "@/utils/roles"; +import { Card, Button, Col, Text, Grid, TabPanel } from "@tremor/react"; +import AvailableTeamsPanel from "@/components/team/available_teams"; +import type { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; + +import { Member, v2TeamListCall } from "@/components/networking"; +import { updateExistingKeys } from "@/utils/dataUtils"; +import TeamsHeaderTabs from "@/app/(dashboard)/teams/components/TeamsHeaderTabs"; +import TeamsFilters from "@/app/(dashboard)/teams/components/TeamsFilters"; +import useFetchTeams from "@/app/(dashboard)/teams/hooks/useFetchTeams"; +import TeamsTable from "@/app/(dashboard)/teams/components/TeamsTable/TeamsTable"; +import DeleteTeamModal from "@/app/(dashboard)/teams/components/modals/DeleteTeamModal"; +import CreateTeamModal from "@/app/(dashboard)/teams/components/modals/CreateTeamModal"; + +interface TeamProps { + teams: Team[] | null; + accessToken: string | null; + setTeams: React.Dispatch>; + userID: string | null; + userRole: string | null; + organizations: Organization[] | null; + premiumUser?: boolean; +} + +interface FilterState { + team_id: string; + team_alias: string; + organization_id: string; + sort_by: string; + sort_order: "asc" | "desc"; +} + +interface TeamInfo { + members_with_roles: Member[]; +} + +interface PerTeamInfo { + keys: KeyResponse[]; + team_info: TeamInfo; +} + +const TeamsView: React.FC = ({ + teams, + accessToken, + setTeams, + userID, + userRole, + organizations, + premiumUser = false, +}) => { + const [currentOrg, setCurrentOrg] = useState(null); + const [showFilters, setShowFilters] = useState(false); + const [filters, setFilters] = useState({ + team_id: "", + team_alias: "", + organization_id: "", + sort_by: "created_at", + sort_order: "desc", + }); + + const [form] = Form.useForm(); + const [memberForm] = Form.useForm(); + + const [selectedTeamId, setSelectedTeamId] = useState(null); + const [editTeam, setEditTeam] = useState(false); + + const [isTeamModalVisible, setIsTeamModalVisible] = useState(false); + const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); + const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); + const [userModels, setUserModels] = useState([]); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [teamToDelete, setTeamToDelete] = useState(null); + const [perTeamInfo, setPerTeamInfo] = useState>({}); + + const [loggingSettings, setLoggingSettings] = useState([]); + const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({}); + const { lastRefreshed, onRefreshClick: handleRefreshClick } = useFetchTeams({ currentOrg, setTeams }); + + useEffect(() => { + const fetchTeamInfo = () => { + if (!teams) return; + + const newPerTeamInfo = teams.reduce( + (acc, team) => { + acc[team.team_id] = { + keys: team.keys || [], + team_info: { + members_with_roles: team.members_with_roles || [], + }, + }; + return acc; + }, + {} as Record, + ); + + setPerTeamInfo(newPerTeamInfo); + }; + + fetchTeamInfo(); + }, [teams]); + + const handleOk = () => { + setIsTeamModalVisible(false); + form.resetFields(); + setLoggingSettings([]); + setModelAliases({}); + }; + + const handleMemberOk = () => { + setIsAddMemberModalVisible(false); + setIsEditMemberModalVisible(false); + memberForm.resetFields(); + }; + + const handleCancel = () => { + setIsTeamModalVisible(false); + form.resetFields(); + setLoggingSettings([]); + setModelAliases({}); + }; + + const handleDelete = async (team_id: string) => { + // Set the team to delete and open the confirmation modal + setTeamToDelete(team_id); + setIsDeleteModalOpen(true); + }; + + const confirmDelete = async () => { + if (teamToDelete == null || teams == null || accessToken == null) { + return; + } + + try { + await teamDeleteCall(accessToken, teamToDelete); + // Successfully completed the deletion. Update the state to trigger a rerender. + fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); + } catch (error) { + console.error("Error deleting the team:", error); + // Handle any error situations, such as displaying an error message to the user. + } + + // Close the confirmation modal and reset the teamToDelete + setIsDeleteModalOpen(false); + setTeamToDelete(null); + }; + + const cancelDelete = () => { + // Close the confirmation modal and reset the teamToDelete + setIsDeleteModalOpen(false); + setTeamToDelete(null); + }; + + const is_team_admin = (team: any) => { + if (team == null || team.members_with_roles == null) { + return false; + } + for (let i = 0; i < team.members_with_roles.length; i++) { + let member = team.members_with_roles[i]; + if (member.user_id == userID && member.role == "admin") { + return true; + } + } + return false; + }; + + const handleFilterChange = (key: keyof FilterState, value: string) => { + const newFilters = { ...filters, [key]: value }; + setFilters(newFilters); + // Call teamListCall with the new filters + if (accessToken) { + v2TeamListCall( + accessToken, + newFilters.organization_id || null, + null, + newFilters.team_id || null, + newFilters.team_alias || null, + ) + .then((response) => { + if (response && response.teams) { + setTeams(response.teams); + } + }) + .catch((error) => { + console.error("Error fetching teams:", error); + }); + } + }; + + const handleSortChange = (sortBy: string, sortOrder: "asc" | "desc") => { + const newFilters = { + ...filters, + sort_by: sortBy, + sort_order: sortOrder, + }; + setFilters(newFilters); + // Call teamListCall with the new sort parameters + if (accessToken) { + v2TeamListCall( + accessToken, + filters.organization_id || null, + null, + filters.team_id || null, + filters.team_alias || null, + ) + .then((response) => { + if (response && response.teams) { + setTeams(response.teams); + } + }) + .catch((error) => { + console.error("Error fetching teams:", error); + }); + } + }; + + const handleFilterReset = () => { + setFilters({ + team_id: "", + team_alias: "", + organization_id: "", + sort_by: "created_at", + sort_order: "desc", + }); + // Reset teams list + if (accessToken) { + v2TeamListCall(accessToken, null, userID || null, null, null) + .then((response) => { + if (response && response.teams) { + setTeams(response.teams); + } + }) + .catch((error) => { + console.error("Error fetching teams:", error); + }); + } + }; + + return ( +
+ + + {(userRole == "Admin" || userRole == "Org Admin") && ( + + )} + {selectedTeamId ? ( + { + setTeams((teams) => { + if (teams == null) { + return teams; + } + const updated = teams.map((team) => { + if (data.team_id === team.team_id) { + return updateExistingKeys(team, data); + } + return team; + }); + // Minimal fix: refresh the full team list after an update + if (accessToken) { + fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); + } + return updated; + }); + }} + onClose={() => { + setSelectedTeamId(null); + setEditTeam(false); + }} + accessToken={accessToken} + is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))} + is_proxy_admin={userRole == "Admin"} + userModels={userModels} + editTeam={editTeam} + /> + ) : ( + + + + Click on “Team ID” to view team details and manage team members. + + + + +
+
+ +
+
+ + {isDeleteModalOpen && ( + + )} +
+ +
+
+ + + + {isAdminRole(userRole || "") && ( + + + + )} +
+ )} + {(userRole == "Admin" || userRole == "Org Admin") && ( + + )} + +
+
+ ); +}; + +export default TeamsView; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsFilters.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsFilters.tsx new file mode 100644 index 00000000000..3c7d0951a5e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsFilters.tsx @@ -0,0 +1,141 @@ +import { Select, SelectItem } from "@tremor/react"; +import React from "react"; +import { Organization } from "@/components/networking"; + +interface TeamsFiltersProps { + filters: FilterState; + organizations: Organization[] | null; + showFilters: boolean; + onToggleFilters: (toggle: boolean) => void; + onChange: (key: K, value: FilterState[K]) => void; + onReset: () => void; +} + +type FilterState = { + team_id: string; + team_alias: string; + organization_id: string; + sort_by: string; + sort_order: "asc" | "desc"; +}; + +const TeamsFilters = ({ + filters, + organizations, + showFilters, + onToggleFilters, + onChange, + onReset, +}: TeamsFiltersProps) => { + return ( +
+ {/* Search and Filter Controls */} +
+ {/* Team Alias Search */} +
+ onChange("team_alias", e.target.value)} + /> + + + +
+ + {/* Filter Button */} + + + {/* Reset Filters Button */} + +
+ + {/* Additional Filters */} + {showFilters && ( +
+ {/* Team ID Search */} +
+ onChange("team_id", e.target.value)} + /> + + + +
+ + {/* Organization Dropdown */} +
+ +
+
+ )} +
+ ); +}; + +export default TeamsFilters; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsHeaderTabs.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsHeaderTabs.tsx new file mode 100644 index 00000000000..d57cb3a74f0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsHeaderTabs.tsx @@ -0,0 +1,38 @@ +import { Icon, Tab, TabGroup, TabList, TabPanels, Text } from "@tremor/react"; +import { isAdminRole } from "@/utils/roles"; +import { RefreshIcon } from "@heroicons/react/outline"; +import React from "react"; + +type TeamsHeaderTabsProps = { + lastRefreshed: string; + onRefresh: () => void; + userRole: string | null; + children: React.ReactNode; +}; + +const TeamsHeaderTabs = ({ lastRefreshed, onRefresh, userRole, children }: TeamsHeaderTabsProps) => { + return ( + + +
+ Your Teams + Available Teams + {isAdminRole(userRole || "") && Default Team Settings} +
+
+ {lastRefreshed && Last Refreshed: {lastRefreshed}} + +
+
+ {children} +
+ ); +}; + +export default TeamsHeaderTabs; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx new file mode 100644 index 00000000000..5cabe4c4a8f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx @@ -0,0 +1,98 @@ +import { Badge, Icon, TableCell, Text } from "@tremor/react"; +import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import React, { useState } from "react"; +import { Team } from "@/components/key_team_helpers/key_list"; + +interface ModelsCellProps { + team: Team; +} + +const ModelsCell = ({ team }: ModelsCellProps) => { + const [expandedAccordion, setExpandedAccordion] = useState(false); + + return ( + 3 ? "px-0" : ""} + > +
+ {Array.isArray(team.models) ? ( +
+ {team.models.length === 0 ? ( + + All Proxy Models + + ) : ( + <> +
+ {team.models.length > 3 && ( +
+ { + setExpandedAccordion((prev) => !prev); + }} + /> +
+ )} +
+ {team.models.slice(0, 3).map((model: string, index: number) => + model === "all-proxy-models" ? ( + + All Proxy Models + + ) : ( + + + {model.length > 30 + ? `${getModelDisplayName(model).slice(0, 30)}...` + : getModelDisplayName(model)} + + + ), + )} + {team.models.length > 3 && !expandedAccordion && ( + + + +{team.models.length - 3} {team.models.length - 3 === 1 ? "more model" : "more models"} + + + )} + {expandedAccordion && ( +
+ {team.models.slice(3).map((model: string, index: number) => + model === "all-proxy-models" ? ( + + All Proxy Models + + ) : ( + + + {model.length > 30 + ? `${getModelDisplayName(model).slice(0, 30)}...` + : getModelDisplayName(model)} + + + ), + )} +
+ )} +
+
+ + )} +
+ ) : null} +
+
+ ); +}; + +export default ModelsCell; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/TeamsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/TeamsTable.tsx new file mode 100644 index 00000000000..4e8b3286c81 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/TeamsTable.tsx @@ -0,0 +1,177 @@ +import { + Badge, + Button, + Icon, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + Text, +} from "@tremor/react"; +import { Tooltip } from "antd"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { ChevronDownIcon, ChevronRightIcon, PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import React, { useState } from "react"; +import { type KeyResponse, Team } from "@/components/key_team_helpers/key_list"; +import { Member, Organization } from "@/components/networking"; +import ModelsCell from "@/app/(dashboard)/teams/components/TeamsTable/ModelsCell"; +import YourRoleCell from "@/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/YourRoleCell"; + +type TeamsTableProps = { + teams: Team[] | null; + currentOrg: Organization | null; + perTeamInfo: Record; + userRole: string | null; + userId: string | null; + setSelectedTeamId: (teamId: string) => void; + setEditTeam: (editTeam: boolean) => void; + onDeleteTeam: (teamId: string) => void; +}; + +interface TeamInfo { + members_with_roles: Member[]; +} + +interface PerTeamInfo { + keys: KeyResponse[]; + team_info: TeamInfo; +} + +const TeamsTable = ({ + teams, + currentOrg, + setSelectedTeamId, + perTeamInfo, + userRole, + userId, + setEditTeam, + onDeleteTeam, +}: TeamsTableProps) => { + return ( + + + + Team Name + Team ID + Created + Spend (USD) + Budget (USD) + Models + Organization + Your Role + Info + + + + + {teams && teams.length > 0 + ? teams + .filter((team) => { + if (!currentOrg) return true; + return team.organization_id === currentOrg.organization_id; + }) + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .map((team: any) => ( + + + {team["team_alias"]} + + +
+ + + +
+
+ + {team.created_at ? new Date(team.created_at).toLocaleDateString() : "N/A"} + + + {formatNumberWithCommas(team["spend"], 4)} + + + {team["max_budget"] !== null && team["max_budget"] !== undefined ? team["max_budget"] : "No limit"} + + + {team.organization_id} + + + + {perTeamInfo && + team.team_id && + perTeamInfo[team.team_id] && + perTeamInfo[team.team_id].keys && + perTeamInfo[team.team_id].keys.length}{" "} + Keys + + + {perTeamInfo && + team.team_id && + perTeamInfo[team.team_id] && + perTeamInfo[team.team_id].team_info && + perTeamInfo[team.team_id].team_info.members_with_roles && + perTeamInfo[team.team_id].team_info.members_with_roles.length}{" "} + Members + + + + {userRole == "Admin" ? ( + <> + { + setSelectedTeamId(team.team_id); + setEditTeam(true); + }} + /> + onDeleteTeam(team.team_id)} icon={TrashIcon} size="sm" /> + + ) : null} + +
+ )) + : null} +
+
+ ); +}; + +export default TeamsTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/TeamRoleBadge.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/TeamRoleBadge.test.tsx new file mode 100644 index 00000000000..b7e659403cb --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/TeamRoleBadge.test.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import TeamRoleBadge from "./TeamRoleBadge"; + +const renderBadge = (role: string | null) => render(
{TeamRoleBadge(role)}
); + +describe("TeamRoleBadge", () => { + it("renders admin badge with correct label, base classes, styles, and an icon", () => { + renderBadge("admin"); + const label = screen.getByText("Admin"); + const badge = label.closest("span")!; + expect(badge).toHaveClass("inline-flex", "items-center", "border", "text-xs", "font-medium"); + expect(badge).toHaveStyle({ + backgroundColor: "#EEF2FF", + color: "#3730A3", + borderColor: "#C7D2FE", + }); + expect(badge.querySelector("svg")).toBeInTheDocument(); // ShieldIcon renders as an SVG + }); + + it.each<[string | null]>([["user"], [null], ["viewer" as unknown as string]])( + "renders member badge for non-admin role (%p) with correct styles", + (role) => { + renderBadge(role); + const label = screen.getByText("Member"); + const badge = label.closest("span")!; + expect(badge).toHaveClass("inline-flex", "items-center", "border", "text-xs", "font-medium"); + expect(badge).toHaveStyle({ + backgroundColor: "#F3F4F6", + color: "#4B5563", + borderColor: "#E5E7EB", + }); + expect(badge.querySelector("svg")).toBeInTheDocument(); // UserIcon renders as an SVG + }, + ); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/TeamRoleBadge.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/TeamRoleBadge.tsx new file mode 100644 index 00000000000..394b5d85348 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/TeamRoleBadge.tsx @@ -0,0 +1,47 @@ +import { ShieldIcon, UserIcon } from "lucide-react"; + +const MEMBER_BADGE_BG = "#F3F4F6"; // gray-100 +const MEMBER_BADGE_TEXT = "#4B5563"; // gray-600 +const MEMBER_BADGE_BORDER = "#E5E7EB"; // gray-200 + +const ADMIN_BADGE_BG = "#EEF2FF"; // indigo-50 +const ADMIN_BADGE_TEXT = "#3730A3"; // indigo-800 +const ADMIN_BADGE_BORDER = "#C7D2FE"; // indigo-200 + +const TeamRoleBadge = (role: string | null) => { + const base = "inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium border"; + + switch (role) { + case "admin": + return ( + + + Admin + + ); + case "user": + default: + return ( + + + Member + + ); + } +}; + +export default TeamRoleBadge; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/YourRoleCell.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/YourRoleCell.test.tsx new file mode 100644 index 00000000000..20a4497159d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/YourRoleCell.test.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import type { Team } from "@/components/key_team_helpers/key_list"; +import YourRoleCell from "./YourRoleCell"; + +// Lightweight mocks for stable, focused tests +vi.mock("@tremor/react", () => ({ + TableCell: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +// The component invokes TeamRoleBadge as a function, so mock it as such +vi.mock("@/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/TeamRoleBadge", () => ({ + __esModule: true, + default: (role: string | null) => {role === "admin" ? "Admin" : "Member"}, +})); + +const team = (members?: Array<{ user_id: string; role: "admin" | "user" }>): Team => + ({ members_with_roles: members }) as unknown as Team; + +describe("YourRoleCell", () => { + it("renders Admin when the user is an admin of the team", () => { + render(); + expect(screen.getByTestId("cell")).toBeInTheDocument(); + expect(screen.getByTestId("badge")).toHaveTextContent("Admin"); + }); + + it("renders Member when the user is a regular member", () => { + render(); + expect(screen.getByTestId("badge")).toHaveTextContent("Member"); + }); + + it.each<[string, Team, string | null]>([ + ["userId is null", team([{ user_id: "u3", role: "admin" }]), null], + ["user not in team", team([{ user_id: "x", role: "user" }]), "y"], + ["team has no members", team([]), "u4"], + ["members field undefined", team(undefined), "u5"], + ])("falls back to Member when no role can be determined (%s)", (_label, t, uid) => { + render(); + expect(screen.getByTestId("badge")).toHaveTextContent("Member"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/YourRoleCell.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/YourRoleCell.tsx new file mode 100644 index 00000000000..66592943fd5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/YourRoleCell.tsx @@ -0,0 +1,22 @@ +import { TableCell } from "@tremor/react"; +import { Team } from "@/components/key_team_helpers/key_list"; +import TeamRoleBadge from "@/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/TeamRoleBadge"; + +interface YourRoleCellProps { + team: Team; + userId: string | null; +} + +const getUserRole = (team: Team, userId: string | null): string | null => { + if (!userId) return null; + const member = team.members_with_roles?.find((m) => m.user_id === userId); + return member?.role ?? null; +}; + +const YourRoleCell = ({ team, userId }: YourRoleCellProps) => { + const roleBadge = TeamRoleBadge(getUserRole(team, userId)); + + return {roleBadge}; +}; + +export default YourRoleCell; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx new file mode 100644 index 00000000000..e6d9a85898e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx @@ -0,0 +1,573 @@ +import { Button as Button2, Form, Input, Modal, Select as Select2, Tooltip } from "antd"; +import { Accordion, AccordionBody, AccordionHeader, Text, TextInput } from "@tremor/react"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { + fetchAvailableModelsForTeamOrKey, + getModelDisplayName, + unfurlWildcardModelsInList, +} from "@/components/key_team_helpers/fetch_available_models_team_key"; +import NumericalInput from "@/components/shared/numerical_input"; +import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector"; +import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; +import PremiumLoggingSettings from "@/components/common_components/PremiumLoggingSettings"; +import ModelAliasManager from "@/components/common_components/ModelAliasManager"; +import React, { useEffect, useState } from "react"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { fetchMCPAccessGroups, getGuardrailsList, Organization, Team, teamCreateCall } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions"; + +interface ModelAliases { + [key: string]: string; +} + +interface CreateTeamModalProps { + isTeamModalVisible: boolean; + handleOk: () => void; + handleCancel: () => void; + currentOrg: Organization | null; + organizations: Organization[] | null; + teams: Team[] | null; + setTeams: (teams: Team[] | null) => void; + modelAliases: ModelAliases; + setModelAliases: (modelAliases: ModelAliases) => void; + loggingSettings: any[]; + setLoggingSettings: (loggingSettings: any[]) => void; + setIsTeamModalVisible: (isTeamModalVisible: boolean) => void; +} + +const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { + let tempModelsToPick = []; + + if (organization) { + if (organization.models.length > 0) { + console.log(`organization.models: ${organization.models}`); + tempModelsToPick = organization.models; + } else { + // show all available models if the team has no models set + tempModelsToPick = userModels; + } + } else { + // no team set, show all available models + tempModelsToPick = userModels; + } + + return unfurlWildcardModelsInList(tempModelsToPick, userModels); +}; + +const CreateTeamModal = ({ + isTeamModalVisible, + handleOk, + handleCancel, + currentOrg, + organizations, + teams, + setTeams, + modelAliases, + setModelAliases, + loggingSettings, + setLoggingSettings, + setIsTeamModalVisible, +}: CreateTeamModalProps) => { + const { userId: userID, userRole, accessToken, premiumUser } = useAuthorized(); + const [form] = Form.useForm(); + const [userModels, setUserModels] = useState([]); + const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); + const [modelsToPick, setModelsToPick] = useState([]); + const [guardrailsList, setGuardrailsList] = useState([]); + const [mcpAccessGroups, setMcpAccessGroups] = useState([]); + const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); + + useEffect(() => { + const fetchUserModels = async () => { + try { + if (userID === null || userRole === null || accessToken === null) { + return; + } + const models = await fetchAvailableModelsForTeamOrKey(userID, userRole, accessToken); + if (models) { + setUserModels(models); + } + } catch (error) { + console.error("Error fetching user models:", error); + } + }; + + fetchUserModels(); + }, [accessToken, userID, userRole, teams]); + + useEffect(() => { + console.log(`currentOrgForCreateTeam: ${currentOrgForCreateTeam}`); + const models = getOrganizationModels(currentOrgForCreateTeam, userModels); + console.log(`models: ${models}`); + setModelsToPick(models); + form.setFieldValue("models", []); + }, [currentOrgForCreateTeam, userModels]); + + useEffect(() => { + fetchMcpAccessGroups(); + }, [accessToken]); + + useEffect(() => { + const fetchGuardrails = async () => { + try { + if (accessToken == null) { + return; + } + + const response = await getGuardrailsList(accessToken); + const guardrailNames = response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); + setGuardrailsList(guardrailNames); + } catch (error) { + console.error("Failed to fetch guardrails:", error); + } + }; + + fetchGuardrails(); + }, [accessToken]); + + const fetchMcpAccessGroups = async () => { + try { + if (accessToken == null) { + return; + } + const groups = await fetchMCPAccessGroups(accessToken); + setMcpAccessGroups(groups); + } catch (error) { + console.error("Failed to fetch MCP access groups:", error); + } + }; + + const handleCreate = async (formValues: Record) => { + try { + console.log(`formValues: ${JSON.stringify(formValues)}`); + if (accessToken != null) { + const newTeamAlias = formValues?.team_alias; + const existingTeamAliases = teams?.map((t) => t.team_alias) ?? []; + let organizationId = formValues?.organization_id || currentOrg?.organization_id; + if (organizationId === "" || typeof organizationId !== "string") { + formValues.organization_id = null; + } else { + formValues.organization_id = organizationId.trim(); + } + + // Remove guardrails from top level since it's now in metadata + if (existingTeamAliases.includes(newTeamAlias)) { + throw new Error(`Team alias ${newTeamAlias} already exists, please pick another alias`); + } + + NotificationsManager.info("Creating Team"); + + // Handle logging settings in metadata + if (loggingSettings.length > 0) { + let metadata = {}; + if (formValues.metadata) { + try { + metadata = JSON.parse(formValues.metadata); + } catch (e) { + console.warn("Invalid JSON in metadata field, starting with empty object"); + } + } + + // Add logging settings to metadata + metadata = { + ...metadata, + logging: loggingSettings.filter((config) => config.callback_name), // Only include configs with callback_name + }; + + formValues.metadata = JSON.stringify(metadata); + } + + // Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission + if ( + (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) || + (formValues.allowed_mcp_servers_and_groups && + (formValues.allowed_mcp_servers_and_groups.servers?.length > 0 || + formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0 || + formValues.allowed_mcp_servers_and_groups.toolPermissions)) + ) { + formValues.object_permission = {}; + if (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) { + formValues.object_permission.vector_stores = formValues.allowed_vector_store_ids; + delete formValues.allowed_vector_store_ids; + } + if (formValues.allowed_mcp_servers_and_groups) { + const { servers, accessGroups } = formValues.allowed_mcp_servers_and_groups; + if (servers && servers.length > 0) { + formValues.object_permission.mcp_servers = servers; + } + if (accessGroups && accessGroups.length > 0) { + formValues.object_permission.mcp_access_groups = accessGroups; + } + delete formValues.allowed_mcp_servers_and_groups; + } + + // Add tool permissions separately + if (formValues.mcp_tool_permissions && Object.keys(formValues.mcp_tool_permissions).length > 0) { + if (!formValues.object_permission) { + formValues.object_permission = {}; + } + formValues.object_permission.mcp_tool_permissions = formValues.mcp_tool_permissions; + delete formValues.mcp_tool_permissions; + } + } + + // Transform allowed_mcp_access_groups into object_permission + if (formValues.allowed_mcp_access_groups && formValues.allowed_mcp_access_groups.length > 0) { + if (!formValues.object_permission) { + formValues.object_permission = {}; + } + formValues.object_permission.mcp_access_groups = formValues.allowed_mcp_access_groups; + delete formValues.allowed_mcp_access_groups; + } + + // Add model_aliases if any are defined + if (Object.keys(modelAliases).length > 0) { + formValues.model_aliases = modelAliases; + } + + const response: any = await teamCreateCall(accessToken, formValues); + if (teams !== null) { + setTeams([...teams, response]); + } else { + setTeams([response]); + } + console.log(`response for team create call: ${response}`); + NotificationsManager.success("Team created"); + form.resetFields(); + setLoggingSettings([]); + setModelAliases({}); + setIsTeamModalVisible(false); + } + } catch (error) { + console.error("Error creating the team:", error); + NotificationsManager.fromBackend("Error creating the team: " + error); + } + }; + + return ( + +
+ <> + + + + + Organization{" "} + + Organizations can have multiple teams. Learn more about{" "} + e.stopPropagation()} + > + user management hierarchy + + + } + > + + + + } + name="organization_id" + initialValue={currentOrg ? currentOrg.organization_id : null} + className="mt-8" + > + { + form.setFieldValue("organization_id", value); + setCurrentOrgForCreateTeam(organizations?.find((org) => org.organization_id === value) || null); + }} + filterOption={(input, option) => { + if (!option) return false; + const optionValue = option.children?.toString() || ""; + return optionValue.toLowerCase().includes(input.toLowerCase()); + }} + optionFilterProp="children" + > + {organizations?.map((org) => ( + + {org.organization_alias}{" "} + ({org.organization_id}) + + ))} + + + + Models{" "} + + + + + } + name="models" + > + + + All Proxy Models + + {modelsToPick.map((model) => ( + + {getModelDisplayName(model)} + + ))} + + + + + + + + + daily + weekly + monthly + + + + + + + + + + { + if (!mcpAccessGroupsLoaded) { + fetchMcpAccessGroups(); + setMcpAccessGroupsLoaded(true); + } + }} + > + + Additional Settings + + + + { + e.target.value = e.target.value.trim(); + }} + /> + + (value ? Number(value) : undefined)} + tooltip="This is the individual budget for a user in the team." + > + + + + + + + + + + + + + + + + Guardrails{" "} + + e.stopPropagation()} + > + + + + + } + name="guardrails" + className="mt-8" + help="Select existing guardrails or enter new ones" + > + ({ + value: name, + label: name, + }))} + /> + + + Allowed Vector Stores{" "} + + + + + } + name="allowed_vector_store_ids" + className="mt-8" + help="Select vector stores this team can access. Leave empty for access to all vector stores" + > + form.setFieldValue("allowed_vector_store_ids", values)} + value={form.getFieldValue("allowed_vector_store_ids")} + accessToken={accessToken || ""} + placeholder="Select vector stores (optional)" + /> + + + + + + + MCP Settings + + + + Allowed MCP Servers{" "} + + + + + } + name="allowed_mcp_servers_and_groups" + className="mt-4" + help="Select MCP servers or access groups this team can access" + > + form.setFieldValue("allowed_mcp_servers_and_groups", val)} + value={form.getFieldValue("allowed_mcp_servers_and_groups")} + accessToken={accessToken || ""} + placeholder="Select MCP servers or access groups (optional)" + /> + + + {/* Hidden field to register mcp_tool_permissions with the form */} + + + + prevValues.allowed_mcp_servers_and_groups !== currentValues.allowed_mcp_servers_and_groups || + prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions + } + > + {() => ( +
+ form.setFieldsValue({ mcp_tool_permissions: toolPerms })} + /> +
+ )} +
+
+
+ + + + Logging Settings + + +
+ +
+
+
+ + + + Model Aliases + + +
+ + Create custom aliases for models that can be used by team members in API calls. This allows you to + create shortcuts for specific models. + + +
+
+
+ +
+ Create Team +
+
+
+ ); +}; + +export default CreateTeamModal; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/DeleteTeamModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/DeleteTeamModal.tsx new file mode 100644 index 00000000000..0be627fdfc4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/DeleteTeamModal.tsx @@ -0,0 +1,95 @@ +import { AlertTriangleIcon, XIcon } from "lucide-react"; +import React, { useState } from "react"; +import { Team } from "@/components/key_team_helpers/key_list"; + +interface DeleteTeamModalProps { + teams: Team[] | null; + teamToDelete: string | null; + onCancel: () => void; + onConfirm: () => void; +} + +const DeleteTeamModal = ({ teams, teamToDelete, onCancel, onConfirm }: DeleteTeamModalProps) => { + const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); + + const team = teams?.find((t) => t.team_id === teamToDelete); + const teamName = team?.team_alias || ""; + const keyCount = team?.keys?.length || 0; + const isValid = deleteConfirmInput === teamName; + + return ( +
+
+
+
+

Delete Team

+ +
+
+ {keyCount > 0 && ( +
+
+ +
+
+

+ Warning: This team has {keyCount} associated key{keyCount > 1 ? "s" : ""}. +

+

+ Deleting the team will also delete all associated keys. This action is irreversible. +

+
+
+ )} +

+ Are you sure you want to force delete this team and all its keys? +

+
+ + setDeleteConfirmInput(e.target.value)} + placeholder="Enter team name exactly" + className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-base" + autoFocus + /> +
+
+
+
+ + +
+
+
+ ); +}; + +export default DeleteTeamModal; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/hooks/useFetchTeams.ts b/ui/litellm-dashboard/src/app/(dashboard)/teams/hooks/useFetchTeams.ts new file mode 100644 index 00000000000..c02787896f9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/hooks/useFetchTeams.ts @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useState } from "react"; +import { fetchTeams } from "@/components/common_components/fetch_teams"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { Organization, Team } from "@/components/networking"; + +interface useFetchTeamsProps { + currentOrg: Organization | null; + setTeams: (teams: Team[] | null) => void; +} + +const useFetchTeams = ({ currentOrg, setTeams }: useFetchTeamsProps) => { + const [lastRefreshed, setLastRefreshed] = useState(""); + const { accessToken, userId, userRole } = useAuthorized(); + + const onRefreshClick = useCallback(() => { + const currentDate = new Date(); + setLastRefreshed(currentDate.toLocaleString()); + }, []); + + useEffect(() => { + if (accessToken) { + fetchTeams(accessToken, userId, userRole, currentOrg, setTeams).then(); + } + onRefreshClick(); + }, [accessToken, currentOrg, lastRefreshed, onRefreshClick, setTeams, userId, userRole]); + + return { lastRefreshed, setLastRefreshed, onRefreshClick }; +}; + +export default useFetchTeams; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx index 42e6d250ee4..041c50dd32a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx @@ -1,6 +1,6 @@ "use client"; -import Teams from "@/components/teams"; +import TeamsView from "@/app/(dashboard)/teams/TeamsView"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; import { useEffect, useState } from "react"; @@ -10,9 +10,6 @@ import { fetchOrganizations } from "@/components/organizations"; const TeamsPage = () => { const { accessToken, userId, userRole } = useAuthorized(); const { teams, setTeams } = useTeams(); - const [searchParams, setSearchParams] = useState(() => - typeof window === "undefined" ? new URLSearchParams() : new URLSearchParams(window.location.search), - ); const [organizations, setOrganizations] = useState([]); useEffect(() => { @@ -20,9 +17,8 @@ const TeamsPage = () => { }, [accessToken]); return ( - ) : page == "teams" ? ( - >; - userID: string | null; - userRole: string | null; - organizations: Organization[] | null; - premiumUser?: boolean; -} - -interface FilterState { - team_id: string; - team_alias: string; - organization_id: string; - sort_by: string; - sort_order: "asc" | "desc"; -} - -interface EditTeamModalProps { - visible: boolean; - onCancel: () => void; - team: any; // Assuming TeamType is a type representing your team object - onSubmit: (data: FormData) => void; // Assuming FormData is the type of data to be submitted -} - -import { - teamCreateCall, - teamMemberAddCall, - teamMemberUpdateCall, - Member, - modelAvailableCall, - v2TeamListCall, -} from "./networking"; -import { updateExistingKeys } from "@/utils/dataUtils"; - -interface TeamInfo { - members_with_roles: Member[]; -} - -interface PerTeamInfo { - keys: KeyResponse[]; - team_info: TeamInfo; -} - -const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { - let tempModelsToPick = []; - - if (organization) { - if (organization.models.length > 0) { - console.log(`organization.models: ${organization.models}`); - tempModelsToPick = organization.models; - } else { - // show all available models if the team has no models set - tempModelsToPick = userModels; - } - } else { - // no team set, show all available models - tempModelsToPick = userModels; - } - - return unfurlWildcardModelsInList(tempModelsToPick, userModels); -}; - -const Teams: React.FC = ({ - teams, - searchParams, - accessToken, - setTeams, - userID, - userRole, - organizations, - premiumUser = false, -}) => { - const [lastRefreshed, setLastRefreshed] = useState(""); - const [currentOrg, setCurrentOrg] = useState(null); - const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); - const [showFilters, setShowFilters] = useState(false); - const [filters, setFilters] = useState({ - team_id: "", - team_alias: "", - organization_id: "", - sort_by: "created_at", - sort_order: "desc", - }); - - useEffect(() => { - console.log(`inside useeffect - ${lastRefreshed}`); - if (accessToken) { - // Call your function here - fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); - } - handleRefreshClick(); - }, [lastRefreshed]); - - const [form] = Form.useForm(); - const [memberForm] = Form.useForm(); - const { Title, Paragraph } = Typography; - const [value, setValue] = useState(""); - const [editModalVisible, setEditModalVisible] = useState(false); - - const [selectedTeam, setSelectedTeam] = useState(null); - const [selectedTeamId, setSelectedTeamId] = useState(null); - const [editTeam, setEditTeam] = useState(false); - - const [isTeamModalVisible, setIsTeamModalVisible] = useState(false); - const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); - const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); - const [userModels, setUserModels] = useState([]); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [teamToDelete, setTeamToDelete] = useState(null); - const [modelsToPick, setModelsToPick] = useState([]); - const [perTeamInfo, setPerTeamInfo] = useState>({}); - - // Add this state near the other useState declarations - const [guardrailsList, setGuardrailsList] = useState([]); - const [expandedAccordions, setExpandedAccordions] = useState>({}); - const [loggingSettings, setLoggingSettings] = useState([]); - const [mcpAccessGroups, setMcpAccessGroups] = useState([]); - const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); - const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); - const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({}); - - useEffect(() => { - console.log(`currentOrgForCreateTeam: ${currentOrgForCreateTeam}`); - const models = getOrganizationModels(currentOrgForCreateTeam, userModels); - console.log(`models: ${models}`); - setModelsToPick(models); - form.setFieldValue("models", []); - }, [currentOrgForCreateTeam, userModels]); - - // Add this useEffect to fetch guardrails - useEffect(() => { - const fetchGuardrails = async () => { - try { - if (accessToken == null) { - return; - } - - const response = await getGuardrailsList(accessToken); - const guardrailNames = response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); - setGuardrailsList(guardrailNames); - } catch (error) { - console.error("Failed to fetch guardrails:", error); - } - }; - - fetchGuardrails(); - }, [accessToken]); - - const fetchMcpAccessGroups = async () => { - try { - if (accessToken == null) { - return; - } - const groups = await fetchMCPAccessGroups(accessToken); - setMcpAccessGroups(groups); - } catch (error) { - console.error("Failed to fetch MCP access groups:", error); - } - }; - - useEffect(() => { - fetchMcpAccessGroups(); - }, [accessToken]); - - useEffect(() => { - const fetchTeamInfo = () => { - if (!teams) return; - - const newPerTeamInfo = teams.reduce( - (acc, team) => { - acc[team.team_id] = { - keys: team.keys || [], - team_info: { - members_with_roles: team.members_with_roles || [], - }, - }; - return acc; - }, - {} as Record, - ); - - setPerTeamInfo(newPerTeamInfo); - }; - - fetchTeamInfo(); - }, [teams]); - - const handleOk = () => { - setIsTeamModalVisible(false); - form.resetFields(); - setLoggingSettings([]); - setModelAliases({}); - }; - - const handleMemberOk = () => { - setIsAddMemberModalVisible(false); - setIsEditMemberModalVisible(false); - memberForm.resetFields(); - }; - - const handleCancel = () => { - setIsTeamModalVisible(false); - form.resetFields(); - setLoggingSettings([]); - setModelAliases({}); - }; - - const handleMemberCancel = () => { - setIsAddMemberModalVisible(false); - setIsEditMemberModalVisible(false); - memberForm.resetFields(); - }; - - const handleDelete = async (team_id: string) => { - // Set the team to delete and open the confirmation modal - setTeamToDelete(team_id); - setIsDeleteModalOpen(true); - }; - - const confirmDelete = async () => { - if (teamToDelete == null || teams == null || accessToken == null) { - return; - } - - try { - await teamDeleteCall(accessToken, teamToDelete); - // Successfully completed the deletion. Update the state to trigger a rerender. - fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); - } catch (error) { - console.error("Error deleting the team:", error); - // Handle any error situations, such as displaying an error message to the user. - } - - // Close the confirmation modal and reset the teamToDelete - setIsDeleteModalOpen(false); - setTeamToDelete(null); - }; - - const cancelDelete = () => { - // Close the confirmation modal and reset the teamToDelete - setIsDeleteModalOpen(false); - setTeamToDelete(null); - }; - - useEffect(() => { - const fetchUserModels = async () => { - try { - if (userID === null || userRole === null || accessToken === null) { - return; - } - const models = await fetchAvailableModelsForTeamOrKey(userID, userRole, accessToken); - if (models) { - setUserModels(models); - } - } catch (error) { - console.error("Error fetching user models:", error); - } - }; - - fetchUserModels(); - }, [accessToken, userID, userRole, teams]); - - const handleCreate = async (formValues: Record) => { - try { - console.log(`formValues: ${JSON.stringify(formValues)}`); - if (accessToken != null) { - const newTeamAlias = formValues?.team_alias; - const existingTeamAliases = teams?.map((t) => t.team_alias) ?? []; - let organizationId = formValues?.organization_id || currentOrg?.organization_id; - if (organizationId === "" || typeof organizationId !== "string") { - formValues.organization_id = null; - } else { - formValues.organization_id = organizationId.trim(); - } - - // Remove guardrails from top level since it's now in metadata - if (existingTeamAliases.includes(newTeamAlias)) { - throw new Error(`Team alias ${newTeamAlias} already exists, please pick another alias`); - } - - NotificationsManager.info("Creating Team"); - - // Handle logging settings in metadata - if (loggingSettings.length > 0) { - let metadata = {}; - if (formValues.metadata) { - try { - metadata = JSON.parse(formValues.metadata); - } catch (e) { - console.warn("Invalid JSON in metadata field, starting with empty object"); - } - } - - // Add logging settings to metadata - metadata = { - ...metadata, - logging: loggingSettings.filter((config) => config.callback_name), // Only include configs with callback_name - }; - - formValues.metadata = JSON.stringify(metadata); - } - - // Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission - if ( - (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) || - (formValues.allowed_mcp_servers_and_groups && - (formValues.allowed_mcp_servers_and_groups.servers?.length > 0 || - formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0 || - formValues.allowed_mcp_servers_and_groups.toolPermissions)) - ) { - formValues.object_permission = {}; - if (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) { - formValues.object_permission.vector_stores = formValues.allowed_vector_store_ids; - delete formValues.allowed_vector_store_ids; - } - if (formValues.allowed_mcp_servers_and_groups) { - const { servers, accessGroups } = formValues.allowed_mcp_servers_and_groups; - if (servers && servers.length > 0) { - formValues.object_permission.mcp_servers = servers; - } - if (accessGroups && accessGroups.length > 0) { - formValues.object_permission.mcp_access_groups = accessGroups; - } - delete formValues.allowed_mcp_servers_and_groups; - } - - // Add tool permissions separately - if (formValues.mcp_tool_permissions && Object.keys(formValues.mcp_tool_permissions).length > 0) { - if (!formValues.object_permission) { - formValues.object_permission = {}; - } - formValues.object_permission.mcp_tool_permissions = formValues.mcp_tool_permissions; - delete formValues.mcp_tool_permissions; - } - } - - // Transform allowed_mcp_access_groups into object_permission - if (formValues.allowed_mcp_access_groups && formValues.allowed_mcp_access_groups.length > 0) { - if (!formValues.object_permission) { - formValues.object_permission = {}; - } - formValues.object_permission.mcp_access_groups = formValues.allowed_mcp_access_groups; - delete formValues.allowed_mcp_access_groups; - } - - // Add model_aliases if any are defined - if (Object.keys(modelAliases).length > 0) { - formValues.model_aliases = modelAliases; - } - - const response: any = await teamCreateCall(accessToken, formValues); - if (teams !== null) { - setTeams([...teams, response]); - } else { - setTeams([response]); - } - console.log(`response for team create call: ${response}`); - NotificationsManager.success("Team created"); - form.resetFields(); - setLoggingSettings([]); - setModelAliases({}); - setIsTeamModalVisible(false); - } - } catch (error) { - console.error("Error creating the team:", error); - NotificationsManager.fromBackend("Error creating the team: " + error); - } - }; - - const is_team_admin = (team: any) => { - if (team == null || team.members_with_roles == null) { - return false; - } - for (let i = 0; i < team.members_with_roles.length; i++) { - let member = team.members_with_roles[i]; - if (member.user_id == userID && member.role == "admin") { - return true; - } - } - return false; - }; - - const handleRefreshClick = () => { - // Update the 'lastRefreshed' state to the current date and time - const currentDate = new Date(); - setLastRefreshed(currentDate.toLocaleString()); - }; - - const handleFilterChange = (key: keyof FilterState, value: string) => { - const newFilters = { ...filters, [key]: value }; - setFilters(newFilters); - // Call teamListCall with the new filters - if (accessToken) { - v2TeamListCall( - accessToken, - newFilters.organization_id || null, - null, - newFilters.team_id || null, - newFilters.team_alias || null, - ) - .then((response) => { - if (response && response.teams) { - setTeams(response.teams); - } - }) - .catch((error) => { - console.error("Error fetching teams:", error); - }); - } - }; - - const handleSortChange = (sortBy: string, sortOrder: "asc" | "desc") => { - const newFilters = { - ...filters, - sort_by: sortBy, - sort_order: sortOrder, - }; - setFilters(newFilters); - // Call teamListCall with the new sort parameters - if (accessToken) { - v2TeamListCall( - accessToken, - filters.organization_id || null, - null, - filters.team_id || null, - filters.team_alias || null, - ) - .then((response) => { - if (response && response.teams) { - setTeams(response.teams); - } - }) - .catch((error) => { - console.error("Error fetching teams:", error); - }); - } - }; - - const handleFilterReset = () => { - setFilters({ - team_id: "", - team_alias: "", - organization_id: "", - sort_by: "created_at", - sort_order: "desc", - }); - // Reset teams list - if (accessToken) { - v2TeamListCall(accessToken, null, userID || null, null, null) - .then((response) => { - if (response && response.teams) { - setTeams(response.teams); - } - }) - .catch((error) => { - console.error("Error fetching teams:", error); - }); - } - }; - - return ( -
- - - {(userRole == "Admin" || userRole == "Org Admin") && ( - - )} - {selectedTeamId ? ( - { - setTeams((teams) => { - if (teams == null) { - return teams; - } - const updated = teams.map((team) => { - if (data.team_id === team.team_id) { - return updateExistingKeys(team, data); - } - return team; - }); - // Minimal fix: refresh the full team list after an update - if (accessToken) { - fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); - } - return updated; - }); - }} - onClose={() => { - setSelectedTeamId(null); - setEditTeam(false); - }} - accessToken={accessToken} - is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))} - is_proxy_admin={userRole == "Admin"} - userModels={userModels} - editTeam={editTeam} - /> - ) : ( - - -
- Your Teams - Available Teams - {isAdminRole(userRole || "") && Default Team Settings} -
-
- {lastRefreshed && Last Refreshed: {lastRefreshed}} - -
-
- - - - Click on “Team ID” to view team details and manage team members. - - - - -
-
- {/* Search and Filter Controls */} -
- {/* Team Alias Search */} -
- handleFilterChange("team_alias", e.target.value)} - /> - - - -
- - {/* Filter Button */} - - - {/* Reset Filters Button */} - -
- - {/* Additional Filters */} - {showFilters && ( -
- {/* Team ID Search */} -
- handleFilterChange("team_id", e.target.value)} - /> - - - -
- - {/* Organization Dropdown */} -
- -
-
- )} -
-
- - - - Team Name - Team ID - Created - Spend (USD) - Budget (USD) - Models - Organization - Info - - - - - {teams && teams.length > 0 - ? teams - .filter((team) => { - if (!currentOrg) return true; - return team.organization_id === currentOrg.organization_id; - }) - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .map((team: any) => ( - - - {team["team_alias"]} - - -
- - - -
-
- - {team.created_at ? new Date(team.created_at).toLocaleDateString() : "N/A"} - - - {formatNumberWithCommas(team["spend"], 4)} - - - {team["max_budget"] !== null && team["max_budget"] !== undefined - ? team["max_budget"] - : "No limit"} - - 3 ? "px-0" : ""} - > -
- {Array.isArray(team.models) ? ( -
- {team.models.length === 0 ? ( - - All Proxy Models - - ) : ( - <> -
- {team.models.length > 3 && ( -
- { - setExpandedAccordions((prev) => ({ - ...prev, - [team.team_id]: !prev[team.team_id], - })); - }} - /> -
- )} -
- {team.models.slice(0, 3).map((model: string, index: number) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} - {team.models.length > 3 && !expandedAccordions[team.team_id] && ( - - - +{team.models.length - 3}{" "} - {team.models.length - 3 === 1 - ? "more model" - : "more models"} - - - )} - {expandedAccordions[team.team_id] && ( -
- {team.models.slice(3).map((model: string, index: number) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} -
- )} -
-
- - )} -
- ) : null} -
-
- - {team.organization_id} - - - {perTeamInfo && - team.team_id && - perTeamInfo[team.team_id] && - perTeamInfo[team.team_id].keys && - perTeamInfo[team.team_id].keys.length}{" "} - Keys - - - {perTeamInfo && - team.team_id && - perTeamInfo[team.team_id] && - perTeamInfo[team.team_id].team_info && - perTeamInfo[team.team_id].team_info.members_with_roles && - perTeamInfo[team.team_id].team_info.members_with_roles.length}{" "} - Members - - - - {userRole == "Admin" ? ( - <> - { - setSelectedTeamId(team.team_id); - setEditTeam(true); - }} - /> - handleDelete(team.team_id)} - icon={TrashIcon} - size="sm" - /> - - ) : null} - -
- )) - : null} -
-
- {isDeleteModalOpen && - (() => { - const team = teams?.find((t) => t.team_id === teamToDelete); - const teamName = team?.team_alias || ""; - const keyCount = team?.keys?.length || 0; - const isValid = deleteConfirmInput === teamName; - return ( -
-
-
-
-

Delete Team

- -
-
- {keyCount > 0 && ( -
-
- -
-
-

- Warning: This team has {keyCount} associated key{keyCount > 1 ? "s" : ""}. -

-

- Deleting the team will also delete all associated keys. This action is - irreversible. -

-
-
- )} -

- Are you sure you want to force delete this team and all its keys? -

-
- - setDeleteConfirmInput(e.target.value)} - placeholder="Enter team name exactly" - className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-base" - autoFocus - /> -
-
-
-
- - -
-
-
- ); - })()} -
- -
-
- - - - {isAdminRole(userRole || "") && ( - - - - )} -
-
- )} - {(userRole == "Admin" || userRole == "Org Admin") && ( - -
- <> - - - - - Organization{" "} - - Organizations can have multiple teams. Learn more about{" "} - e.stopPropagation()} - > - user management hierarchy - - - } - > - - - - } - name="organization_id" - initialValue={currentOrg ? currentOrg.organization_id : null} - className="mt-8" - > - { - form.setFieldValue("organization_id", value); - setCurrentOrgForCreateTeam(organizations?.find((org) => org.organization_id === value) || null); - }} - filterOption={(input, option) => { - if (!option) return false; - const optionValue = option.children?.toString() || ""; - return optionValue.toLowerCase().includes(input.toLowerCase()); - }} - optionFilterProp="children" - > - {organizations?.map((org) => ( - - {org.organization_alias}{" "} - ({org.organization_id}) - - ))} - - - - Models{" "} - - - - - } - name="models" - > - - - All Proxy Models - - {modelsToPick.map((model) => ( - - {getModelDisplayName(model)} - - ))} - - - - - - - - - daily - weekly - monthly - - - - - - - - - - { - if (!mcpAccessGroupsLoaded) { - fetchMcpAccessGroups(); - setMcpAccessGroupsLoaded(true); - } - }} - > - - Additional Settings - - - - { - e.target.value = e.target.value.trim(); - }} - /> - - (value ? Number(value) : undefined)} - tooltip="This is the individual budget for a user in the team." - > - - - - - - - - - - - - - - - - Guardrails{" "} - - e.stopPropagation()} - > - - - - - } - name="guardrails" - className="mt-8" - help="Select existing guardrails or enter new ones" - > - ({ - value: name, - label: name, - }))} - /> - - - Allowed Vector Stores{" "} - - - - - } - name="allowed_vector_store_ids" - className="mt-8" - help="Select vector stores this team can access. Leave empty for access to all vector stores" - > - form.setFieldValue("allowed_vector_store_ids", values)} - value={form.getFieldValue("allowed_vector_store_ids")} - accessToken={accessToken || ""} - placeholder="Select vector stores (optional)" - /> - - - - - - - MCP Settings - - - - Allowed MCP Servers{" "} - - - - - } - name="allowed_mcp_servers_and_groups" - className="mt-4" - help="Select MCP servers or access groups this team can access" - > - form.setFieldValue("allowed_mcp_servers_and_groups", val)} - value={form.getFieldValue("allowed_mcp_servers_and_groups")} - accessToken={accessToken || ""} - placeholder="Select MCP servers or access groups (optional)" - /> - - - {/* Hidden field to register mcp_tool_permissions with the form */} - - - - prevValues.allowed_mcp_servers_and_groups !== currentValues.allowed_mcp_servers_and_groups || - prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions - } - > - {() => ( -
- form.setFieldsValue({ mcp_tool_permissions: toolPerms })} - /> -
- )} -
-
-
- - - - Logging Settings - - -
- -
-
-
- - - - Model Aliases - - -
- - Create custom aliases for models that can be used by team members in API calls. This allows - you to create shortcuts for specific models. - - -
-
-
- -
- Create Team -
-
-
- )} - -
-
- ); -}; - -export default Teams; diff --git a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx index 93e15312f63..e8d46355e00 100644 --- a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx +++ b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx @@ -18,10 +18,53 @@ const { stub, jwtDecodeMock } = vi.hoisted(() => { * Mocks * --------------------------- */ -// next/navigation: just return empty URLSearchParams (no invitation/page) -vi.mock("next/navigation", () => ({ - useSearchParams: () => new URLSearchParams(""), -})); +vi.mock("@/hooks/useFeatureFlags", () => { + const React = require("react"); + + // minimal context so useFeatureFlags() returns something stable + const FeatureFlagsCtx = React.createContext({ get: () => false, flags: {} }); + + // Defensive provider: handle undefined props and allow optional value override + const FeatureFlagsProvider = (props: any) => { + const p = props || {}; + const value = p.value ?? { get: () => false, flags: {} }; + return React.createElement(FeatureFlagsCtx.Provider, { value }, p.children); + }; + + const useFeatureFlags = () => React.useContext(FeatureFlagsCtx); + + return { + __esModule: true, + default: FeatureFlagsProvider, // supports default import + FeatureFlagsProvider, // supports named import + useFeatureFlags, // supports named import + }; +}); + +// next/navigation mock: search params + router + pathname +vi.mock("next/navigation", () => { + const router = { + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + }; + + return { + __esModule: true, + // what your tests already relied on + useSearchParams: () => new URLSearchParams(""), + // added: satisfies useAuthorized / SidebarProvider + useRouter: () => router, + // optional helpers some components often read + usePathname: () => "/", + // optional: noop versions if code calls them + redirect: vi.fn(), // App Router server action usually; safe noop here + notFound: vi.fn(), + }; +}); // Networking layer vi.mock("@/components/networking", () => { @@ -87,6 +130,7 @@ vi.mock("@/lib/cva.config", () => ({ })); import CreateKeyPage from "@/app/page"; +import { FeatureFlagsProvider } from "@/hooks/useFeatureFlags"; /** ---------------------------- * Helpers