From 409d12b7a5e3abf88d726621562fc6f591f78c11 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 9 Feb 2026 14:40:51 -0800 Subject: [PATCH 1/6] Add alert about email notifications --- .../src/components/CreateUserButton.test.tsx | 297 ++++++++++++++++++ ...e_user_button.tsx => CreateUserButton.tsx} | 141 +++++---- .../components/create_user_button.test.tsx | 35 --- .../organisms/create_key_button.tsx | 4 +- .../src/components/view_users.tsx | 4 +- 5 files changed, 373 insertions(+), 108 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/CreateUserButton.test.tsx rename ui/litellm-dashboard/src/components/{create_user_button.tsx => CreateUserButton.tsx} (81%) delete mode 100644 ui/litellm-dashboard/src/components/create_user_button.test.tsx diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx new file mode 100644 index 00000000000..bfaddacc319 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx @@ -0,0 +1,297 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { CreateUserButton } from "./CreateUserButton"; +import * as networking from "./networking"; +import NotificationsManager from "./molecules/notifications_manager"; + +vi.mock("./networking", () => ({ + userCreateCall: vi.fn(), + modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }), + invitationCreateCall: vi.fn(), + getProxyUISettings: vi.fn().mockResolvedValue({ + PROXY_BASE_URL: null, + PROXY_LOGOUT_URL: null, + DEFAULT_TEAM_DISABLED: false, + SSO_ENABLED: false, + }), + getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost"), +})); + +vi.mock("./bulk_create_users_button", () => ({ + default: () =>
Bulk Create Users
, +})); + +const mockUserCreateCall = vi.mocked(networking.userCreateCall); +const mockInvitationCreateCall = vi.mocked(networking.invitationCreateCall); +const mockGetProxyUISettings = vi.mocked(networking.getProxyUISettings); +const mockNotificationsManager = vi.mocked(NotificationsManager); + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + +const defaultProps = { + userID: "123", + accessToken: "token", + teams: [], + possibleUIRoles: null as Record> | null, +}; + +function renderWithProviders(ui: React.ReactElement) { + const qc = createQueryClient(); + return render({ui}); +} + +describe("CreateUserButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetProxyUISettings.mockResolvedValue({ + PROXY_BASE_URL: null, + PROXY_LOGOUT_URL: null, + DEFAULT_TEAM_DISABLED: false, + SSO_ENABLED: false, + }); + }); + + it("should render the create user form when embedded", () => { + renderWithProviders( + , + ); + expect(screen.getByRole("button", { name: /create user/i })).toBeInTheDocument(); + }); + + it("should render the invite user button when not embedded", async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + }); + + it("should open the invite modal when invite user button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: /invite user/i })).toBeInTheDocument(); + }); + + it("should display email invitations info message in embedded mode", () => { + renderWithProviders(); + expect(screen.getByText("Email invitations")).toBeInTheDocument(); + }); + + it("should display user role options when possibleUIRoles is provided", async () => { + const possibleUIRoles = { + proxy_admin: { ui_label: "Admin", description: "Full access" }, + proxy_user: { ui_label: "User", description: "Limited access" }, + }; + renderWithProviders( + , + ); + await userEvent.click(screen.getByRole("combobox", { name: /user role/i })); + expect(screen.getByText("Admin")).toBeInTheDocument(); + expect(screen.getByText("User")).toBeInTheDocument(); + }); + + it("should call userCreateCall when form is submitted in embedded mode", async () => { + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-123" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-1", + user_id: "new-user-123", + has_user_setup_sso: false, + } as any); + + renderWithProviders( + , + ); + + await user.type(screen.getByLabelText(/user email/i), "test@example.com"); + await user.click(screen.getByRole("combobox", { name: /user role/i })); + await user.click(screen.getByText("User")); + await user.click(screen.getByRole("button", { name: /create user/i })); + + await waitFor(() => { + expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.objectContaining({ + user_email: "test@example.com", + user_role: "proxy_user", + })); + }); + }); + + it("should call onUserCreated callback when user is created in embedded mode", async () => { + const user = userEvent.setup(); + const onUserCreated = vi.fn(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-456" } }); + + renderWithProviders( + , + ); + + await user.type(screen.getByLabelText(/user email/i), "embedded@example.com"); + await user.click(screen.getByRole("combobox", { name: /user role/i })); + await user.click(screen.getByText("User")); + await user.click(screen.getByRole("button", { name: /create user/i })); + + await waitFor(() => { + expect(onUserCreated).toHaveBeenCalledWith("new-user-456"); + }); + }); + + it("should show success notification when user is created successfully in standalone mode", async () => { + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-789" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-2", + user_id: "new-user-789", + has_user_setup_sso: false, + } as any); + + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); + + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + await user.type(within(dialog).getByLabelText(/user email/i), "standalone@example.com"); + await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); + await user.click(screen.getByText("User")); + await user.click(within(dialog).getByRole("button", { name: /invite user/i })); + + await waitFor(() => { + expect(mockNotificationsManager.success).toHaveBeenCalledWith("API user Created"); + }); + }); + + it("should show error notification when user creation fails", async () => { + const user = userEvent.setup(); + mockUserCreateCall.mockRejectedValue({ response: { data: { detail: "Email already exists" } } }); + + renderWithProviders( + , + ); + + await user.type(screen.getByLabelText(/user email/i), "duplicate@example.com"); + await user.click(screen.getByRole("combobox", { name: /user role/i })); + await user.click(screen.getByText("User")); + await user.click(screen.getByRole("button", { name: /create user/i })); + + await waitFor(() => { + expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Email already exists"); + }); + }); + + it("should show info notification when making API call", async () => { + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-3", + user_id: "new-user", + has_user_setup_sso: false, + } as any); + + renderWithProviders( + , + ); + + await user.type(screen.getByLabelText(/user email/i), "info@example.com"); + await user.click(screen.getByRole("combobox", { name: /user role/i })); + await user.click(screen.getByText("User")); + await user.click(screen.getByRole("button", { name: /create user/i })); + + await waitFor(() => { + expect(mockNotificationsManager.info).toHaveBeenCalledWith("Making API Call"); + }); + }); + + it("should close modal when cancel is clicked in standalone mode", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); + expect(screen.getByRole("dialog", { name: /invite user/i })).toBeInTheDocument(); + + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + await user.click(within(dialog).getByRole("button", { name: /close/i })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("should show onboarding modal when user is created and SSO is disabled", async () => { + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "sso-user" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-sso", + user_id: "sso-user", + has_user_setup_sso: false, + } as any); + + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); + + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + await user.type(within(dialog).getByLabelText(/user email/i), "sso@example.com"); + await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); + await user.click(screen.getByText("User")); + await user.click(within(dialog).getByRole("button", { name: /invite user/i })); + + await waitFor(() => { + expect(mockInvitationCreateCall).toHaveBeenCalledWith("token", "sso-user"); + }); + await waitFor(() => { + expect(mockNotificationsManager.success).toHaveBeenCalledWith("API user Created"); + }); + }); + + it("should use SSO flow without invitationCreateCall when SSO is enabled", async () => { + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "sso-enabled-user" } }); + mockGetProxyUISettings.mockResolvedValue({ + PROXY_BASE_URL: "http://localhost", + PROXY_LOGOUT_URL: null, + DEFAULT_TEAM_DISABLED: false, + SSO_ENABLED: true, + }); + + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); + + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + await user.type(within(dialog).getByLabelText(/user email/i), "sso-enabled@example.com"); + await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); + await user.click(screen.getByText("User")); + await user.click(within(dialog).getByRole("button", { name: /invite user/i })); + + await waitFor(() => { + expect(mockInvitationCreateCall).not.toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mockNotificationsManager.success).toHaveBeenCalledWith("API user Created"); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/create_user_button.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.tsx similarity index 81% rename from ui/litellm-dashboard/src/components/create_user_button.tsx rename to ui/litellm-dashboard/src/components/CreateUserButton.tsx index da155b042a3..3a15fbd5055 100644 --- a/ui/litellm-dashboard/src/components/create_user_button.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.tsx @@ -1,33 +1,29 @@ -import React, { useState, useEffect } from "react"; -import { Button, Modal, Form, Input, Select, Select as Select2 } from "antd"; -import { - Button as Button2, - Text, - TextInput, - SelectItem, - Accordion, - AccordionHeader, - AccordionBody, - Title, -} from "@tremor/react"; -import OnboardingModal from "./onboarding_link"; -import { InvitationLink } from "./onboarding_link"; -import { - userCreateCall, - modelAvailableCall, - invitationCreateCall, - getProxyUISettings, - getProxyBaseUrl, -} from "./networking"; -import BulkCreateUsers from "./bulk_create_users_button"; -const { Option } = Select; -import { Tooltip } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; +import { InfoCircleOutlined, UserAddOutlined } from "@ant-design/icons"; import { useQueryClient } from "@tanstack/react-query"; -import NotificationsManager from "./molecules/notifications_manager"; +import { + Accordion, + AccordionBody, + AccordionHeader, + Button as Button2, + SelectItem, + TextInput, +} from "@tremor/react"; +import { Alert, Button, Form, Input, Modal, Select, Select as Select2, Space, Tooltip, Typography } from "antd"; +import React, { useEffect, useState } from "react"; +import BulkCreateUsers from "./bulk_create_users_button"; import TeamDropdown from "./common_components/team_dropdown"; - +import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; +import NotificationsManager from "./molecules/notifications_manager"; +import { + getProxyBaseUrl, + getProxyUISettings, + invitationCreateCall, + modelAvailableCall, + userCreateCall, +} from "./networking"; +import OnboardingModal, { InvitationLink } from "./onboarding_link"; +const { Option } = Select; +const { Text, Link, Title } = Typography; // Helper function to generate UUID compatible across all environments const generateUUID = (): string => { if (typeof crypto !== "undefined" && crypto.randomUUID) { @@ -58,14 +54,8 @@ interface UISettings { SSO_ENABLED: boolean; } -const Createuser: React.FC = ({ - userID, - accessToken, - teams, - possibleUIRoles, - onUserCreated, - isEmbedded = false, -}) => { +export const CreateUserButton: React.FC = ({ + userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false }) => { const queryClient = useQueryClient(); const [uiSettings, setUISettings] = useState(null); const [form] = Form.useForm(); @@ -75,28 +65,18 @@ const Createuser: React.FC = ({ const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false); const [invitationLinkData, setInvitationLinkData] = useState(null); const [baseUrl, setBaseUrl] = useState(null); - // get all models useEffect(() => { const fetchData = async () => { try { - const userRole = "any"; // You may need to get the user role dynamically + const userRole = "any"; const modelDataResponse = await modelAvailableCall(accessToken, userID, userRole); - // Assuming modelDataResponse.data contains an array of model objects with a 'model_name' property const availableModels = []; for (let i = 0; i < modelDataResponse.data.length; i++) { const model = modelDataResponse.data[i]; availableModels.push(model.id); } - console.log("Model data response:", modelDataResponse.data); - console.log("Available models:", availableModels); - - // Assuming modelDataResponse.data contains an array of model names setUserModels(availableModels); - - // get ui settings const uiSettingsResponse = await getProxyUISettings(accessToken); - console.log("uiSettingsResponse:", uiSettingsResponse); - setUISettings(uiSettingsResponse); } catch (error) { console.error("Error fetching model data:", error); @@ -104,9 +84,8 @@ const Createuser: React.FC = ({ }; setBaseUrl(getProxyBaseUrl()); - - fetchData(); // Call the function to fetch model data when the component mounts - }, []); // Empty dependency array to run only once + fetchData(); + }, []); const handleOk = () => { setIsModalVisible(false); @@ -126,25 +105,19 @@ const Createuser: React.FC = ({ setIsModalVisible(true); } if ((!formValues.models || formValues.models.length === 0) && formValues.user_role !== "proxy_admin") { - console.log("formValues.user_role", formValues.user_role); - // If models is empty or undefined, set it to "no-default-models" formValues.models = ["no-default-models"]; } - console.log("formValues in create user:", formValues); const response = await userCreateCall(accessToken, null, formValues); await queryClient.invalidateQueries({ queryKey: ["userList"] }); - console.log("user create Response:", response); setApiuser(true); const user_id = response.data?.user_id || response.user_id; - // Call the callback if provided (for embedded mode) if (onUserCreated && isEmbedded) { onUserCreated(user_id); form.resetFields(); - return; // Skip the invitation flow when embedded + return; } - // only do invite link flow if sso is not enabled if (!uiSettings?.SSO_ENABLED) { invitationCreateCall(accessToken, user_id).then((data) => { data.has_user_setup_sso = false; @@ -184,6 +157,21 @@ const Createuser: React.FC = ({ if (isEmbedded) { return (
+ + New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured. + {" "} + + Learn how to set up email notifications + + + } + type="info" + showIcon + className="mb-4" + /> @@ -194,9 +182,9 @@ const Createuser: React.FC = ({
{ui_label}{" "} -

+ {description} -

+
))} @@ -234,16 +222,33 @@ const Createuser: React.FC = ({ onOk={handleOk} onCancel={handleCancel} > - Create a User who can own keys + + Create a User who can own keys + + New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured. + {" "} + + Learn how to set up email notifications + + + } + type="info" + showIcon + className="mb-4" + /> + - + Global Proxy Role{" "} - + @@ -256,9 +261,9 @@ const Createuser: React.FC = ({
{ui_label}{" "} -

+ {description} -

+
))} @@ -279,7 +284,7 @@ const Createuser: React.FC = ({
- Personal Key Creation + Personal Key Creation = ({
- +
@@ -326,6 +331,4 @@ const Createuser: React.FC = ({ )} ); -}; - -export default Createuser; +}; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/create_user_button.test.tsx b/ui/litellm-dashboard/src/components/create_user_button.test.tsx deleted file mode 100644 index e40a1e0ac3c..00000000000 --- a/ui/litellm-dashboard/src/components/create_user_button.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from "react"; -import { render } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import Createuser from "./create_user_button"; - -vi.mock("./networking", () => ({ - userCreateCall: vi.fn(), - modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }), - invitationCreateCall: vi.fn(), - getProxyUISettings: vi.fn().mockResolvedValue({ - PROXY_BASE_URL: null, - PROXY_LOGOUT_URL: null, - DEFAULT_TEAM_DISABLED: false, - SSO_ENABLED: false, - }), - getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost"), -})); - -const createQueryClient = () => - new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0 } }, - }); - -describe("Create User Button", () => { - it("should render the create user button", () => { - const qc = createQueryClient(); - const { getByText } = render( - - - , - ); - expect(getByText("Create User")).toBeInTheDocument(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 80037280b63..abadbe10590 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -21,7 +21,7 @@ import PremiumLoggingSettings from "../common_components/PremiumLoggingSettings" import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem"; import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "../common_components/RouterSettingsAccordion"; import TeamDropdown from "../common_components/team_dropdown"; -import Createuser from "../create_user_button"; +import { CreateUserButton } from "../CreateUserButton"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; import { Team } from "../key_team_helpers/key_list"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; @@ -1347,7 +1347,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { footer={null} width={800} > - = ({ accessToken, toke ) : userID && accessToken ? ( <> - +