diff --git a/ui/litellm-dashboard/src/components/all_keys_table.tsx b/ui/litellm-dashboard/src/components/all_keys_table.tsx index 4ed2886745a..66644da6d3c 100644 --- a/ui/litellm-dashboard/src/components/all_keys_table.tsx +++ b/ui/litellm-dashboard/src/components/all_keys_table.tsx @@ -186,6 +186,18 @@ export function AllKeysTable({ accessorKey: "organization_id", cell: (info) => info.getValue() ? info.renderValue() : "Not Set", }, + { + header: "User ID", + accessorKey: "user_id", + cell: (info) => { + const userId = info.getValue() as string; + return userId ? ( + + {userId.slice(0, 5)}... + + ) : "Not Set"; + }, + }, { header: "Created", accessorKey: "created_at", diff --git a/ui/litellm-dashboard/src/components/common_components/user_form.tsx b/ui/litellm-dashboard/src/components/common_components/user_form.tsx new file mode 100644 index 00000000000..2b0a3564575 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/user_form.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { Form, Select, TextInput } from "@tremor/react"; +import { Form as AntForm, Radio } from "antd"; +import TeamDropdown from "./team_dropdown"; +import { getPossibleUserRoles } from "../networking"; + +interface UserFormProps { + form: any; + teams: any[] | null; + possibleUIRoles: null | Record>; + setPossibleUIRoles?: (roles: any) => void; + accessToken?: string; +} + +const UserForm: React.FC = ({ + form, + teams, + possibleUIRoles, + setPossibleUIRoles, + accessToken +}) => { + React.useEffect(() => { + // Fetch roles if they're not available and we have a setter + if (!possibleUIRoles && setPossibleUIRoles && accessToken) { + getPossibleUserRoles(accessToken).then(roles => { + setPossibleUIRoles(roles); + }); + } + }, [possibleUIRoles, setPossibleUIRoles, accessToken]); + + return ( + <> + + + + + + + {possibleUIRoles && + Object.entries(possibleUIRoles).map( + ([role, { ui_label, description }]) => ( + + + {ui_label}{" "} + + {description} + + + + ) + )} + + + + + + + + + + + > + ); +}; + +export default UserForm; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/create_key_button.tsx b/ui/litellm-dashboard/src/components/create_key_button.tsx index 47ceebbf941..46cb3ef01eb 100644 --- a/ui/litellm-dashboard/src/components/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/create_key_button.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import { Button, TextInput, Grid, Col } from "@tremor/react"; import { Card, @@ -30,11 +30,15 @@ import { modelAvailableCall, getGuardrailsList, proxyBaseUrl, + getPossibleUserRoles, + userFilterUICall, } from "./networking"; import { Team } from "./key_team_helpers/key_list"; import TeamDropdown from "./common_components/team_dropdown"; import { InfoCircleOutlined } from '@ant-design/icons'; import { Tooltip } from 'antd'; +import Createuser from "./create_user_button"; +import debounce from 'lodash/debounce'; const { Option } = Select; @@ -48,6 +52,18 @@ interface CreateKeyProps { teams: Team[] | null; } +interface User { + user_id: string; + user_email: string; + role?: string; +} + +interface UserOption { + label: string; + value: string; + user: User; +} + const getPredefinedTags = (data: any[] | null) => { let allTags = []; @@ -138,6 +154,13 @@ const CreateKey: React.FC = ({ const [predefinedTags, setPredefinedTags] = useState(getPredefinedTags(data)); const [guardrailsList, setGuardrailsList] = useState([]); const [selectedCreateKeyTeam, setSelectedCreateKeyTeam] = useState(team); + const [isCreateUserModalVisible, setIsCreateUserModalVisible] = useState(false); + const [newlyCreatedUserId, setNewlyCreatedUserId] = useState(null); + const [possibleUIRoles, setPossibleUIRoles] = useState< + Record> + >({}); + const [userOptions, setUserOptions] = useState([]); + const [userSearchLoading, setUserSearchLoading] = useState(false); const handleOk = () => { setIsModalVisible(false); @@ -172,6 +195,29 @@ const CreateKey: React.FC = ({ fetchGuardrails(); }, [accessToken]); + // Fetch possible user roles when component mounts + useEffect(() => { + const fetchPossibleRoles = async () => { + try { + if (accessToken) { + // Check if roles are cached in session storage + const cachedRoles = sessionStorage.getItem('possibleUserRoles'); + if (cachedRoles) { + setPossibleUIRoles(JSON.parse(cachedRoles)); + } else { + const availableUserRoles = await getPossibleUserRoles(accessToken); + sessionStorage.setItem('possibleUserRoles', JSON.stringify(availableUserRoles)); + setPossibleUIRoles(availableUserRoles); + } + } + } catch (error) { + console.error("Error fetching possible user roles:", error); + } + }; + + fetchPossibleRoles(); + }, [accessToken]); + const handleCreate = async (formValues: Record) => { try { const newKeyAlias = formValues?.key_alias ?? ""; @@ -233,6 +279,60 @@ const CreateKey: React.FC = ({ form.setFieldValue('models', []); }, [selectedCreateKeyTeam, userModels]); + // Add a callback function to handle user creation + const handleUserCreated = (userId: string) => { + setNewlyCreatedUserId(userId); + form.setFieldsValue({ user_id: userId }); + setIsCreateUserModalVisible(false); + }; + + const fetchUsers = async (searchText: string): Promise => { + if (!searchText) { + setUserOptions([]); + return; + } + + setUserSearchLoading(true); + try { + const params = new URLSearchParams(); + params.append('user_email', searchText); // Always search by email + if (accessToken == null) { + return; + } + const response = await userFilterUICall(accessToken, params); + + const data: User[] = response; + const options: UserOption[] = data.map(user => ({ + label: `${user.user_email} (${user.user_id})`, + value: user.user_id, + user + })); + + setUserOptions(options); + } catch (error) { + console.error('Error fetching users:', error); + message.error('Failed to search for users'); + } finally { + setUserSearchLoading(false); + } + }; + + const debouncedSearch = useCallback( + debounce((text: string) => fetchUsers(text), 300), + [accessToken] + ); + + const handleUserSearch = (value: string): void => { + debouncedSearch(value); + }; + + const handleUserSelect = (_value: string, option: UserOption): void => { + const selectedUser = option.user; + form.setFieldsValue({ + user_id: selectedUser.user_id + }); + }; + return ( setIsModalVisible(true)}> @@ -241,7 +341,7 @@ const CreateKey: React.FC = ({ = ({ wrapperCol={{ span: 16 }} labelAlign="left" > - <> - + {/* Section 1: Key Ownership */} + + 1. Key Ownership + + Owned By{' '} + + + + + } + className="mb-4" + > setKeyOwner(e.target.value)} value={keyOwner} @@ -265,34 +377,59 @@ const CreateKey: React.FC = ({ + {keyOwner === "another_user" && ( + + User ID{' '} + + + + + } + name="user_id" + className="mt-4" + rules={[{ required: keyOwner === "another_user", message: `Please input the user ID of the user you are assigning the key to` }]} + > + + + handleUserSelect(value, option as UserOption)} + options={userOptions} + loading={userSearchLoading} + allowClear + style={{ width: '100%' }} + notFoundContent={userSearchLoading ? 'Searching...' : 'No users found'} + /> + setIsCreateUserModalVisible(true)} + style={{ marginLeft: '8px' }} + > + Create User + + + + Search by email to find users + + + + )} - form.setFieldValue('user_id', e.target.value)} - /> - - - - - - + Team{' '} + + + + + } name="team_id" initialValue={team ? team.team_id : null} - className="mt-8" + className="mt-4" > = ({ /> + + + {/* Section 2: Key Details */} + + 2. Key Details + + {keyOwner === "you" || keyOwner === "another_user" ? "Key Name" : "Service Account ID"}{' '} + + + + + } + name="key_alias" + rules={[{ required: true, message: `Please input a ${keyOwner === "you" ? "key name" : "service account ID"}` }]} + help="required" + > + + + Models{' '} - + @@ -315,6 +475,7 @@ const CreateKey: React.FC = ({ name="models" rules={[{ required: true, message: "Please select a model" }]} help="required" + className="mt-4" > = ({ ))} - + + + {/* Section 3: Optional Settings */} + + - Optional Settings + 3. Optional Settings + Max Budget (USD){' '} + + + + + } name="max_budget" help={`Budget cannot exceed team max budget: $${team?.max_budget !== null && team?.max_budget !== undefined ? team?.max_budget : "unlimited"}`} rules={[ @@ -366,8 +538,15 @@ const CreateKey: React.FC = ({ + Reset Budget{' '} + + + + + } name="budget_duration" help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`} > @@ -378,8 +557,15 @@ const CreateKey: React.FC = ({ + Tokens per minute Limit (TPM){' '} + + + + + } name="tpm_limit" help={`TPM cannot exceed team TPM limit: ${team?.tpm_limit !== null && team?.tpm_limit !== undefined ? team?.tpm_limit : "unlimited"}`} rules={[ @@ -402,8 +588,15 @@ const CreateKey: React.FC = ({ + Requests per minute Limit (RPM){' '} + + + + + } name="rpm_limit" help={`RPM cannot exceed team RPM limit: ${team?.rpm_limit !== null && team?.rpm_limit !== undefined ? team?.rpm_limit : "unlimited"}`} rules={[ @@ -426,17 +619,24 @@ const CreateKey: React.FC = ({ + Expire Key{' '} + + + + + } name="duration" - className="mt-8" + className="mt-4" > - + Guardrails{' '} - + = ({ } name="guardrails" - className="mt-8" + className="mt-4" help="Select existing guardrails or enter new ones" > = ({ /> - + + Metadata{' '} + + + + + } + name="metadata" + className="mt-4" + > - + + Tags{' '} + + + + + } + name="tags" + className="mt-4" + help={`Tags for tracking spend and/or doing tag-based routing.`} + > = ({ options={predefinedTags} /> - + @@ -507,13 +730,34 @@ const CreateKey: React.FC = ({ - > + Create Key + + {/* Add the Create User Modal */} + {isCreateUserModalVisible && ( + setIsCreateUserModalVisible(false)} + footer={null} + width={800} + > + + + )} + {apiKey && ( >; + onUserCreated?: (userId: string) => void; + isEmbedded?: boolean; } // Define an interface for the UI settings @@ -42,6 +44,8 @@ const Createuser: React.FC = ({ accessToken, teams, possibleUIRoles, + onUserCreated, + isEmbedded = false, }) => { const [uiSettings, setUISettings] = useState(null); const [form] = Form.useForm(); @@ -113,13 +117,22 @@ const Createuser: React.FC = ({ const handleCreate = async (formValues: { user_id: string }) => { try { message.info("Making API Call"); - setIsModalVisible(true); + if (!isEmbedded) { + setIsModalVisible(true); + } console.log("formValues in create user:", formValues); const response = await userCreateCall(accessToken, null, formValues); console.log("user create Response:", response); setApiuser(response["key"]); const user_id = response.data?.user_id || response.user_id; + // Call the callback if provided (for embedded mode) + if (onUserCreated && isEmbedded) { + onUserCreated(user_id); + form.resetFields(); + return; // Skip the invitation flow when embedded + } + // only do invite link flow if sso is not enabled if (!uiSettings?.SSO_ENABLED) { invitationCreateCall(accessToken, user_id).then((data) => { @@ -156,6 +169,66 @@ const Createuser: React.FC = ({ } }; + // Modify the return statement to handle embedded mode + if (isEmbedded) { + return ( + + + + + + + {possibleUIRoles && + Object.entries(possibleUIRoles).map( + ([role, { ui_label, description }]) => ( + + + {ui_label}{" "} + + {description} + + + + ), + )} + + + + + {teams ? ( + teams.map((team: any) => ( + + {team.team_alias} + + )) + ) : ( + + Default Team + + )} + + + + + + + + Create User + + + ); + } + + // Original return for standalone mode return ( setIsModalVisible(true)}>
+ {description} +