Merge pull request #21958 from atapia27/feat/virtual-keys-team-table

virtual-keys-team-table
This commit is contained in:
yuneng-jiang 2026-03-03 23:01:34 -08:00 committed by GitHub
commit d0e30842e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1403 additions and 6 deletions

View file

@ -1,7 +1,115 @@
import { teamListCall, organizationListCall } from "../networking"
import { teamListCall, organizationListCall, keyListCall } from "../networking";
import { Team } from "./key_list";
import { Organization } from "../networking";
export interface TeamFilterOptions {
keyAliases: string[];
organizationIds: string[];
userIds: Array<{ id: string; email: string }>;
}
const FILTER_OPTIONS_PAGE_SIZE = 100; // API max per page
const MAX_PAGES = 10; // Cap at 1000 keys; filter completeness beyond ~500 has diminishing returns
const processKeysIntoOptions = (
keys: Array<Record<string, unknown>>,
keyAliases: Set<string>,
organizationIds: Set<string>,
userMap: Map<string, string>,
) => {
for (const key of keys) {
const alias = key?.key_alias;
if (alias && typeof alias === "string") {
keyAliases.add(alias.trim());
}
const orgId = key?.organization_id;
if (orgId && typeof orgId === "string") {
organizationIds.add(orgId.trim());
}
const userId = key?.user_id;
if (userId && typeof userId === "string") {
const email = (key?.user as { user_email?: string })?.user_email || userId;
userMap.set(userId, email);
}
}
};
/**
* Fetches filter options (key aliases, org IDs, user IDs) from team keys.
* Fetches page 1 first to get totalPages, then batches remaining pages with
* Promise.allSettled (preserves successful pages if some fail). Capped at 10 pages (1000 keys)
*/
export const fetchTeamFilterOptions = async (
accessToken: string | null,
teamId: string,
): Promise<TeamFilterOptions> => {
if (!accessToken || !teamId) {
return { keyAliases: [], organizationIds: [], userIds: [] };
}
try {
const keyAliases = new Set<string>();
const organizationIds = new Set<string>();
const userMap = new Map<string, string>();
// First request: get page 1 and totalPages
const firstResponse = await keyListCall(
accessToken,
null,
teamId,
null,
null,
null,
1,
FILTER_OPTIONS_PAGE_SIZE,
null,
null,
"user",
null,
);
const firstKeys = firstResponse?.keys || [];
const totalPages = firstResponse?.total_pages ?? 1;
processKeysIntoOptions(firstKeys, keyAliases, organizationIds, userMap);
// Batch fetch remaining pages (2 through min(totalPages, MAX_PAGES)) in parallel
const pagesToFetch = Math.min(totalPages, MAX_PAGES) - 1;
if (pagesToFetch > 0) {
const pagePromises = Array.from({ length: pagesToFetch }, (_, i) =>
keyListCall(
accessToken,
null,
teamId,
null,
null,
null,
i + 2,
FILTER_OPTIONS_PAGE_SIZE,
null,
null,
"user",
null,
),
);
const results = await Promise.allSettled(pagePromises);
for (const result of results) {
if (result.status === "fulfilled") {
processKeysIntoOptions(result.value?.keys || [], keyAliases, organizationIds, userMap);
}
}
}
return {
keyAliases: Array.from(keyAliases).sort(),
organizationIds: Array.from(organizationIds).sort(),
userIds: Array.from(userMap.entries()).map(([id, email]) => ({ id, email })),
};
} catch (error) {
console.error("Error fetching team filter options:", error);
return { keyAliases: [], organizationIds: [], userIds: [] };
}
};
/**
* Fetches all teams across all pages
* @param accessToken The access token for API authentication

View file

@ -100,12 +100,33 @@ vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
}),
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: vi.fn().mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
isPending: false,
isFetching: false,
refetch: vi.fn(),
}),
}));
vi.mock("../key_team_helpers/filter_helpers", () => ({
fetchTeamFilterOptions: vi.fn().mockResolvedValue({
keyAliases: [],
organizationIds: [],
userIds: [],
}),
fetchAllKeyAliases: vi.fn().mockResolvedValue([]),
fetchAllOrganizations: vi.fn().mockResolvedValue([]),
}));
import { useAllProxyModels } from "@/app/(dashboard)/hooks/models/useModels";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
const mockUseAllProxyModels = vi.mocked(useAllProxyModels);
const mockUseKeys = vi.mocked(useKeys);
const mockUseTeam = vi.mocked(useTeam);
const mockUseOrganization = vi.mocked(useOrganization);
const mockUseCurrentUser = vi.mocked(useCurrentUser);
@ -180,6 +201,12 @@ describe("TeamInfoView", () => {
data: { models: [] },
isLoading: false,
} as any);
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] });
@ -558,10 +585,109 @@ describe("TeamInfoView", () => {
renderWithProviders(<TeamInfoView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Virtual Keys")).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Virtual Keys" })).toBeInTheDocument();
});
});
it("should show Virtual Keys tab when user cannot edit team", async () => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
renderWithProviders(<TeamInfoView {...defaultProps} is_team_admin={false} is_proxy_admin={false} />);
await waitFor(() => {
expect(screen.getByRole("tab", { name: "Virtual Keys" })).toBeInTheDocument();
});
});
it("should display X Members in Virtual Keys tab when navigated to", async () => {
const user = userEvent.setup();
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
const fiveKeys = Array.from({ length: 5 }, (_, i) => ({
token: `sk-${i}`,
token_id: `key-${i}`,
key_alias: `key_${i}`,
key_name: `sk-...${i}`,
user_id: `user-${i}`,
organization_id: null,
user: { user_id: `user-${i}`, user_email: `user${i}@test.com` },
created_at: "2024-01-01T00:00:00Z",
team_id: "123",
spend: 0,
max_budget: 100,
models: ["gpt-4"],
}));
mockUseKeys.mockReturnValue({
data: { keys: fiveKeys, total_count: 5, current_page: 1, total_pages: 1 },
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
renderWithProviders(<TeamInfoView {...defaultProps} />);
await waitFor(() => {
const teamNameElements = screen.queryAllByText("Test Team");
expect(teamNameElements.length).toBeGreaterThan(0);
});
const virtualKeysTab = screen.getByRole("tab", { name: "Virtual Keys" });
await user.click(virtualKeysTab);
await waitFor(() => {
expect(screen.getByText("5 Members")).toBeInTheDocument();
});
});
it("should show Filters and pagination controls in Virtual Keys tab", async () => {
const user = userEvent.setup();
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
mockUseKeys.mockReturnValue({
data: {
keys: [
{
token: "sk-1",
token_id: "key-1",
key_alias: "key1",
key_name: "sk-...1",
user_id: "user-1",
organization_id: null,
user: { user_id: "user-1", user_email: "user1@test.com" },
created_at: "2024-01-01T00:00:00Z",
team_id: "123",
spend: 0,
max_budget: 100,
models: ["gpt-4"],
},
],
total_count: 1,
current_page: 1,
total_pages: 1,
},
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
renderWithProviders(<TeamInfoView {...defaultProps} />);
await waitFor(() => {
const teamNameElements = screen.queryAllByText("Test Team");
expect(teamNameElements.length).toBeGreaterThan(0);
});
const virtualKeysTab = screen.getByRole("tab", { name: "Virtual Keys" });
await user.click(virtualKeysTab);
await waitFor(() => {
expect(screen.getByText("1 Member")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: "Filters" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument();
expect(screen.getByText("Page 1 of 1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument();
});
it("should display object permissions when present", async () => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({

View file

@ -48,6 +48,7 @@ import {
TEAM_INFO_TAB_LABELS,
} from "./tabVisibilityUtils";
import TeamMembersComponent from "./TeamMemberTab";
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
export interface TeamMembership {
user_id: string;
@ -726,6 +727,17 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</Grid>
),
},
{
key: TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS],
children: (
<TeamVirtualKeysTable
teamId={teamId}
teamAlias={info.team_alias}
organization={organization}
/>
),
},
{
key: TEAM_INFO_TAB_KEYS.MEMBERS,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS],

View file

@ -0,0 +1,356 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { fetchTeamFilterOptions } from "../key_team_helpers/filter_helpers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { KeyResponse } from "../key_team_helpers/key_list";
import { Organization } from "../networking";
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn(),
}));
vi.mock("../key_team_helpers/filter_helpers", () => ({
fetchTeamFilterOptions: vi.fn().mockResolvedValue({
keyAliases: [],
organizationIds: [],
userIds: [],
}),
}));
vi.mock("../key_team_helpers/fetch_available_models_team_key", () => ({
getModelDisplayName: vi.fn((model: string) => model),
}));
vi.mock("../templates/key_info_view", () => ({
default: vi.fn(({ onClose }: { onClose: () => void }) => (
<div>
<span>Key Info View</span>
<button onClick={onClose}>Close</button>
</div>
)),
}));
const mockUseKeys = useKeys as MockedFunction<typeof useKeys>;
const mockUseAuthorized = useAuthorized as MockedFunction<typeof useAuthorized>;
const createMockKey = (overrides: Partial<KeyResponse> = {}): KeyResponse =>
({
token: "sk-test123",
token_id: "key-1",
key_alias: "alice_key_team1",
key_name: "sk-...abc",
user_id: "user-1",
organization_id: null,
user: { user_id: "user-1", user_email: "alice@example.com" },
created_at: "2024-01-01T00:00:00Z",
team_id: "team-1",
spend: 0,
max_budget: 100,
models: ["gpt-4"],
...overrides,
} as KeyResponse);
const mockOrganization: Organization = {
organization_id: "org-123",
organization_alias: "Test Org",
budget_id: "budget-1",
metadata: {},
models: [],
spend: 0,
model_spend: {},
created_at: "",
created_by: "",
updated_at: "",
updated_by: "",
litellm_budget_table: {},
teams: [],
users: [],
members: [],
};
describe("TeamVirtualKeysTable", () => {
const defaultProps = {
teamId: "team-1",
teamAlias: "team1",
organization: null as Organization | null,
};
beforeEach(() => {
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" } as any);
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 } as KeysResponse,
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
});
it("should render successfully", async () => {
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("0 Members")).toBeInTheDocument();
});
});
it("should display X Members instead of Showing X of Y results", async () => {
mockUseKeys.mockReturnValue({
data: {
keys: [createMockKey(), createMockKey({ token: "sk-2", token_id: "key-2" })],
total_count: 2,
current_page: 1,
total_pages: 1,
} as KeysResponse,
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("2 Members")).toBeInTheDocument();
});
expect(screen.queryByText(/Showing.*results/)).not.toBeInTheDocument();
});
it("should display 1 Member when singular", async () => {
mockUseKeys.mockReturnValue({
data: {
keys: [createMockKey()],
total_count: 1,
current_page: 1,
total_pages: 1,
} as KeysResponse,
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("1 Member")).toBeInTheDocument();
});
});
it("should call useKeys with page, pageSize, and expand user for server-side pagination", async () => {
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(mockUseKeys).toHaveBeenCalledWith(
1,
50,
expect.objectContaining({
teamID: "team-1",
expand: "user",
})
);
});
});
it("should enrich keys with organization_id when organization is provided", async () => {
const keyWithoutOrg = createMockKey({ organization_id: null });
mockUseKeys.mockReturnValue({
data: {
keys: [keyWithoutOrg],
total_count: 1,
current_page: 1,
total_pages: 1,
} as KeysResponse,
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
renderWithProviders(
<TeamVirtualKeysTable {...defaultProps} organization={mockOrganization} />
);
await waitFor(() => {
expect(screen.getByText("1 Member")).toBeInTheDocument();
});
// Key with org_id should display in table - org-123 from organization
await waitFor(() => {
expect(screen.getByText("org-123")).toBeInTheDocument();
});
});
it("should show table with Key ID column header", async () => {
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("0 Members")).toBeInTheDocument();
});
expect(screen.getByText("Key ID")).toBeInTheDocument();
});
it("should display keys in table when data is loaded", async () => {
mockUseKeys.mockReturnValue({
data: {
keys: [
createMockKey({ key_alias: "alice_key_team1" }),
createMockKey({ token: "sk-2", token_id: "key-2", key_alias: "bob_key_team1" }),
],
total_count: 2,
current_page: 1,
total_pages: 1,
} as KeysResponse,
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("2 Members")).toBeInTheDocument();
});
expect(screen.getByText("alice_key_team1")).toBeInTheDocument();
expect(screen.getByText("bob_key_team1")).toBeInTheDocument();
});
it("should show Page X of Y when multiple pages exist", async () => {
mockUseKeys.mockReturnValue({
data: {
keys: [createMockKey()],
total_count: 100,
current_page: 1,
total_pages: 3,
} as KeysResponse,
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
});
expect(screen.getByText("100 Members")).toBeInTheDocument();
});
it("should fetch page 2 when Next is clicked", async () => {
const user = userEvent.setup();
mockUseKeys.mockImplementation((page: number) => ({
data: {
keys: page === 1 ? [createMockKey()] : [createMockKey({ token: "sk-page2", key_alias: "page2_key" })],
total_count: 100,
current_page: page,
total_pages: 3,
} as KeysResponse,
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any));
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
});
const nextButton = screen.getByRole("button", { name: "Next" });
await user.click(nextButton);
await waitFor(() => {
expect(mockUseKeys).toHaveBeenLastCalledWith(
2,
50,
expect.objectContaining({ teamID: "team-1" })
);
});
});
it("should show Loading keys when isPending", async () => {
mockUseKeys.mockReturnValue({
data: undefined,
isPending: true,
isFetching: true,
refetch: vi.fn(),
} as any);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Loading keys...")).toBeInTheDocument();
});
});
it("should show No keys found when keys array is empty", async () => {
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 } as KeysResponse,
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("0 Members")).toBeInTheDocument();
});
expect(screen.getByText("No keys found")).toBeInTheDocument();
});
it("should fetch team-scoped filter options for Key Alias, Organization ID, and User ID", async () => {
const mockFetchTeamFilterOptions = vi.mocked(fetchTeamFilterOptions);
mockFetchTeamFilterOptions.mockResolvedValue({
keyAliases: ["alice_key_team1", "charlie_key_team1"],
organizationIds: ["org-123"],
userIds: [
{ id: "user-1", email: "alice@example.com" },
{ id: "user-2", email: "charlie@example.com" },
],
});
// Use unique teamId to avoid cache hit from previous tests (refetchOnMount: false)
renderWithProviders(
<TeamVirtualKeysTable {...defaultProps} teamId="team-filter-options-test" />
);
await waitFor(() => {
expect(mockFetchTeamFilterOptions).toHaveBeenCalledWith(
"test-token",
"team-filter-options-test"
);
});
});
it("should open Key Info View when key is clicked", async () => {
mockUseKeys.mockReturnValue({
data: {
keys: [createMockKey({ token: "sk-click-me", key_alias: "clickable_key" })],
total_count: 1,
current_page: 1,
total_pages: 1,
} as KeysResponse,
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("clickable_key")).toBeInTheDocument();
});
const keyButton = screen.getByRole("button", { name: /sk-click-me|clickable_key/ });
await userEvent.click(keyButton);
await waitFor(() => {
expect(screen.getByText("Key Info View")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,778 @@
// TO-DO: Standardize tables eventually
"use client";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
import {
ColumnDef,
flexRender,
getCoreRowModel,
PaginationState,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
Badge,
Button,
Icon,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Text,
} from "@tremor/react";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Popover, Skeleton, Tooltip } from "antd";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
import FilterComponent, { FilterOption } from "../molecules/filter";
import { Organization } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import { useQuery } from "@tanstack/react-query";
import { fetchTeamFilterOptions } from "../key_team_helpers/filter_helpers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
interface TeamVirtualKeysTableProps {
teamId: string;
teamAlias?: string;
organization: Organization | null;
}
/**
* TeamVirtualKeysTable variant of VirtualKeysTable scoped to a single team.
* Displays all virtual keys belonging to the team with same format and styling.
*/
export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVirtualKeysTableProps) {
const { accessToken } = useAuthorized();
const [selectedKey, setSelectedKey] = useState<KeyResponse | null>(null);
const [sorting, setSorting] = useState<SortingState>([
{ id: "created_at", desc: true },
]);
const [tablePagination, setTablePagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
});
const [filters, setFilters] = useState<Record<string, string>>({
"Organization ID": "",
"Key Alias": "",
"User ID": "",
"Sort By": "created_at",
"Sort Order": "desc",
});
const sortBy = sorting.length > 0 ? sorting[0].id : "created_at";
const sortOrder = sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : "desc";
const pageIndex = tablePagination.pageIndex;
const pageSize = tablePagination.pageSize;
const {
data: keys,
isPending: isLoading,
isFetching,
refetch,
} = useKeys(pageIndex + 1, pageSize, {
teamID: teamId,
organizationID: filters["Organization ID"]?.trim() || undefined,
selectedKeyAlias: filters["Key Alias"]?.trim() || undefined,
userID: filters["User ID"]?.trim() || undefined,
sortBy: sortBy || undefined,
sortOrder: sortOrder || undefined,
expand: "user",
});
const displayKeys = useMemo(() => {
const kList = keys?.keys || [];
const orgId = organization?.organization_id;
if (!orgId) return kList;
return kList.map((k: KeyResponse) => ({
...k,
organization_id: k.organization_id || orgId,
}));
}, [keys?.keys, organization?.organization_id]);
const totalCount = keys?.total_count ?? 0;
const pageCount = keys?.total_pages ?? 0;
const [expandedAccordions, setExpandedAccordions] = useState<Record<string, boolean>>({});
const currentTeam: Team = useMemo(
() => ({
team_id: teamId,
team_alias: teamAlias || teamId,
models: [],
max_budget: null,
budget_duration: null,
tpm_limit: null,
rpm_limit: null,
organization_id: organization?.organization_id || "",
created_at: "",
keys: [],
members_with_roles: [],
spend: 0,
}),
[teamId, teamAlias, organization],
);
const teamFilterOptionsQuery = useQuery({
queryKey: ["teamFilterOptions", teamId, accessToken],
queryFn: async () => fetchTeamFilterOptions(accessToken, teamId),
enabled: !!accessToken && !!teamId,
staleTime: 30000, // 30 seconds - align with useKeys
});
const teamFilterOptions = teamFilterOptionsQuery.data || {
keyAliases: [],
organizationIds: [],
userIds: [],
};
const handleStorageChange = useCallback(() => {
refetch?.();
}, [refetch]);
useEffect(() => {
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, [handleStorageChange]);
const handleFilterChange = useCallback((newFilters: Record<string, string>, skipDebounce = false) => {
setFilters((prev) => ({
...prev,
"Organization ID": newFilters["Organization ID"] ?? prev["Organization ID"],
"Key Alias": newFilters["Key Alias"] ?? prev["Key Alias"],
"User ID": newFilters["User ID"] ?? prev["User ID"],
"Sort By": newFilters["Sort By"] ?? prev["Sort By"] ?? "created_at",
"Sort Order": newFilters["Sort Order"] ?? prev["Sort Order"] ?? "desc",
}));
if (!skipDebounce) {
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}
}, []);
const handleFilterReset = useCallback(() => {
setFilters({
"Organization ID": "",
"Key Alias": "",
"User ID": "",
"Sort By": "created_at",
"Sort Order": "desc",
});
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}, []);
const filterOptions: FilterOption[] = useMemo(
() => [
{
name: "Organization ID",
label: "Organization ID",
isSearchable: true,
searchFn: async (searchText: string) => {
const { organizationIds } = teamFilterOptions;
if (!organizationIds.length) return [];
const lower = searchText.toLowerCase();
const filtered = lower
? organizationIds.filter((id) => id.toLowerCase().includes(lower))
: organizationIds;
return filtered.map((id) => ({ label: id, value: id }));
},
},
{
name: "Key Alias",
label: "Key Alias",
isSearchable: true,
searchFn: async (searchText: string) => {
const { keyAliases } = teamFilterOptions;
const lower = searchText.toLowerCase();
const filtered = lower
? keyAliases.filter((alias) => alias.toLowerCase().includes(lower))
: keyAliases;
return filtered.map((alias) => ({ label: alias, value: alias }));
},
},
{
name: "User ID",
label: "User ID",
isSearchable: true,
searchFn: async (searchText: string) => {
const { userIds } = teamFilterOptions;
const lower = searchText.toLowerCase();
const filtered = lower
? userIds.filter(
(u) =>
u.id.toLowerCase().includes(lower) || u.email.toLowerCase().includes(lower),
)
: userIds;
return filtered.map((u) => ({
label: u.email ? `${u.id} (${u.email})` : u.id,
value: u.id,
}));
},
},
],
[teamFilterOptions],
);
const columns: ColumnDef<KeyResponse>[] = useMemo(
() => [
{
id: "token",
accessorKey: "token",
header: "Key ID",
size: 100,
enableSorting: true,
cell: (info) => {
const value = info.getValue() as string;
const width = info.cell.column.getSize();
return (
<Tooltip title={value}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate block"
style={{ maxWidth: width, overflow: "hidden" }}
onClick={() => setSelectedKey(info.row.original)}
>
{value ?? "-"}
</Button>
</Tooltip>
);
},
},
{
id: "key_alias",
accessorKey: "key_alias",
header: "Key Alias",
size: 150,
enableSorting: true,
cell: (info) => {
const value = info.getValue() as string;
const width = info.cell.column.getSize();
return (
<Tooltip title={value}>
<span
className="font-mono text-xs truncate block"
style={{ maxWidth: width, overflow: "hidden" }}
>
{value ?? "-"}
</span>
</Tooltip>
);
},
},
{
id: "key_name",
accessorKey: "key_name",
header: "Secret Key",
size: 120,
enableSorting: false,
cell: (info) => <span className="font-mono text-xs">{info.getValue() as string}</span>,
},
{
id: "organization_id",
accessorKey: "organization_id",
header: "Organization ID",
size: 140,
enableSorting: false,
cell: (info) => (info.getValue() ? info.renderValue() : "-"),
},
{
id: "user_email",
accessorKey: "user",
header: "User Email",
size: 160,
enableSorting: false,
cell: (info) => {
const user = info.getValue() as { user_email?: string } | undefined;
const value = user?.user_email;
const width = info.cell.column.getSize();
return (
<Tooltip title={value}>
<span
className="font-mono text-xs truncate block"
style={{ maxWidth: width, overflow: "hidden" }}
>
{value ?? "-"}
</span>
</Tooltip>
);
},
},
{
id: "user_id",
accessorKey: "user_id",
header: "User ID",
size: 70,
enableSorting: false,
cell: (info) => {
const userId = info.getValue() as string | null;
const displayValue = userId === "default_user_id" ? "Default Proxy Admin" : userId;
const width = info.cell.column.getSize();
return (
<Tooltip title={displayValue}>
<span
className="font-mono text-xs truncate block"
style={{ maxWidth: width, overflow: "hidden" }}
>
{displayValue ?? "-"}
</span>
</Tooltip>
);
},
},
{
id: "created_at",
accessorKey: "created_at",
header: "Created At",
size: 120,
enableSorting: true,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "-";
},
},
{
id: "created_by",
accessorKey: "created_by",
header: "Created By",
size: 70,
enableSorting: false,
cell: (info) => {
const value = info.getValue() as string | null;
const displayValue = value === "default_user_id" ? "Default Proxy Admin" : value;
const width = info.cell.column.getSize();
return (
<Tooltip title={displayValue}>
<span
className="font-mono text-xs truncate block"
style={{ maxWidth: width, overflow: "hidden" }}
>
{displayValue ?? "-"}
</span>
</Tooltip>
);
},
},
{
id: "updated_at",
accessorKey: "updated_at",
header: "Updated At",
size: 120,
enableSorting: true,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "Never";
},
},
{
id: "last_active",
accessorKey: "last_active",
header: () => (
<span className="flex items-center gap-1">
Last Active
<Popover
content="This is a new field and is not backfilled. Only new key usage will update this value."
trigger="hover"
>
<InfoCircleOutlined className="text-gray-400 text-xs cursor-help" />
</Popover>
</span>
),
size: 130,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
if (!value) return "Unknown";
const date = new Date(value as string);
return (
<Tooltip title={date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "long" })}>
<span>{date.toLocaleDateString()}</span>
</Tooltip>
);
},
},
{
id: "expires",
accessorKey: "expires",
header: "Expires",
size: 120,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "Never";
},
},
{
id: "spend",
accessorKey: "spend",
header: "Spend (USD)",
size: 100,
enableSorting: true,
cell: (info) => formatNumberWithCommas(info.getValue() as number, 4),
},
{
id: "max_budget",
accessorKey: "max_budget",
header: "Budget (USD)",
size: 110,
enableSorting: true,
cell: (info) => {
const maxBudget = info.getValue() as number | null;
if (maxBudget === null) return "Unlimited";
return `$${formatNumberWithCommas(maxBudget)}`;
},
},
{
id: "budget_reset_at",
accessorKey: "budget_reset_at",
header: "Budget Reset",
size: 130,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleString() : "Never";
},
},
{
id: "models",
accessorKey: "models",
header: "Models",
size: 200,
enableSorting: false,
cell: (info) => {
const models = info.getValue() as string[];
return (
<div className="flex flex-col py-2">
{Array.isArray(models) ? (
<div className="flex flex-col">
{models.length === 0 ? (
<Badge size="xs" className="mb-1" color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<>
<div className="flex items-start">
{models.length > 3 && (
<div>
<Icon
icon={expandedAccordions[info.row.id] ? ChevronDownIcon : ChevronRightIcon}
className="cursor-pointer"
size="xs"
onClick={() =>
setExpandedAccordions((prev) => ({
...prev,
[info.row.id]: !prev[info.row.id],
}))
}
/>
</div>
)}
<div className="flex flex-wrap gap-1">
{models.slice(0, 3).map((model, index) =>
model === "all-proxy-models" ? (
<Badge key={index} size="xs" color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<Badge key={index} size="xs" color="blue">
<Text>
{model.length > 30
? `${getModelDisplayName(model).slice(0, 30)}...`
: getModelDisplayName(model)}
</Text>
</Badge>
),
)}
{models.length > 3 && !expandedAccordions[info.row.id] && (
<Badge size="xs" color="gray" className="cursor-pointer">
<Text>
+{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"}
</Text>
</Badge>
)}
{expandedAccordions[info.row.id] && (
<div className="flex flex-wrap gap-1">
{models.slice(3).map((model, index) =>
model === "all-proxy-models" ? (
<Badge key={index + 3} size="xs" color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<Badge key={index + 3} size="xs" color="blue">
<Text>
{model.length > 30
? `${getModelDisplayName(model).slice(0, 30)}...`
: getModelDisplayName(model)}
</Text>
</Badge>
),
)}
</div>
)}
</div>
</div>
</>
)}
</div>
) : null}
</div>
);
},
},
{
id: "rate_limits",
header: "Rate Limits",
size: 140,
enableSorting: false,
cell: ({ row }) => {
const key = row.original;
return (
<div>
<div>TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}</div>
<div>RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}</div>
</div>
);
},
},
],
[expandedAccordions],
);
const handleSortingChange = useCallback(
(updaterOrValue: React.SetStateAction<SortingState>) => {
const newSorting =
typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue;
setSorting(newSorting);
if (newSorting?.length > 0) {
const sortState = newSorting[0];
handleFilterChange(
{
"Sort By": sortState.id,
"Sort Order": sortState.desc ? "desc" : "asc",
},
true,
);
}
},
[sorting, handleFilterChange],
);
const table = useReactTable({
data: displayKeys,
columns,
columnResizeMode: "onChange",
columnResizeDirection: "ltr",
state: { sorting, pagination: tablePagination },
onSortingChange: handleSortingChange,
onPaginationChange: setTablePagination,
getCoreRowModel: getCoreRowModel(),
// getSortedRowModel not needed — manualSorting: true delegates sorting to the server
enableSorting: true,
manualSorting: true, // Server sorts via useKeys. Avoid redundant client-side sort
manualPagination: true,
pageCount: pageCount,
});
return (
<div className="w-full h-full overflow-hidden">
{selectedKey ? (
<KeyInfoView
keyId={selectedKey.token}
onClose={() => setSelectedKey(null)}
keyData={selectedKey}
teams={[currentTeam]}
onDelete={refetch}
/>
) : (
<div className="border-b py-4 flex-1 overflow-hidden">
<div className="w-full mb-6">
<FilterComponent
options={filterOptions}
onApplyFilters={handleFilterChange}
initialValues={filters}
onResetFilters={handleFilterReset}
/>
</div>
<div className="flex items-center justify-between w-full mb-4">
{isLoading || isFetching ? (
<Skeleton.Node active style={{ width: 200, height: 20 }} />
) : (
<span className="inline-flex text-sm text-gray-700">
{totalCount} Member{totalCount !== 1 ? "s" : ""}
</span>
)}
<div className="inline-flex items-center gap-2">
{isLoading || isFetching ? (
<Skeleton.Node active style={{ width: 74, height: 20 }} />
) : (
<span className="text-sm text-gray-700">
Page {pageIndex + 1} of {table.getPageCount()}
</span>
)}
{isLoading || isFetching ? (
<Skeleton.Button active size="small" style={{ width: 84, height: 30 }} />
) : (
<button
onClick={() => table.previousPage()}
disabled={isLoading || isFetching || !table.getCanPreviousPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
)}
{isLoading || isFetching ? (
<Skeleton.Button active size="small" style={{ width: 58, height: 30 }} />
) : (
<button
onClick={() => table.nextPage()}
disabled={isLoading || isFetching || !table.getCanNextPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
)}
</div>
</div>
<div className="h-[75vh] overflow-auto">
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1" style={{ width: table.getCenterTotalSize() }}>
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
data-header-id={header.id}
className={`py-1 h-8 relative hover:bg-gray-50 ${
header.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
style={{
width: header.getSize(),
position: "relative",
cursor: header.column.getCanSort() ? "pointer" : "default",
}}
onMouseEnter={() => {
const resizer = document.querySelector(
`[data-header-id="${header.id}"] .resizer`,
);
if (resizer) (resizer as HTMLElement).style.opacity = "0.5";
}}
onMouseLeave={() => {
const resizer = document.querySelector(
`[data-header-id="${header.id}"] .resizer`,
);
if (resizer && !header.column.getIsResizing())
(resizer as HTMLElement).style.opacity = "0";
}}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{header.id !== "actions" && header.column.getCanSort() && (
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
)}
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={`resizer ${table.options.columnResizeDirection} ${
header.column.getIsResizing() ? "isResizing" : ""
}`}
style={{
position: "absolute",
right: 0,
top: 0,
height: "100%",
width: "5px",
background: header.column.getIsResizing() ? "#3b82f6" : "transparent",
cursor: "col-resize",
userSelect: "none",
touchAction: "none",
opacity: header.column.getIsResizing() ? 1 : 0,
}}
/>
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading || isFetching ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>Loading keys...</p>
</div>
</TableCell>
</TableRow>
) : displayKeys.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize(),
maxWidth: "8-x",
whiteSpace: "pre-wrap",
overflow: "hidden",
}}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
cell.column.id === "models" &&
Array.isArray(cell.getValue()) &&
(cell.getValue() as string[]).length > 3
? "px-0"
: ""
}`}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No keys found</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -11,6 +11,7 @@ describe("team_info_tabs", () => {
describe("TEAM_INFO_TAB_LABELS", () => {
it("should have label for every tab key", () => {
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.OVERVIEW]).toBe("Overview");
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]).toBe("Virtual Keys");
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS]).toBe("Members");
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]).toBe("Member Permissions");
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.SETTINGS]).toBe("Settings");
@ -18,15 +19,16 @@ describe("team_info_tabs", () => {
});
describe("getTeamInfoVisibleTabs", () => {
it("returns only overview when user cannot edit team", () => {
it("returns overview and virtual keys when user cannot edit team", () => {
const tabs = getTeamInfoVisibleTabs(false);
expect(tabs).toEqual([TEAM_INFO_TAB_KEYS.OVERVIEW]);
expect(tabs).toEqual([TEAM_INFO_TAB_KEYS.OVERVIEW, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]);
});
it("returns all tabs when user can edit team", () => {
const tabs = getTeamInfoVisibleTabs(true);
expect(tabs).toEqual([
TEAM_INFO_TAB_KEYS.OVERVIEW,
TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS,
TEAM_INFO_TAB_KEYS.MEMBERS,
TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS,
TEAM_INFO_TAB_KEYS.SETTINGS,
@ -55,6 +57,19 @@ describe("team_info_tabs", () => {
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.OVERVIEW, true)).toBe(true);
});
it("always returns true for virtual keys tab regardless of edit permission", () => {
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, false)).toBe(true);
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, true)).toBe(true);
});
it("returns false for member permissions tab when user cannot edit", () => {
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS, false)).toBe(false);
});
it("returns true for member permissions tab when user can edit", () => {
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS, true)).toBe(true);
});
it("returns false for members tab when user cannot edit", () => {
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MEMBERS, false)).toBe(false);
});

View file

@ -5,6 +5,7 @@
export const TEAM_INFO_TAB_KEYS = {
OVERVIEW: "overview",
VIRTUAL_KEYS: "virtual-keys",
MEMBERS: "members",
MEMBER_PERMISSIONS: "member-permissions",
SETTINGS: "settings",
@ -12,6 +13,7 @@ export const TEAM_INFO_TAB_KEYS = {
export const TEAM_INFO_TAB_LABELS: Record<string, string> = {
[TEAM_INFO_TAB_KEYS.OVERVIEW]: "Overview",
[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]: "Virtual Keys",
[TEAM_INFO_TAB_KEYS.MEMBERS]: "Members",
[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]: "Member Permissions",
[TEAM_INFO_TAB_KEYS.SETTINGS]: "Settings",
@ -19,11 +21,11 @@ export const TEAM_INFO_TAB_LABELS: Record<string, string> = {
/**
* Returns the list of tab keys that should be visible based on permissions.
* - Overview: always visible
* - Overview, Virtual Keys: always visible
* - Members, Member Permissions, Settings: only when canEditTeam is true
*/
export function getTeamInfoVisibleTabs(canEditTeam: boolean): readonly string[] {
const baseTabs = [TEAM_INFO_TAB_KEYS.OVERVIEW];
const baseTabs = [TEAM_INFO_TAB_KEYS.OVERVIEW, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS];
if (canEditTeam) {
return [
...baseTabs,