feat(ui): rebuild the Teams table on the shared DataTable (#33128)

* feat(ui): rebuild the Teams table on the shared DataTable

The Your Teams tab moves off the Ant Design table onto the shared DataTable that the Virtual Keys page uses, following the new dashboard design. It gains server-side sort, pagination and filtering, a toolbar with a filter drawer and a columns menu, and a per-row actions menu

Sorting is wired only to the columns /v2/team/list can actually order by (team_alias, created_at); Spend / Budget and Updated stay unsorted because the endpoint silently ignores those fields. The design's "Created by" column is dropped since the team object has no such field, and the drawer's "Has keys" filter is dropped for the same reason. The Resources cell shows members, models and keys as colored pills, and the actions menu keeps the existing Edit, Copy team ID and Delete behaviors, with Edit and Delete gated to Admin

The teams grid gets its own unit tests in TeamsPage/TeamsTable.test.tsx. Teams.tsx keeps the create-team modal, delete modal, detail view and tabs, now refreshing the list through React Query invalidation instead of a manual refetch

* fix(ui): match Teams loading skeletons to the rendered row height

The default twoLine and chips skeleton shapes rendered the Team and Resources cells shorter than the loaded row (a real row measures 55px, the old skeleton ~49px), so the loading state looked visibly squat. Give the Team column a custom renderSkeleton that mirrors the two-line IdentityCell (measured 54px) and the Resources column one that mirrors the pills, and mark the hidden Rate Limits column as two-line so it matches when shown

* fix(ui): keep team admins' Members tab by deriving is_team_admin from the selected team

The redesign computed is_team_admin from useTeam(selectedTeamId), but that hook returns teamInfoCall's nested { team_info: { members_with_roles } } shape, so the top-level members_with_roles read was always undefined and is_team_admin was always false. For a non-proxy-admin team admin that hid the Members, Member Permissions and Settings tabs in the team detail view, which broke the team-admin add/remove member e2e tests. Pass the Team object up from the table instead (/v2/team/list returns it with a top-level members_with_roles), matching the pre-redesign behavior; proxy admins were unaffected because is_proxy_admin already granted access

Also point the Delete-a-team e2e at the new kebab: open the row actions menu, then click Delete team, rather than clicking the old inline delete icon
This commit is contained in:
yuneng-jiang 2026-07-13 17:47:14 -07:00 committed by GitHub
parent 53aaabba5e
commit e35f5d2b73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1211 additions and 1160 deletions

View file

@ -96,7 +96,9 @@ test.describe("Proxy Admin - Teams", () => {
const teamRow = page.locator("tr", { hasText: E2E_TEAM_DELETE_ALIAS }).first();
await expect(teamRow).toBeVisible({ timeout: 10_000 });
await teamRow.locator("svg, img").last().click();
// Actions live in a kebab menu: open it, then click "Delete team".
await teamRow.locator('[data-testid^="team-actions-"]').click();
await page.getByTestId("team-action-delete").click();
const modal = page.locator(".ant-modal:visible");
await expect(modal).toBeVisible({ timeout: 5_000 });

View file

@ -1643,13 +1643,13 @@
},
"src/components/Teams.tsx": {
"no-nested-ternary": {
"count": 4
"count": 2
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 4
"count": 3
}
},
"src/components/ToolDetail.tsx": {

View file

@ -84,6 +84,24 @@ export const teamListCall = async (
}
};
export const teamsTableKeys = createQueryKeys("teamsTable");
export const useTeamsTable = (
page: number,
pageSize: number,
options: TeamListCallOptions = {},
): UseQueryResult<TeamsResponse> => {
const { accessToken } = useAuthorized();
return useQuery<TeamsResponse>({
queryKey: teamsTableKeys.list({ page, limit: pageSize, ...options }),
queryFn: async () => await teamListCall(accessToken!, page, pageSize, options),
enabled: Boolean(accessToken),
staleTime: 30000,
placeholderData: keepPreviousData,
});
};
const teamKeys = createQueryKeys("teams");
export const useTeams = (): UseQueryResult<Team[]> => {
const { accessToken, userId, userRole } = useAuthorized();

View file

@ -1,15 +1,24 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key";
import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking";
import Teams from "./Teams";
import { teamListCall } from "@/app/(dashboard)/hooks/teams/useTeams";
const mockTeamInfoView = vi.fn();
const mockUseOrganizations = vi.fn();
// The teams grid is unit-tested in TeamsPage/TeamsTable.test.tsx. Here we stub it and drive its callbacks
// directly so we can test the Teams shell wiring (delete modal, detail view) without the real DataTable.
let mockTeamsTableProps: any = null;
vi.mock("./TeamsPage/TeamsTable", () => ({
TeamsTable: (props: any) => {
mockTeamsTableProps = props;
return <div data-testid="teams-table-stub" />;
},
}));
vi.mock("./networking", () => ({
teamCreateCall: vi.fn(),
teamDeleteCall: vi.fn(),
@ -19,8 +28,9 @@ vi.mock("./networking", () => ({
getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }),
}));
// Teams invalidates teamsTableKeys on mutations; the selected team is passed up from the table.
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
teamListCall: vi.fn().mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 0 }),
teamsTableKeys: { all: ["teamsTable"] },
}));
vi.mock("./molecules/notifications_manager", () => ({
@ -116,6 +126,21 @@ vi.mock("./common_components/AccessGroupSelector", () => ({
),
}));
const baseTableTeam = {
team_id: "1",
team_alias: "Test Team",
organization_id: "org-123",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [],
members_with_roles: [],
spend: 0,
};
const createQueryClient = () => {
return new QueryClient({
defaultOptions: {
@ -131,10 +156,16 @@ const renderWithQueryClient = (component: React.ReactElement) => {
return render(<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>);
};
// Re-establish safe defaults before every test (clearAllMocks keeps return values, so restore them here).
beforeEach(() => {
mockTeamsTableProps = null;
});
describe("Teams - handleCreate organization handling", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTeamInfoView.mockClear();
mockTeamsTableProps = null;
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]);
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
@ -142,7 +173,6 @@ describe("Teams - handleCreate organization handling", () => {
});
it("should not include organization_id when it's an empty string", async () => {
const mockAccessToken = "test-token";
const formValues: Record<string, any> = {
team_alias: "Test Team",
organization_id: "", // Empty string
@ -168,7 +198,6 @@ describe("Teams - handleCreate organization handling", () => {
models: [],
};
// Simulate the handleCreate logic
let organizationId = formValues?.organization_id || null;
if (organizationId === "" || typeof organizationId !== "string") {
formValues.organization_id = null;
@ -182,11 +211,10 @@ describe("Teams - handleCreate organization handling", () => {
it("should trim and keep valid organization_id string", async () => {
const formValues: Record<string, any> = {
team_alias: "Test Team",
organization_id: " org-123 ", // String with whitespace
organization_id: " org-123 ",
models: [],
};
// Simulate the handleCreate logic
let organizationId = formValues?.organization_id || null;
if (organizationId === "" || typeof organizationId !== "string") {
formValues.organization_id = null;
@ -204,7 +232,6 @@ describe("Teams - handleCreate organization handling", () => {
models: [],
};
// Simulate the handleCreate logic
let organizationId = formValues?.organization_id || null;
if (organizationId === "" || typeof organizationId !== "string") {
formValues.organization_id = null;
@ -223,7 +250,6 @@ describe("Teams - handleCreate organization handling", () => {
max_budget: 100,
};
// Simulate the handleCreate logic
let organizationId = formValues?.organization_id || null;
if (organizationId === "" || typeof organizationId !== "string") {
formValues.organization_id = null;
@ -231,18 +257,13 @@ describe("Teams - handleCreate organization handling", () => {
formValues.organization_id = organizationId.trim();
}
// Verify the structure
expect(formValues).toEqual({
team_alias: "Test Team",
organization_id: null,
models: ["gpt-4"],
max_budget: 100,
});
// Verify we're not sending an empty string
expect(formValues.organization_id).not.toBe("");
// Verify it's explicitly null, not undefined
expect(formValues.organization_id).toBeNull();
});
@ -259,7 +280,6 @@ describe("Teams - handleCreate organization handling", () => {
models: [],
};
// Simulate the handleCreate logic with currentOrg fallback
let organizationId = formValues?.organization_id || currentOrg?.organization_id;
if (organizationId === "" || typeof organizationId !== "string") {
formValues.organization_id = null;
@ -270,204 +290,27 @@ describe("Teams - handleCreate organization handling", () => {
expect(formValues.organization_id).toBe("fallback-org-id");
});
it("should not include organizations as an empty array in the request payload", async () => {
const mockTeamCreateCall = vi.mocked(teamCreateCall);
const mockAccessToken = "test-token";
const formValues = {
team_alias: "Test Team",
organization_id: "org-123",
models: ["gpt-4"],
organizations: [], // This should never be sent
};
// Remove organizations key if it's empty
if (Array.isArray(formValues.organizations) && formValues.organizations.length === 0) {
delete (formValues as any).organizations;
}
// Verify organizations key is removed
expect(formValues).not.toHaveProperty("organizations");
expect(formValues).toEqual({
team_alias: "Test Team",
organization_id: "org-123",
models: ["gpt-4"],
});
});
it("should handle organization_id validation for org admins", () => {
// This test simulates the validation that should happen for org admins
const isOrgAdmin = true;
const formValues: Record<string, any> = {
team_alias: "Test Team",
// organization_id is missing/undefined
};
// For org admins, organization_id should be required
const hasOrganization =
formValues.organization_id !== undefined &&
formValues.organization_id !== null &&
formValues.organization_id !== "";
if (isOrgAdmin && !hasOrganization) {
// This should trigger validation error
expect(hasOrganization).toBe(false);
}
});
it("should allow null organization_id for global admins", () => {
const isAdmin = true;
const formValues: Record<string, any> = {
team_alias: "Test Team",
organization_id: null,
models: [],
};
// Global admins can create teams without an organization
if (isAdmin) {
expect(formValues.organization_id).toBeNull();
// This is valid for admins
}
});
it("should ensure organization_id is never an empty list", () => {
const invalidFormValues: Record<string, any> = {
team_alias: "Test Team",
organization_id: [], // Wrong type - should be string or null
};
// Type check: organization_id should never be an array
expect(Array.isArray(invalidFormValues.organization_id)).toBe(true);
// Correct it to null
if (Array.isArray(invalidFormValues.organization_id)) {
invalidFormValues.organization_id = null;
}
expect(invalidFormValues.organization_id).toBeNull();
expect(Array.isArray(invalidFormValues.organization_id)).toBe(false);
});
it("should clear the delete modal when the cancel button is clicked", async () => {
it("opens the delete modal when the table's delete action fires", async () => {
mockUseOrganizations.mockReturnValue({ data: [] });
vi.mocked(teamListCall).mockResolvedValue({
teams: [
{
team_id: "1",
team_alias: "Test Team",
organization_id: "org-123",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [],
members_with_roles: [],
spend: 0,
},
],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => {
expect(screen.getByTestId("delete-team-button")).toBeInTheDocument();
});
const deleteTeamButton = screen.getByTestId("delete-team-button");
act(() => {
fireEvent.click(deleteTeamButton);
await waitFor(() => expect(mockTeamsTableProps).not.toBeNull());
await act(async () => {
mockTeamsTableProps.onDeleteTeam(baseTableTeam);
});
expect(screen.getByText("Delete Team?")).toBeInTheDocument();
});
});
describe("Teams - empty state", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseOrganizations.mockReturnValue({ data: [] });
});
it("should display empty state message when teams array is empty", async () => {
vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 });
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => {
expect(screen.getByText("No teams yet")).toBeInTheDocument();
});
expect(
screen.getByText("Create your first team to organize members and manage access to models."),
).toBeInTheDocument();
});
it("should display empty state message when teams is null", async () => {
vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 });
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => {
expect(screen.getByText("No teams yet")).toBeInTheDocument();
});
expect(
screen.getByText("Create your first team to organize members and manage access to models."),
).toBeInTheDocument();
});
it("should not display empty state when teams array has items", async () => {
vi.mocked(teamListCall).mockResolvedValue({
teams: [
{
team_id: "1",
team_alias: "Test Team",
organization_id: "org-123",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [],
members_with_roles: [],
spend: 0,
},
],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => {
expect(screen.getByText("Test Team")).toBeInTheDocument();
});
expect(screen.queryByText("No teams yet")).not.toBeInTheDocument();
expect(
screen.queryByText("Create your first team to organize members and manage access to models."),
).not.toBeInTheDocument();
});
});
describe("Teams - helper functions", () => {
describe("getAdminOrganizations", () => {
it("should return all organizations for Admin role", () => {
const organizations = [
{
organization_id: "org-1",
organization_alias: "Org 1",
models: [],
members: [],
},
{
organization_id: "org-2",
organization_alias: "Org 2",
models: [],
members: [],
},
{ organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] },
{ organization_id: "org-2", organization_alias: "Org 2", models: [], members: [] },
];
// Simulate getAdminOrganizations logic for Admin
const userRole = "Admin";
const result = userRole === "Admin" ? organizations : [];
@ -477,7 +320,6 @@ describe("Teams - helper functions", () => {
it("should return only org_admin organizations for Org Admin role", () => {
const userID = "user-123";
const userRole = "Org Admin";
const organizations = [
{
organization_id: "org-1",
@ -499,7 +341,6 @@ describe("Teams - helper functions", () => {
},
];
// Simulate getAdminOrganizations logic
const result = organizations.filter((org) =>
org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"),
);
@ -519,7 +360,6 @@ describe("Teams - helper functions", () => {
},
];
// Simulate getAdminOrganizations logic
const result = organizations.filter((org) =>
org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"),
);
@ -531,8 +371,7 @@ describe("Teams - helper functions", () => {
describe("canCreateOrManageTeams", () => {
it("should return true for Admin role", () => {
const userRole = "Admin";
const result = userRole === "Admin";
expect(result).toBe(true);
expect(userRole === "Admin").toBe(true);
});
it("should return true for org_admin in any organization", () => {
@ -577,6 +416,7 @@ describe("Teams - helper functions", () => {
describe("Teams - premium props", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTeamInfoView.mockClear();
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]);
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
@ -584,38 +424,14 @@ describe("Teams - premium props", () => {
mockUseOrganizations.mockReturnValue({ data: [] });
});
it("passes premiumUser flag to TeamInfoView", async () => {
vi.mocked(teamListCall).mockResolvedValue({
teams: [
{
team_id: "team-123456789",
team_alias: "Premium Team",
organization_id: "org-123",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [],
members_with_roles: [],
spend: 0,
},
],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
it("passes premiumUser flag to TeamInfoView when a team is opened", async () => {
const premiumTeam = { ...baseTableTeam, team_id: "team-123456789", team_alias: "Premium Team" };
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" premiumUser={true} />);
const teamIdElement = await screen.findByText("team-123456789");
act(() => {
fireEvent.click(teamIdElement);
});
await waitFor(() => expect(mockTeamsTableProps).not.toBeNull());
act(() => mockTeamsTableProps.onSelectTeam(premiumTeam));
await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled());
expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ premiumUser: true }));
});
});
@ -627,114 +443,22 @@ describe("Teams - Default Team Settings tab visibility", () => {
});
it("should show Default Team Settings tab for Admin role", () => {
vi.mocked(teamListCall).mockResolvedValue({
teams: [
{
team_id: "1",
team_alias: "Test Team",
organization_id: "org-123",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [],
members_with_roles: [],
spend: 0,
},
],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
expect(screen.getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument();
});
it("should show Default Team Settings tab for proxy_admin role", () => {
vi.mocked(teamListCall).mockResolvedValue({
teams: [
{
team_id: "1",
team_alias: "Test Team",
organization_id: "org-123",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [],
members_with_roles: [],
spend: 0,
},
],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="proxy_admin" />);
expect(screen.getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument();
});
it("should not show Default Team Settings tab for proxy_admin_viewer role", () => {
vi.mocked(teamListCall).mockResolvedValue({
teams: [
{
team_id: "1",
team_alias: "Test Team",
organization_id: "org-123",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [],
members_with_roles: [],
spend: 0,
},
],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="proxy_admin_viewer" />);
expect(screen.queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument();
});
it("should not show Default Team Settings tab for Admin Viewer role", () => {
vi.mocked(teamListCall).mockResolvedValue({
teams: [
{
team_id: "1",
team_alias: "Test Team",
organization_id: "org-123",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [],
members_with_roles: [],
spend: 0,
},
],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin Viewer" />);
expect(screen.queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument();
});
});
@ -761,7 +485,6 @@ describe("Teams - access_group_ids in team create", () => {
});
it("should pass access_group_ids to teamCreateCall when creating team", async () => {
vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 });
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
@ -773,25 +496,19 @@ describe("Teams - access_group_ids in team create", () => {
expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
});
const teamNameInput = screen.getByLabelText(/team name/i);
fireEvent.change(teamNameInput, { target: { value: "Test Team" } });
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
const modelsInput = screen.getByTestId("create-team-models-select");
fireEvent.change(modelsInput, { target: { value: "gpt-4" } });
const additionalSettingsAccordion = screen.getByText("Additional Settings");
fireEvent.click(additionalSettingsAccordion);
fireEvent.click(screen.getByText("Additional Settings"));
await waitFor(() => {
expect(screen.getByTestId("access-group-selector")).toBeInTheDocument();
});
const accessGroupInput = screen.getByTestId("access-group-selector");
fireEvent.change(accessGroupInput, { target: { value: "ag-1,ag-2" } });
fireEvent.change(screen.getByTestId("access-group-selector"), { target: { value: "ag-1,ag-2" } });
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
const createTeamSubmitButton = createTeamSubmitButtons[createTeamSubmitButtons.length - 1];
fireEvent.click(createTeamSubmitButton);
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
await waitFor(() => {
expect(teamCreateCall).toHaveBeenCalledWith(
@ -814,9 +531,6 @@ describe("Teams - models dropdown options", () => {
});
it("should not render all-proxy-models option in models select", async () => {
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4", "gpt-3.5-turbo"]);
vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 });
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => {
@ -831,207 +545,7 @@ describe("Teams - models dropdown options", () => {
await waitFor(() => {
expect(screen.getByLabelText(/models/i)).toBeInTheDocument();
});
const allProxyModelsOption = screen.queryByText("All Proxy Models");
expect(allProxyModelsOption).not.toBeInTheDocument();
});
});
describe("Teams - organization alias display", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseOrganizations.mockReturnValue({ data: [] });
});
it("should display organization alias instead of organization id", async () => {
const mockOrganizations = [
{
organization_id: "org-123",
organization_alias: "Test Organization",
budget_id: "budget-1",
metadata: {},
models: [],
spend: 0,
model_spend: {},
created_at: new Date().toISOString(),
created_by: "user-1",
updated_at: new Date().toISOString(),
updated_by: "user-1",
litellm_budget_table: null,
teams: null,
users: null,
members: null,
},
];
mockUseOrganizations.mockReturnValue({ data: mockOrganizations });
vi.mocked(teamListCall).mockResolvedValue({
teams: [
{
team_id: "1",
team_alias: "Test Team",
organization_id: "org-123",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [],
members_with_roles: [],
spend: 0,
},
],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => {
expect(screen.getByText("Test Organization")).toBeInTheDocument();
});
expect(screen.queryByText("org-123")).not.toBeInTheDocument();
});
it("should display organization id when alias is not found", async () => {
mockUseOrganizations.mockReturnValue({ data: [] });
vi.mocked(teamListCall).mockResolvedValue({
teams: [
{
team_id: "1",
team_alias: "Test Team",
organization_id: "org-unknown",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [],
members_with_roles: [],
spend: 0,
},
],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => {
expect(screen.getByText("org-unknown")).toBeInTheDocument();
});
});
it("should display N/A when organization_id is null", async () => {
mockUseOrganizations.mockReturnValue({ data: [] });
vi.mocked(teamListCall).mockResolvedValue({
teams: [
{
team_id: "1",
team_alias: "Test Team",
organization_id: null,
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [],
members_with_roles: [],
spend: 0,
},
],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => {
// When organization_id is null, the table shows "—" in the Organization column
expect(screen.getAllByText("—").length).toBeGreaterThan(0);
});
});
});
describe("Teams - Resources column keys badge", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseOrganizations.mockReturnValue({ data: [] });
});
it("renders keys_count from the v2 payload in the Resources badge", async () => {
vi.mocked(teamListCall).mockResolvedValue({
teams: [
{
team_id: "1",
team_alias: "Team With Keys",
organization_id: "org-123",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [],
keys_count: 3,
members_with_roles: [],
spend: 0,
},
],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
const { container } = renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => {
expect(screen.getByText("Team With Keys")).toBeInTheDocument();
});
const cyanTag = container.querySelector(".ant-tag-cyan");
expect(cyanTag).not.toBeNull();
expect(cyanTag?.textContent).toContain("3");
});
it("falls back to keys.length when keys_count is absent", async () => {
vi.mocked(teamListCall).mockResolvedValue({
teams: [
{
team_id: "2",
team_alias: "Legacy Team",
organization_id: "org-123",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
keys: [{ token: "t1" }, { token: "t2" }],
members_with_roles: [],
spend: 0,
},
],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
const { container } = renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => {
expect(screen.getByText("Legacy Team")).toBeInTheDocument();
});
const cyanTag = container.querySelector(".ant-tag-cyan");
expect(cyanTag).not.toBeNull();
expect(cyanTag?.textContent).toContain("2");
expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument();
});
});
@ -1042,39 +556,16 @@ describe("Teams - delete team warning copy", () => {
});
const openDeleteModal = async (team: any) => {
vi.mocked(teamListCall).mockResolvedValue({
teams: [team],
total: 1,
page: 1,
page_size: 100,
total_pages: 1,
});
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => {
expect(screen.getByTestId("delete-team-button")).toBeInTheDocument();
});
act(() => {
fireEvent.click(screen.getByTestId("delete-team-button"));
await waitFor(() => expect(mockTeamsTableProps).not.toBeNull());
await act(async () => {
mockTeamsTableProps.onDeleteTeam(team);
});
expect(screen.getByText("Delete Team?")).toBeInTheDocument();
};
const baseTeam = {
team_id: "1",
team_alias: "Test Team",
organization_id: "org-123",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
created_at: new Date().toISOString(),
members_with_roles: [],
spend: 0,
};
it("warns that the team's models are deleted when the team has keys", async () => {
await openDeleteModal({ ...baseTeam, keys: [], keys_count: 5 });
await openDeleteModal({ ...baseTableTeam, keys: [], keys_count: 5 });
expect(screen.getByText(/Warning: This team has 5 keys associated with it/i)).toHaveTextContent(
/along with any models created for this team/i,
@ -1085,7 +576,7 @@ describe("Teams - delete team warning copy", () => {
});
it("still warns about model deletion in the confirmation message when the team has no keys", async () => {
await openDeleteModal({ ...baseTeam, keys: [], keys_count: 0 });
await openDeleteModal({ ...baseTableTeam, keys: [], keys_count: 0 });
expect(screen.queryByText(/Warning: This team has/i)).not.toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to delete this team/i)).toHaveTextContent(
@ -1101,7 +592,6 @@ describe("Teams - LIT-2530 organization stays optional for proxy admin with a si
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 });
vi.mocked(teamCreateCall).mockResolvedValue({
team_id: "new-team-1",
team_alias: "No Org Team",
@ -1142,68 +632,3 @@ describe("Teams - LIT-2530 organization stays optional for proxy admin with a si
});
});
});
describe("Teams - search debounce", () => {
const emptyTeamList = { teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 };
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]);
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(teamListCall).mockResolvedValue(emptyTeamList);
mockUseOrganizations.mockReturnValue({ data: [] });
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
const typeSearch = (value: string) => {
fireEvent.change(screen.getByPlaceholderText("Search teams by name or ID..."), { target: { value } });
};
it("fires a single search request with the last value only after the wait elapses", async () => {
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await act(async () => {});
vi.mocked(teamListCall).mockClear();
act(() => {
typeSearch("a");
typeSearch("ab");
typeSearch("abc");
});
expect(teamListCall).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(299);
});
expect(teamListCall).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(teamListCall).toHaveBeenCalledTimes(1);
expect(teamListCall).toHaveBeenCalledWith("test-token", 1, 10, expect.objectContaining({ search: "abc" }));
});
it("does not fire the search request when unmounted mid-wait", async () => {
const { unmount } = renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await act(async () => {});
vi.mocked(teamListCall).mockClear();
act(() => {
typeSearch("abc");
});
unmount();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(teamListCall).not.toHaveBeenCalled();
});
});

View file

@ -3,40 +3,16 @@ import AvailableTeamsPanel from "@/components/team/available_teams";
import TeamInfoView from "@/components/team/TeamInfo";
import TeamSSOSettings from "@/components/TeamSSOSettings";
import { isProxyAdminRole } from "@/utils/roles";
import { InfoCircleOutlined, PlusOutlined, TeamOutlined, ReloadOutlined } from "@ant-design/icons";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Accordion, AccordionBody, AccordionHeader, TextInput } from "@tremor/react";
import {
Button,
Card,
Flex,
Form,
Input,
Layout,
Modal,
Pagination,
Progress,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
theme,
Tooltip,
Typography,
message,
} from "antd";
import type { ColumnsType } from "antd/es/table";
import type { SorterResult } from "antd/es/table/interface";
import { KeyIcon, LayersIcon, SearchIcon, UsersIcon } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { useDebouncer } from "@tanstack/react-pacer/debouncer";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import OrganizationDropdown from "./common_components/OrganizationDropdown";
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import { teamListCall as v2TeamListCall, type TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Button, Form, Input, Layout, Modal, Select, Switch, Tabs, theme, Tooltip, Typography } from "antd";
import { Plus, Users } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { PageHeader } from "@/components/shared/PageHeader";
import { Button as UIButton } from "@/components/ui/button";
import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams";
import { TeamsTable } from "./TeamsPage/TeamsTable";
import AccessGroupSelector from "./common_components/AccessGroupSelector";
import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector";
import AgentSelector from "./agent_management/AgentSelector";
@ -47,7 +23,7 @@ import {
fetchAvailableModelsForTeamOrKey,
unfurlWildcardModelsInList,
} from "./key_team_helpers/fetch_available_models_team_key";
import type { KeyResponse, Team } from "./key_team_helpers/key_list";
import type { Team } from "./key_team_helpers/key_list";
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
import NotificationsManager from "./molecules/notifications_manager";
@ -63,13 +39,6 @@ interface TeamProps {
premiumUser?: boolean;
}
interface FilterState {
search: string;
organization_id: string;
sort_by: string;
sort_order: "asc" | "desc";
}
interface EditTeamModalProps {
visible: boolean;
onCancel: () => void;
@ -77,21 +46,10 @@ interface EditTeamModalProps {
onSubmit: (data: FormData) => void; // Assuming FormData is the type of data to be submitted
}
import { updateExistingKeys } from "@/utils/dataUtils";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import { Member, teamCreateCall } from "./networking";
import { teamCreateCall } from "./networking";
import { ModelSelect } from "./ModelSelect/ModelSelect";
interface TeamInfo {
members_with_roles: Member[];
}
interface PerTeamInfo {
keys: KeyResponse[];
keys_count: number;
team_info: TeamInfo;
}
const getOrganizationModels = (organization: Organization | null, userModels: string[]) => {
let tempModelsToPick = [];
@ -166,82 +124,17 @@ const getOrganizationAlias = (
const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser = false }) => {
const { data: organizationsData } = useOrganizations();
const organizations = organizationsData ?? null;
const [teams, setTeams] = useState<Team[] | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [totalTeams, setTotalTeams] = useState(0);
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null);
const queryClient = useQueryClient();
const refreshTeams = () => queryClient.invalidateQueries({ queryKey: teamsTableKeys.all });
const [currentOrg] = useState<Organization | null>(null);
const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState<Organization | null>(null);
const [filters, setFilters] = useState<FilterState>({
search: "",
organization_id: "",
sort_by: "created_at",
sort_order: "desc",
});
const [isSearching, setIsSearching] = useState(false);
const fetchTeamsV2 = async (
opts: {
page?: number;
size?: number;
sortBy?: string;
sortOrder?: string;
organizationID?: string;
search?: string;
} = {},
) => {
if (!accessToken) return;
const page = opts.page ?? currentPage;
const size = opts.size ?? pageSize;
const sortBy = opts.sortBy ?? filters.sort_by;
const sortOrder = opts.sortOrder ?? filters.sort_order;
const organizationID = opts.organizationID ?? filters.organization_id;
const search = opts.search ?? filters.search;
setIsLoading(true);
setFetchError(null);
try {
const response: TeamsResponse = await v2TeamListCall(accessToken, page, size, {
organizationID: organizationID || null,
search: search || null,
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
sortBy: sortBy || null,
sortOrder: sortOrder || null,
});
setTeams(response.teams ?? []);
setTotalTeams(response.total ?? 0);
} catch (err: any) {
setFetchError(err?.message || "Failed to fetch teams");
} finally {
setIsLoading(false);
}
};
const searchDebouncer = useDebouncer(
async (value: string) => {
try {
setFilters((prev) => ({ ...prev, search: value }));
setCurrentPage(1);
await fetchTeamsV2({ page: 1, search: value });
} finally {
setIsSearching(false);
}
},
{ wait: DEBOUNCE_WAIT_MS },
);
useEffect(() => {
fetchTeamsV2();
}, [accessToken]);
const [form] = Form.useForm();
const [memberForm] = Form.useForm();
const [value, setValue] = useState("");
const [editModalVisible, setEditModalVisible] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<null | any>(null);
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [editTeam, setEditTeam] = useState<boolean>(false);
@ -252,7 +145,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [teamToDelete, setTeamToDelete] = useState<Team | null>(null);
const [modelsToPick, setModelsToPick] = useState<string[]>([]);
const [perTeamInfo, setPerTeamInfo] = useState<Record<string, PerTeamInfo>>({});
const [isTeamDeleting, setIsTeamDeleting] = useState(false);
// Add this state near the other useState declarations
const [guardrailsList, setGuardrailsList] = useState<string[]>([]);
@ -339,30 +231,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
fetchMcpAccessGroups();
}, [accessToken]);
useEffect(() => {
const fetchTeamInfo = () => {
if (!teams) return;
const newPerTeamInfo = teams.reduce(
(acc, team) => {
acc[team.team_id] = {
keys: team.keys || [],
keys_count: team.keys_count ?? team.keys?.length ?? 0,
team_info: {
members_with_roles: team.members_with_roles || [],
},
};
return acc;
},
{} as Record<string, PerTeamInfo>,
);
setPerTeamInfo(newPerTeamInfo);
};
fetchTeamInfo();
}, [teams]);
const handleOk = () => {
setIsTeamModalVisible(false);
form.resetFields();
@ -400,14 +268,14 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
};
const confirmDelete = async () => {
if (teamToDelete == null || teams == null || accessToken == null) {
if (teamToDelete == null || accessToken == null) {
return;
}
try {
setIsTeamDeleting(true);
await teamDeleteCall(accessToken, teamToDelete.team_id);
await fetchTeamsV2();
await refreshTeams();
NotificationsManager.success("Team deleted successfully");
} catch (error) {
NotificationsManager.fromBackend("Error deleting the team: " + error);
@ -439,13 +307,11 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
};
fetchUserModels();
}, [accessToken, userID, userRole, teams]);
}, [accessToken, userID, userRole]);
const handleCreate = async (formValues: Record<string, any>) => {
try {
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;
@ -453,11 +319,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
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
@ -579,10 +440,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
await teamCreateCall(accessToken, formValues);
NotificationsManager.success("Team created");
await fetchTeamsV2({
page: currentPage,
size: pageSize,
});
await refreshTeams();
form.resetFields();
setLoggingSettings([]);
setModelAliases({});
@ -609,343 +467,31 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
return false;
};
const handleSearchChange = (value: string) => {
setIsSearching(true);
searchDebouncer.maybeExecute(value);
};
const handleFilterChange = async (key: keyof FilterState, value: string) => {
const newFilters = { ...filters, [key]: value };
setFilters(newFilters);
setCurrentPage(1);
if (!accessToken) return;
try {
const response: TeamsResponse = await v2TeamListCall(accessToken, 1, pageSize, {
organizationID: newFilters.organization_id || null,
search: newFilters.search || null,
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
sortBy: newFilters.sort_by || null,
sortOrder: newFilters.sort_order || null,
});
setTeams(response.teams ?? []);
setTotalTeams(response.total ?? 0);
} catch (error) {
console.error("Error fetching teams:", error);
}
};
const handleFilterReset = () => {
searchDebouncer.cancel();
setIsSearching(false);
const resetFilters: FilterState = {
search: "",
organization_id: "",
sort_by: "created_at",
sort_order: "desc",
};
setFilters(resetFilters);
setCurrentPage(1);
fetchTeamsV2({ page: 1, organizationID: "", search: "", sortBy: "created_at", sortOrder: "desc" });
};
const { token } = theme.useToken();
const { Title, Text } = Typography;
const { Text } = Typography;
const { Content } = Layout;
const handleRetry = () => {
fetchTeamsV2();
};
const handleTableSort = (
_pagination: unknown,
_filters: unknown,
sorter: SorterResult<Team> | SorterResult<Team>[],
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
const sortBy = s.order ? (s.columnKey as string) : "created_at";
const sortOrder = s.order === "ascend" ? "asc" : s.order === "descend" ? "desc" : "desc";
setFilters((prev) => ({ ...prev, sort_by: sortBy, sort_order: sortOrder }));
fetchTeamsV2({ sortBy, sortOrder });
};
const teamColumns: ColumnsType<Team> = useMemo(
() => [
{
title: "Team ID",
dataIndex: "team_id",
key: "team_id",
width: 170,
ellipsis: true,
render: (id: string) => (
<IdCell value={id} onClick={(teamId) => setSelectedTeamId(teamId)} dataTestId="team-id-cell" />
),
},
{
title: "Team Alias",
dataIndex: "team_alias",
key: "team_alias",
ellipsis: true,
sorter: true,
render: (alias: string | undefined) => (
<Text style={{ fontSize: 14 }}>
{alias || (
<Text type="secondary" italic>
</Text>
)}
</Text>
),
},
{
title: "Organization",
key: "organization",
width: 160,
ellipsis: true,
render: (_: unknown, record: Team) => {
const orgAlias = getOrganizationAlias(record.organization_id, organizations);
return record.organization_id ? (
<Text ellipsis style={{ fontSize: 14 }}>
{orgAlias}
</Text>
) : (
<Text type="secondary"></Text>
);
},
},
{
title: "Resources",
key: "resources",
width: 240,
render: (_: unknown, record: Team) => {
const memberCount = perTeamInfo?.[record.team_id]?.team_info?.members_with_roles?.length ?? 0;
const modelCount = record.models?.length ?? 0;
const keyCount = perTeamInfo?.[record.team_id]?.keys_count ?? 0;
return (
<Flex gap={12} align="center">
<Tooltip title={`${memberCount} Members`}>
<Tag color="purple" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
<Flex align="center" gap={6}>
<UsersIcon size={14} />
{memberCount}
</Flex>
</Tag>
</Tooltip>
<Tooltip title={`${modelCount} Models`}>
<Tag color="blue" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
<Flex align="center" gap={6}>
<LayersIcon size={14} />
{modelCount}
</Flex>
</Tag>
</Tooltip>
<Tooltip title={`${keyCount} Keys`}>
<Tag color="cyan" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
<Flex align="center" gap={6}>
<KeyIcon size={14} />
{keyCount}
</Flex>
</Tag>
</Tooltip>
</Flex>
);
},
},
{
title: "Spend / Budget",
key: "spend",
width: 200,
sorter: true,
render: (_: unknown, record: Team) => {
const spendVal = record.spend ?? 0;
const budgetVal = record.max_budget;
const spendStr = `$${spendVal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const budgetStr =
budgetVal != null
? `$${budgetVal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: "Unlimited";
const percent = budgetVal != null && budgetVal > 0 ? Math.min((spendVal / budgetVal) * 100, 100) : null;
return (
<Flex vertical gap={2}>
<Text style={{ fontSize: 13 }}>
{spendStr}
<Text type="secondary" style={{ fontSize: 12 }}>
{" / "}
{budgetStr}
</Text>
</Text>
{percent != null && (
<Progress
percent={percent}
size="small"
showInfo={false}
strokeColor={percent >= 90 ? "#ff4d4f" : percent >= 70 ? "#faad14" : "#1677ff"}
style={{ marginBottom: 0 }}
/>
)}
</Flex>
);
},
},
{
title: "Created",
dataIndex: "created_at",
key: "created_at",
width: 130,
ellipsis: true,
sorter: true,
render: (date: string | undefined) => <DateCell value={date} precision="date" />,
},
{
title: "Actions",
key: "actions",
width: 120,
align: "right" as const,
render: (_: unknown, record: Team) => (
<Space size={4}>
<TableIconActionButton
variant="Copy"
tooltipText="Copy Team ID"
onClick={() => {
navigator.clipboard
.writeText(record.team_id)
.then(() => message.success("Team ID copied"))
.catch(() => message.error("Failed to copy"));
}}
/>
{userRole === "Admin" && (
<>
<TableIconActionButton
variant="Edit"
tooltipText="Edit team"
dataTestId="edit-team-button"
onClick={() => {
setSelectedTeamId(record.team_id);
setEditTeam(true);
}}
/>
<TableIconActionButton
variant="Delete"
tooltipText="Delete team"
dataTestId="delete-team-button"
onClick={() => handleDelete(record)}
/>
</>
)}
</Space>
),
},
],
[userRole, perTeamInfo, organizations],
);
const displayTeams = useMemo(() => teams ?? [], [teams]);
const renderTeamsContent = () => {
if (isLoading) {
return (
<Flex justify="center" align="center" style={{ padding: "80px 0" }}>
<AntDLoadingSpinner fontSize={48} />
</Flex>
);
}
if (fetchError) {
return (
<Flex vertical align="center" gap={16} style={{ padding: "64px 0" }}>
<Text type="danger" style={{ fontSize: 15 }}>
Failed to load teams
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{fetchError}
</Text>
<Button icon={<ReloadOutlined />} onClick={handleRetry}>
Retry
</Button>
</Flex>
);
}
return (
<Table<Team>
columns={teamColumns}
dataSource={displayTeams}
rowKey="team_id"
pagination={false}
onChange={handleTableSort}
locale={{
emptyText: (
<div style={{ padding: "64px 0", textAlign: "center" }}>
<TeamOutlined style={{ fontSize: 40, color: "#d9d9d9", marginBottom: 12 }} />
<div>
<Text style={{ fontSize: 15, color: "#595959" }}>No teams yet</Text>
</div>
<div style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 13 }}>
Create your first team to organize members and manage access to models.
</Text>
</div>
{canCreateOrManageTeams(userRole, userID, organizations) && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsTeamModalVisible(true)}
style={{ marginTop: 16 }}
data-testid="create-team-button"
>
Create Team
</Button>
)}
</div>
),
}}
scroll={{ x: 1000 }}
size="middle"
/>
);
};
const tabItems = [
{
key: "your-teams",
label: "Your Teams",
children: (
<>
<Card styles={{ body: { padding: 0 } }}>
<Flex justify="space-between" align="center" style={{ padding: "12px 16px" }}>
<Flex gap={12} align="center">
<Input
prefix={<SearchIcon size={16} />}
suffix={isSearching ? <AntDLoadingSpinner size="small" /> : null}
placeholder="Search teams by name or ID..."
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
style={{ maxWidth: 400 }}
/>
<OrganizationDropdown
organizations={organizations}
value={filters.organization_id || undefined}
onChange={(value: string) => handleFilterChange("organization_id", value || "")}
loading={isLoading}
/>
</Flex>
<Pagination
current={currentPage}
total={totalTeams}
pageSize={pageSize}
onChange={(page, size) => {
setCurrentPage(page);
setPageSize(size);
fetchTeamsV2({ page, size });
}}
size="small"
showTotal={(total) => `${total} teams`}
showSizeChanger
pageSizeOptions={["10", "20", "50"]}
/>
</Flex>
{renderTeamsContent()}
</Card>
<TeamsTable
userRole={userRole}
userID={userID}
onSelectTeam={(team) => {
setSelectedTeam(team);
setSelectedTeamId(team.team_id);
setEditTeam(false);
}}
onEditTeam={(team) => {
setSelectedTeam(team);
setSelectedTeamId(team.team_id);
setEditTeam(true);
}}
onDeleteTeam={handleDelete}
/>
<DeleteResourceModal
isOpen={isDeleteModalOpen}
@ -996,26 +542,16 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
{selectedTeamId ? (
<TeamInfoView
teamId={selectedTeamId}
onUpdate={(data) => {
setTeams((teams) => {
if (teams == null) {
return teams;
}
return teams.map((team) => {
if (data.team_id === team.team_id) {
return updateExistingKeys(team, data);
}
return team;
});
});
fetchTeamsV2();
onUpdate={() => {
refreshTeams();
}}
onClose={() => {
setSelectedTeam(null);
setSelectedTeamId(null);
setEditTeam(false);
}}
accessToken={accessToken}
is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))}
is_team_admin={is_team_admin(selectedTeam)}
is_proxy_admin={userRole == "Admin"}
userModels={userModels}
editTeam={editTeam}
@ -1023,25 +559,21 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
/>
) : (
<>
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Space direction="vertical" size={0}>
<Title level={2} style={{ margin: 0 }}>
<TeamOutlined style={{ marginRight: 8 }} />
Teams
</Title>
<Text type="secondary">Manage teams, members, and their access to models and budgets</Text>
</Space>
{canCreateOrManageTeams(userRole, userID, organizations) && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsTeamModalVisible(true)}
data-testid="create-team-button"
>
Create Team
</Button>
)}
</Flex>
<div className="mb-4">
<PageHeader
icon={<Users className="size-5" />}
title="Teams"
subtitle="Manage teams, members, and their access to models and budgets"
actions={
canCreateOrManageTeams(userRole, userID, organizations) ? (
<UIButton onClick={() => setIsTeamModalVisible(true)} data-testid="create-team-button">
<Plus className="size-4" />
Create Team
</UIButton>
) : undefined
}
/>
</div>
<Tabs items={tabItems} />
</>

View file

@ -0,0 +1,330 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, MockedFunction, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { Team } from "../key_team_helpers/key_list";
import { TeamsResponse, useTeamsTable } from "@/app/(dashboard)/hooks/teams/useTeams";
import { TeamsTable } from "./TeamsTable";
// Resolve debounced values synchronously so an applied filter lands in the useTeamsTable query within the test tick.
vi.mock("@tanstack/react-pacer/debouncer", async () => {
const React = await vi.importActual<typeof import("react")>("react");
return {
useDebouncedValue: (value: unknown) => [value, { cancel: vi.fn(), flush: vi.fn() }],
useDebouncedState: (initial: unknown) => {
const [value, setValue] = React.useState(initial);
return [value, setValue, { cancel: vi.fn(), flush: vi.fn() }];
},
};
});
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn(() => ({
accessToken: "test-token",
userId: "test-user",
userRole: "Admin",
premiumUser: true,
token: "test-token",
})),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeamsTable: vi.fn(),
teamsTableKeys: { all: ["teamsTable"] },
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: vi.fn().mockReturnValue({
data: [{ organization_id: "org-1", organization_alias: "Test Organization" }],
}),
}));
const mockTeam: Team = {
team_id: "team-1",
team_alias: "Acme Team",
models: ["gpt-4", "gpt-3.5-turbo", "claude-3", "claude-3-5-sonnet"],
max_budget: 100,
budget_duration: "1mo",
tpm_limit: 5000,
rpm_limit: 500,
organization_id: "org-1",
created_at: "2024-10-01T10:00:00Z",
updated_at: "2024-11-01T10:00:00Z",
keys: [],
keys_count: 3,
members_with_roles: [
{ user_id: "u1", user_email: "a@x.com", role: "admin" },
{ user_id: "u2", user_email: "b@x.com", role: "user" },
] as unknown as Team["members_with_roles"],
spend: 42.5,
};
const mockUseTeamsTable = useTeamsTable as MockedFunction<typeof useTeamsTable>;
const teamsResult = (teams: Team[], data: Partial<TeamsResponse> = {}, extra: Record<string, unknown> = {}) =>
({
data: {
teams,
total: teams.length,
page: 1,
page_size: 50,
total_pages: 1,
...data,
} as TeamsResponse,
isPending: false,
isFetching: false,
isError: false,
refetch: vi.fn(),
...extra,
}) as any;
const noop = () => {};
const renderTable = (props: Partial<React.ComponentProps<typeof TeamsTable>> = {}) =>
renderWithProviders(
<TeamsTable
userRole="Admin"
userID="admin-1"
onSelectTeam={noop}
onEditTeam={noop}
onDeleteTeam={noop}
{...props}
/>,
);
const openFilters = () => fireEvent.click(screen.getByRole("button", { name: "Filters" }));
const lastOptions = () => mockUseTeamsTable.mock.calls[mockUseTeamsTable.mock.calls.length - 1][2] ?? {};
beforeEach(() => {
vi.clearAllMocks();
mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam]));
});
it("renders a team row with alias, organization, and spend/budget", async () => {
renderTable();
await waitFor(() => {
expect(screen.getByText("Acme Team")).toBeInTheDocument();
expect(screen.getByText("Test Organization")).toBeInTheDocument();
expect(screen.getByText("$42.5000")).toBeInTheDocument();
expect(screen.getByText("of $100")).toBeInTheDocument();
});
});
it("renders the Resources cell with member, model, and key counts", () => {
renderTable();
expect(screen.getByTitle("2 members")).toBeInTheDocument();
expect(screen.getByTitle("4 models")).toBeInTheDocument();
expect(screen.getByTitle("3 keys")).toBeInTheDocument();
});
it("shows 'No teams found' when the list is empty", () => {
mockUseTeamsTable.mockReturnValue(teamsResult([]));
renderTable();
expect(screen.getByText("No teams found")).toBeInTheDocument();
});
it("shows a loading state on initial load and hides the data", () => {
mockUseTeamsTable.mockReturnValue(teamsResult([], {}, { data: null, isPending: true, isFetching: true }));
renderTable();
expect(screen.getByText("Loading teams...")).toBeInTheDocument();
expect(screen.queryByText("Acme Team")).not.toBeInTheDocument();
});
describe("sort contract only backend-sortable columns are sortable", () => {
it("requests the default created_at descending sort on first render", () => {
renderTable();
expect(lastOptions()).toMatchObject({ sortBy: "created_at", sortOrder: "desc" });
});
it("sorts by the backend team_alias field (not the label) when the Team header is clicked", async () => {
renderTable();
fireEvent.click(screen.getByText("Team").closest("button") as HTMLElement);
await waitFor(() => {
expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: "team_alias" }));
});
});
it("does not make Spend / Budget sortable (the backend rejects sort_by=spend)", () => {
renderTable();
expect(screen.getByText("Spend / Budget").closest("button")).toBeNull();
// Team and Created are the only sortable headers.
expect(screen.getByText("Team").closest("button")).not.toBeNull();
expect(screen.getByText("Created").closest("button")).not.toBeNull();
});
});
describe("server-side filtering maps controls to the right query params", () => {
it("sends no filter params when nothing is applied", () => {
renderTable();
expect(lastOptions()).toMatchObject({ organizationID: undefined, team_alias: undefined, teamID: undefined });
});
it("threads an applied Team alias filter into the query", async () => {
renderTable();
openFilters();
fireEvent.change(await screen.findByPlaceholderText(/Enter team alias/), { target: { value: "acme" } });
fireEvent.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => {
expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ team_alias: "acme" }));
});
});
it("threads an applied Team ID filter into the query", async () => {
renderTable();
openFilters();
fireEvent.change(await screen.findByPlaceholderText(/Enter team ID/), { target: { value: "team-xyz" } });
fireEvent.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => {
expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ teamID: "team-xyz" }));
});
});
it("threads the toolbar search into the search param", async () => {
renderTable();
fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "platform" } });
await waitFor(() => {
expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: "platform" }));
});
});
});
describe("non-admin scoping", () => {
it("scopes the list to the current user when the role is not an admin role", () => {
renderTable({ userRole: "Internal User", userID: "user-42" });
expect(lastOptions()).toMatchObject({ userID: "user-42" });
});
it("does not scope by user for the Admin role", () => {
renderTable({ userRole: "Admin", userID: "admin-1" });
expect(lastOptions()).toMatchObject({ userID: undefined });
});
});
describe("row actions", () => {
it("opens the team detail when the team cell is clicked", () => {
const onSelectTeam = vi.fn();
renderTable({ onSelectTeam });
fireEvent.click(screen.getByText("Acme Team"));
expect(onSelectTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" }));
});
it("offers Edit and Delete to an Admin and wires them to the callbacks", async () => {
const onEditTeam = vi.fn();
const onDeleteTeam = vi.fn();
const user = userEvent.setup();
renderTable({ userRole: "Admin", onEditTeam, onDeleteTeam });
await user.click(screen.getByTestId("team-actions-team-1"));
await user.click(await screen.findByText("Edit team"));
expect(onEditTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" }));
await user.click(screen.getByTestId("team-actions-team-1"));
await user.click(await screen.findByText("Delete team"));
expect(onDeleteTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" }));
});
it("hides Edit and Delete from a non-admin, leaving only Copy team ID", async () => {
const user = userEvent.setup();
renderTable({ userRole: "Internal User" });
await user.click(screen.getByTestId("team-actions-team-1"));
expect(await screen.findByText("Copy team ID")).toBeInTheDocument();
expect(screen.queryByText("Edit team")).not.toBeInTheDocument();
expect(screen.queryByText("Delete team")).not.toBeInTheDocument();
});
});
describe("pagination total comes from the query response", () => {
it("shows the total count and page count from the response", async () => {
mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], { total: 137, total_pages: 3 }));
renderTable();
await waitFor(() => {
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 137");
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 3");
});
});
});
describe("refresh control", () => {
it("calls refetch when clicked", () => {
const refetch = vi.fn();
mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], {}, { refetch }));
renderTable();
fireEvent.click(screen.getByTestId("datatable-refresh"));
expect(refetch).toHaveBeenCalledTimes(1);
});
it("keeps rows visible but disables refresh while a background fetch is in flight", () => {
mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], {}, { isFetching: true }));
renderTable();
expect(screen.getByTestId("datatable-refresh")).toBeDisabled();
expect(screen.getByText("Acme Team")).toBeInTheDocument();
});
});
describe("column rendering details", () => {
it("shows the organization alias when the id resolves, and the raw id when it does not", async () => {
mockUseTeamsTable.mockReturnValue(
teamsResult([
{ ...mockTeam, team_id: "a", organization_id: "org-1" },
{ ...mockTeam, team_id: "b", team_alias: "Orphan Team", organization_id: "org-unknown" },
]),
);
renderTable();
await waitFor(() => {
expect(screen.getByText("Test Organization")).toBeInTheDocument();
expect(screen.getByText("org-unknown")).toBeInTheDocument();
});
});
it("renders an em dash for a team with no organization", () => {
mockUseTeamsTable.mockReturnValue(teamsResult([{ ...mockTeam, organization_id: null as unknown as string }]));
renderTable();
expect(screen.getByText("—")).toBeInTheDocument();
});
it("falls back to keys.length when keys_count is absent", () => {
mockUseTeamsTable.mockReturnValue(
teamsResult([
{
...mockTeam,
keys_count: undefined,
keys: [{ token: "t1" }, { token: "t2" }] as unknown as Team["keys"],
},
]),
);
renderTable();
expect(screen.getByTitle("2 keys")).toBeInTheDocument();
});
});
describe("hidden-by-default columns", () => {
it("hides Members, Models, Rate Limits, and Updated until toggled on", async () => {
const user = userEvent.setup();
renderTable();
expect(screen.queryByText("Rate Limits")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Columns" }));
const menu = await screen.findByRole("menu");
expect(within(menu).getByText("Rate Limits")).toBeInTheDocument();
expect(within(menu).getByText("Updated")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,201 @@
"use client";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeamsTable } from "@/app/(dashboard)/hooks/teams/useTeams";
import {
DataTable,
DataTableFilterDrawer,
DataTableFilterField,
DataTableToolbar,
} from "@/components/shared/DataTable";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Input } from "@/components/ui/input";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import React, { useCallback, useMemo, useState } from "react";
import { Team } from "../key_team_helpers/key_list";
import { getTeamTableColumns, TEAM_TABLE_HIDDEN_COLUMNS } from "./teamTableColumns";
interface TeamsTableProps {
userRole: string | null;
userID: string | null;
onSelectTeam: (team: Team) => void;
onEditTeam: (team: Team) => void;
onDeleteTeam: (team: Team) => void;
}
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
const toSortOrder = (sorting: SortingState): "asc" | "desc" | undefined => {
const active = sorting[0];
if (!active) return undefined;
return active.desc ? "desc" : "asc";
};
const FILTER_LABELS: Record<string, string> = {
org_id: "Organization",
alias: "Team alias",
team_id: "Team ID",
};
export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDeleteTeam }: TeamsTableProps) {
const { data: fetchedOrganizations } = useOrganizations();
const organizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]);
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const [tablePagination, setTablePagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 50 });
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [filtersOpen, setFiltersOpen] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS });
const getFilterValue = useCallback(
(columnId: string): string | undefined => {
const entry = columnFilters.find((filter) => filter.id === columnId);
return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined;
},
[columnFilters],
);
const isAdminView = userRole === "Admin" || userRole === "Admin Viewer";
const teamListOptions = {
organizationID: getFilterValue("org_id"),
team_alias: getFilterValue("alias"),
teamID: getFilterValue("team_id"),
search: searchQuery.trim() || undefined,
userID: isAdminView ? undefined : userID ?? undefined,
sortBy: sorting[0]?.id,
sortOrder: toSortOrder(sorting),
};
const {
data: teamsResponse,
isPending: isLoading,
isFetching,
refetch,
} = useTeamsTable(tablePagination.pageIndex + 1, tablePagination.pageSize, teamListOptions);
const teamList = useMemo<Team[]>(() => teamsResponse?.teams ?? [], [teamsResponse]);
const rowCount = teamsResponse?.total ?? 0;
const handleSearchChange = useCallback((value: string) => {
setSearchInput(value);
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}, []);
const handleSortingChange = useCallback<OnChangeFn<SortingState>>((updaterOrValue) => {
setSorting(updaterOrValue);
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}, []);
const handleColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>((updaterOrValue) => {
setColumnFilters(updaterOrValue);
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}, []);
const columns = useMemo(() => {
const columnDeps = { organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam };
return getTeamTableColumns(columnDeps);
}, [organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam]);
const orgOptions = useMemo(
() =>
organizations
.filter((org) => org.organization_id)
.map((org) => {
const id = org.organization_id as string;
return { label: org.organization_alias || id, value: id, sublabel: org.organization_alias ? id : undefined };
}),
[organizations],
);
const formatFilterValue = useCallback(
(columnId: string, value: unknown): string => {
const raw = String(value);
if (columnId === "org_id") {
return organizations.find((org) => org.organization_id === raw)?.organization_alias || raw;
}
return raw;
},
[organizations],
);
return (
<DataTable
data={teamList}
columns={columns}
getRowId={(row) => row.team_id}
defaultColumnVisibility={TEAM_TABLE_HIDDEN_COLUMNS}
sortingMode="server"
sorting={sorting}
onSortingChange={handleSortingChange}
paginationMode="server"
pagination={tablePagination}
onPaginationChange={setTablePagination}
rowCount={rowCount}
filterMode="server"
columnFilters={columnFilters}
onColumnFiltersChange={handleColumnFiltersChange}
enableColumnResizing
columnResizeMode="onChange"
isLoading={isLoading}
loadingMessage="Loading teams..."
noDataMessage="No teams found"
maxBodyHeight="calc(75vh - 210px)"
size="compact"
toolbar={(table) => (
<>
<DataTableToolbar
table={table}
searchValue={searchInput}
onSearchChange={handleSearchChange}
searchPlaceholder="Search teams by name or ID…"
onRefresh={() => refetch?.()}
isRefreshing={isFetching}
onOpenFilters={() => setFiltersOpen(true)}
filterLabels={FILTER_LABELS}
formatFilterValue={formatFilterValue}
/>
<DataTableFilterDrawer
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description="Narrow down your teams"
>
{({ get, set }) => (
<>
<DataTableFilterField label="Organization">
<SearchSelect
options={orgOptions}
value={(get("org_id") as string) || undefined}
onValueChange={(value) => set("org_id", value)}
placeholder="Select an organization…"
emptyText="No organizations found"
/>
</DataTableFilterField>
<DataTableFilterField label="Team alias">
<Input
value={(get("alias") as string) ?? ""}
onChange={(event) => set("alias", event.target.value)}
placeholder="Enter team alias…"
/>
</DataTableFilterField>
<DataTableFilterField label="Team ID">
<Input
value={(get("team_id") as string) ?? ""}
onChange={(event) => set("team_id", event.target.value)}
placeholder="Enter team ID…"
/>
</DataTableFilterField>
</>
)}
</DataTableFilterDrawer>
</>
)}
/>
);
}

View file

@ -0,0 +1,287 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Copy, KeyRound, Layers, MoreHorizontal, Pencil, Trash2, Users } from "lucide-react";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdentityCell, SpendBudgetCell } from "@/components/shared/table_cells";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/cva.config";
import { copyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils";
import { Team } from "../key_team_helpers/key_list";
import { Organization } from "../networking";
interface ResourceTone {
icon: typeof Users;
className: string;
}
const RESOURCE_TONES: Record<"members" | "models" | "keys", ResourceTone> = {
members: { icon: Users, className: "bg-violet-50 text-violet-700 ring-violet-600/20" },
models: { icon: Layers, className: "bg-sky-50 text-sky-700 ring-sky-600/20" },
keys: { icon: KeyRound, className: "bg-emerald-50 text-emerald-700 ring-emerald-600/20" },
};
const teamMemberCount = (team: Team): number => team.members_count ?? team.members_with_roles?.length ?? 0;
const teamModelCount = (team: Team): number => team.models?.length ?? 0;
const teamKeyCount = (team: Team): number => team.keys_count ?? team.keys?.length ?? 0;
function ResourcesCell({ team }: { team: Team }) {
const items = [
{ key: "members" as const, label: "members", count: teamMemberCount(team) },
{ key: "models" as const, label: "models", count: teamModelCount(team) },
{ key: "keys" as const, label: "keys", count: teamKeyCount(team) },
];
return (
<div className="flex items-center gap-1.5">
{items.map((item) => {
const tone = RESOURCE_TONES[item.key];
const Icon = tone.icon;
return (
<span
key={item.key}
title={`${item.count} ${item.label}`}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset [&_svg]:size-3.5",
tone.className,
)}
>
<Icon />
<span className="tabular-nums">{item.count}</span>
</span>
);
})}
</div>
);
}
function RateLimitLine({ label, value }: { label: string; value: number | null }) {
return (
<div>
<span className="text-[10px] font-semibold text-muted-foreground">{label} </span>
<span className="tabular-nums">{value != null ? formatNumberWithCommas(value) : "Unlimited"}</span>
</div>
);
}
interface TeamRowActionsProps {
team: Team;
canManage: boolean;
onEditTeam: (team: Team) => void;
onDeleteTeam: (team: Team) => void;
}
function TeamRowActions({ team, canManage, onEditTeam, onDeleteTeam }: TeamRowActionsProps) {
const handleCopy = () => {
void copyToClipboard(team.team_id, "Team ID copied");
};
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open team actions"
data-testid={`team-actions-${team.team_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{canManage && (
<DropdownMenuItem onClick={() => onEditTeam(team)} data-testid="team-action-edit">
<Pencil />
Edit team
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleCopy} data-testid="team-action-copy">
<Copy />
Copy team ID
</DropdownMenuItem>
{canManage && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDeleteTeam(team)} data-testid="team-action-delete">
<Trash2 />
Delete team
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface TeamTableColumnsDeps {
organizations: Organization[];
userRole: string | null;
onSelectTeam: (team: Team) => void;
onEditTeam: (team: Team) => void;
onDeleteTeam: (team: Team) => void;
}
export const getTeamTableColumns = ({
organizations,
userRole,
onSelectTeam,
onEditTeam,
onDeleteTeam,
}: TeamTableColumnsDeps): ColumnDef<Team>[] => {
const canManage = userRole === "Admin";
return [
{
id: "team_alias",
accessorKey: "team_alias",
meta: {
title: "Team",
renderSkeleton: () => (
<div className="flex flex-col gap-2 py-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3.5 w-24 opacity-65" />
</div>
),
},
header: ({ column }) => <DataTableSortHeader column={column} title="Team" variant="header-cycle" />,
size: 260,
enableSorting: true,
cell: ({ row }) => {
const team = row.original;
const hasAlias = Boolean(team.team_alias);
return (
<IdentityCell
title={team.team_alias || team.team_id}
subtitle={hasAlias ? team.team_id : undefined}
onClick={() => onSelectTeam(team)}
/>
);
},
},
{
id: "organization_alias",
accessorKey: "organization_id",
meta: { title: "Organization" },
header: "Organization",
size: 160,
enableSorting: false,
cell: (info) => {
const orgId = info.getValue() as string | null;
if (!orgId) return <span className="text-muted-foreground"></span>;
const org = organizations.find((o) => o.organization_id === orgId);
const displayValue = org?.organization_alias || orgId;
const width = info.cell.column.getSize();
return (
<span className="block truncate text-sm" style={{ maxWidth: width }} title={displayValue}>
{displayValue}
</span>
);
},
},
{
id: "resources",
meta: {
title: "Resources",
renderSkeleton: () => (
<div className="flex items-center gap-1.5">
<Skeleton className="h-6 w-12 rounded-md" />
<Skeleton className="h-6 w-12 rounded-md" />
<Skeleton className="h-6 w-12 rounded-md opacity-65" />
</div>
),
},
header: "Resources",
size: 210,
enableSorting: false,
cell: ({ row }) => <ResourcesCell team={row.original} />,
},
{
id: "spend",
accessorKey: "spend",
meta: { title: "Spend / Budget", skeleton: "meter" },
header: "Spend / Budget",
size: 200,
enableSorting: false,
cell: ({ row }) => <SpendBudgetCell spend={row.original.spend} maxBudget={row.original.max_budget} />,
},
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created" variant="header-cycle" />,
size: 130,
enableSorting: true,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" />,
},
{
id: "members",
meta: { title: "Members" },
header: "Members",
size: 110,
enableSorting: false,
cell: ({ row }) => <span className="text-sm tabular-nums">{teamMemberCount(row.original)}</span>,
},
{
id: "models",
meta: { title: "Models" },
header: "Models",
size: 100,
enableSorting: false,
cell: ({ row }) => <span className="text-sm tabular-nums">{teamModelCount(row.original)}</span>,
},
{
id: "rate_limits",
meta: { title: "Rate Limits", skeleton: "twoLine" },
header: "Rate Limits",
size: 140,
enableSorting: false,
cell: ({ row }) => (
<div className="text-xs leading-tight">
<RateLimitLine label="TPM" value={row.original.tpm_limit} />
<RateLimitLine label="RPM" value={row.original.rpm_limit} />
</div>
),
},
{
id: "updated_at",
accessorKey: "updated_at",
meta: { title: "Updated" },
header: "Updated",
size: 130,
enableSorting: false,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 60,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<TeamRowActions
team={row.original}
canManage={canManage}
onEditTeam={onEditTeam}
onDeleteTeam={onDeleteTeam}
/>
</div>
),
},
];
};
export const TEAM_TABLE_HIDDEN_COLUMNS: Record<string, boolean> = {
members: false,
models: false,
rate_limits: false,
updated_at: false,
};

View file

@ -12,8 +12,10 @@ export interface Team {
rpm_limit: number | null;
organization_id: string;
created_at: string;
updated_at?: string | null;
keys: KeyResponse[];
keys_count?: number;
members_count?: number;
members_with_roles: Member[];
spend: number;
access_group_ids?: string[];

View file

@ -0,0 +1,254 @@
"use client";
import * as React from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { cn } from "@/lib/cva.config";
import { ChevronRightIcon, CheckIcon } from "lucide-react";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props & Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8", className)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};