feat: e2e flow complete - admin can invite new users to proxy via invite links

Completes https://github.com/BerriAI/litellm/issues/3863
This commit is contained in:
Krrish Dholakia 2024-05-27 23:19:30 -07:00
parent 073bca78d4
commit 293d5cf1f2
6 changed files with 614 additions and 242 deletions

View file

@ -4,6 +4,7 @@
*/
import React, { useState, useEffect } from "react";
import { Typography } from "antd";
import { useRouter } from "next/navigation";
import {
Button as Button2,
Modal,
@ -13,6 +14,7 @@ import {
InputNumber,
message,
} from "antd";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { Select, SelectItem } from "@tremor/react";
import {
Table,
@ -28,6 +30,7 @@ import {
Text,
Grid,
Callout,
Divider,
} from "@tremor/react";
import { PencilAltIcon } from "@heroicons/react/outline";
interface AdminPanelProps {
@ -36,67 +39,94 @@ interface AdminPanelProps {
setTeams: React.Dispatch<React.SetStateAction<Object[] | null>>;
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,
userGetAllUsersCall,
User,
setCallbacksCall,
invitationCreateCall,
} from "./networking";
const AdminPanel: React.FC<AdminPanelProps> = ({
searchParams,
accessToken,
showSSOBanner
showSSOBanner,
}) => {
const [form] = Form.useForm();
const [memberForm] = Form.useForm();
const { Title, Paragraph } = Typography;
const [value, setValue] = useState("");
const [admins, setAdmins] = useState<null | any[]>(null);
const [invitationLinkData, setInvitationLinkData] =
useState<InvitationLink | null>(null);
const [isInvitationLinkModalVisible, setInvitationLinkModalVisible] =
useState(false);
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
const [isAddAdminModalVisible, setIsAddAdminModalVisible] = useState(false);
const [isUpdateMemberModalVisible, setIsUpdateModalModalVisible] = useState(false);
const [isUpdateMemberModalVisible, setIsUpdateModalModalVisible] =
useState(false);
const [isAddSSOModalVisible, setIsAddSSOModalVisible] = useState(false);
const [isInstructionsModalVisible, setIsInstructionsModalVisible] = useState(false);
const [isInstructionsModalVisible, setIsInstructionsModalVisible] =
useState(false);
const router = useRouter();
const [baseUrl, setBaseUrl] = useState("");
let nonSssoUrl;
try {
nonSssoUrl = window.location.origin;
} catch (error) {
nonSssoUrl = '<your-proxy-url>';
nonSssoUrl = "<your-proxy-url>";
}
nonSssoUrl += '/fallback/login';
nonSssoUrl += "/fallback/login";
const handleAddSSOOk = () => {
setIsAddSSOModalVisible(false);
form.resetFields();
};
const handleAddSSOOk = () => {
setIsAddSSOModalVisible(false);
form.resetFields();
};
const handleAddSSOCancel = () => {
setIsAddSSOModalVisible(false);
form.resetFields();
};
const handleAddSSOCancel = () => {
setIsAddSSOModalVisible(false);
form.resetFields();
};
const handleShowInstructions = (formValues: Record<string, any>) => {
handleAdminCreate(formValues);
handleSSOUpdate(formValues);
setIsAddSSOModalVisible(false);
setIsInstructionsModalVisible(true);
// Optionally, you can call handleSSOUpdate here with the formValues
};
const handleShowInstructions = (formValues: Record<string, any>) => {
handleAdminCreate(formValues);
handleSSOUpdate(formValues);
setIsAddSSOModalVisible(false);
setIsInstructionsModalVisible(true);
// Optionally, you can call handleSSOUpdate here with the formValues
};
const handleInstructionsOk = () => {
setIsInstructionsModalVisible(false);
};
const handleInstructionsOk = () => {
setIsInstructionsModalVisible(false);
};
const handleInstructionsCancel = () => {
setIsInstructionsModalVisible(false);
};
const handleInstructionsCancel = () => {
setIsInstructionsModalVisible(false);
};
const roles = ["proxy_admin", "proxy_admin_viewer"]
const roles = ["proxy_admin", "proxy_admin_viewer"];
useEffect(() => {
if (router) {
const { protocol, host } = window.location;
const baseUrl = `${protocol}//${host}`;
setBaseUrl(baseUrl);
}
}, [router]);
useEffect(() => {
// Fetch model info and set the default selected model
@ -167,82 +197,97 @@ const handleInstructionsCancel = () => {
const handleMemberUpdateCancel = () => {
setIsUpdateModalModalVisible(false);
memberForm.resetFields();
}
};
// Define the type for the handleMemberCreate function
type HandleMemberCreate = (formValues: Record<string, any>) => Promise<void>;
const addMemberForm = (handleMemberCreate: HandleMemberCreate,) => {
return <Form
form={form}
onFinish={handleMemberCreate}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<>
<Form.Item label="Email" name="user_email" className="mb-4">
<Input
name="user_email"
className="px-3 py-2 border rounded-md w-full"
/>
</Form.Item>
<div className="text-center mb-4">OR</div>
<Form.Item label="User ID" name="user_id" className="mb-4">
<Input
name="user_id"
className="px-3 py-2 border rounded-md w-full"
/>
</Form.Item>
</>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit">Add member</Button2>
</div>
</Form>
}
const modifyMemberForm = (handleMemberUpdate: HandleMemberCreate, currentRole: string, userID: string) => {
return <Form
form={form}
onFinish={handleMemberUpdate}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<>
<Form.Item rules={[{ required: true, message: 'Required' }]} label="User Role" name="user_role" labelCol={{ span: 10 }} labelAlign="left">
<Select value={currentRole}>
{roles.map((role, index) => (
<SelectItem
key={index}
value={role}
>
{role}
</SelectItem>
))}
</Select>
</Form.Item>
<Form.Item
label="Team ID"
name="user_id"
hidden={true}
initialValue={userID}
valuePropName="user_id"
className="mt-8"
const addMemberForm = (handleMemberCreate: HandleMemberCreate) => {
return (
<Form
form={form}
onFinish={handleMemberCreate}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<Input value={userID} disabled />
</Form.Item>
</>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit">Update role</Button2>
</div>
</Form>
}
<>
<Form.Item label="Email" name="user_email" className="mb-4">
<Input
name="user_email"
className="px-3 py-2 border rounded-md w-full"
/>
</Form.Item>
<div className="text-center mb-4">OR</div>
<Form.Item label="User ID" name="user_id" className="mb-4">
<Input
name="user_id"
className="px-3 py-2 border rounded-md w-full"
/>
</Form.Item>
</>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit">Add member</Button2>
</div>
</Form>
);
};
const modifyMemberForm = (
handleMemberUpdate: HandleMemberCreate,
currentRole: string,
userID: string
) => {
return (
<Form
form={form}
onFinish={handleMemberUpdate}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<>
<Form.Item
rules={[{ required: true, message: "Required" }]}
label="User Role"
name="user_role"
labelCol={{ span: 10 }}
labelAlign="left"
>
<Select value={currentRole}>
{roles.map((role, index) => (
<SelectItem key={index} value={role}>
{role}
</SelectItem>
))}
</Select>
</Form.Item>
<Form.Item
label="Team ID"
name="user_id"
hidden={true}
initialValue={userID}
valuePropName="user_id"
className="mt-8"
>
<Input value={userID} disabled />
</Form.Item>
</>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit">Update role</Button2>
</div>
</Form>
);
};
const handleMemberUpdate = async (formValues: Record<string, any>) => {
try{
try {
if (accessToken != null && admins != null) {
message.info("Making API Call");
const response: any = await userUpdateUserCall(accessToken, formValues, null);
const response: any = await userUpdateUserCall(
accessToken,
formValues,
null
);
console.log(`response for team create call: ${response}`);
// Checking if the team exists in the list and updating or adding accordingly
const foundIndex = admins.findIndex((user) => {
@ -257,20 +302,24 @@ const handleInstructionsCancel = () => {
admins.push(response);
// If new user is found, update it
setAdmins(admins); // Set the new state
}
message.success("Refresh tab to see updated user role")
}
message.success("Refresh tab to see updated user role");
setIsUpdateModalModalVisible(false);
}
} catch (error) {
console.error("Error creating the key:", error);
}
}
};
const handleMemberCreate = async (formValues: Record<string, any>) => {
try {
if (accessToken != null && admins != null) {
message.info("Making API Call");
const response: any = await userUpdateUserCall(accessToken, formValues, "proxy_admin_viewer");
const response: any = await userUpdateUserCall(
accessToken,
formValues,
"proxy_admin_viewer"
);
console.log(`response for team create call: ${response}`);
// Checking if the team exists in the list and updating or adding accordingly
const foundIndex = admins.findIndex((user) => {
@ -301,12 +350,23 @@ const handleInstructionsCancel = () => {
user_email: formValues.user_email,
user_id: formValues.user_id,
};
const response: any = await userUpdateUserCall(accessToken, formValues, "proxy_admin");
const response: any = await userUpdateUserCall(
accessToken,
formValues,
"proxy_admin"
);
// Give admin an invite link for inviting user to proxy
const user_id = response.data?.user_id || response.user_id;
invitationCreateCall(accessToken, user_id).then((data) => {
setInvitationLinkData(data);
setInvitationLinkModalVisible(true);
});
console.log(`response for team create call: ${response}`);
// Checking if the team exists in the list and updating or adding accordingly
const foundIndex = admins.findIndex((user) => {
console.log(
`user.user_id=${user.user_id}; response.user_id=${response.user_id}`
`user.user_id=${user.user_id}; response.user_id=${user_id}`
);
return user.user_id === response.user_id;
});
@ -336,22 +396,24 @@ const handleInstructionsCancel = () => {
},
};
setCallbacksCall(accessToken, payload);
}
};
console.log(`admins: ${admins?.length}`);
return (
<div className="w-full m-2 mt-2 p-8">
<Title level={4}>Admin Access </Title>
<Paragraph>
{
showSSOBanner && <a href="https://docs.litellm.ai/docs/proxy/ui#restrict-ui-access">Requires SSO Setup</a>
}
<br/>
<b>Proxy Admin: </b> Can create keys, teams, users, add models, etc. <br/>
<b>Proxy Admin Viewer: </b>Can just view spend. They cannot create keys, teams or
grant users access to new models.{" "}
{showSSOBanner && (
<a href="https://docs.litellm.ai/docs/proxy/ui#restrict-ui-access">
Requires SSO Setup
</a>
)}
<br />
<b>Proxy Admin: </b> Can create keys, teams, users, add models, etc.{" "}
<br />
<b>Proxy Admin Viewer: </b>Can just view spend. They cannot create keys,
teams or grant users access to new models.{" "}
</Paragraph>
<Grid numItems={1} className="gap-2 p-2 w-full">
<Col numColSpan={1}>
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]">
<Table>
@ -370,20 +432,29 @@ const handleInstructionsCancel = () => {
{member["user_email"]
? member["user_email"]
: member["user_id"]
? member["user_id"]
: null}
? member["user_id"]
: null}
</TableCell>
<TableCell>{member["user_role"]}</TableCell>
<TableCell>
<Icon icon={PencilAltIcon} size="sm" onClick={() => setIsUpdateModalModalVisible(true)}/>
<Icon
icon={PencilAltIcon}
size="sm"
onClick={() => setIsUpdateModalModalVisible(true)}
/>
<Modal
title="Update role"
visible={isUpdateMemberModalVisible}
width={800}
footer={null}
onOk={handleMemberUpdateOk}
onCancel={handleMemberUpdateCancel}>
{modifyMemberForm(handleMemberUpdate, member["user_role"], member["user_id"])}
onCancel={handleMemberUpdateCancel}
>
{modifyMemberForm(
handleMemberUpdate,
member["user_role"],
member["user_id"]
)}
</Modal>
</TableCell>
</TableRow>
@ -394,129 +465,183 @@ const handleInstructionsCancel = () => {
</Card>
</Col>
<Col numColSpan={1}>
<div className="flex justify-start">
<Button
<div className="flex justify-start">
<Button
className="mr-4 mb-5"
onClick={() => setIsAddAdminModalVisible(true)}
>
>
+ Add admin
</Button>
<Modal
title="Add admin"
visible={isAddAdminModalVisible}
width={800}
footer={null}
onOk={handleAdminOk}
onCancel={handleAdminCancel}>
{addMemberForm(handleAdminCreate)}
</Modal>
<Button
</Button>
<Modal
title="Add admin"
visible={isAddAdminModalVisible}
width={800}
footer={null}
onOk={handleAdminOk}
onCancel={handleAdminCancel}
>
{addMemberForm(handleAdminCreate)}
</Modal>
<Modal
title="Invitation Link"
visible={isInvitationLinkModalVisible}
width={600}
footer={null}
onOk={handleAdminOk}
onCancel={handleAdminCancel}
>
{/* {JSON.stringify(invitationLinkData)} */}
<Paragraph>
Copy and send the generated link to onboard this user to the
proxy.
</Paragraph>
<div className="flex justify-between pt-5 pb-2">
<Text className="text-base">User ID</Text>
<Text>{invitationLinkData?.user_id}</Text>
</div>
<div className="flex justify-between pt-5 pb-2">
<Text>Invitation Link</Text>
<Text>
{baseUrl}/onboarding/{invitationLinkData?.id}
</Text>
</div>
<div className="flex justify-end mt-5">
<div></div>
<CopyToClipboard
text={`${baseUrl}/onboarding/${invitationLinkData?.id}`}
onCopy={() => message.success("Copied!")}
>
<Button variant="primary">Copy invitation link</Button>
</CopyToClipboard>
</div>
</Modal>
<Button
className="mb-5"
onClick={() => setIsAddMemberModalVisible(true)}
>
>
+ Add viewer
</Button>
<Modal
title="Add viewer"
visible={isAddMemberModalVisible}
width={800}
footer={null}
onOk={handleMemberOk}
onCancel={handleMemberCancel}
>
{addMemberForm(handleMemberCreate)}
</Modal>
</Button>
<Modal
title="Add viewer"
visible={isAddMemberModalVisible}
width={800}
footer={null}
onOk={handleMemberOk}
onCancel={handleMemberCancel}
>
{addMemberForm(handleMemberCreate)}
</Modal>
</div>
</Col>
</Grid>
<Grid>
<Title level={4}>Add SSO</Title>
<div className="flex justify-start mb-4">
<Button onClick={() => setIsAddSSOModalVisible(true)}>Add SSO</Button>
<Modal
title="Add SSO"
visible={isAddSSOModalVisible}
width={800}
footer={null}
onOk={handleAddSSOOk}
onCancel={handleAddSSOCancel}
>
<Form
form={form}
onFinish={handleShowInstructions}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<>
<Form.Item
label="Admin Email"
name="user_email"
rules={[{ required: true, message: "Please enter the email of the proxy admin" }]}
<Title level={4}>Add SSO</Title>
<div className="flex justify-start mb-4">
<Button onClick={() => setIsAddSSOModalVisible(true)}>Add SSO</Button>
<Modal
title="Add SSO"
visible={isAddSSOModalVisible}
width={800}
footer={null}
onOk={handleAddSSOOk}
onCancel={handleAddSSOCancel}
>
<Form
form={form}
onFinish={handleShowInstructions}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<Input />
</Form.Item>
<Form.Item
label="PROXY BASE URL"
name="proxy_base_url"
rules={[{ required: true, message: "Please enter the proxy base url" }]}
>
<Input />
</Form.Item>
<>
<Form.Item
label="Admin Email"
name="user_email"
rules={[
{
required: true,
message: "Please enter the email of the proxy admin",
},
]}
>
<Input />
</Form.Item>
<Form.Item
label="PROXY BASE URL"
name="proxy_base_url"
rules={[
{
required: true,
message: "Please enter the proxy base url",
},
]}
>
<Input />
</Form.Item>
<Form.Item
label="GOOGLE CLIENT ID"
name="google_client_id"
rules={[{ required: true, message: "Please enter the google client id" }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="GOOGLE CLIENT ID"
name="google_client_id"
rules={[
{
required: true,
message: "Please enter the google client id",
},
]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="GOOGLE CLIENT SECRET"
name="google_client_secret"
rules={[{ required: true, message: "Please enter the google client secret" }]}
>
<Input.Password />
</Form.Item>
</>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit">Save</Button2>
</div>
</Form>
</Modal>
<Modal
title="SSO Setup Instructions"
visible={isInstructionsModalVisible}
width={800}
footer={null}
onOk={handleInstructionsOk}
onCancel={handleInstructionsCancel}
>
<p>Follow these steps to complete the SSO setup:</p>
<Text className="mt-2">
1. DO NOT Exit this TAB
</Text>
<Text className="mt-2">
2. Open a new tab, visit your proxy base url
</Text>
<Text className="mt-2">
3. Confirm your SSO is configured correctly and you can login on the new Tab
</Text>
<Text className="mt-2">
4. If Step 3 is successful, you can close this tab
</Text>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 onClick={handleInstructionsOk}>Done</Button2>
</div>
</Modal>
</div>
<Callout title="Login without SSO" color="teal">
If you need to login without sso, you can access <a href= {nonSssoUrl} target="_blank"><b>{nonSssoUrl}</b> </a>
</Callout>
</Grid>
<Form.Item
label="GOOGLE CLIENT SECRET"
name="google_client_secret"
rules={[
{
required: true,
message: "Please enter the google client secret",
},
]}
>
<Input.Password />
</Form.Item>
</>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit">Save</Button2>
</div>
</Form>
</Modal>
<Modal
title="SSO Setup Instructions"
visible={isInstructionsModalVisible}
width={800}
footer={null}
onOk={handleInstructionsOk}
onCancel={handleInstructionsCancel}
>
<p>Follow these steps to complete the SSO setup:</p>
<Text className="mt-2">1. DO NOT Exit this TAB</Text>
<Text className="mt-2">
2. Open a new tab, visit your proxy base url
</Text>
<Text className="mt-2">
3. Confirm your SSO is configured correctly and you can login on
the new Tab
</Text>
<Text className="mt-2">
4. If Step 3 is successful, you can close this tab
</Text>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 onClick={handleInstructionsOk}>Done</Button2>
</div>
</Modal>
</div>
<Callout title="Login without SSO" color="teal">
If you need to login without sso, you can access{" "}
<a href={nonSssoUrl} target="_blank">
<b>{nonSssoUrl}</b>{" "}
</a>
</Callout>
</Grid>
</div>
);
};