Merge pull request #9328 from BerriAI/litellm_control_internal_user_settings_ui

[UI] - Allow controlling default internal user settings on ui
This commit is contained in:
Ishaan Jaff 2025-03-17 22:07:47 -07:00 • committed by GitHub
commit d42f84dd4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 781 additions and 55 deletions

View file

@ -124,6 +124,14 @@ class LitellmUserRoles(str, enum.Enum):
}
return ui_labels.get(self.value, "")
@property
def is_internal_user_role(self) -> bool:
"""returns true if this role is an `internal_user` or `internal_user_viewer` role"""
return self.value in [
self.INTERNAL_USER,
self.INTERNAL_USER_VIEW_ONLY,
]
class LitellmTableNames(str, enum.Enum):
"""
@ -2609,3 +2617,32 @@ class SpecialManagementEndpointEnums(enum.Enum):
class TransformRequestBody(BaseModel):
call_type: CallTypes
request_body: dict
class DefaultInternalUserParams(LiteLLMPydanticObjectBase):
"""
Default parameters to apply when a new user signs in via SSO or is created on the /user/new API endpoint
"""
user_role: Optional[
Literal[
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
]
] = Field(
default=LitellmUserRoles.INTERNAL_USER,
description="Default role assigned to new users created",
)
max_budget: Optional[float] = Field(
default=None,
description="Default maximum budget (in USD) for new users created",
)
budget_duration: Optional[str] = Field(
default=None,
description="Default budget duration for new users (e.g. 'daily', 'weekly', 'monthly')",
)
models: Optional[List[str]] = Field(
default=None, description="Default list of models that new users can access"
)

View file

@ -86,7 +86,7 @@ def _update_internal_new_user_params(data_json: dict, data: NewUserRequest) -> d
)
is_internal_user = False
if data.user_role == LitellmUserRoles.INTERNAL_USER:
if data.user_role and data.user_role.is_internal_user_role:
is_internal_user = True
if litellm.default_internal_user_params:
for key, value in litellm.default_internal_user_params.items():

View file

@ -3,6 +3,7 @@ from typing import List
from fastapi import APIRouter, Depends, HTTPException
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
@ -108,3 +109,94 @@ async def delete_allowed_ip(ip_address: IPAddress):
await proxy_config.save_config(new_config=config)
return {"message": f"IP {ip_address.ip} deleted successfully", "status": "success"}
@router.get(
"/get/internal_user_settings",
tags=["SSO Settings"],
dependencies=[Depends(user_api_key_auth)],
)
async def get_sso_settings():
"""
Get all SSO settings from the litellm_settings configuration.
Returns a structured object with values and descriptions for UI display.
"""
from pydantic import TypeAdapter
from litellm.proxy.proxy_server import proxy_config
# Load existing config
config = await proxy_config.get_config()
litellm_settings = config.get("litellm_settings", {}) or {}
default_internal_user_params = (
litellm_settings.get("default_internal_user_params", {}) or {}
)
# Create the settings object first
sso_settings = DefaultInternalUserParams(**(default_internal_user_params))
# Get the schema for UISSOSettings
schema = TypeAdapter(DefaultInternalUserParams).json_schema(by_alias=True)
# Convert to dict for response
settings_dict = sso_settings.model_dump()
# Add descriptions to the response
result = {
"values": settings_dict,
"schema": {"description": schema.get("description", ""), "properties": {}},
}
# Add property descriptions
for field_name, field_info in schema["properties"].items():
result["schema"]["properties"][field_name] = {
"description": field_info.get("description", ""),
"type": field_info.get("type", "string"),
}
# Add nested object descriptions
for def_name, def_schema in schema.get("definitions", {}).items():
result["schema"][def_name] = {
"description": def_schema.get("description", ""),
"properties": {
prop_name: {"description": prop_info.get("description", "")}
for prop_name, prop_info in def_schema.get("properties", {}).items()
},
}
return result
@router.patch(
"/update/internal_user_settings",
tags=["SSO Settings"],
dependencies=[Depends(user_api_key_auth)],
)
async def update_internal_user_settings(settings: DefaultInternalUserParams):
"""
Update the default internal user parameters for SSO users.
These settings will be applied to new users who sign in via SSO.
"""
from litellm.proxy.proxy_server import proxy_config
# Update the in-memory settings
litellm.default_internal_user_params = settings.model_dump(exclude_none=True)
# Load existing config
config = await proxy_config.get_config()
# Update config with new settings
if "litellm_settings" not in config:
config["litellm_settings"] = {}
config["litellm_settings"]["default_internal_user_params"] = settings.model_dump(
exclude_none=True
)
# Save the updated config
await proxy_config.save_config(new_config=config)
return {
"message": "Internal user settings updated successfully",
"status": "success",
"settings": litellm.default_internal_user_params,
}

View file

@ -0,0 +1,140 @@
import json
import os
import sys
import pytest
from fastapi.testclient import TestClient
sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
from litellm.proxy._types import DefaultInternalUserParams, LitellmUserRoles
from litellm.proxy.proxy_server import app
client = TestClient(app)
@pytest.fixture
def mock_proxy_config(monkeypatch):
"""Mock the proxy_config to avoid actual file operations during tests"""
mock_config = {
"litellm_settings": {
"default_internal_user_params": {
"user_role": LitellmUserRoles.INTERNAL_USER,
"max_budget": 100.0,
"budget_duration": "30d",
"models": ["gpt-3.5-turbo", "gpt-4"],
}
}
}
async def mock_get_config():
return mock_config
# Add a counter to track save_config calls
save_config_call_count = 0
async def mock_save_config(new_config=None):
nonlocal mock_config, save_config_call_count
save_config_call_count += 1
if new_config:
mock_config = new_config
return mock_config
from litellm.proxy.proxy_server import proxy_config
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
monkeypatch.setattr(proxy_config, "save_config", mock_save_config)
# Return both the config and the call counter
return {"config": mock_config, "save_call_count": lambda: save_config_call_count}
@pytest.fixture
def mock_auth(monkeypatch):
"""Mock the authentication to bypass auth checks"""
async def mock_user_api_key_auth():
return {"user_id": "test_user"}
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
user_api_key_auth,
)
monkeypatch.setattr(
"litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.user_api_key_auth",
mock_user_api_key_auth,
)
class TestProxySettingEndpoints:
def test_get_internal_user_settings(self, mock_proxy_config, mock_auth):
"""Test getting the internal user settings"""
response = client.get("/get/internal_user_settings")
assert response.status_code == 200
data = response.json()
# Check structure of response
assert "values" in data
assert "schema" in data
# Check values match our mock config
values = data["values"]
mock_params = mock_proxy_config["config"]["litellm_settings"][
"default_internal_user_params"
]
assert values["user_role"] == mock_params["user_role"]
assert values["max_budget"] == mock_params["max_budget"]
assert values["budget_duration"] == mock_params["budget_duration"]
assert values["models"] == mock_params["models"]
# Check schema contains descriptions
assert "properties" in data["schema"]
assert "user_role" in data["schema"]["properties"]
assert "description" in data["schema"]["properties"]["user_role"]
def test_update_internal_user_settings(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""Test updating the internal user settings"""
# Mock litellm.default_internal_user_params
import litellm
monkeypatch.setattr(litellm, "default_internal_user_params", {})
# New settings to update
new_settings = {
"user_role": LitellmUserRoles.PROXY_ADMIN,
"max_budget": 200.0,
"budget_duration": "7d",
"models": ["gpt-4", "claude-3"],
}
response = client.patch("/update/internal_user_settings", json=new_settings)
assert response.status_code == 200
data = response.json()
# Check response structure
assert data["status"] == "success"
assert "settings" in data
# Verify settings were updated
settings = data["settings"]
assert settings["user_role"] == new_settings["user_role"]
assert settings["max_budget"] == new_settings["max_budget"]
assert settings["budget_duration"] == new_settings["budget_duration"]
assert settings["models"] == new_settings["models"]
# Verify the config was updated
updated_config = mock_proxy_config["config"]["litellm_settings"][
"default_internal_user_params"
]
assert updated_config["user_role"] == new_settings["user_role"]
assert updated_config["max_budget"] == new_settings["max_budget"]
# Verify save_config was called exactly once
assert mock_proxy_config["save_call_count"]() == 1

View file

@ -0,0 +1,331 @@
import React, { useState, useEffect } from "react";
import { Card, Title, Text, Divider, Button, TextInput } from "@tremor/react";
import { Typography, Spin, message, Switch, Select, Form } from "antd";
import { getInternalUserSettings, updateInternalUserSettings, modelAvailableCall } from "./networking";
import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
interface SSOSettingsProps {
accessToken: string | null;
possibleUIRoles?: Record<string, Record<string, string>> | null;
userID?: string;
}
const SSOSettings: React.FC<SSOSettingsProps> = ({ accessToken, possibleUIRoles }) => {
const [loading, setLoading] = useState<boolean>(true);
const [settings, setSettings] = useState<any>(null);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [editedValues, setEditedValues] = useState<any>({});
const [saving, setSaving] = useState<boolean>(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const { Paragraph } = Typography;
const { Option } = Select;
useEffect(() => {
const fetchSSOSettings = async () => {
if (!accessToken) {
setLoading(false);
return;
}
try {
const data = await getInternalUserSettings(accessToken);
setSettings(data);
setEditedValues(data.values || {});
// Fetch available models
if (accessToken) {
try {
const modelResponse = await modelAvailableCall(accessToken, null, null);
if (modelResponse && modelResponse.data) {
const modelNames = modelResponse.data.map((model: { id: string }) => model.id);
setAvailableModels(modelNames);
}
} catch (error) {
console.error("Error fetching available models:", error);
}
}
} catch (error) {
console.error("Error fetching SSO settings:", error);
message.error("Failed to fetch SSO settings");
} finally {
setLoading(false);
}
};
fetchSSOSettings();
}, [accessToken]);
const handleSaveSettings = async () => {
if (!accessToken) return;
setSaving(true);
try {
const updatedSettings = await updateInternalUserSettings(accessToken, editedValues);
setSettings({...settings, values: updatedSettings.settings});
setIsEditing(false);
} catch (error) {
console.error("Error updating SSO settings:", error);
message.error("Failed to update settings");
} finally {
setSaving(false);
}
};
const handleTextInputChange = (key: string, value: any) => {
setEditedValues((prev: Record<string, any>) => ({
...prev,
[key]: value
}));
};
const renderEditableField = (key: string, property: any, value: any) => {
const type = property.type;
if (key === "user_role" && possibleUIRoles) {
return (
<Select
style={{ width: '100%' }}
value={editedValues[key] || ""}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
{Object.entries(possibleUIRoles)
.filter(([role]) => role.includes("internal_user"))
.map(([role, { ui_label, description }]) => (
<Option key={role} value={role}>
<div className="flex items-center">
<span>{ui_label}</span>
<span className="ml-2 text-xs text-gray-500">{description}</span>
</div>
</Option>
))}
</Select>
);
} else if (key === "budget_duration") {
return (
<BudgetDurationDropdown
value={editedValues[key] || null}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
/>
);
} else if (type === "boolean") {
return (
<div className="mt-2">
<Switch
checked={!!editedValues[key]}
onChange={(checked) => handleTextInputChange(key, checked)}
/>
</div>
);
} else if (type === "array" && property.items?.enum) {
return (
<Select
mode="multiple"
style={{ width: '100%' }}
value={editedValues[key] || []}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
{property.items.enum.map((option: string) => (
<Option key={option} value={option}>{option}</Option>
))}
</Select>
);
} else if (key === "models") {
return (
<Select
mode="multiple"
style={{ width: '100%' }}
value={editedValues[key] || []}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
{availableModels.map((model: string) => (
<Option key={model} value={model}>
{getModelDisplayName(model)}
</Option>
))}
</Select>
);
} else if (type === "string" && property.enum) {
return (
<Select
style={{ width: '100%' }}
value={editedValues[key] || ""}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
{property.enum.map((option: string) => (
<Option key={option} value={option}>{option}</Option>
))}
</Select>
);
} else {
return (
<TextInput
value={editedValues[key] !== undefined ? String(editedValues[key]) : ""}
onChange={(e) => handleTextInputChange(key, e.target.value)}
placeholder={property.description || ""}
className="mt-2"
/>
);
}
};
const renderValue = (key: string, value: any): JSX.Element => {
if (value === null || value === undefined) return <span className="text-gray-400">Not set</span>;
if (key === "user_role" && possibleUIRoles && possibleUIRoles[value]) {
const { ui_label, description } = possibleUIRoles[value];
return (
<div>
<span className="font-medium">{ui_label}</span>
{description && <p className="text-xs text-gray-500 mt-1">{description}</p>}
</div>
);
}
if (key === "budget_duration") {
return <span>{getBudgetDurationLabel(value)}</span>;
}
if (typeof value === "boolean") {
return <span>{value ? "Enabled" : "Disabled"}</span>;
}
if (key === "models" && Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-400">None</span>;
return (
<div className="flex flex-wrap gap-2 mt-1">
{value.map((model, index) => (
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
{getModelDisplayName(model)}
</span>
))}
</div>
);
}
if (typeof value === "object") {
if (Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-400">None</span>;
return (
<div className="flex flex-wrap gap-2 mt-1">
{value.map((item, index) => (
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
{typeof item === "object" ? JSON.stringify(item) : String(item)}
</span>
))}
</div>
);
}
return (
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto mt-1">
{JSON.stringify(value, null, 2)}
</pre>
);
}
return <span>{String(value)}</span>;
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<Spin size="large" />
</div>
);
}
if (!settings) {
return (
<Card>
<Text>No settings available or you do not have permission to view them.</Text>
</Card>
);
}
// Dynamically render settings based on the schema
const renderSettings = () => {
const { values, schema } = settings;
if (!schema || !schema.properties) {
return <Text>No schema information available</Text>;
}
return Object.entries(schema.properties).map(([key, property]: [string, any]) => {
const value = values[key];
const displayName = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
return (
<div key={key} className="mb-6 pb-6 border-b border-gray-200 last:border-0">
<Text className="font-medium text-lg">{displayName}</Text>
<Paragraph className="text-sm text-gray-500 mt-1">
{property.description || "No description available"}
</Paragraph>
{isEditing ? (
<div className="mt-2">
{renderEditableField(key, property, value)}
</div>
) : (
<div className="mt-1 p-2 bg-gray-50 rounded">
{renderValue(key, value)}
</div>
)}
</div>
);
});
};
return (
<Card>
<div className="flex justify-end items-center mb-4">
{!loading && settings && (
isEditing ? (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => {
setIsEditing(false);
setEditedValues(settings.values || {});
}}
disabled={saving}
>
Cancel
</Button>
<Button
onClick={handleSaveSettings}
loading={saving}
>
Save Changes
</Button>
</div>
) : (
<Button
onClick={() => setIsEditing(true)}
>
Edit Settings
</Button>
)
)}
</div>
{settings?.schema?.description && (
<Paragraph className="mb-4">{settings.schema.description}</Paragraph>
)}
<Divider />
<div className="mt-4 space-y-4">
{renderSettings()}
</div>
</Card>
);
};
export default SSOSettings;

View file

@ -0,0 +1,46 @@
import React from "react";
import { Select } from "antd";
const { Option } = Select;
interface BudgetDurationDropdownProps {
value?: string | null;
onChange: (value: string) => void;
className?: string;
style?: React.CSSProperties;
}
const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
value,
onChange,
className = "",
style = {}
}) => {
return (
<Select
style={{ width: '100%', ...style }}
value={value || undefined}
onChange={onChange}
className={className}
placeholder="n/a"
>
<Option value="24h">daily</Option>
<Option value="7d">weekly</Option>
<Option value="30d">monthly</Option>
</Select>
);
};
export const getBudgetDurationLabel = (value: string | null | undefined): string => {
if (!value) return "Not set";
const budgetDurationMap: Record<string, string> = {
"24h": "daily",
"7d": "weekly",
"30d": "monthly"
};
return budgetDurationMap[value] || value;
};
export default BudgetDurationDropdown;

View file

@ -40,6 +40,7 @@ import { Tooltip } from 'antd';
import Createuser from "./create_user_button";
import debounce from 'lodash/debounce';
import { rolesWithWriteAccess } from '../utils/roles';
import BudgetDurationDropdown from "./common_components/budget_duration_dropdown";
@ -572,11 +573,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({
name="budget_duration"
help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`}
>
<Select defaultValue={null} placeholder="n/a">
<Select.Option value="24h">daily</Select.Option>
<Select.Option value="7d">weekly</Select.Option>
<Select.Option value="30d">monthly</Select.Option>
</Select>
<BudgetDurationDropdown onChange={(value) => form.setFieldValue('budget_duration', value)} />
</Form.Item>
<Form.Item
className="mt-4"

View file

@ -119,7 +119,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({ visible, possibleUIRoles,
label="User Budget (USD)"
name="max_budget"
tooltip="(float) - Maximum budget of this user"
help="Ignored if the key has a team_id; team budget applies there."
help="Maximum budget of this user."
>
<InputNumber min={0} step={1} />
</Form.Item>

View file

@ -3970,7 +3970,73 @@ export const uiSpendLogDetailsCall = async (
return data;
} catch (error) {
console.error("Failed to fetch log details:", error);
throw error;
}
};
export const getInternalUserSettings = async (accessToken: string) => {
try {
// Construct base URL
let url = proxyBaseUrl
? `${proxyBaseUrl}/get/internal_user_settings`
: `/get/internal_user_settings`;
console.log("Fetching SSO settings from:", url);
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
console.log("Fetched SSO settings:", data);
return data;
} catch (error) {
console.error("Failed to fetch SSO settings:", error);
throw error;
}
};
export const updateInternalUserSettings = async (accessToken: string, settings: Record<string, any>) => {
try {
// Construct base URL
let url = proxyBaseUrl
? `${proxyBaseUrl}/update/internal_user_settings`
: `/update/internal_user_settings`;
console.log("Updating internal user settings:", settings);
const response = await fetch(url, {
method: "PATCH",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(settings),
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
console.log("Updated internal user settings:", data);
message.success("Internal user settings updated successfully");
return data;
} catch (error) {
console.error("Failed to update internal user settings:", error);
throw error;
}
};

View file

@ -49,6 +49,7 @@ import { columns } from "./view_users/columns";
import { UserDataTable } from "./view_users/table";
import { UserInfo } from "./view_users/types";
import BulkCreateUsers from "./bulk_create_users_button";
import SSOSettings from "./SSOSettings";
interface ViewUserDashboardProps {
accessToken: string | null;
@ -106,6 +107,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
>({});
const defaultPageSize = 25;
const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState("users");
// check if window is not undefined
if (typeof window !== "undefined") {
@ -285,56 +287,71 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
</div>
</div>
<div className="bg-white rounded-lg shadow">
<div className="border-b px-6 py-4">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0">
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700">
Showing{" "}
{userListResponse && userListResponse.users && userListResponse.users.length > 0
? (userListResponse.page - 1) * userListResponse.page_size + 1
: 0}{" "}
-{" "}
{userListResponse && userListResponse.users
? Math.min(
userListResponse.page * userListResponse.page_size,
userListResponse.total
)
: 0}{" "}
of {userListResponse ? userListResponse.total : 0} results
</span>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={!userListResponse || currentPage <= 1}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-700">
Page {userListResponse ? userListResponse.page : "-"} of{" "}
{userListResponse ? userListResponse.total_pages : "-"}
</span>
<button
onClick={() => setCurrentPage((p) => p + 1)}
disabled={
!userListResponse ||
currentPage >= userListResponse.total_pages
}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
<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>
<div className="bg-white rounded-lg shadow">
<div className="border-b px-6 py-4">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0">
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700">
Showing{" "}
{userListResponse && userListResponse.users && userListResponse.users.length > 0
? (userListResponse.page - 1) * userListResponse.page_size + 1
: 0}{" "}
-{" "}
{userListResponse && userListResponse.users
? Math.min(
userListResponse.page * userListResponse.page_size,
userListResponse.total
)
: 0}{" "}
of {userListResponse ? userListResponse.total : 0} results
</span>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={!userListResponse || currentPage <= 1}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-700">
Page {userListResponse ? userListResponse.page : "-"} of{" "}
{userListResponse ? userListResponse.total_pages : "-"}
</span>
<button
onClick={() => setCurrentPage((p) => p + 1)}
disabled={
!userListResponse ||
currentPage >= userListResponse.total_pages
}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
</div>
<UserDataTable
data={userData || []}
columns={tableColumns}
isLoading={!userData}
/>
</div>
</div>
</div>
<UserDataTable
data={userData || []}
columns={tableColumns}
isLoading={!userData}
/>
</div>
</TabPanel>
<TabPanel>
<SSOSettings accessToken={accessToken} possibleUIRoles={possibleUIRoles} userID={userID}/>
</TabPanel>
</TabPanels>
</TabGroup>
{/* Existing Modals */}
<EditUserModal