[Fix] RBAC: Open Handler-Level Admin Reads + Hide UI Write Buttons for Admin Viewer

The default-allow-GET fix in route_checks unblocked the route layer, but a
second class of bug remained: handlers that gate on `user_role !=
PROXY_ADMIN` (or a private `_require_proxy_admin` helper) reject admin
viewer at the handler before the route's HTTP method even matters.

Backend: relax handler role checks on read endpoints to allow
PROXY_ADMIN_VIEW_ONLY (same `_user_has_admin_view` helper used elsewhere).

  - /v1/access_group GET (list) + /v1/access_group/{id} GET — split
    `_require_proxy_admin` into a parallel `_require_admin_view` for the
    two read handlers; writes (POST / PUT / DELETE) keep the strict gate.
  - /cloudzero/settings GET, /vantage/settings GET — read-only views.
  - /config_overrides/hashicorp_vault GET — read-only config view.
  - /team/permissions_list GET — let admin viewer see permissions like
    a Proxy Admin would.
  - /jwt/key/mapping/list, /jwt/key/mapping/info — JWT mapping reads.
  - /v1/mcp/discover, /v1/mcp/openapi-registry — MCP picker views.
  - /schedule/anthropic_beta_headers_reload/status — read-only status.
  - /adaptive_router/state — read-only live snapshot.

UI: hide write buttons that admin viewer should not see (button click
would fail the backend write gate, but the UX expectation is no button).

  - Internal Users: hide "Invite User" button.
  - Access Groups: hide "Create Access Group" + Delete row action.
  - Budgets: hide "+ Create Budget" + Edit/Delete row actions.
  - Prompts: hide "+ Add New Prompt" / "Upload .prompt File"; gate the
    prompt-table Edit/Delete actions on `isProxyAdminRole` (was
    `isAdminRole` which incorrectly included admin viewer).
  - Router Settings → Fallbacks: hide AddFallbacks panel + per-row Test
    + Delete actions.
  - AI Hub: hide "Select Models / Agents / MCP Servers / Skills to Make
    Public" + "Useful Links Management" (writes).

These pages remain VISIBLE for admin viewer (read parity); only the
write entry points are hidden.
This commit is contained in:
Yuneng Jiang 2026-04-29 23:52:11 -07:00
parent 00145f91a8
commit 0423f51e2a
No known key found for this signature in database
14 changed files with 168 additions and 100 deletions

View File

@ -38,6 +38,17 @@ def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None:
)
def _require_admin_view(user_api_key_dict: UserAPIKeyAuth) -> None:
"""Admin Viewer parity: PROXY_ADMIN or PROXY_ADMIN_VIEW_ONLY may read."""
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
if not _user_has_admin_view(user_api_key_dict):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": CommonProxyErrors.not_allowed_access.value},
)
def _record_to_response(record) -> AccessGroupResponse:
return AccessGroupResponse(
access_group_id=record.access_group_id,
@ -372,7 +383,7 @@ async def create_access_group(
async def list_access_groups(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> List[AccessGroupResponse]:
_require_proxy_admin(user_api_key_dict)
_require_admin_view(user_api_key_dict)
prisma_client = get_prisma_client_or_throw(
CommonProxyErrors.db_not_connected_error.value
)
@ -391,7 +402,7 @@ async def get_access_group(
access_group_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> AccessGroupResponse:
_require_proxy_admin(user_api_key_dict)
_require_admin_view(user_api_key_dict)
prisma_client = get_prisma_client_or_throw(
CommonProxyErrors.db_not_connected_error.value
)

View File

@ -267,9 +267,11 @@ async def get_hashicorp_vault_config(
Get current Hashicorp Vault configuration.
Returns decrypted values from DB, or falls back to current env vars.
"""
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
from litellm.proxy.proxy_server import prisma_client, proxy_config
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
# Admin Viewer follows the read-parity rule.
if not _user_has_admin_view(user_api_key_dict):
raise HTTPException(
status_code=403,
detail="Only admin users can view config overrides",

View File

@ -10,6 +10,7 @@ from litellm.proxy._types import (
hash_token,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
router = APIRouter()
@ -194,7 +195,8 @@ async def list_jwt_key_mappings(
):
from litellm.proxy.proxy_server import prisma_client
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
# Admin Viewer follows the read-parity rule.
if not _user_has_admin_view(user_api_key_dict):
raise HTTPException(
status_code=403, detail="Only proxy admins can list JWT key mappings"
)
@ -233,7 +235,8 @@ async def info_jwt_key_mapping(
):
from litellm.proxy.proxy_server import prisma_client
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
# Admin Viewer follows the read-parity rule.
if not _user_has_admin_view(user_api_key_dict):
raise HTTPException(
status_code=403, detail="Only proxy admins can get JWT key mapping info"
)

View File

@ -2119,7 +2119,8 @@ if MCP_AVAILABLE:
Used by the UI to show a discovery grid when adding new MCP servers.
"""
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
# Admin Viewer follows the read-parity rule.
if not _user_has_admin_view(user_api_key_dict):
raise HTTPException(
status_code=403,
detail={
@ -2176,7 +2177,8 @@ if MCP_AVAILABLE:
async def get_openapi_registry(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
# Admin Viewer follows the read-parity rule.
if not _user_has_admin_view(user_api_key_dict):
raise HTTPException(
status_code=403,
detail={

View File

@ -4623,9 +4623,11 @@ async def team_member_permissions(
complete_team_data = LiteLLM_TeamTable(**existing_team_row.model_dump())
# Admin Viewer follows the read-parity rule: see team permissions like
# a Proxy Admin would. Team / org admins keep their existing scope.
if (
hasattr(user_api_key_dict, "user_role")
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
and not _user_has_admin_view(user_api_key_dict)
and not _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
)

View File

@ -14058,8 +14058,8 @@ async def get_anthropic_beta_headers_reload_status(
Get the status of the scheduled Anthropic beta headers reload job.
"""
# Check if user is admin
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
# Read-only status — admin viewers can read.
if not _user_has_admin_view(user_api_key_dict):
raise HTTPException(
status_code=403,
detail=f"Access denied. Admin role required. Current role: {user_api_key_dict.user_role}",
@ -14165,7 +14165,8 @@ async def get_adaptive_router_state(
adaptive-router deployment. Each snapshot's `router_name` field identifies
which deployment it came from.
"""
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
# Read-only state — admin viewers can read.
if not _user_has_admin_view(user_api_key_dict):
raise HTTPException(
status_code=403,
detail={"error": CommonProxyErrors.not_allowed_access.value},

View File

@ -6,6 +6,7 @@ from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
@ -127,10 +128,10 @@ async def get_cloudzero_settings(
Only the first 4 and last 4 characters of the API key are shown.
Returns null/empty values when settings are not configured (consistent with other settings endpoints).
Only admin users can view CloudZero settings.
Only admin users (Proxy Admin or Admin Viewer) can view CloudZero settings.
"""
# Validation
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
# Validation — Admin Viewer follows the read-parity rule.
if not _user_has_admin_view(user_api_key_dict):
raise HTTPException(
status_code=403,
detail={"error": CommonProxyErrors.not_allowed_access.value},

View File

@ -7,6 +7,7 @@ from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
@ -140,9 +141,10 @@ async def get_vantage_settings(
View current Vantage settings.
Returns the current Vantage configuration with the API key masked for security.
Only admin users can view Vantage settings.
Only admin users (Proxy Admin or Admin Viewer) can view Vantage settings.
"""
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
# Admin Viewer follows the read-parity rule.
if not _user_has_admin_view(user_api_key_dict):
raise HTTPException(
status_code=403,
detail={"error": CommonProxyErrors.not_allowed_access.value},

View File

@ -22,7 +22,7 @@ import {
modelHubPublicModelsCall,
} from "@/components/networking";
import PublicModelHub from "@/components/public_model_hub";
import { isAdminRole } from "@/utils/roles";
import { isAdminRole, isProxyAdminRole } from "@/utils/roles";
import { CopyOutlined } from "@ant-design/icons";
import { Badge, Button, Card, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
import { Modal } from "antd";
@ -61,6 +61,10 @@ interface ModelGroupInfo {
}
const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage, premiumUser, userRole }) => {
// Admin Viewer follows the read-parity rule: see the AI Hub catalog, but
// cannot toggle public visibility (write).
const canModify = isProxyAdminRole(userRole || "");
const [publicPageAllowed, setPublicPageAllowed] = useState<boolean>(false);
const [modelHubData, setModelHubData] = useState<ModelGroupInfo[] | null>(null);
const [loading, setLoading] = useState<boolean>(true);
@ -420,7 +424,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
</div>
{/* Useful Links Management Section for Admins */}
{isAdminRole(userRole || "") && (
{canModify && (
<div className="mt-8 mb-2">
<UsefulLinksManagement accessToken={accessToken} userRole={userRole} />
</div>
@ -441,7 +445,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
{/* Model Filters and Table */}
<Card>
{/* Header with Make Public Button */}
{publicPage == false && isAdminRole(userRole || "") && (
{publicPage == false && canModify && (
<div className="flex justify-end mb-4">
<Button onClick={() => handleMakePublicPage()}>Select Models to Make Public</Button>
</div>
@ -470,7 +474,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
<TabPanel>
<Card>
{/* Header with Make Public Button */}
{publicPage == false && isAdminRole(userRole || "") && (
{publicPage == false && canModify && (
<div className="flex justify-end mb-4">
<Button onClick={() => handleMakeAgentPublicPage()}>Select Agents to Make Public</Button>
</div>
@ -496,7 +500,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
<TabPanel>
<Card>
{/* Header with Make Public Button */}
{publicPage == false && isAdminRole(userRole || "") && (
{publicPage == false && canModify && (
<div className="flex justify-end mb-4">
<Button onClick={() => handleMakeMcpPublicPage()}>Select MCP Servers to Make Public</Button>
</div>
@ -520,7 +524,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
{/* Skill Hub Tab */}
<TabPanel>
{publicPage == false && isAdminRole(userRole || "") && (
{publicPage == false && canModify && (
<div className="flex justify-end mb-4">
<Button onClick={() => setIsMakeSkillPublicModalVisible(true)}>
Select Skills to Make Public
@ -530,7 +534,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
<SkillHubDashboard
skills={skillHubData}
isLoading={skillLoading}
isAdmin={isAdminRole(userRole || "")}
isAdmin={canModify}
accessToken={accessToken}
publicPage={publicPage}
onPublishSuccess={async () => {

View File

@ -43,6 +43,8 @@ import {
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal";
import { AccessGroup } from "./types";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isProxyAdminRole } from "@/utils/roles";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -130,6 +132,9 @@ function buildAntdColumns(
export function AccessGroupsPage() {
const { token } = theme.useToken();
const { userRole } = useAuthorized();
// Admin Viewer follows the read-parity rule: see access groups, no writes.
const canModify = isProxyAdminRole(userRole ?? "");
const { data: groupsData, isLoading } = useAccessGroups();
const groups = useMemo(
() => (groupsData ?? []).map(mapResponseToAccessGroup),
@ -251,24 +256,28 @@ export function AccessGroupsPage() {
new Date(getValue() as string).toLocaleDateString(),
meta: { responsive: ["xl"] },
},
{
id: "actions",
header: () => <span>Actions</span>,
enableSorting: false,
cell: ({ row }) => (
<Space>
<TableIconActionButton
variant="Delete"
tooltipText="Delete access group"
onClick={() => setGroupToDelete(row.original)}
/>
</Space>
),
},
...(canModify
? [
{
id: "actions",
header: () => <span>Actions</span>,
enableSorting: false,
cell: ({ row }: { row: Row<AccessGroup> }) => (
<Space>
<TableIconActionButton
variant="Delete"
tooltipText="Delete access group"
onClick={() => setGroupToDelete(row.original)}
/>
</Space>
),
},
]
: []),
],
// setSelectedGroup is stable (useState setter)
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
[canModify],
);
// ---------- TanStack table instance ----------
@ -329,13 +338,15 @@ export function AccessGroupsPage() {
Manage resource permissions for your organization
</Text>
</Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsCreateModalVisible(true)}
>
Create Access Group
</Button>
{canModify && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsCreateModalVisible(true)}
>
Create Access Group
</Button>
)}
</Flex>
<Card styles={{ body: { padding: 0 } }}>

View File

@ -8,6 +8,7 @@ import DeleteResourceModal from "../../../common_components/DeleteResourceModal"
import { ProviderLogo } from "../../../molecules/models/ProviderLogo";
import NotificationsManager from "../../../molecules/notifications_manager";
import { getCallbacksCall, setCallbacksCall } from "../../../networking";
import { isProxyAdminRole } from "@/utils/roles";
import AddFallbacks from "./AddFallbacks";
type FallbackEntry = { [modelName: string]: string[] };
@ -243,15 +244,19 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
};
const hasFallbacks = Array.isArray(routerSettings.fallbacks) && routerSettings.fallbacks.length > 0;
// Admin Viewer follows the read-parity rule: see fallbacks, no writes.
const canModify = isProxyAdminRole(userRole ?? "");
return (
<>
<AddFallbacks
models={modelData?.data ? modelData.data.map((data: any) => data.model_name) : []}
accessToken={accessToken || ""}
value={routerSettings.fallbacks || []}
onChange={handleFallbacksChange}
/>
{canModify && (
<AddFallbacks
models={modelData?.data ? modelData.data.map((data: any) => data.model_name) : []}
accessToken={accessToken || ""}
value={routerSettings.fallbacks || []}
onChange={handleFallbacksChange}
/>
)}
{!hasFallbacks ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-6 text-center">
<Typography.Text type="secondary">
@ -280,30 +285,34 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
{renderFallbacksChain(key, Array.isArray(value) ? value : [], getProviderFromModel)}
</TableCell>
<TableCell className="align-top">
<Tooltip title="Test fallback">
<Icon
icon={PlayIcon}
size="sm"
onClick={() => testFallbackModelResponse(Object.keys(item)[0], accessToken || "")}
className="cursor-pointer hover:text-blue-600"
/>
</Tooltip>
<Tooltip title="Delete fallback">
<span
data-testid="delete-fallback-button"
role="button"
tabIndex={0}
onClick={() => handleDeleteClick(item)}
onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)}
className="cursor-pointer inline-flex"
>
<Icon
icon={TrashIcon}
size="sm"
className="hover:text-red-600"
/>
</span>
</Tooltip>
{canModify && (
<>
<Tooltip title="Test fallback">
<Icon
icon={PlayIcon}
size="sm"
onClick={() => testFallbackModelResponse(Object.keys(item)[0], accessToken || "")}
className="cursor-pointer hover:text-blue-600"
/>
</Tooltip>
<Tooltip title="Delete fallback">
<span
data-testid="delete-fallback-button"
role="button"
tabIndex={0}
onClick={() => handleDeleteClick(item)}
onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)}
className="cursor-pointer inline-flex"
>
<Icon
icon={TrashIcon}
size="sm"
className="hover:text-red-600"
/>
</span>
</Tooltip>
</>
)}
</TableCell>
</TableRow>
)),

View File

@ -28,6 +28,8 @@ import { useBudgets, useDeleteBudget } from "@/app/(dashboard)/hooks/budgets/use
import BudgetModal from "./budget_modal";
import EditBudgetModal from "./edit_budget_modal";
import { CREATE_END_USER_CURL_COMMAND, CHAT_COMPLETIONS_CURL_COMMAND, OPENAI_SDK_PYTHON_CODE } from "./constants";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isProxyAdminRole } from "@/utils/roles";
interface BudgetSettingsPageProps {
accessToken: string | null;
@ -47,6 +49,10 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
const [selectedBudget, setSelectedBudget] = useState<budgetItem | null>(null);
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const { userRole } = useAuthorized();
// Admin Viewer follows the read-parity rule: see budgets, no writes.
const canModify = isProxyAdminRole(userRole ?? "");
const { data: budgetList = [] } = useBudgets();
const deleteBudget = useDeleteBudget();
@ -89,9 +95,11 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<Button size="sm" variant="primary" className="mb-2" onClick={() => setIsCreateModelVisible(true)}>
+ Create Budget
</Button>
{canModify && (
<Button size="sm" variant="primary" className="mb-2" onClick={() => setIsCreateModelVisible(true)}>
+ Create Budget
</Button>
)}
<TabGroup>
<TabList>
<Tab>Budgets</Tab>
@ -133,18 +141,22 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
<TableCell>{value.max_budget ? value.max_budget : "n/a"}</TableCell>
<TableCell>{value.tpm_limit ? value.tpm_limit : "n/a"}</TableCell>
<TableCell>{value.rpm_limit ? value.rpm_limit : "n/a"}</TableCell>
<TableIconActionButton
variant="Edit"
tooltipText="Edit budget"
onClick={() => handleEditCall(value)}
dataTestId="edit-budget-button"
/>
<TableIconActionButton
variant="Delete"
tooltipText="Delete budget"
onClick={() => handleDeleteClick(value)}
dataTestId="delete-budget-button"
/>
{canModify && (
<>
<TableIconActionButton
variant="Edit"
tooltipText="Edit budget"
onClick={() => handleEditCall(value)}
dataTestId="edit-budget-button"
/>
<TableIconActionButton
variant="Delete"
tooltipText="Delete budget"
onClick={() => handleDeleteClick(value)}
dataTestId="delete-budget-button"
/>
</>
)}
</TableRow>
))}
</TableBody>

View File

@ -8,7 +8,7 @@ import PromptInfoView from "./prompts/prompt_info";
import AddPromptForm from "./prompts/add_prompt_form";
import PromptEditorView from "./prompts/prompt_editor_view";
import NotificationsManager from "./molecules/notifications_manager";
import { isAdminRole } from "@/utils/roles";
import { isAdminRole, isProxyAdminRole } from "@/utils/roles";
interface PromptsProps {
accessToken: string | null;
@ -27,6 +27,8 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
const [promptToDelete, setPromptToDelete] = useState<{ id: string; name: string } | null>(null);
const isAdmin = userRole ? isAdminRole(userRole) : false;
// Admin Viewer follows the read-parity rule: see prompts, no writes.
const canModify = userRole ? isProxyAdminRole(userRole) : false;
const fetchPrompts = async () => {
if (!accessToken) {
@ -128,7 +130,7 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
promptId={selectedPromptId}
onClose={() => setSelectedPromptId(null)}
accessToken={accessToken}
isAdmin={isAdmin}
isAdmin={canModify}
onDelete={fetchPrompts}
onEdit={handleEditPrompt}
/>
@ -136,12 +138,16 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
<>
<div className="flex justify-between items-center mb-4">
<div className="flex gap-2">
<Button onClick={handleAddPrompt} disabled={!accessToken}>
+ Add New Prompt
</Button>
<Button onClick={handleAddPromptFromFile} disabled={!accessToken} variant="secondary">
Upload .prompt File
</Button>
{canModify && (
<>
<Button onClick={handleAddPrompt} disabled={!accessToken}>
+ Add New Prompt
</Button>
<Button onClick={handleAddPromptFromFile} disabled={!accessToken} variant="secondary">
Upload .prompt File
</Button>
</>
)}
</div>
<Select
placeholder="All Environments"
@ -163,7 +169,7 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
onPromptClick={handlePromptClick}
onDeleteClick={handleDeleteClick}
accessToken={accessToken}
isAdmin={isAdmin}
isAdmin={canModify}
/>
</>
)}

View File

@ -304,7 +304,9 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
</>
) : userID && accessToken ? (
<>
<CreateUserButton userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
{isProxyAdmin && (
<CreateUserButton userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
)}
{isProxyAdmin && (
<Button