mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-05 10:24:03 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ada8877aeb
commit
cb7da3044d
@@ -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}
|
||||
|
||||
|
||||
@@ -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<null | any[]>([]);
|
||||
|
||||
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 (
|
||||
<ViewUserDashboard
|
||||
@@ -20,6 +39,7 @@ const UsersPage = () => {
|
||||
userID={userId}
|
||||
teams={teams as any}
|
||||
setKeys={setKeys}
|
||||
orgAdminOrgIds={orgAdminOrgIds}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string, Record<string, string>>;
|
||||
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<CreateuserProps> = ({
|
||||
userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false }) => {
|
||||
userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false, organizationId }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
@@ -112,6 +114,18 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
||||
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();
|
||||
|
||||
@@ -443,8 +443,8 @@ const Sidebar: React.FC<SidebarProps> = ({ 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;
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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<React.SetStateAction<object[] | null>>;
|
||||
orgAdminOrgIds?: string[] | null;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
@@ -69,7 +70,8 @@ const initialFilters: FilterState = {
|
||||
sort_order: "desc",
|
||||
};
|
||||
|
||||
const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, token, userRole, userID, teams }) => {
|
||||
const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ 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<ViewUserDashboardProps> = ({ 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<ViewUserDashboardProps> = ({ 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<ViewUserDashboardProps> = ({ accessToken, toke
|
||||
</>
|
||||
) : userID && accessToken ? (
|
||||
<>
|
||||
<CreateUserButton userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
|
||||
<CreateUserButton userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} organizationId={orgAdminOrgIds?.[0] ?? null} />
|
||||
|
||||
<Button
|
||||
onClick={handleToggleSelectionMode}
|
||||
variant={selectionMode ? "primary" : "secondary"}
|
||||
className="flex items-center"
|
||||
>
|
||||
{selectionMode ? "Cancel Selection" : "Select Users"}
|
||||
</Button>
|
||||
{isProxyAdmin && (
|
||||
<Button
|
||||
onClick={handleToggleSelectionMode}
|
||||
variant={selectionMode ? "primary" : "secondary"}
|
||||
className="flex items-center"
|
||||
>
|
||||
{selectionMode ? "Cancel Selection" : "Select Users"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{selectionMode && (
|
||||
{isProxyAdmin && selectionMode && (
|
||||
<Button onClick={handleBulkEdit} disabled={selectedUsers.length === 0} className="flex items-center">
|
||||
Bulk Edit ({selectedUsers.length} selected)
|
||||
</Button>
|
||||
@@ -321,61 +326,93 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "users" : "settings")}>
|
||||
<TabList className="mb-4">
|
||||
<Tab>Users</Tab>
|
||||
<Tab>Default User Settings</Tab>
|
||||
</TabList>
|
||||
{isProxyAdmin ? (
|
||||
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "users" : "settings")}>
|
||||
<TabList className="mb-4">
|
||||
<Tab>Users</Tab>
|
||||
<Tab>Default User Settings</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<UserDataTable
|
||||
data={userListQuery.data?.users || []}
|
||||
columns={tableColumns}
|
||||
isLoading={userListQuery.isLoading}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
onSortChange={handleSortChange}
|
||||
currentSort={{
|
||||
sortBy: filters.sort_by,
|
||||
sortOrder: filters.sort_order,
|
||||
}}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
handleEdit={(user) => {
|
||||
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}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{!userID || !userRole || !accessToken ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</div>
|
||||
) : (
|
||||
<DefaultUserSettings
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<UserDataTable
|
||||
data={userListQuery.data?.users || []}
|
||||
columns={tableColumns}
|
||||
isLoading={userListQuery.isLoading}
|
||||
accessToken={accessToken}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
onSortChange={handleSortChange}
|
||||
currentSort={{
|
||||
sortBy: filters.sort_by,
|
||||
sortOrder: filters.sort_order,
|
||||
}}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
handleEdit={(user) => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{!userID || !userRole || !accessToken ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</div>
|
||||
) : (
|
||||
<DefaultUserSettings
|
||||
accessToken={accessToken}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
) : (
|
||||
<UserDataTable
|
||||
data={userListQuery.data?.users || []}
|
||||
columns={tableColumns}
|
||||
isLoading={userListQuery.isLoading}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
onSortChange={handleSortChange}
|
||||
currentSort={{
|
||||
sortBy: filters.sort_by,
|
||||
sortOrder: filters.sort_order,
|
||||
}}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
handleEdit={(user) => {
|
||||
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 */}
|
||||
<EditUserModal
|
||||
|
||||
Reference in New Issue
Block a user