diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.integration.test.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.integration.test.tsx new file mode 100644 index 00000000000..e89211596f3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CreateUserButton.integration.test.tsx @@ -0,0 +1,111 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CreateUserButton } from "./CreateUserButton"; + +const createUser = vi.fn<(body: Record) => Response>(); +const unexpectedRequest = vi.fn((path: string) => + Response.json({ error: `Unexpected request ${path}` }, { status: 500 }), +); + +beforeEach(() => { + unexpectedRequest.mockClear(); + createUser.mockReset(); + createUser.mockImplementation((body) => Response.json({ user_id: body.user_id ?? "generated-id" })); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = new URL(input instanceof Request ? input.url : String(input), "http://localhost").pathname; + if (path === "/user/new") return createUser(JSON.parse(String(init?.body))); + if (path === "/models") return Response.json({ data: [] }); + if (path === "/sso/get/ui_settings") return Response.json({ SSO_ENABLED: true }); + if (path === "/v2/team/list") return Response.json({ teams: [], total: 0, total_pages: 0 }); + return unexpectedRequest(path); + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + expect(unexpectedRequest).not.toHaveBeenCalled(); +}); + +const renderForm = (isEmbedded: boolean, onUserCreated = vi.fn()) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + render( + + + , + ); + return onUserCreated; +}; + +describe("custom User ID invitation", () => { + it.each([true, false])("sends the custom ID from the form (embedded=%s)", async (embedded) => { + const user = userEvent.setup(); + const onCreated = renderForm(embedded); + if (!embedded) await user.click(screen.getByRole("button", { name: "+ Invite User" })); + await user.click(screen.getByRole("checkbox", { name: "Supply custom User ID" })); + fireEvent.change(screen.getByRole("textbox", { name: "User ID", exact: true }), { + target: { value: " employee-42 " }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "User Email" }), { + target: { value: "employee@example.test" }, + }); + await user.click(screen.getByRole("button", { name: embedded ? "Create User" : "Invite User", exact: true })); + await waitFor(() => + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: "employee-42", + user_email: "employee@example.test", + auto_create_key: false, + }), + ), + ); + expect(createUser.mock.calls[0][0]).not.toHaveProperty("supply_user_id"); + if (embedded) { + await waitFor(() => expect(onCreated).toHaveBeenCalledWith("employee-42")); + expect(screen.getByRole("checkbox", { name: "Supply custom User ID" })).not.toBeChecked(); + expect(screen.queryByRole("textbox", { name: "User ID", exact: true })).not.toBeInTheDocument(); + } + }); + + it("blocks whitespace and clears the custom ID when automatic generation is selected", async () => { + const user = userEvent.setup(); + renderForm(true); + await user.click(screen.getByRole("checkbox", { name: "Supply custom User ID" })); + fireEvent.change(screen.getByRole("textbox", { name: "User ID", exact: true }), { target: { value: " " } }); + await user.click(screen.getByRole("button", { name: "Create User" })); + expect(await screen.findByText("Enter a User ID")).toBeInTheDocument(); + expect(createUser).not.toHaveBeenCalled(); + fireEvent.change(screen.getByRole("textbox", { name: "User ID", exact: true }), { + target: { value: "discard-me" }, + }); + await user.click(screen.getByRole("checkbox", { name: "Supply custom User ID" })); + await user.click(screen.getByRole("button", { name: "Create User" })); + await waitFor(() => expect(createUser).toHaveBeenCalledWith(expect.objectContaining({ user_id: null }))); + expect(createUser.mock.calls[0][0]).not.toHaveProperty("supply_user_id"); + }); + + it("discards a custom ID when an invitation is cancelled", async () => { + const user = userEvent.setup(); + renderForm(false); + await user.click(screen.getByRole("button", { name: "+ Invite User" })); + await user.click(screen.getByRole("checkbox", { name: "Supply custom User ID" })); + fireEvent.change(screen.getByRole("textbox", { name: "User ID", exact: true }), { target: { value: "cancelled" } }); + await user.click( + within(screen.getByRole("dialog", { name: "Invite User" })).getByRole("button", { name: "Close" }), + ); + await user.click(screen.getByRole("button", { name: "+ Invite User" })); + expect(screen.getByRole("checkbox", { name: "Supply custom User ID" })).not.toBeChecked(); + await user.click(screen.getByRole("checkbox", { name: "Supply custom User ID" })); + expect(screen.getByRole("textbox", { name: "User ID", exact: true })).toHaveValue(""); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx index 94b3726ede0..d04272cecd3 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx @@ -308,7 +308,7 @@ describe("CreateUserButton", () => { const dialog = screen.getByRole("dialog", { name: /invite user/i }); - expectControlBesideLabel(within(dialog).getByRole("checkbox")); + expectControlBesideLabel(within(dialog).getByRole("checkbox", { name: "Send invitation email" })); }); describe("organizations", () => { diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.tsx index 6fbe8f2d2bd..bf188d36716 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.tsx @@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { ChevronRight, CircleHelp, Info, UserPlus } from "lucide-react"; import React, { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; import TeamDropdown from "./common_components/team_dropdown"; import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; @@ -58,6 +58,8 @@ interface UISettings { } interface CreateUserFormValues { + supply_user_id: boolean; + user_id?: string; user_email?: string; user_role: string; team_id?: string | null; @@ -68,6 +70,8 @@ interface CreateUserFormValues { } const EMBEDDED_DEFAULTS: CreateUserFormValues = { + supply_user_id: false, + user_id: "", user_email: undefined, user_role: "internal_user_viewer", team_id: undefined, @@ -76,6 +80,8 @@ const EMBEDDED_DEFAULTS: CreateUserFormValues = { }; const STANDALONE_DEFAULTS: CreateUserFormValues = { + supply_user_id: false, + user_id: "", user_email: undefined, user_role: "internal_user_viewer", team_id: undefined, @@ -95,11 +101,13 @@ const withoutCollapsedModels = (values: CreateUserFormValues, isPersonalKeyOpen: return rest; }; -const buildCreatePayload = (values: CreateUserFormValues): CreateUserFormValues & { organizations?: string[] } => { +const buildCreatePayload = (values: CreateUserFormValues) => { + const { supply_user_id, user_id, ...fields } = values; + const apiValues = supply_user_id ? { ...fields, user_id: user_id?.trim() } : fields; const withModels = (!values.models || values.models.length === 0) && values.user_role !== "proxy_admin" - ? { ...values, models: ["no-default-models"] } - : values; + ? { ...apiValues, models: ["no-default-models"] } + : apiValues; if (!withModels.organization_ids) { return withModels; } @@ -163,6 +171,7 @@ export const CreateUserButton: React.FC = ({ const [uiSettings, setUISettings] = useState(null); const defaultValues = isEmbedded ? EMBEDDED_DEFAULTS : STANDALONE_DEFAULTS; const form = useForm({ defaultValues }); + const supplyUserId = useWatch({ control: form.control, name: "supply_user_id" }); const [isModalVisible, setIsModalVisible] = useState(false); const [apiuser, setApiuser] = useState(false); const [userModels, setUserModels] = useState([]); @@ -205,6 +214,14 @@ export const CreateUserButton: React.FC = ({ }; const handleCreate = async (formValues: CreateUserFormValues) => { + if (formValues.supply_user_id && !formValues.user_id?.trim()) { + form.setError("user_id", { message: "Enter a User ID" }, { shouldFocus: true }); + return; + } + if (formValues.supply_user_id && formValues.user_id?.includes("\u0000")) { + form.setError("user_id", { message: "User ID must not contain NUL characters" }, { shouldFocus: true }); + return; + } try { toast.info(t("users.createUser.toasts.makingApiCall")); if (!isEmbedded) { @@ -255,6 +272,34 @@ export const CreateUserButton: React.FC = ({ ); + const userIdField = ( + <> + + {({ id, value, onChange, onBlur }) => ( + { + onChange(checked); + form.resetField("user_id"); + }} + onBlur={onBlur} + /> + )} + + {supplyUserId && ( + + {({ ref, value, ...control }) => } + + )} + + ); + const teamField = ( = ({ {userEmailField} + {userIdField} {roleField(t("users.createUser.fields.userRole"))} {teamField} {metadataField} @@ -357,6 +403,7 @@ export const CreateUserButton: React.FC = ({
{userEmailField} + {userIdField} {roleField( labelWithHint(t("users.createUser.fields.globalRole"), t("users.createUser.fields.globalRoleHint")), )} @@ -436,7 +483,7 @@ export const CreateUserButton: React.FC = ({ -
+