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, integrationsAtom, connectionsForIntegrationAtom, providerAccountsAtom, 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 { MULTI_SERVICE_FAMILIES, normalizeEmail, type ProviderAccount,} from "../lib/provider-accounts";import { connectionNeedsReconsent, oauthReconnectPayload, reconnectMode, reconsentRequiredScopes,} from "../plugins/oauth-reconnect";import { useOAuthPopupFlow } from "../plugins/oauth-sign-in";import { AddAccountModal } from "./add-account-modal";import { EnableServicesModal, type EnableServiceIntegration } from "./enable-services-modal";import { ConnectionEditSheet } from "./metadata-edit-sheet";import type { CreateCustomMethod } from "./add-custom-method-modal";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";import { PlusIcon } from "lucide-react"; // ---------------------------------------------------------------------------// 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"]; 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 a probed identity (the live account), then the stored label, then the // connection name. The probe is the whole point: it shows WHICH account this is. const identity = (probe?.identity && probe.identity.length > 0 ? probe.identity : null) ?? (connection.identityLabel && connection.identityLabel.length > 0 ? connection.identityLabel : null); const displayLabel = identity ?? String(connection.name); const expired = status === "expired"; 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 === "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> {expired ? ( <Badge variant="destructive" className="shrink-0"> Expired </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} {needsReconsent ? ( <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground"> This connection wasn't granted all the access this integration now needs. </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 })); 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; // `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)) { 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") { toast.success("Reconnected"); trackEvent("connection_reconnected", { integration_slug: String(connection.integration), owner: connection.owner, success: true, }); return; } void oauthPopup.openAuthorization({ owner: payload.owner, 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) => { 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={() => void handleRemove(connection)} /> ))} </CardStackContent> </CardStack> );} type ProviderAccountForServices = ProviderAccount<Connection, EnableServiceIntegration>; const connectedServiceSlugs = (account: ProviderAccountForServices): ReadonlySet<string> => new Set(account.connections.map((entry) => String(entry.integration.slug))); const missingServicesFor = ( account: ProviderAccountForServices, integrations: readonly EnableServiceIntegration[],): readonly EnableServiceIntegration[] => { const connected = connectedServiceSlugs(account); return integrations.filter((integration) => !connected.has(String(integration.slug)));}; export function ProviderAccountsSection(props: { readonly family: string }) { const accountsResult = useAtomValue(providerAccountsAtom); const integrationsResult = useAtomValue(integrationsAtom); const ownerDisplay = useOwnerDisplay(); const [selectedAccount, setSelectedAccount] = useState<ProviderAccountForServices | null>(null); if (!MULTI_SERVICE_FAMILIES.has(props.family)) return null; const integrations: readonly EnableServiceIntegration[] = AsyncResult.isSuccess( integrationsResult, ) ? (integrationsResult.value as readonly EnableServiceIntegration[]) .filter((integration) => integration.kind === props.family) .sort((a, b) => a.name.localeCompare(b.name)) : []; const accounts: readonly ProviderAccountForServices[] = AsyncResult.isSuccess(accountsResult) ? (accountsResult.value as readonly ProviderAccountForServices[]).filter( (account) => account.family === props.family, ) : []; if (!AsyncResult.isSuccess(accountsResult) || !AsyncResult.isSuccess(integrationsResult)) { return ( <section className="space-y-3"> <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> Provider accounts </h3> <div className="flex items-center gap-2 py-3"> <div className="size-1.5 animate-pulse rounded-full bg-muted-foreground/30" /> <p className="text-sm text-muted-foreground">Loading provider accounts...</p> </div> </section> ); } if (accounts.length === 0) return null; const modalIntegrations = selectedAccount ? missingServicesFor(selectedAccount, integrations) : []; return ( <section className="space-y-3"> <div> <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> Provider accounts </h3> </div> <CardStack> <CardStackContent> {accounts.map((account) => { const missing = missingServicesFor(account, integrations); const canAdd = missing.some((integration) => integration.authMethods.some((method) => method.kind === "oauth"), ) && normalizeEmail(account.label) !== null; return ( <CardStackEntry key={account.identityKey} className="flex-wrap items-start gap-3"> <CardStackEntryContent> <CardStackEntryTitle className="flex min-w-0 items-center gap-2"> <span className="truncate">{account.label}</span> {ownerDisplay.showOwnerLabels ? ( <Badge variant="outline">{ownerLabel(account.owner as Owner)}</Badge> ) : null} </CardStackEntryTitle> <CardStackEntryDescription className="mt-1 text-xs"> {account.connections.length} connected{" "} {account.connections.length === 1 ? "service" : "services"} </CardStackEntryDescription> <div className="mt-3 divide-y divide-border rounded-md border border-border"> {account.connections.map((entry) => ( <div key={`${entry.connection.owner}:${String(entry.integration.slug)}:${String( entry.connection.name, )}`} className="flex items-center justify-between gap-3 px-3 py-2" > <div className="min-w-0"> <p className="truncate text-sm font-medium text-foreground"> {entry.integration.name} </p> <p className="truncate text-xs text-muted-foreground"> {entry.connection.identityLabel || String(entry.connection.name)} </p> </div> <Badge variant="outline">{String(entry.connection.name)}</Badge> </div> ))} </div> </CardStackEntryContent> <CardStackEntryActions className="self-start pt-0.5"> <Button type="button" variant="outline" size="sm" disabled={!canAdd} onClick={() => setSelectedAccount(account)} > <PlusIcon /> Add services </Button> </CardStackEntryActions> </CardStackEntry> ); })} </CardStackContent> </CardStack> {selectedAccount ? ( <EnableServicesModal open={true} account={selectedAccount} integrations={modalIntegrations} onOpenChange={(open) => { if (!open) setSelectedAccount(null); }} /> ) : null} </section>Lines 1–500 of 674. Load more
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, integrationsAtom, connectionsForIntegrationAtom, providerAccountsAtom, 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 { MULTI_SERVICE_FAMILIES, normalizeEmail, type ProviderAccount, } from "../lib/provider-accounts"; import { connectionNeedsReconsent, oauthReconnectPayload, reconnectMode, reconsentRequiredScopes, } from "../plugins/oauth-reconnect"; import { useOAuthPopupFlow } from "../plugins/oauth-sign-in"; import { AddAccountModal } from "./add-account-modal"; import { EnableServicesModal, type EnableServiceIntegration } from "./enable-services-modal"; import { ConnectionEditSheet } from "./metadata-edit-sheet"; import type { CreateCustomMethod } from "./add-custom-method-modal"; 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"; import { PlusIcon } from "lucide-react"; // --------------------------------------------------------------------------- // 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"]; 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 a probed identity (the live account), then the stored label, then the // connection name. The probe is the whole point: it shows WHICH account this is. const identity = (probe?.identity && probe.identity.length > 0 ? probe.identity : null) ?? (connection.identityLabel && connection.identityLabel.length > 0 ? connection.identityLabel : null); const displayLabel = identity ?? String(connection.name); const expired = status === "expired"; 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 === "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> {expired ? ( <Badge variant="destructive" className="shrink-0"> Expired </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} {needsReconsent ? ( <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground"> This connection wasn't granted all the access this integration now needs. </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 })); 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; // `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)) { 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") { toast.success("Reconnected"); trackEvent("connection_reconnected", { integration_slug: String(connection.integration), owner: connection.owner, success: true, }); return; } void oauthPopup.openAuthorization({ owner: payload.owner, 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) => { 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={() => void handleRemove(connection)} /> ))} </CardStackContent> </CardStack> ); } type ProviderAccountForServices = ProviderAccount<Connection, EnableServiceIntegration>; const connectedServiceSlugs = (account: ProviderAccountForServices): ReadonlySet<string> => new Set(account.connections.map((entry) => String(entry.integration.slug))); const missingServicesFor = ( account: ProviderAccountForServices, integrations: readonly EnableServiceIntegration[], ): readonly EnableServiceIntegration[] => { const connected = connectedServiceSlugs(account); return integrations.filter((integration) => !connected.has(String(integration.slug))); }; export function ProviderAccountsSection(props: { readonly family: string }) { const accountsResult = useAtomValue(providerAccountsAtom); const integrationsResult = useAtomValue(integrationsAtom); const ownerDisplay = useOwnerDisplay(); const [selectedAccount, setSelectedAccount] = useState<ProviderAccountForServices | null>(null); if (!MULTI_SERVICE_FAMILIES.has(props.family)) return null; const integrations: readonly EnableServiceIntegration[] = AsyncResult.isSuccess( integrationsResult, ) ? (integrationsResult.value as readonly EnableServiceIntegration[]) .filter((integration) => integration.kind === props.family) .sort((a, b) => a.name.localeCompare(b.name)) : []; const accounts: readonly ProviderAccountForServices[] = AsyncResult.isSuccess(accountsResult) ? (accountsResult.value as readonly ProviderAccountForServices[]).filter( (account) => account.family === props.family, ) : []; if (!AsyncResult.isSuccess(accountsResult) || !AsyncResult.isSuccess(integrationsResult)) { return ( <section className="space-y-3"> <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> Provider accounts </h3> <div className="flex items-center gap-2 py-3"> <div className="size-1.5 animate-pulse rounded-full bg-muted-foreground/30" /> <p className="text-sm text-muted-foreground">Loading provider accounts...</p> </div> </section> ); } if (accounts.length === 0) return null; const modalIntegrations = selectedAccount ? missingServicesFor(selectedAccount, integrations) : []; return ( <section className="space-y-3"> <div> <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> Provider accounts </h3> </div> <CardStack> <CardStackContent> {accounts.map((account) => { const missing = missingServicesFor(account, integrations); const canAdd = missing.some((integration) => integration.authMethods.some((method) => method.kind === "oauth"), ) && normalizeEmail(account.label) !== null; return ( <CardStackEntry key={account.identityKey} className="flex-wrap items-start gap-3"> <CardStackEntryContent> <CardStackEntryTitle className="flex min-w-0 items-center gap-2"> <span className="truncate">{account.label}</span> {ownerDisplay.showOwnerLabels ? ( <Badge variant="outline">{ownerLabel(account.owner as Owner)}</Badge> ) : null} </CardStackEntryTitle> <CardStackEntryDescription className="mt-1 text-xs"> {account.connections.length} connected{" "} {account.connections.length === 1 ? "service" : "services"} </CardStackEntryDescription> <div className="mt-3 divide-y divide-border rounded-md border border-border"> {account.connections.map((entry) => ( <div key={`${entry.connection.owner}:${String(entry.integration.slug)}:${String( entry.connection.name, )}`} className="flex items-center justify-between gap-3 px-3 py-2" > <div className="min-w-0"> <p className="truncate text-sm font-medium text-foreground"> {entry.integration.name} </p> <p className="truncate text-xs text-muted-foreground"> {entry.connection.identityLabel || String(entry.connection.name)} </p> </div> <Badge variant="outline">{String(entry.connection.name)}</Badge> </div> ))} </div> </CardStackEntryContent> <CardStackEntryActions className="self-start pt-0.5"> <Button type="button" variant="outline" size="sm" disabled={!canAdd} onClick={() => setSelectedAccount(account)} > <PlusIcon /> Add services </Button> </CardStackEntryActions> </CardStackEntry> ); })} </CardStackContent> </CardStack> {selectedAccount ? ( <EnableServicesModal open={true} account={selectedAccount} integrations={modalIntegrations} onOpenChange={(open) => { if (!open) setSelectedAccount(null); }} /> ) : null} </section>