670 lines · 26.4 KB · TypeScript
RawDownload
import { useEffect, useMemo, useState } from "react";import { useAtomValue, useAtomSet } from "@effect/atom-react";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import * as Exit from "effect/Exit";import { IntegrationSlug, type Connection, type Owner } from "@executor-js/sdk/shared";import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";import { toast } from "sonner"; import {  addConnectionOptimistic,  connectionsForIntegrationAtom,  refreshConnection,  removeConnectionOptimistic,  startOAuth,} from "../api/atoms";import { connectionWriteKeys } from "../api/reactivity-keys";import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display";import { useConnectionHealth } from "../lib/use-connection-health";import { messageFromExit } from "../api/error-reporting";import { ownerLabel, useOwnerDisplay } from "../api/owner-display";import { trackEvent } from "../api/analytics";import type { AuthMethod } from "../lib/auth-placements";import {  connectionNeedsReconsent,  oauthReconnectPayload,  reconnectMode,  reconsentRequiredScopes,} from "../plugins/oauth-reconnect";import { useOAuthPopupFlow } from "../plugins/oauth-sign-in";import { AddAccountModal } from "./add-account-modal";import { ConnectionEditSheet } from "./metadata-edit-sheet";import type { CreateCustomMethod } from "./add-custom-method-modal";import {  AlertDialog,  AlertDialogAction,  AlertDialogCancel,  AlertDialogContent,  AlertDialogDescription,  AlertDialogFooter,  AlertDialogHeader,  AlertDialogTitle,} from "./alert-dialog";import { Badge } from "./badge";import { Button } from "./button";import {  CardStack,  CardStackContent,  CardStackEntry,  CardStackEntryActions,  CardStackEntryContent,  CardStackEntryDescription,  CardStackEntryTitle,  CardStackHeader,} from "./card-stack";import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuItem,  DropdownMenuTrigger,} from "./dropdown-menu"; // ---------------------------------------------------------------------------// Accounts section — the integration's connections, grouped by owner.//// Credentials are IMMUTABLE (no method switch); the editable surface is the// user-curated metadata — description (agent-visible) and account label — via// the per-row Edit sheet. "+ Add connection" opens the create modal. When both// owners have zero accounts, the section collapses to a single empty CTA.// --------------------------------------------------------------------------- const OWNERS: readonly Owner[] = ["org", "user"]; /** Render a health-check detail with any bare https URL as a clickable link. *  Exists for the misconfigured verdict, whose detail is the provider's own *  remediation text (Google's includes the console URL that enables the *  disabled API); the rest of the string stays plain text. */function DetailWithLinks(props: { readonly text: string }) {  const parts = props.text.split(/(https:\/\/[^\s,;)]+)/g);  return (    <>      {parts.map((part, index) =>        part.startsWith("https://") ? (          <a            // Stable for a given detail string: parts are positional.            // oxlint-disable-next-line react/no-array-index-key            key={index}            href={part}            target="_blank"            rel="noreferrer"            className="underline underline-offset-2 hover:text-foreground"          >            {part}          </a>        ) : (          part        ),      )}    </>  );} // The confirm dialog names the connection the way the row does — stored// identity label first, connection name otherwise. (No live probe identity// here; that state lives inside the row.)const connectionDisplayLabel = (connection: Connection | null): string => {  if (connection === null) return "connection";  return connection.identityLabel && connection.identityLabel.length > 0    ? connection.identityLabel    : String(connection.name);}; function AccountRow(props: {  readonly connection: Connection;  /** The integration declares scopes this connection was not granted — it must   *  reconnect to grant the newly-needed access (e.g. after a service was added). */  readonly needsReconsent: boolean;  readonly showOwnerLabel: boolean;  readonly onEdit: () => void;  readonly onReconnect: () => void;  readonly onRemove: () => void;}) {  const { connection, needsReconsent } = props;  const [checking, setChecking] = useState(false);   // The status renders WITHOUT any clicking: every checkHealth run persists its  // verdict on the connection row, so the list answers "has this expired?" at a  // glance. A live check from this session takes precedence. We deliberately do  // NOT derive expiry from the stored `expiresAt`: that's the access-token  // lifetime, which refreshes, so a passive countdown means nothing.  //  // Health checks are AUTOMATIC: the hook revalidates on mount, stale-while-  // revalidate style (shared with the integrations-list summary), and the  // persisted verdict renders instantly while the probe corrects it in place.  const { probe, status, runCheck } = useConnectionHealth(connection);  const indicator = HEALTH_INDICATOR_COLOR[status];   // Prefer the stored label from the connection row, then a probed identity,  // then the connection name. OAuth labels come from the grant's OIDC claims,  // while health identities remain useful for non-OAuth probes.  const identity =    (connection.identityLabel && connection.identityLabel.length > 0      ? connection.identityLabel      : null) ?? (probe?.identity && probe.identity.length > 0 ? probe.identity : null);  const displayLabel = identity ?? String(connection.name);   const expired = status === "expired";  // `misconfigured` is deliberately NOT folded into `needsHealthAttention`: it  // gets its own amber "API disabled" badge and its own link-rendered detail  // below, because the remediation is a console visit, not a reconnect.  const misconfigured = status === "misconfigured";  const needsHealthAttention = status === "expired" || status === "degraded";  const healthDetail = needsHealthAttention ? probe?.detail : undefined;  const missingOAuthScopes = connection.missingOAuthScopes ?? [];   const handleCheck = async () => {    if (checking) return;    setChecking(true);    const exit = await runCheck();    setChecking(false);    if (Exit.isFailure(exit)) {      toast.error(messageFromExit(exit, "Health check failed"));      return;    }    // The hook already folded the fresh probe into the live state.    if (exit.value.status === "healthy") {      toast.success(        exit.value.identity ? `Healthy: ${exit.value.identity}` : "Connection is healthy",      );    } else if (exit.value.status === "expired") {      toast.error("Connection expired, reconnect to restore access");    } else if (exit.value.status === "misconfigured") {      // NOT a reconnect prompt: the credential is fine; the upstream API is      // disabled where the OAuth client lives. The detail carries the      // provider's own instruction (with a console link for Google).      toast.warning(        exit.value.detail ?? "An upstream API is disabled for this connection's OAuth client",      );    } else if (exit.value.status === "degraded") {      toast.warning(exit.value.detail ?? "Connection check returned an error");    } else {      toast.message("No health check is configured for this integration");    }  };   return (    <CardStackEntry className="flex-wrap items-start">      <CardStackEntryContent>        <CardStackEntryTitle className="flex min-w-0 items-center gap-2">          <span            aria-label={`Status: ${HEALTH_STATUS_LABEL[status]}`}            title={HEALTH_STATUS_LABEL[status]}            className={`size-2 shrink-0 rounded-full ${indicator.dot}`}          />          <span className="truncate">{displayLabel}</span>          {needsHealthAttention ? (            <Badge variant={expired ? "destructive" : "outline"} className="shrink-0">              {HEALTH_STATUS_LABEL[status]}            </Badge>          ) : null}          {misconfigured ? (            <Badge              variant="outline"              className="shrink-0 border-amber-600/40 text-amber-600 dark:text-amber-500"            >              API disabled            </Badge>          ) : null}          {needsReconsent ? (            <Badge variant="outline" className="shrink-0 border-border text-muted-foreground">              Reconnect to grant access            </Badge>          ) : null}        </CardStackEntryTitle>        {connection.description && connection.description.length > 0 ? (          <CardStackEntryDescription className="mt-1 text-xs">            {connection.description}          </CardStackEntryDescription>        ) : null}        {misconfigured && probe?.detail ? (          // Not CardStackEntryDescription: that truncates to one line, and this          // text IS the remediation (the enable-API console link must stay          // visible in full). Wrap instead; break anywhere so the long URL          // cannot overflow the row.          <p className="mt-1 whitespace-normal text-xs text-muted-foreground [overflow-wrap:anywhere]">            <DetailWithLinks text={probe.detail} />          </p>        ) : null}        {healthDetail ? (          <CardStackEntryDescription className="mt-1 overflow-visible whitespace-normal text-clip text-xs text-muted-foreground">            {healthDetail}          </CardStackEntryDescription>        ) : null}        {needsReconsent ? (          <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">            This connection wasn't granted all the access this integration now needs.          </CardStackEntryDescription>        ) : null}        {missingOAuthScopes.length > 0 ? (          <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">            Missing scopes: {missingOAuthScopes.join(", ")}          </CardStackEntryDescription>        ) : null}      </CardStackEntryContent>      <CardStackEntryActions className="self-start pt-0.5">        {props.showOwnerLabel ? (          <Badge variant="outline">{ownerLabel(connection.owner)}</Badge>        ) : null}        <DropdownMenu>          <DropdownMenuTrigger asChild>            <Button              variant="ghost"              size="icon"              className="size-7 opacity-0 transition-opacity group-hover/card-stack-entry:opacity-100 group-focus-within/card-stack-entry:opacity-100 data-[state=open]:opacity-100"            >              <svg viewBox="0 0 16 16" className="size-3">                <circle cx="8" cy="3" r="1.2" fill="currentColor" />                <circle cx="8" cy="8" r="1.2" fill="currentColor" />                <circle cx="8" cy="13" r="1.2" fill="currentColor" />              </svg>            </Button>          </DropdownMenuTrigger>          <DropdownMenuContent align="end" className="w-40">            <DropdownMenuItem              className="text-sm"              disabled={checking}              onClick={() => void handleCheck()}            >              {checking ? "Checking…" : "Check now"}            </DropdownMenuItem>            <DropdownMenuItem className="text-sm" onClick={props.onEdit}>              Edit            </DropdownMenuItem>            <DropdownMenuItem className="text-sm" onClick={props.onReconnect}>              Reconnect            </DropdownMenuItem>            <DropdownMenuItem variant="destructive" className="text-sm" onClick={props.onRemove}>              Remove            </DropdownMenuItem>          </DropdownMenuContent>        </DropdownMenu>      </CardStackEntryActions>    </CardStackEntry>  );} function OwnerAccounts(props: {  readonly integration: IntegrationSlug;  readonly owner: Owner;  readonly showOwnerLabels: boolean;  readonly methods: readonly AuthMethod[];  readonly onEdit: (connection: Connection) => void;  readonly onDcrReconnect: (connection: Connection) => void;  /** The integration's declared oauth scopes — compared against each connection's   *  granted `oauthScope` to flag connections that must reconnect for new access. */  readonly declaredScopes: readonly string[] | undefined;}) {  const { integration, owner } = props;  const connections = useAtomValue(connectionsForIntegrationAtom({ integration, owner }));  // Removal confirms in a dialog. State lives here (not in the row) because the  // Remove menu item closes its dropdown on click, which would unmount a dialog  // nested inside it — so the row only nominates the connection to remove.  const [removingConnection, setRemovingConnection] = useState<Connection | null>(null);  const doRemove = useAtomSet(removeConnectionOptimistic(owner), {    mode: "promiseExit",  });  const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" });  const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });  // OAuth connections re-CONSENT on Reconnect (a token refresh cannot widen  // scopes and fails with no refresh token), so they re-run the OAuth flow. The  // popup flow re-mints the SAME connection (owner/integration/name) with a  // fresh refresh token + the widened scope union. Static creds keep the refresh  // path. One flow hosted per owner-group is enough — Reconnect is one-at-a-time.  const oauthPopup = useOAuthPopupFlow({    popupName: "reconnect-oauth",    detectPopupClosed: false,    startErrorMessage: "Failed to reconnect",  });   const rows: readonly Connection[] = AsyncResult.isSuccess(connections) ? connections.value : [];  if (rows.length === 0) return null;   const handleReconnect = async (connection: Connection) => {    // OAuth connection → re-run the OAuth flow (re-consent + widened scopes +    // fresh refresh token); re-minting overwrites the existing connection.    if (reconnectMode(connection) === "oauth") {      const method = props.methods.find(        (candidate: AuthMethod) =>          candidate.kind === "oauth" && String(candidate.template) === String(connection.template),      );      if (        method?.oauth?.supportsDynamicRegistration === true ||        method?.oauth?.discoveryUrl != null      ) {        props.onDcrReconnect(connection);        return;      }      const payload = oauthReconnectPayload(connection);      if (payload === null) return;      // Claim the sign-in window on the click: `oauth.start` below is a network      // round trip, and the browser's user activation can expire before it      // answers, which would leave Reconnect silently doing nothing.      const reservation = oauthPopup.reserve();      if (reservation.kind === "blocked") return;      // `oauth.start` discriminates the grant: client_credentials mints inline      // (`status: "connected"`, no authorization URL) while authorization_code      // returns a redirect the popup must complete. The popup hook only handles      // the redirect grant (a null authorization URL is an error there), so we      // start once here and branch — inline-connected is handled directly,      // redirect hands the already-issued URL to the popup. Both re-mint the      // SAME connection (owner/integration/name).      const startExit = await doStartOAuth({        payload,        reactivityKeys: connectionWriteKeys,      });      if (Exit.isFailure(startExit)) {        oauthPopup.releaseReservation();        toast.error(messageFromExit(startExit, "Failed to reconnect"));        trackEvent("connection_reconnected", {          integration_slug: String(connection.integration),          owner: connection.owner,          success: false,        });        return;      }      const started = startExit.value;      if (started.status === "connected") {        oauthPopup.releaseReservation();        toast.success("Reconnected");        trackEvent("connection_reconnected", {          integration_slug: String(connection.integration),          owner: connection.owner,          success: true,        });        return;      }      void oauthPopup.openAuthorization({        owner: payload.owner,        reservation,        run: () =>          Promise.resolve({            state: started.state,            authorizationUrl: started.authorizationUrl,          }),        onSuccess: () => {          toast.success("Reconnected");          trackEvent("connection_reconnected", {            integration_slug: String(connection.integration),            owner: connection.owner,            success: true,          });        },        onError: () => {          toast.error("Failed to reconnect");          trackEvent("connection_reconnected", {            integration_slug: String(connection.integration),            owner: connection.owner,            success: false,          });        },      });      return;    }    // Non-OAuth connection → token refresh (the original path).    const exit = await doRefresh({      params: {        owner: connection.owner,        integration: connection.integration,        name: connection.name,      },      reactivityKeys: connectionWriteKeys,    });    trackEvent("connection_reconnected", {      integration_slug: String(connection.integration),      owner: connection.owner,      success: Exit.isSuccess(exit),    });    if (Exit.isFailure(exit)) {      toast.error(messageFromExit(exit, "Failed to reconnect"));    }  };   const handleRemove = async (connection: Connection) => {    setRemovingConnection(null);    const exit = await doRemove({      params: {        owner: connection.owner,        integration: connection.integration,        name: connection.name,      },      reactivityKeys: connectionWriteKeys,    });    trackEvent("connection_removed", {      integration_slug: String(connection.integration),      owner: connection.owner,      success: Exit.isSuccess(exit),    });    if (Exit.isFailure(exit)) {      toast.error(messageFromExit(exit, "Failed to remove connection"));    }  };   return (    <CardStack>      {props.showOwnerLabels ? <CardStackHeader>{ownerLabel(owner)}</CardStackHeader> : null}      <CardStackContent>        {rows.map((connection: Connection) => (          <AccountRow            key={`${connection.owner}:${connection.integration}:${connection.name}`}            connection={connection}            needsReconsent={connectionNeedsReconsent(connection, props.declaredScopes)}            showOwnerLabel={props.showOwnerLabels}            onEdit={() => props.onEdit(connection)}            onReconnect={() => void handleReconnect(connection)}            onRemove={() => setRemovingConnection(connection)}          />        ))}      </CardStackContent>      <AlertDialog        open={removingConnection !== null}        onOpenChange={(open: boolean) => {          if (!open) setRemovingConnection(null);        }}      >        <AlertDialogContent size="sm">          <AlertDialogHeader>            <AlertDialogTitle>              Remove {connectionDisplayLabel(removingConnection)}?            </AlertDialogTitle>            <AlertDialogDescription>              Tools using this connection lose access. This cannot be undone; reconnecting later              creates a new connection.            </AlertDialogDescription>          </AlertDialogHeader>          <AlertDialogFooter>            <AlertDialogCancel>Cancel</AlertDialogCancel>            <AlertDialogAction              variant="destructive"              onClick={() => {                if (removingConnection !== null) void handleRemove(removingConnection);              }}            >              Remove connection            </AlertDialogAction>          </AlertDialogFooter>        </AlertDialogContent>      </AlertDialog>    </CardStack>  );} export function AccountsSection(props: {  readonly integration: IntegrationSlug;  readonly integrationName: string;  readonly methods: readonly AuthMethod[];  readonly accountHandoff?: IntegrationAccountHandoff | null;  /** When provided, Add connection shows a "+ Custom method" row. The plugin binds   *  this to its own configure mutation. Omitted for plugins with fixed auth. */  readonly createCustomMethod?: CreateCustomMethod;  readonly removeCustomMethod?: (method: AuthMethod) => Promise<boolean>;}) {

Lines 1–500 of 670.