= ({
};
const handleClose = (): void => {
- form.resetFields();
+ form.reset(emptyValues);
setUserOptions([]);
onCancel();
};
+ const swallowEnter = (event: React.KeyboardEvent): void => {
+ if (event.key === "Enter") event.preventDefault();
+ };
+
+ const optionsFor = (fieldName: "user_email" | "user_id", value: string | undefined): UserOption[] => {
+ const visible = selectedField === fieldName ? userOptions : [];
+ if (value == null || value === "" || visible.some((option) => option.value === value)) return visible;
+ return [{ label: value, value, user: null }, ...visible];
+ };
+
+ const renderUserSearch = (
+ fieldName: "user_email" | "user_id",
+ placeholder: string,
+ controlProps: { id: string; value: string | undefined; onChange: (value: string | undefined) => void },
+ testId?: string,
+ ) => {
+ const items = optionsFor(fieldName, controlProps.value);
+ const selected = items.find((option) => option.value === controlProps.value) ?? null;
+ return (
+
+ {
+ controlProps.onChange(option?.value);
+ handleSelect(option);
+ }}
+ onInputValueChange={(text: string) => handleSearch(text, fieldName)}
+ isItemEqualToValue={(a: UserOption, b: UserOption) => a.value === b.value}
+ itemToStringLabel={(option: UserOption) => option.label}
+ >
+
+
+ {loading ? "Loading..." : "No results"}
+
+ {(option: UserOption) => (
+
+ {option.label}
+
+ )}
+
+
+
+
+ );
+ };
+
return (
-
-
- OR
+
+
+ {({ id, value, onChange }) =>
+ renderUserSearch("user_email", "Search by email", { id, value, onChange }, "member-email-search")
+ }
+
-
-
+ OR
-
-
-
+
+ {({ id, value, onChange }) => renderUserSearch("user_id", "Search by user ID", { id, value, onChange })}
+
-
- } loading={isSubmitting}>
- {isSubmitting ? "Adding..." : "Add Member"}
-
-
-
+
+ {({ id, value, onChange }) => (
+
+ )}
+
+
+
+
+
+
+
+
);
};
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
index 1d6c46dc0c9..9df13a982ed 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
@@ -209,6 +209,41 @@ const createMockTeamData = (overrides = {}) => ({
team_memberships: [],
});
+const seedDefaultMocks = () => {
+ mockUseAllProxyModels.mockReturnValue({
+ data: { data: [] },
+ isLoading: false,
+ } as any);
+ mockUseTeam.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ } as any);
+ mockUseOrganization.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ } as any);
+ mockUseCurrentUser.mockReturnValue({
+ data: { models: [] },
+ isLoading: false,
+ } as any);
+ mockUseKeys.mockReturnValue({
+ data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
+ isPending: false,
+ isFetching: false,
+ refetch: vi.fn(),
+ } as any);
+ vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: [], isLoading: false } as any);
+
+ can.mockReturnValue(true);
+ vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] });
+ vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] });
+ vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
+ vi.mocked(networking.getTeamPermissionsCall).mockResolvedValue({
+ all_available_permissions: [],
+ team_member_permissions: [],
+ });
+};
+
describe("TeamInfoView", () => {
const defaultProps = {
teamId: "123",
@@ -222,40 +257,7 @@ describe("TeamInfoView", () => {
premiumUser: false,
};
- beforeEach(() => {
- mockUseAllProxyModels.mockReturnValue({
- data: { data: [] },
- isLoading: false,
- } as any);
- mockUseTeam.mockReturnValue({
- data: undefined,
- isLoading: false,
- } as any);
- mockUseOrganization.mockReturnValue({
- data: undefined,
- isLoading: false,
- } as any);
- mockUseCurrentUser.mockReturnValue({
- data: { models: [] },
- isLoading: false,
- } as any);
- mockUseKeys.mockReturnValue({
- data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
- isPending: false,
- isFetching: false,
- refetch: vi.fn(),
- } as any);
- vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: [], isLoading: false } as any);
-
- can.mockReturnValue(true);
- vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] });
- vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] });
- vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
- vi.mocked(networking.getTeamPermissionsCall).mockResolvedValue({
- all_available_permissions: [],
- team_member_permissions: [],
- });
- });
+ beforeEach(seedDefaultMocks);
afterEach(() => {
vi.clearAllMocks();
@@ -1565,3 +1567,96 @@ describe("TeamInfoView", () => {
});
});
});
+
+describe("TeamInfoView - which team member fields reach the update payload depends on the open sections", () => {
+ const props = {
+ teamId: "123",
+ onUpdate: vi.fn(),
+ onClose: vi.fn(),
+ accessToken: "test-token",
+ is_team_admin: true,
+ is_proxy_admin: true,
+ userModels: ["gpt-4"],
+ editTeam: false,
+ };
+
+ beforeEach(seedDefaultMocks);
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const openEditor = async (user: ReturnType) => {
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({
+ team_member_budget_table: { max_budget: 42, budget_duration: "30d", tpm_limit: 11, rpm_limit: 22 },
+ default_team_member_models: ["gpt-4"],
+ }),
+ );
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
+
+ renderWithProviders();
+ await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await screen.findByLabelText("Team Name");
+ };
+
+ const save = async (user: ReturnType) => {
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+ await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
+ return vi.mocked(networking.teamUpdateCall).mock.calls[0][1] as Record;
+ };
+
+ it("omits every stored team member field when Team Member Settings is left closed", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditor(user);
+
+ const payload = await save(user);
+
+ expect(payload.team_member_budget_duration).toBeUndefined();
+ expect(payload).not.toHaveProperty("team_member_budget");
+ expect(payload).not.toHaveProperty("team_member_tpm_limit");
+ expect(payload).not.toHaveProperty("team_member_rpm_limit");
+ expect(payload).not.toHaveProperty("default_team_member_models");
+
+ const wireBody = JSON.parse(JSON.stringify(payload));
+ expect(Object.keys(wireBody).filter((key) => key.startsWith("team_member"))).toEqual([]);
+ expect(wireBody).not.toHaveProperty("default_team_member_models");
+ });
+
+ it("resends every stored team member field once Team Member Settings is opened", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditor(user);
+
+ await user.click(screen.getByText("Team Member Settings"));
+ await screen.findByLabelText("Default Budget (USD)");
+ const payload = await save(user);
+
+ expect(payload.team_member_budget_duration).toBe("30d");
+ expect(payload.team_member_budget).toBe(42);
+ expect(payload.team_member_tpm_limit).toBe(11);
+ expect(payload.team_member_rpm_limit).toBe(22);
+ expect(payload.default_team_member_models).toEqual(["gpt-4"]);
+ });
+
+ it("omits object_permission.search_tools while Search Tool Settings is closed", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditor(user);
+
+ const payload = await save(user);
+
+ expect(payload.object_permission).not.toHaveProperty("search_tools");
+ });
+
+ it("includes object_permission.search_tools once Search Tool Settings is opened", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditor(user);
+
+ await user.click(screen.getByText("Search Tool Settings"));
+ await screen.findByPlaceholderText("Select search tools (optional, empty = all allowed)");
+ const payload = await save(user);
+
+ expect(payload.object_permission).toHaveProperty("search_tools");
+ });
+});