import { Suspense, useEffect, useMemo, useState } from "react";import { useNavigate } from "@tanstack/react-router";import { useAtomValue, useAtomSet, useAtomRefresh } from "@effect/atom-react";import * as Exit from "effect/Exit";import { toast } from "sonner";import { trackEvent } from "../api/analytics";import { messageFromExit } from "../api/error-reporting";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import { AuthTemplateSlug, IntegrationSlug, ToolAddress, effectivePolicyFromSorted, type Connection, type Owner,} from "@executor-js/sdk/shared";import { checkConnectionHealth, connectionsAllAtom, integrationToolsAllAtom, integrationsOptimisticAtom, integrationAtom, policiesOptimisticAtom, refreshConnection, removeIntegrationOptimistic,} from "../api/atoms";import { connectionCheckKeys, connectionWriteKeys, integrationWriteKeys,} from "../api/reactivity-keys";import { ToolTree } from "../components/tool-tree";import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail";import type { ToolSummary } from "../components/tool-tree";import { AccountsSection } from "../components/accounts-section";import { IntegrationEditSheet } from "../components/metadata-edit-sheet";import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs";import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements";import { usePolicyActions } from "../hooks/use-policy-actions";import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client";import { Button } from "../components/button";import { Skeleton } from "../components/skeleton";import { useExecutorDocumentTitle } from "../lib/document-title";import { ErrorState } from "../components/error-state";import { isAsyncResultLoading } from "../lib/async-result";import { useConnectionsHealth } from "../lib/use-connection-health";import { integrationDetailInternalTabFromSearch, type IntegrationDetailInternalTab, type IntegrationDetailSearchTab,} from "../lib/integration-detail-tabs"; // v2: the route's `namespace` param is the integration slug. Tools belong to// the integration's per-owner connections; a tool's policy id is// `<integration>.<tool>` (D17). The wire row also carries the owner + the// connection name that produced it, so the Tools tab can group by account.type ToolRow = { readonly address: ToolAddress; readonly integration: string; readonly name: string; readonly description: string; readonly requiresApproval?: boolean; readonly owner: Owner; readonly connection: string; readonly static?: boolean;}; export function IntegrationDetailPage(props: { namespace: string; tab?: IntegrationDetailSearchTab; /** Route-validated `addAccount=1`: open the add-connection flow on arrival. * Set by the `/connect/<slug>` deep link, which navigates client-side and so * cannot rely on the raw location search below. */ addAccount?: boolean;}) { const { namespace } = props; const slug = IntegrationSlug.make(namespace); const integrationPlugins = useIntegrationPlugins(); const integration = useAtomValue(integrationAtom(slug)); // Tools + connections merge BOTH owners (omit-owner read); each row carries // its own owner + connection so the Tools tab can group per account. const tools = useAtomValue(integrationToolsAllAtom(slug)); const policies = useAtomValue(policiesOptimisticAtom); const connectionsResult = useAtomValue(connectionsAllAtom); const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); const refreshTools = useAtomRefresh(integrationToolsAllAtom(slug)); const doRemove = useAtomSet(removeIntegrationOptimistic, { mode: "promiseExit" }); const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" }); const doCheckHealth = useAtomSet(checkConnectionHealth, { mode: "promiseExit" }); // Policies are owner-partitioned on write; the integration policy menu writes // Workspace (org) rules, preserving the prior default behavior. const policyActions = usePolicyActions("org"); const navigate = useNavigate(); // HMR: refresh integration tools when the backend is hot-reloaded useEffect(() => { if (!import.meta.hot) return; const refresh = () => { refreshTools(); refreshIntegrations(); }; import.meta.hot.on("executor:backend-updated", refresh); return () => { import.meta.hot?.off("executor:backend-updated", refresh); }; }, [refreshTools, refreshIntegrations]); const [selectedToolId, setSelectedToolId] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState(false); const [deleting, setDeleting] = useState(false); const [refreshing, setRefreshing] = useState(false); const [retryingTools, setRetryingTools] = useState(false); const [editSheetOpen, setEditSheetOpen] = useState(false); const [activeTab, setActiveTab] = useState<IntegrationDetailInternalTab>(() => integrationDetailInternalTabFromSearch(props.tab), ); const [manualAccountHandoff, setManualAccountHandoff] = useState<IntegrationAccountHandoff | null>(null); const [locationSearch] = useState(() => typeof window === "undefined" ? "" : window.location.search, ); useEffect(() => { setConfirmDelete(false); setEditSheetOpen(false); }, [namespace]); useEffect(() => { setActiveTab(integrationDetailInternalTabFromSearch(props.tab)); }, [namespace, props.tab]); const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null; useExecutorDocumentTitle(integrationData?.name || namespace); const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in"; const currentTab = isBuiltInIntegration ? "tools" : activeTab; const canRefresh = integrationData?.canRefresh ?? false; const canRemove = integrationData?.canRemove ?? false; const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => { const search = new URLSearchParams(locationSearch); // The route-validated flag and the raw `addAccount=1` are the same request; // either one opens the flow. The extra prefill fields below are read from // the raw search, which is populated on the full page loads that carry them. if (!props.addAccount && search.get("addAccount") !== "1") return null; const owner = search.get("owner"); const template = search.get("template"); const label = search.get("label"); // `oauthClient=1` is the OAuth-app registration handoff // (`oauth.clients.createHandoff`): pull the NON-secret prefill fields. The // client secret is never in the URL; the human types it into the form. const oauthClient = ((): IntegrationAccountHandoff["oauthClient"] => { if (search.get("oauthClient") !== "1") return undefined; const slug = search.get("clientSlug"); const grant = search.get("grant"); const clientId = search.get("clientId"); const authorizationUrl = search.get("authorizationUrl"); const tokenUrl = search.get("tokenUrl"); const resource = search.get("resource"); return { ...(slug != null && slug.length > 0 ? { slug } : {}), ...(grant != null && grant.length > 0 ? { grant } : {}), ...(clientId != null && clientId.length > 0 ? { clientId } : {}), ...(authorizationUrl != null && authorizationUrl.length > 0 ? { authorizationUrl } : {}), ...(tokenUrl != null && tokenUrl.length > 0 ? { tokenUrl } : {}), ...(resource != null && resource.length > 0 ? { resource } : {}), }; })(); return { key: `${String(props.addAccount)}:${locationSearch}`, ...(owner === "org" || owner === "user" ? { owner } : {}), ...(template != null && template.length > 0 ? { template } : {}), ...(label != null && label.length > 0 ? { label } : {}), ...(oauthClient !== undefined ? { oauthClient } : {}), }; }, [locationSearch, props.addAccount]); const accountHandoff = manualAccountHandoff ?? urlAccountHandoff; useEffect(() => { if (accountHandoff && !isBuiltInIntegration) { setActiveTab("accounts"); } }, [accountHandoff, isBuiltInIntegration]); // Find the plugin edit component based on integration kind const editPlugin = useMemo(() => { if (!integrationData) return null; return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null; }, [integrationData, integrationPlugins]); // Policies are pre-sorted by the server in evaluation order (owner rank, then // position ASC). The matcher walks the list and stops at the first hit per // owner, mirroring server-side resolution. const policyList = useMemo( () => (AsyncResult.isSuccess(policies) ? policies.value : []), [policies], ); const sortedPolicies = useMemo( () => [...policyList].sort((a, b) => { if (a.position < b.position) return -1; if (a.position > b.position) return 1; return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }), [policyList], ); // Per-account selection id — uniquely identifies a tool row in the grouped // Tools tab: `${owner}:${connection}:${integration}.${name}`. The SAME tool // name appears under each connection that serves it, so the id must include // the account. The policy id (owner/connection-independent) stays // `<integration>.<tool>` for the leaf's `tool.name`. const accountToolId = (t: ToolRow): string => `${t.owner}:${t.connection}:${t.integration}.${t.name}`; // Per-selection metadata: address (schema), bare tool name (run-panel segment), // connection name + its OWN owner (so the run panel pre-selects that account // and addresses it under the connection's real owner — never an ambient one). const selectionById = useMemo(() => { const map = new Map< string, { readonly address: ToolAddress; readonly bareName: string; readonly connection: string; readonly owner: Owner; readonly static: boolean; } >(); if (!AsyncResult.isSuccess(tools)) return map; for (const t of tools.value as readonly ToolRow[]) { const id = accountToolId(t); if (!map.has(id)) { map.set(id, { address: t.address, bareName: t.name, connection: t.connection, owner: t.owner, static: t.static === true, }); } } return map; }, [tools]); // This integration's connections across BOTH owners — the accounts the run // panel can invoke a tool against to verify credentials. The panel addresses // each connection under its OWN owner (no ambient owner). const integrationConnections = useMemo<readonly Connection[]>(() => { if (!AsyncResult.isSuccess(connectionsResult)) return []; return (connectionsResult.value as readonly Connection[]).filter( (connection: Connection) => connection.integration === slug, ); }, [connectionsResult, slug]); const healthProbeFor = useConnectionsHealth(integrationConnections); const toolsHealthIssue = useMemo(() => { const issues = integrationConnections .map((connection) => ({ connection, probe: healthProbeFor(connection) })) .filter( ( entry, ): entry is { connection: Connection; probe: NonNullable<ReturnType<typeof healthProbeFor>>; } => entry.probe?.status === "expired" || entry.probe?.status === "degraded", ) .sort((a, b) => { const rank = (status: string): number => (status === "expired" ? 0 : 1); return rank(a.probe.status) - rank(b.probe.status); }); return issues[0] ?? null; }, [healthProbeFor, integrationConnections]); // Account-grouped tool rows for the Tools tab. NOT deduped across // connections: one row per (owner, connection, tool). The leaf's `name` is the // policy id `<integration>.<tool>` so leaf policy patterns stay correct, while // `id` is account-unique for selection and `owner`/`connection` drive grouping. const integrationTools: ToolSummary[] = useMemo(() => { if (!AsyncResult.isSuccess(tools)) return []; return (tools.value as readonly ToolRow[]).map((t) => { // Display id stays connection-agnostic (`integration.<tool>`); the policy // match uses the FULL address so connection-aware rules resolve correctly. const displayId = `${t.integration}.${t.name}`; const matchId = t.static ? String(t.address) : `${t.integration}.${t.owner}.${t.connection}.${t.name}`; return { id: accountToolId(t), name: displayId, description: t.description, policy: effectivePolicyFromSorted(matchId, policyList, t.requiresApproval), owner: t.owner, connection: t.connection, }; }); }, [tools, policyList]); // Distinct tool count (deduped across accounts) for the header — the grouped // list repeats a tool per connection, but the count should read like before. const distinctToolCount = useMemo(() => { const ids = new Set<string>(); for (const t of integrationTools) ids.add(t.name); return ids.size; }, [integrationTools]); const selectedTool = useMemo( () => integrationTools.find((t) => t.id === selectedToolId) ?? null, [integrationTools, selectedToolId], ); const selection = selectedToolId ? (selectionById.get(selectedToolId) ?? null) : null; const selectedAddress = selection?.address ?? null; const selectedBareName = selection?.bareName ?? null; const connectedEmptyTools = !isBuiltInIntegration && integrationConnections.length > 0 && integrationTools.length === 0; const hasToolSyncIssue = connectedEmptyTools && toolsHealthIssue !== null; const emptyToolsTitle = toolsHealthIssue ? toolsHealthIssue.probe.status === "expired" ? "Connection rejected" : "Tool sync failed" : "Checking connection"; const emptyToolsDescription = toolsHealthIssue?.probe.detail ?? "Checking whether this connection can load the GraphQL schema."; // Declared auth methods — derived server-side from the owning plugin's config // and carried on the integration catalog response. This is authoritative even // when the integration has zero connections (so e.g. an MCP OAuth server shows // its OAuth method before any account exists). const declaredMethods = useMemo<readonly AuthMethod[]>(() => { if (!integrationData) return []; return authMethodsFromDescriptors(integrationData.authMethods); }, [integrationData]); // Connection-inference fallback — only infer from real existing connections. // Do not invent an API-key method from an empty catalog response: open/no-auth // integrations intentionally have no credential method, and showing an API key // there misrepresents the integration. const inferredFallbackMethods = useMemo<readonly AuthMethod[]>(() => { const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; const templates = new Set<string>(); for (const connection of connections as readonly Connection[]) { if (connection.integration === slug) templates.add(String(connection.template)); } return Array.from(templates).map((template: string): AuthMethod => { const isOAuth = template === "oauth2" || template === "oauth"; return { id: template, label: isOAuth ? "OAuth2" : `API key (${template})`, kind: isOAuth ? "oauth" : "apikey", source: "spec", template: AuthTemplateSlug.make(template), placements: isOAuth ? [] : [{ carrier: "header", name: "Authorization", prefix: "" }], }; }); }, [connectionsResult, slug]); // Prefer the integration's DECLARED methods; only fall through to inference // when the plugin declares none (no projector → empty `authMethods`). const accountsMethods = declaredMethods.length > 0 ? declaredMethods : inferredFallbackMethods; const handleDelete = async () => { if (!integrationData) return; setDeleting(true); const exit = await doRemove({ params: { slug }, reactivityKeys: integrationWriteKeys, }); trackEvent("integration_removed", { integration_slug: String(slug), success: Exit.isSuccess(exit), }); if (Exit.isFailure(exit)) { setDeleting(false); setConfirmDelete(false); return; } void navigate({ to: "/{-$orgSlug}" }); }; const handleRefresh = async () => { if (!integrationData) return; setRefreshing(true); // v2: refresh re-resolves tools per connection. Refresh every connection of // this integration for the active owner. const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; const refreshExits: boolean[] = []; let connectionCount = 0; for (const connection of connections) { if (connection.integration !== slug) continue; connectionCount++; const refreshExit = await doRefresh({ params: { owner: connection.owner, integration: slug, name: connection.name }, reactivityKeys: connectionWriteKeys, }); refreshExits.push(Exit.isSuccess(refreshExit)); } trackEvent("integration_refreshed", { integration_slug: String(slug), connection_count: connectionCount, success: connectionCount > 0 && refreshExits.every(Boolean), }); setRefreshing(false); }; const handleRetryTools = async () => { if (retryingTools) return; setRetryingTools(true); let refreshedAny = false; // The first still-unhealthy verdict (or failed call) explains why nothing // synced; without it the button spins and stops with no visible change. let firstProblem: string | null = null; for (const connection of integrationConnections) { const ref = { owner: connection.owner, integration: slug, name: connection.name, }; const health = await doCheckHealth({ params: ref, query: {}, reactivityKeys: connectionCheckKeys, }); if (Exit.isFailure(health)) { firstProblem ??= messageFromExit(health, "Health check failed"); continue; } if (health.value.status !== "healthy") { firstProblem ??= health.value.detail ?? "The connection is still unhealthy."; continue; } const refreshed = await doRefresh({ params: ref, reactivityKeys: connectionWriteKeys, }); if (Exit.isFailure(refreshed)) { firstProblem ??= messageFromExit(refreshed, "Tool sync failed"); continue; } refreshedAny = true; } trackEvent("integration_refreshed", { integration_slug: String(slug), connection_count: integrationConnections.length, success: refreshedAny, }); if (refreshedAny) { refreshTools(); toast.success("Tools synced"); } else { toast.error(firstProblem ?? "No connections to sync"); } setRetryingTools(false); }; const handleOpenAddConnection = () => { setActiveTab("accounts"); setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` }); }; const handleTabChange = (value: string) => { const nextTab = value === "tools" ? "tools" : "accounts"; setActiveTab(nextTab); void navigate({ to: "/{-$orgSlug}/integrations/$namespace", params: { namespace }, search: { tab: nextTab, }, }); }; return ( <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> {/* Header bar */} <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm"> <div className="flex min-w-0 items-center gap-3"> <h2 className="truncate text-sm font-semibold text-foreground"> {integrationData?.name || namespace} </h2> {AsyncResult.isSuccess(tools) && ( <span className="hidden text-xs tabular-nums text-muted-foreground sm:block"> {distinctToolCount} {distinctToolCount === 1 ? "tool" : "tools"} </span> )} </div> <div className="flex shrink-0 items-center gap-2"> {!confirmDelete && !isBuiltInIntegration && integrationData && ( <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}> Edit </Button> )} {canRefresh && ( <Button variant="outline" size="sm" onClick={() => void handleRefresh()} disabled={refreshing} >Lines 1–500 of 744. Load more
import { Suspense, useEffect, useMemo, useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useAtomValue, useAtomSet, useAtomRefresh } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import { toast } from "sonner"; import { trackEvent } from "../api/analytics"; import { messageFromExit } from "../api/error-reporting"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AuthTemplateSlug, IntegrationSlug, ToolAddress, effectivePolicyFromSorted, type Connection, type Owner, } from "@executor-js/sdk/shared"; import { checkConnectionHealth, connectionsAllAtom, integrationToolsAllAtom, integrationsOptimisticAtom, integrationAtom, policiesOptimisticAtom, refreshConnection, removeIntegrationOptimistic, } from "../api/atoms"; import { connectionCheckKeys, connectionWriteKeys, integrationWriteKeys, } from "../api/reactivity-keys"; import { ToolTree } from "../components/tool-tree"; import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; import type { ToolSummary } from "../components/tool-tree"; import { AccountsSection } from "../components/accounts-section"; import { IntegrationEditSheet } from "../components/metadata-edit-sheet"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs"; import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements"; import { usePolicyActions } from "../hooks/use-policy-actions"; import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client"; import { Button } from "../components/button"; import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; import { useConnectionsHealth } from "../lib/use-connection-health"; import { integrationDetailInternalTabFromSearch, type IntegrationDetailInternalTab, type IntegrationDetailSearchTab, } from "../lib/integration-detail-tabs"; // v2: the route's `namespace` param is the integration slug. Tools belong to // the integration's per-owner connections; a tool's policy id is // `<integration>.<tool>` (D17). The wire row also carries the owner + the // connection name that produced it, so the Tools tab can group by account. type ToolRow = { readonly address: ToolAddress; readonly integration: string; readonly name: string; readonly description: string; readonly requiresApproval?: boolean; readonly owner: Owner; readonly connection: string; readonly static?: boolean; }; export function IntegrationDetailPage(props: { namespace: string; tab?: IntegrationDetailSearchTab; /** Route-validated `addAccount=1`: open the add-connection flow on arrival. * Set by the `/connect/<slug>` deep link, which navigates client-side and so * cannot rely on the raw location search below. */ addAccount?: boolean; }) { const { namespace } = props; const slug = IntegrationSlug.make(namespace); const integrationPlugins = useIntegrationPlugins(); const integration = useAtomValue(integrationAtom(slug)); // Tools + connections merge BOTH owners (omit-owner read); each row carries // its own owner + connection so the Tools tab can group per account. const tools = useAtomValue(integrationToolsAllAtom(slug)); const policies = useAtomValue(policiesOptimisticAtom); const connectionsResult = useAtomValue(connectionsAllAtom); const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); const refreshTools = useAtomRefresh(integrationToolsAllAtom(slug)); const doRemove = useAtomSet(removeIntegrationOptimistic, { mode: "promiseExit" }); const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" }); const doCheckHealth = useAtomSet(checkConnectionHealth, { mode: "promiseExit" }); // Policies are owner-partitioned on write; the integration policy menu writes // Workspace (org) rules, preserving the prior default behavior. const policyActions = usePolicyActions("org"); const navigate = useNavigate(); // HMR: refresh integration tools when the backend is hot-reloaded useEffect(() => { if (!import.meta.hot) return; const refresh = () => { refreshTools(); refreshIntegrations(); }; import.meta.hot.on("executor:backend-updated", refresh); return () => { import.meta.hot?.off("executor:backend-updated", refresh); }; }, [refreshTools, refreshIntegrations]); const [selectedToolId, setSelectedToolId] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState(false); const [deleting, setDeleting] = useState(false); const [refreshing, setRefreshing] = useState(false); const [retryingTools, setRetryingTools] = useState(false); const [editSheetOpen, setEditSheetOpen] = useState(false); const [activeTab, setActiveTab] = useState<IntegrationDetailInternalTab>(() => integrationDetailInternalTabFromSearch(props.tab), ); const [manualAccountHandoff, setManualAccountHandoff] = useState<IntegrationAccountHandoff | null>(null); const [locationSearch] = useState(() => typeof window === "undefined" ? "" : window.location.search, ); useEffect(() => { setConfirmDelete(false); setEditSheetOpen(false); }, [namespace]); useEffect(() => { setActiveTab(integrationDetailInternalTabFromSearch(props.tab)); }, [namespace, props.tab]); const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null; useExecutorDocumentTitle(integrationData?.name || namespace); const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in"; const currentTab = isBuiltInIntegration ? "tools" : activeTab; const canRefresh = integrationData?.canRefresh ?? false; const canRemove = integrationData?.canRemove ?? false; const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => { const search = new URLSearchParams(locationSearch); // The route-validated flag and the raw `addAccount=1` are the same request; // either one opens the flow. The extra prefill fields below are read from // the raw search, which is populated on the full page loads that carry them. if (!props.addAccount && search.get("addAccount") !== "1") return null; const owner = search.get("owner"); const template = search.get("template"); const label = search.get("label"); // `oauthClient=1` is the OAuth-app registration handoff // (`oauth.clients.createHandoff`): pull the NON-secret prefill fields. The // client secret is never in the URL; the human types it into the form. const oauthClient = ((): IntegrationAccountHandoff["oauthClient"] => { if (search.get("oauthClient") !== "1") return undefined; const slug = search.get("clientSlug"); const grant = search.get("grant"); const clientId = search.get("clientId"); const authorizationUrl = search.get("authorizationUrl"); const tokenUrl = search.get("tokenUrl"); const resource = search.get("resource"); return { ...(slug != null && slug.length > 0 ? { slug } : {}), ...(grant != null && grant.length > 0 ? { grant } : {}), ...(clientId != null && clientId.length > 0 ? { clientId } : {}), ...(authorizationUrl != null && authorizationUrl.length > 0 ? { authorizationUrl } : {}), ...(tokenUrl != null && tokenUrl.length > 0 ? { tokenUrl } : {}), ...(resource != null && resource.length > 0 ? { resource } : {}), }; })(); return { key: `${String(props.addAccount)}:${locationSearch}`, ...(owner === "org" || owner === "user" ? { owner } : {}), ...(template != null && template.length > 0 ? { template } : {}), ...(label != null && label.length > 0 ? { label } : {}), ...(oauthClient !== undefined ? { oauthClient } : {}), }; }, [locationSearch, props.addAccount]); const accountHandoff = manualAccountHandoff ?? urlAccountHandoff; useEffect(() => { if (accountHandoff && !isBuiltInIntegration) { setActiveTab("accounts"); } }, [accountHandoff, isBuiltInIntegration]); // Find the plugin edit component based on integration kind const editPlugin = useMemo(() => { if (!integrationData) return null; return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null; }, [integrationData, integrationPlugins]); // Policies are pre-sorted by the server in evaluation order (owner rank, then // position ASC). The matcher walks the list and stops at the first hit per // owner, mirroring server-side resolution. const policyList = useMemo( () => (AsyncResult.isSuccess(policies) ? policies.value : []), [policies], ); const sortedPolicies = useMemo( () => [...policyList].sort((a, b) => { if (a.position < b.position) return -1; if (a.position > b.position) return 1; return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }), [policyList], ); // Per-account selection id — uniquely identifies a tool row in the grouped // Tools tab: `${owner}:${connection}:${integration}.${name}`. The SAME tool // name appears under each connection that serves it, so the id must include // the account. The policy id (owner/connection-independent) stays // `<integration>.<tool>` for the leaf's `tool.name`. const accountToolId = (t: ToolRow): string => `${t.owner}:${t.connection}:${t.integration}.${t.name}`; // Per-selection metadata: address (schema), bare tool name (run-panel segment), // connection name + its OWN owner (so the run panel pre-selects that account // and addresses it under the connection's real owner — never an ambient one). const selectionById = useMemo(() => { const map = new Map< string, { readonly address: ToolAddress; readonly bareName: string; readonly connection: string; readonly owner: Owner; readonly static: boolean; } >(); if (!AsyncResult.isSuccess(tools)) return map; for (const t of tools.value as readonly ToolRow[]) { const id = accountToolId(t); if (!map.has(id)) { map.set(id, { address: t.address, bareName: t.name, connection: t.connection, owner: t.owner, static: t.static === true, }); } } return map; }, [tools]); // This integration's connections across BOTH owners — the accounts the run // panel can invoke a tool against to verify credentials. The panel addresses // each connection under its OWN owner (no ambient owner). const integrationConnections = useMemo<readonly Connection[]>(() => { if (!AsyncResult.isSuccess(connectionsResult)) return []; return (connectionsResult.value as readonly Connection[]).filter( (connection: Connection) => connection.integration === slug, ); }, [connectionsResult, slug]); const healthProbeFor = useConnectionsHealth(integrationConnections); const toolsHealthIssue = useMemo(() => { const issues = integrationConnections .map((connection) => ({ connection, probe: healthProbeFor(connection) })) .filter( ( entry, ): entry is { connection: Connection; probe: NonNullable<ReturnType<typeof healthProbeFor>>; } => entry.probe?.status === "expired" || entry.probe?.status === "degraded", ) .sort((a, b) => { const rank = (status: string): number => (status === "expired" ? 0 : 1); return rank(a.probe.status) - rank(b.probe.status); }); return issues[0] ?? null; }, [healthProbeFor, integrationConnections]); // Account-grouped tool rows for the Tools tab. NOT deduped across // connections: one row per (owner, connection, tool). The leaf's `name` is the // policy id `<integration>.<tool>` so leaf policy patterns stay correct, while // `id` is account-unique for selection and `owner`/`connection` drive grouping. const integrationTools: ToolSummary[] = useMemo(() => { if (!AsyncResult.isSuccess(tools)) return []; return (tools.value as readonly ToolRow[]).map((t) => { // Display id stays connection-agnostic (`integration.<tool>`); the policy // match uses the FULL address so connection-aware rules resolve correctly. const displayId = `${t.integration}.${t.name}`; const matchId = t.static ? String(t.address) : `${t.integration}.${t.owner}.${t.connection}.${t.name}`; return { id: accountToolId(t), name: displayId, description: t.description, policy: effectivePolicyFromSorted(matchId, policyList, t.requiresApproval), owner: t.owner, connection: t.connection, }; }); }, [tools, policyList]); // Distinct tool count (deduped across accounts) for the header — the grouped // list repeats a tool per connection, but the count should read like before. const distinctToolCount = useMemo(() => { const ids = new Set<string>(); for (const t of integrationTools) ids.add(t.name); return ids.size; }, [integrationTools]); const selectedTool = useMemo( () => integrationTools.find((t) => t.id === selectedToolId) ?? null, [integrationTools, selectedToolId], ); const selection = selectedToolId ? (selectionById.get(selectedToolId) ?? null) : null; const selectedAddress = selection?.address ?? null; const selectedBareName = selection?.bareName ?? null; const connectedEmptyTools = !isBuiltInIntegration && integrationConnections.length > 0 && integrationTools.length === 0; const hasToolSyncIssue = connectedEmptyTools && toolsHealthIssue !== null; const emptyToolsTitle = toolsHealthIssue ? toolsHealthIssue.probe.status === "expired" ? "Connection rejected" : "Tool sync failed" : "Checking connection"; const emptyToolsDescription = toolsHealthIssue?.probe.detail ?? "Checking whether this connection can load the GraphQL schema."; // Declared auth methods — derived server-side from the owning plugin's config // and carried on the integration catalog response. This is authoritative even // when the integration has zero connections (so e.g. an MCP OAuth server shows // its OAuth method before any account exists). const declaredMethods = useMemo<readonly AuthMethod[]>(() => { if (!integrationData) return []; return authMethodsFromDescriptors(integrationData.authMethods); }, [integrationData]); // Connection-inference fallback — only infer from real existing connections. // Do not invent an API-key method from an empty catalog response: open/no-auth // integrations intentionally have no credential method, and showing an API key // there misrepresents the integration. const inferredFallbackMethods = useMemo<readonly AuthMethod[]>(() => { const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; const templates = new Set<string>(); for (const connection of connections as readonly Connection[]) { if (connection.integration === slug) templates.add(String(connection.template)); } return Array.from(templates).map((template: string): AuthMethod => { const isOAuth = template === "oauth2" || template === "oauth"; return { id: template, label: isOAuth ? "OAuth2" : `API key (${template})`, kind: isOAuth ? "oauth" : "apikey", source: "spec", template: AuthTemplateSlug.make(template), placements: isOAuth ? [] : [{ carrier: "header", name: "Authorization", prefix: "" }], }; }); }, [connectionsResult, slug]); // Prefer the integration's DECLARED methods; only fall through to inference // when the plugin declares none (no projector → empty `authMethods`). const accountsMethods = declaredMethods.length > 0 ? declaredMethods : inferredFallbackMethods; const handleDelete = async () => { if (!integrationData) return; setDeleting(true); const exit = await doRemove({ params: { slug }, reactivityKeys: integrationWriteKeys, }); trackEvent("integration_removed", { integration_slug: String(slug), success: Exit.isSuccess(exit), }); if (Exit.isFailure(exit)) { setDeleting(false); setConfirmDelete(false); return; } void navigate({ to: "/{-$orgSlug}" }); }; const handleRefresh = async () => { if (!integrationData) return; setRefreshing(true); // v2: refresh re-resolves tools per connection. Refresh every connection of // this integration for the active owner. const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; const refreshExits: boolean[] = []; let connectionCount = 0; for (const connection of connections) { if (connection.integration !== slug) continue; connectionCount++; const refreshExit = await doRefresh({ params: { owner: connection.owner, integration: slug, name: connection.name }, reactivityKeys: connectionWriteKeys, }); refreshExits.push(Exit.isSuccess(refreshExit)); } trackEvent("integration_refreshed", { integration_slug: String(slug), connection_count: connectionCount, success: connectionCount > 0 && refreshExits.every(Boolean), }); setRefreshing(false); }; const handleRetryTools = async () => { if (retryingTools) return; setRetryingTools(true); let refreshedAny = false; // The first still-unhealthy verdict (or failed call) explains why nothing // synced; without it the button spins and stops with no visible change. let firstProblem: string | null = null; for (const connection of integrationConnections) { const ref = { owner: connection.owner, integration: slug, name: connection.name, }; const health = await doCheckHealth({ params: ref, query: {}, reactivityKeys: connectionCheckKeys, }); if (Exit.isFailure(health)) { firstProblem ??= messageFromExit(health, "Health check failed"); continue; } if (health.value.status !== "healthy") { firstProblem ??= health.value.detail ?? "The connection is still unhealthy."; continue; } const refreshed = await doRefresh({ params: ref, reactivityKeys: connectionWriteKeys, }); if (Exit.isFailure(refreshed)) { firstProblem ??= messageFromExit(refreshed, "Tool sync failed"); continue; } refreshedAny = true; } trackEvent("integration_refreshed", { integration_slug: String(slug), connection_count: integrationConnections.length, success: refreshedAny, }); if (refreshedAny) { refreshTools(); toast.success("Tools synced"); } else { toast.error(firstProblem ?? "No connections to sync"); } setRetryingTools(false); }; const handleOpenAddConnection = () => { setActiveTab("accounts"); setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` }); }; const handleTabChange = (value: string) => { const nextTab = value === "tools" ? "tools" : "accounts"; setActiveTab(nextTab); void navigate({ to: "/{-$orgSlug}/integrations/$namespace", params: { namespace }, search: { tab: nextTab, }, }); }; return ( <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> {/* Header bar */} <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm"> <div className="flex min-w-0 items-center gap-3"> <h2 className="truncate text-sm font-semibold text-foreground"> {integrationData?.name || namespace} </h2> {AsyncResult.isSuccess(tools) && ( <span className="hidden text-xs tabular-nums text-muted-foreground sm:block"> {distinctToolCount} {distinctToolCount === 1 ? "tool" : "tools"} </span> )} </div> <div className="flex shrink-0 items-center gap-2"> {!confirmDelete && !isBuiltInIntegration && integrationData && ( <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}> Edit </Button> )} {canRefresh && ( <Button variant="outline" size="sm" onClick={() => void handleRefresh()} disabled={refreshing} >