import { AiConnectionPoolConnector } from "@/components/ai-connections/AiConnectionPoolConnector"; import { aiConnectionRouterPluginKey } from "@paperclipai/shared"; import { ConnectionInstructionsSettings } from "@/features/connections/ConnectionInstructions"; import { HonchoWorkspaceSettings } from "@/features/connections/HonchoWorkspaceSettings"; import { BrowserUseSettingsPanel } from "./app-detail/BrowserUseSettingsPanel"; import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE, isRemoteMcpConnectorId, isRemoteMcpConnectorMethod } from "@paperclipai/shared"; import { RemoteMcpManagement } from "@/features/connections/remote-mcp/RemoteMcpManagement"; import { remoteMcpProviders } from "@/features/connections/remote-mcp/providers"; import { ManagedAiConnectionDetails } from "@/components/ai-connections/ManagedAiConnectionDetails"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { EmailConnectionAccess } from "@/components/EmailConnectionAccess"; import { EmailConnectionInboxes } from "./chat/EmailEndpointSetup"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Loader2, Pencil } from "lucide-react"; import type { ToolApplication, ToolConnection, ToolPolicy, ToolProfileWithDetails, } from "@paperclipai/shared"; import { connectionDisplaySecondaryHint, humanizeConnectionDisplayName, aiSubscriptionNeedsIsolatedLogin, isToolConnectionAttentionHealth as isAttentionHealthStatus, } from "@paperclipai/shared"; import { Navigate, useParams, useNavigate, useSearchParams } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useToast } from "@/context/ToastContext"; import { queryKeys } from "@/lib/queryKeys"; import { consumeSkillSourceReturn } from "@/lib/skill-source-connect-return"; import { toolsApi } from "@/api/tools"; import { agentsApi } from "@/api/agents"; import { accessApi } from "@/api/access"; import { buildCompanyUserProfileMap } from "@/lib/company-members"; import { installStateFrom, type InstallState } from "@/lib/tool-installs"; import { navigateTopLevel } from "@/lib/browserNavigation"; import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { AppLogo } from "./AppLogo"; import { UnverifiedServerBadge } from "./UnverifiedServerBadge"; import { appApplicationSourceSlug, appConnectionSourceSlug, appDefinitionDarkLogoUrl, appDefinitionLogoUrl, appDefinitionName, appDefinitionSlug, type AppGalleryDisplayEntry, } from "./app-definition-display"; import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs"; import { ConnectionProvenanceChip } from "./ConnectionProvenanceChip"; import { IdentitiesSection } from "./app-detail/IdentitiesSection"; import { PermissionsPanel } from "./app-detail/PermissionsPanel"; import { AgentConnectionAccess } from "./app-detail/AgentConnectionAccess"; import { ConnectedAggregatorApps } from "./app-detail/ConnectedAggregatorApps"; import { isAppAggregator } from "@paperclipai/shared/aggregator-apps"; import { actionPermissionMutation } from "./app-detail/action-permissions"; import { RailwayAccessPanel } from "./app-detail/RailwayAccessPanel"; import { ReviewPanel } from "./app-detail/ReviewPanel"; import { AccessRetryBanner } from "./app-detail/AccessRetryBanner"; import { ReconnectCard, DangerZone, connectionAddress, connectionTransportLabel, } from "./app-detail/AdvancedPanel"; import type { AccessDraft } from "./app-detail/types"; import { connectionDisplayNameForOwner, connectionOwnerProfile, } from "./connection-owner"; export { connectionAddress, connectionTransportLabel }; export function AppDetail(props: { renderActions?: (connection: ToolConnection) => ReactNode; renderAgentSettings?: (connection: ToolConnection) => ReactNode; renderConnectionSettings?: (connection: ToolConnection) => ReactNode; onReconnect?: (connection: ToolConnection) => void } = {}) { const { connectionId = "" } = useParams<{ connectionId: string }>(); const connection = useQuery({ queryKey: queryKeys.tools.connection(connectionId), queryFn: () => toolsApi.getConnection(connectionId), enabled: !!connectionId }); if (connection.isPending) return

Loading connection…

; if (connection.error) return

{connection.error.message}

; const pluginKey = connection.data && aiConnectionRouterPluginKey(connection.data); // Keyed so page state, such as a failed refresh's recovery link, never carries over to another connection. return pluginKey ? : ; } function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectionSettings, onReconnect }: { renderActions?: (connection: ToolConnection) => ReactNode; /** Optional agent settings within Permissions, following the access controls. */ renderAgentSettings?: (connection: ToolConnection) => ReactNode; /** Provider prerequisites shown before identity and agent access. */ renderConnectionSettings?: (connection: ToolConnection) => ReactNode; onReconnect?: (connection: ToolConnection) => void; } = {}) { const { connectionId = "", tab } = useParams<{ connectionId: string; tab?: string }>(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); const { pushToast } = useToast(); const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null; const needsCatalog = activeTab === "review" || activeTab === "permissions"; const connectionQuery = useQuery({ queryKey: queryKeys.tools.connection(connectionId), queryFn: () => toolsApi.getConnection(connectionId), enabled: !!connectionId && !!activeTab, }); const applicationsQuery = useQuery({ queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listApplications(selectedCompanyId!), enabled: !!selectedCompanyId && !!activeTab, }); const installsQuery = useQuery({ queryKey: queryKeys.tools.connectionInstalls(connectionId), queryFn: () => toolsApi.getConnectionInstalls(connectionId), enabled: !!connectionId && activeTab === "permissions", }); const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listGallery(selectedCompanyId!), enabled: !!selectedCompanyId && !!activeTab, }); const catalogQuery = useQuery({ queryKey: queryKeys.tools.catalog(connectionId), queryFn: () => toolsApi.listCatalog(connectionId), enabled: !!connectionId && needsCatalog, }); const profilesQuery = useQuery({ queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listProfiles(selectedCompanyId!), enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"), }); const policiesQuery = useQuery({ queryKey: queryKeys.tools.policies(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listPolicies(selectedCompanyId!), enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"), }); const agentsQuery = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId && activeTab === "permissions", }); const userDirectoryQuery = useQuery({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"), queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), enabled: !!selectedCompanyId && !!activeTab, }); // Identity grants drive reconnect authorization on every tab as well as the // Permissions controls. A personal reconnect belongs to one fixed user, so // the banner must not offer that action to anyone else. const grantsQuery = useQuery({ queryKey: queryKeys.tools.connectionGrants(connectionId), queryFn: () => toolsApi.listConnectionGrants(connectionId), enabled: !!connectionId && !!activeTab, }); const connection = connectionQuery.data; const application = connection ? (applicationsQuery.data?.applications ?? []).find((candidate) => candidate.id === connection.applicationId) : undefined; const grantRows = grantsQuery.data?.grants ?? []; const retainedPersonalGrant = connection?.credentialPolicy === "per_user" ? grantRows.find((grant) => ( grant.kind === "user" && grant.subjectUserId === connection.createdByUserId )) ?? grantRows.find((grant) => grant.kind === "user" && grant.status === "active") ?? grantRows.find((grant) => grant.kind === "user") ?? null : null; const currentUserPersonalGrant = grantRows.find((grant) => ( grant.kind === "user" && grant.subjectUserId === grantsQuery.data?.currentUserId )) ?? null; const retainedAgentGrant = connection?.credentialPolicy === "per_agent" ? grantRows.find((grant) => grant.kind === "agent" && grant.status === "active") ?? grantRows.find((grant) => grant.kind === "agent") ?? null : null; const retainedOrganizationGrant = grantRows.find((grant) => ( grant.kind === "organization" && grant.isDefault )) ?? grantRows.find((grant) => grant.kind === "organization") ?? null; const managedIdentityGrant = connection?.credentialPolicy === "per_user" ? currentUserPersonalGrant ?? retainedPersonalGrant : connection?.credentialPolicy === "per_agent" ? retainedAgentGrant : connection?.credentialPolicy === "per_user_with_fallback" ? currentUserPersonalGrant ?? retainedOrganizationGrant : retainedOrganizationGrant; const managedPersonalUserId = managedIdentityGrant?.kind === "user" ? managedIdentityGrant.subjectUserId ?? connection?.createdByUserId ?? null : null; const canReconnect = managedIdentityGrant?.kind === "user" ? Boolean( managedPersonalUserId && managedPersonalUserId === grantsQuery.data?.currentUserId && grantsQuery.data?.capabilities.canConnectAsCurrentUser, ) : grantsQuery.data?.capabilities.canConfigure === true; const reconnectUnavailableMessage = grantsQuery.isLoading ? "Checking who can reconnect this identity…" : grantsQuery.isError ? "We couldn't verify who can reconnect this identity. Reload the page to try again." : managedIdentityGrant?.kind === "user" && managedPersonalUserId !== grantsQuery.data?.currentUserId ? "The person this connection belongs to must reconnect it." : "You don't have permission to reconnect this identity."; const logoEntry = useMemo( () => galleryEntryFor((galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[], connection, application), [galleryQuery.data, connection, application], ); const brandKey = appApplicationSourceSlug(application) ?? appConnectionSourceSlug(connection) ?? (logoEntry ? appDefinitionSlug(logoEntry) : null); const userProfileById = useMemo( () => buildCompanyUserProfileMap(userDirectoryQuery.data?.users), [userDirectoryQuery.data], ); const owner = connection ? connectionOwnerProfile(connection, userProfileById) : null; const baseAppName = connection ? logoEntry ? appDefinitionName(logoEntry) : humanizeConnectionDisplayName(connection) : "App"; const appName = connection ? connectionDisplayNameForOwner(connection, baseAppName, owner) : "App"; const successNoticeShownFor = useRef(null); useEffect(() => { if ( activeTab !== "permissions" || searchParams.get("success") !== "1" || !connection || successNoticeShownFor.current === connection.id ) return; successNoticeShownFor.current = connection.id; const skillSourcePath = selectedCompanyId === connection.companyId && connection.status === "active" && appConnectionSourceSlug(connection) === "github" ? consumeSkillSourceReturn(connection.companyId) : null; pushToast({ title: `${appName} connected`, body: skillSourcePath ? "Choose a repository to import your skills." : "The connection is ready. Review permissions or test an action below.", tone: "success", }); navigate(skillSourcePath ?? appTabHref(connection.id, "permissions"), { replace: true }); }, [activeTab, appName, connection, navigate, pushToast, searchParams, selectedCompanyId]); useEffect(() => { if (!activeTab) return; setBreadcrumbs([ { label: "Connectors", href: "/apps" }, { label: appName, href: appTabHref(connectionId, "permissions") }, { label: appTabLabel(activeTab) }, ]); return () => setBreadcrumbs([]); }, [setBreadcrumbs, appName, connectionId, activeTab]); const catalog = catalogQuery.data?.catalog ?? []; const profile = useMemo( () => (profilesQuery.data?.profiles ?? []).find((p) => p.profileKey === `app:${connectionId}`), [profilesQuery.data, connectionId], ); const enabledIds = useMemo(() => enabledCatalogIds(profile), [profile]); const askFirstIds = useMemo( () => askFirstCatalogIds(policiesQuery.data?.policies ?? [], connectionId), [policiesQuery.data, connectionId], ); const install = useMemo( () => installStateFrom(installsQuery.data?.installs ?? connection?.installs), [connection?.installs, installsQuery.data?.installs], ); const access = useMemo(() => accessFrom(connection?.connectionPurpose === "ai" ? undefined : profile, install), [connection?.connectionPurpose, profile, install]); const managesRemoteMcpAccess = isRemoteMcpConnectorMethod(connection?.config?.sourceTemplateKey, connection?.config?.connectionMethodKey); const agents = agentsQuery.data ?? []; const disconnectRemote = useMutation({ mutationFn: () => toolsApi.archiveConnection(connectionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); navigate("/apps"); }, onError: (error) => pushToast({ title: "Couldn't disconnect", body: error instanceof Error ? error.message : "Please try again.", tone: "error" }), }); const [pending, setPending] = useState(false); const persist = useMutation({ mutationFn: async (next: { enabled: Set; askFirst: Set; access: AccessDraft; reviewed?: Set; }) => { return connection?.connectionPurpose === "ai" ? toolsApi.putConnectionInstalls(connectionId, next.access.mode === "all" ? [{ targetType: "company", targetId: selectedCompanyId! }] : [...next.access.agentIds].map(targetId => ({ targetType: "agent" as const, targetId }))) : toolsApi.finishApp(selectedCompanyId!, connectionId, { enabledCatalogEntryIds: [...next.enabled], askFirstCatalogEntryIds: [...next.askFirst].filter((id) => next.enabled.has(id)), ...(next.reviewed ? { reviewedCatalogEntryIds: [...next.reviewed] } : {}), access: next.access.mode === "all" ? "all_agents" : { agentIds: [...next.access.agentIds] }, }); }, onMutate: () => setPending(true), onSuccess: () => { void installsQuery.refetch(); queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.policies(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); }, onError: (error) => pushToast({ title: "Couldn't save that", body: error instanceof Error ? error.message : "Please try again.", tone: "error", }), onSettled: () => setPending(false), }); const [renaming, setRenaming] = useState(false); const [nameDraft, setNameDraft] = useState(""); const rename = useMutation({ mutationFn: (name: string) => toolsApi.updateConnection(connectionId, { name }), onSuccess: () => { setRenaming(false); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); }, onError: (error) => pushToast({ title: "Couldn't rename the app", body: error instanceof Error ? error.message : "Please try again.", tone: "error", }), }); const startOAuth = useMutation({ mutationFn: (input?: { asAgentId?: string }) => toolsApi.startOAuth(connectionId, input), onSuccess: async (start) => { try { const target = await prepareOAuthNavigation(start); if (target.kind === "reauthentication" && start.handoff) { savePendingCloudHandoff(start.handoff.session); } navigateTopLevel(target.url); } catch (error) { pushToast({ title: "Couldn't start sign-in", body: error instanceof Error ? error.message : "Please try again.", tone: "error", }); } }, onError: (error) => pushToast({ title: "Couldn't start sign-in", body: error instanceof Error ? error.message : "Please try again.", tone: "error", }), }); const invalidateGrants = () => { queryClient.invalidateQueries({ queryKey: queryKeys.tools.connectionGrants(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); }; /** * "Connect as me" and "Reconnect" for the signed-in user's own identity. The * subject is always the caller — the server refuses any other subject — so * there is no path here to start consent on a coworker's behalf. */ const startPersonalAuth = useMutation({ mutationFn: () => { const subjectUserId = grantsQuery.data?.currentUserId; if (!subjectUserId) throw new Error("Sign in again to connect your own account."); return toolsApi.startPersonalAuthorization(selectedCompanyId!, connectionId, { subjectUserId, returnTo: appTabHref(connectionId, "permissions"), }); }, onSuccess: async ({ url, handoff }) => { try { const target = await prepareOAuthNavigation({ authorizationUrl: url, handoff }); if (target.kind === "reauthentication" && handoff) { savePendingCloudHandoff(handoff.session); } navigateTopLevel(target.url); } catch (error) { pushToast({ title: "Couldn't start sign-in", body: error instanceof Error ? error.message : "Please try again.", tone: "error", }); } }, onError: (error) => pushToast({ title: "Couldn't start sign-in", body: error instanceof Error ? error.message : "Please try again.", tone: "error", }), }); // A denied or conflicting audience save keeps the dialog open with the // selection intact, so the error is surfaced inline rather than as a toast. const [audienceError, setAudienceError] = useState(null); const [audienceOpenGrantId, setAudienceOpenGrantId] = useState(null); const replaceAudience = useMutation({ mutationFn: ({ grantId, memberUserIds }: { grantId: string; memberUserIds: string[] }) => toolsApi.replaceConnectionGrantMembers(connectionId, grantId, memberUserIds), onMutate: () => setAudienceError(null), onSuccess: (grant) => { invalidateGrants(); setAudienceOpenGrantId(null); pushToast({ title: "Audience saved", body: (grant.members?.length ?? 0) === 0 ? "Every organization member can use this identity." : `${grant.members?.length} ${grant.members?.length === 1 ? "member" : "members"} can use this identity.`, tone: "success", }); }, onError: (error) => setAudienceError(error instanceof Error ? error.message : "We couldn't save that audience."), }); const refreshTools = useMutation({ mutationFn: () => toolsApi.refreshCatalog(connectionId), onSuccess: (result) => { // Discovery extends the app profile for new actions as well as the catalog. queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.policies(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); pushToast({ title: `Found ${result.discoveredCount} ${result.discoveredCount === 1 ? "action" : "actions"}`, body: result.quarantinedCount > 0 ? `${result.quarantinedCount} new ${result.quarantinedCount === 1 ? "action needs" : "actions need"} your OK.` : undefined, tone: "success", }); }, onError: (error) => pushToast({ title: "Couldn't refresh actions", body: error instanceof Error ? error.message : "Please try again.", tone: "error", }), }); const refreshGitHubAccess = useMutation({ mutationFn: () => toolsApi.checkConnectionHealth(connectionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connectionGrants(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); pushToast({ title: "GitHub access refreshed", body: "Account, installation, and repository access are current.", tone: "success", }); }, onError: (error) => { // The failed check is saved on the connection; refetch so the banner // shows it next to any GitHub recovery link. queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); pushToast({ title: "Couldn't refresh GitHub access", body: error instanceof Error ? error.message : "Please try again.", tone: "error", }); }, }); const apply = (mutate: { enabled?: Set; askFirst?: Set; access?: AccessDraft; reviewed?: Set; }) => persist.mutate({ enabled: mutate.enabled ?? new Set(enabledIds), askFirst: mutate.askFirst ?? new Set(askFirstIds), access: mutate.access ?? access, reviewed: mutate.reviewed, }); const reviewQuarantined = (allowedIds: string[]) => { const quarantinedIds = new Set(quarantined.map((entry) => entry.id)); const nextEnabled = new Set([...enabledIds].filter((id) => !quarantinedIds.has(id))); for (const id of allowedIds) nextEnabled.add(id); apply({ enabled: nextEnabled, reviewed: quarantinedIds }); }; // Keep old bookmarks and OAuth return URLs working after Setup and Test were // consolidated into Permissions, and Activity moved to the company feed. if (connectionId && (tab === "setup" || tab === "test")) { const query = searchParams.toString(); return ; } if (tab === "activity") { return ; } if (!connectionId || !activeTab) { return ; } if (!selectedCompanyId) { return
Select an organization to manage apps.
; } if (connectionQuery.isLoading) { return (
); } if (!connection) { return (

We couldn't find that app.

); } if (isRetiredComposioConnection(connection)) { return

{appName}

Connection retired

{RETIRED_COMPOSIO_MESSAGE}

Remove each obsolete connection separately. Removing this one does not remove other connections.

{grantsQuery.data?.capabilities.canConfigure === true && disconnectRemote.mutate()} />}
; } const aiGrantRevoked = connection.connectionPurpose === "ai" && grantRows.length > 0 && grantRows.every((grant) => grant.status === "revoked"); const status: StatusInfo = aiGrantRevoked ? { label: "Revoked", tone: "attention" } : statusFor(connection); const needsReconnect = connection.requiresReauthorization ?? (status.tone === "attention" && connection.healthStatus !== "unknown"); const quarantined = catalog.filter((e) => e.status === "quarantined"); const active = catalog.filter((e) => e.status === "active"); const readOnly = active.filter((e) => e.isReadOnly); const canChange = active.filter((e) => !e.isReadOnly); const actionsContent = renderActions?.(connection) ?? (connection.connectionPurpose === "ai" ? : undefined); const actionCount = actionsContent !== undefined ? null : catalogQuery.data ? active.length : null; const reviewLoading = catalogQuery.isLoading || profilesQuery.isLoading || policiesQuery.isLoading; const permissionsLoading = reviewLoading || installsQuery.isLoading || agentsQuery.isLoading; const reviewFailed = catalogQuery.isError || profilesQuery.isError || policiesQuery.isError; const permissionsFailed = reviewFailed || installsQuery.isError || agentsQuery.isError; return (
{ setNameDraft(appName); setRenaming(true); }} onRenameCancel={() => setRenaming(false)} onRenameSubmit={(next) => { if (next && next !== appName) rename.mutate(next); else setRenaming(false); }} /> {status.tone === "attention" && connection.requiresReauthorization === false && ( refreshGitHubAccess.mutate()} /> )} {needsReconnect && ( onReconnect(connection) : (connection.connectionPurpose === "ai" || isRemoteMcpConnectorMethod(connection.config?.sourceTemplateKey, connection.config?.connectionMethodKey)) ? () => navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&reconnect=${connection.id}`) : undefined} onReconnected={() => { queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) }); }} /> )} {activeTab === "review" && ( reviewFailed ? { void catalogQuery.refetch(); void profilesQuery.refetch(); void policiesQuery.refetch(); }} /> : reviewLoading ? : )} {activeTab === "permissions" && ( permissionsFailed ? { void catalogQuery.refetch(); void profilesQuery.refetch(); void policiesQuery.refetch(); void installsQuery.refetch(); void agentsQuery.refetch(); }} /> : permissionsLoading ? :
{isAppAggregator(brandKey) && grantsQuery.data?.capabilities.canConfigure === true ? : null} {connection.config?.sourceTemplateKey === "honcho" && } {renderConnectionSettings?.(connection)} {connection.config?.sourceTemplateKey === "browser-use-cloud" && } {connection.config?.sourceTemplateKey === "railway" && } {connection.config?.provider === "agentmail" && } {connection.config?.provider === "agentmail" ? : <> agent.id === managedIdentityGrant.subjectAgentId) ?? null : null} grantsQuery={grantsQuery.data} loading={grantsQuery.isLoading} error={grantsQuery.isError} connectPending={startPersonalAuth.isPending || startOAuth.isPending} audiencePending={replaceAudience.isPending} audienceError={audienceError} audienceGrantId={audienceOpenGrantId} onOpenAudience={(grantId) => { setAudienceError(null); setAudienceOpenGrantId(grantId); }} onCloseAudience={() => { setAudienceOpenGrantId(null); setAudienceError(null); }} onConnectAsMe={() => onReconnect ? onReconnect(connection) : startPersonalAuth.mutate()} onConnectOrganization={() => onReconnect ? onReconnect(connection) : startOAuth.mutate()} onConnectAgent={(agentId) => startOAuth.mutate({ asAgentId: agentId })} onRefreshAccess={() => refreshGitHubAccess.mutate()} refreshAccessPending={refreshGitHubAccess.isPending} onReplaceAudience={(grant, memberUserIds) => replaceAudience.mutate({ grantId: grant.id, memberUserIds })} /> {connection.config?.sourceTemplateKey === "composio" ?

These permissions apply to all apps available through this Composio connection. Manage app accounts and sign-in in Composio.

: null} {connection.config?.sourceTemplateKey !== "composio" && isRemoteMcpConnectorMethod(connection.config?.sourceTemplateKey, connection.config?.connectionMethodKey) &&

Paperclip controls access to the tools listed here. App and action permissions inside these tools are managed in {baseAppName}.

} {logoEntry?.agentInstructions && }{renderAgentSettings?.(connection)}} actions={actionsContent} connectionId={connectionId} capabilities={grantsQuery.data?.capabilities} appName={appName} agents={agents} access={access} install={connection.connectionPurpose === "ai" || managesRemoteMcpAccess ? installStateFrom([]) : install} readOnly={readOnly} canChange={canChange} quarantined={quarantined} enabledIds={enabledIds} askFirstIds={askFirstIds} pending={pending} refreshPending={refreshTools.isPending} permissionChangeWarning={ connection.credentialPolicy === "per_agent" && managedIdentityGrant?.providerTenant?.github ? "Shell Git and gh use this account for the run and are not constrained by per-tool Ask-first controls." : undefined } onSaveAccess={(next) => apply({ access: connection.connectionPurpose === "ai" || managesRemoteMcpAccess ? next : accessIncludingInstalls(next, install) })} onRefreshActions={() => refreshTools.mutate()} onSetActionPermission={(ids, next) => apply(actionPermissionMutation(ids, next, enabledIds, askFirstIds))} onReviewQuarantined={reviewQuarantined} /> { await toolsApi.deleteProfile(profileId); await profilesQuery.refetch(); queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) }); }} /> {managesRemoteMcpAccess && isRemoteMcpConnectorId(connection.config?.sourceTemplateKey) && navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&reconnect=${connection.id}`)} onManage={() => window.open(remoteMcpProviders[connection.config?.sourceTemplateKey as keyof typeof remoteMcpProviders].dashboardUrl, "_blank", "noopener,noreferrer")} onDisconnect={() => disconnectRemote.mutateAsync()} />} }
)}
); } export function AppDetailHeader({ appName, connection, logoEntry, brandKey, allowRemoteLogo, status, actionCount, canRename = true, renaming, nameDraft, renamePending, onNameDraftChange, onRenameStart, onRenameCancel, onRenameSubmit, }: { appName: string; connection: ToolConnection; logoEntry: AppGalleryDisplayEntry | null; brandKey: string | null; allowRemoteLogo: boolean; status: StatusInfo; actionCount: number | null; canRename?: boolean; renaming: boolean; nameDraft: string; renamePending: boolean; onNameDraftChange: (value: string) => void; onRenameStart: () => void; onRenameCancel: () => void; onRenameSubmit: (value: string) => void; }) { const unverifiedHost = unverifiedRemoteHost(connection); return (
{renaming ? (
{ event.preventDefault(); onRenameSubmit(nameDraft.trim()); }} > onNameDraftChange(event.target.value)} className="h-9 w-64 text-lg font-bold" autoFocus />
) : (

{appName}

)}
{connection.config?.provider !== "agentmail" && actionCount !== null && ( {actionCount} {actionCount === 1 ? "action" : "actions"} available )} {connectionDisplaySecondaryHint(connection) ? ( {connectionDisplaySecondaryHint(connection)} ) : null} {unverifiedHost ? : null}
); } function ToolsLoading({ mcpActions = false }: { mcpActions?: boolean }) { return (
{mcpActions ? "Loading MCP actions, this may take a minute." : "Loading tools…"}
); } function ToolsLoadError({ onRetry }: { onRetry: () => void }) { return (

Couldn’t load tools for this app.

); } function unverifiedRemoteHost(connection: ToolConnection): string | null { const sourceTemplateKey = connection.config?.sourceTemplateKey ?? connection.transportConfig.sourceTemplateKey; if ( connection.transport !== "mcp_remote" || (typeof sourceTemplateKey === "string" && sourceTemplateKey.trim()) ) return null; const value = connection.config?.url ?? connection.config?.endpoint ?? connection.config?.remoteUrl ?? connection.transportConfig.url ?? connection.transportConfig.endpoint ?? connection.transportConfig.remoteUrl; if (typeof value !== "string") return null; try { return new URL(value).host || null; } catch { return null; } } type StatusInfo = { label: string; tone: "connected" | "attention" | "paused" }; function statusFor(connection: ToolConnection): StatusInfo { if (connection.enabled === false || connection.status === "disabled") { return { label: "Paused", tone: "paused" }; } if (isAttentionHealthStatus(connection.healthStatus) || (connection.connectionPurpose === "ai" && (connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(connection.config)))) { return { label: "Needs attention", tone: "attention" }; } return { label: "Connected", tone: "connected" }; } function StatusBadge({ status }: { status: StatusInfo }) { const klass: Record = { connected: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", attention: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300", paused: "border-border bg-muted text-muted-foreground", }; return ( {status.tone === "connected" && } {status.label} ); } function enabledCatalogIds(profile: ToolProfileWithDetails | undefined): Set { const ids = new Set(); for (const entry of profile?.entries ?? []) { if (entry.effect === "include" && entry.catalogEntryId) ids.add(entry.catalogEntryId); } return ids; } function askFirstCatalogIds(policies: ToolPolicy[], connectionId: string): Set { const ids = new Set(); for (const policy of policies) { if (policy.policyType !== "require_approval" || policy.enabled === false) continue; const config = (policy.config ?? {}) as { source?: unknown; connectionId?: unknown; catalogEntryId?: unknown }; if (config.source === "app_gallery_finish" && config.connectionId === connectionId && typeof config.catalogEntryId === "string") { ids.add(config.catalogEntryId); } } return ids; } /** * Who may use this connection, read back from the app profile's bindings. * * `finishApp` replaces a profile's whole binding set, so every save from this * page — including an action-permission toggle — has to restate this. The * Permissions tab exposes the bindings as Agent access, while installs remain * a separate "always loaded" choice. If the profile has no bindings yet, the * install state remains the safest legacy fallback. * * That fallback is the important part. It used to return "all agents" for an * unbound profile, which turned any unrelated save into a silent company-wide * grant from a control the reader could not see. Installs authorize their * targets, so mirroring the install state is both the truthful reading and the * one that agrees with what the tab displays. */ function accessFrom( profile: ToolProfileWithDetails | undefined, install: InstallState, ): AccessDraft { const bindings = profile?.bindings ?? []; if (bindings.some((b) => b.targetType === "company")) { return { mode: "all", agentIds: new Set() }; } const agentIds = new Set(bindings.filter((b) => b.targetType === "agent").map((b) => b.targetId)); if (agentIds.size > 0) return { mode: "specific", agentIds }; return install.onAll ? { mode: "all", agentIds: new Set() } : { mode: "specific", agentIds: new Set(install.agentIds) }; } function accessIncludingInstalls(next: AccessDraft, install: InstallState): AccessDraft { if (install.onAll || next.mode === "all") { return { mode: "all", agentIds: new Set() }; } return { mode: "specific", agentIds: new Set([...next.agentIds, ...install.agentIds]), }; } function galleryEntryFor( apps: AppGalleryDisplayEntry[], connection: ToolConnection | undefined, application: ToolApplication | undefined, ): AppGalleryDisplayEntry | null { if (!connection) return null; const sourceSlug = appApplicationSourceSlug(application) ?? appConnectionSourceSlug(connection); if (sourceSlug) { const keyed = apps.find((app) => appDefinitionSlug(app) === sourceSlug); if (keyed) return keyed; } const name = connection.name.toLowerCase(); return apps.find((app) => appDefinitionName(app).toLowerCase() === name) ?? apps.find((app) => appDefinitionSlug(app) === name) ?? null; }