diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c549f48126e..acff771cf65 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1711,6 +1711,18 @@ class NewUserRequest(GenerateRequestBase): sso_user_id: str | None = None organizations: list[str] | None = None + @model_validator(mode="before") + @classmethod + def _accept_singular_organization_id(cls, values: object) -> object: + if not isinstance(values, dict): + return values + organization_id: Final = values.get("organization_id") + current: Final = values.get("organizations") + organizations: Final[list[object]] = list(current) if isinstance(current, list) else [] + if not isinstance(organization_id, str) or organization_id in organizations: + return values + return {**values, "organizations": [*organizations, organization_id]} + class NewUserResponse(GenerateKeyResponse): max_budget: float | None = None diff --git a/tests/test_litellm/proxy/test__types.py b/tests/test_litellm/proxy/test__types.py index 26bb1533da4..2b6ab2a8b7c 100644 --- a/tests/test_litellm/proxy/test__types.py +++ b/tests/test_litellm/proxy/test__types.py @@ -10,6 +10,7 @@ from litellm.proxy._types import ( LiteLLM_AuditLogs, LiteLLM_TeamMembership, LitellmUserRoles, + NewUserRequest, OrganizationMemberUpdateRequest, ResetSpendRequest, UpdateKeyRequest, @@ -277,3 +278,32 @@ def test_team_membership_budget_table_present_still_works(): } result = LiteLLM_TeamMembership.model_validate(data) assert result.litellm_budget_table is None + + +def test_a_singular_organization_id_reaches_the_organizations_list(): + request = NewUserRequest.model_validate({"user_email": "member@org.test", "organization_id": "org-1"}) + + assert request.organizations == ["org-1"] + + +def test_a_singular_organization_id_is_merged_with_an_organizations_list(): + request = NewUserRequest.model_validate( + {"user_email": "member@org.test", "organization_id": "org-2", "organizations": ["org-1"]} + ) + + assert request.organizations == ["org-1", "org-2"] + + +def test_an_organization_named_both_ways_is_not_duplicated(): + request = NewUserRequest.model_validate( + {"user_email": "member@org.test", "organization_id": "org-1", "organizations": ["org-1"]} + ) + + assert request.organizations == ["org-1"] + + +@pytest.mark.parametrize("body", [{}, {"organization_id": None}]) +def test_a_request_without_an_organization_id_keeps_its_organizations_untouched(body): + request = NewUserRequest.model_validate({"user_email": "member@org.test", **body}) + + assert request.organizations is None diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx index 095bdbf7250..22b6e8f849e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx @@ -66,6 +66,25 @@ const makeUser = (userId: string, email: string) => ({ budget_duration: null, }); +const session = vi.hoisted(() => ({ + userId: "admin-user-id", + userRole: "Admin", + organizations: [] as unknown[], +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ + accessToken: "test-token", + token: "test-token", + userId: session.userId, + userRole: session.userRole, + }), +})); + +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: () => ({ data: session.organizations }), +})); + const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -84,16 +103,21 @@ const defaultProps = { teams: [], }; -const renderDashboard = (overrides: Partial = {}) => - renderWithProviders( +const renderDashboard = (overrides: Partial = {}) => { + const props = { ...defaultProps, ...overrides }; + session.userId = props.userID; + session.userRole = props.userRole; + return renderWithProviders( - + , ); +}; describe("ViewUserDashboard", () => { beforeEach(() => { vi.clearAllMocks(); + session.organizations = []; userListCall.mockResolvedValue({ users: [makeUser("user-1", "test@example.com")], total: 1, @@ -161,6 +185,29 @@ describe("ViewUserDashboard", () => { expect(screen.queryByRole("button", { name: /\+ bulk invite users/i })).not.toBeInTheDocument(); }); + it("shows the invite button to an org admin, whose session role is only Internal User", async () => { + session.organizations = [ + { organization_id: "org-1", members: [{ user_id: "org-admin-user", user_role: "org_admin" }] }, + ]; + + renderDashboard({ userRole: "Internal User", userID: "org-admin-user" }); + + expect(await screen.findByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /\+ bulk invite users/i })).not.toBeInTheDocument(); + expect(screen.queryByTestId("toggle-user-selection")).not.toBeInTheDocument(); + }); + + it("keeps the invite button hidden from a plain member of an organization", async () => { + session.organizations = [ + { organization_id: "org-1", members: [{ user_id: "member-user", user_role: "internal_user" }] }, + ]; + + renderDashboard({ userRole: "Internal User", userID: "member-user" }); + + expect(await screen.findByText("test@example.com")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /\+ invite user/i })).not.toBeInTheDocument(); + }); + it("keeps actions unavailable while the user list is loading", () => { userListCall.mockReturnValue(new Promise(() => undefined)); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx index 7a3133840be..39d333ad199 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx @@ -18,6 +18,7 @@ import OnboardingModal, { InvitationLink } from "@/components/onboarding_link"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; +import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -59,6 +60,7 @@ const ViewUserDashboard: React.FC = ({ orgAdminOrgIds, }) => { const isProxyAdmin = userRole ? isProxyAdminRole(userRole) : false; + const isOrgAdmin = useIsOrgAdmin(); const queryClient = useQueryClient(); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE }); @@ -327,7 +329,7 @@ const ViewUserDashboard: React.FC = ({ )} {!userListQuery.isLoading && userID && accessToken && ( <> - {isProxyAdmin && ( + {(isProxyAdmin || isOrgAdmin) && ( )} diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx index 3777c46973f..3420812ec74 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -342,6 +342,54 @@ describe("CreateUserButton", () => { }); }); + it("preselects and posts the organization the caller administers", async () => { + const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations"); + vi.mocked(useOrganizations).mockReturnValue({ + data: [ + { + organization_id: "org-1", + organization_alias: "My Org", + members: [{ user_id: "123", user_role: "org_admin" }], + }, + { + organization_id: "org-2", + organization_alias: "Someone Else's Org", + members: [{ user_id: "999", user_role: "org_admin" }], + }, + ], + isLoading: false, + } as any); + + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "org-user" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-preselect", + user_id: "org-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 }); + fireEvent.change(within(dialog).getByLabelText(/user email/i), { target: { value: "member@example.com" } }); + await user.click(within(dialog).getByRole("button", { name: /invite user/i })); + + await waitFor(() => { + expect(mockUserCreateCall).toHaveBeenCalledWith( + "token", + null, + expect.objectContaining({ organizations: ["org-1"] }), + ); + }); + }); + it("should not call organizationMemberAddCall after user creation", async () => { const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations"); vi.mocked(useOrganizations).mockReturnValue({ diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.tsx index e052a9e0818..87da06dd2e4 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.tsx @@ -26,6 +26,7 @@ import { userCreateCall, } from "./networking"; import OnboardingModal, { InvitationLink } from "./onboarding_link"; +import { orgsAdministeredBy } from "@/utils/roles"; // Helper function to generate UUID compatible across all environments const generateUUID = (): string => { @@ -170,6 +171,9 @@ export const CreateUserButton: React.FC = ({ label: `${org.organization_alias} (${org.organization_id})`, value: org.organization_id ?? "", })); + const administeredOrganizationIds = orgsAdministeredBy(organizations, userID) + .map((org) => org.organization_id) + .filter((id): id is string => Boolean(id)); useEffect(() => { const fetchData = async () => { @@ -193,6 +197,14 @@ export const CreateUserButton: React.FC = ({ fetchData(); }, []); + const handleOpen = () => { + form.reset({ + ...defaultValues, + organization_ids: administeredOrganizationIds.length > 0 ? administeredOrganizationIds : undefined, + }); + setIsModalVisible(true); + }; + const handleCancel = () => { setIsModalVisible(false); setApiuser(false); @@ -325,7 +337,7 @@ export const CreateUserButton: React.FC = ({ // Original return for standalone mode return ( <> - !open && handleCancel()}> diff --git a/ui/litellm-dashboard/src/utils/roles.test.ts b/ui/litellm-dashboard/src/utils/roles.test.ts index 0170d01d8e6..ed7c12f1d4e 100644 --- a/ui/litellm-dashboard/src/utils/roles.test.ts +++ b/ui/litellm-dashboard/src/utils/roles.test.ts @@ -6,6 +6,7 @@ import { isAdminRole, spendScopeUserId, isOrgAdminForAnyOrg, + orgsAdministeredBy, isOrgAdminSessionRole, isProxyAdminRole, isUserTeamAdminForAnyTeam, @@ -192,6 +193,27 @@ describe("roles", () => { }); }); + describe("orgsAdministeredBy", () => { + const org = (organization_id: string, members: { user_id: string; user_role: string }[]): Organization => + ({ organization_id, members }) as unknown as Organization; + + it("returns only the organizations the user administers", () => { + const organizations = [ + org("org-1", [{ user_id: "user-1", user_role: "internal_user" }]), + org("org-2", [{ user_id: "user-1", user_role: "org_admin" }]), + org("org-3", [{ user_id: "user-2", user_role: "org_admin" }]), + ]; + + expect(orgsAdministeredBy(organizations, "user-1").map((o) => o.organization_id)).toEqual(["org-2"]); + }); + + it("returns an empty list for missing organizations or a missing user id", () => { + expect(orgsAdministeredBy(null, "user-1")).toEqual([]); + expect(orgsAdministeredBy(undefined, "user-1")).toEqual([]); + expect(orgsAdministeredBy([org("org-1", [{ user_id: "user-1", user_role: "org_admin" }])], null)).toEqual([]); + }); + }); + describe("isOrgAdminSessionRole", () => { it("accepts both the raw and the formatted org admin role", () => { expect(isOrgAdminSessionRole("org_admin")).toBe(true); diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index 62a5f02cc39..4ea81714904 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -46,19 +46,24 @@ export const isUserTeamAdminForSingleTeam = (teamMemberWithRoles: Member[] | nul return teamMemberWithRoles.some((member) => member.user_id === userID && member.role === "admin"); }; -export const isOrgAdminForAnyOrg = ( +export const orgsAdministeredBy = ( organizations: Organization[] | null | undefined, userID: string | null | undefined, -): boolean => { +): Organization[] => { if (organizations == null || !userID) { - return false; + return []; } - return organizations.some((org) => { + return organizations.filter((org) => { const members: OrganizationMembership[] = org.members ?? []; return members.some((member) => member.user_id === userID && member.user_role === ORG_ADMIN_MEMBERSHIP_ROLE); }); }; +export const isOrgAdminForAnyOrg = ( + organizations: Organization[] | null | undefined, + userID: string | null | undefined, +): boolean => orgsAdministeredBy(organizations, userID).length > 0; + export const formatUserRole = (userRole: string): string => { if (!userRole) { return "Undefined Role";