fix(ui): preserve subpath in bulk invite links

This commit is contained in:
YankhangSIT 2026-06-17 22:40:23 +08:00
parent cee6c9c724
commit a393ec65e7
2 changed files with 76 additions and 5 deletions

View file

@ -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<string[]>) => {
const parsedCsv = {
data: [
["user_email", "user_role"],
["person@example.com", "internal_user"],
],
errors: [],
meta: {},
} as ParseResult<string[]>;
config?.complete?.(parsedCsv);
return undefined as ReturnType<typeof Papa.parse>;
});
});
it("should render", () => {
const { getByText } = render(<BulkCreateUsersButton accessToken="test-token" teams={[]} possibleUIRoles={null} />);
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(<BulkCreateUsersButton accessToken="test-token" teams={[]} possibleUIRoles={null} />);
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();
});
});

View file

@ -76,7 +76,11 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
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<BulkCreateUsersProps> = ({
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<BulkCreateUsersProps> = ({
);
} 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) =>