mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-24 18:24:20 +00:00
[UI] - Add Managing Team Member permissions on UI (#9927)
* add getTeamPermissionsCall and teamPermissionsUpdateCall to networking * add ui changes for team member permission management * fix linting error
This commit is contained in:
parent
7fde06d8d3
commit
e62f27188c
6 changed files with 432 additions and 62 deletions
|
@ -4411,4 +4411,79 @@ export const updateDefaultTeamSettings = async (accessToken: string, settings: R
|
|||
console.error("Failed to update default team settings:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const getTeamPermissionsCall = async (
|
||||
accessToken: string,
|
||||
teamId: string
|
||||
) => {
|
||||
try {
|
||||
let url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/team/permissions_list?team_id=${teamId}`
|
||||
: `/team/permissions_list?team_id=${teamId}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
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("Team permissions response:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to get team permissions:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const teamPermissionsUpdateCall = async (
|
||||
accessToken: string,
|
||||
teamId: string,
|
||||
permissions: string[]
|
||||
) => {
|
||||
try {
|
||||
let url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/team/permissions_update`
|
||||
: `/team/permissions_update`;
|
||||
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
team_id: teamId,
|
||||
team_member_permissions: permissions,
|
||||
}),
|
||||
});
|
||||
|
||||
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("Team permissions response:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to update team permissions:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
175
ui/litellm-dashboard/src/components/team/member_permissions.tsx
Normal file
175
ui/litellm-dashboard/src/components/team/member_permissions.tsx
Normal file
|
@ -0,0 +1,175 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
Title,
|
||||
Text,
|
||||
Button as TremorButton,
|
||||
Table,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@tremor/react";
|
||||
import { Button, message, Checkbox, Empty } from "antd";
|
||||
import { ReloadOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import { getTeamPermissionsCall, teamPermissionsUpdateCall } from "@/components/networking";
|
||||
import { getPermissionInfo } from "./permission_definitions";
|
||||
|
||||
interface MemberPermissionsProps {
|
||||
teamId: string;
|
||||
accessToken: string | null;
|
||||
canEditTeam: boolean;
|
||||
}
|
||||
|
||||
const MemberPermissions: React.FC<MemberPermissionsProps> = ({
|
||||
teamId,
|
||||
accessToken,
|
||||
canEditTeam,
|
||||
}) => {
|
||||
const [permissions, setPermissions] = useState<string[]>([]);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (!accessToken) return;
|
||||
const response = await getTeamPermissionsCall(accessToken, teamId);
|
||||
const allPermissions = response.all_available_permissions || [];
|
||||
setPermissions(allPermissions);
|
||||
const teamPermissions = response.team_member_permissions || [];
|
||||
setSelectedPermissions(teamPermissions);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
message.error("Failed to load permissions");
|
||||
console.error("Error fetching permissions:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPermissions();
|
||||
}, [teamId, accessToken]);
|
||||
|
||||
const handlePermissionChange = (permission: string, checked: boolean) => {
|
||||
const newSelectedPermissions = checked
|
||||
? [...selectedPermissions, permission]
|
||||
: selectedPermissions.filter((p) => p !== permission);
|
||||
setSelectedPermissions(newSelectedPermissions);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
setSaving(true);
|
||||
await teamPermissionsUpdateCall(accessToken, teamId, selectedPermissions);
|
||||
message.success("Permissions updated successfully");
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
message.error("Failed to update permissions");
|
||||
console.error("Error updating permissions:", error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
fetchPermissions();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-center">Loading permissions...</div>;
|
||||
}
|
||||
|
||||
const hasPermissions = permissions.length > 0;
|
||||
|
||||
return (
|
||||
<Card className="bg-white shadow-md rounded-md p-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center border-b pb-4 mb-6">
|
||||
<Title className="mb-2 sm:mb-0">Member Permissions</Title>
|
||||
{canEditTeam && hasChanges && (
|
||||
<div className="flex gap-3">
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
Reset
|
||||
</Button>
|
||||
<TremorButton
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<SaveOutlined /> Save Changes
|
||||
</TremorButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Text className="mb-6 text-gray-600">
|
||||
Control what team members can do when they are not team admins.
|
||||
</Text>
|
||||
|
||||
{hasPermissions ? (
|
||||
<Table className="mt-4">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Method</TableHeaderCell>
|
||||
<TableHeaderCell>Endpoint</TableHeaderCell>
|
||||
<TableHeaderCell>Description</TableHeaderCell>
|
||||
<TableHeaderCell className="text-right">Access</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{permissions.map((permission) => {
|
||||
const permInfo = getPermissionInfo(permission);
|
||||
return (
|
||||
<TableRow
|
||||
key={permission}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
permInfo.method === "GET"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-green-100 text-green-800"
|
||||
}`}
|
||||
>
|
||||
{permInfo.method}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-sm text-gray-800">
|
||||
{permInfo.endpoint}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-700">
|
||||
{permInfo.description}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Checkbox
|
||||
checked={selectedPermissions.includes(permission)}
|
||||
onChange={(e) =>
|
||||
handlePermissionChange(permission, e.target.checked)
|
||||
}
|
||||
disabled={!canEditTeam}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="py-12">
|
||||
<Empty description="No permissions available" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberPermissions;
|
|
@ -0,0 +1,64 @@
|
|||
export interface PermissionInfo {
|
||||
method: string;
|
||||
endpoint: string;
|
||||
description: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of permission endpoint patterns to their descriptions
|
||||
*/
|
||||
export const PERMISSION_DESCRIPTIONS: Record<string, string> = {
|
||||
'/key/generate': 'Member can generate a virtual key for this team',
|
||||
'/key/update': 'Member can update a virtual key belonging to this team',
|
||||
'/key/delete': 'Member can delete a virtual key belonging to this team',
|
||||
'/key/info': 'Member can get info about a virtual key belonging to this team',
|
||||
'/key/regenerate': 'Member can regenerate a virtual key belonging to this team',
|
||||
'/key/{key_id}/regenerate': 'Member can regenerate a virtual key belonging to this team',
|
||||
'/key/list': 'Member can list virtual keys belonging to this team',
|
||||
'/key/block': 'Member can block a virtual key belonging to this team',
|
||||
'/key/unblock': 'Member can unblock a virtual key belonging to this team'
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the HTTP method for a given permission endpoint
|
||||
*/
|
||||
export const getMethodForEndpoint = (endpoint: string): string => {
|
||||
if (endpoint.includes('/info') || endpoint.includes('/list')) {
|
||||
return 'GET';
|
||||
}
|
||||
return 'POST';
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a permission string into a structured PermissionInfo object
|
||||
*/
|
||||
export const getPermissionInfo = (permission: string): PermissionInfo => {
|
||||
const method = getMethodForEndpoint(permission);
|
||||
const endpoint = permission;
|
||||
|
||||
// Find exact match or fallback to default description
|
||||
let description = PERMISSION_DESCRIPTIONS[permission];
|
||||
|
||||
// If no exact match, try to find a partial match based on patterns
|
||||
if (!description) {
|
||||
for (const [pattern, desc] of Object.entries(PERMISSION_DESCRIPTIONS)) {
|
||||
if (permission.includes(pattern)) {
|
||||
description = desc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback if no match found
|
||||
if (!description) {
|
||||
description = `Access ${permission}`;
|
||||
}
|
||||
|
||||
return {
|
||||
method,
|
||||
endpoint,
|
||||
description,
|
||||
route: permission
|
||||
};
|
||||
};
|
|
@ -20,6 +20,8 @@ import {
|
|||
Table,
|
||||
Icon
|
||||
} from "@tremor/react";
|
||||
import TeamMembersComponent from "./team_member_view";
|
||||
import MemberPermissions from "./member_permissions";
|
||||
import { teamInfoCall, teamMemberDeleteCall, teamMemberAddCall, teamMemberUpdateCall, Member, teamUpdateCall } from "@/components/networking";
|
||||
import { Button, Form, Input, Select, message, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
|
@ -30,10 +32,9 @@ import { PencilAltIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
|||
import MemberModal from "./edit_membership";
|
||||
import UserSearchModal from "@/components/common_components/user_search_modal";
|
||||
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
|
||||
|
||||
interface TeamData {
|
||||
export interface TeamData {
|
||||
team_id: string;
|
||||
team_info: {
|
||||
team_alias: string;
|
||||
|
@ -62,15 +63,15 @@ interface TeamData {
|
|||
team_memberships: any[];
|
||||
}
|
||||
|
||||
interface TeamInfoProps {
|
||||
export interface TeamInfoProps {
|
||||
teamId: string;
|
||||
onUpdate: (data: any) => void;
|
||||
onClose: () => void;
|
||||
accessToken: string | null;
|
||||
is_team_admin: boolean;
|
||||
is_proxy_admin: boolean;
|
||||
userModels: string[];
|
||||
editTeam: boolean;
|
||||
onUpdate?: (team: Team) => void
|
||||
}
|
||||
|
||||
const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
|
@ -80,8 +81,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
is_team_admin,
|
||||
is_proxy_admin,
|
||||
userModels,
|
||||
editTeam,
|
||||
onUpdate
|
||||
editTeam
|
||||
}) => {
|
||||
const [teamData, setTeamData] = useState<TeamData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
@ -202,10 +202,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
};
|
||||
|
||||
const response = await teamUpdateCall(accessToken, updateData);
|
||||
if (onUpdate) {
|
||||
onUpdate(response.data)
|
||||
}
|
||||
|
||||
|
||||
message.success("Team settings updated successfully");
|
||||
setIsEditing(false);
|
||||
fetchTeamInfo();
|
||||
|
@ -241,6 +238,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
<Tab key="overview">Overview</Tab>,
|
||||
...(canEditTeam ? [
|
||||
<Tab key="members">Members</Tab>,
|
||||
<Tab key="member-permissions">Member Permissions</Tab>,
|
||||
<Tab key="settings">Settings</Tab>
|
||||
] : [])
|
||||
]}
|
||||
|
@ -287,58 +285,23 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
|
||||
{/* Members Panel */}
|
||||
<TabPanel>
|
||||
<div className="space-y-4">
|
||||
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>User ID</TableHeaderCell>
|
||||
<TableHeaderCell>User Email</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TeamMembersComponent
|
||||
teamData={teamData}
|
||||
canEditTeam={canEditTeam}
|
||||
handleMemberDelete={handleMemberDelete}
|
||||
setSelectedEditMember={setSelectedEditMember}
|
||||
setIsEditMemberModalVisible={setIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={setIsAddMemberModalVisible}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TableBody>
|
||||
{teamData.team_info.members_with_roles.map((member: Member, index: number) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member.user_id}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member.user_email}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member.role}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditTeam && (
|
||||
<>
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedEditMember(member);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
onClick={() => handleMemberDelete(member)}
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
<TremorButton onClick={() => setIsAddMemberModalVisible(true)}>
|
||||
Add Member
|
||||
</TremorButton>
|
||||
</div>
|
||||
{/* Member Permissions Panel */}
|
||||
<TabPanel>
|
||||
<MemberPermissions
|
||||
teamId={teamId}
|
||||
accessToken={accessToken}
|
||||
canEditTeam={canEditTeam}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* Settings Panel */}
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
import React from 'react';
|
||||
import { Member } from "@/components/networking";
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableHeaderCell,
|
||||
TableBody,
|
||||
TableCell,
|
||||
Text,
|
||||
Icon,
|
||||
Button as TremorButton,
|
||||
} from '@tremor/react';
|
||||
import {
|
||||
TeamData,
|
||||
} from './team_info';
|
||||
import { PencilAltIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
|
||||
interface TeamMembersComponentProps {
|
||||
teamData: TeamData;
|
||||
canEditTeam: boolean;
|
||||
handleMemberDelete: (member: Member) => void;
|
||||
setSelectedEditMember: (member: Member) => void;
|
||||
setIsEditMemberModalVisible: (visible: boolean) => void;
|
||||
setIsAddMemberModalVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
const TeamMembersComponent: React.FC<TeamMembersComponentProps> = ({
|
||||
teamData,
|
||||
canEditTeam,
|
||||
handleMemberDelete,
|
||||
setSelectedEditMember,
|
||||
setIsEditMemberModalVisible,
|
||||
setIsAddMemberModalVisible,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>User ID</TableHeaderCell>
|
||||
<TableHeaderCell>User Email</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{teamData.team_info.members_with_roles.map((member: Member, index: number) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member.user_id}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member.user_email}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member.role}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditTeam && (
|
||||
<>
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedEditMember(member);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => handleMemberDelete(member)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
<TremorButton onClick={() => setIsAddMemberModalVisible(true)}>
|
||||
Add Member
|
||||
</TremorButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamMembersComponent;
|
|
@ -324,7 +324,7 @@ const Teams: React.FC<TeamProps> = ({
|
|||
{selectedTeamId ? (
|
||||
<TeamInfoView
|
||||
teamId={selectedTeamId}
|
||||
onUpdate={data => {
|
||||
onUpdate={(data) => {
|
||||
setTeams(teams => {
|
||||
if (teams == null) {
|
||||
return teams;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue