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 { trackEvent } from "../api/analytics"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AuthTemplateSlug, IntegrationSlug, ToolAddress, effectivePolicyFromSorted, type Connection, type Owner, } from "@executor-js/sdk/shared"; import { connectionsAllAtom, integrationToolsAllAtom, integrationsOptimisticAtom, integrationAtom, policiesOptimisticAtom, refreshConnection, removeIntegrationOptimistic, } from "../api/atoms"; import { 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 { 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 // `.` (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/` 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" }); // 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(null); const [confirmDelete, setConfirmDelete] = useState(false); const [deleting, setDeleting] = useState(false); const [refreshing, setRefreshing] = useState(false); const [editSheetOpen, setEditSheetOpen] = useState(false); const [activeTab, setActiveTab] = useState(() => integrationDetailInternalTabFromSearch(props.tab), ); const [manualAccountHandoff, setManualAccountHandoff] = useState(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(() => { 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 // `.` 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(() => { if (!AsyncResult.isSuccess(connectionsResult)) return []; return (connectionsResult.value as readonly Connection[]).filter( (connection: Connection) => connection.integration === slug, ); }, [connectionsResult, slug]); // 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 `.` 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.`); 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(); 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; // 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(() => { 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(() => { const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; const templates = new Set(); 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 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 (
{/* Header bar */}

{integrationData?.name || namespace}

{AsyncResult.isSuccess(tools) && ( {distinctToolCount} {distinctToolCount === 1 ? "tool" : "tools"} )}
{!confirmDelete && !isBuiltInIntegration && integrationData && ( )} {canRefresh && ( )} {canRemove && (confirmDelete ? (
) : ( ))}
{!isBuiltInIntegration && Accounts} Tools
{/* Hub: integration-level auth methods + accounts. Plugins that declare auth methods fill the `accounts` slot (real methods from the plugin's config); otherwise we render the generic fallback. */} {!isBuiltInIntegration && ( {editPlugin?.accounts ? ( }> ) : (
)}
)} {/* Tools -- split pane (unchanged behavior) */} {isAsyncResultLoading(tools) ? ( ) : ( AsyncResult.match(tools, { onInitial: () => , onFailure: () => (
), onSuccess: () => (
{/* Left: tool tree */}
void policyActions.set(pattern, action)} onClearPolicy={(pattern) => void policyActions.clear(pattern)} policies={sortedPolicies} groupByConnection={!isBuiltInIntegration} />
{/* Right: tool detail with Schema · TypeScript · Run tabs */}
{selectedTool && selectedAddress && selectedBareName ? ( void policyActions.set(pattern, action)} onClearPolicy={(pattern, policyId) => void policyActions.clear(pattern, policyId) } {...(!selection?.static && selectedBareName ? { integration: slug, runToolName: selectedBareName, connections: integrationConnections, initialConnectionName: selection?.connection ?? null, } : {})} /> ) : !isBuiltInIntegration && integrationConnections.length === 0 ? ( 0} /> ) : ( 0} /> )}
), }) )}
); } function NoConnectionToolsEmptyState(props: { readonly onAddConnection: () => void; readonly canAddConnection: boolean; }) { return (

No tools yet

Add a connection to unlock this integration's tools.

); } function IntegrationDetailSkeleton() { return (
{/* Left: tool tree skeleton */}
{Array.from({ length: 8 }).map((_, i) => (
))}
{/* Right: tool detail skeleton */}
); } function AccountsSkeleton() { return (
); }