mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
fix(ui): show the invite user button to org admins
This commit is contained in:
parent
ec3f8183c3
commit
528b9bc871
8 changed files with 188 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue