mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge pull request #21165 from BerriAI/litellm_access_group_ui
[Feature] UI - Access Groups: Table and Details Page
This commit is contained in:
commit
3ebafc4a10
20 changed files with 2426 additions and 10 deletions
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 || ""),
|
||||
});
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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" ? (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
{"by"}
|
||||
<DefaultProxyAdminTag userId={accessGroup.created_by} />
|
||||
</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Last Updated">
|
||||
{new Date(accessGroup.updated_at).toLocaleString()}
|
||||
{accessGroup.updated_by && (
|
||||
<Text>
|
||||
{"by"}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
46
ui/litellm-dashboard/src/components/AccessGroups/types.ts
Normal file
46
ui/litellm-dashboard/src/components/AccessGroups/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue