@@ -150,4 +129,3 @@ const CreateTagModal: React.FC = ({
};
export default CreateTagModal;
-
diff --git a/ui/litellm-dashboard/src/components/tag_management/tag_info.tsx b/ui/litellm-dashboard/src/components/tag_management/tag_info.tsx
index 60cde134e60..1c66a107db7 100644
--- a/ui/litellm-dashboard/src/components/tag_management/tag_info.tsx
+++ b/ui/litellm-dashboard/src/components/tag_management/tag_info.tsx
@@ -1,5 +1,15 @@
import React, { useState, useEffect } from "react";
-import { Card, Text, Title, Button, Badge, Accordion, AccordionHeader, AccordionBody, Title as TremorTitle } from "@tremor/react";
+import {
+ Card,
+ Text,
+ Title,
+ Button,
+ Badge,
+ Accordion,
+ AccordionHeader,
+ AccordionBody,
+ Title as TremorTitle,
+} from "@tremor/react";
import { Form, Input, Select as Select2, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { fetchUserModels } from "../organisms/create_key_button";
@@ -131,7 +141,7 @@ const TagInfoView: React.FC = ({ tagId, onClose, accessToken,
-
+
@@ -141,15 +151,15 @@ const TagInfoView: React.FC = ({ tagId, onClose, accessToken,
- Allowed LLMs{" "}
-
+ Allowed Models
+
}
name="models"
>
-
+
{userModels.map((modelId) => (
{getModelDisplayName(modelId)}
@@ -228,7 +238,7 @@ const TagInfoView: React.FC = ({ tagId, onClose, accessToken,
{tagDetails.description || "-"}
-
Allowed LLMs
+
Allowed Models
{!tagDetails.models || tagDetails.models.length === 0 ? (
All Models
@@ -256,30 +266,33 @@ const TagInfoView: React.FC
= ({ tagId, onClose, accessToken,
Budget & Rate Limits
- {tagDetails.litellm_budget_table.max_budget !== undefined && tagDetails.litellm_budget_table.max_budget !== null && (
-
- Max Budget
- ${tagDetails.litellm_budget_table.max_budget}
-
- )}
+ {tagDetails.litellm_budget_table.max_budget !== undefined &&
+ tagDetails.litellm_budget_table.max_budget !== null && (
+
+ Max Budget
+ ${tagDetails.litellm_budget_table.max_budget}
+
+ )}
{tagDetails.litellm_budget_table.budget_duration && (
Budget Duration
{tagDetails.litellm_budget_table.budget_duration}
)}
- {tagDetails.litellm_budget_table.tpm_limit !== undefined && tagDetails.litellm_budget_table.tpm_limit !== null && (
-
- TPM Limit
- {tagDetails.litellm_budget_table.tpm_limit.toLocaleString()}
-
- )}
- {tagDetails.litellm_budget_table.rpm_limit !== undefined && tagDetails.litellm_budget_table.rpm_limit !== null && (
-
- RPM Limit
- {tagDetails.litellm_budget_table.rpm_limit.toLocaleString()}
-
- )}
+ {tagDetails.litellm_budget_table.tpm_limit !== undefined &&
+ tagDetails.litellm_budget_table.tpm_limit !== null && (
+
+ TPM Limit
+ {tagDetails.litellm_budget_table.tpm_limit.toLocaleString()}
+
+ )}
+ {tagDetails.litellm_budget_table.rpm_limit !== undefined &&
+ tagDetails.litellm_budget_table.rpm_limit !== null && (
+
+ RPM Limit
+ {tagDetails.litellm_budget_table.rpm_limit.toLocaleString()}
+
+ )}
)}
diff --git a/ui/litellm-dashboard/src/components/team/team_info.test.tsx b/ui/litellm-dashboard/src/components/team/team_info.test.tsx
index 526f0972d98..17041659cec 100644
--- a/ui/litellm-dashboard/src/components/team/team_info.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/team_info.test.tsx
@@ -1,7 +1,7 @@
+import * as networking from "@/components/networking";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import TeamInfoView from "./team_info";
-import { render, waitFor } from "@testing-library/react";
-import * as networking from "@/components/networking";
// Mock the networking module
vi.mock("@/components/networking", () => ({
@@ -61,7 +61,7 @@ describe("TeamInfoView", () => {
vi.mocked(networking.getGuardrailsList).mockResolvedValue([]);
vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
- const { getByText } = render(
+ render(
{}}
@@ -75,7 +75,87 @@ describe("TeamInfoView", () => {
/>,
);
await waitFor(() => {
- expect(getByText("User ID")).toBeInTheDocument();
+ expect(screen.queryByText("User ID")).not.toBeNull();
});
});
+
+ it("should not show all-proxy-models option when user has no access to it", async () => {
+ vi.mocked(networking.teamInfoCall).mockResolvedValue({
+ team_id: "123",
+ team_info: {
+ team_alias: "Test Team",
+ team_id: "123",
+ organization_id: null,
+ admins: ["admin@test.com"],
+ members: ["user1@test.com", "user2@test.com"],
+ members_with_roles: [
+ {
+ user_id: "user1@test.com",
+ user_email: "user1@test.com",
+ role: "member",
+ spend: 0,
+ budget_id: "budget1",
+ },
+ ],
+ metadata: {},
+ tpm_limit: null,
+ rpm_limit: null,
+ max_budget: null,
+ budget_duration: null,
+ models: ["gpt-4"],
+ blocked: false,
+ spend: 0,
+ max_parallel_requests: null,
+ budget_reset_at: null,
+ model_id: null,
+ litellm_model_table: null,
+ created_at: "2024-01-01T00:00:00Z",
+ team_member_budget_table: null,
+ },
+ keys: [],
+ team_memberships: [],
+ });
+
+ vi.mocked(networking.getGuardrailsList).mockResolvedValue([]);
+ vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
+
+ render(
+ {}}
+ onClose={() => {}}
+ accessToken="123"
+ is_team_admin={true}
+ is_proxy_admin={true}
+ userModels={["gpt-4", "gpt-3.5-turbo"]}
+ editTeam={false}
+ premiumUser={false}
+ />,
+ );
+
+ await waitFor(() => {
+ expect(screen.getAllByText("Test Team")).not.toBeNull();
+ });
+
+ const settingsTab = screen.getByRole("tab", { name: "Settings" });
+ act(() => {
+ fireEvent.click(settingsTab);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Team Settings")).toBeInTheDocument();
+ });
+
+ const editButton = screen.getByRole("button", { name: "Edit Settings" });
+ act(() => {
+ fireEvent.click(editButton);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Models")).toBeInTheDocument();
+ });
+
+ const allProxyModelsOption = screen.queryByText("All Proxy Models");
+ expect(allProxyModelsOption).not.toBeInTheDocument();
+ });
});
diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx
index 0dada8bb79f..1c6ba629ef7 100644
--- a/ui/litellm-dashboard/src/components/team/team_info.tsx
+++ b/ui/litellm-dashboard/src/components/team/team_info.tsx
@@ -1,49 +1,50 @@
-import React, { useState, useEffect } from "react";
-import NumericalInput from "../shared/numerical_input";
+import UserSearchModal from "@/components/common_components/user_search_modal";
import {
- Card,
- Title,
- Text,
- Tab,
- TabList,
- TabGroup,
- TabPanel,
- TabPanels,
- Grid,
- Badge,
- Button as TremorButton,
- TextInput,
-} from "@tremor/react";
-import TeamMembersComponent from "./team_member_view";
-import MemberPermissions from "./member_permissions";
-import {
- teamInfoCall,
- teamMemberDeleteCall,
- teamMemberAddCall,
- teamMemberUpdateCall,
- Member,
- teamUpdateCall,
getGuardrailsList,
+ Member,
+ teamInfoCall,
+ teamMemberAddCall,
+ teamMemberDeleteCall,
+ teamMemberUpdateCall,
+ teamUpdateCall,
} from "@/components/networking";
-import { Button, Form, Input, Select, Switch, message, Modal, Tooltip } from "antd";
+import { formatNumberWithCommas } from "@/utils/dataUtils";
+import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
import { InfoCircleOutlined } from "@ant-design/icons";
import { ArrowLeftIcon } from "@heroicons/react/outline";
-import MemberModal from "./edit_membership";
-import UserSearchModal from "@/components/common_components/user_search_modal";
+import {
+ Badge,
+ Card,
+ Grid,
+ Tab,
+ TabGroup,
+ TabList,
+ TabPanel,
+ TabPanels,
+ Text,
+ TextInput,
+ Title,
+ Button as TremorButton,
+} from "@tremor/react";
+import { Button, Form, Input, message, Select, Switch, Tooltip } from "antd";
+import { CheckIcon, CopyIcon } from "lucide-react";
+import React, { useEffect, useState } from "react";
+import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
+import DeleteResourceModal from "../common_components/DeleteResourceModal";
+import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
-import ObjectPermissionsView from "../object_permissions_view";
-import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
+import LoggingSettingsView from "../logging_settings_view";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
-import { formatNumberWithCommas } from "@/utils/dataUtils";
-import EditLoggingSettings from "./EditLoggingSettings";
-import LoggingSettingsView from "../logging_settings_view";
-import { fetchMCPAccessGroups } from "../networking";
-import { CheckIcon, CopyIcon } from "lucide-react";
-import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
import NotificationsManager from "../molecules/notifications_manager";
-import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector";
-import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
+import { fetchMCPAccessGroups } from "../networking";
+import ObjectPermissionsView from "../object_permissions_view";
+import NumericalInput from "../shared/numerical_input";
+import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
+import MemberModal from "./edit_membership";
+import EditLoggingSettings from "./EditLoggingSettings";
+import MemberPermissions from "./member_permissions";
+import TeamMembersComponent from "./team_member_view";
export interface TeamMembership {
user_id: string;
@@ -139,6 +140,7 @@ const TeamInfoView: React.FC = ({
const [copiedStates, setCopiedStates] = useState>({});
const [guardrailsList, setGuardrailsList] = useState([]);
const [memberToDelete, setMemberToDelete] = useState(null);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
console.log("userModels in team info", userModels);
@@ -272,6 +274,7 @@ const TeamInfoView: React.FC = ({
const handleMemberDelete = (member: Member) => {
setMemberToDelete(member);
+ setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
@@ -294,11 +297,13 @@ const TeamInfoView: React.FC = ({
console.error("Error removing team member:", error);
} finally {
setIsDeleting(false);
+ setIsDeleteModalOpen(false);
setMemberToDelete(null);
}
};
const handleDeleteCancel = () => {
+ setIsDeleteModalOpen(false);
setMemberToDelete(null);
};
@@ -581,10 +586,19 @@ const TeamInfoView: React.FC = ({
-
+
);
};
diff --git a/ui/litellm-dashboard/src/components/view_users/columns.tsx b/ui/litellm-dashboard/src/components/view_users/columns.tsx
index 32bfa0ed6d2..20df4fc246e 100644
--- a/ui/litellm-dashboard/src/components/view_users/columns.tsx
+++ b/ui/litellm-dashboard/src/components/view_users/columns.tsx
@@ -22,10 +22,12 @@ export const columns = (
handleUserClick: (userId: string, openInEditMode?: boolean) => void,
selectionOptions?: SelectionOptions,
): ColumnDef
[] => {
+ // Backend sortable columns: user_id, user_email, created_at, spend, user_alias, user_role
const baseColumns: ColumnDef[] = [
{
header: "User ID",
accessorKey: "user_id",
+ enableSorting: true,
cell: ({ row }) => (
{row.original.user_id ? `${row.original.user_id.slice(0, 7)}...` : "-"}
@@ -35,16 +37,19 @@ export const columns = (
{
header: "Email",
accessorKey: "user_email",
+ enableSorting: true,
cell: ({ row }) => {row.original.user_email || "-"},
},
{
header: "Global Proxy Role",
accessorKey: "user_role",
+ enableSorting: true,
cell: ({ row }) => {possibleUIRoles?.[row.original.user_role]?.ui_label || "-"},
},
{
header: "Spend (USD)",
accessorKey: "spend",
+ enableSorting: true,
cell: ({ row }) => (
{row.original.spend ? formatNumberWithCommas(row.original.spend, 4) : "-"}
),
@@ -52,6 +57,7 @@ export const columns = (
{
header: "Budget (USD)",
accessorKey: "max_budget",
+ enableSorting: false,
cell: ({ row }) => (
{row.original.max_budget !== null ? row.original.max_budget : "Unlimited"}
),
@@ -66,6 +72,7 @@ export const columns = (
),
accessorKey: "sso_user_id",
+ enableSorting: false,
cell: ({ row }) => (
{row.original.sso_user_id !== null ? row.original.sso_user_id : "-"}
),
@@ -73,6 +80,7 @@ export const columns = (
{
header: "API Keys",
accessorKey: "key_count",
+ enableSorting: false,
cell: ({ row }) => (
{row.original.key_count > 0 ? (
@@ -90,7 +98,7 @@ export const columns = (
{
header: "Created At",
accessorKey: "created_at",
- sortingFn: "datetime",
+ enableSorting: true,
cell: ({ row }) => (
{row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "-"}
@@ -100,7 +108,7 @@ export const columns = (
{
header: "Updated At",
accessorKey: "updated_at",
- sortingFn: "datetime",
+ enableSorting: false,
cell: ({ row }) => (
{row.original.updated_at ? new Date(row.original.updated_at).toLocaleDateString() : "-"}
@@ -110,6 +118,7 @@ export const columns = (
{
id: "actions",
header: "Actions",
+ enableSorting: false,
cell: ({ row }) => (
@@ -148,6 +157,7 @@ export const columns = (
return [
{
id: "select",
+ enableSorting: false,
header: () => (
{
const updateFilters = vi.fn();
- const { getByText } = render(
+ render(
{
/>,
);
- expect(getByText("Filters")).toBeInTheDocument();
+ expect(screen.getByText("Filters")).toBeInTheDocument();
+ });
+
+ it("should call onSortChange when clicking a sortable header", () => {
+ const filters = {
+ email: "",
+ user_id: "",
+ user_role: "",
+ sso_user_id: "",
+ team: "",
+ model: "",
+ min_spend: null,
+ max_spend: null,
+ sort_by: "created_at",
+ sort_order: "desc" as const,
+ };
+
+ const updateFilters = vi.fn();
+ const onSortChange = vi.fn();
+
+ const possibleUIRoles = {
+ admin: { ui_label: "Admin" },
+ user: { ui_label: "User" },
+ };
+
+ render(
+ ,
+ );
+
+ const emailHeader = screen.getByRole("columnheader", { name: /email/i });
+ act(() => {
+ fireEvent.click(emailHeader);
+ });
+
+ expect(onSortChange).toHaveBeenCalledWith("user_email", "desc");
});
});
diff --git a/ui/litellm-dashboard/src/components/view_users/table.tsx b/ui/litellm-dashboard/src/components/view_users/table.tsx
index 7ce8f4ce650..f20eb2a6d14 100644
--- a/ui/litellm-dashboard/src/components/view_users/table.tsx
+++ b/ui/litellm-dashboard/src/components/view_users/table.tsx
@@ -1,11 +1,4 @@
-import {
- ColumnDef,
- flexRender,
- getCoreRowModel,
- getSortedRowModel,
- SortingState,
- useReactTable,
-} from "@tanstack/react-table";
+import { ColumnDef, flexRender, getCoreRowModel, SortingState, useReactTable } from "@tanstack/react-table";
import React from "react";
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell, Select, SelectItem } from "@tremor/react";
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
@@ -167,17 +160,23 @@ export function UserDataTable({
state: {
sorting,
},
- onSortingChange: (newSorting: any) => {
+ onSortingChange: (updaterOrValue: any) => {
+ const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue;
setSorting(newSorting);
- if (newSorting.length > 0) {
+ if (newSorting && Array.isArray(newSorting) && newSorting.length > 0 && newSorting[0]) {
const sortState = newSorting[0];
- const sortBy = sortState.id;
- const sortOrder = sortState.desc ? "desc" : "asc";
- onSortChange?.(sortBy, sortOrder);
+ if (sortState.id) {
+ const sortBy = sortState.id;
+ const sortOrder = sortState.desc ? "desc" : "asc";
+ onSortChange?.(sortBy, sortOrder);
+ }
+ } else {
+ // Reset to default sort when no sorting is selected
+ onSortChange?.("created_at", "desc");
}
},
getCoreRowModel: getCoreRowModel(),
- getSortedRowModel: getSortedRowModel(),
+ manualSorting: true,
enableSorting: true,
});
@@ -403,7 +402,7 @@ export function UserDataTable({
header.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
- }`}
+ } ${header.column.getCanSort() ? "cursor-pointer hover:bg-gray-50" : ""}`}
onClick={header.column.getToggleSortingHandler()}
>
@@ -412,7 +411,7 @@ export function UserDataTable({
? null
: flexRender(header.column.columnDef.header, header.getContext())}
- {header.id !== "actions" && (
+ {header.id !== "actions" && header.column.getCanSort() && (
{header.column.getIsSorted() ? (
{