diff --git a/litellm/proxy/_experimental/out/404.html b/litellm/proxy/_experimental/out/404.html
deleted file mode 100644
index 41cc292f2..000000000
--- a/litellm/proxy/_experimental/out/404.html
+++ /dev/null
@@ -1 +0,0 @@
-
404: This page could not be found.LiteLLM Dashboard404
This page could not be found.
\ No newline at end of file
diff --git a/litellm/proxy/_experimental/out/model_hub.html b/litellm/proxy/_experimental/out/model_hub.html
deleted file mode 100644
index 80a9a9ee0..000000000
--- a/litellm/proxy/_experimental/out/model_hub.html
+++ /dev/null
@@ -1 +0,0 @@
-LiteLLM Dashboard
\ No newline at end of file
diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding.html
deleted file mode 100644
index 50b15455f..000000000
--- a/litellm/proxy/_experimental/out/onboarding.html
+++ /dev/null
@@ -1 +0,0 @@
-LiteLLM Dashboard
\ No newline at end of file
diff --git a/litellm/proxy/_super_secret_config.yaml b/litellm/proxy/_super_secret_config.yaml
index 3bc516604..c6b0bb011 100644
--- a/litellm/proxy/_super_secret_config.yaml
+++ b/litellm/proxy/_super_secret_config.yaml
@@ -15,9 +15,9 @@ model_list:
# rpm: 10
# model_name: gpt-3.5-turbo-fake-model
- litellm_params:
- api_base: https://openai-gpt-4-test-v-1.openai.azure.com/
+ api_base: https://openai-gpt-4-test-v-1.openai.azure.com
api_key: os.environ/AZURE_API_KEY
- api_version: '2023-05-15'
+ api_version: 2024-02-15-preview
model: azure/chatgpt-v-2
model_name: gpt-3.5-turbo
- litellm_params:
@@ -26,10 +26,10 @@ model_list:
- litellm_params:
api_base: https://openai-gpt-4-test-v-1.openai.azure.com/
api_key: os.environ/AZURE_API_KEY
- api_version: '2023-05-15'
+ api_version: 2024-02-15-preview
model: azure/chatgpt-v-2
drop_params: True
- model_name: gpt-3.5-turbo-drop-params
+ model_name: gpt-3.5-turbo
- model_name: tts
litellm_params:
model: openai/tts-1
@@ -37,6 +37,7 @@ model_list:
litellm_params:
api_base: https://openai-france-1234.openai.azure.com
api_key: os.environ/AZURE_FRANCE_API_KEY
+ api_version: 2024-02-15-preview
model: azure/gpt-turbo
- model_name: text-embedding
litellm_params:
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index e5ea35a39..8a0369892 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -190,6 +190,7 @@ class LiteLLMRoutes(enum.Enum):
"/model/info",
"/v2/model/info",
"/v2/key/info",
+ "/model_group/info",
]
# NOTE: ROUTES ONLY FOR MASTER KEY - only the Master Key should be able to Reset Spend
@@ -1343,6 +1344,12 @@ class InvitationModel(LiteLLMBase):
updated_by: str
+class InvitationClaim(LiteLLMBase):
+ invitation_link: str
+ user_id: str
+ password: str
+
+
class ConfigFieldInfo(LiteLLMBase):
field_name: str
field_value: Any
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 4c6e933f4..5cf6d12ed 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -1311,7 +1311,9 @@ async def user_api_key_auth(
if user_id != valid_token.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
- detail="key not allowed to access this user's info",
+ detail="key not allowed to access this user's info. user_id={}, key's user_id={}".format(
+ user_id, valid_token.user_id
+ ),
)
elif route == "/model/info":
# /model/info just shows models user has access to
@@ -1406,7 +1408,7 @@ async def user_api_key_auth(
"/global/predict/spend/logs",
"/global/activity",
"/health/services",
- ]
+ ] + LiteLLMRoutes.info_routes.value
# check if the current route startswith any of the allowed routes
if (
route is not None
@@ -9164,6 +9166,7 @@ async def new_user(data: NewUserRequest):
data_json["table_name"] = (
"user" # only create a user, don't create key if 'auto_create_key' set to False
)
+
response = await generate_key_helper_fn(request_type="user", **data_json)
# Admin UI Logic
@@ -12765,7 +12768,10 @@ async def login(request: Request):
_password = getattr(_user_row, "password", "unknown")
# check if password == _user_row.password
- if secrets.compare_digest(password, _password):
+ hash_password = hash_token(token=password)
+ if secrets.compare_digest(password, _password) or secrets.compare_digest(
+ hash_password, _password
+ ):
if os.getenv("DATABASE_URL") is not None:
response = await generate_key_helper_fn(
request_type="key",
@@ -12928,6 +12934,92 @@ async def onboarding(invite_link: str):
}
+@app.post("/onboarding/claim_token", include_in_schema=False)
+async def claim_onboarding_link(data: InvitationClaim):
+ """
+ Special route. Allows UI link share user to update their password.
+
+ - Get the invite link
+ - Validate it's still 'valid'
+ - Check if user within initial session (prevents abuse)
+ - Get user from db
+ - Update user password
+
+ This route can only update user password.
+ """
+ global prisma_client
+ ### VALIDATE INVITE LINK ###
+ if prisma_client is None:
+ raise HTTPException(
+ status_code=500,
+ detail={"error": CommonProxyErrors.db_not_connected_error.value},
+ )
+
+ invite_obj = await prisma_client.db.litellm_invitationlink.find_unique(
+ where={"id": data.invitation_link}
+ )
+ if invite_obj is None:
+ raise HTTPException(
+ status_code=401, detail={"error": "Invitation link does not exist in db."}
+ )
+ #### CHECK IF EXPIRED
+ # Extract the date part from both datetime objects
+ utc_now_date = litellm.utils.get_utc_datetime().date()
+ expires_at_date = invite_obj.expires_at.date()
+ if expires_at_date < utc_now_date:
+ raise HTTPException(
+ status_code=401, detail={"error": "Invitation link has expired."}
+ )
+
+ #### CHECK IF CLAIMED
+ ##### if claimed - check if within valid session (within 10 minutes of being claimed)
+ ##### if unclaimed - reject
+
+ current_time = litellm.utils.get_utc_datetime()
+
+ if invite_obj.is_accepted == True:
+ time_difference = current_time - invite_obj.updated_at
+
+ # Check if the difference is within 10 minutes
+ if time_difference > timedelta(minutes=10):
+ raise HTTPException(
+ status_code=401,
+ detail={
+ "error": "The invitation link has already been claimed. Please ask your admin for a new invite link."
+ },
+ )
+ else:
+ raise HTTPException(
+ status_code=401,
+ detail={
+ "error": "The invitation link was never validated. Please file an issue, if this is not intended - https://github.com/BerriAI/litellm/issues."
+ },
+ )
+
+ #### CHECK IF VALID USER ID
+ if invite_obj.user_id != data.user_id:
+ raise HTTPException(
+ status_code=401,
+ detail={
+ "error": "Invalid invitation link. The user id submitted does not match the user id this link is attached to. Got={}, Expected={}".format(
+ data.user_id, invite_obj.user_id
+ )
+ },
+ )
+ ### UPDATE USER OBJECT ###
+ hash_password = hash_token(token=data.password)
+ user_obj = await prisma_client.db.litellm_usertable.update(
+ where={"user_id": invite_obj.user_id}, data={"password": hash_password}
+ )
+
+ if user_obj is None:
+ raise HTTPException(
+ status_code=401, detail={"error": "User does not exist in db."}
+ )
+
+ return user_obj
+
+
@app.get("/get_image", include_in_schema=False)
def get_image():
"""Get logo to show on admin UI"""
diff --git a/ui/litellm-dashboard/src/app/onboarding/page.tsx b/ui/litellm-dashboard/src/app/onboarding/page.tsx
index 9803cf40a..44c37610c 100644
--- a/ui/litellm-dashboard/src/app/onboarding/page.tsx
+++ b/ui/litellm-dashboard/src/app/onboarding/page.tsx
@@ -1,12 +1,22 @@
"use client";
import React, { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
-import { Card, Title, Text, TextInput, Callout, Button, Grid, Col } from "@tremor/react";
-import { RiAlarmWarningLine, RiCheckboxCircleLine } from '@remixicon/react';
+import {
+ Card,
+ Title,
+ Text,
+ TextInput,
+ Callout,
+ Button,
+ Grid,
+ Col,
+} from "@tremor/react";
+import { RiAlarmWarningLine, RiCheckboxCircleLine } from "@remixicon/react";
import {
invitationClaimCall,
userUpdateUserCall,
getOnboardingCredentials,
+ claimOnboardingToken,
} from "@/components/networking";
import { jwtDecode } from "jwt-decode";
import { Form, Button as Button2, message } from "antd";
@@ -18,6 +28,7 @@ export default function Onboarding() {
const [accessToken, setAccessToken] = useState(null);
const [defaultUserEmail, setDefaultUserEmail] = useState("");
const [userEmail, setUserEmail] = useState("");
+ const [userID, setUserID] = useState(null);
const [loginUrl, setLoginUrl] = useState("");
const [jwtToken, setJwtToken] = useState("");
@@ -26,11 +37,10 @@ export default function Onboarding() {
return;
}
getOnboardingCredentials(inviteID).then((data) => {
- const login_url = data.login_url;
+ const login_url = data.login_url;
console.log("login_url:", login_url);
setLoginUrl(login_url);
-
const token = data.token;
const decoded = jwtDecode(token) as { [key: string]: any };
setJwtToken(token);
@@ -42,31 +52,44 @@ export default function Onboarding() {
const user_email = decoded.user_email;
setUserEmail(user_email);
+ const user_id = decoded.user_id;
+ setUserID(user_id);
});
-
}, [inviteID]);
-
const handleSubmit = (formValues: Record) => {
- console.log("in handle submit. accessToken:", accessToken, "token:", jwtToken, "formValues:", formValues);
+ console.log(
+ "in handle submit. accessToken:",
+ accessToken,
+ "token:",
+ jwtToken,
+ "formValues:",
+ formValues
+ );
if (!accessToken || !jwtToken) {
return;
}
formValues.user_email = userEmail;
- userUpdateUserCall(accessToken, formValues, null).then((data) => {
+ if (!userID || !inviteID) {
+ return;
+ }
+ claimOnboardingToken(
+ accessToken,
+ inviteID,
+ userID,
+ formValues.password
+ ).then((data) => {
let litellm_dashboard_ui = "/ui/";
const user_id = data.data?.user_id || data.user_id;
litellm_dashboard_ui += "?userID=" + user_id + "&token=" + jwtToken;
console.log("redirecting to:", litellm_dashboard_ui);
window.location.href = litellm_dashboard_ui;
-
});
// redirect to login page
-
};
return (
@@ -81,33 +104,26 @@ export default function Onboarding() {
icon={RiCheckboxCircleLine}
color="sky"
>
-
-
- SSO is under the Enterprise Tirer.
-
+
+ SSO is under the Enterprise Tirer.
-
-
-
-
-
+
+
+
+
-
+
+
diff --git a/ui/litellm-dashboard/src/components/admins.tsx b/ui/litellm-dashboard/src/components/admins.tsx
index fbcb20e5c..3b012d5b4 100644
--- a/ui/litellm-dashboard/src/components/admins.tsx
+++ b/ui/litellm-dashboard/src/components/admins.tsx
@@ -33,6 +33,8 @@ import {
Divider,
} from "@tremor/react";
import { PencilAltIcon } from "@heroicons/react/outline";
+import OnboardingModal from "./onboarding_link";
+import { InvitationLink } from "./onboarding_link";
interface AdminPanelProps {
searchParams: any;
accessToken: string | null;
@@ -40,17 +42,6 @@ interface AdminPanelProps {
showSSOBanner: boolean;
}
-interface InvitationLink {
- id: string;
- user_id: string;
- is_accepted: boolean;
- accepted_at: Date | null;
- expires_at: Date;
- created_at: Date;
- created_by: string;
- updated_at: Date;
- updated_by: string;
-}
import {
userUpdateUserCall,
Member,
@@ -84,11 +75,15 @@ const AdminPanel: React.FC = ({
useState(false);
const router = useRouter();
- const [possibleUIRoles, setPossibleUIRoles] = useState>>(null);
+ const [possibleUIRoles, setPossibleUIRoles] = useState
+ >>(null);
const isLocal = process.env.NODE_ENV === "development";
- const [baseUrl, setBaseUrl] = useState(isLocal ? "http://localhost:4000" : "");
-
+ const [baseUrl, setBaseUrl] = useState(
+ isLocal ? "http://localhost:4000" : ""
+ );
let nonSssoUrl;
try {
@@ -346,7 +341,6 @@ const AdminPanel: React.FC = ({
setIsInvitationLinkModalVisible(true);
});
-
const foundIndex = admins.findIndex((user) => {
console.log(
`user.user_id=${user.user_id}; response.user_id=${response.user_id}`
@@ -462,7 +456,11 @@ const AdminPanel: React.FC = ({
? member["user_id"]
: null}
- {possibleUIRoles?.[member?.user_role]?.ui_label || "-"}
+
+ {" "}
+ {possibleUIRoles?.[member?.user_role]?.ui_label ||
+ "-"}
+
= ({
>
{addMemberForm(handleAdminCreate)}
-
- {/* {JSON.stringify(invitationLinkData)} */}
-
- Copy and send the generated link to onboard this user to the
- proxy.
-
-
- User ID
- {invitationLinkData?.user_id}
-
-
- Invitation Link
-
- {baseUrl}/ui/onboarding?id={invitationLinkData?.id}
-
-
-
-
-
message.success("Copied!")}
- >
-
-
-
-
+
);
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index 2b89c5ef1..e436c7808 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -522,6 +522,12 @@ export const userInfoCall = async (
if (userRole == "App User" && userID) {
url = `${url}?user_id=${userID}`;
}
+ if (
+ (userRole == "Internal User" || userRole == "Internal Viewer") &&
+ userID
+ ) {
+ url = `${url}?user_id=${userID}`;
+ }
console.log("in userInfoCall viewAll=", viewAll);
if (viewAll && page_size && page != null && page != undefined) {
url = `${url}?view_all=true&page=${page}&page_size=${page_size}`;
@@ -617,14 +623,15 @@ export const getTotalSpendCall = async (accessToken: String) => {
}
};
-
export const getOnboardingCredentials = async (inviteUUID: String) => {
/**
* Get all models on proxy
*/
try {
- let url = proxyBaseUrl ? `${proxyBaseUrl}/onboarding/get_token` : `/onboarding/get_token`;
- url += `?invite_link=${inviteUUID}`
+ let url = proxyBaseUrl
+ ? `${proxyBaseUrl}/onboarding/get_token`
+ : `/onboarding/get_token`;
+ url += `?invite_link=${inviteUUID}`;
const response = await fetch(url, {
method: "GET",
@@ -648,6 +655,43 @@ export const getOnboardingCredentials = async (inviteUUID: String) => {
}
};
+export const claimOnboardingToken = async (
+ accessToken: string,
+ inviteUUID: string,
+ userID: string,
+ password: String
+) => {
+ const url = proxyBaseUrl
+ ? `${proxyBaseUrl}/onboarding/claim_token`
+ : `/onboarding/claim_token`;
+ try {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ invitation_link: inviteUUID,
+ user_id: userID,
+ password: password,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.text();
+ message.error("Failed to delete team: " + errorData, 10);
+ throw new Error("Network response was not ok");
+ }
+ const data = await response.json();
+ console.log(data);
+ return data;
+ // Handle success - you might want to update some state or UI based on the created key
+ } catch (error) {
+ console.error("Failed to delete key:", error);
+ throw error;
+ }
+};
export const modelInfoCall = async (
accessToken: String,
userID: String,
@@ -1024,15 +1068,12 @@ export const tagsSpendLogsCall = async (
}
};
-export const allTagNamesCall = async (
- accessToken: String,
-) => {
+export const allTagNamesCall = async (accessToken: String) => {
try {
let url = proxyBaseUrl
? `${proxyBaseUrl}/global/spend/all_tag_names`
: `/global/spend/all_tag_names`;
-
console.log("in global/spend/all_tag_names call", url);
const response = await fetch(`${url}`, {
method: "GET",
@@ -1055,16 +1096,12 @@ export const allTagNamesCall = async (
}
};
-
-export const allEndUsersCall = async (
- accessToken: String,
-) => {
+export const allEndUsersCall = async (accessToken: String) => {
try {
let url = proxyBaseUrl
? `${proxyBaseUrl}/global/all_end_users`
: `/global/all_end_users`;
-
console.log("in global/all_end_users call", url);
const response = await fetch(`${url}`, {
method: "GET",
@@ -1087,7 +1124,6 @@ export const allEndUsersCall = async (
}
};
-
export const userSpendLogsCall = async (
accessToken: String,
token: String,
@@ -1377,13 +1413,11 @@ export const adminGlobalActivityPerModel = async (
}
};
-
-
export const adminGlobalActivityExceptions = async (
accessToken: String,
startTime: String | undefined,
endTime: String | undefined,
- modelGroup: String,
+ modelGroup: String
) => {
try {
let url = proxyBaseUrl
@@ -1429,7 +1463,7 @@ export const adminGlobalActivityExceptionsPerDeployment = async (
accessToken: String,
startTime: String | undefined,
endTime: String | undefined,
- modelGroup: String,
+ modelGroup: String
) => {
try {
let url = proxyBaseUrl
@@ -1666,9 +1700,7 @@ export const userGetAllUsersCall = async (
}
};
-export const getPossibleUserRoles = async (
- accessToken: String,
-) => {
+export const getPossibleUserRoles = async (accessToken: String) => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/user/available_roles`
diff --git a/ui/litellm-dashboard/src/components/onboarding_link.tsx b/ui/litellm-dashboard/src/components/onboarding_link.tsx
new file mode 100644
index 000000000..09a635a14
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/onboarding_link.tsx
@@ -0,0 +1,86 @@
+import React, { useState, useEffect } from "react";
+import {
+ Button as Button2,
+ Modal,
+ Form,
+ Input,
+ Select as Select2,
+ InputNumber,
+ message,
+ Typography,
+} from "antd";
+import { CopyToClipboard } from "react-copy-to-clipboard";
+import { Text, Button } from "@tremor/react";
+export interface InvitationLink {
+ id: string;
+ user_id: string;
+ is_accepted: boolean;
+ accepted_at: Date | null;
+ expires_at: Date;
+ created_at: Date;
+ created_by: string;
+ updated_at: Date;
+ updated_by: string;
+}
+
+interface OnboardingProps {
+ isInvitationLinkModalVisible: boolean;
+ setIsInvitationLinkModalVisible: React.Dispatch<
+ React.SetStateAction
+ >;
+ baseUrl: string;
+ invitationLinkData: InvitationLink | null;
+}
+
+const OnboardingModal: React.FC = ({
+ isInvitationLinkModalVisible,
+ setIsInvitationLinkModalVisible,
+ baseUrl,
+ invitationLinkData,
+}) => {
+ const { Title, Paragraph } = Typography;
+ const handleInvitationOk = () => {
+ setIsInvitationLinkModalVisible(false);
+ };
+
+ const handleInvitationCancel = () => {
+ setIsInvitationLinkModalVisible(false);
+ };
+
+ return (
+
+ {/* {JSON.stringify(invitationLinkData)} */}
+
+ Copy and send the generated link to onboard this user to the proxy.
+
+
+ User ID
+ {invitationLinkData?.user_id}
+
+
+ Invitation Link
+
+ {baseUrl}/ui/onboarding?id={invitationLinkData?.id}
+
+
+
+
+
message.success("Copied!")}
+ >
+
+
+
+
+ );
+};
+
+export default OnboardingModal;
diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx
index 1a2439055..d214e13ee 100644
--- a/ui/litellm-dashboard/src/components/user_dashboard.tsx
+++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx
@@ -101,7 +101,7 @@ const UserDashboard: React.FC = ({
return "App User";
case "internal_user":
return "Internal User";
- case "internal_viewer":
+ case "internal_user_viewer":
return "Internal Viewer";
default:
return "Unknown Role";
diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx
index 1b7d2fac6..6b1deee6e 100644
--- a/ui/litellm-dashboard/src/components/view_users.tsx
+++ b/ui/litellm-dashboard/src/components/view_users.tsx
@@ -25,15 +25,17 @@ import {
TextInput,
} from "@tremor/react";
-import {
- message,
-} from "antd";
+import { message } from "antd";
-import { userInfoCall, userUpdateUserCall, getPossibleUserRoles } from "./networking";
+import {
+ userInfoCall,
+ userUpdateUserCall,
+ getPossibleUserRoles,
+} from "./networking";
import { Badge, BadgeDelta, Button } from "@tremor/react";
import RequestAccess from "./request_model_access";
import CreateUser from "./create_user_button";
-import EditUserModal from "./edit_user";
+import EditUserModal from "./edit_user";
import Paragraph from "antd/es/skeleton/Paragraph";
import {
PencilAltIcon,
@@ -67,14 +69,16 @@ const ViewUserDashboard: React.FC = ({
const [selectedItem, setSelectedItem] = useState(null);
const [editModalVisible, setEditModalVisible] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
- const [possibleUIRoles, setPossibleUIRoles] = useState>>({});
+ const [possibleUIRoles, setPossibleUIRoles] = useState<
+ Record>
+ >({});
const defaultPageSize = 25;
const handleEditCancel = async () => {
setSelectedUser(null);
setEditModalVisible(false);
};
-
+
const handleEditSubmit = async (editedUser: any) => {
console.log("inside handleEditSubmit:", editedUser);
@@ -87,7 +91,7 @@ const ViewUserDashboard: React.FC = ({
message.success(`User ${editedUser.user_id} updated successfully`);
} catch (error) {
console.error("There was an error updating the user", error);
- }
+ }
if (userData) {
const updatedUserData = userData.map((user) =>
user.user_id === editedUser.user_id ? editedUser : user
@@ -119,13 +123,11 @@ const ViewUserDashboard: React.FC = ({
const availableUserRoles = await getPossibleUserRoles(accessToken);
setPossibleUIRoles(availableUserRoles);
-
} catch (error) {
console.error("There was an error fetching the model data", error);
}
};
-
if (accessToken && token && userRole && userID) {
fetchData();
}
@@ -174,10 +176,14 @@ const ViewUserDashboard: React.FC = ({
return (
-
+
-
-
+
@@ -218,9 +224,8 @@ const ViewUserDashboard: React.FC = ({
user.key_aliases.filter(
(key: any) => key !== null
).length
-
}
- Keys
+ Keys
) : (
@@ -233,23 +238,24 @@ const ViewUserDashboard: React.FC = ({
)}
{/* {user.key_aliases.filter(key => key !== null).length} Keys */}
-
-
+
+
-
- {
- setSelectedUser(user)
- setEditModalVisible(true)
- }}>View Keys
-{/*
+ {
+ setSelectedUser(user);
+ setEditModalVisible(true);
+ }}
+ >
+ View Keys
+
+ {/*
{
setOpenDialogId(user.user_id)
setSelectedItem(user)
}}>View Keys */}
-
-
-
))}
@@ -283,12 +289,12 @@ const ViewUserDashboard: React.FC
= ({
+ visible={editModalVisible}
+ possibleUIRoles={possibleUIRoles}
+ onCancel={handleEditCancel}
+ user={selectedUser}
+ onSubmit={handleEditSubmit}
+ />
{renderPagination()}