744 lines · 29.4 KB · TypeScript
RawDownload
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.