diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx index 7397eaa6b24..48094fdf87d 100644 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx @@ -1,6 +1,17 @@ -import { render } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, it, expect, vi } from "vitest"; +import Papa from "papaparse"; +import type { ParseConfig, ParseResult } from "papaparse"; import BulkCreateUsersButton from "./bulk_create_users_button"; +import { getProxyUISettings, invitationCreateCall, userCreateCall } from "./networking"; + +vi.mock("papaparse", () => ({ + default: { + parse: vi.fn(), + unparse: vi.fn(), + }, +})); vi.mock("./networking", () => ({ userCreateCall: vi.fn(), @@ -21,8 +32,64 @@ vi.mock("./molecules/notifications_manager", () => ({ })); describe("BulkCreateUsersButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.history.pushState({}, "", "/"); + + vi.mocked(getProxyUISettings).mockResolvedValue({ + PROXY_BASE_URL: null, + PROXY_LOGOUT_URL: null, + DEFAULT_TEAM_DISABLED: false, + SSO_ENABLED: false, + }); + vi.mocked(userCreateCall).mockResolvedValue({ + user_id: "user-1", + key: "key-1", + }); + vi.mocked(invitationCreateCall).mockResolvedValue({ + id: "invite-1", + }); + vi.mocked(Papa.parse).mockImplementation((_file: unknown, config?: ParseConfig) => { + const parsedCsv = { + data: [ + ["user_email", "user_role"], + ["person@example.com", "internal_user"], + ], + errors: [], + meta: {}, + } as ParseResult; + + config?.complete?.(parsedCsv); + return undefined as ReturnType; + }); + }); + it("should render", () => { const { getByText } = render(); expect(getByText("+ Bulk Invite Users")).toBeInTheDocument(); }); + + it("keeps the dashboard subpath in generated invitation links", async () => { + window.history.pushState({}, "", "/litellm/ui"); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("+ Bulk Invite Users")); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(input).not.toBeNull(); + + const csv = new File(["user_email,user_role\nperson@example.com,internal_user\n"], "users.csv", { + type: "text/csv", + }); + await user.upload(input, csv); + + await screen.findByText("person@example.com"); + await user.click(screen.getAllByRole("button", { name: /Create 1 Users/i })[0]); + + await waitFor(() => expect(invitationCreateCall).toHaveBeenCalledWith("test-token", "user-1")); + expect( + await screen.findByText(`${window.location.origin}/litellm/ui?invitation_id=invite-1`), + ).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx index 4c182bdfae4..97a8b90291d 100644 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx @@ -76,7 +76,11 @@ const BulkCreateUsersButton: React.FC = ({ fetchUISettings(); // Set base URL - const base = new URL("/", window.location.href); + const base = new URL(window.location.href); + const uiPathIndex = base.pathname.indexOf("/ui"); + base.pathname = uiPathIndex >= 0 ? base.pathname.slice(0, uiPathIndex + 1) : "/"; + base.search = ""; + base.hash = ""; setBaseUrl(base.toString()); }, [accessToken]); @@ -364,7 +368,7 @@ const BulkCreateUsersButton: React.FC = ({ if (!uiSettings?.SSO_ENABLED) { // Regular invitation flow const invitationData = await invitationCreateCall(accessToken, user_id); - const invitationUrl = new URL(`/ui?invitation_id=${invitationData.id}`, baseUrl).toString(); + const invitationUrl = new URL(`ui?invitation_id=${invitationData.id}`, baseUrl).toString(); setParsedData((current) => current.map((u, i) => @@ -380,7 +384,7 @@ const BulkCreateUsersButton: React.FC = ({ ); } else { // SSO flow - just use the base URL - const invitationUrl = new URL("/ui", baseUrl).toString(); + const invitationUrl = new URL("ui", baseUrl).toString(); setParsedData((current) => current.map((u, i) =>