From 1e4f66f0fc86c1f4021b3fea925b65ff5d0e7139 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 17 Aug 2026 10:08:36 -0700 Subject: [PATCH] feat(access-groups): add PATCH route and migrate the edit modal to RHF + zod + shadcn Register PATCH /v1/access_group/{id} (and the unified alias) on the existing partial-update handler so the dashboard has a proper partial-update verb, keep PUT for compatibility, and regenerate the lazy OpenAPI snapshot fragment plus schema.d.ts for it. Replace the antd Edit Access Group modal with a shadcn dialog that shares its tabbed fields with the create dialog, hydrates from the record, and sends only the dirty fields over PATCH. Fix usePickDirty so every field edited before the first save is sent: RHF only maintains dirtyFields for subscribers and formState.dirtyFields read inside a submit handler is a stale snapshot, which also dropped the second edited field in the organization settings form. --- litellm/proxy/_lazy_openapi_snapshot.json | 117 +++++++ .../access_group_endpoints.py | 10 + .../test_access_group_endpoints.py | 25 +- .../AccessGroupsDetailsPage.test.tsx | 8 +- .../_components/AccessGroupsDetailsPage.tsx | 8 +- .../AccessGroupsModal/AccessGroupBaseForm.tsx | 147 --------- .../AccessGroupEditModal.tsx | 73 ----- .../AccessGroupCreateDialog.tsx | 155 +-------- .../access-group-create/mapper.test.ts | 3 +- .../_components/access-group-create/mapper.ts | 12 +- .../_components/access-group-create/schema.ts | 11 - .../AccessGroupEditDialog.test.tsx | 257 +++++++++++++++ .../AccessGroupEditDialog.tsx | 119 +++++++ .../access-group-edit/mapper.test.ts | 57 ++++ .../_components/access-group-edit/mapper.ts | 25 ++ .../AccessGroupFormFields.tsx | 159 +++++++++ .../_components/access-group-form/schema.ts | 19 ++ .../hooks/accessGroups/useAccessGroups.ts | 16 +- .../hooks/accessGroups/useEditAccessGroup.ts | 72 ----- .../org-settings/OrgSettingsForm.test.tsx | 13 + .../org-settings/OrgSettingsForm.tsx | 5 +- .../src/lib/forms/pickDirty.test.ts | 301 +++++++----------- .../src/lib/forms/pickDirty.ts | 14 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 76 ++++- 24 files changed, 1025 insertions(+), 677 deletions(-) delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/schema.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/AccessGroupEditDialog.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/AccessGroupEditDialog.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/mapper.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-edit/mapper.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-form/AccessGroupFormFields.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-form/schema.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 7fe02c6d8bc..0f7a933e77c 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -782,6 +782,13 @@ }, "ValidationError": { "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, "loc": { "items": { "anyOf": [ @@ -1202,6 +1209,61 @@ "access_groups" ] }, + "patch": { + "operationId": "update_access_group_v1_access_group__access_group_id__patch", + "parameters": [ + { + "in": "path", + "name": "access_group_id", + "required": true, + "schema": { + "title": "Access Group Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessGroupUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessGroupResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Update Access Group", + "tags": [ + "access_groups" + ] + }, "put": { "operationId": "update_access_group_v1_access_group__access_group_id__put", "parameters": [ @@ -1416,6 +1478,61 @@ "access_groups" ] }, + "patch": { + "operationId": "update_access_group_v1_unified_access_group__access_group_id__patch", + "parameters": [ + { + "in": "path", + "name": "access_group_id", + "required": true, + "schema": { + "title": "Access Group Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessGroupUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessGroupResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Update Access Group", + "tags": [ + "access_groups" + ] + }, "put": { "operationId": "update_access_group_v1_unified_access_group__access_group_id__put", "parameters": [ diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py index 2271501d480..117b5b3f824 100644 --- a/litellm/proxy/management_endpoints/access_group_endpoints.py +++ b/litellm/proxy/management_endpoints/access_group_endpoints.py @@ -416,6 +416,10 @@ async def get_access_group( return _record_to_response(record) +@router.patch( + "/v1/access_group/{access_group_id}", + response_model=AccessGroupResponse, +) @router.put( "/v1/access_group/{access_group_id}", response_model=AccessGroupResponse, @@ -637,6 +641,12 @@ router.add_api_route( methods=["PUT"], response_model=AccessGroupResponse, ) +router.add_api_route( + "/v1/unified_access_group/{access_group_id}", + update_access_group, + methods=["PATCH"], + response_model=AccessGroupResponse, +) router.add_api_route( "/v1/unified_access_group/{access_group_id}", delete_access_group, diff --git a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py index 016e10859b6..9331555bd62 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py @@ -410,6 +410,7 @@ def test_get_access_group_forbidden_non_admin(client_and_mocks, user_role): # --------------------------------------------------------------------------- +@pytest.mark.parametrize("method", ["put", "patch"]) @pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) @pytest.mark.parametrize( "update_payload", @@ -419,18 +420,36 @@ def test_get_access_group_forbidden_non_admin(client_and_mocks, user_role): {"assigned_team_ids": [], "assigned_key_ids": ["key-1"]}, ], ) -def test_update_access_group_success(client_and_mocks, base_path, update_payload): - """Update access group with various payloads returns 200.""" +def test_update_access_group_success(client_and_mocks, method, base_path, update_payload): + """Update access group with various payloads returns 200 over both PUT and PATCH.""" client, _, mock_table, *_ = client_and_mocks existing = _make_access_group_record(access_group_id="ag-update") mock_table.find_unique = AsyncMock(return_value=existing) - resp = client.put(f"{base_path}/ag-update", json=update_payload) + resp = client.request(method, f"{base_path}/ag-update", json=update_payload) assert resp.status_code == 200 mock_table.update.assert_awaited_once() +@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) +def test_patch_access_group_writes_only_sent_fields(client_and_mocks, base_path): + """PATCH with one field leaves every other column out of the write, so untouched grants survive.""" + client, _, mock_table, *_ = client_and_mocks + + existing = _make_access_group_record( + access_group_id="ag-update", access_model_names=["model-1"], access_agent_ids=["agent-1"] + ) + mock_table.find_unique = AsyncMock(return_value=existing) + + resp = client.patch(f"{base_path}/ag-update", json={"description": "Only this changes"}) + assert resp.status_code == 200 + assert mock_table.update.call_args.kwargs["data"] == { + "updated_by": "admin_user", + "description": "Only this changes", + } + + def test_update_access_group_not_found(client_and_mocks): """Update access group returns 404 when not found.""" client, _, mock_table, *_ = client_and_mocks diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx index cf41f623fd6..ba256d713a2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx @@ -7,11 +7,11 @@ 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 ? ( +vi.mock("./access-group-edit/AccessGroupEditDialog", () => ({ + AccessGroupEditDialog: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => + open ? (
- +
) : null, })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx index 9476a8d98af..6d72489c052 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx @@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; -import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal"; +import { AccessGroupEditDialog } from "./access-group-edit/AccessGroupEditDialog"; interface AccessGroupDetailProps { accessGroupId: string; @@ -218,11 +218,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr - setIsEditModalVisible(false)} - /> + ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx deleted file mode 100644 index 72b89e34301..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx +++ /dev/null @@ -1,147 +0,0 @@ -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; - 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: ( - - - General Info - - ), - children: ( -
- - - - -