Merge pull request #21165 from BerriAI/litellm_access_group_ui

[Feature] UI - Access Groups: Table and Details Page
This commit is contained in:
yuneng-jiang 2026-02-13 17:17:18 -08:00 committed by GitHub
commit 3ebafc4a10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2426 additions and 10 deletions

View file

@ -0,0 +1,63 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups";
// ── Fetch function ───────────────────────────────────────────────────────────
const fetchAccessGroupDetails = async (
accessToken: string,
accessGroupId: string,
): Promise<AccessGroupResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`;
const response = await fetch(url, {
method: "GET",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
};
// ── Hook ─────────────────────────────────────────────────────────────────────
export const useAccessGroupDetails = (accessGroupId?: string) => {
const { accessToken, userRole } = useAuthorized();
const queryClient = useQueryClient();
return useQuery<AccessGroupResponse>({
queryKey: accessGroupKeys.detail(accessGroupId!),
queryFn: async () => fetchAccessGroupDetails(accessToken!, accessGroupId!),
enabled:
Boolean(accessToken && accessGroupId) &&
all_admin_roles.includes(userRole || ""),
// Seed from the list cache when available
initialData: () => {
if (!accessGroupId) return undefined;
const groups = queryClient.getQueryData<AccessGroupResponse[]>(
accessGroupKeys.list({}),
);
return groups?.find((g) => g.access_group_id === accessGroupId);
},
});
};

View file

@ -0,0 +1,242 @@
/* @vitest-environment jsdom */
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useAccessGroups, AccessGroupResponse } from "./useAccessGroups";
import * as networking from "@/components/networking";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => "http://proxy.example"),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
deriveErrorMessage: vi.fn((data: unknown) => (data as { detail?: string })?.detail ?? "Unknown error"),
handleError: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn(() => ({
accessToken: "test-token-123",
userRole: "Admin",
})),
}));
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
const wrapper = ({ children }: { children: React.ReactNode }) => {
const queryClient = createQueryClient();
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const mockAccessToken = "test-token-123";
const mockAccessGroups: AccessGroupResponse[] = [
{
access_group_id: "ag-1",
access_group_name: "Group One",
description: "First group",
access_model_ids: [],
access_mcp_server_ids: [],
access_agent_ids: [],
assigned_team_ids: [],
assigned_key_ids: [],
created_at: "2025-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2025-01-01T00:00:00Z",
updated_by: "user-1",
},
];
const fetchMock = vi.fn();
describe("useAccessGroups", () => {
beforeEach(async () => {
vi.clearAllMocks();
vi.mocked(networking.getProxyBaseUrl).mockReturnValue("http://proxy.example");
vi.mocked(networking.getGlobalLitellmHeaderName).mockReturnValue("Authorization");
const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
vi.mocked(useAuthorizedModule.default).mockReturnValue({
accessToken: mockAccessToken,
userRole: "Admin",
} as any);
global.fetch = fetchMock;
});
it("should return hook result without errors", () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const { result } = renderHook(() => useAccessGroups(), { wrapper });
expect(result.current).toBeDefined();
expect(result.current).toHaveProperty("data");
expect(result.current).toHaveProperty("isSuccess");
expect(result.current).toHaveProperty("isError");
expect(result.current).toHaveProperty("status");
});
it("should return access groups when access token and admin role are present", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockAccessGroups),
} as Response);
const { result } = renderHook(() => useAccessGroups(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(fetchMock).toHaveBeenCalledWith(
"http://proxy.example/v1/access_group",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Authorization: `Bearer ${mockAccessToken}`,
"Content-Type": "application/json",
}),
}),
);
expect(result.current.data).toEqual(mockAccessGroups);
});
it("should not fetch when access token is null", async () => {
const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
vi.mocked(useAuthorizedModule.default).mockReturnValue({
accessToken: null,
userRole: "Admin",
} as any);
const { result } = renderHook(() => useAccessGroups(), { wrapper });
expect(result.current.isFetching).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});
it("should not fetch when access token is empty string", async () => {
const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
vi.mocked(useAuthorizedModule.default).mockReturnValue({
accessToken: "",
userRole: "Admin",
} as any);
const { result } = renderHook(() => useAccessGroups(), { wrapper });
expect(result.current.isFetching).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});
it("should not fetch when user role is not an admin role", async () => {
const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
vi.mocked(useAuthorizedModule.default).mockReturnValue({
accessToken: mockAccessToken,
userRole: "Viewer",
} as any);
const { result } = renderHook(() => useAccessGroups(), { wrapper });
expect(result.current.isFetching).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});
it("should not fetch when user role is null", async () => {
const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
vi.mocked(useAuthorizedModule.default).mockReturnValue({
accessToken: mockAccessToken,
userRole: null,
} as any);
const { result } = renderHook(() => useAccessGroups(), { wrapper });
expect(result.current.isFetching).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});
it("should fetch when user role is proxy_admin", async () => {
const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
vi.mocked(useAuthorizedModule.default).mockReturnValue({
accessToken: mockAccessToken,
userRole: "proxy_admin",
} as any);
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockAccessGroups),
} as Response);
const { result } = renderHook(() => useAccessGroups(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(fetchMock).toHaveBeenCalled();
expect(result.current.data).toEqual(mockAccessGroups);
});
it("should expose error state when fetch fails", async () => {
fetchMock.mockResolvedValue({
ok: false,
json: () => Promise.resolve({ detail: "Forbidden" }),
} as Response);
vi.mocked(networking.deriveErrorMessage).mockReturnValue("Forbidden");
const { result } = renderHook(() => useAccessGroups(), { wrapper });
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeInstanceOf(Error);
expect((result.current.error as Error).message).toBe("Forbidden");
expect(result.current.data).toBeUndefined();
expect(networking.handleError).toHaveBeenCalledWith("Forbidden");
});
it("should return empty array when API returns empty list", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const { result } = renderHook(() => useAccessGroups(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it("should propagate network errors", async () => {
const networkError = new Error("Network failure");
fetchMock.mockRejectedValue(networkError);
const { result } = renderHook(() => useAccessGroups(), { wrapper });
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(networkError);
expect(result.current.data).toBeUndefined();
});
});

View file

@ -0,0 +1,70 @@
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
// ── Types ────────────────────────────────────────────────────────────────────
export interface AccessGroupResponse {
access_group_id: string;
access_group_name: string;
description: string | null;
access_model_ids: string[];
access_mcp_server_ids: string[];
access_agent_ids: string[];
assigned_team_ids: string[];
assigned_key_ids: string[];
created_at: string;
created_by: string | null;
updated_at: string;
updated_by: string | null;
}
// ── Query keys (shared across access-group hooks) ────────────────────────────
export const accessGroupKeys = createQueryKeys("accessGroups");
// ── Fetch function ───────────────────────────────────────────────────────────
const fetchAccessGroups = async (
accessToken: string,
): Promise<AccessGroupResponse[]> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/v1/access_group`;
const response = await fetch(url, {
method: "GET",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
};
// ── Hook ─────────────────────────────────────────────────────────────────────
export const useAccessGroups = () => {
const { accessToken, userRole } = useAuthorized();
return useQuery<AccessGroupResponse[]>({
queryKey: accessGroupKeys.list({}),
queryFn: async () => fetchAccessGroups(accessToken!),
enabled:
Boolean(accessToken) && all_admin_roles.includes(userRole || ""),
});
};

View file

@ -0,0 +1,68 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups";
// ── Types ────────────────────────────────────────────────────────────────────
export interface AccessGroupCreateParams {
access_group_name: string;
description?: string | null;
access_model_ids?: string[];
access_mcp_server_ids?: string[];
access_agent_ids?: string[];
assigned_team_ids?: string[];
assigned_key_ids?: string[];
}
// ── Fetch function ───────────────────────────────────────────────────────────
const createAccessGroup = async (
accessToken: string,
params: AccessGroupCreateParams,
): Promise<AccessGroupResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/v1/access_group`;
const response = await fetch(url, {
method: "POST",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
};
// ── Hook ─────────────────────────────────────────────────────────────────────
export const useCreateAccessGroup = () => {
const { accessToken } = useAuthorized();
const queryClient = useQueryClient();
return useMutation<AccessGroupResponse, Error, AccessGroupCreateParams>({
mutationFn: async (params) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return createAccessGroup(accessToken, params);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: accessGroupKeys.all });
},
});
};

View file

@ -0,0 +1,55 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { accessGroupKeys } from "./useAccessGroups";
// ── Fetch function ───────────────────────────────────────────────────────────
const deleteAccessGroup = async (
accessToken: string,
accessGroupId: string,
): Promise<void> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`;
const response = await fetch(url, {
method: "DELETE",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
// 204 No Content — nothing to parse
};
// ── Hook ─────────────────────────────────────────────────────────────────────
export const useDeleteAccessGroup = () => {
const { accessToken } = useAuthorized();
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: async (accessGroupId) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return deleteAccessGroup(accessToken, accessGroupId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: accessGroupKeys.all });
},
});
};

View file

@ -0,0 +1,77 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups";
// ── Types ────────────────────────────────────────────────────────────────────
export interface AccessGroupUpdateParams {
access_group_name?: string;
description?: string | null;
access_model_ids?: string[];
access_mcp_server_ids?: string[];
access_agent_ids?: string[];
assigned_team_ids?: string[];
assigned_key_ids?: string[];
}
export interface EditAccessGroupVariables {
accessGroupId: string;
params: AccessGroupUpdateParams;
}
// ── Fetch function ───────────────────────────────────────────────────────────
const updateAccessGroup = async (
accessToken: string,
accessGroupId: string,
params: AccessGroupUpdateParams,
): Promise<AccessGroupResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`;
const response = await fetch(url, {
method: "PUT",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
};
// ── Hook ─────────────────────────────────────────────────────────────────────
export const useEditAccessGroup = () => {
const { accessToken } = useAuthorized();
const queryClient = useQueryClient();
return useMutation<AccessGroupResponse, Error, EditAccessGroupVariables>({
mutationFn: async ({ accessGroupId, params }) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return updateAccessGroup(accessToken, accessGroupId, params);
},
onSuccess: (_data, { accessGroupId }) => {
queryClient.invalidateQueries({ queryKey: accessGroupKeys.all });
queryClient.invalidateQueries({
queryKey: accessGroupKeys.detail(accessGroupId),
});
},
});
};

View file

@ -35,6 +35,7 @@ import TransformRequestPanel from "@/components/transform_request";
import UIThemeSettings from "@/components/ui_theme_settings";
import Usage from "@/components/usage";
import UserDashboard from "@/components/user_dashboard";
import { AccessGroupsPage } from "@/components/AccessGroups/AccessGroupsPage";
import VectorStoreManagement from "@/components/vector_store_management";
import SpendLogsTable from "@/components/view_logs";
import ViewUserDashboard from "@/components/view_users";
@ -542,6 +543,8 @@ function CreateKeyPageContent() {
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "claude-code-plugins" ? (
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "access-groups" ? (
<AccessGroupsPage />
) : page == "vector-stores" ? (
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "new_usage" ? (

View file

@ -0,0 +1,384 @@
import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails";
import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails");
vi.mock("./AccessGroupsModal/AccessGroupEditModal", () => ({
AccessGroupEditModal: ({
visible,
onCancel,
}: {
visible: boolean;
onCancel: () => void;
}) =>
visible ? (
<div role="dialog" aria-label="Edit Access Group">
<button onClick={onCancel}>Close Modal</button>
</div>
) : null,
}));
const mockUseAccessGroupDetails = vi.mocked(useAccessGroupDetails);
const baseMockReturnValue = {
data: undefined,
isLoading: false,
isError: false,
error: null,
isFetching: false,
isPending: false,
isSuccess: true,
status: "success" as const,
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isRefetching: false,
isLoadingError: false,
isPaused: false,
isPlaceholderData: false,
isRefetchError: false,
isStale: false,
fetchStatus: "idle" as const,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useAccessGroupDetails>;
const createMockAccessGroup = (
overrides: Partial<AccessGroupResponse> = {}
): AccessGroupResponse => ({
access_group_id: "ag-1",
access_group_name: "Test Group",
description: "A test access group",
access_model_ids: ["model-1", "model-2"],
access_mcp_server_ids: ["mcp-1"],
access_agent_ids: ["agent-1"],
assigned_team_ids: ["team-1"],
assigned_key_ids: ["key-1", "key-2"],
created_at: "2025-01-01T00:00:00Z",
created_by: null,
updated_at: "2025-01-02T00:00:00Z",
updated_by: null,
...overrides,
});
describe("AccessGroupDetail", () => {
const mockOnBack = vi.fn();
const accessGroupId = "ag-1";
beforeEach(() => {
vi.clearAllMocks();
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup(),
} as ReturnType<typeof useAccessGroupDetails>);
});
it("should render the component", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByRole("heading", { name: "Test Group" })).toBeInTheDocument();
});
it("should not show access group content when loading", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: undefined,
isLoading: true,
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.queryByRole("heading", { name: "Test Group" })).not.toBeInTheDocument();
});
it("should show empty state when access group is not found", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: undefined,
isLoading: false,
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByText("Access group not found")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("should call onBack when back button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
const buttons = screen.getAllByRole("button");
const backButton = buttons.find((btn) => !btn.textContent?.includes("Edit"));
await user.click(backButton!);
expect(mockOnBack).toHaveBeenCalledTimes(1);
});
it("should display access group name and ID", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByRole("heading", { name: "Test Group" })).toBeInTheDocument();
expect(screen.getByText(/ID:/)).toBeInTheDocument();
});
it("should display description in Group Details", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByText("Group Details")).toBeInTheDocument();
expect(screen.getByText("A test access group")).toBeInTheDocument();
});
it("should display em dash when description is empty", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ description: null }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByText("—")).toBeInTheDocument();
});
it("should open edit modal when Edit Access Group button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.queryByRole("dialog", { name: "Edit Access Group" })).not.toBeInTheDocument();
const editButton = screen.getByRole("button", { name: /Edit Access Group/i });
await user.click(editButton);
expect(screen.getByRole("dialog", { name: "Edit Access Group" })).toBeInTheDocument();
});
it("should close edit modal when Close Modal is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
await user.click(screen.getByRole("button", { name: /Edit Access Group/i }));
expect(screen.getByRole("dialog", { name: "Edit Access Group" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Close Modal" }));
expect(screen.queryByRole("dialog", { name: "Edit Access Group" })).not.toBeInTheDocument();
});
it("should display attached keys", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByText("Attached Keys")).toBeInTheDocument();
expect(screen.getByText("key-1")).toBeInTheDocument();
expect(screen.getByText("key-2")).toBeInTheDocument();
});
it("should display attached teams", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByText("Attached Teams")).toBeInTheDocument();
expect(screen.getByText("team-1")).toBeInTheDocument();
});
it("should show View All button for keys when more than 5", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({
assigned_key_ids: ["k1", "k2", "k3", "k4", "k5", "k6"],
}),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument();
});
it("should toggle between View All and Show Less for keys", async () => {
const user = userEvent.setup();
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({
assigned_key_ids: ["k1", "k2", "k3", "k4", "k5", "k6"],
}),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
await user.click(screen.getByRole("button", { name: "View All (6)" }));
expect(screen.getByRole("button", { name: "Show Less" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Show Less" }));
expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument();
});
it("should show View All button for teams when more than 5", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({
assigned_team_ids: ["t1", "t2", "t3", "t4", "t5", "t6"],
}),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument();
});
it("should show empty state when no keys attached", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ assigned_key_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByText("No keys attached")).toBeInTheDocument();
});
it("should show empty state when no teams attached", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ assigned_team_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByText("No teams attached")).toBeInTheDocument();
});
it("should display Models tab with model IDs", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByRole("tab", { name: /Models/i })).toBeInTheDocument();
expect(screen.getByText("model-1")).toBeInTheDocument();
expect(screen.getByText("model-2")).toBeInTheDocument();
});
it("should display MCP Servers tab with server IDs", async () => {
const user = userEvent.setup();
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
const mcpTab = screen.getByRole("tab", { name: /MCP Servers/i });
expect(mcpTab).toBeInTheDocument();
await user.click(mcpTab);
expect(screen.getByText("mcp-1")).toBeInTheDocument();
});
it("should display Agents tab with agent IDs", async () => {
const user = userEvent.setup();
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
const agentsTab = screen.getByRole("tab", { name: /Agents/i });
expect(agentsTab).toBeInTheDocument();
await user.click(agentsTab);
expect(screen.getByText("agent-1")).toBeInTheDocument();
});
it("should show empty state in Models tab when no models assigned", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ access_model_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByText("No models assigned to this group")).toBeInTheDocument();
});
it("should show empty state in MCP Servers tab when none assigned", async () => {
const user = userEvent.setup();
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ access_mcp_server_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
await user.click(screen.getByRole("tab", { name: /MCP Servers/i }));
expect(screen.getByText("No MCP servers assigned to this group")).toBeInTheDocument();
});
it("should show empty state in Agents tab when none assigned", async () => {
const user = userEvent.setup();
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ access_agent_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
await user.click(screen.getByRole("tab", { name: /Agents/i }));
expect(screen.getByText("No agents assigned to this group")).toBeInTheDocument();
});
it("should truncate long key IDs with ellipsis", () => {
const longKeyId = "a".repeat(25);
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ assigned_key_ids: [longKeyId] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByText(/a{10}\.\.\.a{6}/)).toBeInTheDocument();
});
it("should display created and last updated timestamps", () => {
renderWithProviders(
<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />
);
expect(screen.getByText("Created")).toBeInTheDocument();
expect(screen.getByText("Last Updated")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,345 @@
import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails";
import {
Button,
Card,
Col,
Descriptions,
Empty,
Flex,
Layout,
List,
Row,
Spin,
Tabs,
Tag,
theme,
Typography
} from "antd";
import {
ArrowLeftIcon,
BotIcon,
EditIcon,
KeyIcon,
LayersIcon,
ServerIcon,
UsersIcon,
} from "lucide-react";
import { useState } from "react";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal";
const { Title, Text } = Typography;
const { Content } = Layout;
interface AccessGroupDetailProps {
accessGroupId: string;
onBack: () => void;
}
export function AccessGroupDetail({
accessGroupId,
onBack,
}: AccessGroupDetailProps) {
const { data: accessGroup, isLoading } =
useAccessGroupDetails(accessGroupId);
const { token } = theme.useToken();
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [showAllKeys, setShowAllKeys] = useState(false);
const [showAllTeams, setShowAllTeams] = useState(false);
const MAX_PREVIEW = 5;
if (isLoading) {
return (
<Content
style={{
padding: token.paddingLG,
paddingInline: token.paddingLG * 2,
}}
>
<Flex justify="center" align="center" style={{ minHeight: 300 }}>
<Spin size="large" />
</Flex>
</Content>
);
}
if (!accessGroup) {
return (
<Content
style={{
padding: token.paddingLG,
paddingInline: token.paddingLG * 2,
}}
>
<Button
icon={<ArrowLeftIcon size={16} />}
onClick={onBack}
type="text"
style={{ marginBottom: 16 }}
/>
<Empty description="Access group not found" />
</Content>
);
}
const modelIds = accessGroup.access_model_ids ?? [];
const mcpServerIds = accessGroup.access_mcp_server_ids ?? [];
const agentIds = accessGroup.access_agent_ids ?? [];
const keyIds = accessGroup.assigned_key_ids ?? [];
const teamIds = accessGroup.assigned_team_ids ?? [];
const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW);
const displayedTeams = showAllTeams
? teamIds
: teamIds.slice(0, MAX_PREVIEW);
const handleEdit = () => {
setIsEditModalVisible(true);
};
const tabItems = [
{
key: "models",
label: (
<Flex align="center" gap={8}>
<LayersIcon size={16} />
Models
<Tag style={{ marginInlineEnd: 0 }}>{modelIds.length}</Tag>
</Flex>
),
children:
modelIds.length > 0 ? (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
dataSource={modelIds}
renderItem={(id) => (
<List.Item>
<Card size="small">
<Text code>{id}</Text>
</Card>
</List.Item>
)}
/>
) : (
<Empty description="No models assigned to this group" />
),
},
{
key: "mcp",
label: (
<Flex align="center" gap={8}>
<ServerIcon size={16} />
MCP Servers
<Tag>{mcpServerIds.length}</Tag>
</Flex>
),
children:
mcpServerIds.length > 0 ? (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
dataSource={mcpServerIds}
renderItem={(id) => (
<List.Item>
<Card size="small">
<Text code>{id}</Text>
</Card>
</List.Item>
)}
/>
) : (
<Empty description="No MCP servers assigned to this group" />
),
},
{
key: "agents",
label: (
<Flex align="center" gap={8}>
<BotIcon size={16} />
Agents
<Tag>{agentIds.length}</Tag>
</Flex>
),
children:
agentIds.length > 0 ? (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
dataSource={agentIds}
renderItem={(id) => (
<List.Item>
<Card size="small">
<Text code>{id}</Text>
</Card>
</List.Item>
)}
/>
) : (
<Empty description="No agents assigned to this group" />
),
},
];
return (
<Content
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Button
icon={<ArrowLeftIcon size={16} />}
onClick={onBack}
type="text"
/>
<div>
<Title level={2} style={{ margin: 0 }}>
{accessGroup.access_group_name}
</Title>
<Text type="secondary">
ID: <Text copyable>{accessGroup.access_group_id}</Text>
</Text>
</div>
</div>
<Button
type="primary"
icon={<EditIcon size={16} />}
onClick={handleEdit}
>
Edit Access Group
</Button>
</div>
{/* Group Details */}
<Row style={{ marginBottom: 24 }}>
<Card>
<Descriptions title="Group Details" column={1}>
<Descriptions.Item label="Description">
{accessGroup.description || "—"}
</Descriptions.Item>
<Descriptions.Item label="Created">
{new Date(accessGroup.created_at).toLocaleString()}
{accessGroup.created_by && (
<Text>
&nbsp;{"by"}&nbsp;
<DefaultProxyAdminTag userId={accessGroup.created_by} />
</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="Last Updated">
{new Date(accessGroup.updated_at).toLocaleString()}
{accessGroup.updated_by && (
<Text>
&nbsp;{"by"}&nbsp;
<DefaultProxyAdminTag userId={accessGroup.updated_by} />
</Text>
)}
</Descriptions.Item>
</Descriptions>
</Card>
</Row>
{/* Attached Keys & Teams */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<KeyIcon size={16} />
Attached Keys
<Tag>{keyIds.length}</Tag>
</Flex>
}
extra={
keyIds.length > MAX_PREVIEW ? (
<Button
type="link"
onClick={() => setShowAllKeys(!showAllKeys)}
>
{showAllKeys ? "Show Less" : `View All (${keyIds.length})`}
</Button>
) : null
}
>
{keyIds.length > 0 ? (
<Flex wrap="wrap" gap={8}>
{displayedKeys.map((id) => (
<Tag key={id}>
<Text code style={{ fontSize: 12 }}>
{id.length > 20
? `${id.slice(0, 10)}...${id.slice(-6)}`
: id}
</Text>
</Tag>
))}
</Flex>
) : (
<Empty
description="No keys attached"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<UsersIcon size={16} />
Attached Teams
<Tag>{teamIds.length}</Tag>
</Flex>
}
extra={
teamIds.length > MAX_PREVIEW ? (
<Button
type="link"
onClick={() => setShowAllTeams(!showAllTeams)}
>
{showAllTeams
? "Show Less"
: `View All (${teamIds.length})`}
</Button>
) : null
}
>
{teamIds.length > 0 ? (
<Flex wrap="wrap" gap={8}>
{displayedTeams.map((id) => (
<Tag key={id}>
<Text code style={{ fontSize: 12 }}>
{id}
</Text>
</Tag>
))}
</Flex>
) : (
<Empty
description="No teams attached"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Card>
</Col>
</Row>
{/* Resources Tabs */}
<Card>
<Tabs defaultActiveKey="models" items={tabItems} />
</Card>
{/* Edit Modal */}
<AccessGroupEditModal
visible={isEditModalVisible}
accessGroup={accessGroup}
onCancel={() => setIsEditModalVisible(false)}
/>
</Content>
);
}

View file

@ -0,0 +1,159 @@
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import type { FormInstance } from "antd";
import { Form, Input, Select, Space, Tabs } from "antd";
import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react";
const { TextArea } = Input;
export interface AccessGroupFormValues {
name: string;
description: string;
modelIds: string[];
mcpServerIds: string[];
agentIds: string[];
}
interface AccessGroupBaseFormProps {
form: FormInstance<AccessGroupFormValues>;
isNameDisabled?: boolean;
}
export function AccessGroupBaseForm({
form,
isNameDisabled = false,
}: AccessGroupBaseFormProps) {
const { data: agentsData } = useAgents();
const { data: mcpServersData } = useMCPServers();
const agents = agentsData?.agents ?? [];
const mcpServers = mcpServersData ?? [];
const items = [
{
key: "1",
label: (
<Space align="center" size={4}>
<InfoIcon size={16} />
General Info
</Space>
),
children: (
<div style={{ paddingTop: 16 }}>
<Form.Item
name="name"
label="Group Name"
rules={[
{
required: true,
message: "Please enter the access group name",
},
]}
>
<Input
placeholder="e.g. Engineering Team"
disabled={isNameDisabled}
/>
</Form.Item>
<Form.Item
name="description"
label="Description"
>
<TextArea
rows={4}
placeholder="Describe the purpose of this access group..."
/>
</Form.Item>
</div>
),
},
{
key: "2",
label: (
<Space align="center" size={4}>
<LayersIcon size={16} />
Models
</Space>
),
children: (
<div style={{ paddingTop: 16 }}>
<Form.Item name="modelIds" label="Allowed Models">
<ModelSelect
context="global"
value={form.getFieldValue("modelIds") ?? []}
onChange={(values) => form.setFieldsValue({ modelIds: values })}
style={{ width: "100%" }}
/>
</Form.Item>
</div>
),
},
{
key: "3",
label: (
<Space align="center" size={4}>
<ServerIcon size={16} />
MCP Servers
</Space>
),
children: (
<div style={{ paddingTop: 16 }}>
<Form.Item name="mcpServerIds" label="Allowed MCP Servers">
<Select
mode="multiple"
placeholder="Select MCP servers"
style={{ width: "100%" }}
optionFilterProp="label"
allowClear
options={mcpServers.map((server) => ({
label: server.server_name ?? server.server_id,
value: server.server_id,
}))}
/>
</Form.Item>
</div>
),
},
{
key: "4",
label: (
<Space align="center" size={4}>
<BotIcon size={16} />
Agents
</Space>
),
children: (
<div style={{ paddingTop: 16 }}>
<Form.Item name="agentIds" label="Allowed Agents">
<Select
mode="multiple"
placeholder="Select agents"
style={{ width: "100%" }}
optionFilterProp="label"
allowClear
options={agents.map((agent) => ({
label: agent.agent_name,
value: agent.agent_id,
}))}
/>
</Form.Item>
</div>
),
},
];
return (
<Form
form={form}
layout="vertical"
name="access_group_form"
initialValues={{
modelIds: [],
mcpServerIds: [],
agentIds: [],
}}
>
<Tabs defaultActiveKey="1" items={items} />
</Form>
);
}

View file

@ -0,0 +1,67 @@
import React from "react";
import { Modal, Form, message } from "antd";
import {
AccessGroupBaseForm,
AccessGroupFormValues,
} from "./AccessGroupBaseForm";
import {
useCreateAccessGroup,
AccessGroupCreateParams,
} from "@/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup";
interface AccessGroupCreateModalProps {
visible: boolean;
onCancel: () => void;
onSuccess?: () => void;
}
export function AccessGroupCreateModal({
visible,
onCancel,
onSuccess,
}: AccessGroupCreateModalProps) {
const [form] = Form.useForm<AccessGroupFormValues>();
const createMutation = useCreateAccessGroup();
const handleOk = () => {
form
.validateFields()
.then((values) => {
const params: AccessGroupCreateParams = {
access_group_name: values.name,
description: values.description,
access_model_ids: values.modelIds,
access_mcp_server_ids: values.mcpServerIds,
access_agent_ids: values.agentIds,
};
createMutation.mutate(params, {
onSuccess: () => {
message.success("Access group created successfully");
form.resetFields();
onSuccess?.();
onCancel();
},
});
})
.catch((info) => {
console.log("Validate Failed:", info);
});
};
return (
<Modal
title="Create Access Group"
open={visible}
onOk={handleOk}
onCancel={onCancel}
width={700}
okText="Create Group"
cancelText="Cancel"
confirmLoading={createMutation.isPending}
destroyOnClose
>
<AccessGroupBaseForm form={form} />
</Modal>
);
}

View file

@ -0,0 +1,85 @@
import React, { useEffect } from "react";
import { Modal, Form, message } from "antd";
import {
AccessGroupBaseForm,
AccessGroupFormValues,
} from "./AccessGroupBaseForm";
import {
useEditAccessGroup,
AccessGroupUpdateParams,
} from "@/app/(dashboard)/hooks/accessGroups/useEditAccessGroup";
import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
interface AccessGroupEditModalProps {
visible: boolean;
accessGroup: AccessGroupResponse;
onCancel: () => void;
onSuccess?: () => void;
}
export function AccessGroupEditModal({
visible,
accessGroup,
onCancel,
onSuccess,
}: AccessGroupEditModalProps) {
const [form] = Form.useForm<AccessGroupFormValues>();
const editMutation = useEditAccessGroup();
// Populate the form with initial values whenever the modal opens or the data changes
useEffect(() => {
if (visible && accessGroup) {
form.setFieldsValue({
name: accessGroup.access_group_name,
description: accessGroup.description ?? "",
modelIds: accessGroup.access_model_ids ?? [],
mcpServerIds: accessGroup.access_mcp_server_ids ?? [],
agentIds: accessGroup.access_agent_ids ?? [],
});
}
}, [visible, accessGroup, form]);
const handleOk = () => {
form
.validateFields()
.then((values) => {
const params: AccessGroupUpdateParams = {
access_group_name: values.name,
description: values.description,
access_model_ids: values.modelIds,
access_mcp_server_ids: values.mcpServerIds,
access_agent_ids: values.agentIds,
};
editMutation.mutate(
{ accessGroupId: accessGroup.access_group_id, params },
{
onSuccess: () => {
message.success("Access group updated successfully");
onSuccess?.();
onCancel();
},
},
);
})
.catch((info) => {
console.log("Validate Failed:", info);
});
};
return (
<Modal
title="Edit Access Group"
open={visible}
onOk={handleOk}
onCancel={onCancel}
width={700}
okText="Save Changes"
cancelText="Cancel"
confirmLoading={editMutation.isPending}
destroyOnHidden
>
<AccessGroupBaseForm form={form} />
</Modal>
);
}

View file

@ -0,0 +1,321 @@
import { renderWithProviders, screen, within } from "@/../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AccessGroupsPage } from "./AccessGroupsPage";
import type { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
const mockAccessGroups: AccessGroupResponse[] = [
{
access_group_id: "ag-1",
access_group_name: "Admin Group",
description: "Administrators with full access",
access_model_ids: ["m1", "m2"],
access_mcp_server_ids: ["s1"],
access_agent_ids: ["a1"],
assigned_team_ids: [],
assigned_key_ids: [],
created_at: "2024-01-15T10:00:00Z",
created_by: "user-1",
updated_at: "2024-01-20T12:00:00Z",
updated_by: "user-1",
},
{
access_group_id: "ag-2",
access_group_name: "Read Only",
description: "Read-only access to models",
access_model_ids: ["m1"],
access_mcp_server_ids: [],
access_agent_ids: [],
assigned_team_ids: [],
assigned_key_ids: [],
created_at: "2024-01-10T09:00:00Z",
created_by: null,
updated_at: "2024-01-12T11:00:00Z",
updated_by: null,
},
];
const mockUseAccessGroups = vi.fn();
const mockUseDeleteAccessGroup = vi.fn();
const mockMutate = vi.fn();
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
useAccessGroups: () => mockUseAccessGroups(),
}));
vi.mock("@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup", () => ({
useDeleteAccessGroup: () => mockUseDeleteAccessGroup(),
}));
vi.mock("./AccessGroupsDetailsPage", () => ({
AccessGroupDetail: ({
accessGroupId,
onBack,
}: {
accessGroupId: string;
onBack: () => void;
}) => (
<div data-testid="access-group-detail">
<span>Detail for {accessGroupId}</span>
<button onClick={onBack}>Back</button>
</div>
),
}));
vi.mock("./AccessGroupsModal/AccessGroupCreateModal", () => ({
AccessGroupCreateModal: ({
visible,
onCancel,
}: {
visible: boolean;
onCancel: () => void;
}) =>
visible ? (
<div data-testid="create-access-group-modal">
<button onClick={onCancel}>Cancel</button>
</div>
) : null,
}));
vi.mock("../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton", () => ({
default: ({
variant,
tooltipText,
onClick,
}: {
variant: string;
tooltipText: string;
onClick: () => void;
}) => (
<button
data-testid={`action-button-${variant.toLowerCase()}`}
aria-label={tooltipText}
onClick={onClick}
>
{variant}
</button>
),
}));
describe("AccessGroupsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAccessGroups.mockReturnValue({
data: mockAccessGroups,
isLoading: false,
});
mockUseDeleteAccessGroup.mockReturnValue({
mutate: mockMutate,
isPending: false,
});
});
it("should render", () => {
renderWithProviders(<AccessGroupsPage />);
expect(screen.getByRole("heading", { name: "Access Groups" })).toBeInTheDocument();
});
it("should display page title and subtitle", () => {
renderWithProviders(<AccessGroupsPage />);
expect(screen.getByRole("heading", { name: "Access Groups" })).toBeInTheDocument();
expect(
screen.getByText("Manage resource permissions for your organization"),
).toBeInTheDocument();
});
it("should display Create Access Group button", () => {
renderWithProviders(<AccessGroupsPage />);
expect(
screen.getByRole("button", { name: /create access group/i }),
).toBeInTheDocument();
});
it("should display search input with placeholder", () => {
renderWithProviders(<AccessGroupsPage />);
expect(
screen.getByPlaceholderText("Search groups by name, ID, or description..."),
).toBeInTheDocument();
});
it("should display access groups in table", () => {
renderWithProviders(<AccessGroupsPage />);
expect(screen.getByText("ag-1")).toBeInTheDocument();
expect(screen.getByText("Admin Group")).toBeInTheDocument();
expect(screen.getByText("ag-2")).toBeInTheDocument();
expect(screen.getByText("Read Only")).toBeInTheDocument();
});
it("should display resource counts for each group", () => {
renderWithProviders(<AccessGroupsPage />);
const table = screen.getByRole("table");
expect(table).toHaveTextContent("2");
expect(table).toHaveTextContent("1");
});
it("should filter groups by search text matching name", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
const searchInput = screen.getByPlaceholderText(
"Search groups by name, ID, or description...",
);
await user.type(searchInput, "Admin");
expect(screen.getByText("Admin Group")).toBeInTheDocument();
expect(screen.queryByText("Read Only")).not.toBeInTheDocument();
});
it("should filter groups by search text matching ID", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
const searchInput = screen.getByPlaceholderText(
"Search groups by name, ID, or description...",
);
await user.type(searchInput, "ag-2");
expect(screen.getByText("Read Only")).toBeInTheDocument();
expect(screen.queryByText("Admin Group")).not.toBeInTheDocument();
});
it("should filter groups by search text matching description", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
const searchInput = screen.getByPlaceholderText(
"Search groups by name, ID, or description...",
);
await user.type(searchInput, "read-only");
expect(screen.getByText("Read Only")).toBeInTheDocument();
expect(screen.queryByText("Admin Group")).not.toBeInTheDocument();
});
it("should reset to first page when search text changes", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
const searchInput = screen.getByPlaceholderText(
"Search groups by name, ID, or description...",
);
await user.type(searchInput, "Admin");
const pagination = screen.getByText(/groups/);
expect(pagination).toHaveTextContent("1 groups");
});
it("should open create modal when Create Access Group button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
await user.click(screen.getByRole("button", { name: /create access group/i }));
expect(screen.getByTestId("create-access-group-modal")).toBeInTheDocument();
});
it("should close create modal when cancel is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
await user.click(screen.getByRole("button", { name: /create access group/i }));
expect(screen.getByTestId("create-access-group-modal")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByTestId("create-access-group-modal")).not.toBeInTheDocument();
});
it("should navigate to detail view when group ID is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
await user.click(screen.getByText("ag-1"));
expect(screen.getByTestId("access-group-detail")).toBeInTheDocument();
expect(screen.getByText("Detail for ag-1")).toBeInTheDocument();
});
it("should return to list view when Back is clicked from detail", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
await user.click(screen.getByText("ag-1"));
expect(screen.getByTestId("access-group-detail")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Back" }));
expect(screen.queryByTestId("access-group-detail")).not.toBeInTheDocument();
expect(screen.getByText("Admin Group")).toBeInTheDocument();
});
it("should open delete modal when delete action is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
const deleteButtons = screen.getAllByRole("button", {
name: "Delete access group",
});
await user.click(deleteButtons[0]);
const dialog = screen.getByRole("dialog", { name: "Delete Access Group" });
expect(dialog).toBeInTheDocument();
expect(
within(dialog).getByText(
"Are you sure you want to delete this access group? This action cannot be undone.",
),
).toBeInTheDocument();
expect(within(dialog).getByText("Access Group Information")).toBeInTheDocument();
expect(within(dialog).getByText("ag-1")).toBeInTheDocument();
expect(within(dialog).getByText("Admin Group")).toBeInTheDocument();
});
it("should close delete modal when cancel is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
const deleteButtons = screen.getAllByRole("button", {
name: "Delete access group",
});
await user.click(deleteButtons[0]);
const dialog = screen.getByRole("dialog", { name: "Delete Access Group" });
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("dialog", { name: "Delete Access Group" })).not.toBeInTheDocument();
});
it("should call delete mutation when delete is confirmed", async () => {
const user = userEvent.setup();
mockMutate.mockImplementation((_id: string, opts?: { onSuccess?: () => void }) => {
opts?.onSuccess?.();
});
renderWithProviders(<AccessGroupsPage />);
const deleteButtons = screen.getAllByRole("button", {
name: "Delete access group",
});
await user.click(deleteButtons[0]);
const dialog = screen.getByRole("dialog", { name: "Delete Access Group" });
const deleteConfirmButton = within(dialog).getByRole("button", { name: /delete/i });
await user.click(deleteConfirmButton);
expect(mockMutate).toHaveBeenCalledWith("ag-1", expect.any(Object));
});
it("should display pagination with total count", () => {
renderWithProviders(<AccessGroupsPage />);
expect(screen.getByText("2 groups")).toBeInTheDocument();
});
it("should show table headers for ID, Name, Resources, and Actions", () => {
renderWithProviders(<AccessGroupsPage />);
expect(screen.getByRole("columnheader", { name: /ID/i })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /Name/i })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /Resources/i })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /Actions/i })).toBeInTheDocument();
});
it("should display loading state when data is loading", () => {
mockUseAccessGroups.mockReturnValue({
data: undefined,
isLoading: true,
});
renderWithProviders(<AccessGroupsPage />);
const table = screen.getByRole("table");
expect(table).toBeInTheDocument();
});
it("should display empty state when no groups match search", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupsPage />);
const searchInput = screen.getByPlaceholderText(
"Search groups by name, ID, or description...",
);
await user.type(searchInput, "nonexistent-group-xyz");
expect(screen.getByRole("table")).toBeInTheDocument();
});
it("should display empty data when useAccessGroups returns empty array", () => {
mockUseAccessGroups.mockReturnValue({
data: [],
isLoading: false,
});
renderWithProviders(<AccessGroupsPage />);
expect(screen.getByRole("table")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,401 @@
import {
AccessGroupResponse,
useAccessGroups,
} from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup";
import { PlusOutlined } from "@ant-design/icons";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
Row,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
Button,
Card,
Flex,
Input,
Layout,
Pagination,
Space,
Table,
Tag,
theme,
Tooltip,
Typography,
} from "antd";
import {
BotIcon,
LayersIcon,
SearchIcon,
ServerIcon
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import {
SortState,
TableHeaderSortDropdown,
} from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal";
import { AccessGroup } from "./types";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData, TValue> {
responsive?: string[];
}
}
const { Title, Text } = Typography;
const { Content } = Layout;
function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
return {
id: r.access_group_id,
name: r.access_group_name,
description: r.description ?? "",
modelIds: r.access_model_ids,
mcpServerIds: r.access_mcp_server_ids,
agentIds: r.access_agent_ids,
keyIds: r.assigned_key_ids,
teamIds: r.assigned_team_ids,
createdAt: r.created_at,
createdBy: r.created_by ?? "",
updatedAt: r.updated_at,
updatedBy: r.updated_by ?? "",
};
}
function buildAntdColumns(
table: ReturnType<typeof useReactTable<AccessGroup>>,
rowLookup: Map<string, Row<AccessGroup>>,
onSortingChange: (s: SortingState) => void,
) {
const headers = table.getHeaderGroups()[0]?.headers ?? [];
return headers.map((header) => {
const canSort = header.column.getCanSort();
const isSorted = header.column.getIsSorted();
const meta = header.column.columnDef.meta as
| { responsive?: string[] }
| undefined;
const col: Record<string, unknown> = {
title: (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{canSort && (
<TableHeaderSortDropdown
sortState={isSorted === false ? false : (isSorted as SortState)}
onSortChange={(newState) => {
if (newState === false) {
onSortingChange([]);
} else {
onSortingChange([
{ id: header.column.id, desc: newState === "desc" },
]);
}
}}
columnId={header.column.id}
/>
)}
</div>
),
key: header.id,
width: header.column.columnDef.size,
render: (_: unknown, record: AccessGroup) => {
const row = rowLookup.get(record.id);
if (!row) return null;
const cell = row
.getVisibleCells()
.find((c) => c.column.id === header.id);
if (!cell) return null;
return flexRender(cell.column.columnDef.cell, cell.getContext());
},
};
if (meta?.responsive) {
col.responsive = meta.responsive;
}
return col;
});
}
export function AccessGroupsPage() {
const { token } = theme.useToken();
const { data: groupsData, isLoading } = useAccessGroups();
const groups = useMemo(
() => (groupsData ?? []).map(mapResponseToAccessGroup),
[groupsData],
);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [sorting, setSorting] = useState<SortingState>([]);
const [groupToDelete, setGroupToDelete] = useState<AccessGroup | null>(null);
const deleteMutation = useDeleteAccessGroup();
const pageSize = 10;
useEffect(() => {
setCurrentPage(1);
}, [searchText]);
// ---------- filtered data ----------
const filteredGroups = useMemo(
() =>
groups.filter(
(group) =>
group.name.toLowerCase().includes(searchText.toLowerCase()) ||
group.id.toLowerCase().includes(searchText.toLowerCase()) ||
group.description.toLowerCase().includes(searchText.toLowerCase()),
),
[groups, searchText],
);
// ---------- TanStack column definitions ----------
const columnDefs = useMemo<ColumnDef<AccessGroup>[]>(
() => [
{
id: "id",
accessorKey: "id",
header: () => <span>ID</span>,
enableSorting: false,
size: 170,
cell: ({ row }) => {
const record = row.original;
return (
<Tooltip title={record.id}>
<Text
ellipsis
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
style={{ fontSize: 14, padding: "1px 8px" }}
onClick={() => setSelectedGroupId(record.id)}
>
{record.id}
</Text>
</Tooltip>
);
},
},
{
id: "name",
accessorKey: "name",
header: () => <span>Name</span>,
enableSorting: true,
cell: ({ getValue }) => getValue() as string,
},
{
id: "resources",
header: () => <span>Resources</span>,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<Flex gap={12} align="center">
<Tooltip title={`${record.modelIds.length} Models`}>
<Tag color="blue" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
<Flex align="center" gap={6}>
<LayersIcon size={14} />
{record.modelIds.length}
</Flex>
</Tag>
</Tooltip>
<Tooltip title={`${record.mcpServerIds.length} MCP Servers`}>
<Tag color="cyan" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
<Flex align="center" gap={6}>
<ServerIcon size={14} />
{record.mcpServerIds.length}
</Flex>
</Tag>
</Tooltip>
<Tooltip title={`${record.agentIds.length} Agents`}>
<Tag color="purple" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
<Flex align="center" gap={6}>
<BotIcon size={14} />
{record.agentIds.length}
</Flex>
</Tag>
</Tooltip>
</Flex>
);
},
},
{
id: "createdAt",
accessorKey: "createdAt",
header: () => <span>Created</span>,
enableSorting: true,
sortingFn: "datetime",
cell: ({ getValue }) =>
new Date(getValue() as string).toLocaleDateString(),
meta: { responsive: ["lg"] },
},
{
id: "updatedAt",
accessorKey: "updatedAt",
header: () => <span>Updated</span>,
enableSorting: false,
cell: ({ getValue }) =>
new Date(getValue() as string).toLocaleDateString(),
meta: { responsive: ["xl"] },
},
{
id: "actions",
header: () => <span>Actions</span>,
enableSorting: false,
cell: ({ row }) => (
<Space>
<TableIconActionButton
variant="Delete"
tooltipText="Delete access group"
onClick={() => setGroupToDelete(row.original)}
/>
</Space>
),
},
],
// setSelectedGroup is stable (useState setter)
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// ---------- TanStack table instance ----------
const table = useReactTable<AccessGroup>({
data: filteredGroups,
columns: columnDefs,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
});
// All sorted rows from TanStack
const sortedRows = table.getRowModel().rows;
// Paginated slice
const paginatedRows = sortedRows.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
// Map for O(1) lookup by record id in antd render()
const rowLookup = useMemo(
() => new Map(paginatedRows.map((row) => [row.original.id, row])),
[paginatedRows],
);
// Convert TanStack headers → antd columns
const antdColumns = buildAntdColumns(table, rowLookup, setSorting);
// antd dataSource (just the originals for the current page)
const dataSource = paginatedRows.map((row) => row.original);
if (selectedGroupId) {
return (
<AccessGroupDetail
accessGroupId={selectedGroupId}
onBack={() => setSelectedGroupId(null)}
/>
);
}
return (
<Content
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
>
<Flex
justify="space-between"
align="center"
style={{ marginBottom: 16 }}
>
<Space direction="vertical" size={0}>
<Title level={2} style={{ margin: 0 }}>
Access Groups
</Title>
<Text type="secondary">
Manage resource permissions for your organization
</Text>
</Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsCreateModalVisible(true)}
>
Create Access Group
</Button>
</Flex>
<Card styles={{ body: { padding: 0 } }}>
<Flex
justify="space-between"
align="center"
style={{
padding: "12px 16px",
}}
>
<Input
prefix={<SearchIcon size={16} />}
placeholder="Search groups by name, ID, or description..."
style={{ maxWidth: 400 }}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
/>
<Pagination
current={currentPage}
total={sortedRows.length}
pageSize={pageSize}
onChange={(page) => setCurrentPage(page)}
size="small"
showTotal={(total) => `${total} groups`}
showSizeChanger={false}
/>
</Flex>
<Table
columns={antdColumns}
dataSource={dataSource}
rowKey="id"
loading={isLoading}
pagination={false}
/>
</Card>
<AccessGroupCreateModal
visible={isCreateModalVisible}
onCancel={() => setIsCreateModalVisible(false)}
/>
<DeleteResourceModal
isOpen={!!groupToDelete}
title="Delete Access Group"
message="Are you sure you want to delete this access group? This action cannot be undone."
resourceInformationTitle="Access Group Information"
resourceInformation={[
{ label: "ID", value: groupToDelete?.id, code: true },
{ label: "Name", value: groupToDelete?.name },
{ label: "Description", value: groupToDelete?.description || "—" },
]}
onCancel={() => setGroupToDelete(null)}
onOk={() => {
if (!groupToDelete) return;
deleteMutation.mutate(groupToDelete.id, {
onSuccess: () => {
setGroupToDelete(null);
},
});
}}
confirmLoading={deleteMutation.isPending}
/>
</Content>
);
}

View file

@ -0,0 +1,46 @@
export interface AccessGroup {
id: string
name: string
description: string
modelIds: string[]
mcpServerIds: string[]
agentIds: string[]
keyIds: string[]
teamIds: string[]
createdAt: string
createdBy: string
updatedAt: string
updatedBy: string
}
export interface Model {
id: string
name: string
provider: string
}
export interface McpServer {
id: string
name: string
endpoint: string
}
export interface Agent {
id: string
name: string
type: string
}
export interface AccessGroupKey {
id: string
alias: string
status: string
createdAt: string
}
export interface AccessGroupTeam {
id: string
name: string
members: number
role: string
}

View file

@ -0,0 +1,24 @@
import { Tag, Typography } from "antd";
const { Text } = Typography;
const DEFAULT_USER_ID = "default_user_id";
interface DefaultProxyAdminTagProps {
userId: string | null | undefined;
}
/**
* Renders "Default Proxy Admin" as a blue Tag when the given userId is
* the well-known `default_user_id`, otherwise renders the raw value as
* plain text.
*/
export default function DefaultProxyAdminTag({
userId,
}: DefaultProxyAdminTagProps) {
if (userId === DEFAULT_USER_ID) {
return <Tag color="blue">Default Proxy Admin</Tag>;
}
return <Text>{userId}</Text>;
}

View file

@ -151,11 +151,7 @@ const menuGroups: MenuGroup[] = [
{
key: "logs",
page: "logs",
label: (
<span className="flex items-center gap-4">
Logs <NewBadge />
</span>
),
label: "Logs",
icon: <LineChartOutlined />,
},
],
@ -183,6 +179,17 @@ const menuGroups: MenuGroup[] = [
icon: <BankOutlined />,
roles: all_admin_roles,
},
{
key: "access-groups",
page: "access-groups",
label: (
<span className="flex items-center gap-2">
Access Groups <NewBadge />
</span>
),
icon: <BlockOutlined />,
roles: all_admin_roles,
},
{
key: "budgets",
page: "budgets",

View file

@ -19,6 +19,7 @@ export const pageDescriptions: Record<string, string> = {
users: "Manage internal user accounts and permissions",
teams: "Create and manage teams for access control",
organizations: "Manage organizations and their members",
"access-groups": "Manage access groups for role-based permissions",
budgets: "Set and monitor spending budgets",
api_ref: "Browse API documentation and endpoints",
"model-hub-table": "Explore available AI models and providers",

View file

@ -2,7 +2,6 @@
import { ConfigType, GeneralSettingsFieldName, useDeleteProxyConfigField, useProxyConfig } from "@/app/(dashboard)/hooks/proxyConfig/useProxyConfig";
import { StoreRequestInSpendLogsParams, useStoreRequestInSpendLogs } from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs";
import NewBadge from "@/components/common_components/NewBadge";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { parseErrorMessage } from "@/components/shared/errorUtils";
import { ClockCircleOutlined } from "@ant-design/icons";
@ -99,7 +98,7 @@ const SpendLogsSettingsModal: React.FC<SpendLogsSettingsModalProps> = ({ isVisib
return (
<Modal
title={<span className="flex gap-2"><Typography.Title level={5}>Spend Logs Settings</Typography.Title><NewBadge /></span>}
title={<Typography.Title level={5}>Spend Logs Settings</Typography.Title>}
open={isVisible}
footer={
<Space>

View file

@ -9,7 +9,6 @@ import { Row } from "@tanstack/react-table";
import { Switch, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import { Button, Tooltip } from "antd";
import { internalUserRoles } from "../../utils/roles";
import NewBadge from "../common_components/NewBadge";
import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage";
import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage";
import { fetchAllKeyAliases } from "../key_team_helpers/filter_helpers";
@ -514,11 +513,11 @@ export default function SpendLogsTable({
<TabPanel>
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">Request Logs</h1>
<NewBadge dot><Button
<Button
icon={<SettingOutlined />}
onClick={() => setIsSpendLogsSettingsModalVisible(true)}
title="Spend Logs Settings"
/></NewBadge>
/>
</div>
{selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView ? (
<KeyInfoView