diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupCreateModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupCreateModal.tsx deleted file mode 100644 index 2103701bb67..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupCreateModal.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from "react"; -import { Modal, Form } from "antd"; -import MessageManager from "@/components/molecules/message_manager"; -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(); - const createMutation = useCreateAccessGroup(); - - const handleOk = () => { - form - .validateFields() - .then((values) => { - const params: AccessGroupCreateParams = { - access_group_name: values.name, - description: values.description, - access_model_names: values.modelIds, - access_mcp_server_ids: values.mcpServerIds, - access_agent_ids: values.agentIds, - }; - - createMutation.mutate(params, { - onSuccess: () => { - MessageManager.success("Access group created successfully"); - form.resetFields(); - onSuccess?.(); - onCancel(); - }, - }); - }) - .catch((info) => {}); - }; - - return ( - - - - ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx index 5e212901bd1..e43febb9c0d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx @@ -62,11 +62,11 @@ vi.mock("./AccessGroupsDetailsPage", () => ({ ), })); -vi.mock("./AccessGroupsModal/AccessGroupCreateModal", () => ({ - AccessGroupCreateModal: ({ visible, onCancel }: { visible: boolean; onCancel: () => void }) => - visible ? ( +vi.mock("./access-group-create/AccessGroupCreateDialog", () => ({ + AccessGroupCreateDialog: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => + open ? (
- +
) : null, })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx index f37acb3d85a..8f51177bafe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx @@ -7,7 +7,7 @@ import { PageHeader } from "@/components/shared/PageHeader"; import { Button } from "@/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; import { AccessGroupDetail } from "./AccessGroupsDetailsPage"; -import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal"; +import { AccessGroupCreateDialog } from "./access-group-create/AccessGroupCreateDialog"; import { AccessGroupsTable } from "./AccessGroupsTable"; import { AccessGroup } from "./types"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; @@ -104,7 +104,7 @@ export function AccessGroupsPage() { onDeleteClick={setGroupToDelete} /> - setIsCreateModalVisible(false)} /> + ({ + __esModule: true, + default: { success: vi.fn(), fromBackend: vi.fn() }, +})); +vi.mock("@/components/ModelSelect/ModelSelect", () => ({ + ModelSelect: ({ onChange }: { onChange: (values: string[]) => void }) => ( + + ), +})); +vi.mock("@/app/(dashboard)/hooks/agents/useAgents", () => ({ + useAgents: () => ({ data: { agents: [{ agent_id: "agent-1", agent_name: "Support Agent" }] } }), +})); +vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({ + useMCPServers: () => ({ data: [{ server_id: "srv-1", server_name: "GitHub MCP" }] }), +})); + +import { AccessGroupCreateDialog } from "./AccessGroupCreateDialog"; + +const Harness = ({ createAccessGroup }: { createAccessGroup: (body: unknown) => Promise }) => { + const [open, setOpen] = React.useState(true); + return ( + <> + + + + ); +}; + +const renderDialog = (overrides?: { createAccessGroup?: ReturnType }) => { + const createAccessGroup = overrides?.createAccessGroup ?? vi.fn().mockResolvedValue({}); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return { createAccessGroup }; +}; + +describe("AccessGroupCreateDialog", () => { + it("blocks submit and shows an error when the name is missing", async () => { + const user = userEvent.setup(); + const { createAccessGroup } = renderDialog(); + + await user.click(screen.getByRole("button", { name: "Create Group" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Please enter the access group name"); + expect(createAccessGroup).not.toHaveBeenCalled(); + }); + + it("returns to the General Info tab when submitting an invalid form from another tab", async () => { + const user = userEvent.setup(); + const { createAccessGroup } = renderDialog(); + + await user.click(screen.getByRole("tab", { name: "Models" })); + await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument()); + + await user.click(screen.getByRole("button", { name: "Create Group" })); + + expect(await screen.findByLabelText("Group Name")).toBeInTheDocument(); + expect(await screen.findByRole("alert")).toHaveTextContent("Please enter the access group name"); + expect(createAccessGroup).not.toHaveBeenCalled(); + }); + + it("sends only the group name for a minimal create and closes the dialog", async () => { + const user = userEvent.setup(); + const { createAccessGroup } = renderDialog(); + + await user.type(screen.getByLabelText("Group Name"), "prod-models"); + await user.click(screen.getByRole("button", { name: "Create Group" })); + + await waitFor(() => expect(createAccessGroup).toHaveBeenCalledTimes(1)); + expect(createAccessGroup.mock.calls[0][0]).toStrictEqual({ access_group_name: "prod-models" }); + await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument()); + }); + + it("maps the description and model selections into the create body", async () => { + const user = userEvent.setup(); + const { createAccessGroup } = renderDialog(); + + await user.type(screen.getByLabelText("Group Name"), "prod-models"); + await user.type(screen.getByLabelText("Description"), "engineering access"); + await user.click(screen.getByRole("tab", { name: "Models" })); + await user.click(screen.getByRole("button", { name: "set-models" })); + await user.click(screen.getByRole("button", { name: "Create Group" })); + + await waitFor(() => expect(createAccessGroup).toHaveBeenCalledTimes(1)); + expect(createAccessGroup.mock.calls[0][0]).toStrictEqual({ + access_group_name: "prod-models", + description: "engineering access", + access_model_names: ["gpt-5.2"], + }); + }); + + it("keeps the dialog open with the entered values when the create fails", async () => { + const user = userEvent.setup(); + const { createAccessGroup } = renderDialog({ + createAccessGroup: vi.fn().mockRejectedValue(new Error("boom")), + }); + + await user.type(screen.getByLabelText("Group Name"), "prod-models"); + await user.click(screen.getByRole("button", { name: "Create Group" })); + + await waitFor(() => expect(createAccessGroup).toHaveBeenCalledTimes(1)); + expect(screen.getByLabelText("Group Name")).toHaveValue("prod-models"); + }); + + it("resets the form when the dialog is cancelled and reopened", async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.type(screen.getByLabelText("Group Name"), "abandoned"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument()); + + await user.click(screen.getByRole("button", { name: "reopen" })); + expect(screen.getByLabelText("Group Name")).toHaveValue(""); + }); + + it("resets the form when the dialog is dismissed with Escape and reopened", async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.type(screen.getByLabelText("Group Name"), "abandoned"); + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument()); + + await user.click(screen.getByRole("button", { name: "reopen" })); + expect(screen.getByLabelText("Group Name")).toHaveValue(""); + }); + + it("cannot be dismissed while a create is pending, then closes once on success", async () => { + const user = userEvent.setup(); + let resolveCreate: (value: unknown) => void = () => {}; + const createAccessGroup = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + renderDialog({ createAccessGroup }); + + await user.type(screen.getByLabelText("Group Name"), "prod-models"); + await user.keyboard("{Enter}"); + await waitFor(() => expect(createAccessGroup).toHaveBeenCalledTimes(1)); + + await user.keyboard("{Escape}"); + expect(screen.getByLabelText("Group Name")).toHaveValue("prod-models"); + + resolveCreate({}); + await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument()); + }); + + it("does not fire a second create while one is pending", async () => { + const user = userEvent.setup(); + let resolveCreate: (value: unknown) => void = () => {}; + const createAccessGroup = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + renderDialog({ createAccessGroup }); + + await user.type(screen.getByLabelText("Group Name"), "prod-models"); + await user.keyboard("{Enter}"); + await waitFor(() => expect(createAccessGroup).toHaveBeenCalledTimes(1)); + await user.keyboard("{Enter}"); + + expect(createAccessGroup).toHaveBeenCalledTimes(1); + resolveCreate({}); + await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument()); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx new file mode 100644 index 00000000000..3f2205b4206 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react"; +import * as React from "react"; + +import { accessGroupKeys } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; +import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; +import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; +import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { useZodForm } from "@/lib/forms/useZodForm"; +import { fetchClient } from "@/lib/http/api"; + +import { buildAccessGroupCreateBody, emptyAccessGroupFormValues, type AccessGroupCreateBody } from "./mapper"; +import { accessGroupCreateSchema } from "./schema"; + +const GENERAL_TAB = "general"; + +interface MultiSelectOption { + value: string; + label: string; +} + +interface MultiSelectProps { + id: string; + value: string[]; + onChange: (value: string[]) => void; + options: MultiSelectOption[]; + placeholder: string; + "aria-invalid": true | undefined; + "aria-describedby": string | undefined; +} + +const MultiSelect = ({ + id, + value, + onChange, + options, + placeholder, + "aria-invalid": ariaInvalid, + "aria-describedby": ariaDescribedBy, +}: MultiSelectProps) => ( + +); + +const defaultCreateAccessGroup = async (body: AccessGroupCreateBody): Promise => { + const { data } = await fetchClient.POST("/v1/access_group", { body }); + return data; +}; + +interface AccessGroupCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + createAccessGroup?: (body: AccessGroupCreateBody) => Promise; +} + +export const AccessGroupCreateDialog = ({ + open, + onOpenChange, + createAccessGroup = defaultCreateAccessGroup, +}: AccessGroupCreateDialogProps) => { + const queryClient = useQueryClient(); + const form = useZodForm(accessGroupCreateSchema, { defaultValues: emptyAccessGroupFormValues }); + const [activeTab, setActiveTab] = React.useState(GENERAL_TAB); + + const { data: agentsData } = useAgents(); + const { data: mcpServersData } = useMCPServers(); + + const mcpServerOptions = (mcpServersData ?? []).map((server) => ({ + value: server.server_id, + label: server.server_name ?? server.server_id, + })); + const agentOptions = (agentsData?.agents ?? []).map((agent) => ({ + value: agent.agent_id, + label: agent.agent_name, + })); + + const closeAndReset = () => { + form.reset(emptyAccessGroupFormValues); + setActiveTab(GENERAL_TAB); + onOpenChange(false); + }; + + const mutation = useMutation({ + mutationFn: (body: AccessGroupCreateBody) => createAccessGroup(body), + onSuccess: () => { + NotificationsManager.success("Access group created successfully"); + queryClient.invalidateQueries({ queryKey: accessGroupKeys.all }); + closeAndReset(); + }, + onError: (error: unknown) => + NotificationsManager.fromBackend(error instanceof Error ? error.message : "Failed to create access group"), + }); + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen && mutation.isPending) return; + if (!nextOpen) { + form.reset(emptyAccessGroupFormValues); + setActiveTab(GENERAL_TAB); + } + onOpenChange(nextOpen); + }; + + const onSubmit = form.handleSubmit( + (values) => { + if (mutation.isPending) return; + mutation.mutate(buildAccessGroupCreateBody(values)); + }, + // the only validated field (name) lives on the General Info tab + () => setActiveTab(GENERAL_TAB), + ); + + return ( + + + + Create Access Group + + +
+ + + + + General Info + + + + Models + + + + MCP Servers + + + + Agents + + + + + + + {({ ref, ...field }) => } + + + {({ ref, ...field }) => ( +