From cb7da3044ddcfd193336fd7622a6c34870bdf08a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 7 Mar 2026 17:40:29 -0800 Subject: [PATCH] feat: allow org admins to view Internal Users page and invite users Org admins can now see the Internal Users page in the left nav, view users scoped to their organization(s), and invite new users who are automatically added to the org. Proxy admins remain unaffected. Co-Authored-By: Claude Opus 4.6 --- .../internal_user_endpoints.py | 12 ++ .../src/app/(dashboard)/users/page.tsx | 22 ++- .../src/components/CreateUserButton.tsx | 16 +- .../src/components/leftnav.tsx | 4 +- .../src/components/networking.tsx | 5 + .../src/components/view_users.tsx | 163 +++++++++++------- 6 files changed, 155 insertions(+), 67 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 92862ed9dc..1ddb7b47c1 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1502,6 +1502,10 @@ async def get_users( sort_order: str = fastapi.Query( default="asc", description="Sort order ('asc' or 'desc')" ), + organization_id: Optional[str] = fastapi.Query( + default=None, + description="Filter users by organization membership. Comma-separated for multiple orgs.", + ), ): """ Get a paginated list of users with filtering and sorting options. @@ -1576,6 +1580,14 @@ async def get_users( "in": sso_id_list, } + if organization_id is not None and isinstance(organization_id, str): + org_id_list = [ + oid.strip() for oid in organization_id.split(",") if oid.strip() + ] + where_conditions["organization_memberships"] = { + "some": {"organization_id": {"in": org_id_list}} + } + ## Filter any none fastapi.Query params - e.g. where_conditions: {'user_email': {'contains': Query(None), 'mode': 'insensitive'}, 'teams': {'has': Query(None)}} where_conditions = {k: v for k, v in where_conditions.items() if v is not None} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx index 5ab6920b28..ae7a0b9767 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx @@ -3,13 +3,32 @@ import ViewUserDashboard from "@/components/view_users"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; -import { useState } from "react"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { isProxyAdminRole } from "@/utils/roles"; +import { useState, useMemo } from "react"; +import { Organization } from "@/components/networking"; const UsersPage = () => { const { accessToken, userRole, userId, token } = useAuthorized(); const [keys, setKeys] = useState([]); const { teams } = useTeams(); + const { data: organizations } = useOrganizations(); + + // Compute org IDs where the user is an org_admin, but only if they're NOT a proxy admin + const orgAdminOrgIds = useMemo(() => { + if (!userId || !organizations || !userRole) return null; + // Proxy admins see all users — no org filtering + if (isProxyAdminRole(userRole)) return null; + + const adminOrgIds = organizations + .filter((org: Organization) => + org.members?.some((member) => member.user_id === userId && member.user_role === "org_admin") + ) + .map((org: Organization) => org.organization_id); + + return adminOrgIds.length > 0 ? adminOrgIds : null; + }, [userId, organizations, userRole]); return ( { userID={userId} teams={teams as any} setKeys={setKeys} + orgAdminOrgIds={orgAdminOrgIds} /> ); }; diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.tsx index d463ced08f..09978a1c93 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.tsx @@ -19,6 +19,7 @@ import { getProxyUISettings, invitationCreateCall, modelAvailableCall, + organizationMemberAddCall, userCreateCall, } from "./networking"; import OnboardingModal, { InvitationLink } from "./onboarding_link"; @@ -44,6 +45,7 @@ interface CreateuserProps { possibleUIRoles: null | Record>; onUserCreated?: (userId: string) => void; isEmbedded?: boolean; + organizationId?: string | null; } // Define an interface for the UI settings @@ -55,7 +57,7 @@ interface UISettings { } export const CreateUserButton: React.FC = ({ - userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false }) => { + userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false, organizationId }) => { const queryClient = useQueryClient(); const [uiSettings, setUISettings] = useState(null); const [form] = Form.useForm(); @@ -112,6 +114,18 @@ export const CreateUserButton: React.FC = ({ setApiuser(true); const user_id = response.data?.user_id || response.user_id; + // Auto-add user to the org admin's organization + if (organizationId && user_id) { + try { + await organizationMemberAddCall(accessToken, organizationId, { + role: "internal_user", + user_id: user_id, + }); + } catch (orgError) { + console.error("Failed to add user to organization:", orgError); + } + } + if (onUserCreated && isEmbedded) { onUserCreated(user_id); form.resetFields(); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index fa35a566de..d01fc06bc0 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -443,8 +443,8 @@ const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapse children: item.children ? filterItemsByRole(item.children) : undefined, })) .filter((item) => { - // Special handling for organizations menu item - allow org_admins - if (item.key === "organizations") { + // Special handling for organizations and users menu items - allow org_admins + if (item.key === "organizations" || item.key === "users") { const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin; if (!hasRoleAccess) return false; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 91454d8d8b..1dd9afc698 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1104,6 +1104,7 @@ export const userListCall = async ( sso_user_id: string | null = null, sortBy: string | null = null, sortOrder: "asc" | "desc" | null = null, + organizationIds: string[] | null = null, ) => { /** * Get all available teams on proxy @@ -1151,6 +1152,10 @@ export const userListCall = async ( queryParams.append("sort_order", sortOrder); } + if (organizationIds && organizationIds.length > 0) { + queryParams.append("organization_id", organizationIds.join(",")); + } + const queryString = queryParams.toString(); if (queryString) { url += `?${queryString}`; diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index 576a1a84be..49a55f393f 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -16,7 +16,7 @@ import { import OnboardingModal, { InvitationLink } from "./onboarding_link"; import { updateExistingKeys } from "@/utils/dataUtils"; -import { isAdminRole } from "@/utils/roles"; +import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Typography } from "antd"; @@ -39,6 +39,7 @@ interface ViewUserDashboardProps { userID: string | null; teams: any[] | null; setKeys: React.Dispatch>; + orgAdminOrgIds?: string[] | null; } interface FilterState { @@ -69,7 +70,8 @@ const initialFilters: FilterState = { sort_order: "desc", }; -const ViewUserDashboard: React.FC = ({ accessToken, token, userRole, userID, teams }) => { +const ViewUserDashboard: React.FC = ({ accessToken, token, userRole, userID, teams, orgAdminOrgIds }) => { + const isProxyAdmin = userRole ? isProxyAdminRole(userRole) : false; const queryClient = useQueryClient(); const [currentPage, setCurrentPage] = useState(1); const [editModalVisible, setEditModalVisible] = useState(false); @@ -245,7 +247,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke }; const userListQuery = useQuery({ - queryKey: ["userList", { debouncedFilter: debouncedFilters, currentPage }], + queryKey: ["userList", { debouncedFilter: debouncedFilters, currentPage, orgAdminOrgIds }], queryFn: async () => { if (!accessToken) throw new Error("Access token required"); @@ -260,6 +262,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke debouncedFilters.sso_user_id || null, debouncedFilters.sort_by, debouncedFilters.sort_order, + orgAdminOrgIds ?? null, ); }, enabled: Boolean(accessToken && token && userRole && userID), @@ -301,17 +304,19 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke ) : userID && accessToken ? ( <> - + - + {isProxyAdmin && ( + + )} - {selectionMode && ( + {isProxyAdmin && selectionMode && ( @@ -321,61 +326,93 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke - setActiveTab(index === 0 ? "users" : "settings")}> - - Users - Default User Settings - + {isProxyAdmin ? ( + setActiveTab(index === 0 ? "users" : "settings")}> + + Users + Default User Settings + - - - { - setSelectedUser(user); - setEditModalVisible(true); - }} - handleDelete={handleDelete} - handleResetPassword={handleResetPassword} - enableSelection={selectionMode} - selectedUsers={selectedUsers} - onSelectionChange={handleSelectionChange} - filters={filters} - updateFilters={updateFilters} - initialFilters={initialFilters} - teams={teams} - userListResponse={userListResponse} - currentPage={currentPage} - handlePageChange={handlePageChange} - /> - - - - {!userID || !userRole || !accessToken ? ( -
- -
- ) : ( - + + { + setSelectedUser(user); + setEditModalVisible(true); + }} + handleDelete={handleDelete} + handleResetPassword={handleResetPassword} + enableSelection={selectionMode} + selectedUsers={selectedUsers} + onSelectionChange={handleSelectionChange} + filters={filters} + updateFilters={updateFilters} + initialFilters={initialFilters} + teams={teams} + userListResponse={userListResponse} + currentPage={currentPage} + handlePageChange={handlePageChange} /> - )} - -
-
+ + + + {!userID || !userRole || !accessToken ? ( +
+ +
+ ) : ( + + )} +
+ +
+ ) : ( + { + setSelectedUser(user); + setEditModalVisible(true); + }} + handleDelete={handleDelete} + handleResetPassword={handleResetPassword} + enableSelection={false} + selectedUsers={[]} + onSelectionChange={handleSelectionChange} + filters={filters} + updateFilters={updateFilters} + initialFilters={initialFilters} + teams={teams} + userListResponse={userListResponse} + currentPage={currentPage} + handlePageChange={handlePageChange} + /> + )} {/* Existing Modals */}