Merge remote-tracking branch 'origin/litellm_internal_staging' into feat-prism-provider

This commit is contained in:
rajitkhanna 2026-09-11 19:47:39 -07:00
commit 1ea086167e
11 changed files with 617 additions and 195 deletions

View file

@ -4388,7 +4388,12 @@ class TeamAccessGroupModelGrant(LiteLLMPydanticObjectBase):
agent_ids: tuple[str, ...] = ()
class TeamInfoMember(Member):
user_alias: str | None = None
class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
members_with_roles: tuple[TeamInfoMember, ...] = ()
team_member_budget_table: LiteLLM_BudgetTableFull | None = None
# Resources inherited from access groups (separate from direct assignments)
access_group_models: list[str] | None = None

View file

@ -61,6 +61,7 @@ from litellm.proxy._types import (
SpecialProxyStrings,
TeamAccessGroupModelGrant,
TeamAddMemberResponse,
TeamInfoMember,
TeamInfoResponseObject,
TeamInfoResponseObjectTeamTable,
TeamListResponseObject,
@ -4292,37 +4293,35 @@ async def _add_team_member_budget_table(
return team_info_response_object
async def _hydrate_member_emails(
async def _hydrate_member_user_details(
prisma_client: PrismaClient,
members: Sequence[Member],
) -> tuple[Member, ...]:
"""Fill in ``user_email`` for roster entries that were stored without one.
``members_with_roles`` is a denormalized snapshot written at add-time, so an entry
stored with ``user_email=None`` keeps that null even once the user row has an email.
Look the missing ones up in ``LiteLLM_UserTable`` (one indexed query) and fill them
in. A stored email is never overwritten - the snapshot stays the source of truth
wherever it has a value.
"""
missing_user_ids: Final = frozenset(m.user_id for m in members if not m.user_email and m.user_id is not None)
if not missing_user_ids:
return tuple(members)
user_rows: Final[Sequence[prisma_models.LiteLLM_UserTable]] = await _user_db(prisma_client).find_many(
where={ # mutable-ok: Prisma query filters are dict-shaped
"user_id": { # mutable-ok: Prisma query filters are dict-shaped
"in": sorted(missing_user_ids)
) -> tuple[TeamInfoMember, ...]:
"""Attach ``user_alias`` and fill in a missing ``user_email`` from ``LiteLLM_UserTable`` in one query."""
user_ids: Final = frozenset(m.user_id for m in members if m.user_id is not None)
user_rows: Final[Sequence[prisma_models.LiteLLM_UserTable]] = (
await _user_db(prisma_client).find_many(
where={ # mutable-ok: Prisma query filters are dict-shaped
"user_id": { # mutable-ok: Prisma query filters are dict-shaped
"in": sorted(user_ids)
}
}
}
)
if user_ids
else ()
)
email_by_user_id: Final = MappingProxyType({u.user_id: u.user_email for u in user_rows if u.user_email})
user_by_id: Final = MappingProxyType({u.user_id: u for u in user_rows})
return tuple(
m.model_copy(update={"user_email": email_by_user_id[m.user_id]}) # mutable-ok: pydantic update payload
if not m.user_email and m.user_id is not None and m.user_id in email_by_user_id
else m
for m in members
)
def hydrate(m: Member) -> TeamInfoMember:
user_row: Final = user_by_id.get(m.user_id) if m.user_id is not None else None
return TeamInfoMember(
role=m.role,
user_id=m.user_id,
user_email=m.user_email or (user_row.user_email if user_row is not None else None),
user_alias=user_row.user_alias if user_row is not None else None,
)
return tuple(hydrate(m) for m in members)
async def _resolve_team_access_group_resources(
@ -4462,17 +4461,12 @@ async def team_info(
# Resolve resources inherited from access groups
resolved_team_info: Final = await _resolve_team_access_group_resources(_team_info)
# Fill in emails the add-time roster snapshot never captured
hydrated_members: Final = await _hydrate_member_emails(
hydrated_members: Final = await _hydrate_member_user_details(
prisma_client=prisma_client,
members=resolved_team_info.members_with_roles,
)
hydrated_team_info: Final = resolved_team_info.model_copy(
update={ # mutable-ok: pydantic update payload
# list(), not the tuple: model_copy skips validation, so the field has
# to be handed the list[Member] the response model declares.
"members_with_roles": list(hydrated_members) # mutable-ok: declared list[Member]
}
update={"members_with_roles": hydrated_members} # mutable-ok: pydantic update payload
)
response_object: Final = TeamInfoResponseObject(

View file

@ -11220,13 +11220,14 @@ async def test_team_info_returns_model_aliases():
@pytest.mark.asyncio
async def test_team_info_hydrates_member_emails_from_the_user_table():
"""/team/info must fill in emails missing from the members_with_roles snapshot.
async def test_team_info_hydrates_member_names_and_emails_from_the_user_table():
"""/team/info must attach each member's display name and fill in emails missing
from the members_with_roles snapshot.
members_with_roles is written at add-time, so a member added by user_id alone
carries user_email=None forever. Without this join the Admin UI's member table
shows "-" for a user that has an email on their user row. A stored email is left
exactly as-is.
members_with_roles is written at add-time, so it never carries user_alias and a
member added by user_id alone carries user_email=None forever. Without this join
the Admin UI's member table can only show emails. A stored email is left exactly
as-is.
"""
from fastapi import Request
@ -11246,13 +11247,8 @@ async def test_team_info_hydrates_member_emails_from_the_user_table():
find_many = AsyncMock(
return_value=[
LiteLLM_UserTable(
user_id="no-email-on-roster",
user_email="real@example.com",
max_budget=None,
spend=0.0,
models=[],
)
_user_row("no-email-on-roster", "real@example.com", "Real Person"),
_user_row("already-stored", "current@example.com", "Stored Person"),
]
)
@ -11270,12 +11266,12 @@ async def test_team_info_hydrates_member_emails_from_the_user_table():
)
members = response["team_info"].members_with_roles
assert [(m.user_id, m.user_email) for m in members] == [
("no-email-on-roster", "real@example.com"),
("already-stored", "stored@example.com"),
assert [(m.user_id, m.user_email, m.user_alias) for m in members] == [
("no-email-on-roster", "real@example.com", "Real Person"),
("already-stored", "stored@example.com", "Stored Person"),
]
# only the member actually missing an email is looked up
assert find_many.await_args.kwargs["where"] == {"user_id": {"in": ["no-email-on-roster"]}}
find_many.assert_awaited_once()
assert find_many.await_args.kwargs["where"] == {"user_id": {"in": ["already-stored", "no-email-on-roster"]}}
@pytest.mark.asyncio
@ -12472,89 +12468,93 @@ async def test_resolve_existing_member_user_ids_skips_the_query_when_no_user_ids
repo.return_value.table.find_many.assert_not_awaited()
def _user_row(user_id: str, user_email: str | None) -> LiteLLM_UserTable:
def _user_row(user_id: str, user_email: str | None, user_alias: str | None = None) -> LiteLLM_UserTable:
return LiteLLM_UserTable(
user_id=user_id, user_email=user_email, max_budget=None, spend=0.0, models=[]
user_id=user_id, user_email=user_email, user_alias=user_alias, max_budget=None, spend=0.0, models=[]
)
@pytest.mark.asyncio
async def test_hydrate_member_emails_fills_in_emails_the_roster_snapshot_never_captured():
"""A member added by user_id alone has user_email=None on the stored roster entry.
/team/info has to fill it in from the user row, or the UI renders "-" for a user
that plainly has an email.
async def test_hydrate_member_user_details_attaches_alias_and_fills_in_missing_email():
"""The stored roster never carries a display name, and a member added by user_id
alone has user_email=None. /team/info has to fill both in from the user row so the
UI can show and search by a human-readable name instead of only an email.
"""
from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_emails
from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_user_details
find_many = AsyncMock(return_value=[_user_row("by-id", "found@example.com")])
find_many = AsyncMock(return_value=[_user_row("by-id", "found@example.com", "Found Person")])
with patch("litellm.proxy.management_endpoints.team_endpoints.UserRepository") as repo:
repo.return_value.table.find_many = find_many
hydrated = await _hydrate_member_emails(
hydrated = await _hydrate_member_user_details(
prisma_client=MagicMock(),
members=[Member(user_id="by-id", role="admin")],
)
assert [(m.user_id, m.user_email, m.role) for m in hydrated] == [("by-id", "found@example.com", "admin")]
assert [(m.user_id, m.user_email, m.user_alias, m.role) for m in hydrated] == [
("by-id", "found@example.com", "Found Person", "admin")
]
find_many.assert_awaited_once()
assert find_many.await_args.kwargs["where"] == {"user_id": {"in": ["by-id"]}}
@pytest.mark.asyncio
async def test_hydrate_member_emails_never_overwrites_a_stored_email():
"""The snapshot wins wherever it has a value - hydration only fills blanks.
Overwriting would be a real behavior change to /team/info; filling a null is not.
"""
from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_emails
find_many = AsyncMock(return_value=[_user_row("has-email", "current@example.com")])
async def test_hydrate_member_user_details_never_overwrites_a_stored_email():
"""The snapshot wins wherever it has a value - hydration only fills blanks."""
from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_user_details
with patch("litellm.proxy.management_endpoints.team_endpoints.UserRepository") as repo:
repo.return_value.table.find_many = find_many
repo.return_value.table.find_many = AsyncMock(
return_value=[_user_row("has-email", "current@example.com", "Current Name")]
)
hydrated = await _hydrate_member_emails(
hydrated = await _hydrate_member_user_details(
prisma_client=MagicMock(),
members=[Member(user_id="has-email", user_email="stored@example.com", role="user")],
)
assert hydrated[0].user_email == "stored@example.com"
# nothing was missing, so no round-trip either
find_many.assert_not_awaited()
assert (hydrated[0].user_email, hydrated[0].user_alias) == ("stored@example.com", "Current Name")
@pytest.mark.asyncio
async def test_hydrate_member_emails_leaves_members_alone_when_the_user_row_has_no_email():
"""A user row with no email leaves the member as-is rather than inventing one."""
from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_emails
async def test_hydrate_member_user_details_leaves_blanks_when_the_user_row_is_bare_or_missing():
"""A user row with no email or alias, or no user row at all, must not invent values."""
from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_user_details
with patch("litellm.proxy.management_endpoints.team_endpoints.UserRepository") as repo:
repo.return_value.table.find_many = AsyncMock(return_value=[_user_row("no-email", None)])
repo.return_value.table.find_many = AsyncMock(return_value=[_user_row("bare", None)])
hydrated = await _hydrate_member_emails(
hydrated = await _hydrate_member_user_details(
prisma_client=MagicMock(),
members=[Member(user_id="no-email", role="user"), Member(user_email="e@example.com", role="user")],
members=[
Member(user_id="bare", role="user"),
Member(user_id="deleted", user_email="gone@example.com", role="user"),
Member(user_email="e@example.com", role="user"),
],
)
assert [m.user_email for m in hydrated] == [None, "e@example.com"]
assert [(m.user_id, m.user_email, m.user_alias) for m in hydrated] == [
("bare", None, None),
("deleted", "gone@example.com", None),
(None, "e@example.com", None),
]
@pytest.mark.asyncio
async def test_hydrate_member_emails_skips_the_query_when_every_member_has_one():
"""No blanks means /team/info pays for no extra query."""
from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_emails
async def test_hydrate_member_user_details_skips_the_query_when_no_member_has_a_user_id():
"""Email-only roster entries give nothing to look up, so /team/info pays for no query."""
from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_user_details
with patch("litellm.proxy.management_endpoints.team_endpoints.UserRepository") as repo:
repo.return_value.table.find_many = AsyncMock()
hydrated = await _hydrate_member_emails(
hydrated = await _hydrate_member_user_details(
prisma_client=MagicMock(),
members=[Member(user_id="a", user_email="a@example.com", role="user")],
members=[Member(user_email="a@example.com", role="user")],
)
assert hydrated[0].user_email == "a@example.com"
assert [(m.user_email, m.user_alias) for m in hydrated] == [("a@example.com", None)]
repo.return_value.table.find_many.assert_not_awaited()

View file

@ -0,0 +1,215 @@
import { 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";
import type { Member } from "@/components/networking";
import { renderWithProviders } from "../../../tests/test-utils";
import MemberTable, { MemberTableColumn, memberRoleOptions } from "./MemberTable";
const MEMBERS: Member[] = [
{ user_id: "u-zed", user_email: "zed@example.com", user_alias: "Zed Ortiz", role: "user" },
{ user_id: "u-nameless", user_email: "mystery@example.com", user_alias: null, role: "user" },
{ user_id: "u-amy", user_email: "amy@example.com", user_alias: "amy chen", role: "admin" },
{ user_id: "u-bob", user_email: null, user_alias: "Bob Lee", role: "user" },
];
const BUDGETS: Record<string, number | null> = { "u-zed": 50, "u-nameless": null, "u-amy": 1000, "u-bob": 5 };
const budgetColumn: MemberTableColumn = {
title: "Budget",
key: "budget",
sortValue: (member) => BUDGETS[member.user_id ?? ""] ?? null,
render: (member) => <span>{BUDGETS[member.user_id ?? ""] ?? "Unlimited"}</span>,
};
const renderTable = (overrides: Partial<React.ComponentProps<typeof MemberTable>> = {}) => {
const props = {
members: MEMBERS,
canEdit: true,
onEdit: vi.fn(),
onDelete: vi.fn(),
extraColumns: [budgetColumn],
...overrides,
};
renderWithProviders(<MemberTable {...props} />);
return props;
};
const rowIds = (): (string | null)[] =>
Array.from(document.querySelectorAll("tbody tr[data-row-id]")).map((row) => row.getAttribute("data-row-id"));
const search = (value: string) => fireEvent.change(screen.getByTestId("datatable-search"), { target: { value } });
describe("MemberTable display", () => {
it("shows each member's name, falling back to a dash when there is none", () => {
renderTable();
expect(within(screen.getByRole("row", { name: /zed@example\.com/ })).getByText("Zed Ortiz")).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /^name/i })).toBeInTheDocument();
const cells = within(screen.getByRole("row", { name: /mystery@example\.com/ })).getAllByRole("cell");
expect(cells[0]).toHaveTextContent("-");
});
it("orders members by name with nameless members last by default", () => {
renderTable();
expect(rowIds()).toEqual(["u-amy", "u-bob", "u-zed", "u-nameless"]);
});
it("reports the full member count", () => {
renderTable();
expect(screen.getByText("4 Members")).toBeInTheDocument();
});
});
describe("MemberTable search", () => {
it("matches on name case-insensitively", async () => {
renderTable();
search("ZED");
await waitFor(() => expect(rowIds()).toEqual(["u-zed"]));
});
it("matches on email", async () => {
renderTable();
search("mystery@");
await waitFor(() => expect(rowIds()).toEqual(["u-nameless"]));
});
it("matches on user id", async () => {
renderTable();
search("u-bob");
await waitFor(() => expect(rowIds()).toEqual(["u-bob"]));
});
it("still searches names when the first member has no name", async () => {
renderTable({ members: [MEMBERS[1], MEMBERS[0]] });
search("ortiz");
await waitFor(() => expect(rowIds()).toEqual(["u-zed"]));
});
it("does not match on role", async () => {
renderTable();
search("admin");
await waitFor(() => expect(rowIds()).toEqual([]));
expect(screen.getByText("No members match your search or filters")).toBeInTheDocument();
});
});
describe("MemberTable sorting", () => {
it("sorts by email with missing emails last in both directions", async () => {
const user = userEvent.setup();
renderTable();
await user.click(screen.getByTestId("sort-header-user_email"));
expect(rowIds()).toEqual(["u-amy", "u-nameless", "u-zed", "u-bob"]);
await user.click(screen.getByTestId("sort-header-user_email"));
expect(rowIds()).toEqual(["u-zed", "u-nameless", "u-amy", "u-bob"]);
});
it("sorts by role", async () => {
const user = userEvent.setup();
renderTable();
await user.click(screen.getByTestId("sort-header-role"));
expect(rowIds()[0]).toBe("u-amy");
await user.click(screen.getByTestId("sort-header-role"));
expect(rowIds()[3]).toBe("u-amy");
});
it("sorts an extra column numerically by its sort value with blanks last", async () => {
const user = userEvent.setup();
renderTable();
await user.click(screen.getByTestId("sort-header-budget"));
expect(rowIds()).toEqual(["u-bob", "u-zed", "u-amy", "u-nameless"]);
await user.click(screen.getByTestId("sort-header-budget"));
expect(rowIds()).toEqual(["u-amy", "u-zed", "u-bob", "u-nameless"]);
});
it("flips name order on the second click", async () => {
const user = userEvent.setup();
renderTable();
await user.click(screen.getByTestId("sort-header-user_alias"));
expect(rowIds()).toEqual(["u-zed", "u-bob", "u-amy", "u-nameless"]);
});
it("leaves extra columns without a sort value unsortable", () => {
renderTable({
extraColumns: [{ title: "Rate Limits", key: "rate_limits", render: () => <span>No Limits</span> }],
});
expect(screen.getByRole("columnheader", { name: "Rate Limits" })).toBeInTheDocument();
expect(screen.queryByTestId("sort-header-rate_limits")).not.toBeInTheDocument();
});
});
describe("MemberTable role filter", () => {
it("shows only members with the chosen role and clears on reset", async () => {
const user = userEvent.setup();
renderTable({ roleColumnTitle: "Team Role" });
await user.click(screen.getByTestId("datatable-filters-trigger"));
await user.click(screen.getByTestId("filter-role"));
await user.click(await screen.findByRole("option", { name: "admin" }));
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => expect(rowIds()).toEqual(["u-amy"]));
expect(screen.getByTestId("filter-chip-role")).toHaveTextContent("Team Role");
await user.click(screen.getByTestId("datatable-filters-trigger"));
await user.click(screen.getByTestId("filter-drawer-reset"));
await waitFor(() => expect(rowIds()).toHaveLength(4));
});
it("offers the roles present in the roster", () => {
expect(memberRoleOptions(MEMBERS)).toEqual(["admin", "user"]);
expect(memberRoleOptions([{ user_id: "x", role: "" }])).toEqual([]);
});
});
describe("MemberTable actions", () => {
it("passes the clicked member to onEdit and onDelete", async () => {
const user = userEvent.setup();
const { onEdit, onDelete } = renderTable();
const row = screen.getByRole("row", { name: /amy@example\.com/ });
await user.click(within(row).getByTestId("edit-member"));
await user.click(within(row).getByTestId("delete-member"));
expect(onEdit).toHaveBeenCalledWith(MEMBERS[2]);
expect(onDelete).toHaveBeenCalledWith(MEMBERS[2]);
});
it("hides delete for members the caller excludes", () => {
renderTable({ showDeleteForMember: (member) => member.role !== "admin" });
expect(screen.getAllByTestId("delete-member")).toHaveLength(3);
expect(
within(screen.getByRole("row", { name: /amy@example\.com/ })).queryByTestId("delete-member"),
).not.toBeInTheDocument();
});
it("shows the empty text when there are no members at all", () => {
renderTable({ members: [], emptyText: "No members found" });
expect(screen.getByText("No members found")).toBeInTheDocument();
});
});

View file

@ -1,17 +1,29 @@
import { SimpleTooltip } from "@/components/ui/tooltip";
import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table";
import { Crown, Info, User, UserPlus } from "lucide-react";
import React, { useState } from "react";
import { Member } from "@/components/networking";
import {
DataTable,
DataTableFilterDrawer,
DataTableFilterField,
DataTableSortHeader,
DataTableToolbar,
} from "@/components/shared/DataTable";
import { StatusBadge } from "@/components/shared/table_cells";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Crown, Info, User, UserPlus } from "lucide-react";
import React from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { SimpleTooltip } from "@/components/ui/tooltip";
import TableIconActionButton from "./IconActionButton/TableIconActionButtons/TableIconActionButton";
export type MemberTableSortValue = string | number | null | undefined;
export interface MemberTableColumn {
title: React.ReactNode;
key: React.Key;
dataIndex?: keyof Member;
render?: (value: Member[keyof Member], member: Member, index: number) => React.ReactNode;
key: string;
render: (member: Member) => React.ReactNode;
sortValue?: (member: Member) => MemberTableSortValue;
}
export interface MemberTableProps {
@ -27,12 +39,152 @@ export interface MemberTableProps {
emptyText?: string;
}
const extraColumnCell = (column: MemberTableColumn, member: Member, index: number): React.ReactNode => {
const value = column.dataIndex ? member[column.dataIndex] : undefined;
return column.render ? column.render(value, member, index) : value;
const ALL_ROLES = "all";
export const memberRowId = (member: Member): string => member.user_id ?? member.user_email ?? JSON.stringify(member);
export const memberRoleOptions = (members: readonly Member[]): string[] =>
Array.from(new Set(members.map((member) => member.role).filter((role) => role !== ""))).sort();
const isAdminRole = (role: string): boolean => {
const normalized = role.toLowerCase();
return normalized === "admin" || normalized === "org_admin";
};
const STICKY_ACTIONS_CLASS = "sticky right-0 w-[120px] bg-background";
function RoleHeaderTitle({ title, tooltip }: { title: string; tooltip?: string }) {
if (tooltip === undefined) return <>{title}</>;
return (
<span className="inline-flex items-center gap-2">
{title}
<SimpleTooltip content={tooltip}>
<Info className="size-3.5" />
</SimpleTooltip>
</span>
);
}
const ACTIONS_COLUMN_WIDTH = 120;
interface MemberColumnDeps {
canEdit: boolean;
onEdit: (member: Member) => void;
onDelete: (member: Member) => void;
roleColumnTitle: string;
roleTooltip?: string;
extraColumns: MemberTableColumn[];
showDeleteForMember?: (member: Member) => boolean;
}
const extraColumnDef = (column: MemberTableColumn): ColumnDef<Member> => {
const { sortValue } = column;
if (sortValue === undefined) {
return {
id: column.key,
header: () => <span className="font-medium">{column.title}</span>,
enableSorting: false,
enableGlobalFilter: false,
cell: ({ row }) => column.render(row.original),
};
}
return {
id: column.key,
accessorFn: (member) => sortValue(member) ?? undefined,
header: ({ column: tableColumn }) => <DataTableSortHeader column={tableColumn} title={column.title} />,
sortDescFirst: false,
sortUndefined: "last",
enableGlobalFilter: false,
cell: ({ row }) => column.render(row.original),
};
};
const buildColumns = ({
canEdit,
onEdit,
onDelete,
roleColumnTitle,
roleTooltip,
extraColumns,
showDeleteForMember,
}: MemberColumnDeps): ColumnDef<Member>[] => [
{
id: "user_alias",
accessorFn: (member) => member.user_alias || undefined,
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
sortingFn: "text",
sortUndefined: "last",
enableGlobalFilter: true,
meta: { title: "Name" },
cell: ({ row }) => row.original.user_alias || <span className="text-muted-foreground">-</span>,
},
{
id: "user_email",
accessorFn: (member) => member.user_email || undefined,
header: ({ column }) => <DataTableSortHeader column={column} title="User Email" />,
sortingFn: "text",
sortUndefined: "last",
enableGlobalFilter: true,
meta: { title: "User Email" },
cell: ({ row }) => row.original.user_email || "-",
},
{
id: "user_id",
accessorFn: (member) => member.user_id ?? undefined,
header: "User ID",
enableSorting: false,
enableGlobalFilter: true,
cell: ({ row }) =>
row.original.user_id === "default_user_id" ? (
<StatusBadge tone="info" label="Default Proxy Admin" />
) : (
row.original.user_id || "-"
),
},
{
id: "role",
accessorFn: (member) => member.role,
header: ({ column }) => (
<DataTableSortHeader column={column} title={<RoleHeaderTitle title={roleColumnTitle} tooltip={roleTooltip} />} />
),
sortingFn: "text",
filterFn: "equalsString",
enableGlobalFilter: false,
meta: { title: roleColumnTitle },
cell: ({ row }) => (
<span className="inline-flex items-center gap-2">
{isAdminRole(row.original.role) ? <Crown className="size-3.5" /> : <User className="size-3.5" />}
<span className="capitalize">{row.original.role || "-"}</span>
</span>
),
},
...extraColumns.map(extraColumnDef),
{
id: "actions",
header: "Actions",
size: ACTIONS_COLUMN_WIDTH,
enableSorting: false,
enableGlobalFilter: false,
meta: { pinned: "right" },
cell: ({ row }) =>
canEdit ? (
<span className="inline-flex items-center gap-2">
<TableIconActionButton
variant="Edit"
tooltipText="Edit member"
dataTestId="edit-member"
onClick={() => onEdit(row.original)}
/>
{(!showDeleteForMember || showDeleteForMember(row.original)) && (
<TableIconActionButton
variant="Delete"
tooltipText="Delete member"
dataTestId="delete-member"
onClick={() => onDelete(row.original)}
/>
)}
</span>
) : null,
},
];
export default function MemberTable({
members,
@ -46,90 +198,89 @@ export default function MemberTable({
showDeleteForMember,
emptyText,
}: MemberTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [filtersOpen, setFiltersOpen] = useState(false);
const columnDeps: MemberColumnDeps = {
canEdit,
onEdit,
onDelete,
roleColumnTitle,
roleTooltip,
extraColumns,
showDeleteForMember,
};
const columns = buildColumns(columnDeps);
const roleFilterItems = [
{ value: ALL_ROLES, label: "All Roles" },
...memberRoleOptions(members).map((role) => ({ value: role, label: role })),
];
const isNarrowed = globalFilter !== "" || columnFilters.length > 0;
return (
<div className="flex w-full flex-col gap-2">
<span className="inline-flex text-sm text-foreground">
{members.length} Member{members.length !== 1 ? "s" : ""}
</span>
<Table>
<TableHeader>
<TableRow>
<TableHead>User Email</TableHead>
<TableHead>User ID</TableHead>
<TableHead>
{roleTooltip ? (
<span className="inline-flex items-center gap-2">
{roleColumnTitle}
<SimpleTooltip content={roleTooltip}>
<Info className="size-3.5" />
</SimpleTooltip>
</span>
) : (
roleColumnTitle
<DataTable
data={members}
columns={columns}
getRowId={memberRowId}
sortingMode="client"
defaultSorting={[{ id: "user_alias", desc: false }]}
filterMode="client"
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
globalFilter={globalFilter}
onGlobalFilterChange={setGlobalFilter}
noDataMessage={
<span className="text-muted-foreground">
{isNarrowed ? "No members match your search or filters" : emptyText ?? "No data"}
</span>
}
toolbar={(table) => (
<>
<DataTableToolbar
table={table}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Search by name, email, or user ID"
onOpenFilters={() => setFiltersOpen(true)}
showViewOptions={false}
/>
<DataTableFilterDrawer
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description="Narrow down members"
>
{({ get, set }) => (
<DataTableFilterField label={roleColumnTitle}>
<Select
items={roleFilterItems}
value={(get("role") as string | undefined) ?? ALL_ROLES}
onValueChange={(value) => set("role", value === ALL_ROLES ? undefined : value)}
>
<SelectTrigger className="w-full" data-testid="filter-role">
<SelectValue placeholder="All Roles" />
</SelectTrigger>
<SelectContent>
{roleFilterItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</DataTableFilterField>
)}
</TableHead>
{extraColumns.map((column) => (
<TableHead key={column.key}>{column.title}</TableHead>
))}
<TableHead className={STICKY_ACTIONS_CLASS}>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={extraColumns.length + 4} className="text-center text-muted-foreground">
{emptyText ?? "No data"}
</TableCell>
</TableRow>
) : (
members.map((member, memberIndex) => (
<TableRow key={member.user_id ?? member.user_email ?? JSON.stringify(member)}>
<TableCell>{member.user_email || "-"}</TableCell>
<TableCell>
{member.user_id === "default_user_id" ? (
<StatusBadge tone="info" label="Default Proxy Admin" />
) : (
member.user_id || "-"
)}
</TableCell>
<TableCell>
<span className="inline-flex items-center gap-2">
{member.role?.toLowerCase() === "admin" || member.role?.toLowerCase() === "org_admin" ? (
<Crown className="size-3.5" />
) : (
<User className="size-3.5" />
)}
<span className="capitalize">{member.role || "-"}</span>
</span>
</TableCell>
{extraColumns.map((column) => (
<TableCell key={column.key}>{extraColumnCell(column, member, memberIndex)}</TableCell>
))}
<TableCell className={STICKY_ACTIONS_CLASS}>
{canEdit ? (
<span className="inline-flex items-center gap-2">
<TableIconActionButton
variant="Edit"
tooltipText="Edit member"
dataTestId="edit-member"
onClick={() => onEdit(member)}
/>
{(!showDeleteForMember || showDeleteForMember(member)) && (
<TableIconActionButton
variant="Delete"
tooltipText="Delete member"
dataTestId="delete-member"
onClick={() => onDelete(member)}
/>
)}
</span>
) : null}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DataTableFilterDrawer>
</>
)}
/>
{onAddMember && canEdit && (
<Button onClick={onAddMember} className="self-start">
<UserPlus className="size-4" />

View file

@ -2946,6 +2946,7 @@ export interface Member {
role: string;
user_id: string | null;
user_email?: string | null;
user_alias?: string | null;
max_budget_in_team?: number | null;
tpm_limit?: number | null;
rpm_limit?: number | null;

View file

@ -121,23 +121,23 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
return <div className="p-4">Organization not found</div>;
}
const orgMemberById = new Map((orgData.members || []).map((m) => [m.user_id, m]));
const orgMemberFor = (record: Member) => (record.user_id != null ? orgMemberById.get(record.user_id) : undefined);
const orgExtraColumns: MemberTableColumn[] = [
{
title: "Spend (USD)",
key: "spend",
render: (_: unknown, record: Member) => {
const orgMember =
record.user_id != null ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined;
return <MoneyCell value={orgMember?.spend} decimals={4} />;
},
sortValue: (record: Member) => orgMemberFor(record)?.spend ?? null,
render: (record: Member) => <MoneyCell value={orgMemberFor(record)?.spend} decimals={4} />,
},
{
title: "Created At",
key: "created_at",
render: (_: unknown, record: Member) => {
const orgMember =
record.user_id != null ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined;
return <span>{orgMember?.created_at ? new Date(orgMember.created_at).toLocaleString() : "-"}</span>;
sortValue: (record: Member) => orgMemberFor(record)?.created_at ?? null,
render: (record: Member) => {
const createdAt = orgMemberFor(record)?.created_at;
return <span>{createdAt ? new Date(createdAt).toLocaleString() : "-"}</span>;
},
},
];
@ -252,10 +252,12 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<TabsContent keepMounted={hasVisited("members")} value="members" className="pt-4">
<div className="space-y-4">
<MemberTable
key={orgData.organization_id}
members={(orgData.members || []).map((m) => ({
role: m.user_role || "",
user_id: m.user_id,
user_email: m.user_email,
user_alias: m.user?.user_alias ?? null,
}))}
canEdit={canEditOrg}
onEdit={(member) => {

View file

@ -390,6 +390,28 @@ describe("DataTable filtering", () => {
expect(names()).toEqual(["Alice"]);
});
it("client global filter searches an opted-in column even when the first row has no value", () => {
const nicknameColumns: ColumnDef<Person, unknown>[] = [
...nameEmailColumns,
{
id: "nickname",
accessorFn: (row) => (row.id === "b" ? "Bobby" : undefined),
enableGlobalFilter: true,
header: "Nickname",
},
];
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={nicknameColumns}
filterMode="client"
globalFilter="bobby"
onGlobalFilterChange={vi.fn()}
/>,
);
expect(names()).toEqual(["Bob"]);
});
it("server mode never filters locally even when columnFilters is set", () => {
render(
<DataTable

View file

@ -96,6 +96,13 @@ function derivePinning<TData, TValue>(columns: ColumnDef<TData, TValue>[]): Colu
return { left: collect("left"), right: collect("right") };
}
function columnCanGlobalFilter<TData>(firstRow: TData | undefined, column: Column<TData, unknown>): boolean {
if (column.columnDef.enableGlobalFilter === true) return true;
if (firstRow === undefined || column.accessorFn === undefined) return false;
const firstValue: unknown = column.accessorFn(firstRow, 0);
return typeof firstValue === "string" || typeof firstValue === "number";
}
function buildRowModels<TData>(
sortingMode: SortingMode,
paginationMode: PaginationMode,
@ -516,6 +523,7 @@ function useDataTableInstance<TData extends RowData, TValue>(
onRowSelectionChange: rowSelectionState.onChange,
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
getColumnCanGlobalFilter: (column) => columnCanGlobalFilter(data[0], column),
getCoreRowModel: getCoreRowModel(),
...buildRowModels(sortingMode, paginationMode, filterMode, expansionGuard),
...(getRowId !== undefined ? { getRowId } : {}),

View file

@ -1,4 +1,4 @@
import { screen, within } from "@testing-library/react";
import { fireEvent, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
@ -168,6 +168,27 @@ describe("TeamMembersComponent", () => {
expect(table).toHaveTextContent("admin");
});
it("clears the member search when a different team is shown", () => {
const props = {
canEditTeam: false,
handleMemberDelete: mockHandleMemberDelete,
setSelectedEditMember: mockSetSelectedEditMember,
setIsEditMemberModalVisible: mockSetIsEditMemberModalVisible,
setIsAddMemberModalVisible: mockSetIsAddMemberModalVisible,
};
const { rerender } = renderWithProviders(<TeamMembersComponent teamData={createMockTeamData()} {...props} />);
fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "user2" } });
expect(screen.queryByText("user1@test.com")).not.toBeInTheDocument();
const otherTeam = createMockTeamData({ team_id: "team-456" });
rerender(<TeamMembersComponent teamData={otherTeam} {...props} />);
expect(screen.getByTestId("datatable-search")).toHaveValue("");
expect(screen.getAllByText("user1@test.com").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("user2@test.com").length).toBeGreaterThanOrEqual(1);
});
it("should render Add Member button", () => {
renderWithProviders(
<TeamMembersComponent

View file

@ -108,7 +108,7 @@ export default function TeamMemberTab({
</span>
),
key: "model_scope",
render: (_: unknown, record: Member) => {
render: (record: Member) => {
const models = getUserAllowedModels(record.user_id);
if (!models) {
return <span className="text-muted-foreground">(all team models)</span>;
@ -141,9 +141,8 @@ export default function TeamMemberTab({
</span>
),
key: "spend",
render: (_: unknown, record: Member) => (
<MoneyCell value={getUserCurrentCycleSpend(record.user_id)} decimals={2} />
),
sortValue: (record: Member) => getUserCurrentCycleSpend(record.user_id),
render: (record: Member) => <MoneyCell value={getUserCurrentCycleSpend(record.user_id)} decimals={2} />,
},
{
title: (
@ -155,19 +154,22 @@ export default function TeamMemberTab({
</span>
),
key: "total_spend",
render: (_: unknown, record: Member) => <MoneyCell value={getUserTotalSpend(record.user_id)} decimals={2} />,
sortValue: (record: Member) => getUserTotalSpend(record.user_id),
render: (record: Member) => <MoneyCell value={getUserTotalSpend(record.user_id)} decimals={2} />,
},
{
title: "Team Member Budget (USD)",
key: "budget",
render: (_: unknown, record: Member) => (
sortValue: (record: Member) => getUserBudget(record.user_id),
render: (record: Member) => (
<MoneyCell value={getUserBudget(record.user_id)} decimals={2} emptyText="Unlimited" showZero />
),
},
{
title: "Budget Reset",
key: "budget_reset",
render: (_: unknown, record: Member) => <DateCell value={getUserBudgetReset(record.user_id)} precision="date" />,
sortValue: (record: Member) => getUserBudgetReset(record.user_id),
render: (record: Member) => <DateCell value={getUserBudgetReset(record.user_id)} precision="date" />,
},
{
title: (
@ -179,12 +181,13 @@ export default function TeamMemberTab({
</span>
),
key: "rate_limits",
render: (_: unknown, record: Member) => <span>{getUserRateLimits(record.user_id)}</span>,
render: (record: Member) => <span>{getUserRateLimits(record.user_id)}</span>,
},
];
return (
<MemberTable
key={teamData.team_id}
members={teamData.team_info.members_with_roles}
canEdit={canEditTeam}
onEdit={(record) => {