mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge pull request #21958 from atapia27/feat/virtual-keys-team-table
virtual-keys-team-table
This commit is contained in:
commit
d0e30842e5
7 changed files with 1403 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue