import { useEffect, useMemo, useState } from "react"; import { useAtomValue, useAtomRefresh, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; import { IntegrationSlug, type Connection, type OAuthClientSummary, type Owner, } from "@executor-js/sdk/shared"; import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; import { toast } from "sonner"; import { addConnectionOptimistic, connectionsForIntegrationAtom, oauthClientsOptimisticAtom, 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, reconnectClientsView, reconnectMode, reconnectRoute, reconsentRequiredScopes, retryReconnectClientsOnMenuOpen, } from "../plugins/oauth-reconnect"; import { useOAuthPopupFlow } from "../plugins/oauth-sign-in"; import { AddAccountModal, hasDcr } 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://") ? ( {part} ) : ( 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; /** Reconnect routing needs the stored client binding; while the client * summaries carry no data the route is unknown, so the action is * disabled rather than guessed (same idiom as "Check now" above). */ readonly reconnectDisabled: boolean; /** The summaries query failed: the Reconnect item stays disabled but says * so (never a silently dead action). Retained stale data never routes — a * binding changed since the snapshot could misroute. Opening the menu * retries the query via `onMenuOpenChange`, so the hint reflects a retry * that just failed, not a permanently stuck state. */ readonly reconnectFailed: boolean; /** Forwarded to the row menu; the owner uses the OPEN transition to retry * a failed client-summaries query. */ readonly onMenuOpenChange: (open: boolean) => 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 ( {displayLabel} {needsHealthAttention ? ( {HEALTH_STATUS_LABEL[status]} ) : null} {misconfigured ? ( API disabled ) : null} {needsReconsent ? ( Reconnect to grant access ) : null} {connection.description && connection.description.length > 0 ? ( {connection.description} ) : 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.

) : null} {healthDetail ? ( {healthDetail} ) : null} {needsReconsent ? ( This connection wasn't granted all the access this integration now needs. ) : null} {missingOAuthScopes.length > 0 ? ( Missing scopes: {missingOAuthScopes.join(", ")} ) : null}
{props.showOwnerLabel ? ( {ownerLabel(connection.owner)} ) : null} void handleCheck()} > {checking ? "Checking…" : "Check now"} Edit Reconnect {props.reconnectFailed ? ( // Same failed-query voice as the modal's picker errors; the // trailing placement mirrors DropdownMenuShortcut. Failed to load ) : null} Remove
); } function OwnerAccounts(props: { readonly integration: IntegrationSlug; readonly owner: Owner; readonly showOwnerLabels: boolean; readonly methods: readonly AuthMethod[]; readonly onEdit: (connection: Connection) => void; /** Hand the connection to the modal's automatic reconnect flow. Only called * once the stored binding was vetted as auto-minted DCR (or gone); * `storedClient` is that binding's summary — undefined when the row is * gone — so the handoff can carry its resource. */ readonly onDcrReconnect: ( connection: Connection, storedClient: OAuthClientSummary | undefined, ) => 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 })); // Registered-app summaries: the Reconnect routing below inspects the STORED // client binding (its origin kind and resource), not just the method's // capability, before sending a connection into the automatic flow. Routing // only ever reads a current successful load — never stale data — and a // failed query marks the action failed (recoverable — opening the row menu // retries the query). const allClients = useAtomValue(oauthClientsOptimisticAtom); const refreshClients = useAtomRefresh(oauthClientsOptimisticAtom); const clientsView = reconnectClientsView(allClients); // 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(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), ); // Route by the STORED binding's origin (`reconnectRoute`): an // auto-minted DCR binding re-runs the automatic probe/registration flow // (direct reuse dead-ends once the callback origin drifts, #1542); a // static/BYO or first-party binding takes the direct path below even on // a discovery-capable integration — re-registering would silently // rebind the connection to an automatic client. Unless the client list // is a CURRENT success the binding is UNKNOWN and no route may be // chosen: the menu item is disabled until then, and this guard // backstops a race — a permanent wrong choice on a guess is never // acceptable. A failed refresh never routes, even when stale data is // retained: a binding changed since the snapshot could repeat the // origin-drift dead end or silently rebind to an automatic client. const route = reconnectRoute( clientsView.kind === "ready" ? clientsView.clients : undefined, connection, hasDcr(method), ); if (route.kind === "unknown") return; if (route.kind === "automatic") { props.onDcrReconnect(connection, route.stored); 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 ( {props.showOwnerLabels ? {ownerLabel(owner)} : null} {rows.map((connection: Connection) => ( props.onEdit(connection)} onReconnect={() => void handleReconnect(connection)} // An OAuth Reconnect routes by the stored client binding; without // a current successful load (loading or failed) the route is // unknown, so the action waits. Static-credential rows refresh // without the binding. reconnectDisabled={ reconnectMode(connection) === "oauth" && clientsView.kind !== "ready" } // A failed load is surfaced on the item (not silently disabled) // and recovers on menu open, which retries the query. reconnectFailed={reconnectMode(connection) === "oauth" && clientsView.kind === "failed"} onMenuOpenChange={(open: boolean) => retryReconnectClientsOnMenuOpen(open, clientsView, refreshClients) } onRemove={() => setRemovingConnection(connection)} /> ))} { if (!open) setRemovingConnection(null); }} > Remove {connectionDisplayLabel(removingConnection)}? Tools using this connection lose access. This cannot be undone; reconnecting later creates a new connection. Cancel { if (removingConnection !== null) void handleRemove(removingConnection); }} > Remove connection ); } 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; }) { const { integration, integrationName, methods, accountHandoff, createCustomMethod, removeCustomMethod, } = props; const [adding, setAdding] = useState(false); const [editingConnection, setEditingConnection] = useState(null); const [reconnectHandoff, setReconnectHandoff] = useState(null); const ownerDisplay = useOwnerDisplay(); const canAddConnection = methods.length > 0 || createCustomMethod !== undefined; useEffect(() => { if (accountHandoff) { setAdding(true); } }, [accountHandoff]); // The integration's declared oauth scopes — what connections need granted. A // connection granted fewer is flagged to reconnect (e.g. after a service was // added widened the consent). // // Spec-derived oauth scopes are the full per-operation catalog union (e.g. an // OpenAPI integration like PostHog declares hundreds of scopes). Those are requested // broadly but not individually required: a provider that narrows the grant to // the user's actual access is healthy, not in need of reconnect. So only treat // CUSTOM (user-configured) scopes as required here; never the spec catalog. const oauthMethod = methods.find((m: AuthMethod) => m.kind === "oauth"); const declaredScopes = reconsentRequiredScopes(oauthMethod); // Read both owners to decide between the grouped view and the empty CTA. The // grouped sub-components re-read these (effect-atom dedupes) and self-hide. const orgConnections = useAtomValue(connectionsForIntegrationAtom({ integration, owner: "org" })); const userConnections = useAtomValue( connectionsForIntegrationAtom({ integration, owner: "user" }), ); // Mount the optimistic-add atoms so the section participates in the same // optimistic surface the modal writes through (keeps the registry warm). useAtomSet(addConnectionOptimistic("org")); useAtomSet(addConnectionOptimistic("user")); const totalCount = useMemo(() => { const orgRows = AsyncResult.isSuccess(orgConnections) ? orgConnections.value.length : 0; const userRows = AsyncResult.isSuccess(userConnections) ? userConnections.value.length : 0; return orgRows + userRows; }, [orgConnections, userConnections]); const loading = !AsyncResult.isSuccess(orgConnections) && !AsyncResult.isSuccess(userConnections); // When there are zero connections the dashed empty-state card below carries // its own "Add connection" CTA, so the header button would be a redundant // second copy of the same action. Show the header button only outside that // state (populated, or still loading). const showEmptyState = !loading && totalCount === 0; const openAddConnection = () => { trackEvent("connection_add_opened", { integration_slug: String(integration), has_oauth_method: methods.some((m: AuthMethod) => m.kind === "oauth"), has_api_key_method: methods.some((m: AuthMethod) => m.kind !== "oauth" && m.kind !== "none"), }); setAdding(true); }; const modalState = reconnectHandoff ?? accountHandoff; const modal = ( { setAdding(open); if (!open) setReconnectHandoff(null); }} initialState={modalState} createCustomMethod={createCustomMethod} removeCustomMethod={removeCustomMethod} /> ); return (

Connections

{!showEmptyState ? ( ) : null}
{loading ? (

Loading accounts…

) : showEmptyState ? (

No connections yet

Add a connection to make this integration's tools available.

) : (
{OWNERS.map((owner: Owner) => ( { if (connection.oauthClient == null) return; setReconnectHandoff({ key: `reconnect:${connection.owner}:${String(connection.integration)}:${String( connection.name, )}:${Date.now()}`, owner: connection.owner, template: String(connection.template), label: String(connection.name), ...(connection.identityLabel != null ? { identityLabel: connection.identityLabel } : {}), oauthClient: { action: "reconnect", slug: String(connection.oauthClient), owner: connection.oauthClientOwner ?? connection.owner, // Vetted by the routing in `handleReconnect`: the stored // binding is auto-minted DCR (or its row is gone), so the // modal may re-run the automatic flow. dynamicRegistration: true, // The stored client's RFC 8707 resource — an EXPLICIT null // for a client registered WITHOUT a resource indicator // (that absence always survives re-registration); a stored // value is reconciled against the probe downstream, so a // migrated resource follows the server. Omitted when the // row is gone. ...(storedClient !== undefined ? { resource: storedClient.resource ?? null } : {}), }, }); }} declaredScopes={declaredScopes} /> ))}
)} {modal} { if (!open) setEditingConnection(null); }} />
); }