mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-20 09:25:34 +00:00
refactor: remove old IntegrationList component and update imports (#670)
This commit is contained in:
commit
5cdfa04acd
6 changed files with 787 additions and 1094 deletions
|
|
@ -1,499 +0,0 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { TooltipSimple } from "@/components/ui/tooltip";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import { proxyFetchGet, proxyFetchPost, proxyFetchPut, proxyFetchDelete, fetchDelete, fetchGet, fetchPost } from "@/api/http";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import ellipseIcon from "@/assets/mcp/Ellipse-25.svg";
|
||||
import { capitalizeFirstLetter } from "@/lib";
|
||||
import { MCPEnvDialog } from "@/pages/Setting/components/MCPEnvDialog";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { OAuth } from "@/lib/oauth";
|
||||
import { useTranslation } from "react-i18next";
|
||||
interface IntegrationItem {
|
||||
key: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
env_vars: string[];
|
||||
toolkit?: string; // Add toolkit field
|
||||
onInstall: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface IntegrationListProps {
|
||||
items: IntegrationItem[];
|
||||
addOption: (mcp: any, isLocal: boolean) => void;
|
||||
onShowEnvConfig?: (mcp: any) => void;
|
||||
installedKeys?: string[];
|
||||
oauth?: OAuth | null;
|
||||
}
|
||||
|
||||
export default function IntegrationList({
|
||||
items,
|
||||
addOption,
|
||||
onShowEnvConfig,
|
||||
installedKeys = [],
|
||||
oauth,
|
||||
}: IntegrationListProps) {
|
||||
const [callBackUrl, setCallBackUrl] = useState<string | null>(null);
|
||||
const [showEnvConfig, setShowEnvConfig] = useState(false);
|
||||
const [activeMcp, setActiveMcp] = useState<any | null>(null);
|
||||
const { email, checkAgentTool } = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
// local installed status
|
||||
const [installed, setInstalled] = useState<{ [key: string]: boolean }>({});
|
||||
// configs cache
|
||||
const [configs, setConfigs] = useState<any[]>([]);
|
||||
// 1. add useRef lock
|
||||
const isLockedRef = useRef(false);
|
||||
// add cache oauth event ref
|
||||
const pendingOauthEventRef = useRef<{
|
||||
provider: string;
|
||||
code: string;
|
||||
} | null>(null);
|
||||
|
||||
async function fetchInstalled(ignore: boolean = false) {
|
||||
try {
|
||||
const configsRes = await proxyFetchGet("/api/configs");
|
||||
console.log("configsRes", configsRes);
|
||||
if (!ignore) {
|
||||
console.log("configsRes", Array.isArray(configsRes), configsRes);
|
||||
setConfigs(Array.isArray(configsRes) ? configsRes : []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("fetchInstalled error", e);
|
||||
if (!ignore) setConfigs([]);
|
||||
}
|
||||
}
|
||||
// fetch configs when mounted
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
fetchInstalled(ignore);
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// items or configs change, recalculate installed
|
||||
useEffect(() => {
|
||||
// For Google Calendar, check for allowed env keys
|
||||
// For other integrations, check by config_group
|
||||
const map: { [key: string]: boolean } = {};
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.key === "Google Calendar") {
|
||||
// Only mark installed when refresh token is present (auth completed)
|
||||
const hasRefreshToken = configs.some(
|
||||
(c: any) =>
|
||||
c.config_group?.toLowerCase() === "google calendar" &&
|
||||
c.config_name === "GOOGLE_REFRESH_TOKEN" &&
|
||||
c.config_value && String(c.config_value).length > 0
|
||||
);
|
||||
map[item.key] = hasRefreshToken;
|
||||
} else {
|
||||
// For other integrations, use config_group presence
|
||||
const hasConfig = configs.some(
|
||||
(c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase()
|
||||
);
|
||||
map[item.key] = hasConfig;
|
||||
}
|
||||
});
|
||||
|
||||
setInstalled(map);
|
||||
}, [items, configs]);
|
||||
|
||||
// public save env/config logic
|
||||
const saveEnvAndConfig = async (
|
||||
provider: string,
|
||||
envVarKey: string,
|
||||
value: string
|
||||
) => {
|
||||
const configPayload = {
|
||||
// Keep exact group name to satisfy backend whitelist
|
||||
config_group: provider,
|
||||
config_name: envVarKey,
|
||||
config_value: value,
|
||||
};
|
||||
|
||||
// Fetch latest configs to avoid stale state when deciding POST/PUT
|
||||
let latestConfigs: any[] = Array.isArray(configs) ? configs : [];
|
||||
try {
|
||||
const fresh = await proxyFetchGet("/api/configs");
|
||||
if (Array.isArray(fresh)) latestConfigs = fresh;
|
||||
} catch {}
|
||||
|
||||
// Backend uniqueness is by config_name for a user
|
||||
let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey);
|
||||
|
||||
if (existingConfig) {
|
||||
await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload);
|
||||
} else {
|
||||
const res = await proxyFetchPost("/api/configs", configPayload);
|
||||
if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) {
|
||||
try {
|
||||
const again = await proxyFetchGet("/api/configs");
|
||||
const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null;
|
||||
if (found) {
|
||||
await proxyFetchPut(`/api/configs/${found.id}`, configPayload);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (window.electronAPI?.envWrite) {
|
||||
await window.electronAPI.envWrite(email, { key: envVarKey, value });
|
||||
}
|
||||
};
|
||||
|
||||
// useCallback to ensure processOauth can get the latest items when items change
|
||||
const processOauth = useCallback(
|
||||
async (data: { provider: string; code: string }) => {
|
||||
if (isLockedRef.current) return;
|
||||
if (!items || items.length === 0) {
|
||||
// items not ready, cache event, wait for items to have value
|
||||
pendingOauthEventRef.current = data;
|
||||
console.warn("items is empty, cache oauth event", data);
|
||||
return;
|
||||
}
|
||||
const provider = data.provider.toLowerCase();
|
||||
isLockedRef.current = true;
|
||||
try {
|
||||
const tokenResult = await proxyFetchPost(
|
||||
`/api/oauth/${provider}/token`,
|
||||
{ code: data.code }
|
||||
);
|
||||
setInstalled((prev) => ({
|
||||
...prev,
|
||||
[capitalizeFirstLetter(provider)]: true,
|
||||
}));
|
||||
const currentItem = items.find(
|
||||
(item) => item.key.toLowerCase() === provider
|
||||
);
|
||||
if (provider === "slack") {
|
||||
if (
|
||||
tokenResult.access_token &&
|
||||
currentItem &&
|
||||
currentItem.env_vars &&
|
||||
currentItem.env_vars.length > 0
|
||||
) {
|
||||
const envVarKey = currentItem.env_vars[0];
|
||||
await saveEnvAndConfig(
|
||||
provider,
|
||||
envVarKey,
|
||||
tokenResult.access_token
|
||||
);
|
||||
await fetchInstalled();
|
||||
console.log(
|
||||
"Slack authorization successful and configuration saved!"
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"Slack authorization successful, but access_token not found or env configuration not found"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log(`${data.provider} authorization failed: ${e.message || e}`);
|
||||
} finally {
|
||||
isLockedRef.current = false;
|
||||
}
|
||||
},
|
||||
[items, oauth]
|
||||
);
|
||||
|
||||
// listen to main process oauth authorization callback, automatically mark as installed and get token
|
||||
useEffect(() => {
|
||||
const handler = (_event: any, data: { provider: string; code: string }) => {
|
||||
if (!data.provider || !data.code) return;
|
||||
processOauth(data);
|
||||
};
|
||||
window.ipcRenderer?.on("oauth-authorized", handler);
|
||||
return () => {
|
||||
window.ipcRenderer?.off("oauth-authorized", handler);
|
||||
};
|
||||
}, [items, oauth, processOauth]);
|
||||
|
||||
// listen to oauth callback URL notification
|
||||
useEffect(() => {
|
||||
const handler = (_event: any, data: { url: string; provider: string }) => {
|
||||
console.log('OAuth callback URL:', data);
|
||||
if (data.url && data.provider) {
|
||||
setCallBackUrl(data.url);
|
||||
}
|
||||
};
|
||||
window.ipcRenderer?.on("oauth-callback-url", handler);
|
||||
return () => {
|
||||
window.ipcRenderer?.off("oauth-callback-url", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// listen to items change, if there is a cached oauth event and items is ready, automatically process
|
||||
useEffect(() => {
|
||||
if (items && items.length > 0 && pendingOauthEventRef.current) {
|
||||
processOauth(pendingOauthEventRef.current);
|
||||
pendingOauthEventRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items, oauth]);
|
||||
|
||||
|
||||
// install/uninstall
|
||||
const handleInstall = useCallback(
|
||||
async (item: IntegrationItem) => {
|
||||
console.log(item);
|
||||
if (item.key === "EXA Search") {
|
||||
let mcp = {
|
||||
name: "EXA Search",
|
||||
key: "EXA Search",
|
||||
install_command: {
|
||||
env: {} as any,
|
||||
},
|
||||
id: 13,
|
||||
};
|
||||
item.env_vars.map((key) => {
|
||||
mcp.install_command.env[key] = "";
|
||||
});
|
||||
onShowEnvConfig?.(mcp);
|
||||
// setActiveMcp(mcp);
|
||||
// setShowEnvConfig(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.key === "Google Calendar") {
|
||||
// Always prompt env dialog first instead of jumping to authorization
|
||||
let mcp = {
|
||||
name: "Google Calendar",
|
||||
key: "Google Calendar",
|
||||
install_command: {
|
||||
env: {} as any,
|
||||
},
|
||||
id: 14,
|
||||
};
|
||||
item.env_vars.map((key) => {
|
||||
mcp.install_command.env[key] = "";
|
||||
});
|
||||
onShowEnvConfig?.(mcp);
|
||||
return;
|
||||
}
|
||||
if (installed[item.key]) return;
|
||||
await item.onInstall();
|
||||
// refresh configs after install to update installed state indicator
|
||||
await fetchInstalled();
|
||||
},
|
||||
[installed]
|
||||
);
|
||||
|
||||
const onConnect = async (mcp: any) => {
|
||||
console.log("[IntegrationList onConnect] Starting for", mcp.key);
|
||||
|
||||
// Refresh configs first to get latest state
|
||||
await fetchInstalled();
|
||||
|
||||
// Save all environment variables
|
||||
await Promise.all(
|
||||
Object.keys(mcp.install_command.env).map(async (key) => {
|
||||
return saveEnvAndConfig(mcp.key, key, mcp.install_command.env[key]);
|
||||
})
|
||||
);
|
||||
|
||||
// After saving env vars, handle Google Calendar authorization flow
|
||||
if (mcp.key === "Google Calendar") {
|
||||
console.log("[IntegrationList onConnect] Google Calendar detected, starting auth flow");
|
||||
|
||||
// Trigger install/authorization
|
||||
const calendarItem = items.find(item => item.key === "Google Calendar");
|
||||
try {
|
||||
if (calendarItem && calendarItem.onInstall) {
|
||||
await calendarItem.onInstall();
|
||||
} else {
|
||||
await fetchPost("/install/tool/google_calendar");
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
console.log("[IntegrationList onConnect] Starting OAuth status polling");
|
||||
|
||||
// Keep the dialog open and poll OAuth status until completion
|
||||
const start = Date.now();
|
||||
const timeoutMs = 5 * 60 * 1000; // 5 minutes
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const statusRes: any = await fetchGet("/oauth/status/google_calendar");
|
||||
console.log("[IntegrationList onConnect] OAuth status:", statusRes?.status);
|
||||
|
||||
if (statusRes?.status === "success") {
|
||||
console.log("[IntegrationList onConnect] Success! Closing dialog");
|
||||
await fetchInstalled();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (statusRes?.status === "failed" || statusRes?.status === "cancelled") {
|
||||
console.log("[IntegrationList onConnect] Failed/cancelled, keeping dialog open");
|
||||
// Stop waiting on failure/cancellation; keep dialog open for retry
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[IntegrationList onConnect] Polling error:", err);
|
||||
// ignore transient polling errors
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
// Timeout reached; return to allow user to try again
|
||||
console.log("[IntegrationList onConnect] Polling timeout");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[IntegrationList onConnect] Non-Google Calendar, closing immediately");
|
||||
await fetchInstalled();
|
||||
addOption(mcp, true);
|
||||
onClose();
|
||||
};
|
||||
const onClose = () => {
|
||||
setShowEnvConfig(false);
|
||||
setActiveMcp(null);
|
||||
};
|
||||
|
||||
// uninstall logic
|
||||
const handleUninstall = useCallback(
|
||||
async (item: IntegrationItem) => {
|
||||
// find all config_group matching config, delete one by one
|
||||
checkAgentTool(item.key);
|
||||
const groupKey = item.key.toLowerCase();
|
||||
const toDelete = configs.filter(
|
||||
(c: any) => c.config_group && c.config_group.toLowerCase() === groupKey
|
||||
);
|
||||
for (const config of toDelete) {
|
||||
try {
|
||||
await proxyFetchDelete(`/api/configs/${config.id}`);
|
||||
// delete env
|
||||
if (
|
||||
item.env_vars &&
|
||||
item.env_vars.length > 0 &&
|
||||
window.electronAPI?.envRemove
|
||||
) {
|
||||
await window.electronAPI.envRemove(email, item.env_vars[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore error
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up authentication tokens for Google Calendar and Notion
|
||||
if (item.key === "Google Calendar") {
|
||||
try {
|
||||
await fetchDelete("/uninstall/tool/google_calendar");
|
||||
console.log("Cleaned up Google Calendar authentication tokens");
|
||||
} catch (e) {
|
||||
console.log("Failed to clean up Google Calendar tokens:", e);
|
||||
}
|
||||
} else if (item.key === "Notion") {
|
||||
try {
|
||||
await fetchDelete("/uninstall/tool/notion");
|
||||
console.log("Cleaned up Notion authentication tokens");
|
||||
} catch (e) {
|
||||
console.log("Failed to clean up Notion tokens:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// delete after refresh configs
|
||||
setConfigs((prev) =>
|
||||
prev.filter((c: any) => c.config_group?.toLowerCase() !== groupKey)
|
||||
);
|
||||
},
|
||||
[configs]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MCPEnvDialog
|
||||
showEnvConfig={showEnvConfig}
|
||||
onClose={onClose}
|
||||
onConnect={onConnect}
|
||||
activeMcp={activeMcp}
|
||||
></MCPEnvDialog>
|
||||
{items.map((item) => {
|
||||
const isInstalled = !!installed[item.key];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="cursor-pointer hover:bg-gray-100 px-3 py-2 flex justify-between"
|
||||
onClick={() => {
|
||||
if (
|
||||
![
|
||||
"X(Twitter)",
|
||||
"WhatsApp",
|
||||
"LinkedIn",
|
||||
"Reddit",
|
||||
"Github",
|
||||
].includes(item.name)
|
||||
) {
|
||||
if (item.env_vars.length === 0 || isInstalled) {
|
||||
// Ensure toolkit field is passed and normalized for known cases
|
||||
const normalizedToolkit =
|
||||
item.name === "Notion" ? "notion_mcp_toolkit" : item.toolkit;
|
||||
addOption({ ...item, toolkit: normalizedToolkit }, true);
|
||||
} else {
|
||||
handleInstall(item);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-xs">
|
||||
<img
|
||||
src={ellipseIcon}
|
||||
alt="icon"
|
||||
className="w-3 h-3 mr-2"
|
||||
style={{
|
||||
filter: isInstalled
|
||||
? "grayscale(0%) brightness(0) saturate(100%) invert(41%) sepia(99%) saturate(749%) hue-rotate(81deg) brightness(95%) contrast(92%)"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
<div className="text-base leading-snug font-bold text-text-action">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<TooltipSimple content={item.desc}>
|
||||
<CircleAlert className="w-4 h-4 text-icon-secondary" />
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
{item.env_vars.length !== 0 && (
|
||||
<Button
|
||||
disabled={[
|
||||
"X(Twitter)",
|
||||
"WhatsApp",
|
||||
"LinkedIn",
|
||||
"Reddit",
|
||||
"Github",
|
||||
].includes(item.name)}
|
||||
variant={isInstalled ? "secondary" : "primary"}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
return isInstalled
|
||||
? handleUninstall(item)
|
||||
: handleInstall(item);
|
||||
}}
|
||||
>
|
||||
{[
|
||||
"X(Twitter)",
|
||||
"WhatsApp",
|
||||
"LinkedIn",
|
||||
"Reddit",
|
||||
"Github",
|
||||
].includes(item.name)
|
||||
? t("layout.coming-soon")
|
||||
: isInstalled
|
||||
? t("layout.uninstall")
|
||||
: t("layout.install")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import { Textarea } from "../ui/textarea";
|
|||
import { Button } from "../ui/button";
|
||||
import githubIcon from "@/assets/github.svg";
|
||||
import { TooltipSimple } from "../ui/tooltip";
|
||||
import IntegrationList from "./IntegrationList";
|
||||
import IntegrationList from "@/components/IntegrationList";
|
||||
import { getProxyBaseURL } from "@/lib";
|
||||
import { capitalizeFirstLetter } from "@/lib";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
|
@ -720,11 +720,13 @@ const ToolSelect = forwardRef<
|
|||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-dropdown-bg rounded-lg border border-solid border-input-border-default overflow-y-auto">
|
||||
<div className="max-h-[192px] overflow-y-auto">
|
||||
<IntegrationList
|
||||
onShowEnvConfig={onShowEnvConfig}
|
||||
addOption={addOption}
|
||||
items={integrations}
|
||||
/>
|
||||
<IntegrationList
|
||||
variant="select"
|
||||
onShowEnvConfig={onShowEnvConfig}
|
||||
addOption={addOption}
|
||||
items={integrations}
|
||||
translationNamespace="layout"
|
||||
/>
|
||||
{mcpList
|
||||
.filter(
|
||||
(opt) =>
|
||||
|
|
|
|||
446
src/components/IntegrationList/index.tsx
Normal file
446
src/components/IntegrationList/index.tsx
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TooltipSimple } from "@/components/ui/tooltip";
|
||||
import { CircleAlert, Settings2 } from "lucide-react";
|
||||
import { fetchGet, fetchPost } from "@/api/http";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import ellipseIcon from "@/assets/mcp/Ellipse-25.svg";
|
||||
import { MCPEnvDialog } from "@/pages/Setting/components/MCPEnvDialog";
|
||||
import { OAuth } from "@/lib/oauth";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useIntegrationManagement,
|
||||
type IntegrationItem,
|
||||
} from "@/hooks/useIntegrationManagement";
|
||||
|
||||
type IntegrationListVariant = "select" | "manage";
|
||||
|
||||
interface IntegrationListProps {
|
||||
items: IntegrationItem[];
|
||||
variant?: IntegrationListVariant; // "select" for AddWorker, "manage" for Setting
|
||||
|
||||
// Select mode props (AddWorker)
|
||||
addOption?: (mcp: any, isLocal: boolean) => void;
|
||||
onShowEnvConfig?: (mcp: any) => void;
|
||||
|
||||
// Manage mode props (Setting)
|
||||
showSelect?: boolean;
|
||||
selectPlaceholder?: string;
|
||||
selectContent?: React.ReactNode;
|
||||
onSelectChange?: (value: string, item: IntegrationItem) => void;
|
||||
showConfigButton?: boolean;
|
||||
showInstallButton?: boolean;
|
||||
onConfigClick?: (item: IntegrationItem) => void;
|
||||
showStatusDot?: boolean;
|
||||
|
||||
// Common props
|
||||
installedKeys?: string[];
|
||||
oauth?: OAuth | null;
|
||||
translationNamespace?: "layout" | "setting"; // For translation keys
|
||||
}
|
||||
|
||||
export default function IntegrationList({
|
||||
items,
|
||||
variant = "manage", // Default to manage mode for backward compatibility
|
||||
addOption,
|
||||
onShowEnvConfig,
|
||||
showSelect = false,
|
||||
selectPlaceholder = "Select...",
|
||||
selectContent,
|
||||
onSelectChange,
|
||||
showConfigButton = true,
|
||||
showInstallButton = true,
|
||||
onConfigClick,
|
||||
showStatusDot = true,
|
||||
installedKeys = [],
|
||||
oauth,
|
||||
translationNamespace = variant === "select" ? "layout" : "setting",
|
||||
}: IntegrationListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showEnvConfig, setShowEnvConfig] = useState(false);
|
||||
const [activeMcp, setActiveMcp] = useState<any | null>(null);
|
||||
const isSelectMode = variant === "select";
|
||||
|
||||
// Use shared hook for integration management
|
||||
const {
|
||||
installed,
|
||||
fetchInstalled,
|
||||
saveEnvAndConfig,
|
||||
handleUninstall,
|
||||
createMcpFromItem,
|
||||
} = useIntegrationManagement(items);
|
||||
|
||||
// Install handler - different logic for select vs manage mode
|
||||
const handleInstall = useCallback(
|
||||
async (item: IntegrationItem) => {
|
||||
console.log(item);
|
||||
const searchKey = isSelectMode ? "EXA Search" : "Search";
|
||||
|
||||
if (item.key === searchKey) {
|
||||
const mcp = createMcpFromItem(item, 13);
|
||||
if (isSelectMode) {
|
||||
onShowEnvConfig?.(mcp);
|
||||
} else {
|
||||
setActiveMcp(mcp);
|
||||
setShowEnvConfig(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.key === "Google Calendar") {
|
||||
const mcp = createMcpFromItem(item, 14);
|
||||
if (isSelectMode) {
|
||||
onShowEnvConfig?.(mcp);
|
||||
} else {
|
||||
setActiveMcp(mcp);
|
||||
setShowEnvConfig(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (installed[item.key]) return;
|
||||
await item.onInstall();
|
||||
// Only refresh in select mode
|
||||
if (isSelectMode) {
|
||||
await fetchInstalled();
|
||||
}
|
||||
},
|
||||
[
|
||||
installed,
|
||||
createMcpFromItem,
|
||||
isSelectMode,
|
||||
onShowEnvConfig,
|
||||
fetchInstalled,
|
||||
]
|
||||
);
|
||||
|
||||
// onConnect handler - different logic for select vs manage mode
|
||||
const onConnect = async (mcp: any) => {
|
||||
console.log("[IntegrationList onConnect] Starting for", mcp.key);
|
||||
|
||||
// Refresh configs first to get latest state
|
||||
await fetchInstalled();
|
||||
|
||||
// Save all environment variables
|
||||
await Promise.all(
|
||||
Object.keys(mcp.install_command.env).map(async (key) => {
|
||||
return saveEnvAndConfig(mcp.key, key, mcp.install_command.env[key]);
|
||||
})
|
||||
);
|
||||
|
||||
// After saving env vars, handle Google Calendar authorization flow
|
||||
if (mcp.key === "Google Calendar") {
|
||||
console.log(
|
||||
"[IntegrationList onConnect] Google Calendar detected, starting auth flow"
|
||||
);
|
||||
|
||||
// Trigger install/authorization
|
||||
const calendarItem = items.find((item) => item.key === "Google Calendar");
|
||||
try {
|
||||
if (calendarItem && calendarItem.onInstall) {
|
||||
await calendarItem.onInstall();
|
||||
} else {
|
||||
await fetchPost("/install/tool/google_calendar");
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Select mode: poll OAuth status
|
||||
if (isSelectMode) {
|
||||
console.log(
|
||||
"[IntegrationList onConnect] Starting OAuth status polling"
|
||||
);
|
||||
|
||||
const start = Date.now();
|
||||
const timeoutMs = 5 * 60 * 1000; // 5 minutes
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const statusRes: any = await fetchGet(
|
||||
"/oauth/status/google_calendar"
|
||||
);
|
||||
console.log(
|
||||
"[IntegrationList onConnect] OAuth status:",
|
||||
statusRes?.status
|
||||
);
|
||||
|
||||
if (statusRes?.status === "success") {
|
||||
console.log(
|
||||
"[IntegrationList onConnect] Success! Closing dialog"
|
||||
);
|
||||
await fetchInstalled();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
statusRes?.status === "failed" ||
|
||||
statusRes?.status === "cancelled"
|
||||
) {
|
||||
console.log(
|
||||
"[IntegrationList onConnect] Failed/cancelled, keeping dialog open"
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[IntegrationList onConnect] Polling error:", err);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
console.log("[IntegrationList onConnect] Polling timeout");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Select mode: add to tools and close
|
||||
if (isSelectMode && addOption) {
|
||||
console.log(
|
||||
"[IntegrationList onConnect] Non-Google Calendar, closing immediately"
|
||||
);
|
||||
await fetchInstalled();
|
||||
addOption(mcp, true);
|
||||
onClose();
|
||||
} else {
|
||||
// Manage mode: just close
|
||||
await fetchInstalled();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
setShowEnvConfig(false);
|
||||
setActiveMcp(null);
|
||||
};
|
||||
|
||||
const handleOpenConfig = useCallback(
|
||||
(item: IntegrationItem) => {
|
||||
// if external handler provided by parent, use it
|
||||
if (onConfigClick) {
|
||||
onConfigClick(item);
|
||||
return;
|
||||
}
|
||||
// default behavior: if item has env vars, open built-in MCP config dialog
|
||||
if (item?.env_vars && item.env_vars.length > 0) {
|
||||
const mcp = createMcpFromItem(item, -1);
|
||||
setActiveMcp(mcp);
|
||||
setShowEnvConfig(true);
|
||||
}
|
||||
},
|
||||
[onConfigClick, createMcpFromItem]
|
||||
);
|
||||
|
||||
const COMING_SOON_ITEMS = [
|
||||
"X(Twitter)",
|
||||
"WhatsApp",
|
||||
"LinkedIn",
|
||||
"Reddit",
|
||||
"Github",
|
||||
];
|
||||
|
||||
// Determine container and item styles based on variant
|
||||
const containerClassName = isSelectMode
|
||||
? "space-y-3"
|
||||
: "flex flex-col w-full items-start justify-start gap-4";
|
||||
|
||||
const itemClassName = isSelectMode
|
||||
? "cursor-pointer hover:bg-gray-100 px-3 py-2 flex justify-between"
|
||||
: "w-full px-6 py-4 bg-surface-secondary rounded-2xl";
|
||||
|
||||
const titleClassName = isSelectMode
|
||||
? "text-base leading-snug font-bold text-text-action"
|
||||
: "text-label-lg font-bold text-text-heading";
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<MCPEnvDialog
|
||||
showEnvConfig={showEnvConfig}
|
||||
onClose={onClose}
|
||||
onConnect={onConnect}
|
||||
activeMcp={activeMcp}
|
||||
></MCPEnvDialog>
|
||||
{items.map((item) => {
|
||||
const isInstalled = !!installed[item.key];
|
||||
const isComingSoon = COMING_SOON_ITEMS.includes(item.name);
|
||||
|
||||
return (
|
||||
<div key={item.key} className=" w-full">
|
||||
<div
|
||||
className={itemClassName}
|
||||
onClick={
|
||||
isSelectMode
|
||||
? () => {
|
||||
if (!isComingSoon) {
|
||||
if (item.env_vars.length === 0 || isInstalled) {
|
||||
// Ensure toolkit field is passed and normalized for known cases
|
||||
const normalizedToolkit =
|
||||
item.name === "Notion"
|
||||
? "notion_mcp_toolkit"
|
||||
: item.toolkit;
|
||||
addOption?.(
|
||||
{ ...item, toolkit: normalizedToolkit },
|
||||
true
|
||||
);
|
||||
} else {
|
||||
handleInstall(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isSelectMode ? (
|
||||
<div className="flex items-center gap-xs">
|
||||
{(isSelectMode || showStatusDot) && (
|
||||
<img
|
||||
src={ellipseIcon}
|
||||
alt="icon"
|
||||
className="w-3 h-3 mr-2"
|
||||
style={{
|
||||
filter: isInstalled
|
||||
? "grayscale(0%) brightness(0) saturate(100%) invert(41%) sepia(99%) saturate(749%) hue-rotate(81deg) brightness(95%) contrast(92%)"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={titleClassName}>{item.name}</div>
|
||||
<div className="flex items-center">
|
||||
<TooltipSimple content={item.desc}>
|
||||
<CircleAlert className="w-4 h-4 text-icon-secondary" />
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row w-full items-center justify-between gap-xs">
|
||||
<div className="flex flex-row items-center gap-xs">
|
||||
{showStatusDot && (
|
||||
<img
|
||||
src={ellipseIcon}
|
||||
alt="icon"
|
||||
className="w-3 h-3 mr-2"
|
||||
style={{
|
||||
filter: isInstalled
|
||||
? "grayscale(0%) brightness(0) saturate(100%) invert(41%) sepia(99%) saturate(749%) hue-rotate(81deg) brightness(95%) contrast(92%)"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={titleClassName}>{item.name}</div>
|
||||
<div className="flex items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlert className="w-4 h-4 text-icon-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div>{item.desc}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-md">
|
||||
{showConfigButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleOpenConfig(item);
|
||||
}}
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
{t("setting.setting")}
|
||||
</Button>
|
||||
)}
|
||||
{showInstallButton && (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isComingSoon}
|
||||
variant={
|
||||
isComingSoon
|
||||
? "ghost"
|
||||
: isInstalled
|
||||
? "outline"
|
||||
: "primary"
|
||||
}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return isInstalled
|
||||
? handleUninstall(item)
|
||||
: handleInstall(item);
|
||||
}}
|
||||
>
|
||||
{isComingSoon
|
||||
? t(`${translationNamespace}.coming-soon`)
|
||||
: isInstalled
|
||||
? t(`${translationNamespace}.uninstall`)
|
||||
: t(`${translationNamespace}.install`)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isSelectMode && item.env_vars.length !== 0 && (
|
||||
<Button
|
||||
disabled={isComingSoon}
|
||||
variant={isInstalled ? "secondary" : "primary"}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
return isInstalled
|
||||
? handleUninstall(item)
|
||||
: handleInstall(item);
|
||||
}}
|
||||
>
|
||||
{isComingSoon
|
||||
? t(`${translationNamespace}.coming-soon`)
|
||||
: isInstalled
|
||||
? t(`${translationNamespace}.uninstall`)
|
||||
: t(`${translationNamespace}.install`)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isSelectMode && showSelect && (
|
||||
<div className="flex flex-row w-full items-center gap-md mt-6 pt-6 border-b-0 border-x-0 border-solid border-border-secondary">
|
||||
<div className="flex flex-row w-full items-center justify-between gap-md">
|
||||
<div className="text-body-md text-text-body">
|
||||
{" "}
|
||||
Default {item.name}
|
||||
</div>
|
||||
<div className="flex-1 max-w-[300px]">
|
||||
<Select onValueChange={(v) => onSelectChange?.(v, item)}>
|
||||
<SelectTrigger size="default">
|
||||
<SelectValue placeholder={selectPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-100">
|
||||
{selectContent ?? (
|
||||
<>
|
||||
<SelectItem value="more">
|
||||
More integrations
|
||||
</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
294
src/hooks/useIntegrationManagement.ts
Normal file
294
src/hooks/useIntegrationManagement.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
proxyFetchGet,
|
||||
proxyFetchPost,
|
||||
proxyFetchPut,
|
||||
proxyFetchDelete,
|
||||
fetchDelete,
|
||||
} from "@/api/http";
|
||||
import { capitalizeFirstLetter } from "@/lib";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
export interface IntegrationItem {
|
||||
key: string;
|
||||
name: string;
|
||||
desc: string | React.ReactNode;
|
||||
env_vars: string[];
|
||||
toolkit?: string;
|
||||
onInstall: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing integration configurations, OAuth, and installation state
|
||||
*/
|
||||
export function useIntegrationManagement(items: IntegrationItem[]) {
|
||||
const { email, checkAgentTool } = useAuthStore();
|
||||
|
||||
// Local installed status
|
||||
const [installed, setInstalled] = useState<{ [key: string]: boolean }>({});
|
||||
// Configs cache
|
||||
const [configs, setConfigs] = useState<any[]>([]);
|
||||
// Lock to prevent concurrent OAuth processing
|
||||
const isLockedRef = useRef(false);
|
||||
// Cache OAuth event when items are not ready
|
||||
const pendingOauthEventRef = useRef<{
|
||||
provider: string;
|
||||
code: string;
|
||||
} | null>(null);
|
||||
const [callBackUrl, setCallBackUrl] = useState<string | null>(null);
|
||||
|
||||
// Fetch installed configs
|
||||
const fetchInstalled = useCallback(async (ignore: boolean = false) => {
|
||||
try {
|
||||
const configsRes = await proxyFetchGet("/api/configs");
|
||||
if (!ignore) {
|
||||
setConfigs(Array.isArray(configsRes) ? configsRes : []);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!ignore) setConfigs([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch configs when mounted
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
fetchInstalled(ignore);
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [fetchInstalled]);
|
||||
|
||||
// Recalculate installed status when items or configs change
|
||||
useEffect(() => {
|
||||
const map: { [key: string]: boolean } = {};
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.key === "Google Calendar") {
|
||||
// Only mark installed when refresh token is present (auth completed)
|
||||
const hasRefreshToken = configs.some(
|
||||
(c: any) =>
|
||||
c.config_group?.toLowerCase() === "google calendar" &&
|
||||
c.config_name === "GOOGLE_REFRESH_TOKEN" &&
|
||||
c.config_value && String(c.config_value).length > 0
|
||||
);
|
||||
map[item.key] = hasRefreshToken;
|
||||
} else {
|
||||
// For other integrations, use config_group presence
|
||||
const hasConfig = configs.some(
|
||||
(c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase()
|
||||
);
|
||||
map[item.key] = hasConfig;
|
||||
}
|
||||
});
|
||||
|
||||
setInstalled(map);
|
||||
}, [items, configs]);
|
||||
|
||||
// Save environment variable and config
|
||||
const saveEnvAndConfig = useCallback(async (
|
||||
provider: string,
|
||||
envVarKey: string,
|
||||
value: string
|
||||
) => {
|
||||
const configPayload = {
|
||||
// Keep exact group name to satisfy backend whitelist
|
||||
config_group: provider,
|
||||
config_name: envVarKey,
|
||||
config_value: value,
|
||||
};
|
||||
|
||||
// Fetch latest configs to avoid stale state when deciding POST/PUT
|
||||
let latestConfigs: any[] = Array.isArray(configs) ? configs : [];
|
||||
try {
|
||||
const fresh = await proxyFetchGet("/api/configs");
|
||||
if (Array.isArray(fresh)) latestConfigs = fresh;
|
||||
} catch {}
|
||||
|
||||
// Backend uniqueness is by config_name for a user
|
||||
let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey);
|
||||
|
||||
if (existingConfig) {
|
||||
await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload);
|
||||
} else {
|
||||
const res = await proxyFetchPost("/api/configs", configPayload);
|
||||
if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) {
|
||||
try {
|
||||
const again = await proxyFetchGet("/api/configs");
|
||||
const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null;
|
||||
if (found) {
|
||||
await proxyFetchPut(`/api/configs/${found.id}`, configPayload);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (window.electronAPI?.envWrite) {
|
||||
await window.electronAPI.envWrite(email, { key: envVarKey, value });
|
||||
}
|
||||
}, [configs, email]);
|
||||
|
||||
// Process OAuth callback
|
||||
const processOauth = useCallback(
|
||||
async (data: { provider: string; code: string }) => {
|
||||
if (isLockedRef.current) return;
|
||||
if (!items || items.length === 0) {
|
||||
// Items not ready, cache event, wait for items to have value
|
||||
pendingOauthEventRef.current = data;
|
||||
console.warn("items is empty, cache oauth event", data);
|
||||
return;
|
||||
}
|
||||
const provider = data.provider.toLowerCase();
|
||||
isLockedRef.current = true;
|
||||
try {
|
||||
const tokenResult = await proxyFetchPost(
|
||||
`/api/oauth/${provider}/token`,
|
||||
{ code: data.code }
|
||||
);
|
||||
setInstalled((prev) => ({
|
||||
...prev,
|
||||
[capitalizeFirstLetter(provider)]: true,
|
||||
}));
|
||||
const currentItem = items.find(
|
||||
(item) => item.key.toLowerCase() === provider
|
||||
);
|
||||
if (provider === "slack") {
|
||||
if (
|
||||
tokenResult.access_token &&
|
||||
currentItem &&
|
||||
currentItem.env_vars &&
|
||||
currentItem.env_vars.length > 0
|
||||
) {
|
||||
const envVarKey = currentItem.env_vars[0];
|
||||
await saveEnvAndConfig(
|
||||
provider,
|
||||
envVarKey,
|
||||
tokenResult.access_token
|
||||
);
|
||||
await fetchInstalled();
|
||||
console.log(
|
||||
"Slack authorization successful and configuration saved!"
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"Slack authorization successful, but access_token not found or env configuration not found"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log(`${data.provider} authorization failed: ${e.message || e}`);
|
||||
} finally {
|
||||
isLockedRef.current = false;
|
||||
}
|
||||
},
|
||||
[items, saveEnvAndConfig, fetchInstalled]
|
||||
);
|
||||
|
||||
// Listen to main process OAuth authorization callback
|
||||
useEffect(() => {
|
||||
const handler = (_event: any, data: { provider: string; code: string }) => {
|
||||
if (!data.provider || !data.code) return;
|
||||
processOauth(data);
|
||||
};
|
||||
window.ipcRenderer?.on("oauth-authorized", handler);
|
||||
return () => {
|
||||
window.ipcRenderer?.off("oauth-authorized", handler);
|
||||
};
|
||||
}, [processOauth]);
|
||||
|
||||
// Listen to OAuth callback URL notification
|
||||
useEffect(() => {
|
||||
const handler = (_event: any, data: { url: string; provider: string }) => {
|
||||
if (data.url && data.provider) {
|
||||
setCallBackUrl(data.url);
|
||||
}
|
||||
};
|
||||
window.ipcRenderer?.on("oauth-callback-url", handler);
|
||||
return () => {
|
||||
window.ipcRenderer?.off("oauth-callback-url", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Process cached OAuth event when items are ready
|
||||
useEffect(() => {
|
||||
if (items && items.length > 0 && pendingOauthEventRef.current) {
|
||||
processOauth(pendingOauthEventRef.current);
|
||||
pendingOauthEventRef.current = null;
|
||||
}
|
||||
}, [items, processOauth]);
|
||||
|
||||
// Uninstall integration
|
||||
const handleUninstall = useCallback(
|
||||
async (item: IntegrationItem) => {
|
||||
checkAgentTool(item.key);
|
||||
const groupKey = item.key.toLowerCase();
|
||||
const toDelete = configs.filter(
|
||||
(c: any) => c.config_group && c.config_group.toLowerCase() === groupKey
|
||||
);
|
||||
for (const config of toDelete) {
|
||||
try {
|
||||
await proxyFetchDelete(`/api/configs/${config.id}`);
|
||||
// Delete env
|
||||
if (
|
||||
item.env_vars &&
|
||||
item.env_vars.length > 0 &&
|
||||
window.electronAPI?.envRemove
|
||||
) {
|
||||
await window.electronAPI.envRemove(email, item.env_vars[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up authentication tokens for Google Calendar and Notion
|
||||
if (item.key === "Google Calendar") {
|
||||
try {
|
||||
await fetchDelete("/uninstall/tool/google_calendar");
|
||||
console.log("Cleaned up Google Calendar authentication tokens");
|
||||
} catch (e) {
|
||||
console.log("Failed to clean up Google Calendar tokens:", e);
|
||||
}
|
||||
} else if (item.key === "Notion") {
|
||||
try {
|
||||
await fetchDelete("/uninstall/tool/notion");
|
||||
console.log("Cleaned up Notion authentication tokens");
|
||||
} catch (e) {
|
||||
console.log("Failed to clean up Notion tokens:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update configs after deletion
|
||||
setConfigs((prev) =>
|
||||
prev.filter((c: any) => c.config_group?.toLowerCase() !== groupKey)
|
||||
);
|
||||
},
|
||||
[configs, email, checkAgentTool]
|
||||
);
|
||||
|
||||
// Helper to create MCP object from integration item
|
||||
const createMcpFromItem = useCallback((item: IntegrationItem, id: number) => {
|
||||
const mcp = {
|
||||
name: item.name,
|
||||
key: item.key,
|
||||
install_command: {
|
||||
env: {} as any,
|
||||
},
|
||||
id,
|
||||
};
|
||||
item.env_vars.forEach((key) => {
|
||||
mcp.install_command.env[key] = "";
|
||||
});
|
||||
return mcp;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
installed,
|
||||
configs,
|
||||
callBackUrl,
|
||||
fetchInstalled,
|
||||
saveEnvAndConfig,
|
||||
handleUninstall,
|
||||
createMcpFromItem,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { ChevronDown, ChevronUp, Plus, Store, ChevronLeft } from "lucide-react";
|
||||
import SearchInput from "@/components/SearchInput";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import IntegrationList from "./components/IntegrationList";
|
||||
import IntegrationList from "@/components/IntegrationList";
|
||||
import { getProxyBaseURL } from "@/lib";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -654,6 +654,7 @@ export default function SettingMCP() {
|
|||
<>
|
||||
<div className="flex-1 w-full">
|
||||
<IntegrationList
|
||||
variant="manage"
|
||||
items={essentialIntegrations}
|
||||
showConfigButton={true}
|
||||
showInstallButton={false}
|
||||
|
|
@ -703,6 +704,7 @@ export default function SettingMCP() {
|
|||
{!collapsedMCP && (
|
||||
<IntegrationList
|
||||
key={refreshKey}
|
||||
variant="manage"
|
||||
items={integrations}
|
||||
showConfigButton={false}
|
||||
showInstallButton={true}
|
||||
|
|
@ -759,43 +761,43 @@ export default function SettingMCP() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
<MCPConfigDialog
|
||||
open={!!showConfig}
|
||||
form={configForm}
|
||||
mcp={showConfig}
|
||||
onChange={setConfigForm as any}
|
||||
onSave={handleConfigSave}
|
||||
onClose={handleConfigClose}
|
||||
loading={saving}
|
||||
errorMsg={errorMsg}
|
||||
onSwitchStatus={handleConfigSwitch}
|
||||
/>
|
||||
<MCPAddDialog
|
||||
open={showAdd}
|
||||
addType={addType}
|
||||
setAddType={setAddType}
|
||||
localJson={localJson}
|
||||
setLocalJson={setLocalJson}
|
||||
remoteName={remoteName}
|
||||
setRemoteName={setRemoteName}
|
||||
remoteUrl={remoteUrl}
|
||||
setRemoteUrl={setRemoteUrl}
|
||||
installing={installing}
|
||||
onClose={() => setShowAdd(false)}
|
||||
onInstall={handleInstall}
|
||||
/>
|
||||
<MCPDeleteDialog
|
||||
open={!!deleteTarget}
|
||||
target={deleteTarget}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
loading={deleting}
|
||||
/>
|
||||
<SearchEngineConfigDialog
|
||||
open={showSearchEngineConfig}
|
||||
onClose={() => setShowSearchEngineConfig(false)}
|
||||
/>
|
||||
</div>
|
||||
<MCPConfigDialog
|
||||
open={!!showConfig}
|
||||
form={configForm}
|
||||
mcp={showConfig}
|
||||
onChange={setConfigForm as any}
|
||||
onSave={handleConfigSave}
|
||||
onClose={handleConfigClose}
|
||||
loading={saving}
|
||||
errorMsg={errorMsg}
|
||||
onSwitchStatus={handleConfigSwitch}
|
||||
/>
|
||||
<MCPAddDialog
|
||||
open={showAdd}
|
||||
addType={addType}
|
||||
setAddType={setAddType}
|
||||
localJson={localJson}
|
||||
setLocalJson={setLocalJson}
|
||||
remoteName={remoteName}
|
||||
setRemoteName={setRemoteName}
|
||||
remoteUrl={remoteUrl}
|
||||
setRemoteUrl={setRemoteUrl}
|
||||
installing={installing}
|
||||
onClose={() => setShowAdd(false)}
|
||||
onInstall={handleInstall}
|
||||
/>
|
||||
<MCPDeleteDialog
|
||||
open={!!deleteTarget}
|
||||
target={deleteTarget}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
loading={deleting}
|
||||
/>
|
||||
<SearchEngineConfigDialog
|
||||
open={showSearchEngineConfig}
|
||||
onClose={() => setShowSearchEngineConfig(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,552 +0,0 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CircleAlert, Settings2, Check } from "lucide-react";
|
||||
import {
|
||||
proxyFetchGet,
|
||||
proxyFetchPost,
|
||||
proxyFetchPut,
|
||||
proxyFetchDelete,
|
||||
fetchDelete,
|
||||
} from "@/api/http";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import ellipseIcon from "@/assets/mcp/Ellipse-25.svg";
|
||||
import { capitalizeFirstLetter } from "@/lib";
|
||||
import { MCPEnvDialog } from "./MCPEnvDialog";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { OAuth } from "@/lib/oauth";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DropdownMenu, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemWithButton,
|
||||
} from "@/components/ui/select";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
interface IntegrationItem {
|
||||
key: string;
|
||||
name: string;
|
||||
desc: string | React.ReactNode;
|
||||
env_vars: string[];
|
||||
onInstall: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
interface IntegrationListProps {
|
||||
items: IntegrationItem[];
|
||||
installedKeys?: string[];
|
||||
oauth?: OAuth;
|
||||
// optional select beside each item for importing options
|
||||
showSelect?: boolean; // default hidden
|
||||
selectPlaceholder?: string; // placeholder text
|
||||
selectContent?: React.ReactNode; // custom content for SelectContent (e.g., list of <SelectItem />)
|
||||
onSelectChange?: (value: string, item: IntegrationItem) => void; // callback when select changes
|
||||
// button group options
|
||||
showConfigButton?: boolean; // whether to show the config button (default: true)
|
||||
showInstallButton?: boolean; // whether to show the install/uninstall button (default: true)
|
||||
onConfigClick?: (item: IntegrationItem) => void; // optional external handler to open a popup
|
||||
// status dot icon (ellipse) visibility
|
||||
showStatusDot?: boolean; // default: true
|
||||
}
|
||||
|
||||
export default function IntegrationList({
|
||||
items,
|
||||
showSelect = false,
|
||||
selectPlaceholder = "Select...",
|
||||
selectContent,
|
||||
onSelectChange,
|
||||
showConfigButton = true,
|
||||
showInstallButton = true,
|
||||
onConfigClick,
|
||||
showStatusDot = true,
|
||||
}: IntegrationListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showEnvConfig, setShowEnvConfig] = useState(false);
|
||||
const [activeMcp, setActiveMcp] = useState<any | null>(null);
|
||||
const { email, checkAgentTool } = useAuthStore();
|
||||
const [callBackUrl, setCallBackUrl] = useState<string | null>(null);
|
||||
|
||||
// local installed status
|
||||
const [installed, setInstalled] = useState<{ [key: string]: boolean }>({});
|
||||
// configs cache
|
||||
const [configs, setConfigs] = useState<any[]>([]);
|
||||
// 1. add useRef lock
|
||||
const isLockedRef = useRef(false);
|
||||
// 2. add ref to cache oauth event
|
||||
const pendingOauthEventRef = useRef<{
|
||||
provider: string;
|
||||
code: string;
|
||||
} | null>(null);
|
||||
|
||||
async function fetchInstalled(ignore: boolean = false) {
|
||||
try {
|
||||
const configsRes = await proxyFetchGet("/api/configs");
|
||||
if (!ignore) {
|
||||
setConfigs(Array.isArray(configsRes) ? configsRes : []);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!ignore) setConfigs([]);
|
||||
}
|
||||
}
|
||||
// 3. fetch configs when mounted
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
fetchInstalled();
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// items or configs change, recalculate installed
|
||||
useEffect(() => {
|
||||
// construct installed map
|
||||
const map: { [key: string]: boolean } = {};
|
||||
items.forEach((item) => {
|
||||
if (item.key === "Google Calendar") {
|
||||
// Only mark installed after refresh token exists
|
||||
const hasRefreshToken = configs.some(
|
||||
(c: any) =>
|
||||
c.config_group?.toLowerCase() === "google calendar" &&
|
||||
c.config_name === "GOOGLE_REFRESH_TOKEN" &&
|
||||
c.config_value && String(c.config_value).length > 0
|
||||
);
|
||||
map[item.key] = hasRefreshToken;
|
||||
} else {
|
||||
// For other integrations, use presence of any config in the group
|
||||
const hasConfig = configs.some(
|
||||
(c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase()
|
||||
);
|
||||
map[item.key] = hasConfig;
|
||||
}
|
||||
});
|
||||
setInstalled(map);
|
||||
}, [items, configs]);
|
||||
|
||||
// public save env/config logic
|
||||
const saveEnvAndConfig = async (
|
||||
provider: string,
|
||||
envVarKey: string,
|
||||
value: string
|
||||
) => {
|
||||
const configPayload = {
|
||||
// Use exact group name, do not transform case to avoid whitelist mismatch
|
||||
config_group: provider,
|
||||
config_name: envVarKey,
|
||||
config_value: value,
|
||||
};
|
||||
|
||||
// Fetch latest configs to avoid stale state when deciding POST/PUT
|
||||
let latestConfigs: any[] = Array.isArray(configs) ? configs : [];
|
||||
try {
|
||||
const fresh = await proxyFetchGet("/api/configs");
|
||||
if (Array.isArray(fresh)) latestConfigs = fresh;
|
||||
} catch {}
|
||||
|
||||
// Check if config already exists (by name, regardless of group - backend uniqueness is by name)
|
||||
let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey);
|
||||
|
||||
if (existingConfig) {
|
||||
await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload);
|
||||
} else {
|
||||
const res = await proxyFetchPost("/api/configs", configPayload);
|
||||
// If backend says it already exists (race), switch to PUT
|
||||
if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) {
|
||||
try {
|
||||
const again = await proxyFetchGet("/api/configs");
|
||||
const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null;
|
||||
if (found) {
|
||||
await proxyFetchPut(`/api/configs/${found.id}`, configPayload);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (window.electronAPI?.envWrite) {
|
||||
await window.electronAPI.envWrite(email, { key: envVarKey, value });
|
||||
}
|
||||
};
|
||||
|
||||
// wrap with useCallback, ensure processOauth can get the latest items and oauth when items change
|
||||
const processOauth = useCallback(
|
||||
async (data: { provider: string; code: string }) => {
|
||||
if (isLockedRef.current) return;
|
||||
console.log("items", items);
|
||||
if (!items || items.length === 0) {
|
||||
// items are not ready, cache event, wait for items to have value
|
||||
pendingOauthEventRef.current = data;
|
||||
console.warn("items are empty, cache oauth event", data);
|
||||
return;
|
||||
}
|
||||
const provider = data.provider.toLowerCase();
|
||||
isLockedRef.current = true;
|
||||
|
||||
try {
|
||||
const tokenResult = await proxyFetchPost(
|
||||
`/api/oauth/${provider}/token`,
|
||||
{ code: data.code }
|
||||
);
|
||||
setInstalled((prev) => ({
|
||||
...prev,
|
||||
[capitalizeFirstLetter(provider)]: true,
|
||||
}));
|
||||
const currentItem = items.find(
|
||||
(item) => item.key.toLowerCase() === provider
|
||||
);
|
||||
console.log("provider", provider);
|
||||
console.log("items", items);
|
||||
if (provider === "slack") {
|
||||
if (
|
||||
tokenResult.access_token &&
|
||||
currentItem &&
|
||||
currentItem.env_vars &&
|
||||
currentItem.env_vars.length > 0
|
||||
) {
|
||||
const envVarKey = currentItem.env_vars[0];
|
||||
await saveEnvAndConfig(
|
||||
provider,
|
||||
envVarKey,
|
||||
tokenResult.access_token
|
||||
);
|
||||
fetchInstalled();
|
||||
console.log(
|
||||
"Slack authorization successful and configuration saved!"
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"Slack authorization successful, but access_token not found or env configuration not found"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// other provider authorization successful, can be extended
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log(`${data.provider} authorization failed: ${e.message || e}`);
|
||||
} finally {
|
||||
isLockedRef.current = false;
|
||||
}
|
||||
},
|
||||
[items, callBackUrl] // add oauth to dependencies
|
||||
);
|
||||
|
||||
// listen to main process oauth authorization callback, automatically mark as installed and get token
|
||||
useEffect(() => {
|
||||
const handler = (_event: any, data: { provider: string; code: string }) => {
|
||||
if (!data.provider || !data.code) return;
|
||||
processOauth(data);
|
||||
};
|
||||
window.ipcRenderer?.on("oauth-authorized", handler);
|
||||
return () => {
|
||||
window.ipcRenderer?.off("oauth-authorized", handler);
|
||||
};
|
||||
}, [processOauth]);
|
||||
|
||||
// listen to oauth callback URL notification
|
||||
useEffect(() => {
|
||||
const handler = (_event: any, data: { url: string; provider: string }) => {
|
||||
console.log("Received OAuth callback URL:", data);
|
||||
|
||||
if (data.url && data.provider) {
|
||||
console.log(`${data.provider} OAuth callback URL: ${data.url}`);
|
||||
setCallBackUrl(data.url);
|
||||
// Add user prompt or other processing logic here
|
||||
}
|
||||
};
|
||||
window.ipcRenderer?.on("oauth-callback-url", handler);
|
||||
return () => {
|
||||
window.ipcRenderer?.off("oauth-callback-url", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// as long as oauth changes and there is a cached event, process it
|
||||
useEffect(() => {
|
||||
if (pendingOauthEventRef.current) {
|
||||
processOauth(pendingOauthEventRef.current);
|
||||
pendingOauthEventRef.current = null;
|
||||
}
|
||||
}, [processOauth]);
|
||||
|
||||
// install/uninstall
|
||||
const handleInstall = useCallback(
|
||||
async (item: IntegrationItem) => {
|
||||
console.log(item);
|
||||
if (item.key === "Search") {
|
||||
let mcp = {
|
||||
name: "Search",
|
||||
key: "Search",
|
||||
install_command: {
|
||||
env: {} as any,
|
||||
},
|
||||
id: 13,
|
||||
};
|
||||
item.env_vars.map((key) => {
|
||||
mcp.install_command.env[key] = "";
|
||||
});
|
||||
setActiveMcp(mcp);
|
||||
setShowEnvConfig(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.key === "Google Calendar") {
|
||||
let mcp = {
|
||||
name: "Google Calendar",
|
||||
key: "Google Calendar",
|
||||
install_command: {
|
||||
env: {} as any,
|
||||
},
|
||||
id: 14,
|
||||
};
|
||||
item.env_vars.map((key) => {
|
||||
mcp.install_command.env[key] = "";
|
||||
});
|
||||
setActiveMcp(mcp);
|
||||
setShowEnvConfig(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (installed[item.key]) return;
|
||||
await item.onInstall();
|
||||
},
|
||||
[installed]
|
||||
);
|
||||
|
||||
const onConnect = async (mcp: any) => {
|
||||
// Refresh configs first to get latest state
|
||||
await fetchInstalled();
|
||||
|
||||
// Save all environment variables
|
||||
await Promise.all(
|
||||
Object.keys(mcp.install_command.env).map((key) => {
|
||||
return saveEnvAndConfig(mcp.key, key, mcp.install_command.env[key]);
|
||||
})
|
||||
);
|
||||
|
||||
// After saving env vars, trigger installation/instantiation for Google Calendar
|
||||
if (mcp.key === "Google Calendar") {
|
||||
const calendarItem = items.find(item => item.key === "Google Calendar");
|
||||
if (calendarItem && calendarItem.onInstall) {
|
||||
await calendarItem.onInstall();
|
||||
}
|
||||
}
|
||||
|
||||
await fetchInstalled();
|
||||
onClose();
|
||||
};
|
||||
const onClose = () => {
|
||||
setShowEnvConfig(false);
|
||||
setActiveMcp(null);
|
||||
};
|
||||
|
||||
const handleOpenConfig = useCallback((item: IntegrationItem) => {
|
||||
// if external handler provided by parent, use it
|
||||
if (onConfigClick) {
|
||||
onConfigClick(item);
|
||||
return;
|
||||
}
|
||||
// default behavior: if item has env vars, open built-in MCP config dialog
|
||||
if (item?.env_vars && item.env_vars.length > 0) {
|
||||
const mcp = {
|
||||
name: item.name,
|
||||
key: item.key,
|
||||
install_command: {
|
||||
env: {} as any,
|
||||
},
|
||||
id: -1,
|
||||
};
|
||||
item.env_vars.forEach((key) => {
|
||||
(mcp.install_command.env as any)[key] = "";
|
||||
});
|
||||
setActiveMcp(mcp);
|
||||
setShowEnvConfig(true);
|
||||
}
|
||||
}, [onConfigClick]);
|
||||
|
||||
// uninstall logic
|
||||
const handleUninstall = useCallback(
|
||||
async (item: IntegrationItem) => {
|
||||
checkAgentTool(item.key);
|
||||
// find all configs that match config_group, delete one by one
|
||||
const groupKey = item.key.toLowerCase();
|
||||
const toDelete = configs.filter(
|
||||
(c: any) => c.config_group && c.config_group.toLowerCase() === groupKey
|
||||
);
|
||||
console.log("toDelete", toDelete);
|
||||
for (const config of toDelete) {
|
||||
try {
|
||||
await proxyFetchDelete(`/api/configs/${config.id}`);
|
||||
console.log("envRemove", email, item.env_vars[0]);
|
||||
|
||||
// delete env
|
||||
if (
|
||||
item.env_vars &&
|
||||
item.env_vars.length > 0 &&
|
||||
window.electronAPI?.envRemove
|
||||
) {
|
||||
|
||||
await window.electronAPI.envRemove(email, item.env_vars[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("envRemove error", e);
|
||||
// ignore error
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up authentication tokens for Google Calendar and Notion
|
||||
if (item.key === "Google Calendar") {
|
||||
try {
|
||||
await fetchDelete("/uninstall/tool/google_calendar");
|
||||
console.log("Cleaned up Google Calendar authentication tokens");
|
||||
} catch (e) {
|
||||
console.log("Failed to clean up Google Calendar tokens:", e);
|
||||
}
|
||||
} else if (item.key === "Notion") {
|
||||
try {
|
||||
await fetchDelete("/uninstall/tool/notion");
|
||||
console.log("Cleaned up Notion authentication tokens");
|
||||
} catch (e) {
|
||||
console.log("Failed to clean up Notion tokens:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// after deletion, refresh configs
|
||||
setConfigs((prev) =>
|
||||
prev.filter((c: any) => c.config_group?.toLowerCase() !== groupKey)
|
||||
);
|
||||
},
|
||||
[configs]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full items-start justify-start gap-4">
|
||||
<MCPEnvDialog
|
||||
showEnvConfig={showEnvConfig}
|
||||
onClose={onClose}
|
||||
onConnect={onConnect}
|
||||
activeMcp={activeMcp}
|
||||
></MCPEnvDialog>
|
||||
{items.map((item) => {
|
||||
const isInstalled = !!installed[item.key];
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="w-full px-6 py-4 bg-surface-secondary rounded-2xl flex flex-col items-center justify-between"
|
||||
>
|
||||
<div className="flex flex-row w-full items-center gap-xs">
|
||||
<div className="flex flex-row w-full items-center gap-xs">
|
||||
{showStatusDot && (
|
||||
<img
|
||||
src={ellipseIcon}
|
||||
alt="icon"
|
||||
className="w-3 h-3 mr-2"
|
||||
style={{
|
||||
filter: isInstalled
|
||||
? "grayscale(0%) brightness(0) saturate(100%) invert(41%) sepia(99%) saturate(749%) hue-rotate(81deg) brightness(95%) contrast(92%)"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="text-label-lg font-bold text-text-heading">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlert className="w-4 h-4 text-icon-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div>{item.desc}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-md">
|
||||
{showConfigButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleOpenConfig(item);
|
||||
}}
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
{t("setting.setting")}
|
||||
</Button>
|
||||
)}
|
||||
{showInstallButton && (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={[
|
||||
"X(Twitter)",
|
||||
"WhatsApp",
|
||||
"LinkedIn",
|
||||
"Reddit",
|
||||
"Github",
|
||||
].includes(item.name)}
|
||||
variant={[
|
||||
"X(Twitter)",
|
||||
"WhatsApp",
|
||||
"LinkedIn",
|
||||
"Reddit",
|
||||
"Github",
|
||||
].includes(item.name) ? "ghost" : (isInstalled ? "outline" : "primary")}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return isInstalled ? handleUninstall(item) : handleInstall(item);
|
||||
}}
|
||||
>
|
||||
{[
|
||||
"X(Twitter)",
|
||||
"WhatsApp",
|
||||
"LinkedIn",
|
||||
"Reddit",
|
||||
"Github",
|
||||
].includes(item.name)
|
||||
? t("setting.coming-soon")
|
||||
: isInstalled
|
||||
? t("setting.uninstall")
|
||||
: t("setting.install")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSelect && (
|
||||
<div className="flex flex-row w-full items-center gap-md mt-6 pt-6 border-b-0 border-x-0 border-solid border-border-secondary">
|
||||
<div className="flex flex-row w-full items-center justify-between gap-md">
|
||||
<div className="text-body-md text-text-body"> Default {item.name}</div>
|
||||
<div className="flex-1 max-w-[300px]">
|
||||
<Select onValueChange={(v) => onSelectChange?.(v, item)}>
|
||||
<SelectTrigger size="default">
|
||||
<SelectValue placeholder={selectPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-100">
|
||||
{selectContent ?? (
|
||||
<>
|
||||
<SelectItem value="more">More integrations</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue