diff --git a/ui/litellm-dashboard/src/components/OldTeams.test.tsx b/ui/litellm-dashboard/src/components/OldTeams.test.tsx
index 0ca37cc82d7..f0f08e3907a 100644
--- a/ui/litellm-dashboard/src/components/OldTeams.test.tsx
+++ b/ui/litellm-dashboard/src/components/OldTeams.test.tsx
@@ -14,7 +14,8 @@ vi.mock("./networking", () => ({
teamDeleteCall: vi.fn(),
fetchMCPAccessGroups: vi.fn(),
v2TeamListCall: vi.fn(),
- getGuardrailsList: vi.fn(),
+ getGuardrailsList: vi.fn().mockResolvedValue({ guardrails: [] }),
+ getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }),
}));
vi.mock("./common_components/fetch_teams", () => ({
@@ -70,12 +71,16 @@ vi.mock("./ModelSelect/ModelSelect", () => {
data-testid={dataTestId || "model-select"}
value={Array.isArray(value) ? value.join(", ") : ""}
onChange={(e) => {
- // Mock onChange - in real usage this would be handled by Ant Design Select
if (onChange) {
- onChange(value || []);
+ const newVal = e.target.value
+ ? e.target.value
+ .split(",")
+ .map((s: string) => s.trim())
+ .filter(Boolean)
+ : [];
+ onChange(newVal);
}
}}
- readOnly
/>
);
});
@@ -89,6 +94,27 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: () => mockUseOrganizations(),
}));
+vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
+ useAccessGroups: vi.fn().mockReturnValue({
+ data: [
+ { access_group_id: "ag-1", access_group_name: "Group 1" },
+ { access_group_id: "ag-2", access_group_name: "Group 2" },
+ ],
+ isLoading: false,
+ isError: false,
+ }),
+}));
+
+vi.mock("./common_components/AccessGroupSelector", () => ({
+ default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
+ onChange?.(e.target.value ? e.target.value.split(",").map((s) => s.trim()) : [])}
+ />
+ ),
+}));
+
const createQueryClient = () => {
return new QueryClient({
defaultOptions: {
@@ -740,6 +766,79 @@ describe("OldTeams - Default Team Settings tab visibility", () => {
});
});
+describe("OldTeams - access_group_ids in team create", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockTeamInfoView.mockClear();
+ vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4", "gpt-3.5-turbo"]);
+ vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
+ vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
+ vi.mocked(teamCreateCall).mockResolvedValue({
+ team_id: "new-team-1",
+ team_alias: "Test Team",
+ models: ["gpt-4"],
+ organization_id: null,
+ keys: [],
+ members_with_roles: [],
+ spend: 0,
+ } as any);
+ mockUseOrganizations.mockReturnValue({ data: [{ organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] }] });
+ });
+
+ it("should pass access_group_ids to teamCreateCall when creating team", async () => {
+ renderWithQueryClient(
+ ,
+ );
+
+ const createButton = screen.getByRole("button", { name: /create new team/i });
+ act(() => {
+ fireEvent.click(createButton);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
+ });
+
+ const teamNameInput = screen.getByLabelText(/team name/i);
+ fireEvent.change(teamNameInput, { target: { value: "Test Team" } });
+
+ const modelsInput = screen.getByTestId("create-team-models-select");
+ fireEvent.change(modelsInput, { target: { value: "gpt-4" } });
+
+ const additionalSettingsAccordion = screen.getByText("Additional Settings");
+ fireEvent.click(additionalSettingsAccordion);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("access-group-selector")).toBeInTheDocument();
+ });
+
+ const accessGroupInput = screen.getByTestId("access-group-selector");
+ fireEvent.change(accessGroupInput, { target: { value: "ag-1,ag-2" } });
+
+ const createTeamSubmitButton = screen.getByRole("button", { name: /create team/i });
+ fireEvent.click(createTeamSubmitButton);
+
+ await waitFor(() => {
+ expect(teamCreateCall).toHaveBeenCalledWith(
+ "test-token",
+ expect.objectContaining({
+ team_alias: "Test Team",
+ models: ["gpt-4"],
+ access_group_ids: ["ag-1", "ag-2"],
+ }),
+ );
+ });
+ });
+});
+
describe("OldTeams - models dropdown options", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx
index 7b906505759..702724c9729 100644
--- a/ui/litellm-dashboard/src/components/OldTeams.tsx
+++ b/ui/litellm-dashboard/src/components/OldTeams.tsx
@@ -38,6 +38,7 @@ import {
import { Button as Button2, Form, Input, Modal, Select as Select2, Switch, Tooltip, Typography } from "antd";
import React, { useEffect, useState } from "react";
import { formatNumberWithCommas } from "../utils/dataUtils";
+import AccessGroupSelector from "./common_components/AccessGroupSelector";
import AgentSelector from "./agent_management/AgentSelector";
import { fetchTeams } from "./common_components/fetch_teams";
import ModelAliasManager from "./common_components/ModelAliasManager";
@@ -1376,6 +1377,21 @@ const Teams: React.FC = ({
}))}
/>
+
+ Access Groups{" "}
+
+
+
+
+ }
+ name="access_group_ids"
+ className="mt-8"
+ help="Select access groups to assign to this team"
+ >
+
+
diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx
index 5511a671db7..b54eb21a0ae 100644
--- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx
+++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx
@@ -87,6 +87,7 @@ export interface KeyResponse {
agents?: string[];
agent_access_groups?: string[];
};
+ access_group_ids?: string[];
auto_rotate?: boolean;
rotation_interval?: string;
last_rotation_at?: string;
diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx
index bd5e0ee1cbc..b5198e4f861 100644
--- a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx
+++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx
@@ -1,15 +1,19 @@
+import { act, fireEvent, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import CreateKey from "./create_key_button";
-const mockKeyCreateCall = vi.fn().mockResolvedValue({
- key: "test-api-key",
- soft_budget: null,
+const { mockKeyCreateCall } = vi.hoisted(() => {
+ const fn = vi.fn().mockResolvedValue({
+ key: "test-api-key",
+ soft_budget: null,
+ });
+ return { mockKeyCreateCall: fn };
});
-vi.mock("./networking", () => ({
+vi.mock("../networking", () => ({
keyCreateCall: mockKeyCreateCall,
- modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
+ modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }, { id: "gpt-3.5-turbo" }] }),
getGuardrailsList: vi.fn().mockResolvedValue({ guardrails: [] }),
getPromptsList: vi.fn().mockResolvedValue({ prompts: [] }),
proxyBaseUrl: "http://localhost:4000",
@@ -25,7 +29,7 @@ vi.mock("./networking", () => ({
fetchMCPAccessGroups: vi.fn().mockResolvedValue([]),
}));
-vi.mock("./molecules/notifications_manager", () => ({
+vi.mock("../molecules/notifications_manager", () => ({
default: {
success: vi.fn(),
fromBackend: vi.fn(),
@@ -36,16 +40,22 @@ vi.mock("./molecules/notifications_manager", () => ({
},
}));
+vi.mock("../common_components/AccessGroupSelector", () => ({
+ default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
+ onChange?.(e.target.value ? e.target.value.split(",").map((s) => s.trim()) : [])}
+ />
+ ),
+}));
+
describe("CreateKey", () => {
const defaultProps = {
- userID: "test-user-id",
team: null,
- userRole: "Admin",
- accessToken: "test-token",
data: [],
teams: [],
addKey: vi.fn(),
- premiumUser: false,
};
beforeEach(() => {
@@ -61,4 +71,54 @@ describe("CreateKey", () => {
renderWithProviders();
expect(screen.getByRole("button", { name: /create new key/i })).toBeInTheDocument();
});
+
+ it("should include access_group_ids in keyCreateCall payload when access groups are selected", async () => {
+ renderWithProviders();
+
+ act(() => {
+ fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/key name/i)).toBeInTheDocument();
+ });
+
+ fireEvent.change(screen.getByLabelText(/key name/i), { target: { value: "Test Key" } });
+
+ const optionalSettingsAccordion = screen.getByText("Optional Settings");
+ act(() => {
+ fireEvent.click(optionalSettingsAccordion);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("access-group-selector")).toBeInTheDocument();
+ });
+
+ fireEvent.change(screen.getByTestId("access-group-selector"), { target: { value: "ag-1,ag-2" } });
+
+ const modelsCombobox = screen.getAllByRole("combobox").find((el) => el.closest('[class*="ant-form-item"]')?.textContent?.includes("Models")) ||
+ screen.getAllByRole("combobox")[1];
+ if (modelsCombobox) {
+ act(() => fireEvent.mouseDown(modelsCombobox));
+ await waitFor(() => {
+ const allTeamModels = [...document.body.querySelectorAll(".ant-select-item")].find(
+ (el) => el.textContent?.includes("All Team Models"),
+ );
+ if (allTeamModels) fireEvent.click(allTeamModels);
+ });
+ }
+
+ const createButton = screen.getByRole("button", { name: /create key/i });
+ act(() => fireEvent.click(createButton));
+
+ await waitFor(
+ () => {
+ expect(mockKeyCreateCall).toHaveBeenCalled();
+ const formValues = mockKeyCreateCall.mock.calls[0][2];
+ expect(formValues).toHaveProperty("access_group_ids");
+ expect(formValues.access_group_ids).toEqual(["ag-1", "ag-2"]);
+ },
+ { timeout: 5000 },
+ );
+ });
});
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
index 7a15a6e3f22..cc0483aafd6 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
@@ -89,6 +89,17 @@ vi.mock("@/components/team/member_permissions", () => ({
default: vi.fn(() => Member Permissions
),
}));
+vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
+ useAccessGroups: vi.fn().mockReturnValue({
+ data: [
+ { access_group_id: "ag-1", access_group_name: "Group 1" },
+ { access_group_id: "ag-2", access_group_name: "Group 2" },
+ ],
+ isLoading: false,
+ isError: false,
+ }),
+}));
+
import { useAllProxyModels } from "@/app/(dashboard)/hooks/models/useModels";
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
@@ -669,4 +680,50 @@ describe("TeamInfoView", () => {
expect(screen.getByText(/alert1@test\.com, alert2@test\.com/)).toBeInTheDocument();
});
});
+
+ it("should pass access_group_ids to teamUpdateCall when saving team settings", async () => {
+ const user = userEvent.setup();
+ const accessGroupIds = ["ag-1", "ag-2"];
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({
+ access_group_ids: accessGroupIds,
+ models: ["gpt-4"],
+ })
+ );
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ const teamNameElements = screen.queryAllByText("Test Team");
+ expect(teamNameElements.length).toBeGreaterThan(0);
+ });
+
+ const settingsTab = screen.getByRole("tab", { name: "Settings" });
+ await user.click(settingsTab);
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
+ });
+
+ const editButton = screen.getByRole("button", { name: /edit settings/i });
+ await user.click(editButton);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Team Name")).toBeInTheDocument();
+ });
+
+ const saveButton = screen.getByRole("button", { name: /save changes/i });
+ await user.click(saveButton);
+
+ await waitFor(() => {
+ expect(networking.teamUpdateCall).toHaveBeenCalledWith(
+ "test-token",
+ expect.objectContaining({
+ access_group_ids: accessGroupIds,
+ team_id: "123",
+ })
+ );
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index d081538fb64..ea7a9a1c460 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -23,6 +23,7 @@ import { Button, Form, Input, message, Select, Switch, Tabs, Tooltip } from "ant
import { CheckIcon, CopyIcon } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
+import AccessGroupSelector from "../common_components/AccessGroupSelector";
import AgentSelector from "../agent_management/AgentSelector";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
import DurationSelect from "../common_components/DurationSelect";
@@ -90,6 +91,7 @@ export interface TeamData {
model_aliases: Record;
} | null;
created_at: string;
+ access_group_ids?: string[];
guardrails?: string[];
policies?: string[];
object_permission?: {
@@ -535,6 +537,11 @@ const TeamInfoView: React.FC = ({
updateData.object_permission.vector_stores = values.vector_stores;
}
+ // Pass access_group_ids to the update request
+ if (values.access_group_ids !== undefined) {
+ updateData.access_group_ids = values.access_group_ids;
+ }
+
const response = await teamUpdateCall(accessToken, updateData);
NotificationsManager.success("Team settings updated successfully");
@@ -800,6 +807,7 @@ const TeamInfoView: React.FC = ({
agents: info.object_permission?.agents || [],
accessGroups: info.object_permission?.agent_access_groups || [],
},
+ access_group_ids: info.access_group_ids || [],
}}
layout="vertical"
>
@@ -970,6 +978,20 @@ const TeamInfoView: React.FC = ({
/>
+
+ Access Groups{" "}
+
+
+
+
+ }
+ name="access_group_ids"
+ >
+
+
+
form.setFieldValue("vector_stores", values)}
diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx
index f923dfeadd3..bbe3c206cee 100644
--- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx
+++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx
@@ -56,6 +56,27 @@ vi.mock("../organisms/create_key_button", () => ({
fetchTeamModels: vi.fn().mockResolvedValue(["team-model-1", "team-model-2"]),
}));
+vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
+ useAccessGroups: vi.fn().mockReturnValue({
+ data: [
+ { access_group_id: "ag-1", access_group_name: "Group 1" },
+ { access_group_id: "ag-2", access_group_name: "Group 2" },
+ ],
+ isLoading: false,
+ isError: false,
+ }),
+}));
+
+vi.mock("../common_components/AccessGroupSelector", () => ({
+ default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
+ onChange?.(e.target.value ? e.target.value.split(",").map((s) => s.trim()) : [])}
+ />
+ ),
+}));
+
describe("KeyEditView", () => {
const MOCK_KEY_DATA: KeyResponse = {
token: "test-token-123",
@@ -436,6 +457,43 @@ describe("KeyEditView", () => {
});
+ it("should pass access_group_ids to onSubmit when saving key with access groups", async () => {
+ const onSubmitMock = vi.fn().mockResolvedValue(undefined);
+ const keyDataWithAccessGroups = {
+ ...MOCK_KEY_DATA,
+ access_group_ids: ["ag-1"],
+ };
+
+ renderWithProviders(
+ {}}
+ onSubmit={onSubmitMock}
+ accessToken="test-token"
+ userID="test-user"
+ userRole="admin"
+ premiumUser={false}
+ />,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("access-group-selector")).toBeInTheDocument();
+ });
+
+ const accessGroupInput = screen.getByTestId("access-group-selector");
+ await userEvent.clear(accessGroupInput);
+ await userEvent.type(accessGroupInput, "ag-1,ag-2");
+
+ const submitButton = screen.getByRole("button", { name: /save changes/i });
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(onSubmitMock).toHaveBeenCalled();
+ const callArgs = onSubmitMock.mock.calls[0][0];
+ expect(callArgs.access_group_ids).toEqual(["ag-1", "ag-2"]);
+ });
+ });
+
it("should disable cancel button during submission", async () => {
let resolveSubmit: (() => void) | undefined;
const submitPromise = new Promise((resolve) => {
diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
index 64676e3b94a..1ba3cc7f7b4 100644
--- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
+++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
@@ -5,6 +5,7 @@ import { TextInput, Button as TremorButton } from "@tremor/react";
import { Form, Input, Select, Switch, Tooltip } from "antd";
import { useEffect, useState } from "react";
import AgentSelector from "../agent_management/AgentSelector";
+import AccessGroupSelector from "../common_components/AccessGroupSelector";
import { mapInternalToDisplayNames } from "../callback_info_helpers";
import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings";
import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector";
@@ -169,6 +170,7 @@ export function KeyEditView({
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks)
: [],
+ access_group_ids: keyData.access_group_ids || [],
auto_rotate: keyData.auto_rotate || false,
...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }),
allowed_routes: Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0
@@ -196,6 +198,7 @@ export function KeyEditView({
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks)
: [],
+ access_group_ids: keyData.access_group_ids || [],
auto_rotate: keyData.auto_rotate || false,
...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }),
allowed_routes: Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0
@@ -500,6 +503,20 @@ export function KeyEditView({
+
+ Access Groups{" "}
+
+
+
+
+ }
+ name="access_group_ids"
+ >
+
+
+