554 lines · 22.5 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 { 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"; // 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 }) {  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<string | null>(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<"accounts" | "tools">("accounts");  const [locationSearch] = useState(() =>    typeof window === "undefined" ? "" : window.location.search,  );   useEffect(() => {    setConfirmDelete(false);    setEditSheetOpen(false);  }, [namespace]);   const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;  const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";  const currentTab = isBuiltInIntegration ? "tools" : activeTab;  const canRefresh = integrationData?.canRefresh ?? false;  const canRemove = integrationData?.canRemove ?? false;  const accountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {    if (locationSearch.length === 0) return null;    const search = new URLSearchParams(locationSearch);    if (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: locationSearch,      ...(owner === "org" || owner === "user" ? { owner } : {}),      ...(template != null && template.length > 0 ? { template } : {}),      ...(label != null && label.length > 0 ? { label } : {}),      ...(oauthClient !== undefined ? { oauthClient } : {}),    };  }, [locationSearch]);   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]);   // 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;   // 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);  };   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}            >              {refreshing ? "Refreshing..." : "Refresh"}            </Button>          )}           {canRemove &&            (confirmDelete ? (              <div className="flex items-center gap-2">                <Button                  variant="outline"                  size="sm"                  onClick={() => setConfirmDelete(false)}                  disabled={deleting}                >                  Cancel                </Button>                <Button                  variant="destructive"                  size="sm"                  onClick={() => void handleDelete()}                  disabled={deleting}                >                  {deleting ? "Deleting..." : "Confirm Delete"}                </Button>              </div>            ) : (              <Button                variant="outline"                size="sm"                onClick={() => setConfirmDelete(true)}                className="border-destructive/30 text-destructive hover:bg-destructive/10"              >                Delete              </Button>            ))}        </div>      </div>       <Tabs        value={currentTab}        onValueChange={(value: string) => setActiveTab(value as "accounts" | "tools")}        className="min-h-0 flex-1 gap-0 overflow-hidden"      >        <div className="shrink-0 border-b border-border/60 px-4 py-2">          <TabsList variant="line">            {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>}            <TabsTrigger value="tools">Tools</TabsTrigger>          </TabsList>        </div>         {/* 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 && (          <TabsContent value="accounts" className="min-h-0 overflow-y-auto">            {editPlugin?.accounts ? (              <Suspense fallback={<AccountsSkeleton />}>                <editPlugin.accounts                  sourceId={namespace}                  integrationName={integrationData?.name || namespace}                  accountHandoff={accountHandoff}                />              </Suspense>            ) : (              <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">                <AccountsSection                  integration={slug}                  integrationName={integrationData?.name || namespace}                  methods={accountsMethods}                  accountHandoff={accountHandoff}                />              </div>            )}          </TabsContent>        )}         {/* Tools -- split pane (unchanged behavior) */}        <TabsContent          value="tools"          className="flex min-h-0 flex-col overflow-hidden data-[state=inactive]:hidden"        >          {AsyncResult.match(tools, {            onInitial: () => <IntegrationDetailSkeleton />,            onFailure: () => (              <div className="p-6 text-sm text-destructive">Failed to load tools</div>            ),            onSuccess: () => (              <div className="flex min-h-0 flex-1 overflow-hidden">                {/* Left: tool tree */}                <div className="flex w-72 shrink-0 flex-col border-r border-border/60 lg:w-80 xl:w-[22rem]">                  <ToolTree                    tools={integrationTools}                    selectedToolId={selectedToolId}                    onSelect={setSelectedToolId}                    onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)}                    onClearPolicy={(pattern) => void policyActions.clear(pattern)}                    policies={sortedPolicies}                    groupByConnection={!isBuiltInIntegration}                  />                </div>                 {/* Right: tool detail with Schema · TypeScript · Run tabs */}                <div className="flex min-w-0 flex-1 flex-col overflow-hidden">                  {selectedTool && selectedAddress && selectedBareName ? (                    <ToolDetail                      address={selectedAddress}                      toolName={selectedTool.name}                      staticTool={selection?.static}                      policy={selectedTool.policy}                      onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)}                      onClearPolicy={(pattern) => void policyActions.clear(pattern)}                      {...(!selection?.static && selectedBareName                        ? {                            integration: slug,                            runToolName: selectedBareName,                            connections: integrationConnections,                            initialConnectionName: selection?.connection ?? null,                          }                        : {})}                    />                  ) : (                    <ToolDetailEmpty hasTools={integrationTools.length > 0} />                  )}                </div>              </div>            ),          })}        </TabsContent>      </Tabs>       <IntegrationEditSheet        slug={slug}        open={editSheetOpen}        name={integrationData?.name || namespace}        description={integrationData?.description ?? ""}

Lines 1–500 of 554.