diff --git a/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx b/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx
index 418f27c8188..3264c3a99ea 100644
--- a/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx
+++ b/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, screen, waitFor, within } from "@testing-library/react";
+import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { describe, expect, it, vi } from "vitest";
@@ -160,6 +160,66 @@ describe("MemberTable sorting", () => {
});
});
+describe("MemberTable paging", () => {
+ const roster = (): Member[] =>
+ Array.from({ length: 30 }, (_, index) => ({
+ user_id: `u-${index}`,
+ user_email: `member${index}@example.com`,
+ user_alias: `Member ${String(index).padStart(2, "0")}`,
+ role: index === 0 ? "admin" : "user",
+ }));
+
+ const renderRoster = () => {
+ const props = { members: roster(), canEdit: true, onEdit: vi.fn(), onDelete: vi.fn() };
+ const view = renderWithProviders();
+ return { props, view };
+ };
+
+ const goToSecondPage = async (user: ReturnType) => {
+ await user.click(screen.getByTestId("pagination-next"));
+ expect(screen.getByText("Showing 26-30 of 30")).toBeInTheDocument();
+ };
+
+ it("stays on the current page when the roster is refetched", async () => {
+ const user = userEvent.setup();
+ const { props, view } = renderRoster();
+ await goToSecondPage(user);
+
+ view.rerender();
+ await act(() => new Promise((resolve) => setTimeout(resolve, 20)));
+
+ expect(screen.getByText("Showing 26-30 of 30")).toBeInTheDocument();
+ });
+
+ it("returns to the first page when the sort order changes", async () => {
+ const user = userEvent.setup();
+ renderRoster();
+ await goToSecondPage(user);
+
+ await user.click(screen.getByTestId("sort-header-user_email"));
+
+ expect(await screen.findByText("Showing 1-25 of 30")).toBeInTheDocument();
+ });
+
+ it("returns to the first page when the search or the role filter changes", async () => {
+ const user = userEvent.setup();
+ renderRoster();
+ await goToSecondPage(user);
+
+ search("example.com");
+ expect(await screen.findByText("Showing 1-25 of 30")).toBeInTheDocument();
+
+ search("");
+ await goToSecondPage(user);
+ await user.click(screen.getByTestId("datatable-filters-trigger"));
+ await user.click(screen.getByTestId("filter-role"));
+ await user.click(await screen.findByRole("option", { name: "user" }));
+ await user.click(screen.getByTestId("filter-drawer-apply"));
+
+ expect(await screen.findByText("Showing 1-25 of 29")).toBeInTheDocument();
+ });
+});
+
describe("MemberTable role filter", () => {
it("shows only members with the chosen role and clears on reset", async () => {
const user = userEvent.setup();
diff --git a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx
index 24dd86c9e2c..0acba9ad21d 100644
--- a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx
+++ b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx
@@ -1,4 +1,4 @@
-import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table";
+import type { ColumnDef, ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { Crown, Info, User, UserPlus } from "lucide-react";
import React, { useState } from "react";
@@ -9,6 +9,7 @@ import {
DataTableFilterField,
DataTableSortHeader,
DataTableToolbar,
+ DEFAULT_PAGE_SIZE_OPTIONS,
} from "@/components/shared/DataTable";
import { StatusBadge } from "@/components/shared/table_cells";
import { Button } from "@/components/ui/button";
@@ -44,6 +45,8 @@ export interface MemberTableProps {
const ALL_ROLES = "all";
+const DEFAULT_SORTING: SortingState = [{ id: "user_alias", desc: false }];
+
export const memberRowId = (member: Member): string => member.user_id ?? member.user_email ?? JSON.stringify(member);
export const memberRoleOptions = (members: readonly Member[]): string[] =>
@@ -230,8 +233,21 @@ export default function MemberTable({
}: MemberTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [columnFilters, setColumnFilters] = useState([]);
+ const [sorting, setSorting] = useState(DEFAULT_SORTING);
+ const [pagination, setPagination] = useState({
+ pageIndex: 0,
+ pageSize: DEFAULT_PAGE_SIZE_OPTIONS[0],
+ });
const [filtersOpen, setFiltersOpen] = useState(false);
+ const backToFirstPage =
+ (onChange: OnChangeFn): OnChangeFn =>
+ (updaterOrValue) => {
+ onChange(updaterOrValue);
+ setPagination((current) => ({ ...current, pageIndex: 0 }));
+ };
+ const changeSearch = backToFirstPage(setGlobalFilter);
+
const columnDeps: MemberColumnDeps = {
canEdit,
onEdit,
@@ -260,13 +276,17 @@ export default function MemberTable({
data={members}
columns={columns}
getRowId={memberRowId}
+ paginationMode="client"
+ pagination={pagination}
+ onPaginationChange={setPagination}
sortingMode="client"
- defaultSorting={[{ id: "user_alias", desc: false }]}
+ sorting={sorting}
+ onSortingChange={backToFirstPage(setSorting)}
filterMode="client"
columnFilters={columnFilters}
- onColumnFiltersChange={setColumnFilters}
+ onColumnFiltersChange={backToFirstPage(setColumnFilters)}
globalFilter={globalFilter}
- onGlobalFilterChange={setGlobalFilter}
+ onGlobalFilterChange={changeSearch}
noDataMessage={
{isNarrowed ? "No members match your search or filters" : emptyText ?? "No data"}
@@ -277,7 +297,7 @@ export default function MemberTable({
setFiltersOpen(true)}
showViewOptions={false}
diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx
index ca792084f2a..88d98fac60e 100644
--- a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx
+++ b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx
@@ -149,6 +149,39 @@ test("should display empty state when organization has no members", async () =>
});
});
+test("keeps the members pager on its page when an admin opens a member's edit dialog", async () => {
+ const members = Array.from({ length: 30 }, (_, index) => ({
+ user_id: `user${index + 1}`,
+ user_email: `user${index + 1}@example.com`,
+ user_role: "internal_user",
+ spend: 0,
+ user: { user_alias: `alias${String(index + 1).padStart(2, "0")}` },
+ }));
+ mockUseOrganization.mockReturnValue({ data: { ...mockOrg, members }, isLoading: false } as unknown as ReturnType<
+ typeof useOrganization
+ >);
+
+ const user = userEvent.setup();
+ renderWithProviders(
+ {}}
+ accessToken="test-token"
+ is_org_admin={true}
+ is_proxy_admin={false}
+ userModels={[]}
+ />,
+ );
+
+ await user.click(screen.getByRole("tab", { name: "Members" }));
+ await user.click(screen.getByTestId("pagination-next"));
+ expect(screen.getByText("Showing 26-30 of 30")).toBeInTheDocument();
+
+ await user.click(within(screen.getByRole("row", { name: /alias27/ })).getByTestId("edit-member"));
+
+ expect(screen.getByText("Showing 26-30 of 30")).toBeInTheDocument();
+});
+
test("should display team aliases when teams are available", async () => {
const orgWithTeams = {
...mockOrg,
diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
index b8f292222ed..cd51bcedb7b 100644
--- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
@@ -316,6 +316,91 @@ describe("TeamMembersComponent", () => {
expect(screen.getByText("Jul 15, 2026")).toBeInTheDocument();
});
+ it("shows each member the values from its own membership row", () => {
+ const baseMembership = createMockTeamData().team_memberships[0];
+ const teamData = createMockTeamData({
+ team_memberships: [
+ {
+ ...baseMembership,
+ spend: 1.25,
+ total_spend: 10,
+ litellm_budget_table: { ...baseMembership.litellm_budget_table, max_budget: 250 },
+ },
+ {
+ ...baseMembership,
+ user_id: "user2@test.com",
+ spend: 7.5,
+ total_spend: 20,
+ litellm_budget_table: {
+ ...baseMembership.litellm_budget_table,
+ max_budget: 2500,
+ rpm_limit: 7,
+ tpm_limit: 70,
+ },
+ },
+ ],
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ const user1Row = screen.getByRole("row", { name: /user1@test\.com/ });
+ expect(within(user1Row).getByText("$1.25")).toBeInTheDocument();
+ expect(within(user1Row).getByText("$10.00")).toBeInTheDocument();
+ expect(within(user1Row).getByText("$250.00")).toBeInTheDocument();
+ expect(within(user1Row).getByText("100 RPM / 10000 TPM")).toBeInTheDocument();
+
+ const user2Row = screen.getByRole("row", { name: /user2@test\.com/ });
+ expect(within(user2Row).getByText("$7.50")).toBeInTheDocument();
+ expect(within(user2Row).getByText("$20.00")).toBeInTheDocument();
+ expect(within(user2Row).getByText("$2,500.00")).toBeInTheDocument();
+ expect(within(user2Row).getByText("7 RPM / 70 TPM")).toBeInTheDocument();
+ });
+
+ it("shows a member's new spend and budget once the team is refetched", () => {
+ const baseMembership = createMockTeamData().team_memberships[0];
+ const teamWith = (spend: number, maxBudget: number) =>
+ createMockTeamData({
+ team_memberships: [
+ {
+ ...baseMembership,
+ spend,
+ litellm_budget_table: { ...baseMembership.litellm_budget_table, max_budget: maxBudget },
+ },
+ ],
+ });
+ const renderTab = (teamData: TeamData) => (
+
+ );
+
+ const { rerender } = renderWithProviders(renderTab(teamWith(1.25, 250)));
+ rerender(renderTab(teamWith(3.5, 400)));
+
+ const row = screen.getByRole("row", { name: /user1@test\.com/ });
+ expect(within(row).getByText("$3.50")).toBeInTheDocument();
+ expect(within(row).getByText("$400.00")).toBeInTheDocument();
+ expect(within(row).queryByText("$1.25")).not.toBeInTheDocument();
+ });
+
it("should display formatted budget and Unlimited for member with no budget", () => {
renderWithProviders(
, string> = {
@@ -74,6 +74,14 @@ export default function TeamMemberTab({
const { mutate: resetMemberBudget, isPending: isResettingBudget } = useResetTeamMemberBudget();
const teamDefaultBudget = teamData.team_info.team_member_budget_table?.max_budget ?? null;
+ const membershipsByUserId = useMemo(
+ () => new Map(teamData.team_memberships.map((membership) => [membership.user_id, membership])),
+ [teamData.team_memberships],
+ );
+
+ const findMembership = (userId: string | null): TeamMembership | undefined =>
+ userId ? membershipsByUserId.get(userId) : undefined;
+
const formatNumber = (value: number | null): string => {
if (value === null || value === undefined) return "0";
@@ -95,32 +103,28 @@ export default function TeamMemberTab({
const getUserCurrentCycleSpend = (userId: string | null): number => {
if (!userId) return 0;
- const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
- return membership?.spend ?? 0;
+ return findMembership(userId)?.spend ?? 0;
};
const getUserTotalSpend = (userId: string | null): number => {
if (!userId) return 0;
- const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
- return membership?.total_spend ?? 0;
+ return findMembership(userId)?.total_spend ?? 0;
};
const getUserBudgetSource = (userId: string | null): TeamMemberBudgetSource => {
if (!userId) return "none";
- const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
- return membership?.budget_source ?? "none";
+ return findMembership(userId)?.budget_source ?? "none";
};
const getUserBudget = (userId: string | null): number | null => {
if (!userId) return null;
- const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
- return membership?.litellm_budget_table?.max_budget ?? teamDefaultBudget;
+ return findMembership(userId)?.litellm_budget_table?.max_budget ?? teamDefaultBudget;
};
// Helper function to get rate limits for a user
const getUserRateLimits = (userId: string | null): string => {
if (!userId) return "No Limits";
- const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
+ const membership = findMembership(userId);
const rpmLimit = membership?.litellm_budget_table?.rpm_limit;
const tpmLimit = membership?.litellm_budget_table?.tpm_limit;
@@ -139,15 +143,13 @@ export default function TeamMemberTab({
const getUserAllowedModels = (userId: string | null): string[] | null => {
if (!userId) return null;
- const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
- const models = membership?.litellm_budget_table?.allowed_models;
+ const models = findMembership(userId)?.litellm_budget_table?.allowed_models;
return models && models.length > 0 ? models : null;
};
const getUserBudgetReset = (userId: string | null): string | null => {
if (!userId) return null;
- const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
- return membership?.litellm_budget_table?.budget_reset_at ?? null;
+ return findMembership(userId)?.litellm_budget_table?.budget_reset_at ?? null;
};
const extraColumns: NonNullable["extraColumns"]> = [
@@ -312,7 +314,7 @@ export default function TeamMemberTab({
members={teamData.team_info.members_with_roles}
canEdit={canEditTeam}
onEdit={(record) => {
- const membership = teamData.team_memberships.find((tm) => tm.user_id === record.user_id);
+ const membership = findMembership(record.user_id);
setSelectedEditMember(
seedMemberBudgetFields(
record,