mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Access groups UI
This commit is contained in:
parent
a06113ec82
commit
5dbcca8d43
14 changed files with 1470 additions and 0 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,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,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.updated_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}
|
||||
destroyOnClose
|
||||
>
|
||||
<AccessGroupBaseForm form={form} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -183,6 +183,13 @@ const menuGroups: MenuGroup[] = [
|
|||
icon: <BankOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "access-groups",
|
||||
page: "access-groups",
|
||||
label: "Access Groups",
|
||||
icon: <BlockOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "budgets",
|
||||
page: "budgets",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue