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:
mrinal-berri 2026-11-05 17:18:00 -08:00 • committed by GitHub
parent fb2a1450dd
commit 7b550cdd94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 164 additions and 6 deletions

View file

@ -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("");
});
});

View file

@ -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", () => {

View file

@ -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")}