fix(ui): show the invite user button to org admins

This commit is contained in:
yatishgoel 2026-09-01 16:09:36 +05:30
parent ec3f8183c3
commit 528b9bc871
8 changed files with 188 additions and 10 deletions

View file

@ -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

View file

@ -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

View file

@ -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<typeof defaultProps> = {}) =>
renderWithProviders(
const renderDashboard = (overrides: Partial<typeof defaultProps> = {}) => {
const props = { ...defaultProps, ...overrides };
session.userId = props.userID;
session.userRole = props.userRole;
return renderWithProviders(
<QueryClientProvider client={createQueryClient()}>
<ViewUserDashboard {...defaultProps} {...overrides} />
<ViewUserDashboard {...props} />
</QueryClientProvider>,
);
};
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));

View file

@ -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<ViewUserDashboardProps> = ({
orgAdminOrgIds,
}) => {
const isProxyAdmin = userRole ? isProxyAdminRole(userRole) : false;
const isOrgAdmin = useIsOrgAdmin();
const queryClient = useQueryClient();
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE });
@ -327,7 +329,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
)}
{!userListQuery.isLoading && userID && accessToken && (
<>
{isProxyAdmin && (
{(isProxyAdmin || isOrgAdmin) && (
<CreateUserButton userID={userID} accessToken={accessToken} possibleUIRoles={possibleUIRoles} />
)}

View file

@ -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(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
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({

View file

@ -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<CreateuserProps> = ({
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<CreateuserProps> = ({
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<CreateuserProps> = ({
// Original return for standalone mode
return (
<>
<Button type="button" onClick={() => setIsModalVisible(true)}>
<Button type="button" onClick={handleOpen}>
+ Invite User
</Button>
<Dialog open={isModalVisible} onOpenChange={(open) => !open && handleCancel()}>

View file

@ -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);

View file

@ -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";