mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
1539 lines
61 KiB
TypeScript
1539 lines
61 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
type SyntheticEvent,
|
|
} from 'react';
|
|
import type { ConnectorConnectResponse, ConnectorDetail, ConnectorStatusResponse } from '@open-design/contracts';
|
|
import { useT } from '../i18n';
|
|
import type { Dict } from '../i18n/types';
|
|
import {
|
|
cancelConnectorAuthorization as cancelConnectorAuthorizationRequest,
|
|
connectConnector,
|
|
disconnectConnector,
|
|
fetchConnectorDetail,
|
|
fetchConnectorDiscovery,
|
|
fetchConnectors,
|
|
fetchConnectorStatuses,
|
|
openExternalUrl,
|
|
} from '../providers/registry';
|
|
import {
|
|
isTrustedConnectorCallbackOrigin,
|
|
sortConnectorsForSearch,
|
|
} from './EntryView';
|
|
import { ConnectorLogo, useResolvedTheme } from './ConnectorLogo';
|
|
import { Icon } from './Icon';
|
|
import { CenteredLoader } from './Loading';
|
|
|
|
const CONNECTOR_CALLBACK_MESSAGE_TYPE = 'open-design:connector-connected';
|
|
const CONNECTOR_AUTH_PENDING_STORAGE_KEY = 'od-connectors-authorization-pending';
|
|
const CONNECTOR_AUTH_PENDING_POLL_MS = 2_000;
|
|
const CONNECTOR_TOOL_PREVIEW_LIMIT = 50;
|
|
const AUTHORIZATION_CANCEL_FAILED_MESSAGE = "Couldn't cancel authorization. Try again.";
|
|
const CONNECTOR_AUTH_CONTINUE_LABEL = 'Continue in browser';
|
|
|
|
interface ConnectorAuthorizationPending {
|
|
expiresAt?: string;
|
|
redirectUrl?: string;
|
|
}
|
|
|
|
type ConnectorAuthorizationPendingState = Record<string, ConnectorAuthorizationPending>;
|
|
|
|
function mergeConnectors(current: ConnectorDetail[], incoming: ConnectorDetail[]): ConnectorDetail[] {
|
|
if (current.length === 0) return incoming;
|
|
const incomingById = new Map(incoming.map((connector) => [connector.id, connector]));
|
|
const merged = current.map((connector) => {
|
|
const next = incomingById.get(connector.id);
|
|
if (!next) return connector;
|
|
return {
|
|
...connector,
|
|
...next,
|
|
tools: next.tools.length > 0 ? next.tools : connector.tools,
|
|
toolCount: next.toolCount ?? connector.toolCount,
|
|
toolsNextCursor: next.toolsNextCursor ?? connector.toolsNextCursor,
|
|
toolsHasMore: next.toolsHasMore ?? connector.toolsHasMore,
|
|
};
|
|
});
|
|
const currentIds = new Set(current.map((connector) => connector.id));
|
|
for (const connector of incoming) {
|
|
if (!currentIds.has(connector.id)) merged.push(connector);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function loadConnectorAuthorizationPending(): ConnectorAuthorizationPendingState {
|
|
if (typeof window === 'undefined') return {};
|
|
try {
|
|
const raw = window.sessionStorage.getItem(CONNECTOR_AUTH_PENDING_STORAGE_KEY);
|
|
if (!raw) return {};
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
const pending: ConnectorAuthorizationPendingState = {};
|
|
for (const [connectorId, state] of Object.entries(parsed as Record<string, unknown>)) {
|
|
if (!connectorId) continue;
|
|
if (state && typeof state === 'object' && !Array.isArray(state)) {
|
|
const expiresAt = (state as Record<string, unknown>).expiresAt;
|
|
const redirectUrl = (state as Record<string, unknown>).redirectUrl;
|
|
pending[connectorId] = {
|
|
...(typeof expiresAt === 'string' && expiresAt.trim() ? { expiresAt } : {}),
|
|
...(typeof redirectUrl === 'string' && redirectUrl.trim() ? { redirectUrl } : {}),
|
|
};
|
|
} else {
|
|
pending[connectorId] = {};
|
|
}
|
|
}
|
|
return pruneConnectorAuthorizationPending(pending);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function saveConnectorAuthorizationPending(pending: ConnectorAuthorizationPendingState): void {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
if (Object.keys(pending).length === 0) {
|
|
window.sessionStorage.removeItem(CONNECTOR_AUTH_PENDING_STORAGE_KEY);
|
|
} else {
|
|
window.sessionStorage.setItem(CONNECTOR_AUTH_PENDING_STORAGE_KEY, JSON.stringify(pending));
|
|
}
|
|
} catch {
|
|
/* Ignore unavailable sessionStorage. */
|
|
}
|
|
}
|
|
|
|
export function pruneConnectorAuthorizationPending(
|
|
pending: ConnectorAuthorizationPendingState,
|
|
nowMs = Date.now(),
|
|
): ConnectorAuthorizationPendingState {
|
|
const next: ConnectorAuthorizationPendingState = {};
|
|
for (const [connectorId, state] of Object.entries(pending)) {
|
|
const expiresAtMs = state.expiresAt ? Date.parse(state.expiresAt) : Number.NaN;
|
|
if (Number.isFinite(expiresAtMs) && expiresAtMs <= nowMs) continue;
|
|
next[connectorId] = {
|
|
...(state.expiresAt ? { expiresAt: state.expiresAt } : {}),
|
|
...(state.redirectUrl ? { redirectUrl: state.redirectUrl } : {}),
|
|
};
|
|
}
|
|
return next;
|
|
}
|
|
|
|
export function updateConnectorAuthorizationPendingFromConnectResponse(
|
|
pending: ConnectorAuthorizationPendingState,
|
|
response: ConnectorConnectResponse,
|
|
nowMs = Date.now(),
|
|
): ConnectorAuthorizationPendingState {
|
|
const connectorId = response.connector.id;
|
|
const next = { ...pending };
|
|
if (response.auth?.kind === 'redirect_required' || response.auth?.kind === 'pending') {
|
|
next[connectorId] = {
|
|
...(response.auth.expiresAt ? { expiresAt: response.auth.expiresAt } : {}),
|
|
...(response.auth.redirectUrl ? { redirectUrl: response.auth.redirectUrl } : {}),
|
|
};
|
|
return pruneConnectorAuthorizationPending(next, nowMs);
|
|
}
|
|
delete next[connectorId];
|
|
return pruneConnectorAuthorizationPending(next, nowMs);
|
|
}
|
|
|
|
export function updateConnectorAuthorizationPendingFromStatuses(
|
|
pending: ConnectorAuthorizationPendingState,
|
|
statuses: ConnectorStatusResponse['statuses'],
|
|
nowMs = Date.now(),
|
|
): ConnectorAuthorizationPendingState {
|
|
const next = { ...pending };
|
|
for (const [connectorId, status] of Object.entries(statuses)) {
|
|
if (status.status === 'connected') delete next[connectorId];
|
|
}
|
|
return pruneConnectorAuthorizationPending(next, nowMs);
|
|
}
|
|
|
|
export function clearConnectorAuthorizationErrorsForConnected(
|
|
errors: Record<string, string>,
|
|
statuses: ConnectorStatusResponse['statuses'],
|
|
): Record<string, string> {
|
|
let mutated = false;
|
|
const next = { ...errors };
|
|
for (const [connectorId, status] of Object.entries(statuses)) {
|
|
if (status.status === 'connected' && next[connectorId] !== undefined) {
|
|
delete next[connectorId];
|
|
mutated = true;
|
|
}
|
|
}
|
|
return mutated ? next : errors;
|
|
}
|
|
|
|
export function clearConnectorAuthorizationCancelFailuresForConnected(
|
|
failures: Record<string, boolean>,
|
|
statuses: ConnectorStatusResponse['statuses'],
|
|
): Record<string, boolean> {
|
|
let mutated = false;
|
|
const next = { ...failures };
|
|
for (const [connectorId, status] of Object.entries(statuses)) {
|
|
if (status.status === 'connected' && next[connectorId] !== undefined) {
|
|
delete next[connectorId];
|
|
mutated = true;
|
|
}
|
|
}
|
|
return mutated ? next : failures;
|
|
}
|
|
|
|
export function clearConnectorAuthorizationPending(
|
|
pending: ConnectorAuthorizationPendingState,
|
|
connectorId: string,
|
|
): ConnectorAuthorizationPendingState {
|
|
if (pending[connectorId] === undefined) return pending;
|
|
const next = { ...pending };
|
|
delete next[connectorId];
|
|
return next;
|
|
}
|
|
|
|
export function getConnectorDisplayToolCount(connector: ConnectorDetail): number {
|
|
return connector.toolCount ?? connector.tools.length;
|
|
}
|
|
|
|
export function hasLoadedAllAdvertisedConnectorTools(connector: ConnectorDetail): boolean {
|
|
if (connector.toolsNextCursor) return false;
|
|
if (connector.toolCount === undefined) return connector.tools.length > 0;
|
|
return connector.tools.length >= connector.toolCount;
|
|
}
|
|
|
|
function mergeConnectorTools(current: ConnectorDetail['tools'], incoming: ConnectorDetail['tools']): ConnectorDetail['tools'] {
|
|
const seen = new Set<string>();
|
|
const merged: ConnectorDetail['tools'] = [];
|
|
for (const tool of [...current, ...incoming]) {
|
|
if (seen.has(tool.name)) continue;
|
|
seen.add(tool.name);
|
|
merged.push(tool);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
export function mergeConnectorToolPreview(current: ConnectorDetail, next: ConnectorDetail, append: boolean): ConnectorDetail {
|
|
const merged: ConnectorDetail = {
|
|
...current,
|
|
...next,
|
|
tools: append ? mergeConnectorTools(current.tools, next.tools) : next.tools,
|
|
toolCount: next.toolCount ?? current.toolCount,
|
|
toolsHasMore: next.toolsHasMore ?? false,
|
|
featuredToolNames: next.featuredToolNames ?? current.featuredToolNames,
|
|
};
|
|
if (next.toolsNextCursor !== undefined) return { ...merged, toolsNextCursor: next.toolsNextCursor };
|
|
const { toolsNextCursor: _toolsNextCursor, ...withoutCursor } = merged;
|
|
return withoutCursor;
|
|
}
|
|
|
|
export function mergeConnectorActionResult(current: ConnectorDetail, next: ConnectorDetail): ConnectorDetail {
|
|
return {
|
|
...current,
|
|
...next,
|
|
tools: next.tools.length > 0 ? next.tools : current.tools,
|
|
toolCount: next.toolCount ?? current.toolCount,
|
|
featuredToolNames: next.featuredToolNames ?? current.featuredToolNames,
|
|
};
|
|
}
|
|
|
|
function applyConnectorStatuses(
|
|
current: ConnectorDetail[],
|
|
statuses: ConnectorStatusResponse['statuses'],
|
|
): ConnectorDetail[] {
|
|
if (Object.keys(statuses).length === 0) return current;
|
|
return current.map((connector) => {
|
|
const next = statuses[connector.id];
|
|
if (!next) return connector;
|
|
const { accountLabel: _accountLabel, lastError: _lastError, ...base } = connector;
|
|
return { ...base, ...next };
|
|
});
|
|
}
|
|
|
|
interface ConnectorsBrowserProps {
|
|
composioConfigured: boolean;
|
|
catalogRefreshKey?: string | number;
|
|
/** Optional analytics hook for the integrations surface. The parent
|
|
* (IntegrationsView → ConnectorSection) wires this so provider-tab
|
|
* / search clicks emit on `page_name: 'integrations'`; when omitted
|
|
* (SettingsDialog uses the settings page family instead), no event
|
|
* is fired. */
|
|
onConnectorsTabClick?: (
|
|
element: 'provider_chip' | 'search_connectors',
|
|
) => void;
|
|
/** Analytics hook for the per-connector authorization result. The
|
|
* daemon emits its own server-side telemetry but the click→outcome
|
|
* loop happens in the browser; this lets the parent emit
|
|
* `settings_connector_auth_result` for the completed connect /
|
|
* disconnect attempts the user kicked off here. */
|
|
onConnectorAuthResult?: (params: {
|
|
connectorId: string;
|
|
action: 'connect' | 'disconnect' | 'refresh';
|
|
result: 'success' | 'failed' | 'cancelled';
|
|
errorCode?: string;
|
|
}) => void;
|
|
}
|
|
|
|
/**
|
|
* Connector cards + search, lifted out of the entry-view top tab so it can
|
|
* live under Settings → Connectors. Owns its own data lifecycle: fetches the
|
|
* catalog on mount, lazily enriches with Composio discovery when the user
|
|
* actually opens the surface, and rehydrates statuses on window focus and
|
|
* OAuth callback messages.
|
|
*/
|
|
/**
|
|
* Provider tab definition. Today this is just Composio, but the surface is
|
|
* structured as a list-of-tabs because the next provider integration (e.g.
|
|
* a self-hosted MCP registry) is expected to drop in here without rework.
|
|
*
|
|
* `match` decides whether a given catalog entry belongs to this provider:
|
|
* the entry's `auth.provider` is the source of truth, falling back to the
|
|
* lowercased display `provider` for catalog rows that don't carry an auth
|
|
* payload yet.
|
|
*/
|
|
const PROVIDER_TABS: ReadonlyArray<{
|
|
id: string;
|
|
label: string;
|
|
match: (connector: ConnectorDetail) => boolean;
|
|
}> = [
|
|
{
|
|
id: 'composio',
|
|
label: 'Composio',
|
|
match: (connector) => {
|
|
const provider = connector.auth?.provider ?? connector.provider.toLowerCase();
|
|
return provider === 'composio';
|
|
},
|
|
},
|
|
];
|
|
|
|
const DEFAULT_PROVIDER_TAB_ID = 'composio';
|
|
|
|
const CONNECTOR_CATEGORY_KEYS = {
|
|
'accounting': 'connectors.category.accounting',
|
|
'admin': 'connectors.category.admin',
|
|
'ads & conversion': 'connectors.category.advertising',
|
|
'advertising': 'connectors.category.advertising',
|
|
'ai agents': 'connectors.category.aiAgents',
|
|
'ai chatbots': 'connectors.category.aiAgents',
|
|
'ai infrastructure': 'connectors.category.aiInfrastructure',
|
|
'ai meeting assistants': 'connectors.category.meetings',
|
|
'analytics': 'connectors.category.analytics',
|
|
'artificial intelligence': 'connectors.category.aiAgents',
|
|
'automation': 'connectors.category.automation',
|
|
'bookmark managers': 'connectors.category.personal',
|
|
'calendar': 'connectors.category.calendar',
|
|
'cms': 'connectors.category.cms',
|
|
'code': 'connectors.category.developer',
|
|
'commerce': 'connectors.category.commerce',
|
|
'communication': 'connectors.category.communication',
|
|
'connectors': 'connectors.category.integration',
|
|
'contacts': 'connectors.category.contacts',
|
|
'crm': 'connectors.category.crm',
|
|
'customer support': 'connectors.category.support',
|
|
'data platform': 'connectors.category.dataPlatform',
|
|
'database': 'connectors.category.database',
|
|
'databases': 'connectors.category.database',
|
|
'design': 'connectors.category.design',
|
|
'developer': 'connectors.category.developer',
|
|
'developer tools': 'connectors.category.developer',
|
|
'documents': 'connectors.category.documentation',
|
|
'documentation': 'connectors.category.documentation',
|
|
'ecommerce': 'connectors.category.commerce',
|
|
'education': 'connectors.category.education',
|
|
'email': 'connectors.category.email',
|
|
'email newsletters': 'connectors.category.email',
|
|
'erp': 'connectors.category.erp',
|
|
'electronics': 'connectors.category.commerce',
|
|
'events': 'connectors.category.events',
|
|
'event management': 'connectors.category.events',
|
|
'example': 'connectors.category.integration',
|
|
'feedback': 'connectors.category.surveys',
|
|
'field service': 'connectors.category.fieldService',
|
|
'file management & storage': 'connectors.category.storage',
|
|
'finance': 'connectors.category.finance',
|
|
'fitness': 'connectors.category.fitness',
|
|
'forms': 'connectors.category.forms',
|
|
'forms & surveys': 'connectors.category.forms',
|
|
'fundraising': 'connectors.category.nonprofit',
|
|
'gaming': 'connectors.category.gaming',
|
|
'hospitality': 'connectors.category.hospitality',
|
|
'hr': 'connectors.category.hr',
|
|
'hr talent & recruitment': 'connectors.category.recruiting',
|
|
'human resources': 'connectors.category.hr',
|
|
'images & design': 'connectors.category.design',
|
|
'important': 'connectors.category.integration',
|
|
'integration': 'connectors.category.integration',
|
|
'itsm': 'connectors.category.itsm',
|
|
'it operations': 'connectors.category.itsm',
|
|
'localization': 'connectors.category.localization',
|
|
'logistics': 'connectors.category.logistics',
|
|
'maps': 'connectors.category.maps',
|
|
'marketing': 'connectors.category.marketing',
|
|
'marketing automation': 'connectors.category.marketing',
|
|
'media': 'connectors.category.media',
|
|
'meetings': 'connectors.category.meetings',
|
|
'model context protocol': 'connectors.category.developer',
|
|
'news & lifestyle': 'connectors.category.media',
|
|
'nonprofit': 'connectors.category.nonprofit',
|
|
'notes': 'connectors.category.documentation',
|
|
'notifications': 'connectors.category.communication',
|
|
'observability': 'connectors.category.observability',
|
|
'online courses': 'connectors.category.education',
|
|
'payments': 'connectors.category.payments',
|
|
'payment processing': 'connectors.category.payments',
|
|
'personal': 'connectors.category.personal',
|
|
'phone & sms': 'connectors.category.communication',
|
|
'presentations': 'connectors.category.presentations',
|
|
'premium': 'connectors.category.integration',
|
|
'procurement': 'connectors.category.procurement',
|
|
'product': 'connectors.category.product',
|
|
'product management': 'connectors.category.product',
|
|
'productivity': 'connectors.category.productivity',
|
|
'productivity & project management': 'connectors.category.projectManagement',
|
|
'project management': 'connectors.category.projectManagement',
|
|
'proposal & invoice management': 'connectors.category.accounting',
|
|
'recruiting': 'connectors.category.recruiting',
|
|
'research': 'connectors.category.research',
|
|
'sales': 'connectors.category.salesIntelligence',
|
|
'sales intelligence': 'connectors.category.salesIntelligence',
|
|
'scheduling': 'connectors.category.scheduling',
|
|
'scheduling & booking': 'connectors.category.scheduling',
|
|
'search': 'connectors.category.search',
|
|
'security': 'connectors.category.security',
|
|
'security & identity tools': 'connectors.category.security',
|
|
'server monitoring': 'connectors.category.observability',
|
|
'signing': 'connectors.category.signing',
|
|
'signatures': 'connectors.category.signing',
|
|
'social': 'connectors.category.social',
|
|
'social media accounts': 'connectors.category.social',
|
|
'social media marketing': 'connectors.category.marketing',
|
|
'spreadsheets': 'connectors.category.spreadsheets',
|
|
'storage': 'connectors.category.storage',
|
|
'support': 'connectors.category.support',
|
|
'surveys': 'connectors.category.surveys',
|
|
'task management': 'connectors.category.tasks',
|
|
'tasks': 'connectors.category.tasks',
|
|
'team chat': 'connectors.category.communication',
|
|
'team collaboration': 'connectors.category.communication',
|
|
'time tracking': 'connectors.category.timeTracking',
|
|
'time tracking software': 'connectors.category.timeTracking',
|
|
'url shortener': 'connectors.category.marketing',
|
|
'video': 'connectors.category.video',
|
|
'video & audio': 'connectors.category.video',
|
|
'video conferencing': 'connectors.category.meetings',
|
|
'website builders': 'connectors.category.cms',
|
|
'whiteboard': 'connectors.category.whiteboard',
|
|
} as const satisfies Record<string, keyof Dict>;
|
|
|
|
export function ConnectorsBrowser({
|
|
composioConfigured,
|
|
catalogRefreshKey = 0,
|
|
onConnectorsTabClick,
|
|
onConnectorAuthResult,
|
|
}: ConnectorsBrowserProps) {
|
|
const t = useT();
|
|
const [connectors, setConnectors] = useState<ConnectorDetail[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [toolsLoading, setToolsLoading] = useState(false);
|
|
const [toolsLoaded, setToolsLoaded] = useState(false);
|
|
const [pendingConnectorAction, setPendingConnectorAction] = useState<{
|
|
connectorId: string;
|
|
action: 'connect' | 'disconnect';
|
|
} | null>(null);
|
|
const [connectorAuthorizationPending, setConnectorAuthorizationPending] = useState<ConnectorAuthorizationPendingState>(() => loadConnectorAuthorizationPending());
|
|
const [connectorAuthorizationCancelFailed, setConnectorAuthorizationCancelFailed] = useState<Record<string, boolean>>({});
|
|
const [connectorAuthorizationError, setConnectorAuthorizationError] = useState<Record<string, string>>({});
|
|
const [detailConnectorId, setDetailConnectorId] = useState<string | null>(null);
|
|
const [toolPreviewLoadingIds, setToolPreviewLoadingIds] = useState<Record<string, boolean>>({});
|
|
const [toolPreviewFetchedIds, setToolPreviewFetchedIds] = useState<Record<string, boolean>>({});
|
|
const [toolPreviewFailedIds, setToolPreviewFailedIds] = useState<Record<string, string>>({});
|
|
const [filter, setFilter] = useState('');
|
|
const [selectedProvider, setSelectedProvider] = useState<string>(DEFAULT_PROVIDER_TAB_ID);
|
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
|
const searchTrackedRef = useRef(false);
|
|
const logoTheme = useResolvedTheme();
|
|
const toolPreviewRetryToken = `${composioConfigured ? 'configured' : 'unconfigured'}:${String(catalogRefreshKey)}`;
|
|
|
|
const reloadConnectorStatuses = useCallback(async () => {
|
|
const statuses = await fetchConnectorStatuses();
|
|
setConnectors((curr) => applyConnectorStatuses(curr, statuses));
|
|
setConnectorAuthorizationPending((curr) => updateConnectorAuthorizationPendingFromStatuses(curr, statuses));
|
|
setConnectorAuthorizationError((curr) => clearConnectorAuthorizationErrorsForConnected(curr, statuses));
|
|
setConnectorAuthorizationCancelFailed((curr) => clearConnectorAuthorizationCancelFailuresForConnected(curr, statuses));
|
|
return statuses;
|
|
}, []);
|
|
|
|
const connectorAuthorizationPendingRef = useRef(connectorAuthorizationPending);
|
|
useEffect(() => {
|
|
connectorAuthorizationPendingRef.current = connectorAuthorizationPending;
|
|
}, [connectorAuthorizationPending]);
|
|
|
|
const cancelStaleAuthorizations = useCallback(async (
|
|
pendingBeforeReload: ConnectorAuthorizationPendingState,
|
|
statuses: ConnectorStatusResponse['statuses'],
|
|
nowMs = Date.now(),
|
|
) => {
|
|
const stuck = Object.keys(pendingBeforeReload).filter((connectorId) => {
|
|
if (statuses[connectorId]?.status === 'connected') return false;
|
|
const expiresAt = pendingBeforeReload[connectorId]?.expiresAt;
|
|
if (!expiresAt) return false;
|
|
const expiresAtMs = Date.parse(expiresAt);
|
|
return Number.isFinite(expiresAtMs) && expiresAtMs <= nowMs;
|
|
});
|
|
if (stuck.length === 0) return;
|
|
await Promise.allSettled(stuck.map(async (connectorId) => {
|
|
let connector: ConnectorDetail | null = null;
|
|
try {
|
|
connector = await cancelConnectorAuthorizationRequest(connectorId);
|
|
} catch {
|
|
connector = null;
|
|
}
|
|
if (!connector) {
|
|
setConnectorAuthorizationCancelFailed((curr) => ({ ...curr, [connectorId]: true }));
|
|
return;
|
|
}
|
|
updateConnector(connector);
|
|
setConnectorAuthorizationCancelFailed((curr) => {
|
|
if (curr[connectorId] === undefined) return curr;
|
|
const next = { ...curr };
|
|
delete next[connectorId];
|
|
return next;
|
|
});
|
|
setConnectorAuthorizationError((curr) => {
|
|
if (curr[connectorId] === undefined) return curr;
|
|
const next = { ...curr };
|
|
delete next[connectorId];
|
|
return next;
|
|
});
|
|
setConnectorAuthorizationPending((curr) => clearConnectorAuthorizationPending(curr, connectorId));
|
|
}));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
saveConnectorAuthorizationPending(connectorAuthorizationPending);
|
|
}, [connectorAuthorizationPending]);
|
|
|
|
useEffect(() => {
|
|
if (Object.keys(connectorAuthorizationPending).length === 0) return;
|
|
const interval = window.setInterval(() => {
|
|
setConnectorAuthorizationPending((curr) => pruneConnectorAuthorizationPending(curr));
|
|
void reloadConnectorStatuses();
|
|
}, CONNECTOR_AUTH_PENDING_POLL_MS);
|
|
return () => window.clearInterval(interval);
|
|
}, [connectorAuthorizationPending, reloadConnectorStatuses]);
|
|
|
|
// Initial catalog fetch — always loads the lightweight registry payload so
|
|
// already-configured connectors render immediately.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setToolsLoaded(false);
|
|
(async () => {
|
|
const next = await fetchConnectors();
|
|
if (cancelled) return;
|
|
setConnectors((curr) => mergeConnectors(curr, next));
|
|
setLoading(false);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [composioConfigured, catalogRefreshKey]);
|
|
|
|
// Lazy Composio discovery — enriched toolkit metadata + auth configuration.
|
|
// Heavier round trip; only worth it once a Composio API key is actually
|
|
// saved. Before that, discovery returns no live tools and the web-side
|
|
// provider cache can otherwise keep those empty tool lists after Save key.
|
|
useEffect(() => {
|
|
if (!composioConfigured) {
|
|
setToolsLoaded(false);
|
|
setToolsLoading(false);
|
|
return;
|
|
}
|
|
if (toolsLoaded) return;
|
|
|
|
let cancelled = false;
|
|
setToolsLoading(true);
|
|
(async () => {
|
|
const next = await fetchConnectorDiscovery({ refresh: true });
|
|
if (cancelled) return;
|
|
setConnectors((curr) => mergeConnectors(curr, next));
|
|
setToolsLoaded(true);
|
|
setToolsLoading(false);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
setToolsLoading(false);
|
|
};
|
|
}, [composioConfigured, catalogRefreshKey, toolsLoaded]);
|
|
|
|
// OAuth callback: a popup or system-browser tab postMessages back when an
|
|
// auth flow completes. Trust same-origin + localhost-loopback so packaged
|
|
// dev URLs (different ports) keep working.
|
|
useEffect(() => {
|
|
function onMessage(event: MessageEvent) {
|
|
const data = event.data;
|
|
if (
|
|
!data ||
|
|
typeof data !== 'object' ||
|
|
(data as { type?: unknown }).type !== CONNECTOR_CALLBACK_MESSAGE_TYPE
|
|
)
|
|
return;
|
|
if (!isTrustedConnectorCallbackOrigin(event.origin)) return;
|
|
void reloadConnectorStatuses();
|
|
}
|
|
window.addEventListener('message', onMessage);
|
|
return () => window.removeEventListener('message', onMessage);
|
|
}, [reloadConnectorStatuses]);
|
|
|
|
// System-browser auth flows have no opener to post back to; refresh
|
|
// whenever the window regains focus so the UI catches up silently. If a
|
|
// pending authorization is still not connected after the refresh, the
|
|
// user closed the auth flow without completing it — auto-cancel so the
|
|
// card recovers to its default state instead of staying stuck loading.
|
|
useEffect(() => {
|
|
async function onFocus() {
|
|
const pendingBeforeReload = connectorAuthorizationPendingRef.current;
|
|
const statuses = await reloadConnectorStatuses();
|
|
await cancelStaleAuthorizations(pendingBeforeReload, statuses);
|
|
}
|
|
window.addEventListener('focus', onFocus);
|
|
return () => window.removeEventListener('focus', onFocus);
|
|
}, [reloadConnectorStatuses, cancelStaleAuthorizations]);
|
|
|
|
// The local Composio API-key state is authoritative for masking. Cached
|
|
// connector auth can be stale immediately after the user clears the key.
|
|
const needsComposioKey = !composioConfigured;
|
|
|
|
// Filter and rank connectors by user-visible fields. Exact/prefix matches
|
|
// on connector name/provider are strongest; broad description matches stay
|
|
// searchable but are down-ranked. The provider tab restricts the catalog
|
|
// to a single backing provider before search runs so result rankings stay
|
|
// tab-local.
|
|
const providerScopedConnectors = useMemo(() => {
|
|
const tab =
|
|
PROVIDER_TABS.find((p) => p.id === selectedProvider) ??
|
|
PROVIDER_TABS.find((p) => p.id === DEFAULT_PROVIDER_TAB_ID);
|
|
if (!tab) return connectors;
|
|
return connectors.filter((connector) => tab.match(connector));
|
|
}, [connectors, selectedProvider]);
|
|
|
|
const filteredConnectors = useMemo(() => {
|
|
return sortConnectorsForSearch(providerScopedConnectors, filter);
|
|
}, [providerScopedConnectors, filter]);
|
|
|
|
const hasQuery = filter.trim().length > 0;
|
|
const hasNoResults = hasQuery && filteredConnectors.length === 0;
|
|
const connectorPanelAlerts = useMemo(() => {
|
|
const alerts: Array<{ connectorId: string; connectorName: string; message: string }> = [];
|
|
for (const connector of connectors) {
|
|
if (connector.id === detailConnectorId) continue;
|
|
const message = connectorAuthorizationError[connector.id];
|
|
if (message) {
|
|
alerts.push({ connectorId: connector.id, connectorName: connector.name, message });
|
|
}
|
|
if (connectorAuthorizationCancelFailed[connector.id]) {
|
|
alerts.push({
|
|
connectorId: connector.id,
|
|
connectorName: connector.name,
|
|
message: AUTHORIZATION_CANCEL_FAILED_MESSAGE,
|
|
});
|
|
}
|
|
}
|
|
return alerts;
|
|
}, [connectorAuthorizationCancelFailed, connectorAuthorizationError, connectors, detailConnectorId]);
|
|
|
|
function updateConnector(next: ConnectorDetail | null) {
|
|
if (!next) return;
|
|
setConnectors((curr) => curr.map((connector) => (
|
|
connector.id === next.id ? mergeConnectorActionResult(connector, next) : connector
|
|
)));
|
|
}
|
|
|
|
async function runConnectorAction(connectorId: string, action: 'connect' | 'disconnect') {
|
|
if (pendingConnectorAction) return;
|
|
setPendingConnectorAction({ connectorId, action });
|
|
try {
|
|
if (action === 'connect') {
|
|
setConnectorAuthorizationCancelFailed((curr) => {
|
|
if (curr[connectorId] === undefined) return curr;
|
|
const next = { ...curr };
|
|
delete next[connectorId];
|
|
return next;
|
|
});
|
|
setConnectorAuthorizationError((curr) => {
|
|
if (curr[connectorId] === undefined) return curr;
|
|
const next = { ...curr };
|
|
delete next[connectorId];
|
|
return next;
|
|
});
|
|
try {
|
|
const result = await connectConnector(connectorId);
|
|
updateConnector(result.connector);
|
|
if (result.connector && !result.error) {
|
|
setConnectorAuthorizationPending((curr) => updateConnectorAuthorizationPendingFromConnectResponse(curr, {
|
|
connector: result.connector!,
|
|
...(result.auth === undefined ? {} : { auth: result.auth }),
|
|
}));
|
|
onConnectorAuthResult?.({
|
|
connectorId,
|
|
action: 'connect',
|
|
result: 'success',
|
|
});
|
|
} else {
|
|
setConnectorAuthorizationPending((curr) => clearConnectorAuthorizationPending(curr, connectorId));
|
|
if (result.error) {
|
|
setConnectorAuthorizationError((curr) => ({ ...curr, [connectorId]: result.error! }));
|
|
}
|
|
onConnectorAuthResult?.({
|
|
connectorId,
|
|
action: 'connect',
|
|
result: 'failed',
|
|
...(result.error ? { errorCode: result.error } : {}),
|
|
});
|
|
}
|
|
} catch (err) {
|
|
onConnectorAuthResult?.({
|
|
connectorId,
|
|
action: 'connect',
|
|
result: 'failed',
|
|
errorCode: err instanceof Error ? err.message : String(err),
|
|
});
|
|
throw err;
|
|
}
|
|
} else {
|
|
setConnectorAuthorizationPending((curr) => clearConnectorAuthorizationPending(curr, connectorId));
|
|
setConnectorAuthorizationError((curr) => {
|
|
if (curr[connectorId] === undefined) return curr;
|
|
const next = { ...curr };
|
|
delete next[connectorId];
|
|
return next;
|
|
});
|
|
try {
|
|
updateConnector(await disconnectConnector(connectorId));
|
|
onConnectorAuthResult?.({
|
|
connectorId,
|
|
action: 'disconnect',
|
|
result: 'success',
|
|
});
|
|
} catch (err) {
|
|
onConnectorAuthResult?.({
|
|
connectorId,
|
|
action: 'disconnect',
|
|
result: 'failed',
|
|
errorCode: err instanceof Error ? err.message : String(err),
|
|
});
|
|
throw err;
|
|
}
|
|
}
|
|
} finally {
|
|
setPendingConnectorAction(null);
|
|
}
|
|
}
|
|
|
|
const detailConnector = useMemo(
|
|
() => (detailConnectorId ? connectors.find((c) => c.id === detailConnectorId) ?? null : null),
|
|
[detailConnectorId, connectors],
|
|
);
|
|
|
|
async function hydrateToolPreview(connectorId: string, cursor?: string) {
|
|
if (!composioConfigured) return;
|
|
if (toolPreviewLoadingIds[connectorId]) return;
|
|
setToolPreviewLoadingIds((curr) => ({ ...curr, [connectorId]: true }));
|
|
try {
|
|
const next = await fetchConnectorDetail(connectorId, {
|
|
hydrateTools: true,
|
|
toolsLimit: CONNECTOR_TOOL_PREVIEW_LIMIT,
|
|
...(cursor === undefined ? {} : { toolsCursor: cursor }),
|
|
});
|
|
if (next) {
|
|
setConnectors((curr) => curr.map((connector) => (
|
|
connector.id === next.id ? mergeConnectorToolPreview(connector, next, cursor !== undefined) : connector
|
|
)));
|
|
setToolPreviewFetchedIds((curr) => ({ ...curr, [connectorId]: true }));
|
|
setToolPreviewFailedIds((curr) => {
|
|
if (curr[connectorId] === undefined) return curr;
|
|
const nextFailed = { ...curr };
|
|
delete nextFailed[connectorId];
|
|
return nextFailed;
|
|
});
|
|
} else {
|
|
setToolPreviewFailedIds((curr) => ({ ...curr, [connectorId]: toolPreviewRetryToken }));
|
|
}
|
|
} catch {
|
|
setToolPreviewFailedIds((curr) => ({ ...curr, [connectorId]: toolPreviewRetryToken }));
|
|
} finally {
|
|
setToolPreviewLoadingIds((curr) => ({ ...curr, [connectorId]: false }));
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!detailConnector) return;
|
|
if (!composioConfigured) return;
|
|
if (hasLoadedAllAdvertisedConnectorTools(detailConnector)) return;
|
|
if (toolPreviewFetchedIds[detailConnector.id]) return;
|
|
if (toolPreviewFailedIds[detailConnector.id] === toolPreviewRetryToken) return;
|
|
if (toolPreviewLoadingIds[detailConnector.id]) return;
|
|
void hydrateToolPreview(detailConnector.id);
|
|
}, [composioConfigured, detailConnector, toolPreviewFailedIds, toolPreviewFetchedIds, toolPreviewLoadingIds, toolPreviewRetryToken]);
|
|
|
|
function openConnectorDetails(connectorId: string) {
|
|
setToolPreviewFailedIds((curr) => {
|
|
if (curr[connectorId] === undefined) return curr;
|
|
const next = { ...curr };
|
|
delete next[connectorId];
|
|
return next;
|
|
});
|
|
setDetailConnectorId(connectorId);
|
|
}
|
|
|
|
async function cancelConnectorAuthorization(connectorId: string) {
|
|
const connector = await cancelConnectorAuthorizationRequest(connectorId);
|
|
if (connector) {
|
|
updateConnector(connector);
|
|
setConnectorAuthorizationCancelFailed((curr) => {
|
|
if (curr[connectorId] === undefined) return curr;
|
|
const next = { ...curr };
|
|
delete next[connectorId];
|
|
return next;
|
|
});
|
|
setConnectorAuthorizationError((curr) => {
|
|
if (curr[connectorId] === undefined) return curr;
|
|
const next = { ...curr };
|
|
delete next[connectorId];
|
|
return next;
|
|
});
|
|
setConnectorAuthorizationPending((curr) => clearConnectorAuthorizationPending(curr, connectorId));
|
|
return;
|
|
}
|
|
try {
|
|
const statuses = await reloadConnectorStatuses();
|
|
if (statuses[connectorId]?.status === 'connected') return;
|
|
} catch {
|
|
// Keep the local failure visible when the status refresh itself fails.
|
|
}
|
|
setConnectorAuthorizationCancelFailed((curr) => ({ ...curr, [connectorId]: true }));
|
|
}
|
|
|
|
return (
|
|
<div className="tab-panel connectors-panel connectors-panel-embedded">
|
|
<div className="tab-panel-toolbar">
|
|
<div className="toolbar-left connectors-heading">
|
|
<div>
|
|
<h2>{t('connectors.title')}</h2>
|
|
<p>{t('connectors.subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
<div className="toolbar-right">
|
|
<div
|
|
className="connectors-provider-tabs"
|
|
role="tablist"
|
|
aria-label="Connector provider"
|
|
>
|
|
{PROVIDER_TABS.map((provider) => {
|
|
const active = provider.id === selectedProvider;
|
|
return (
|
|
<button
|
|
key={provider.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={active}
|
|
className={`connectors-provider-tab${active ? ' is-active' : ''}`}
|
|
onClick={() => {
|
|
onConnectorsTabClick?.('provider_chip');
|
|
setSelectedProvider(provider.id);
|
|
}}
|
|
data-testid={`connectors-provider-tab-${provider.id}`}
|
|
>
|
|
{provider.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="toolbar-search connectors-search">
|
|
<span className="search-icon" aria-hidden>
|
|
<Icon name="search" size={13} />
|
|
</span>
|
|
<input
|
|
ref={searchInputRef}
|
|
type="search"
|
|
value={filter}
|
|
onFocus={() => {
|
|
if (searchTrackedRef.current) return;
|
|
searchTrackedRef.current = true;
|
|
onConnectorsTabClick?.('search_connectors');
|
|
}}
|
|
onChange={(event) => setFilter(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape' && filter) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setFilter('');
|
|
}
|
|
}}
|
|
placeholder={t('connectors.searchPlaceholder')}
|
|
aria-label={t('connectors.searchAriaLabel')}
|
|
disabled={needsComposioKey}
|
|
data-testid="connectors-search-input"
|
|
/>
|
|
{hasQuery ? (
|
|
<button
|
|
type="button"
|
|
className="toolbar-search-clear"
|
|
aria-label={t('connectors.searchClear')}
|
|
onClick={() => {
|
|
setFilter('');
|
|
searchInputRef.current?.focus();
|
|
}}
|
|
data-testid="connectors-search-clear"
|
|
>
|
|
<Icon name="close" size={12} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{connectorPanelAlerts.length > 0 ? (
|
|
<div className="connector-panel-alerts">
|
|
{connectorPanelAlerts.map((alert) => (
|
|
<div
|
|
key={`${alert.connectorId}:${alert.message}`}
|
|
className="connector-panel-alert"
|
|
title={`${alert.connectorName}: ${alert.message}`}
|
|
>
|
|
<p className="connector-panel-alert-copy" role="status">
|
|
<strong title={alert.connectorName}>{alert.connectorName}</strong>
|
|
<span className="sr-only">: </span>
|
|
<span title={alert.message}>{alert.message}</span>
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="icon-only connector-panel-alert-action"
|
|
aria-label={t('connectors.openDetailsAria', { name: alert.connectorName })}
|
|
title={t('connectors.openDetailsAria', { name: alert.connectorName })}
|
|
onClick={() => openConnectorDetails(alert.connectorId)}
|
|
>
|
|
<Icon name="external-link" size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{loading ? (
|
|
<CenteredLoader label={t('common.loading')} />
|
|
) : (
|
|
<div
|
|
className={`connector-grid-wrap${needsComposioKey ? ' is-masked' : ''}`}
|
|
data-testid="connector-grid-wrap"
|
|
>
|
|
{hasNoResults && !needsComposioKey ? (
|
|
<div
|
|
className="tab-empty connectors-empty"
|
|
role="status"
|
|
aria-live="polite"
|
|
data-testid="connectors-empty"
|
|
>
|
|
<p className="connectors-empty-title">
|
|
{t('connectors.emptyNoMatchTitle', { query: filter.trim() })}
|
|
</p>
|
|
<p className="connectors-empty-body">{t('connectors.emptyNoMatchBody')}</p>
|
|
<button
|
|
type="button"
|
|
className="ghost connectors-empty-action"
|
|
onClick={() => {
|
|
setFilter('');
|
|
searchInputRef.current?.focus();
|
|
}}
|
|
>
|
|
{t('connectors.emptyNoMatchAction')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="connector-grid"
|
|
aria-hidden={needsComposioKey || undefined}
|
|
>
|
|
{filteredConnectors.map((connector) => (
|
|
<ConnectorCard
|
|
key={connector.id}
|
|
connector={connector}
|
|
disabled={needsComposioKey}
|
|
pendingAction={
|
|
pendingConnectorAction?.connectorId === connector.id
|
|
? pendingConnectorAction.action
|
|
: null
|
|
}
|
|
authorizationPending={connectorAuthorizationPending[connector.id]}
|
|
authorizationCancelFailed={connectorAuthorizationCancelFailed[connector.id] === true}
|
|
toolsLoading={toolsLoading}
|
|
toolsLoaded={toolsLoaded}
|
|
logoTheme={logoTheme}
|
|
onConnect={(connectorId) => runConnectorAction(connectorId, 'connect')}
|
|
onDisconnect={(connectorId) => runConnectorAction(connectorId, 'disconnect')}
|
|
onCancelAuthorization={cancelConnectorAuthorization}
|
|
onOpenDetails={openConnectorDetails}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{needsComposioKey ? (
|
|
<div
|
|
className="connector-gate"
|
|
role="region"
|
|
aria-label={t('connectors.gateTitle')}
|
|
data-testid="connector-gate"
|
|
>
|
|
<div className="connector-gate-card">
|
|
<div className="connector-gate-icon" aria-hidden>
|
|
<Icon name="settings" size={20} />
|
|
</div>
|
|
<h3 className="connector-gate-title">{t('connectors.gateTitle')}</h3>
|
|
<p className="connector-gate-body">{t('connectors.gateBody')}</p>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
{detailConnector ? (
|
|
<ConnectorDetailDrawer
|
|
connector={detailConnector}
|
|
disabled={needsComposioKey}
|
|
pendingAction={
|
|
pendingConnectorAction?.connectorId === detailConnector.id
|
|
? pendingConnectorAction.action
|
|
: null
|
|
}
|
|
authorizationPending={connectorAuthorizationPending[detailConnector.id]}
|
|
authorizationCancelFailed={connectorAuthorizationCancelFailed[detailConnector.id] === true}
|
|
authorizationError={connectorAuthorizationError[detailConnector.id] ?? null}
|
|
toolsLoading={toolsLoading}
|
|
toolsPreviewLoading={Boolean(toolPreviewLoadingIds[detailConnector.id])}
|
|
toolsLoaded={
|
|
Boolean(toolPreviewFetchedIds[detailConnector.id])
|
|
|| toolPreviewFailedIds[detailConnector.id] === toolPreviewRetryToken
|
|
|| hasLoadedAllAdvertisedConnectorTools(detailConnector)
|
|
}
|
|
logoTheme={logoTheme}
|
|
onClose={() => setDetailConnectorId(null)}
|
|
onConnect={(connectorId) => runConnectorAction(connectorId, 'connect')}
|
|
onDisconnect={(connectorId) => runConnectorAction(connectorId, 'disconnect')}
|
|
onCancelAuthorization={cancelConnectorAuthorization}
|
|
onLoadMoreTools={(connectorId, cursor) => hydrateToolPreview(connectorId, cursor)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConnectorCard({
|
|
connector,
|
|
disabled = false,
|
|
pendingAction,
|
|
authorizationPending,
|
|
authorizationCancelFailed,
|
|
toolsLoading: _toolsLoading,
|
|
toolsLoaded,
|
|
logoTheme,
|
|
onConnect,
|
|
onDisconnect,
|
|
onCancelAuthorization,
|
|
onOpenDetails,
|
|
}: {
|
|
connector: ConnectorDetail;
|
|
disabled?: boolean;
|
|
pendingAction: 'connect' | 'disconnect' | null;
|
|
authorizationPending?: ConnectorAuthorizationPending;
|
|
authorizationCancelFailed: boolean;
|
|
toolsLoading: boolean;
|
|
toolsLoaded: boolean;
|
|
logoTheme: 'light' | 'dark';
|
|
onConnect: (connectorId: string) => Promise<void> | void;
|
|
onDisconnect: (connectorId: string) => Promise<void> | void;
|
|
onCancelAuthorization: (connectorId: string) => void;
|
|
onOpenDetails: (connectorId: string) => void;
|
|
}) {
|
|
const t = useT();
|
|
const isConnecting = pendingAction === 'connect';
|
|
const isDisconnecting = pendingAction === 'disconnect';
|
|
const isConnected = connector.status === 'connected';
|
|
const isAuthorizationPending = !isConnected && authorizationPending !== undefined;
|
|
const isPending = pendingAction !== null || isAuthorizationPending;
|
|
const canConnect = !disabled && !isPending && connector.status === 'available';
|
|
const canDisconnect = !disabled && !isPending && isConnected;
|
|
const toolCount = getConnectorDisplayToolCount(connector);
|
|
const showToolsBadge = connector.toolCount !== undefined || connector.tools.length > 0 || toolsLoaded;
|
|
const toolsBadgeLabel = formatToolsBadge(toolCount, t);
|
|
const categoryLabel = connectorCategoryLabel(connector.category, t);
|
|
|
|
function openDetails() {
|
|
if (disabled) return;
|
|
onOpenDetails(connector.id);
|
|
}
|
|
|
|
function onKeyActivate(event: ReactKeyboardEvent<HTMLElement>) {
|
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
if (event.target !== event.currentTarget) return;
|
|
event.preventDefault();
|
|
openDetails();
|
|
}
|
|
|
|
function stop(event: SyntheticEvent) {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
function continueAuthorization(event: SyntheticEvent) {
|
|
stop(event);
|
|
if (!authorizationPending?.redirectUrl) return;
|
|
void openExternalUrl(authorizationPending.redirectUrl);
|
|
}
|
|
|
|
return (
|
|
<article
|
|
className={`connector-card status-${connector.status}${disabled ? ' is-locked' : ''}`}
|
|
data-connector-id={connector.id}
|
|
role="button"
|
|
tabIndex={disabled ? -1 : 0}
|
|
aria-disabled={disabled || undefined}
|
|
aria-label={t('connectors.openDetailsAria', { name: connector.name })}
|
|
onClick={openDetails}
|
|
onKeyDown={onKeyActivate}
|
|
>
|
|
<div className="connector-card-top">
|
|
<ConnectorLogo connector={connector} theme={logoTheme} size="sm" />
|
|
<div className="connector-card-head">
|
|
{/* Title row composes the connector name with an inline
|
|
connection dot when applicable, instead of putting the
|
|
dot in the action column. The dot now reads as a small
|
|
"live status" indicator anchored to the brand label,
|
|
while the action column is reserved purely for the
|
|
connect/disconnect controls and any error/disabled
|
|
status chips. The name span carries the ellipsis so a
|
|
long brand never crowds the dot out of the row. */}
|
|
<h3 className="connector-card-title">
|
|
<span className="connector-card-title-name">{connector.name}</span>
|
|
{isConnected ? (
|
|
<span
|
|
className={`connector-status-dot connector-card-title-dot status-${connector.status}`}
|
|
aria-label={statusLabel(connector.status, t)}
|
|
title={statusLabel(connector.status, t)}
|
|
role="img"
|
|
/>
|
|
) : isAuthorizationPending ? (
|
|
<span
|
|
className="connector-status-dot connector-card-title-dot status-pending"
|
|
aria-label={t('connectors.authorizationPending')}
|
|
title={t('connectors.authorizationPending')}
|
|
role="img"
|
|
/>
|
|
) : null}
|
|
</h3>
|
|
{/* Two-row meta block. Splitting category and tools-badge onto
|
|
their own rows keeps card heights deterministic — long
|
|
category labels no longer push the badge to a new line in
|
|
an unpredictable way, and the tools-badge slot reserves
|
|
its row even before the async discovery resolves so the
|
|
card doesn't grow when the badge appears. The category
|
|
row truncates with ellipsis (one line); the badge row is
|
|
a fixed-height anchor that the badge animates into. */}
|
|
<div className="connector-meta">
|
|
<span
|
|
className="connector-meta-item connector-meta-category"
|
|
title={categoryLabel}
|
|
>
|
|
{categoryLabel}
|
|
</span>
|
|
<span className="connector-meta-tools" aria-hidden={!showToolsBadge}>
|
|
{showToolsBadge ? (
|
|
<span className="connector-tools-badge is-ready" title={toolsBadgeLabel}>
|
|
<span>{toolsBadgeLabel}</span>
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="connector-card-actions">
|
|
{isConnected ? (
|
|
<button
|
|
type="button"
|
|
className={`icon-only connector-action is-disconnect${isDisconnecting ? ' is-loading' : ''}`}
|
|
disabled={!canDisconnect}
|
|
aria-busy={isDisconnecting || undefined}
|
|
aria-label={t('connectors.disconnect')}
|
|
title={t('connectors.disconnect')}
|
|
tabIndex={disabled ? -1 : undefined}
|
|
onMouseDown={stop}
|
|
onKeyDown={stop}
|
|
onClick={(e) => {
|
|
stop(e);
|
|
onDisconnect(connector.id);
|
|
}}
|
|
>
|
|
<Icon name={isDisconnecting ? 'spinner' : 'close'} size={12} />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={`icon-only connector-action is-connect${isConnecting || isAuthorizationPending ? ' is-loading' : ''}`}
|
|
disabled={!canConnect}
|
|
aria-busy={isConnecting || isAuthorizationPending || undefined}
|
|
aria-label={isAuthorizationPending ? t('connectors.authorizationPending') : t('connectors.connect')}
|
|
title={isAuthorizationPending ? t('connectors.authorizationPendingHint') : t('connectors.connect')}
|
|
tabIndex={disabled ? -1 : undefined}
|
|
onMouseDown={stop}
|
|
onKeyDown={stop}
|
|
onClick={(e) => {
|
|
stop(e);
|
|
onConnect(connector.id);
|
|
}}
|
|
>
|
|
<Icon name={isConnecting || isAuthorizationPending ? 'spinner' : 'plus'} size={12} />
|
|
</button>
|
|
)}
|
|
{isAuthorizationPending ? (
|
|
<button
|
|
type="button"
|
|
className="icon-only connector-action is-cancel-authorization"
|
|
aria-label={t('connectors.cancelAuthorization')}
|
|
title={t('connectors.cancelAuthorization')}
|
|
onMouseDown={stop}
|
|
onKeyDown={stop}
|
|
onClick={(e) => {
|
|
stop(e);
|
|
onCancelAuthorization(connector.id);
|
|
}}
|
|
>
|
|
<Icon name="close" size={12} />
|
|
</button>
|
|
) : null}
|
|
{connector.status === 'error' || connector.status === 'disabled' ? (
|
|
<span className={`connector-status-pill status-${connector.status}`}>
|
|
{statusLabel(connector.status, t)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{authorizationCancelFailed ? (
|
|
<p className="connector-authorization-hint connector-authorization-error" role="alert">
|
|
{AUTHORIZATION_CANCEL_FAILED_MESSAGE}
|
|
</p>
|
|
) : null}
|
|
{isAuthorizationPending && authorizationPending.redirectUrl ? (
|
|
<button
|
|
type="button"
|
|
className="connector-authorization-link"
|
|
title={t('connectors.authorizationPendingHint')}
|
|
onClick={continueAuthorization}
|
|
>
|
|
{CONNECTOR_AUTH_CONTINUE_LABEL}
|
|
</button>
|
|
) : null}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function statusLabel(status: ConnectorDetail['status'], t: ReturnType<typeof useT>): string {
|
|
switch (status) {
|
|
case 'available':
|
|
return t('connectors.statusAvailable');
|
|
case 'connected':
|
|
return t('connectors.statusConnected');
|
|
case 'error':
|
|
return t('connectors.statusError');
|
|
case 'disabled':
|
|
return t('connectors.statusDisabled');
|
|
}
|
|
}
|
|
|
|
function connectorCategoryLabel(category: string, t: ReturnType<typeof useT>): string {
|
|
const normalized = category.trim().toLowerCase();
|
|
const key = CONNECTOR_CATEGORY_KEYS[normalized as keyof typeof CONNECTOR_CATEGORY_KEYS];
|
|
return key ? t(key) : category;
|
|
}
|
|
|
|
function formatToolsBadge(count: number, t: ReturnType<typeof useT>): string {
|
|
if (count === 0) return t('connectors.toolsBadgeNone');
|
|
if (count === 1) return t('connectors.toolsBadgeOne', { n: count });
|
|
return t('connectors.toolsBadgeMany', { n: count });
|
|
}
|
|
|
|
function ConnectorDetailDrawer({
|
|
connector,
|
|
disabled,
|
|
pendingAction,
|
|
authorizationPending,
|
|
authorizationCancelFailed,
|
|
authorizationError,
|
|
toolsLoading,
|
|
toolsPreviewLoading,
|
|
toolsLoaded,
|
|
logoTheme,
|
|
onClose,
|
|
onConnect,
|
|
onDisconnect,
|
|
onCancelAuthorization,
|
|
onLoadMoreTools,
|
|
}: {
|
|
connector: ConnectorDetail;
|
|
disabled: boolean;
|
|
pendingAction: 'connect' | 'disconnect' | null;
|
|
authorizationPending?: ConnectorAuthorizationPending;
|
|
authorizationCancelFailed: boolean;
|
|
authorizationError: string | null;
|
|
toolsLoading: boolean;
|
|
toolsPreviewLoading: boolean;
|
|
toolsLoaded: boolean;
|
|
logoTheme: 'light' | 'dark';
|
|
onClose: () => void;
|
|
onConnect: (connectorId: string) => Promise<void> | void;
|
|
onDisconnect: (connectorId: string) => Promise<void> | void;
|
|
onCancelAuthorization: (connectorId: string) => void;
|
|
onLoadMoreTools: (connectorId: string, cursor: string) => Promise<void> | void;
|
|
}) {
|
|
const t = useT();
|
|
const isConnected = connector.status === 'connected';
|
|
const isConnecting = pendingAction === 'connect';
|
|
const isDisconnecting = pendingAction === 'disconnect';
|
|
const isAuthorizationPending = !isConnected && authorizationPending !== undefined;
|
|
const isPending = pendingAction !== null || isAuthorizationPending;
|
|
const canConnect = !disabled && !isPending && connector.status === 'available';
|
|
const canDisconnect = !disabled && !isPending && isConnected;
|
|
const accountLabel = getDisplayableConnectorAccountLabel(connector);
|
|
const actualToolCount = connector.tools.length;
|
|
const toolCount = getConnectorDisplayToolCount(connector);
|
|
const isLoadingTools = toolsPreviewLoading || !toolsLoaded || (toolsLoading && actualToolCount === 0);
|
|
const toolDetailsUnavailable = toolsLoaded && actualToolCount === 0 && toolCount > 0;
|
|
const showToolsBadge = connector.toolCount !== undefined || actualToolCount > 0 || toolsLoaded;
|
|
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
|
|
const categoryLabel = connectorCategoryLabel(connector.category, t);
|
|
const toolsBadgeLabel = formatToolsBadge(toolCount, t);
|
|
|
|
function continueAuthorization(event: SyntheticEvent) {
|
|
event.stopPropagation();
|
|
if (!authorizationPending?.redirectUrl) return;
|
|
void openExternalUrl(authorizationPending.redirectUrl);
|
|
}
|
|
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}
|
|
}
|
|
document.addEventListener('keydown', onKey);
|
|
closeBtnRef.current?.focus();
|
|
const previousOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
return () => {
|
|
document.removeEventListener('keydown', onKey);
|
|
document.body.style.overflow = previousOverflow;
|
|
};
|
|
}, [onClose]);
|
|
|
|
const statusTone = isAuthorizationPending ? 'pending' : connector.status;
|
|
|
|
return (
|
|
<div
|
|
className="connector-drawer-backdrop"
|
|
role="presentation"
|
|
onMouseDown={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<aside
|
|
className="connector-drawer"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="connector-drawer-title"
|
|
data-testid="connector-drawer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<header className="connector-drawer-head">
|
|
<ConnectorLogo connector={connector} theme={logoTheme} size="lg" />
|
|
<div className="connector-drawer-titles">
|
|
<div className="connector-drawer-eyebrow">
|
|
<span>{categoryLabel}</span>
|
|
<span className="connector-meta-dot" aria-hidden>·</span>
|
|
<span>{connector.provider}</span>
|
|
</div>
|
|
<h2 id="connector-drawer-title">{connector.name}</h2>
|
|
<div className="connector-drawer-status">
|
|
<span className={`connector-status-pill status-${statusTone}`}>
|
|
<span className="connector-status-dot" aria-hidden />
|
|
{isAuthorizationPending ? t('connectors.authorizationPending') : statusLabel(connector.status, t)}
|
|
</span>
|
|
{showToolsBadge ? (
|
|
<span className="connector-drawer-tool-count-chip" title={toolsBadgeLabel}>
|
|
<span>{toolsBadgeLabel}</span>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<button
|
|
ref={closeBtnRef}
|
|
type="button"
|
|
className="ghost connector-drawer-close"
|
|
onClick={onClose}
|
|
aria-label={t('common.close')}
|
|
data-testid="connector-drawer-close"
|
|
>
|
|
<Icon name="close" size={14} />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="connector-drawer-body">
|
|
{connector.description ? (
|
|
<section className="connector-drawer-section">
|
|
<h3 className="connector-drawer-section-title">{t('connectors.aboutLabel')}</h3>
|
|
<p className="connector-drawer-description">{connector.description}</p>
|
|
{isAuthorizationPending ? (
|
|
<div className="connector-authorization-block" role="status">
|
|
<p className="connector-authorization-hint">
|
|
{t('connectors.authorizationPendingHint')}
|
|
</p>
|
|
{authorizationPending.redirectUrl ? (
|
|
<button
|
|
type="button"
|
|
className="connector-authorization-link"
|
|
onClick={continueAuthorization}
|
|
>
|
|
{CONNECTOR_AUTH_CONTINUE_LABEL}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
) : null}
|
|
{authorizationError ? (
|
|
<p className="connector-authorization-hint connector-authorization-error" role="alert">
|
|
{authorizationError}
|
|
</p>
|
|
) : null}
|
|
{authorizationCancelFailed ? (
|
|
<p className="connector-authorization-hint connector-authorization-error" role="alert">
|
|
{AUTHORIZATION_CANCEL_FAILED_MESSAGE}
|
|
</p>
|
|
) : null}
|
|
|
|
<section className="connector-drawer-section">
|
|
<div className="connector-drawer-section-head">
|
|
<h3 className="connector-drawer-section-title">{t('connectors.detailsLabel')}</h3>
|
|
{isConnected ? (
|
|
<button
|
|
type="button"
|
|
className={`ghost connector-drawer-inline-action connector-action is-disconnect${isDisconnecting ? ' is-loading' : ''}`}
|
|
disabled={!canDisconnect}
|
|
aria-busy={isDisconnecting || undefined}
|
|
onClick={() => onDisconnect(connector.id)}
|
|
>
|
|
{isDisconnecting ? <Icon name="spinner" size={12} /> : null}
|
|
<span>{t('connectors.disconnect')}</span>
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
<dl className="connector-drawer-details">
|
|
<div>
|
|
<dt>{t('connectors.statusLabel')}</dt>
|
|
<dd>{statusLabel(connector.status, t)}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{t('connectors.categoryLabel')}</dt>
|
|
<dd>{categoryLabel}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{t('connectors.providerLabel')}</dt>
|
|
<dd>{connector.provider}</dd>
|
|
</div>
|
|
{accountLabel ? (
|
|
<div>
|
|
<dt>{t('connectors.account')}</dt>
|
|
<dd>{accountLabel}</dd>
|
|
</div>
|
|
) : null}
|
|
{connector.lastError ? (
|
|
<div className="connector-drawer-details-error">
|
|
<dt>{t('connectors.statusError')}</dt>
|
|
<dd>{connector.lastError}</dd>
|
|
</div>
|
|
) : null}
|
|
</dl>
|
|
</section>
|
|
|
|
<section className="connector-drawer-section">
|
|
<h3 className="connector-drawer-section-title">
|
|
{t('connectors.toolsSection')} <span className="connector-drawer-count">{toolCount}</span>
|
|
</h3>
|
|
{isLoadingTools ? (
|
|
<p className="connector-drawer-empty"><Icon name="spinner" size={12} /> {t('connectors.toolsLoading')}</p>
|
|
) : toolDetailsUnavailable ? (
|
|
<p className="connector-drawer-empty">{t('connectors.toolDetailsUnavailable', { n: toolCount })}</p>
|
|
) : actualToolCount === 0 ? (
|
|
<p className="connector-drawer-empty">{t('connectors.noToolsAvailable')}</p>
|
|
) : (
|
|
<>
|
|
<ul className="connector-drawer-tools">
|
|
{connector.tools.map((tool) => (
|
|
<li key={tool.name} className="connector-drawer-tool">
|
|
<div className="connector-drawer-tool-head">
|
|
<span className="connector-drawer-tool-title">{tool.title || tool.name}</span>
|
|
<span
|
|
className={`connector-drawer-tool-badge side-${tool.safety.sideEffect}`}
|
|
title={tool.safety.reason}
|
|
>
|
|
{tool.safety.sideEffect}
|
|
</span>
|
|
</div>
|
|
{tool.description ? (
|
|
<p className="connector-drawer-tool-desc">{tool.description}</p>
|
|
) : null}
|
|
<code className="connector-drawer-tool-name">{tool.name}</code>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{connector.toolsNextCursor ? (
|
|
<button
|
|
type="button"
|
|
className="ghost connector-drawer-load-more"
|
|
disabled={toolsPreviewLoading}
|
|
onClick={() => onLoadMoreTools(connector.id, connector.toolsNextCursor!)}
|
|
>
|
|
{toolsPreviewLoading ? <Icon name="spinner" size={12} /> : null}
|
|
<span>{t('connectors.loadMoreTools')}</span>
|
|
</button>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
{!isConnected ? (
|
|
<footer className="connector-drawer-foot">
|
|
<button
|
|
type="button"
|
|
className={`primary connector-action is-connect${isConnecting || isAuthorizationPending ? ' is-loading' : ''}`}
|
|
disabled={!canConnect}
|
|
aria-busy={isConnecting || isAuthorizationPending || undefined}
|
|
onClick={() => onConnect(connector.id)}
|
|
>
|
|
{isConnecting || isAuthorizationPending ? <Icon name="spinner" size={12} /> : null}
|
|
<span>{isAuthorizationPending ? t('connectors.authorizationPending') : t('connectors.connect')}</span>
|
|
</button>
|
|
{isAuthorizationPending ? (
|
|
<button
|
|
type="button"
|
|
className="ghost connector-action is-cancel-authorization"
|
|
onClick={() => onCancelAuthorization(connector.id)}
|
|
>
|
|
<span>{t('connectors.cancelAuthorization')}</span>
|
|
</button>
|
|
) : null}
|
|
</footer>
|
|
) : null}
|
|
</aside>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getDisplayableConnectorAccountLabel(connector: ConnectorDetail): string | undefined {
|
|
if (!connector.accountLabel) return undefined;
|
|
const provider = connector.auth?.provider ?? connector.provider.toLowerCase();
|
|
if (provider === 'composio') return undefined;
|
|
return connector.accountLabel;
|
|
}
|