mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
Merge pull request #15384 from BerriAI/litellm_ui_teams_team_role_column
Teams page: new column "Your Role" on the teams table
This commit is contained in:
commit
039c7e0f11
18 changed files with 1750 additions and 1379 deletions
|
|
@ -5,6 +5,35 @@ import { useRouter } from "next/navigation";
|
|||
import { jwtDecode } from "jwt-decode";
|
||||
import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
|
||||
|
||||
function formatUserRole(userRole: string) {
|
||||
if (!userRole) {
|
||||
return "Undefined Role";
|
||||
}
|
||||
switch (userRole.toLowerCase()) {
|
||||
case "app_owner":
|
||||
return "App Owner";
|
||||
case "demo_app_owner":
|
||||
return "App Owner";
|
||||
case "app_admin":
|
||||
return "Admin";
|
||||
case "proxy_admin":
|
||||
return "Admin";
|
||||
case "proxy_admin_viewer":
|
||||
return "Admin Viewer";
|
||||
case "org_admin":
|
||||
return "Org Admin";
|
||||
case "internal_user":
|
||||
return "Internal User";
|
||||
case "internal_user_viewer":
|
||||
case "internal_viewer": // TODO:remove if deprecated
|
||||
return "Internal Viewer";
|
||||
case "app_user":
|
||||
return "App User";
|
||||
default:
|
||||
return "Unknown Role";
|
||||
}
|
||||
}
|
||||
|
||||
const useAuthorized = () => {
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -35,7 +64,7 @@ const useAuthorized = () => {
|
|||
accessToken: decoded?.key ?? null,
|
||||
userId: decoded?.user_id ?? null,
|
||||
userEmail: decoded?.user_email ?? null,
|
||||
userRole: decoded?.user_role ?? null,
|
||||
userRole: formatUserRole(decoded?.user_role ?? null),
|
||||
premiumUser: decoded?.premium_user ?? null,
|
||||
disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null,
|
||||
showSSOBanner: decoded?.login_method === "username_password" ?? false,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function withBase(path: string): string {
|
|||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
const { accessToken, userRole, userId, userEmail, premiumUser } = useAuthorized();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false);
|
||||
const [page, setPage] = useState(() => {
|
||||
return searchParams.get("page") || "api-keys";
|
||||
|
|
@ -51,15 +51,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||
isPublicPage={false}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
userID={null}
|
||||
userEmail={null}
|
||||
userRole={null}
|
||||
premiumUser={false}
|
||||
userID={userId}
|
||||
userEmail={userEmail}
|
||||
userRole={userRole}
|
||||
premiumUser={premiumUser}
|
||||
proxySettings={undefined}
|
||||
setProxySettings={function (value: any): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
accessToken={null}
|
||||
setProxySettings={() => {}}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
<div className="flex flex-1 overflow-auto">
|
||||
<div className="mt-2">
|
||||
|
|
|
|||
359
ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx
Normal file
359
ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { teamDeleteCall, Organization } from "@/components/networking";
|
||||
import { fetchTeams } from "@/components/common_components/fetch_teams";
|
||||
import { Form } from "antd";
|
||||
import TeamInfoView from "@/components/team/team_info";
|
||||
import TeamSSOSettings from "@/components/TeamSSOSettings";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import { Card, Button, Col, Text, Grid, TabPanel } from "@tremor/react";
|
||||
import AvailableTeamsPanel from "@/components/team/available_teams";
|
||||
import type { KeyResponse, Team } from "@/components/key_team_helpers/key_list";
|
||||
|
||||
import { Member, v2TeamListCall } from "@/components/networking";
|
||||
import { updateExistingKeys } from "@/utils/dataUtils";
|
||||
import TeamsHeaderTabs from "@/app/(dashboard)/teams/components/TeamsHeaderTabs";
|
||||
import TeamsFilters from "@/app/(dashboard)/teams/components/TeamsFilters";
|
||||
import useFetchTeams from "@/app/(dashboard)/teams/hooks/useFetchTeams";
|
||||
import TeamsTable from "@/app/(dashboard)/teams/components/TeamsTable/TeamsTable";
|
||||
import DeleteTeamModal from "@/app/(dashboard)/teams/components/modals/DeleteTeamModal";
|
||||
import CreateTeamModal from "@/app/(dashboard)/teams/components/modals/CreateTeamModal";
|
||||
|
||||
interface TeamProps {
|
||||
teams: Team[] | null;
|
||||
accessToken: string | null;
|
||||
setTeams: React.Dispatch<React.SetStateAction<Team[] | null>>;
|
||||
userID: string | null;
|
||||
userRole: string | null;
|
||||
organizations: Organization[] | null;
|
||||
premiumUser?: boolean;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
team_id: string;
|
||||
team_alias: string;
|
||||
organization_id: string;
|
||||
sort_by: string;
|
||||
sort_order: "asc" | "desc";
|
||||
}
|
||||
|
||||
interface TeamInfo {
|
||||
members_with_roles: Member[];
|
||||
}
|
||||
|
||||
interface PerTeamInfo {
|
||||
keys: KeyResponse[];
|
||||
team_info: TeamInfo;
|
||||
}
|
||||
|
||||
const TeamsView: React.FC<TeamProps> = ({
|
||||
teams,
|
||||
accessToken,
|
||||
setTeams,
|
||||
userID,
|
||||
userRole,
|
||||
organizations,
|
||||
premiumUser = false,
|
||||
}) => {
|
||||
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
team_id: "",
|
||||
team_alias: "",
|
||||
organization_id: "",
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc",
|
||||
});
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [memberForm] = Form.useForm();
|
||||
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const [editTeam, setEditTeam] = useState<boolean>(false);
|
||||
|
||||
const [isTeamModalVisible, setIsTeamModalVisible] = useState(false);
|
||||
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
|
||||
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
|
||||
const [userModels, setUserModels] = useState<string[]>([]);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [teamToDelete, setTeamToDelete] = useState<string | null>(null);
|
||||
const [perTeamInfo, setPerTeamInfo] = useState<Record<string, PerTeamInfo>>({});
|
||||
|
||||
const [loggingSettings, setLoggingSettings] = useState<any[]>([]);
|
||||
const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({});
|
||||
const { lastRefreshed, onRefreshClick: handleRefreshClick } = useFetchTeams({ currentOrg, setTeams });
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTeamInfo = () => {
|
||||
if (!teams) return;
|
||||
|
||||
const newPerTeamInfo = teams.reduce(
|
||||
(acc, team) => {
|
||||
acc[team.team_id] = {
|
||||
keys: team.keys || [],
|
||||
team_info: {
|
||||
members_with_roles: team.members_with_roles || [],
|
||||
},
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, PerTeamInfo>,
|
||||
);
|
||||
|
||||
setPerTeamInfo(newPerTeamInfo);
|
||||
};
|
||||
|
||||
fetchTeamInfo();
|
||||
}, [teams]);
|
||||
|
||||
const handleOk = () => {
|
||||
setIsTeamModalVisible(false);
|
||||
form.resetFields();
|
||||
setLoggingSettings([]);
|
||||
setModelAliases({});
|
||||
};
|
||||
|
||||
const handleMemberOk = () => {
|
||||
setIsAddMemberModalVisible(false);
|
||||
setIsEditMemberModalVisible(false);
|
||||
memberForm.resetFields();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsTeamModalVisible(false);
|
||||
form.resetFields();
|
||||
setLoggingSettings([]);
|
||||
setModelAliases({});
|
||||
};
|
||||
|
||||
const handleDelete = async (team_id: string) => {
|
||||
// Set the team to delete and open the confirmation modal
|
||||
setTeamToDelete(team_id);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (teamToDelete == null || teams == null || accessToken == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await teamDeleteCall(accessToken, teamToDelete);
|
||||
// Successfully completed the deletion. Update the state to trigger a rerender.
|
||||
fetchTeams(accessToken, userID, userRole, currentOrg, setTeams);
|
||||
} catch (error) {
|
||||
console.error("Error deleting the team:", error);
|
||||
// Handle any error situations, such as displaying an error message to the user.
|
||||
}
|
||||
|
||||
// Close the confirmation modal and reset the teamToDelete
|
||||
setIsDeleteModalOpen(false);
|
||||
setTeamToDelete(null);
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
// Close the confirmation modal and reset the teamToDelete
|
||||
setIsDeleteModalOpen(false);
|
||||
setTeamToDelete(null);
|
||||
};
|
||||
|
||||
const is_team_admin = (team: any) => {
|
||||
if (team == null || team.members_with_roles == null) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < team.members_with_roles.length; i++) {
|
||||
let member = team.members_with_roles[i];
|
||||
if (member.user_id == userID && member.role == "admin") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleFilterChange = (key: keyof FilterState, value: string) => {
|
||||
const newFilters = { ...filters, [key]: value };
|
||||
setFilters(newFilters);
|
||||
// Call teamListCall with the new filters
|
||||
if (accessToken) {
|
||||
v2TeamListCall(
|
||||
accessToken,
|
||||
newFilters.organization_id || null,
|
||||
null,
|
||||
newFilters.team_id || null,
|
||||
newFilters.team_alias || null,
|
||||
)
|
||||
.then((response) => {
|
||||
if (response && response.teams) {
|
||||
setTeams(response.teams);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching teams:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortChange = (sortBy: string, sortOrder: "asc" | "desc") => {
|
||||
const newFilters = {
|
||||
...filters,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
};
|
||||
setFilters(newFilters);
|
||||
// Call teamListCall with the new sort parameters
|
||||
if (accessToken) {
|
||||
v2TeamListCall(
|
||||
accessToken,
|
||||
filters.organization_id || null,
|
||||
null,
|
||||
filters.team_id || null,
|
||||
filters.team_alias || null,
|
||||
)
|
||||
.then((response) => {
|
||||
if (response && response.teams) {
|
||||
setTeams(response.teams);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching teams:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterReset = () => {
|
||||
setFilters({
|
||||
team_id: "",
|
||||
team_alias: "",
|
||||
organization_id: "",
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc",
|
||||
});
|
||||
// Reset teams list
|
||||
if (accessToken) {
|
||||
v2TeamListCall(accessToken, null, userID || null, null, null)
|
||||
.then((response) => {
|
||||
if (response && response.teams) {
|
||||
setTeams(response.teams);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching teams:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-4 h-[75vh]">
|
||||
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
|
||||
<Col numColSpan={1} className="flex flex-col gap-2">
|
||||
{(userRole == "Admin" || userRole == "Org Admin") && (
|
||||
<Button className="w-fit" onClick={() => setIsTeamModalVisible(true)}>
|
||||
+ Create New Team
|
||||
</Button>
|
||||
)}
|
||||
{selectedTeamId ? (
|
||||
<TeamInfoView
|
||||
teamId={selectedTeamId}
|
||||
onUpdate={(data) => {
|
||||
setTeams((teams) => {
|
||||
if (teams == null) {
|
||||
return teams;
|
||||
}
|
||||
const updated = teams.map((team) => {
|
||||
if (data.team_id === team.team_id) {
|
||||
return updateExistingKeys(team, data);
|
||||
}
|
||||
return team;
|
||||
});
|
||||
// Minimal fix: refresh the full team list after an update
|
||||
if (accessToken) {
|
||||
fetchTeams(accessToken, userID, userRole, currentOrg, setTeams);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
onClose={() => {
|
||||
setSelectedTeamId(null);
|
||||
setEditTeam(false);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))}
|
||||
is_proxy_admin={userRole == "Admin"}
|
||||
userModels={userModels}
|
||||
editTeam={editTeam}
|
||||
/>
|
||||
) : (
|
||||
<TeamsHeaderTabs lastRefreshed={lastRefreshed} onRefresh={handleRefreshClick} userRole={userRole}>
|
||||
<TabPanel>
|
||||
<Text>
|
||||
Click on “Team ID” to view team details <b>and</b> manage team members.
|
||||
</Text>
|
||||
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
|
||||
<Col numColSpan={1}>
|
||||
<Card className="w-full mx-auto flex-auto overflow-hidden overflow-y-auto max-h-[50vh]">
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<TeamsFilters
|
||||
filters={filters}
|
||||
organizations={organizations}
|
||||
showFilters={showFilters}
|
||||
onToggleFilters={setShowFilters}
|
||||
onChange={handleFilterChange}
|
||||
onReset={handleFilterReset}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TeamsTable
|
||||
teams={teams}
|
||||
currentOrg={currentOrg}
|
||||
perTeamInfo={perTeamInfo}
|
||||
userRole={userRole}
|
||||
userId={userID}
|
||||
setSelectedTeamId={setSelectedTeamId}
|
||||
setEditTeam={setEditTeam}
|
||||
onDeleteTeam={handleDelete}
|
||||
/>
|
||||
{isDeleteModalOpen && (
|
||||
<DeleteTeamModal
|
||||
teams={teams}
|
||||
teamToDelete={teamToDelete}
|
||||
onCancel={cancelDelete}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<AvailableTeamsPanel accessToken={accessToken} userID={userID} />
|
||||
</TabPanel>
|
||||
{isAdminRole(userRole || "") && (
|
||||
<TabPanel>
|
||||
<TeamSSOSettings accessToken={accessToken} userID={userID || ""} userRole={userRole || ""} />
|
||||
</TabPanel>
|
||||
)}
|
||||
</TeamsHeaderTabs>
|
||||
)}
|
||||
{(userRole == "Admin" || userRole == "Org Admin") && (
|
||||
<CreateTeamModal
|
||||
isTeamModalVisible={isTeamModalVisible}
|
||||
handleOk={handleOk}
|
||||
handleCancel={handleCancel}
|
||||
currentOrg={currentOrg}
|
||||
organizations={organizations}
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
modelAliases={modelAliases}
|
||||
setModelAliases={setModelAliases}
|
||||
loggingSettings={loggingSettings}
|
||||
setLoggingSettings={setLoggingSettings}
|
||||
setIsTeamModalVisible={setIsTeamModalVisible}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamsView;
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { Select, SelectItem } from "@tremor/react";
|
||||
import React from "react";
|
||||
import { Organization } from "@/components/networking";
|
||||
|
||||
interface TeamsFiltersProps {
|
||||
filters: FilterState;
|
||||
organizations: Organization[] | null;
|
||||
showFilters: boolean;
|
||||
onToggleFilters: (toggle: boolean) => void;
|
||||
onChange: <K extends keyof FilterState>(key: K, value: FilterState[K]) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
type FilterState = {
|
||||
team_id: string;
|
||||
team_alias: string;
|
||||
organization_id: string;
|
||||
sort_by: string;
|
||||
sort_order: "asc" | "desc";
|
||||
};
|
||||
|
||||
const TeamsFilters = ({
|
||||
filters,
|
||||
organizations,
|
||||
showFilters,
|
||||
onToggleFilters,
|
||||
onChange,
|
||||
onReset,
|
||||
}: TeamsFiltersProps) => {
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Team Alias Search */}
|
||||
<div className="relative w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Team Name..."
|
||||
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={filters.team_alias}
|
||||
onChange={(e) => onChange("team_alias", e.target.value)}
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Filter Button */}
|
||||
<button
|
||||
className={`px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2 ${showFilters ? "bg-gray-100" : ""}`}
|
||||
onClick={() => onToggleFilters(!showFilters)}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
Filters
|
||||
{(filters.team_id || filters.team_alias || filters.organization_id) && (
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Reset Filters Button */}
|
||||
<button
|
||||
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
onClick={onReset}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Reset Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Additional Filters */}
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap items-center gap-3 mt-3">
|
||||
{/* Team ID Search */}
|
||||
<div className="relative w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Team ID"
|
||||
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={filters.team_id}
|
||||
onChange={(e) => onChange("team_id", e.target.value)}
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Organization Dropdown */}
|
||||
<div className="w-64">
|
||||
<Select
|
||||
value={filters.organization_id || ""}
|
||||
onValueChange={(value) => onChange("organization_id", value)}
|
||||
placeholder="Select Organization"
|
||||
>
|
||||
{organizations?.map((org) => (
|
||||
<SelectItem key={org.organization_id} value={org.organization_id || ""}>
|
||||
{org.organization_alias || org.organization_id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamsFilters;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { Icon, Tab, TabGroup, TabList, TabPanels, Text } from "@tremor/react";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import React from "react";
|
||||
|
||||
type TeamsHeaderTabsProps = {
|
||||
lastRefreshed: string;
|
||||
onRefresh: () => void;
|
||||
userRole: string | null;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const TeamsHeaderTabs = ({ lastRefreshed, onRefresh, userRole, children }: TeamsHeaderTabsProps) => {
|
||||
return (
|
||||
<TabGroup className="gap-2 h-[75vh] w-full">
|
||||
<TabList className="flex justify-between mt-2 w-full items-center">
|
||||
<div className="flex">
|
||||
<Tab>Your Teams</Tab>
|
||||
<Tab>Available Teams</Tab>
|
||||
{isAdminRole(userRole || "") && <Tab>Default Team Settings</Tab>}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
|
||||
<Icon
|
||||
icon={RefreshIcon} // Modify as necessary for correct icon name
|
||||
variant="shadow"
|
||||
size="xs"
|
||||
className="self-center"
|
||||
onClick={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanels>{children}</TabPanels>
|
||||
</TabGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamsHeaderTabs;
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { Badge, Icon, TableCell, Text } from "@tremor/react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
|
||||
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
import React, { useState } from "react";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
|
||||
interface ModelsCellProps {
|
||||
team: Team;
|
||||
}
|
||||
|
||||
const ModelsCell = ({ team }: ModelsCellProps) => {
|
||||
const [expandedAccordion, setExpandedAccordion] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
style={{
|
||||
maxWidth: "8-x",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className={team.models.length > 3 ? "px-0" : ""}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{Array.isArray(team.models) ? (
|
||||
<div className="flex flex-col">
|
||||
{team.models.length === 0 ? (
|
||||
<Badge size={"xs"} className="mb-1" color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start">
|
||||
{team.models.length > 3 && (
|
||||
<div>
|
||||
<Icon
|
||||
icon={expandedAccordion ? ChevronDownIcon : ChevronRightIcon}
|
||||
className="cursor-pointer"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setExpandedAccordion((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{team.models.slice(0, 3).map((model: string, index: number) =>
|
||||
model === "all-proxy-models" ? (
|
||||
<Badge key={index} size={"xs"} color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge key={index} size={"xs"} color="blue">
|
||||
<Text>
|
||||
{model.length > 30
|
||||
? `${getModelDisplayName(model).slice(0, 30)}...`
|
||||
: getModelDisplayName(model)}
|
||||
</Text>
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
{team.models.length > 3 && !expandedAccordion && (
|
||||
<Badge size={"xs"} color="gray" className="cursor-pointer">
|
||||
<Text>
|
||||
+{team.models.length - 3} {team.models.length - 3 === 1 ? "more model" : "more models"}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
{expandedAccordion && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{team.models.slice(3).map((model: string, index: number) =>
|
||||
model === "all-proxy-models" ? (
|
||||
<Badge key={index + 3} size={"xs"} color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge key={index + 3} size={"xs"} color="blue">
|
||||
<Text>
|
||||
{model.length > 30
|
||||
? `${getModelDisplayName(model).slice(0, 30)}...`
|
||||
: getModelDisplayName(model)}
|
||||
</Text>
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsCell;
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Icon,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
} from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { ChevronDownIcon, ChevronRightIcon, PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
import React, { useState } from "react";
|
||||
import { type KeyResponse, Team } from "@/components/key_team_helpers/key_list";
|
||||
import { Member, Organization } from "@/components/networking";
|
||||
import ModelsCell from "@/app/(dashboard)/teams/components/TeamsTable/ModelsCell";
|
||||
import YourRoleCell from "@/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/YourRoleCell";
|
||||
|
||||
type TeamsTableProps = {
|
||||
teams: Team[] | null;
|
||||
currentOrg: Organization | null;
|
||||
perTeamInfo: Record<string, PerTeamInfo>;
|
||||
userRole: string | null;
|
||||
userId: string | null;
|
||||
setSelectedTeamId: (teamId: string) => void;
|
||||
setEditTeam: (editTeam: boolean) => void;
|
||||
onDeleteTeam: (teamId: string) => void;
|
||||
};
|
||||
|
||||
interface TeamInfo {
|
||||
members_with_roles: Member[];
|
||||
}
|
||||
|
||||
interface PerTeamInfo {
|
||||
keys: KeyResponse[];
|
||||
team_info: TeamInfo;
|
||||
}
|
||||
|
||||
const TeamsTable = ({
|
||||
teams,
|
||||
currentOrg,
|
||||
setSelectedTeamId,
|
||||
perTeamInfo,
|
||||
userRole,
|
||||
userId,
|
||||
setEditTeam,
|
||||
onDeleteTeam,
|
||||
}: TeamsTableProps) => {
|
||||
return (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Team Name</TableHeaderCell>
|
||||
<TableHeaderCell>Team ID</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Spend (USD)</TableHeaderCell>
|
||||
<TableHeaderCell>Budget (USD)</TableHeaderCell>
|
||||
<TableHeaderCell>Models</TableHeaderCell>
|
||||
<TableHeaderCell>Organization</TableHeaderCell>
|
||||
<TableHeaderCell>Your Role</TableHeaderCell>
|
||||
<TableHeaderCell>Info</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{teams && teams.length > 0
|
||||
? teams
|
||||
.filter((team) => {
|
||||
if (!currentOrg) return true;
|
||||
return team.organization_id === currentOrg.organization_id;
|
||||
})
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.map((team: any) => (
|
||||
<TableRow key={team.team_id}>
|
||||
<TableCell
|
||||
style={{
|
||||
maxWidth: "4px",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{team["team_alias"]}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="overflow-hidden">
|
||||
<Tooltip title={team.team_id}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
|
||||
onClick={() => {
|
||||
// Add click handler
|
||||
setSelectedTeamId(team.team_id);
|
||||
}}
|
||||
>
|
||||
{team.team_id.slice(0, 7)}...
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
maxWidth: "4px",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{team.created_at ? new Date(team.created_at).toLocaleDateString() : "N/A"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
maxWidth: "4px",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{formatNumberWithCommas(team["spend"], 4)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
maxWidth: "4px",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{team["max_budget"] !== null && team["max_budget"] !== undefined ? team["max_budget"] : "No limit"}
|
||||
</TableCell>
|
||||
<ModelsCell team={team} />
|
||||
<TableCell>{team.organization_id}</TableCell>
|
||||
<YourRoleCell team={team} userId={userId} />
|
||||
<TableCell>
|
||||
<Text>
|
||||
{perTeamInfo &&
|
||||
team.team_id &&
|
||||
perTeamInfo[team.team_id] &&
|
||||
perTeamInfo[team.team_id].keys &&
|
||||
perTeamInfo[team.team_id].keys.length}{" "}
|
||||
Keys
|
||||
</Text>
|
||||
<Text>
|
||||
{perTeamInfo &&
|
||||
team.team_id &&
|
||||
perTeamInfo[team.team_id] &&
|
||||
perTeamInfo[team.team_id].team_info &&
|
||||
perTeamInfo[team.team_id].team_info.members_with_roles &&
|
||||
perTeamInfo[team.team_id].team_info.members_with_roles.length}{" "}
|
||||
Members
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{userRole == "Admin" ? (
|
||||
<>
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedTeamId(team.team_id);
|
||||
setEditTeam(true);
|
||||
}}
|
||||
/>
|
||||
<Icon onClick={() => onDeleteTeam(team.team_id)} icon={TrashIcon} size="sm" />
|
||||
</>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamsTable;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import TeamRoleBadge from "./TeamRoleBadge";
|
||||
|
||||
const renderBadge = (role: string | null) => render(<div data-testid="wrap">{TeamRoleBadge(role)}</div>);
|
||||
|
||||
describe("TeamRoleBadge", () => {
|
||||
it("renders admin badge with correct label, base classes, styles, and an icon", () => {
|
||||
renderBadge("admin");
|
||||
const label = screen.getByText("Admin");
|
||||
const badge = label.closest("span")!;
|
||||
expect(badge).toHaveClass("inline-flex", "items-center", "border", "text-xs", "font-medium");
|
||||
expect(badge).toHaveStyle({
|
||||
backgroundColor: "#EEF2FF",
|
||||
color: "#3730A3",
|
||||
borderColor: "#C7D2FE",
|
||||
});
|
||||
expect(badge.querySelector("svg")).toBeInTheDocument(); // ShieldIcon renders as an SVG
|
||||
});
|
||||
|
||||
it.each<[string | null]>([["user"], [null], ["viewer" as unknown as string]])(
|
||||
"renders member badge for non-admin role (%p) with correct styles",
|
||||
(role) => {
|
||||
renderBadge(role);
|
||||
const label = screen.getByText("Member");
|
||||
const badge = label.closest("span")!;
|
||||
expect(badge).toHaveClass("inline-flex", "items-center", "border", "text-xs", "font-medium");
|
||||
expect(badge).toHaveStyle({
|
||||
backgroundColor: "#F3F4F6",
|
||||
color: "#4B5563",
|
||||
borderColor: "#E5E7EB",
|
||||
});
|
||||
expect(badge.querySelector("svg")).toBeInTheDocument(); // UserIcon renders as an SVG
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { ShieldIcon, UserIcon } from "lucide-react";
|
||||
|
||||
const MEMBER_BADGE_BG = "#F3F4F6"; // gray-100
|
||||
const MEMBER_BADGE_TEXT = "#4B5563"; // gray-600
|
||||
const MEMBER_BADGE_BORDER = "#E5E7EB"; // gray-200
|
||||
|
||||
const ADMIN_BADGE_BG = "#EEF2FF"; // indigo-50
|
||||
const ADMIN_BADGE_TEXT = "#3730A3"; // indigo-800
|
||||
const ADMIN_BADGE_BORDER = "#C7D2FE"; // indigo-200
|
||||
|
||||
const TeamRoleBadge = (role: string | null) => {
|
||||
const base = "inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium border";
|
||||
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return (
|
||||
<span
|
||||
className={base}
|
||||
style={{
|
||||
backgroundColor: ADMIN_BADGE_BG,
|
||||
color: ADMIN_BADGE_TEXT,
|
||||
borderColor: ADMIN_BADGE_BORDER,
|
||||
}}
|
||||
>
|
||||
<ShieldIcon className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</span>
|
||||
);
|
||||
case "user":
|
||||
default:
|
||||
return (
|
||||
<span
|
||||
className={base}
|
||||
style={{
|
||||
backgroundColor: MEMBER_BADGE_BG,
|
||||
color: MEMBER_BADGE_TEXT,
|
||||
borderColor: MEMBER_BADGE_BORDER,
|
||||
}}
|
||||
>
|
||||
<UserIcon className="h-3 w-3 mr-1" />
|
||||
Member
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default TeamRoleBadge;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
import YourRoleCell from "./YourRoleCell";
|
||||
|
||||
// Lightweight mocks for stable, focused tests
|
||||
vi.mock("@tremor/react", () => ({
|
||||
TableCell: ({ children }: { children: React.ReactNode }) => <div data-testid="cell">{children}</div>,
|
||||
}));
|
||||
|
||||
// The component invokes TeamRoleBadge as a function, so mock it as such
|
||||
vi.mock("@/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/TeamRoleBadge", () => ({
|
||||
__esModule: true,
|
||||
default: (role: string | null) => <span data-testid="badge">{role === "admin" ? "Admin" : "Member"}</span>,
|
||||
}));
|
||||
|
||||
const team = (members?: Array<{ user_id: string; role: "admin" | "user" }>): Team =>
|
||||
({ members_with_roles: members }) as unknown as Team;
|
||||
|
||||
describe("YourRoleCell", () => {
|
||||
it("renders Admin when the user is an admin of the team", () => {
|
||||
render(<YourRoleCell team={team([{ user_id: "u1", role: "admin" }])} userId="u1" />);
|
||||
expect(screen.getByTestId("cell")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("badge")).toHaveTextContent("Admin");
|
||||
});
|
||||
|
||||
it("renders Member when the user is a regular member", () => {
|
||||
render(<YourRoleCell team={team([{ user_id: "u2", role: "user" }])} userId="u2" />);
|
||||
expect(screen.getByTestId("badge")).toHaveTextContent("Member");
|
||||
});
|
||||
|
||||
it.each<[string, Team, string | null]>([
|
||||
["userId is null", team([{ user_id: "u3", role: "admin" }]), null],
|
||||
["user not in team", team([{ user_id: "x", role: "user" }]), "y"],
|
||||
["team has no members", team([]), "u4"],
|
||||
["members field undefined", team(undefined), "u5"],
|
||||
])("falls back to Member when no role can be determined (%s)", (_label, t, uid) => {
|
||||
render(<YourRoleCell team={t} userId={uid} />);
|
||||
expect(screen.getByTestId("badge")).toHaveTextContent("Member");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { TableCell } from "@tremor/react";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import TeamRoleBadge from "@/app/(dashboard)/teams/components/TeamsTable/YourRoleCell/TeamRoleBadge";
|
||||
|
||||
interface YourRoleCellProps {
|
||||
team: Team;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
const getUserRole = (team: Team, userId: string | null): string | null => {
|
||||
if (!userId) return null;
|
||||
const member = team.members_with_roles?.find((m) => m.user_id === userId);
|
||||
return member?.role ?? null;
|
||||
};
|
||||
|
||||
const YourRoleCell = ({ team, userId }: YourRoleCellProps) => {
|
||||
const roleBadge = TeamRoleBadge(getUserRole(team, userId));
|
||||
|
||||
return <TableCell>{roleBadge}</TableCell>;
|
||||
};
|
||||
|
||||
export default YourRoleCell;
|
||||
|
|
@ -0,0 +1,573 @@
|
|||
import { Button as Button2, Form, Input, Modal, Select as Select2, Tooltip } from "antd";
|
||||
import { Accordion, AccordionBody, AccordionHeader, Text, TextInput } from "@tremor/react";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
fetchAvailableModelsForTeamOrKey,
|
||||
getModelDisplayName,
|
||||
unfurlWildcardModelsInList,
|
||||
} from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
import NumericalInput from "@/components/shared/numerical_input";
|
||||
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
|
||||
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
|
||||
import PremiumLoggingSettings from "@/components/common_components/PremiumLoggingSettings";
|
||||
import ModelAliasManager from "@/components/common_components/ModelAliasManager";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { fetchMCPAccessGroups, getGuardrailsList, Organization, Team, teamCreateCall } from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
|
||||
|
||||
interface ModelAliases {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface CreateTeamModalProps {
|
||||
isTeamModalVisible: boolean;
|
||||
handleOk: () => void;
|
||||
handleCancel: () => void;
|
||||
currentOrg: Organization | null;
|
||||
organizations: Organization[] | null;
|
||||
teams: Team[] | null;
|
||||
setTeams: (teams: Team[] | null) => void;
|
||||
modelAliases: ModelAliases;
|
||||
setModelAliases: (modelAliases: ModelAliases) => void;
|
||||
loggingSettings: any[];
|
||||
setLoggingSettings: (loggingSettings: any[]) => void;
|
||||
setIsTeamModalVisible: (isTeamModalVisible: boolean) => void;
|
||||
}
|
||||
|
||||
const getOrganizationModels = (organization: Organization | null, userModels: string[]) => {
|
||||
let tempModelsToPick = [];
|
||||
|
||||
if (organization) {
|
||||
if (organization.models.length > 0) {
|
||||
console.log(`organization.models: ${organization.models}`);
|
||||
tempModelsToPick = organization.models;
|
||||
} else {
|
||||
// show all available models if the team has no models set
|
||||
tempModelsToPick = userModels;
|
||||
}
|
||||
} else {
|
||||
// no team set, show all available models
|
||||
tempModelsToPick = userModels;
|
||||
}
|
||||
|
||||
return unfurlWildcardModelsInList(tempModelsToPick, userModels);
|
||||
};
|
||||
|
||||
const CreateTeamModal = ({
|
||||
isTeamModalVisible,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
currentOrg,
|
||||
organizations,
|
||||
teams,
|
||||
setTeams,
|
||||
modelAliases,
|
||||
setModelAliases,
|
||||
loggingSettings,
|
||||
setLoggingSettings,
|
||||
setIsTeamModalVisible,
|
||||
}: CreateTeamModalProps) => {
|
||||
const { userId: userID, userRole, accessToken, premiumUser } = useAuthorized();
|
||||
const [form] = Form.useForm();
|
||||
const [userModels, setUserModels] = useState<string[]>([]);
|
||||
const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState<Organization | null>(null);
|
||||
const [modelsToPick, setModelsToPick] = useState<string[]>([]);
|
||||
const [guardrailsList, setGuardrailsList] = useState<string[]>([]);
|
||||
const [mcpAccessGroups, setMcpAccessGroups] = useState<string[]>([]);
|
||||
const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserModels = async () => {
|
||||
try {
|
||||
if (userID === null || userRole === null || accessToken === null) {
|
||||
return;
|
||||
}
|
||||
const models = await fetchAvailableModelsForTeamOrKey(userID, userRole, accessToken);
|
||||
if (models) {
|
||||
setUserModels(models);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching user models:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserModels();
|
||||
}, [accessToken, userID, userRole, teams]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(`currentOrgForCreateTeam: ${currentOrgForCreateTeam}`);
|
||||
const models = getOrganizationModels(currentOrgForCreateTeam, userModels);
|
||||
console.log(`models: ${models}`);
|
||||
setModelsToPick(models);
|
||||
form.setFieldValue("models", []);
|
||||
}, [currentOrgForCreateTeam, userModels]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMcpAccessGroups();
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchGuardrails = async () => {
|
||||
try {
|
||||
if (accessToken == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getGuardrailsList(accessToken);
|
||||
const guardrailNames = response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name);
|
||||
setGuardrailsList(guardrailNames);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch guardrails:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchGuardrails();
|
||||
}, [accessToken]);
|
||||
|
||||
const fetchMcpAccessGroups = async () => {
|
||||
try {
|
||||
if (accessToken == null) {
|
||||
return;
|
||||
}
|
||||
const groups = await fetchMCPAccessGroups(accessToken);
|
||||
setMcpAccessGroups(groups);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch MCP access groups:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (formValues: Record<string, any>) => {
|
||||
try {
|
||||
console.log(`formValues: ${JSON.stringify(formValues)}`);
|
||||
if (accessToken != null) {
|
||||
const newTeamAlias = formValues?.team_alias;
|
||||
const existingTeamAliases = teams?.map((t) => t.team_alias) ?? [];
|
||||
let organizationId = formValues?.organization_id || currentOrg?.organization_id;
|
||||
if (organizationId === "" || typeof organizationId !== "string") {
|
||||
formValues.organization_id = null;
|
||||
} else {
|
||||
formValues.organization_id = organizationId.trim();
|
||||
}
|
||||
|
||||
// Remove guardrails from top level since it's now in metadata
|
||||
if (existingTeamAliases.includes(newTeamAlias)) {
|
||||
throw new Error(`Team alias ${newTeamAlias} already exists, please pick another alias`);
|
||||
}
|
||||
|
||||
NotificationsManager.info("Creating Team");
|
||||
|
||||
// Handle logging settings in metadata
|
||||
if (loggingSettings.length > 0) {
|
||||
let metadata = {};
|
||||
if (formValues.metadata) {
|
||||
try {
|
||||
metadata = JSON.parse(formValues.metadata);
|
||||
} catch (e) {
|
||||
console.warn("Invalid JSON in metadata field, starting with empty object");
|
||||
}
|
||||
}
|
||||
|
||||
// Add logging settings to metadata
|
||||
metadata = {
|
||||
...metadata,
|
||||
logging: loggingSettings.filter((config) => config.callback_name), // Only include configs with callback_name
|
||||
};
|
||||
|
||||
formValues.metadata = JSON.stringify(metadata);
|
||||
}
|
||||
|
||||
// Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission
|
||||
if (
|
||||
(formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) ||
|
||||
(formValues.allowed_mcp_servers_and_groups &&
|
||||
(formValues.allowed_mcp_servers_and_groups.servers?.length > 0 ||
|
||||
formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0 ||
|
||||
formValues.allowed_mcp_servers_and_groups.toolPermissions))
|
||||
) {
|
||||
formValues.object_permission = {};
|
||||
if (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) {
|
||||
formValues.object_permission.vector_stores = formValues.allowed_vector_store_ids;
|
||||
delete formValues.allowed_vector_store_ids;
|
||||
}
|
||||
if (formValues.allowed_mcp_servers_and_groups) {
|
||||
const { servers, accessGroups } = formValues.allowed_mcp_servers_and_groups;
|
||||
if (servers && servers.length > 0) {
|
||||
formValues.object_permission.mcp_servers = servers;
|
||||
}
|
||||
if (accessGroups && accessGroups.length > 0) {
|
||||
formValues.object_permission.mcp_access_groups = accessGroups;
|
||||
}
|
||||
delete formValues.allowed_mcp_servers_and_groups;
|
||||
}
|
||||
|
||||
// Add tool permissions separately
|
||||
if (formValues.mcp_tool_permissions && Object.keys(formValues.mcp_tool_permissions).length > 0) {
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
formValues.object_permission.mcp_tool_permissions = formValues.mcp_tool_permissions;
|
||||
delete formValues.mcp_tool_permissions;
|
||||
}
|
||||
}
|
||||
|
||||
// Transform allowed_mcp_access_groups into object_permission
|
||||
if (formValues.allowed_mcp_access_groups && formValues.allowed_mcp_access_groups.length > 0) {
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
formValues.object_permission.mcp_access_groups = formValues.allowed_mcp_access_groups;
|
||||
delete formValues.allowed_mcp_access_groups;
|
||||
}
|
||||
|
||||
// Add model_aliases if any are defined
|
||||
if (Object.keys(modelAliases).length > 0) {
|
||||
formValues.model_aliases = modelAliases;
|
||||
}
|
||||
|
||||
const response: any = await teamCreateCall(accessToken, formValues);
|
||||
if (teams !== null) {
|
||||
setTeams([...teams, response]);
|
||||
} else {
|
||||
setTeams([response]);
|
||||
}
|
||||
console.log(`response for team create call: ${response}`);
|
||||
NotificationsManager.success("Team created");
|
||||
form.resetFields();
|
||||
setLoggingSettings([]);
|
||||
setModelAliases({});
|
||||
setIsTeamModalVisible(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating the team:", error);
|
||||
NotificationsManager.fromBackend("Error creating the team: " + error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Create Team"
|
||||
open={isTeamModalVisible}
|
||||
width={1000}
|
||||
footer={null}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
|
||||
<>
|
||||
<Form.Item
|
||||
label="Team Name"
|
||||
name="team_alias"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input a team name",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput placeholder="" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Organization{" "}
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Organizations can have multiple teams. Learn more about{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/user_management_heirarchy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: "#1890ff",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
user management hierarchy
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="organization_id"
|
||||
initialValue={currentOrg ? currentOrg.organization_id : null}
|
||||
className="mt-8"
|
||||
>
|
||||
<Select2
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Search or select an Organization"
|
||||
onChange={(value) => {
|
||||
form.setFieldValue("organization_id", value);
|
||||
setCurrentOrgForCreateTeam(organizations?.find((org) => org.organization_id === value) || null);
|
||||
}}
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
const optionValue = option.children?.toString() || "";
|
||||
return optionValue.toLowerCase().includes(input.toLowerCase());
|
||||
}}
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{organizations?.map((org) => (
|
||||
<Select2.Option key={org.organization_id} value={org.organization_id}>
|
||||
<span className="font-medium">{org.organization_alias}</span>{" "}
|
||||
<span className="text-gray-500">({org.organization_id})</span>
|
||||
</Select2.Option>
|
||||
))}
|
||||
</Select2>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Models{" "}
|
||||
<Tooltip title="These are the models that your selected team has access to">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="models"
|
||||
>
|
||||
<Select2 mode="multiple" placeholder="Select models" style={{ width: "100%" }}>
|
||||
<Select2.Option key="all-proxy-models" value="all-proxy-models">
|
||||
All Proxy Models
|
||||
</Select2.Option>
|
||||
{modelsToPick.map((model) => (
|
||||
<Select2.Option key={model} value={model}>
|
||||
{getModelDisplayName(model)}
|
||||
</Select2.Option>
|
||||
))}
|
||||
</Select2>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Max Budget (USD)" name="max_budget">
|
||||
<NumericalInput step={0.01} precision={2} width={200} />
|
||||
</Form.Item>
|
||||
<Form.Item className="mt-8" label="Reset Budget" name="budget_duration">
|
||||
<Select2 defaultValue={null} placeholder="n/a">
|
||||
<Select2.Option value="24h">daily</Select2.Option>
|
||||
<Select2.Option value="7d">weekly</Select2.Option>
|
||||
<Select2.Option value="30d">monthly</Select2.Option>
|
||||
</Select2>
|
||||
</Form.Item>
|
||||
<Form.Item label="Tokens per minute Limit (TPM)" name="tpm_limit">
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Requests per minute Limit (RPM)" name="rpm_limit">
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
|
||||
<Accordion
|
||||
className="mt-20 mb-8"
|
||||
onClick={() => {
|
||||
if (!mcpAccessGroupsLoaded) {
|
||||
fetchMcpAccessGroups();
|
||||
setMcpAccessGroupsLoaded(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AccordionHeader>
|
||||
<b>Additional Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Form.Item
|
||||
label="Team ID"
|
||||
name="team_id"
|
||||
help="ID of the team you want to create. If not provided, it will be generated automatically."
|
||||
>
|
||||
<TextInput
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.trim();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Team Member Budget (USD)"
|
||||
name="team_member_budget"
|
||||
normalize={(value) => (value ? Number(value) : undefined)}
|
||||
tooltip="This is the individual budget for a user in the team."
|
||||
>
|
||||
<NumericalInput step={0.01} precision={2} width={200} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Team Member Key Duration (eg: 1d, 1mo)"
|
||||
name="team_member_key_duration"
|
||||
tooltip="Set a limit to the duration of a team member's key. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days), 1mo (month)"
|
||||
>
|
||||
<TextInput placeholder="e.g., 30d" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Team Member RPM Limit"
|
||||
name="team_member_rpm_limit"
|
||||
tooltip="The RPM (Requests Per Minute) limit for individual team members"
|
||||
>
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Team Member TPM Limit"
|
||||
name="team_member_tpm_limit"
|
||||
tooltip="The TPM (Tokens Per Minute) limit for individual team members"
|
||||
>
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Metadata"
|
||||
name="metadata"
|
||||
help="Additional team metadata. Enter metadata as JSON object."
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Guardrails{" "}
|
||||
<Tooltip title="Setup your first guardrail">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="guardrails"
|
||||
className="mt-8"
|
||||
help="Select existing guardrails or enter new ones"
|
||||
>
|
||||
<Select2
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Select or enter guardrails"
|
||||
options={guardrailsList.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Vector Stores{" "}
|
||||
<Tooltip title="Select which vector stores this team can access by default. Leave empty for access to all vector stores">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_vector_store_ids"
|
||||
className="mt-8"
|
||||
help="Select vector stores this team can access. Leave empty for access to all vector stores"
|
||||
>
|
||||
<VectorStoreSelector
|
||||
onChange={(values: string[]) => form.setFieldValue("allowed_vector_store_ids", values)}
|
||||
value={form.getFieldValue("allowed_vector_store_ids")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select vector stores (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-8 mb-8">
|
||||
<AccordionHeader>
|
||||
<b>MCP Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed MCP Servers{" "}
|
||||
<Tooltip title="Select which MCP servers or access groups this team can access">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_mcp_servers_and_groups"
|
||||
className="mt-4"
|
||||
help="Select MCP servers or access groups this team can access"
|
||||
>
|
||||
<MCPServerSelector
|
||||
onChange={(val: any) => form.setFieldValue("allowed_mcp_servers_and_groups", val)}
|
||||
value={form.getFieldValue("allowed_mcp_servers_and_groups")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Hidden field to register mcp_tool_permissions with the form */}
|
||||
<Form.Item name="mcp_tool_permissions" initialValue={{}} hidden>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.allowed_mcp_servers_and_groups !== currentValues.allowed_mcp_servers_and_groups ||
|
||||
prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<div className="mt-6">
|
||||
<MCPToolPermissions
|
||||
accessToken={accessToken || ""}
|
||||
selectedServers={form.getFieldValue("allowed_mcp_servers_and_groups")?.servers || []}
|
||||
toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}}
|
||||
onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-8 mb-8">
|
||||
<AccordionHeader>
|
||||
<b>Logging Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<div className="mt-4">
|
||||
<PremiumLoggingSettings
|
||||
value={loggingSettings}
|
||||
onChange={setLoggingSettings}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-8 mb-8">
|
||||
<AccordionHeader>
|
||||
<b>Model Aliases</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<div className="mt-4">
|
||||
<Text className="text-sm text-gray-600 mb-4">
|
||||
Create custom aliases for models that can be used by team members in API calls. This allows you to
|
||||
create shortcuts for specific models.
|
||||
</Text>
|
||||
<ModelAliasManager
|
||||
accessToken={accessToken || ""}
|
||||
initialModelAliases={modelAliases}
|
||||
onAliasUpdate={setModelAliases}
|
||||
showExampleConfig={false}
|
||||
/>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
</>
|
||||
<div style={{ textAlign: "right", marginTop: "10px" }}>
|
||||
<Button2 htmlType="submit">Create Team</Button2>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTeamModal;
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { AlertTriangleIcon, XIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
|
||||
interface DeleteTeamModalProps {
|
||||
teams: Team[] | null;
|
||||
teamToDelete: string | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const DeleteTeamModal = ({ teams, teamToDelete, onCancel, onConfirm }: DeleteTeamModalProps) => {
|
||||
const [deleteConfirmInput, setDeleteConfirmInput] = useState("");
|
||||
|
||||
const team = teams?.find((t) => t.team_id === teamToDelete);
|
||||
const teamName = team?.team_alias || "";
|
||||
const keyCount = team?.keys?.length || 0;
|
||||
const isValid = deleteConfirmInput === teamName;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl min-h-[380px] py-6 overflow-hidden transform transition-all flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Delete Team</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
setDeleteConfirmInput("");
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
{keyCount > 0 && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-50 border border-red-100 rounded-md mb-5">
|
||||
<div className="text-red-500 mt-0.5">
|
||||
<AlertTriangleIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-base font-medium text-red-600">
|
||||
Warning: This team has {keyCount} associated key{keyCount > 1 ? "s" : ""}.
|
||||
</p>
|
||||
<p className="text-base text-red-600 mt-2">
|
||||
Deleting the team will also delete all associated keys. This action is irreversible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-base text-gray-600 mb-5">
|
||||
Are you sure you want to force delete this team and all its keys?
|
||||
</p>
|
||||
<div className="mb-5">
|
||||
<label className="block text-base font-medium text-gray-700 mb-2">
|
||||
{`Type `}
|
||||
<span className="underline">{teamName}</span>
|
||||
{` to confirm deletion:`}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmInput}
|
||||
onChange={(e) => setDeleteConfirmInput(e.target.value)}
|
||||
placeholder="Enter team name exactly"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-base"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-gray-50 flex justify-end gap-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
setDeleteConfirmInput("");
|
||||
}}
|
||||
className="px-5 py-3 bg-white border border-gray-300 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={!isValid}
|
||||
className={`px-5 py-3 rounded-md text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 ${isValid ? "bg-red-600 hover:bg-red-700" : "bg-red-300 cursor-not-allowed"}`}
|
||||
>
|
||||
Force Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteTeamModal;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchTeams } from "@/components/common_components/fetch_teams";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { Organization, Team } from "@/components/networking";
|
||||
|
||||
interface useFetchTeamsProps {
|
||||
currentOrg: Organization | null;
|
||||
setTeams: (teams: Team[] | null) => void;
|
||||
}
|
||||
|
||||
const useFetchTeams = ({ currentOrg, setTeams }: useFetchTeamsProps) => {
|
||||
const [lastRefreshed, setLastRefreshed] = useState("");
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
|
||||
const onRefreshClick = useCallback(() => {
|
||||
const currentDate = new Date();
|
||||
setLastRefreshed(currentDate.toLocaleString());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken) {
|
||||
fetchTeams(accessToken, userId, userRole, currentOrg, setTeams).then();
|
||||
}
|
||||
onRefreshClick();
|
||||
}, [accessToken, currentOrg, lastRefreshed, onRefreshClick, setTeams, userId, userRole]);
|
||||
|
||||
return { lastRefreshed, setLastRefreshed, onRefreshClick };
|
||||
};
|
||||
|
||||
export default useFetchTeams;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import Teams from "@/components/teams";
|
||||
import TeamsView from "@/app/(dashboard)/teams/TeamsView";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
@ -10,9 +10,6 @@ import { fetchOrganizations } from "@/components/organizations";
|
|||
const TeamsPage = () => {
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
const { teams, setTeams } = useTeams();
|
||||
const [searchParams, setSearchParams] = useState<URLSearchParams>(() =>
|
||||
typeof window === "undefined" ? new URLSearchParams() : new URLSearchParams(window.location.search),
|
||||
);
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -20,9 +17,8 @@ const TeamsPage = () => {
|
|||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<Teams
|
||||
<TeamsView
|
||||
teams={teams}
|
||||
searchParams={searchParams}
|
||||
accessToken={accessToken}
|
||||
setTeams={setTeams}
|
||||
userID={userId}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { ThemeProvider } from "@/contexts/ThemeContext";
|
|||
import UserDashboard from "@/components/user_dashboard";
|
||||
import ModelDashboard from "@/components/templates/model_dashboard";
|
||||
import ViewUserDashboard from "@/components/view_users";
|
||||
import Teams from "@/components/teams";
|
||||
import TeamsView from "@/app/(dashboard)/teams/TeamsView";
|
||||
import Organizations from "@/components/organizations";
|
||||
import { fetchOrganizations } from "@/components/organizations";
|
||||
import AdminPanel from "@/components/admins";
|
||||
|
|
@ -376,10 +376,9 @@ export default function CreateKeyPage() {
|
|||
setKeys={setKeys}
|
||||
/>
|
||||
) : page == "teams" ? (
|
||||
<Teams
|
||||
<TeamsView
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
searchParams={searchParams}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -18,10 +18,53 @@ const { stub, jwtDecodeMock } = vi.hoisted(() => {
|
|||
* Mocks
|
||||
* --------------------------- */
|
||||
|
||||
// next/navigation: just return empty URLSearchParams (no invitation/page)
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
vi.mock("@/hooks/useFeatureFlags", () => {
|
||||
const React = require("react");
|
||||
|
||||
// minimal context so useFeatureFlags() returns something stable
|
||||
const FeatureFlagsCtx = React.createContext({ get: () => false, flags: {} });
|
||||
|
||||
// Defensive provider: handle undefined props and allow optional value override
|
||||
const FeatureFlagsProvider = (props: any) => {
|
||||
const p = props || {};
|
||||
const value = p.value ?? { get: () => false, flags: {} };
|
||||
return React.createElement(FeatureFlagsCtx.Provider, { value }, p.children);
|
||||
};
|
||||
|
||||
const useFeatureFlags = () => React.useContext(FeatureFlagsCtx);
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: FeatureFlagsProvider, // supports default import
|
||||
FeatureFlagsProvider, // supports named import
|
||||
useFeatureFlags, // supports named import
|
||||
};
|
||||
});
|
||||
|
||||
// next/navigation mock: search params + router + pathname
|
||||
vi.mock("next/navigation", () => {
|
||||
const router = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
// what your tests already relied on
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
// added: satisfies useAuthorized / SidebarProvider
|
||||
useRouter: () => router,
|
||||
// optional helpers some components often read
|
||||
usePathname: () => "/",
|
||||
// optional: noop versions if code calls them
|
||||
redirect: vi.fn(), // App Router server action usually; safe noop here
|
||||
notFound: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Networking layer
|
||||
vi.mock("@/components/networking", () => {
|
||||
|
|
@ -87,6 +130,7 @@ vi.mock("@/lib/cva.config", () => ({
|
|||
}));
|
||||
|
||||
import CreateKeyPage from "@/app/page";
|
||||
import { FeatureFlagsProvider } from "@/hooks/useFeatureFlags";
|
||||
|
||||
/** ----------------------------
|
||||
* Helpers
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue