mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
feat(ui): allow custom user IDs in user invitations (#49749)
* feat(ui): allow custom user IDs in user invitations * test(ui): assert unexpected user-creation requests
This commit is contained in:
parent
fb2a1450dd
commit
7b550cdd94
3 changed files with 164 additions and 6 deletions
|
|
@ -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<string, unknown>) => 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(
|
||||
<QueryClientProvider client={client}>
|
||||
<CreateUserButton
|
||||
userID="admin"
|
||||
accessToken="test-token"
|
||||
possibleUIRoles={null}
|
||||
isEmbedded={isEmbedded}
|
||||
onUserCreated={onUserCreated}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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("");
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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<CreateuserProps> = ({
|
|||
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
|
||||
const defaultValues = isEmbedded ? EMBEDDED_DEFAULTS : STANDALONE_DEFAULTS;
|
||||
const form = useForm<CreateUserFormValues>({ defaultValues });
|
||||
const supplyUserId = useWatch({ control: form.control, name: "supply_user_id" });
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [apiuser, setApiuser] = useState<boolean>(false);
|
||||
const [userModels, setUserModels] = useState<string[]>([]);
|
||||
|
|
@ -205,6 +214,14 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
|||
};
|
||||
|
||||
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<CreateuserProps> = ({
|
|||
</FormField>
|
||||
);
|
||||
|
||||
const userIdField = (
|
||||
<>
|
||||
<FormField control={form.control} name="supply_user_id" label="Supply custom User ID" orientation="horizontal">
|
||||
{({ id, value, onChange, onBlur }) => (
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(checked);
|
||||
form.resetField("user_id");
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
{supplyUserId && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user_id"
|
||||
label="User ID"
|
||||
description="A unique ID for this user. Leave the checkbox unchecked to generate one automatically."
|
||||
>
|
||||
{({ ref, value, ...control }) => <Input {...control} ref={ref} value={value ?? ""} />}
|
||||
</FormField>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const teamField = (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
@ -325,6 +370,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
|||
<EmailInvitationsNotice />
|
||||
<FieldGroup>
|
||||
{userEmailField}
|
||||
{userIdField}
|
||||
{roleField(t("users.createUser.fields.userRole"))}
|
||||
{teamField}
|
||||
{metadataField}
|
||||
|
|
@ -357,6 +403,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
|||
<form onSubmit={form.handleSubmit(handleCreate)}>
|
||||
<FieldGroup>
|
||||
{userEmailField}
|
||||
{userIdField}
|
||||
{roleField(
|
||||
labelWithHint(t("users.createUser.fields.globalRole"), t("users.createUser.fields.globalRoleHint")),
|
||||
)}
|
||||
|
|
@ -436,7 +483,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
|||
</Collapsible>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-4 text-right">
|
||||
<div className="sticky bottom-0 -mx-6 mt-4 -mb-6 flex justify-end border-t bg-popover px-6 py-4">
|
||||
<Button type="submit">
|
||||
<UserPlus />
|
||||
{t("users.createUser.submitButton")}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue