diff --git a/ui/litellm-dashboard/src/components/Teams.test.tsx b/ui/litellm-dashboard/src/components/Teams.test.tsx index 6f15f23b193..0ef4357e73f 100644 --- a/ui/litellm-dashboard/src/components/Teams.test.tsx +++ b/ui/litellm-dashboard/src/components/Teams.test.tsx @@ -1106,3 +1106,120 @@ describe("Teams - policies field is gated on the viewPolicies capability", () => expect(screen.queryByText("Policies")).not.toBeInTheDocument(); }); }); + +describe("Teams - which fields reach the create payload depends on the open sections", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTeamInfoView.mockClear(); + vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]); + vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); + vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); + vi.mocked(getPoliciesList).mockResolvedValue({ policies: [] }); + vi.mocked(getDefaultTeamSettings).mockResolvedValue({ values: {} }); + vi.mocked(teamCreateCall).mockResolvedValue({ team_id: "new-team-1" }); + mockUseOrganizations.mockReturnValue({ data: null }); + }); + + const openCreateModal = async () => { + renderWithQueryClient(); + act(() => { + fireEvent.click(screen.getAllByRole("button", { name: /create team/i })[0]); + }); + await waitFor(() => { + expect(screen.getByLabelText(/team name/i)).toBeInTheDocument(); + }); + }; + + const submit = async () => { + const buttons = screen.getAllByRole("button", { name: /create team/i }); + fireEvent.click(buttons[buttons.length - 1]); + await waitFor(() => { + expect(teamCreateCall).toHaveBeenCalled(); + }); + return vi.mocked(teamCreateCall).mock.calls[0][1] as Record; + }; + + const toggleAdditionalSettings = () => fireEvent.click(screen.getByText("Additional Settings")); + + it("sends only the always-visible fields when every section is left closed", async () => { + await openCreateModal(); + fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Closed Sections Team" } }); + + const payload = await submit(); + + expect(Object.keys(payload).sort()).toEqual([ + "budget_duration", + "max_budget", + "metadata", + "models", + "organization_id", + "rpm_limit", + "team_alias", + "tpm_limit", + ]); + expect(payload.team_alias).toBe("Closed Sections Team"); + }); + + it("adds the Additional Settings fields to the payload once that section is opened", async () => { + await openCreateModal(); + fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Open Section Team" } }); + + toggleAdditionalSettings(); + await waitFor(() => { + expect(screen.getByLabelText("Team ID")).toBeInTheDocument(); + }); + fireEvent.change(screen.getByLabelText("Team ID"), { target: { value: "tid-open" } }); + fireEvent.change(screen.getByLabelText("Team Member Budget (USD)"), { target: { value: "12.5" } }); + + const payload = await submit(); + + expect(payload.team_id).toBe("tid-open"); + expect(payload.team_member_budget).toBe(12.5); + expect(Object.keys(payload)).toEqual( + expect.arrayContaining(["access_group_ids", "guardrails", "secret_manager_settings", "team_member_key_duration"]), + ); + }); + + it("drops a value typed in Additional Settings when that section is closed again before saving", async () => { + await openCreateModal(); + fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Reclosed Team" } }); + + toggleAdditionalSettings(); + await waitFor(() => { + expect(screen.getByLabelText("Team ID")).toBeInTheDocument(); + }); + fireEvent.change(screen.getByLabelText("Team ID"), { target: { value: "tid-dropped" } }); + toggleAdditionalSettings(); + await waitFor(() => { + expect(screen.queryByLabelText("Team ID")).not.toBeInTheDocument(); + }); + + const payload = await submit(); + + expect(payload).not.toHaveProperty("team_id"); + }); + + it("restores and sends the typed value when Additional Settings is reopened before saving", async () => { + await openCreateModal(); + fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Reopened Team" } }); + + toggleAdditionalSettings(); + await waitFor(() => { + expect(screen.getByLabelText("Team ID")).toBeInTheDocument(); + }); + fireEvent.change(screen.getByLabelText("Team ID"), { target: { value: "tid-kept" } }); + toggleAdditionalSettings(); + await waitFor(() => { + expect(screen.queryByLabelText("Team ID")).not.toBeInTheDocument(); + }); + toggleAdditionalSettings(); + await waitFor(() => { + expect(screen.getByLabelText("Team ID")).toBeInTheDocument(); + }); + + expect(screen.getByLabelText("Team ID")).toHaveValue("tid-kept"); + const payload = await submit(); + + expect(payload.team_id).toBe("tid-kept"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx index 634c78d28d0..d17948b599e 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx @@ -1,4 +1,5 @@ -import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import UserSearchModal from "./user_search_modal"; import { userFilterUICall } from "@/components/networking"; @@ -76,3 +77,104 @@ describe("UserSearchModal", () => { expect(notice.className).toMatch(/ant-alert-info/); }); }); + +describe("UserSearchModal submit payload", () => { + beforeEach(() => { + vi.mocked(userFilterUICall).mockReset(); + vi.mocked(userFilterUICall).mockResolvedValue([{ user_id: "u-1", user_email: "picked@example.com" }] as never); + }); + + const setup = () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render(); + return { user, onSubmit }; + }; + + const save = () => screen.getByRole("button", { name: /add member/i }); + + const searchByEmail = async (user: ReturnType, text: string) => { + const input = getEmailSearchInput(); + await user.click(input); + await user.type(input, text); + await waitFor(() => expect(userFilterUICall).toHaveBeenCalled(), { timeout: 3000 }); + await user.click(await screen.findByRole("option", { name: "picked@example.com" })); + }; + + it("submits every registered field, with the untouched identity fields undefined", async () => { + const { user, onSubmit } = setup(); + + await user.click(save()); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + const values = onSubmit.mock.calls[0][0]; + expect(Object.keys(values).sort()).toEqual(["role", "user_email", "user_id"]); + expect(values).toStrictEqual({ user_email: undefined, user_id: undefined, role: "user" }); + }); + + it("carries the picked user's email and id into the payload", async () => { + const { user, onSubmit } = setup(); + + await searchByEmail(user, "pick"); + await user.click(save()); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit.mock.calls[0][0]).toStrictEqual({ + user_email: "picked@example.com", + user_id: "u-1", + role: "user", + }); + }); + + it("carries a role changed off its default into the payload", async () => { + const { onSubmit } = setup(); + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + + await user.click(screen.getByLabelText("Member Role")); + await user.click(await screen.findByRole("option", { name: /^admin/ })); + await user.click(save()); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ role: "admin" }); + }); + + it("keeps the picked identity when the option showing the current value is reselected", async () => { + const { user, onSubmit } = setup(); + + await searchByEmail(user, "pick"); + + vi.mocked(userFilterUICall).mockResolvedValue([] as never); + const idInput = screen.getByLabelText("User ID"); + await user.click(idInput); + await user.type(idInput, "zzz"); + await waitFor(() => expect(userFilterUICall).toHaveBeenCalledTimes(2), { timeout: 3000 }); + + await user.click(screen.getByPlaceholderText("Search by email")); + await user.click(await screen.findByRole("option", { name: "picked@example.com" })); + + await user.click(save()); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ + user_email: "picked@example.com", + user_id: "u-1", + }); + }); + + it("does not submit on Enter in any field, while the button still does", async () => { + const { user, onSubmit } = setup(); + + await user.click(getEmailSearchInput()); + await user.keyboard("{Enter}"); + await user.click(screen.getByLabelText("User ID")); + await user.keyboard("{Enter}"); + await user.click(screen.getByLabelText("Member Role")); + await user.keyboard("{Escape}"); + await user.keyboard("{Enter}"); + expect(onSubmit).not.toHaveBeenCalled(); + + await user.click(save()); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx index 9c1d64a1f86..801d19244f8 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx @@ -1,9 +1,25 @@ import { useState } from "react"; -import { Modal, Form, Button, Select, Tooltip, Alert } from "antd"; +import { Modal, Alert } from "antd"; import { UserAddOutlined } from "@ant-design/icons"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; +import { useForm } from "react-hook-form"; import { userFilterUICall } from "@/components/networking"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; + interface User { user_id: string; user_email: string; @@ -13,7 +29,7 @@ interface User { interface UserOption { label: string; value: string; - user: User; + user: User | null; } interface Role { @@ -23,8 +39,8 @@ interface Role { } interface FormValues { - user_email: string; - user_id: string; + user_email: string | undefined; + user_id: string | undefined; role: string; } @@ -56,7 +72,8 @@ const UserSearchModal: React.FC = ({ defaultRole = "user", teamId, }) => { - const [form] = Form.useForm(); + const emptyValues: FormValues = { user_email: undefined, user_id: undefined, role: defaultRole }; + const form = useForm({ defaultValues: emptyValues }); const [userOptions, setUserOptions] = useState([]); const [loading, setLoading] = useState(false); const [selectedField, setSelectedField] = useState<"user_email" | "user_id">("user_email"); @@ -104,13 +121,10 @@ const UserSearchModal: React.FC = ({ debouncedSearch(value, fieldName); }; - const handleSelect = (_value: string, option: UserOption): void => { - const selectedUser = option.user; - form.setFieldsValue({ - user_email: selectedUser.user_email, - user_id: selectedUser.user_id, - role: form.getFieldValue("role"), // Preserve current role selection - }); + const handleSelect = (option: UserOption | null): void => { + if (option?.user == null) return; + form.setValue("user_email", option.user.user_email); + form.setValue("user_id", option.user.user_id); }; const handleSubmit = async (values: FormValues): Promise => { @@ -123,81 +137,125 @@ const UserSearchModal: React.FC = ({ }; 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 ( - - form={form} - onFinish={handleSubmit} - labelCol={{ span: 8 }} - wrapperCol={{ span: 16 }} - labelAlign="left" - initialValues={{ - role: defaultRole, - }} - > - - - - handleSearch(value, "user_id")} - onSelect={(value, option) => handleSelect(value, option as UserOption)} - options={selectedField === "user_id" ? userOptions : []} - loading={loading} - allowClear - /> - +
OR
- - - + + {({ id, value, onChange }) => renderUserSearch("user_id", "Search by user ID", { id, value, onChange })} + -
- -
- + + {({ 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"); + }); +});