From d94e27e3c2d332591af3ac69fe79fc0596dc8c63 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Wed, 8 Oct 2025 19:19:16 -0700 Subject: [PATCH 1/8] refactored teams -> teams header and teams filter --- .../teams/components/TeamsFilters.tsx | 141 ++++ .../teams/components/TeamsHeaderTabs.tsx | 38 + .../src/app/(dashboard)/teams/page.tsx | 2 +- .../(dashboard)/teams}/teams.tsx | 786 +++++++----------- ui/litellm-dashboard/src/app/page.tsx | 2 +- 5 files changed, 499 insertions(+), 470 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsFilters.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsHeaderTabs.tsx rename ui/litellm-dashboard/src/{components => app/(dashboard)/teams}/teams.tsx (56%) 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/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx index 42e6d250ee4..00f482a6faf 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 Teams from "@/app/(dashboard)/teams/teams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; import { useEffect, useState } from "react"; diff --git a/ui/litellm-dashboard/src/components/teams.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/teams.tsx similarity index 56% rename from ui/litellm-dashboard/src/components/teams.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/teams/teams.tsx index 590e0952944..b095ef1cae4 100644 --- a/ui/litellm-dashboard/src/components/teams.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/teams.tsx @@ -1,37 +1,19 @@ import React, { useState, useEffect } from "react"; -import Link from "next/link"; import { Typography } from "antd"; -import { - teamDeleteCall, - teamUpdateCall, - teamInfoCall, - Organization, - DEFAULT_ORGANIZATION, - fetchMCPAccessGroups, -} from "./networking"; -import TeamMemberModal from "@/components/team/edit_membership"; -import { fetchTeams } from "./common_components/fetch_teams"; -import { - InformationCircleIcon, - PencilAltIcon, - PencilIcon, - RefreshIcon, - StatusOnlineIcon, - TrashIcon, - ChevronDownIcon, - ChevronRightIcon, -} from "@heroicons/react/outline"; +import { teamDeleteCall, Organization, fetchMCPAccessGroups } from "@/components/networking"; +import { fetchTeams } from "@/components/common_components/fetch_teams"; +import { PencilAltIcon, RefreshIcon, TrashIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; import { Button as Button2, Modal, Form, Input, Select as Select2, message, Tooltip } from "antd"; -import NumericalInput from "./shared/numerical_input"; +import NumericalInput from "../../../components/shared/numerical_input"; import { fetchAvailableModelsForTeamOrKey, getModelDisplayName, unfurlWildcardModelsInList, -} from "./key_team_helpers/fetch_available_models_team_key"; +} from "@/components/key_team_helpers/fetch_available_models_team_key"; import { Select, SelectItem } from "@tremor/react"; import { InfoCircleOutlined } from "@ant-design/icons"; -import { getGuardrailsList } from "./networking"; -import TeamInfoView, { TeamData } from "@/components/team/team_info"; +import { getGuardrailsList } from "@/components/networking"; +import TeamInfoView from "@/components/team/team_info"; import TeamSSOSettings from "@/components/TeamSSOSettings"; import { isAdminRole } from "@/utils/roles"; import { @@ -58,12 +40,11 @@ import { TabPanels, Tab, } from "@tremor/react"; -import { CogIcon } from "@heroicons/react/outline"; import AvailableTeamsPanel from "@/components/team/available_teams"; -import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; -import PremiumLoggingSettings from "./common_components/PremiumLoggingSettings"; -import type { KeyResponse, Team } from "./key_team_helpers/key_list"; -import { formatNumberWithCommas } from "../utils/dataUtils"; +import VectorStoreSelector from "../../../components/vector_store_management/VectorStoreSelector"; +import PremiumLoggingSettings from "../../../components/common_components/PremiumLoggingSettings"; +import type { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; import { AlertTriangleIcon, XIcon } from "lucide-react"; import MCPServerSelector from "./mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions"; @@ -96,15 +77,10 @@ interface EditTeamModalProps { onSubmit: (data: FormData) => void; // Assuming FormData is the type of data to be submitted } -import { - teamCreateCall, - teamMemberAddCall, - teamMemberUpdateCall, - Member, - modelAvailableCall, - v2TeamListCall, -} from "./networking"; +import { teamCreateCall, 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"; interface TeamInfo { members_with_roles: Member[]; @@ -167,11 +143,7 @@ const Teams: React.FC = ({ 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); @@ -573,450 +545,328 @@ const Teams: React.FC = ({ 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 */} -
- -
-
- )} -
+ + + + Click on “Team ID” to view team details and manage team members. + + + + +
+
+
- - - - Team Name - Team ID - Created - Spend (USD) - Budget (USD) - Models - Organization - Info - - + +
+ + + 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] && ( - + + {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 + + ) : ( + - +{team.models.length - 3}{" "} - {team.models.length - 3 === 1 - ? "more model" - : "more models"} + {model.length > 30 + ? `${getModelDisplayName(model).slice(0, 30)}...` + : getModelDisplayName(model)} - )} - {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)} - - - ), - )} -
- )} -
+ ), + )} + {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 - />
-
-
-
+ + + {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 || "") && ( - + - {isAdminRole(userRole || "") && ( - - - - )} - - + )} + )} {(userRole == "Admin" || userRole == "Org Admin") && ( = ({ - + shouldUpdate={(prevValues, currentValues) => prevValues.allowed_mcp_servers_and_groups !== currentValues.allowed_mcp_servers_and_groups || prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions } diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index cefa680fc82..4b5e19b60f9 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -10,7 +10,7 @@ import { ThemeProvider } from "@/contexts/ThemeContext"; import UserDashboard from "@/components/user_dashboard"; import ModelDashboard from "@/components/templates/model_dashboard"; import ViewUserDashboard from "@/components/view_users"; -import Teams from "@/components/teams"; +import Teams from "@/app/(dashboard)/teams/teams"; import Organizations from "@/components/organizations"; import { fetchOrganizations } from "@/components/organizations"; import AdminPanel from "@/components/admins"; From 7c6e48c780a2114f196cfd17c40c1483f886ffc8 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Thu, 9 Oct 2025 11:09:08 -0700 Subject: [PATCH 2/8] useAuthorized fix, teams refactor progress --- .../app/(dashboard)/hooks/useAuthorized.ts | 31 +- .../src/app/(dashboard)/layout.tsx | 16 +- .../components/TeamsTable/ModelsCell.tsx | 101 +++++++ .../components/TeamsTable/TeamsTable.tsx | 172 +++++++++++ .../(dashboard)/teams/hooks/useFetchTeams.ts | 30 ++ .../src/app/(dashboard)/teams/page.tsx | 4 - .../src/app/(dashboard)/teams/teams.tsx | 274 ++---------------- ui/litellm-dashboard/src/app/page.tsx | 1 - 8 files changed, 358 insertions(+), 271 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/TeamsTable.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/hooks/useFetchTeams.ts 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/components/TeamsTable/ModelsCell.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx new file mode 100644 index 00000000000..0ffd3ae22ea --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx @@ -0,0 +1,101 @@ +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 [expandedAccordions, setExpandedAccordions] = useState>({}); + + return ( + 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} +
+
+ ); +}; + +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..3ffdbc40629 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/TeamsTable.tsx @@ -0,0 +1,172 @@ +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"; + +type TeamsTableProps = { + teams: Team[] | null; + currentOrg: Organization | null; + perTeamInfo: Record; + userRole: 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, + setEditTeam, + onDeleteTeam, +}: TeamsTableProps) => { + return ( + + + + 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"} + + + {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/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 00f482a6faf..531caabd12a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx @@ -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(() => { @@ -22,7 +19,6 @@ const TeamsPage = () => { return ( >; userID: string | null; @@ -70,18 +60,6 @@ interface FilterState { 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, 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"; - interface TeamInfo { members_with_roles: Member[]; } @@ -112,7 +90,6 @@ const getOrganizationModels = (organization: Organization | null, userModels: st const Teams: React.FC = ({ teams, - searchParams, accessToken, setTeams, userID, @@ -120,7 +97,6 @@ const Teams: React.FC = ({ organizations, premiumUser = false, }) => { - const [lastRefreshed, setLastRefreshed] = useState(""); const [currentOrg, setCurrentOrg] = useState(null); const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); const [showFilters, setShowFilters] = useState(false); @@ -132,15 +108,6 @@ const Teams: React.FC = ({ 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(); @@ -158,12 +125,12 @@ const Teams: React.FC = ({ // 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 }>({}); + const { lastRefreshed, onRefreshClick: handleRefreshClick } = useFetchTeams({ currentOrg, setTeams }); useEffect(() => { console.log(`currentOrgForCreateTeam: ${currentOrgForCreateTeam}`); @@ -426,12 +393,6 @@ const Teams: React.FC = ({ 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); @@ -565,214 +526,15 @@ const Teams: React.FC = ({ />
- - - - 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); diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 4b5e19b60f9..d7262afefb9 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -379,7 +379,6 @@ export default function CreateKeyPage() { Date: Thu, 9 Oct 2025 11:48:12 -0700 Subject: [PATCH 3/8] teams refactor: extracted CreateTeamModal --- .../components/TeamsTable/ModelsCell.tsx | 13 +- .../components/modals/CreateTeamModal.tsx | 530 ++++++++++++++ .../components/modals/DeleteTeamModal.tsx | 95 +++ .../src/app/(dashboard)/teams/teams.tsx | 667 +----------------- 4 files changed, 661 insertions(+), 644 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/DeleteTeamModal.tsx 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 index 0ffd3ae22ea..5cabe4c4a8f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx @@ -9,7 +9,7 @@ interface ModelsCellProps { } const ModelsCell = ({ team }: ModelsCellProps) => { - const [expandedAccordions, setExpandedAccordions] = useState>({}); + const [expandedAccordion, setExpandedAccordion] = useState(false); return ( { {team.models.length > 3 && (
{ - setExpandedAccordions((prev) => ({ - ...prev, - [team.team_id]: !prev[team.team_id], - })); + setExpandedAccordion((prev) => !prev); }} />
@@ -61,14 +58,14 @@ const ModelsCell = ({ team }: ModelsCellProps) => { ), )} - {team.models.length > 3 && !expandedAccordions[team.team_id] && ( + {team.models.length > 3 && !expandedAccordion && ( +{team.models.length - 3} {team.models.length - 3 === 1 ? "more model" : "more models"} )} - {expandedAccordions[team.team_id] && ( + {expandedAccordion && (
{team.models.slice(3).map((model: string, index: number) => model === "all-proxy-models" ? ( 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..20f5b04d7aa --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx @@ -0,0 +1,530 @@ +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"; + +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.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; + } + } + + // 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)" + /> + + + Allowed MCP Servers{" "} + + + + + } + name="allowed_mcp_servers_and_groups" + className="mt-8" + 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)" + /> + + + + + + + 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/teams.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/teams.tsx index 36536970b63..543955982ff 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/teams.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/teams.tsx @@ -1,46 +1,27 @@ import React, { useState, useEffect } from "react"; -import { teamDeleteCall, Organization, fetchMCPAccessGroups } from "@/components/networking"; +import { teamDeleteCall, Organization } from "@/components/networking"; import { fetchTeams } from "@/components/common_components/fetch_teams"; -import { Button as Button2, Modal, Form, Input, Select as Select2, Tooltip } from "antd"; -import NumericalInput from "../../../components/shared/numerical_input"; -import { - fetchAvailableModelsForTeamOrKey, - getModelDisplayName, - unfurlWildcardModelsInList, -} from "@/components/key_team_helpers/fetch_available_models_team_key"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { getGuardrailsList } from "@/components/networking"; +import { Form } from "antd"; import TeamInfoView from "@/components/team/team_info"; import TeamSSOSettings from "@/components/TeamSSOSettings"; import { isAdminRole } from "@/utils/roles"; -import { - TextInput, - Card, - Button, - Col, - Text, - Grid, - Accordion, - AccordionHeader, - AccordionBody, - TabPanel, -} from "@tremor/react"; +import { Card, Button, Col, Text, Grid, TabPanel } from "@tremor/react"; import AvailableTeamsPanel from "@/components/team/available_teams"; -import VectorStoreSelector from "../../../components/vector_store_management/VectorStoreSelector"; -import PremiumLoggingSettings from "../../../components/common_components/PremiumLoggingSettings"; import type { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; import { AlertTriangleIcon, XIcon } from "lucide-react"; -import MCPServerSelector from "./mcp_server_management/MCPServerSelector"; +import MCPServerSelector from "../../../components/mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions"; -import ModelAliasManager from "./common_components/ModelAliasManager"; -import NotificationsManager from "./molecules/notifications_manager"; +import ModelAliasManager from "../../../components/common_components/ModelAliasManager"; +import NotificationsManager from "../../../components/molecules/notifications_manager"; -import { teamCreateCall, Member, v2TeamListCall } from "@/components/networking"; +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; @@ -69,25 +50,6 @@ interface PerTeamInfo { 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, accessToken, @@ -98,7 +60,6 @@ const Teams: React.FC = ({ premiumUser = false, }) => { const [currentOrg, setCurrentOrg] = useState(null); - const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); const [showFilters, setShowFilters] = useState(false); const [filters, setFilters] = useState({ team_id: "", @@ -120,61 +81,12 @@ const Teams: React.FC = ({ 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 [loggingSettings, setLoggingSettings] = useState([]); - const [mcpAccessGroups, setMcpAccessGroups] = useState([]); - const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); - const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({}); const { lastRefreshed, onRefreshClick: handleRefreshClick } = useFetchTeams({ currentOrg, setTeams }); - 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; @@ -218,12 +130,6 @@ const Teams: React.FC = ({ 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); @@ -255,131 +161,6 @@ const Teams: React.FC = ({ 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; @@ -535,87 +316,14 @@ const Teams: React.FC = ({ setEditTeam={setEditTeam} onDeleteTeam={handleDelete} /> - {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 - /> -
-
-
-
- - -
-
-
- ); - })()} + {isDeleteModalOpen && ( + + )} @@ -631,333 +339,20 @@ const Teams: React.FC = ({ )} {(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 -
-
-
+ )} From 7d0088952ed86d3455062213ddc1fb8ac7405855 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Thu, 9 Oct 2025 11:50:16 -0700 Subject: [PATCH 4/8] renamed teams component to TeamsView --- .../src/app/(dashboard)/teams/{teams.tsx => TeamsView.tsx} | 4 ++-- ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx | 4 ++-- ui/litellm-dashboard/src/app/page.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename ui/litellm-dashboard/src/app/(dashboard)/teams/{teams.tsx => TeamsView.tsx} (99%) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/teams.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx similarity index 99% rename from ui/litellm-dashboard/src/app/(dashboard)/teams/teams.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx index 543955982ff..c71bd899ac4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/teams.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx @@ -50,7 +50,7 @@ interface PerTeamInfo { team_info: TeamInfo; } -const Teams: React.FC = ({ +const TeamsView: React.FC = ({ teams, accessToken, setTeams, @@ -360,4 +360,4 @@ const Teams: React.FC = ({ ); }; -export default Teams; +export default TeamsView; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx index 531caabd12a..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 "@/app/(dashboard)/teams/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"; @@ -17,7 +17,7 @@ const TeamsPage = () => { }, [accessToken]); return ( - ) : page == "teams" ? ( - Date: Thu, 9 Oct 2025 12:36:10 -0700 Subject: [PATCH 5/8] added back Ishaan's MCP changes --- .../src/app/(dashboard)/teams/TeamsView.tsx | 5 -- .../components/modals/CreateTeamModal.tsx | 51 +++++++++++++++++-- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx index c71bd899ac4..57c538c0644 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx @@ -8,11 +8,6 @@ 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 { AlertTriangleIcon, XIcon } from "lucide-react"; -import MCPServerSelector from "../../../components/mcp_server_management/MCPServerSelector"; -import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions"; -import ModelAliasManager from "../../../components/common_components/ModelAliasManager"; -import NotificationsManager from "../../../components/molecules/notifications_manager"; import { Member, v2TeamListCall } from "@/components/networking"; import { updateExistingKeys } from "@/utils/dataUtils"; 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 index 20f5b04d7aa..e6d9a85898e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx @@ -15,6 +15,7 @@ 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; @@ -182,7 +183,8 @@ const CreateTeamModal = ({ (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.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) { @@ -199,6 +201,15 @@ const CreateTeamModal = ({ } 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 @@ -461,18 +472,26 @@ const CreateTeamModal = ({ placeholder="Select vector stores (optional)" /> + + + + + + MCP Settings + + Allowed MCP Servers{" "} - + } name="allowed_mcp_servers_and_groups" - className="mt-8" - help="Select MCP servers or access groups this team can access. " + className="mt-4" + help="Select MCP servers or access groups this team can access" > form.setFieldValue("allowed_mcp_servers_and_groups", val)} @@ -481,6 +500,30 @@ const CreateTeamModal = ({ 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 })} + /> +
+ )} +
From 87b0ff6ab0a69085346fd6a4bbed61f0a495a935 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Thu, 9 Oct 2025 13:58:34 -0700 Subject: [PATCH 6/8] Update CreateKeyPage.expiredToken.test.tsx --- .../tests/CreateKeyPage.expiredToken.test.tsx | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) 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 From dce17a8d16fd8220011142c6caf834a682a64f61 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Thu, 9 Oct 2025 16:26:11 -0700 Subject: [PATCH 7/8] added column "Your Role" with admin and member badges --- .../src/app/(dashboard)/teams/TeamsView.tsx | 1 + .../components/TeamsTable/TeamsTable.tsx | 5 ++ .../TeamsTable/YourRoleCell/TeamRoleBadge.tsx | 47 +++++++++++++++++++ .../TeamsTable/YourRoleCell/YourRoleCell.tsx | 22 +++++++++ 4 files changed, 75 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/TeamRoleBadge.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/YourRoleCell.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx index 57c538c0644..fa0ec060946 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx @@ -307,6 +307,7 @@ const TeamsView: React.FC = ({ currentOrg={currentOrg} perTeamInfo={perTeamInfo} userRole={userRole} + userId={userID} setSelectedTeamId={setSelectedTeamId} setEditTeam={setEditTeam} onDeleteTeam={handleDelete} 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 index 3ffdbc40629..4e8b3286c81 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/TeamsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/TeamsTable.tsx @@ -18,12 +18,14 @@ 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; @@ -44,6 +46,7 @@ const TeamsTable = ({ setSelectedTeamId, perTeamInfo, userRole, + userId, setEditTeam, onDeleteTeam, }: TeamsTableProps) => { @@ -58,6 +61,7 @@ const TeamsTable = ({ Budget (USD) Models Organization + Your Role Info @@ -127,6 +131,7 @@ const TeamsTable = ({ {team.organization_id} + {perTeamInfo && 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.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; From 679cf54c6e6ecc24db2316cba1b81d86911006d9 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Thu, 9 Oct 2025 16:30:32 -0700 Subject: [PATCH 8/8] adds vitests surrounding new components --- .../YourRoleCell/TeamRoleBadge.test.tsx | 38 ++++++++++++++++ .../YourRoleCell/YourRoleCell.test.tsx | 43 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/TeamRoleBadge.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/YourRoleCell.test.tsx 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/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"); + }); +});