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:
yuneng-jiang
2026-03-07 17:40:29 -08:00
co-authored by Claude Opus 4.6
parent ada8877aeb
commit cb7da3044d
6 changed files with 155 additions and 67 deletions
@@ -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