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);
}}
/>
);
}