[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:
Ishaan Jaff 2025-04-11 21:08:35 -07:00 committed by GitHub
parent 7fde06d8d3
commit e62f27188c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 432 additions and 62 deletions

View file

@ -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;
}
};

View 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;

View file

@ -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
};
};

View file

@ -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 */}

View file

@ -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;

View file

@ -324,7 +324,7 @@ const Teams: React.FC<TeamProps> = ({
{selectedTeamId ? (
<TeamInfoView
teamId={selectedTeamId}
onUpdate={data => {
onUpdate={(data) => {
setTeams(teams => {
if (teams == null) {
return teams;