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://") ? (
{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;
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
Remove
);
}
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(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 (
{props.showOwnerLabels ? {ownerLabel(owner)} : null}
{rows.map((connection: Connection) => (
props.onEdit(connection)}
onReconnect={() => void handleReconnect(connection)}
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.