diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 577dbade0..cd3611e87 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -7737,12 +7737,12 @@ async def block_user(data: BlockUsers): "/end_user/unblock", tags=["Customer Management"], dependencies=[Depends(user_api_key_auth)], + include_in_schema=False, ) @router.post( "/customer/unblock", tags=["Customer Management"], dependencies=[Depends(user_api_key_auth)], - include_in_schema=False, ) async def unblock_user(data: BlockUsers): """ @@ -10718,6 +10718,90 @@ async def login(request: Request): ) +@app.get("/onboarding/{invite_link}", include_in_schema=False) +async def onboarding(invite_link: str): + """ + - Get the invite link + - Validate it's still 'valid' + - Invalidate the link (prevents abuse) + - Get user from db + - Pass in user_email if set + """ + 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": invite_link} + ) + if invite_obj is None: + raise HTTPException( + status_code=401, detail={"error": "Invitation link does not exist in db."} + ) + #### CHECK IF EXPIRED + if invite_obj.expires_at < litellm.utils.get_utc_datetime(): + raise HTTPException( + status_code=401, detail={"error": "Invitation link has expired."} + ) + + #### INVALIDATE LINK + current_time = litellm.utils.get_utc_datetime() + + _ = await prisma_client.db.litellm_invitationlink.update( + where={"id": invite_link}, + data={ + "accepted_at": current_time, + "updated_at": current_time, + "is_accepted": True, + "updated_by": invite_obj.user_id, # type: ignore + }, + ) + + ### GET USER OBJECT ### + user_obj = await prisma_client.db.litellm_usertable.find_unique( + where={"user_id": invite_obj.user_id} + ) + + if user_obj is None: + raise HTTPException( + status_code=401, detail={"error": "User does not exist in db."} + ) + + user_email = user_obj.user_email + + response = await generate_key_helper_fn( + **{"user_role": user_obj.user_role or "app_owner", "duration": "2hr", "key_max_budget": 5, "models": [], "aliases": {}, "config": {}, "spend": 0, "user_id": user_obj.user_id, "team_id": "litellm-dashboard"} # type: ignore + ) + key = response["token"] # type: ignore + + litellm_dashboard_ui = os.getenv("PROXY_BASE_URL", "") + if litellm_dashboard_ui.endswith("/"): + litellm_dashboard_ui += "ui/onboarding" + else: + litellm_dashboard_ui += "/ui/onboarding" + import jwt + + jwt_token = jwt.encode( + { + "user_id": user_obj.user_id, + "key": key, + "user_email": user_obj.user_email, + "user_role": "app_owner", + "login_method": "username_password", + "premium_user": premium_user, + }, + "secret", + algorithm="HS256", + ) + + litellm_dashboard_ui += "?token={}&user_email={}".format(jwt_token, user_email) + return RedirectResponse(url=litellm_dashboard_ui, status_code=303) + + @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 new file mode 100644 index 000000000..dcdd3c553 --- /dev/null +++ b/ui/litellm-dashboard/src/app/onboarding/page.tsx @@ -0,0 +1,92 @@ +"use client"; +import React, { Suspense, useEffect, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Card, Title, Text, TextInput } from "@tremor/react"; +import { + invitationClaimCall, + userUpdateUserCall, +} from "@/components/networking"; +import { jwtDecode } from "jwt-decode"; +import { Form, Button as Button2, message } from "antd"; +export default function Onboarding() { + const [form] = Form.useForm(); + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + const user_email = searchParams.get("user_email"); + const [accessToken, setAccessToken] = useState(null); + const [defaultUserEmail, setDefaultUserEmail] = useState(""); + const router = useRouter(); + + useEffect(() => { + if (!token) { + return; + } + const decoded = jwtDecode(token) as { [key: string]: any }; + + setAccessToken(decoded.key); + }, [token]); + + useEffect(() => { + if (!user_email) { + return; + } + setDefaultUserEmail(user_email); + }, [user_email]); + + const handleSubmit = (formValues: Record) => { + if (!accessToken || !token) { + return; + } + + userUpdateUserCall(accessToken, formValues, null).then((data) => { + let litellm_dashboard_ui = ""; + const user_id = data.data?.user_id || data.user_id; + + litellm_dashboard_ui += "?userID=" + user_id + "&token=" + token; + + router.replace(litellm_dashboard_ui); + }); + }; + return ( +
+ + 🚅 LiteLLM + Sign up + Claim your user account to login to Admin UI. +
+ <> + + + + + + + + + +
+ Sign Up +
+
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/admins.tsx b/ui/litellm-dashboard/src/components/admins.tsx index 4375a9a53..5ffee3449 100644 --- a/ui/litellm-dashboard/src/components/admins.tsx +++ b/ui/litellm-dashboard/src/components/admins.tsx @@ -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>; 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 = ({ 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); - + const [invitationLinkData, setInvitationLinkData] = + useState(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 = ''; + nonSssoUrl = ""; } - 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) => { - handleAdminCreate(formValues); - handleSSOUpdate(formValues); - setIsAddSSOModalVisible(false); - setIsInstructionsModalVisible(true); - // Optionally, you can call handleSSOUpdate here with the formValues -}; + const handleShowInstructions = (formValues: Record) => { + 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) => Promise; - const addMemberForm = (handleMemberCreate: HandleMemberCreate,) => { - return
- <> - - - -
OR
- - - - -
- Add member -
-
- } - - const modifyMemberForm = (handleMemberUpdate: HandleMemberCreate, currentRole: string, userID: string) => { - return
- <> - - - - - -
- Update role -
-
- } + <> + + + +
OR
+ + + + +
+ Add member +
+ + ); + }; + + const modifyMemberForm = ( + handleMemberUpdate: HandleMemberCreate, + currentRole: string, + userID: string + ) => { + return ( +
+ <> + + + + + +
+ Update role +
+
+ ); + }; const handleMemberUpdate = async (formValues: Record) => { - 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) => { 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 (
Admin Access - { - showSSOBanner && Requires SSO Setup - } -
- Proxy Admin: Can create keys, teams, users, add models, etc.
- Proxy Admin Viewer: Can just view spend. They cannot create keys, teams or - grant users access to new models.{" "} + {showSSOBanner && ( + + Requires SSO Setup + + )} +
+ Proxy Admin: Can create keys, teams, users, add models, etc.{" "} +
+ Proxy Admin Viewer: Can just view spend. They cannot create keys, + teams or grant users access to new models.{" "}
- @@ -370,20 +432,29 @@ const handleInstructionsCancel = () => { {member["user_email"] ? member["user_email"] : member["user_id"] - ? member["user_id"] - : null} + ? member["user_id"] + : null} {member["user_role"]} - setIsUpdateModalModalVisible(true)}/> + setIsUpdateModalModalVisible(true)} + /> - {modifyMemberForm(handleMemberUpdate, member["user_role"], member["user_id"])} + onCancel={handleMemberUpdateCancel} + > + {modifyMemberForm( + handleMemberUpdate, + member["user_role"], + member["user_id"] + )} @@ -394,129 +465,183 @@ const handleInstructionsCancel = () => { -
- - - {addMemberForm(handleAdminCreate)} - - + +
+ + - - {addMemberForm(handleMemberCreate)} - + + + {addMemberForm(handleMemberCreate)} + - Add SSO -
- - - -
- <> - Add SSO +
+ + + - - - - - + <> + + + + + + - - - + + + - - - - -
- Save -
- - -
- -

Follow these steps to complete the SSO setup:

- - 1. DO NOT Exit this TAB - - - 2. Open a new tab, visit your proxy base url - - - 3. Confirm your SSO is configured correctly and you can login on the new Tab - - - 4. If Step 3 is successful, you can close this tab - -
- Done -
-
-
- - If you need to login without sso, you can access {nonSssoUrl} - - + + + + +
+ Save +
+ +
+ +

Follow these steps to complete the SSO setup:

+ 1. DO NOT Exit this TAB + + 2. Open a new tab, visit your proxy base url + + + 3. Confirm your SSO is configured correctly and you can login on + the new Tab + + + 4. If Step 3 is successful, you can close this tab + +
+ Done +
+
+
+ + If you need to login without sso, you can access{" "} + + {nonSssoUrl}{" "} + + +
); }; diff --git a/ui/litellm-dashboard/src/components/model_dashboard.tsx b/ui/litellm-dashboard/src/components/model_dashboard.tsx index b11b8dbad..7ce8967cd 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard.tsx @@ -1200,9 +1200,7 @@ const ModelDashboard: React.FC = ({ wordBreak: "break-word", }} > -

- {model.model_name || "-"} -

+

{model.model_name || "-"}

= ({ wordBreak: "break-word", }} > -

- {model.provider || "-"} -

+

{model.provider || "-"}

{userRole === "Admin" && ( = ({ maxWidth: "150px", whiteSpace: "normal", wordBreak: "break-word", - fontSize: "10px", }} + className="text-xs" title={ model && model.api_base ? model.api_base @@ -1251,7 +1247,7 @@ const ModelDashboard: React.FC = ({ wordBreak: "break-word", }} > -
+                            
                               {model.input_cost
                                 ? model.input_cost
                                 : model.litellm_params.input_cost_per_token
@@ -1271,7 +1267,7 @@ const ModelDashboard: React.FC = ({
                               wordBreak: "break-word",
                             }}
                           >
-                            
+                            
                               {model.output_cost
                                 ? model.output_cost
                                 : model.litellm_params.output_cost_per_token
@@ -1285,7 +1281,7 @@ const ModelDashboard: React.FC = ({
                             
-

+

{premiumUser ? formatCreatedAt( model.model_info.created_at @@ -1294,7 +1290,7 @@ const ModelDashboard: React.FC = ({

-

+

{premiumUser ? model.model_info.created_by || "-" : "-"} @@ -1309,11 +1305,11 @@ const ModelDashboard: React.FC = ({ > {model.model_info.db_model ? ( -

DB Model

+

DB Model

) : ( -

Config Model

+

Config Model

)}
diff --git a/ui/litellm-dashboard/src/components/model_hub.tsx b/ui/litellm-dashboard/src/components/model_hub.tsx index 5a141d154..3d5e515b0 100644 --- a/ui/litellm-dashboard/src/components/model_hub.tsx +++ b/ui/litellm-dashboard/src/components/model_hub.tsx @@ -147,7 +147,7 @@ const ModelHub: React.FC = ({ )} -
+
{modelHubData && modelHubData.map((model: ModelInfo) => ( diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 523c951b5..07193f975 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -208,6 +208,81 @@ export const budgetCreateCall = async ( } }; +export const invitationCreateCall = async ( + accessToken: string, + userID: string // Assuming formValues is an object +) => { + try { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/invitation/new` + : `/invitation/new`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user_id: userID, // Include formValues in the request body + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + message.error("Failed to create key: " + errorData, 10); + console.error("Error response from the server:", errorData); + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + console.log("API Response:", 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 create key:", error); + throw error; + } +}; + +export const invitationClaimCall = async ( + accessToken: string, + formValues: Record // Assuming formValues is an object +) => { + try { + console.log("Form Values in invitationCreateCall:", formValues); // Log the form values before making the API call + + console.log("Form Values after check:", formValues); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/invitation/claim` + : `/invitation/claim`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...formValues, // Include formValues in the request body + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + message.error("Failed to create key: " + errorData, 10); + console.error("Error response from the server:", errorData); + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + console.log("API Response:", 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 create key:", error); + throw error; + } +}; + export const alertingSettingsCall = async (accessToken: String) => { /** * Get all configurable params for setting a model