diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index 3d941cf638f..f2cb613ee29 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -10138,6 +10138,8 @@ export interface ToolRow {
updated_at?: string;
created_by?: string;
updated_by?: string;
+ user_agent?: string;
+ last_used_at?: string;
}
export interface ToolPolicyOption {
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
index a5951298557..1be400d328b 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
@@ -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(
);
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(
);
+
+ 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(
);
+
+ 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(
);
+
+ 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({
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index ea7a9a1c460..62208b4186d 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -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
= ({
),
},
+ {
+ key: TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS,
+ label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS],
+ children: (
+
+ ),
+ },
{
key: TEAM_INFO_TAB_KEYS.MEMBERS,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS],
diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx
new file mode 100644
index 00000000000..41df5611e07
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx
@@ -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 }) => (
+
+ Key Info View
+
+
+ )),
+}));
+
+const mockUseKeys = useKeys as MockedFunction;
+const mockUseAuthorized = useAuthorized as MockedFunction;
+
+const createMockKey = (overrides: Partial = {}): 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByText("1 Member")).toBeInTheDocument();
+ });
+ });
+
+ it("should call useKeys with page, pageSize, and expand user for server-side pagination", async () => {
+ renderWithProviders();
+
+ 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(
+
+ );
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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(
+
+ );
+
+ 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();
+
+ 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();
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx
new file mode 100644
index 00000000000..5d76b99ef91
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx
@@ -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(null);
+ const [sorting, setSorting] = useState([
+ { id: "created_at", desc: true },
+ ]);
+ const [tablePagination, setTablePagination] = useState({
+ pageIndex: 0,
+ pageSize: 50,
+ });
+ const [filters, setFilters] = useState>({
+ "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>({});
+
+ 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, 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[] = 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 (
+
+
+
+ );
+ },
+ },
+ {
+ 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 (
+
+
+ {value ?? "-"}
+
+
+ );
+ },
+ },
+ {
+ id: "key_name",
+ accessorKey: "key_name",
+ header: "Secret Key",
+ size: 120,
+ enableSorting: false,
+ cell: (info) => {info.getValue() as string},
+ },
+ {
+ 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 (
+
+
+ {value ?? "-"}
+
+
+ );
+ },
+ },
+ {
+ 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 (
+
+
+ {displayValue ?? "-"}
+
+
+ );
+ },
+ },
+ {
+ 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 (
+
+
+ {displayValue ?? "-"}
+
+
+ );
+ },
+ },
+ {
+ 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: () => (
+
+ Last Active
+
+
+
+
+ ),
+ size: 130,
+ enableSorting: false,
+ cell: (info) => {
+ const value = info.getValue();
+ if (!value) return "Unknown";
+ const date = new Date(value as string);
+ return (
+
+ {date.toLocaleDateString()}
+
+ );
+ },
+ },
+ {
+ 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 (
+
+ {Array.isArray(models) ? (
+
+ {models.length === 0 ? (
+
+ All Proxy Models
+
+ ) : (
+ <>
+
+ {models.length > 3 && (
+
+
+ setExpandedAccordions((prev) => ({
+ ...prev,
+ [info.row.id]: !prev[info.row.id],
+ }))
+ }
+ />
+
+ )}
+
+ {models.slice(0, 3).map((model, index) =>
+ model === "all-proxy-models" ? (
+
+ All Proxy Models
+
+ ) : (
+
+
+ {model.length > 30
+ ? `${getModelDisplayName(model).slice(0, 30)}...`
+ : getModelDisplayName(model)}
+
+
+ ),
+ )}
+ {models.length > 3 && !expandedAccordions[info.row.id] && (
+
+
+ +{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"}
+
+
+ )}
+ {expandedAccordions[info.row.id] && (
+
+ {models.slice(3).map((model, index) =>
+ model === "all-proxy-models" ? (
+
+ All Proxy Models
+
+ ) : (
+
+
+ {model.length > 30
+ ? `${getModelDisplayName(model).slice(0, 30)}...`
+ : getModelDisplayName(model)}
+
+
+ ),
+ )}
+
+ )}
+
+
+ >
+ )}
+
+ ) : null}
+
+ );
+ },
+ },
+ {
+ id: "rate_limits",
+ header: "Rate Limits",
+ size: 140,
+ enableSorting: false,
+ cell: ({ row }) => {
+ const key = row.original;
+ return (
+
+
TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}
+
RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}
+
+ );
+ },
+ },
+ ],
+ [expandedAccordions],
+ );
+
+ const handleSortingChange = useCallback(
+ (updaterOrValue: React.SetStateAction) => {
+ 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 (
+
+ {selectedKey ? (
+
setSelectedKey(null)}
+ keyData={selectedKey}
+ teams={[currentTeam]}
+ onDelete={refetch}
+ />
+ ) : (
+
+
+
+
+
+
+ {isLoading || isFetching ? (
+
+ ) : (
+
+ {totalCount} Member{totalCount !== 1 ? "s" : ""}
+
+ )}
+
+
+ {isLoading || isFetching ? (
+
+ ) : (
+
+ Page {pageIndex + 1} of {table.getPageCount()}
+
+ )}
+
+ {isLoading || isFetching ? (
+
+ ) : (
+
+ )}
+
+ {isLoading || isFetching ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+ {
+ 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
+ }
+ >
+
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+
+ {header.id !== "actions" && header.column.getCanSort() && (
+
+ {header.column.getIsSorted() ? (
+ {
+ asc: ,
+ desc: ,
+ }[header.column.getIsSorted() as string]
+ ) : (
+
+ )}
+
+ )}
+
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,
+ }}
+ />
+
+
+ ))}
+
+ ))}
+
+
+ {isLoading || isFetching ? (
+
+
+
+
+
+ ) : displayKeys.length > 0 ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+ 3
+ ? "px-0"
+ : ""
+ }`}
+ >
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts
index 5a09b4fa36c..8b9a0402c9a 100644
--- a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts
+++ b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts
@@ -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);
});
diff --git a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts
index d77230ea09b..dd0e54baf36 100644
--- a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts
+++ b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts
@@ -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 = {
[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 = {
/**
* 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,
diff --git a/ui/litellm-dashboard/tsconfig.json b/ui/litellm-dashboard/tsconfig.json
index 5b0352feb98..d24bdd340f7 100644
--- a/ui/litellm-dashboard/tsconfig.json
+++ b/ui/litellm-dashboard/tsconfig.json
@@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{