mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - AI Connections separate account access from models and harnesses. > - A pool must act as one connection while retaining each task’s account. > - Core must enforce member access and preserve session and recovery rules. > - A plugin supplies rotation policy without receiving credentials. > - This change adds durable routing and native connector setup and management. ## Linked Issues or Issue Description **Subsystem affected** AI Connections, Connectors, plugins, run dispatch, and session compatibility. **Problem or motivation** Operators need to rotate new tasks across saved accounts while each task keeps its account and session. Pool setup must fit the existing connector catalog and account workflow. **Proposed solution** Add an experimental router binding, a capability-gated plugin hook, and transactional task pins. Plugins declare native pooled connectors through `aiConnectionRouter`. Core hosts the existing-account picker, ordering step, and account settings. Related usage contract: #14936. Companion private plugin: https://github.com/paperclipai/paperclip-cloud/pull/643. **Roadmap alignment** This extends Apps and AI Connections. Core supplies generic enforcement and native connector UI; the private plugin owns rotation and quota policy. The prior duplicate search found no matching router implementation. ## What Changed - Add a router binding without changing existing concrete bindings. Keep the instance flag and new pools disabled by default. Require manual operator configuration. Show no routing toggle in Experimental settings on either open-source or Cloud installs, even after routing is enabled. - Persist company-scoped pools, one shared cursor per pool, and pins keyed by company, pool, agent, and task. Commit pins and cursor advances together with revision checks and bounded retries. Persist run-ID affinity before allocation. - Pass only authorized metadata and normalized usage to plugins. Core retains credential handling, member access checks, runtime qualification, and recovery evidence. Probe outside locks with a shared 15-second budget and freshness cache. - Resolve routing before credential preparation and backend selection. Preserve pins through turns, session resets, removed members, and quota waits. Retain admitted recovery after disable or uninstall. - Separate credential session epochs from token generations. Verified refresh preserves the epoch; reconnect and manual replacement change it. Include the credential slot ID in session and usage-cache identity, so reconnecting an indexed legacy account invalidates its old session even when both epochs are zero. - Validate pool member installations before accepting saved-agent bindings and recheck compatibility when the harness changes. Install only authorized members in the new-agent transaction and record their IDs in local activity. Pool membership cannot install a restricted shared connection. - Preserve pool bindings when agents hire teammates through either creation API or native caller runtime inheritance. Block stale manager credential references; retain explicit child authentication precedence and reject incompatible inherited pools. - Add native connector registration through plugin metadata. Reuse the Connectors catalog, setup header, account header, sidebar, dialogs, and usage display. Setup selects and orders saved connections. Advanced settings hold usage rules and member runtime defaults. New-account setup opens in another tab. - Use revision-checked pool archival from the Connectors catalog and account page. Keep task pins, cursors, recovery evidence, and underlying connections. Reject ordinary connection updates or removals that bypass pool revisions. - Add pool selectors, composer models, override notes, quota status, run details, activity records, and local run-log records. Keep session-adoption copy minimal. - Show **Used by** below the pool connections. List current company agents with shared avatars and profile links. Include paused agents; exclude terminated agents and agents using another pool. - Add Core stories for the generic connector workflow and runtime surfaces. Cloud stories reuse these production routes and tokens through a preview-only alias. ## Verification - Final head `73cb953bca30ed83e4505dd820edd9b5edffd28b`: full workspace `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates` pass locally. - All 422 focused connector/settings/shared-contract/migration tests and all 156 database-backed AI connection, hiring, reconnect, and durable-routing cases pass (69 hiring cases rerun after the final auth-precedence fix). The merged shared contract retains connection instructions and pool metadata. The pool migration is generated at sequence 0299 after the latest upstream migrations; this PR makes no lockfile changes. - All four full-app Playwright tests pass on the final head after a cold restart and migration, against the installed private plugin and isolated database, with no route or pool-API mocks. They cover hidden routing controls after manual opt-in, native pool creation, ordering, membership edits, rename, paused defaults, enabling/save/refresh persistence, stale edits, cancellation/removal, preserved underlying accounts, unavailable routers, and Used by avatars and profile links. Exact command: `PAPERCLIP_CONNECTION_POOL_E2E=1 AI_CONNECTIONS_TEST_COMPANY_ID=a37b9625-5ecf-4e29-8081-04df3d6e7d6f AI_CONNECTIONS_TEST_URL=http://127.0.0.1:3108 pnpm exec playwright test --config tests/ai-connections-app/playwright.config.ts connection-pools.spec.ts`. - [Native setup, ordering, and management screenshots](https://github.com/paperclipai/paperclip/pull/14967#issuecomment-6006976278) address the review follow-up. [Earlier selector, quota, and run-detail screenshots](https://github.com/paperclipai/paperclip/pull/14967#issuecomment-5971537316) show the runtime surfaces. Core previews: `pnpm --filter @paperclipai/ui storybook`, then **Connectors / Pool host** or **AI Connections / Connection pools**. Cloud owns its host-backed plugin stories; both repositories’ Operator Setup Required story assertions pass. - Live acceptance used OpenAI/Codex and Anthropic/Claude ACPX, resumed both exact sessions after restart, preserved pinned accounts through explicit reset and controlled quota deferral/recovery, and committed only two allocations across fourteen runs. A later UI-created task test again rotated OpenAI then Anthropic and resumed OpenAI through follow-up/restart/quota recovery. That later Anthropic execution was blocked by its saved OAuth token expiring (provider 401). No live usage probes ran. - The full local `pnpm test:run` was attempted earlier and did not complete because of macOS embedded PostgreSQL bootstrap/shared-memory failures and the 40,000-file Git fixture timeout. The focused database suites above now pass; full-suite verification is provided by the split CI lanes. The preceding CI run had one runtime readiness timeout; it passes locally both alone and inside the larger runtime suite. That larger local suite also encountered an embedded PostgreSQL setup failure and two macOS temporary-path alias assertions; those two assertions pass with canonical TMPDIR=/private/tmp. All final-head CI checks are terminal green, including full general/serialized server suites, Runner checks, browser E2E shards, canary verification, build, and typecheck. Greptile is 5/5 on that exact head with no unresolved threads. ## Risks - The migration adds routing tables and a credential epoch column. Install the private plugin only with the compatible Core contract. - Routing and each pool require opt-in. Production distribution and fleet defaults remain unchanged. - Unknown usage stays eligible. Known pinned exhaustion waits; revoked access requires operator repair. - Legacy adapters require compatible members. Runner model and effort overrides remain limited by qualified backend support. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository editing, code execution, and browser testing. The exact deployment model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes:` / `Closes:` / `Refs:` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (targeted suites; full-suite limitations are reported above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
992 lines
45 KiB
TypeScript
992 lines
45 KiB
TypeScript
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 {
|
||
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 <p role="status">Loading connection…</p>;
|
||
if (connection.error) return <p role="alert">{connection.error.message}</p>;
|
||
const pluginKey = connection.data && aiConnectionRouterPluginKey(connection.data);
|
||
return pluginKey ? <AiConnectionPoolConnector pluginKey={pluginKey} connection={connection.data} /> : <StandardAppDetail {...props} />;
|
||
}
|
||
|
||
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<string | null>(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<string>;
|
||
askFirst: Set<string>;
|
||
access: AccessDraft;
|
||
reviewed?: Set<string>;
|
||
}) => {
|
||
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<string | null>(null);
|
||
const [audienceOpenGrantId, setAudienceOpenGrantId] = useState<string | null>(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) => pushToast({
|
||
title: "Couldn't refresh GitHub access",
|
||
body: error instanceof Error ? error.message : "Please try again.",
|
||
tone: "error",
|
||
}),
|
||
});
|
||
|
||
const apply = (mutate: {
|
||
enabled?: Set<string>;
|
||
askFirst?: Set<string>;
|
||
access?: AccessDraft;
|
||
reviewed?: Set<string>;
|
||
}) =>
|
||
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 <Navigate replace to={`${appTabHref(connectionId, "permissions")}${query ? `?${query}` : ""}`} />;
|
||
}
|
||
if (tab === "activity") {
|
||
return <Navigate replace to="/activity?action=tool_" />;
|
||
}
|
||
|
||
if (!connectionId || !activeTab) {
|
||
return <Navigate replace to={connectionId ? appTabHref(connectionId, "permissions") : "/apps"} />;
|
||
}
|
||
|
||
if (!selectedCompanyId) {
|
||
return <div className="p-6 text-sm text-muted-foreground">Select an organization to manage apps.</div>;
|
||
}
|
||
if (connectionQuery.isLoading) {
|
||
return (
|
||
<div className="max-w-3xl space-y-4">
|
||
<Skeleton className="h-10 w-56" />
|
||
<Skeleton className="h-48 w-full" />
|
||
<Skeleton className="h-40 w-full" />
|
||
</div>
|
||
);
|
||
}
|
||
if (!connection) {
|
||
return (
|
||
<div className="max-w-3xl p-6">
|
||
<p className="text-sm text-muted-foreground">We couldn't find that app.</p>
|
||
<Button className="mt-4" variant="outline" onClick={() => navigate("/apps")}>
|
||
Back to connectors
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isRetiredComposioConnection(connection)) {
|
||
return <div className="max-w-4xl space-y-6 pb-12">
|
||
<h1 className="text-xl font-semibold">{appName}</h1>
|
||
<section role="status" className="space-y-3 rounded-lg border border-border bg-muted p-4">
|
||
<h2 className="text-sm font-semibold">Connection retired</h2>
|
||
<p className="text-sm text-muted-foreground">{RETIRED_COMPOSIO_MESSAGE}</p>
|
||
<p className="text-sm text-muted-foreground">Remove each obsolete connection separately. Removing this one does not remove other connections.</p>
|
||
<Button variant="outline" onClick={() => navigate("/apps/connect?source=composio")}>Add Composio MCP connection</Button>
|
||
</section>
|
||
{grantsQuery.data?.capabilities.canConfigure === true && <DangerZone
|
||
appName={appName}
|
||
removing={disconnectRemote.isPending}
|
||
onRemove={() => disconnectRemote.mutate()}
|
||
/>}
|
||
</div>;
|
||
}
|
||
|
||
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" ? <ManagedAiConnectionDetails connection={connection} /> : 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 (
|
||
<div className="max-w-4xl space-y-10 pb-12">
|
||
<AppDetailHeader
|
||
appName={appName}
|
||
connection={connection}
|
||
logoEntry={logoEntry}
|
||
brandKey={brandKey}
|
||
allowRemoteLogo={!applicationsQuery.isPending}
|
||
status={status}
|
||
actionCount={actionCount}
|
||
renaming={renaming}
|
||
nameDraft={nameDraft}
|
||
renamePending={rename.isPending}
|
||
onNameDraftChange={setNameDraft}
|
||
onRenameStart={() => {
|
||
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 && (
|
||
<div role="status">
|
||
<p>{connection.healthMessage || "GitHub access could not be checked. Try again."}</p>
|
||
<Button variant="outline" disabled={refreshGitHubAccess.isPending} onClick={() => refreshGitHubAccess.mutate()}>
|
||
Retry access
|
||
</Button>
|
||
</div>
|
||
)}
|
||
{needsReconnect && (
|
||
<ReconnectCard
|
||
connection={connection}
|
||
galleryEntry={logoEntry}
|
||
canReconnect={canReconnect}
|
||
reconnectUnavailableMessage={reconnectUnavailableMessage}
|
||
onReconnect={onReconnect ? () => 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
|
||
? <ToolsLoadError onRetry={() => {
|
||
void catalogQuery.refetch();
|
||
void profilesQuery.refetch();
|
||
void policiesQuery.refetch();
|
||
}} />
|
||
: reviewLoading
|
||
? <ToolsLoading />
|
||
: <ReviewPanel
|
||
connectionId={connectionId}
|
||
quarantined={quarantined}
|
||
pending={pending}
|
||
onReviewQuarantined={reviewQuarantined}
|
||
/>
|
||
)}
|
||
{activeTab === "permissions" && (
|
||
permissionsFailed
|
||
? <ToolsLoadError onRetry={() => {
|
||
void catalogQuery.refetch();
|
||
void profilesQuery.refetch();
|
||
void policiesQuery.refetch();
|
||
void installsQuery.refetch();
|
||
void agentsQuery.refetch();
|
||
}} />
|
||
: permissionsLoading
|
||
? <ToolsLoading />
|
||
: <div className="space-y-10">
|
||
{isAppAggregator(brandKey) && grantsQuery.data?.capabilities.canConfigure === true
|
||
? <ConnectedAggregatorApps key={connection.id} connection={connection} /> : null}
|
||
{connection.config?.sourceTemplateKey === "honcho" && <HonchoWorkspaceSettings key={connection.id} connection={connection} canConfigure={grantsQuery.data?.capabilities?.canConfigure ?? false} />}
|
||
{renderConnectionSettings?.(connection)}
|
||
{connection.config?.sourceTemplateKey === "browser-use-cloud" && <BrowserUseSettingsPanel connection={connection} grants={grantsQuery.data} />}
|
||
{connection.config?.sourceTemplateKey === "railway" && <RailwayAccessPanel connection={connection} grants={grantsQuery.data} />}
|
||
{connection.config?.provider === "agentmail" && <EmailConnectionInboxes companyId={connection.companyId} connectionId={connection.id} canConfigure={grantsQuery.data?.capabilities?.canConfigure ?? false} />}
|
||
{connection.config?.provider === "agentmail" ? <EmailConnectionAccess companyId={connection.companyId} connectionId={connection.id} agents={agents} /> : <>
|
||
<IdentitiesSection
|
||
appName={appName}
|
||
credentialPolicy={connection.credentialPolicy}
|
||
ownerUserId={connection.createdByUserId}
|
||
connectedUser={owner}
|
||
dedicatedAgent={managedIdentityGrant?.kind === "agent"
|
||
? agents.find((agent) => 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" ? <p className="text-sm text-muted-foreground">
|
||
These permissions apply to all apps available through this Composio connection. Manage app accounts and sign-in in Composio.
|
||
</p> : null}
|
||
{connection.config?.sourceTemplateKey !== "composio" && isRemoteMcpConnectorMethod(connection.config?.sourceTemplateKey, connection.config?.connectionMethodKey) && <p className="text-sm text-muted-foreground">Paperclip controls access to the tools listed here. App and action permissions inside these tools are managed in {baseAppName}.</p>}
|
||
<PermissionsPanel
|
||
afterAgentAccess={<>{logoEntry?.agentInstructions && <ConnectionInstructionsSettings key={connection.id} connection={connection} provider={logoEntry.name} template={logoEntry.agentInstructions} canConfigure={grantsQuery.data?.capabilities?.canConfigure ?? false} />}{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}
|
||
/>
|
||
<AgentConnectionAccess
|
||
connectionId={connectionId}
|
||
profiles={profilesQuery.data?.profiles ?? []}
|
||
policies={policiesQuery.data?.policies ?? []}
|
||
catalog={catalog}
|
||
agents={agents}
|
||
canManage={grantsQuery.data?.capabilities.canConfigure === true}
|
||
onRemove={async (profileId) => {
|
||
await toolsApi.deleteProfile(profileId);
|
||
await profilesQuery.refetch();
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) });
|
||
}}
|
||
/>
|
||
{managesRemoteMcpAccess && isRemoteMcpConnectorId(connection.config?.sourceTemplateKey) && <RemoteMcpManagement
|
||
providerName={baseAppName} canReconnect={canReconnect} canDisconnect={grantsQuery.data?.capabilities.canConfigure === true}
|
||
busy={disconnectRemote.isPending}
|
||
onReconnect={() => 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()}
|
||
/>}
|
||
</>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<header>
|
||
<div className="flex items-center gap-3">
|
||
<AppLogo
|
||
name={appName}
|
||
brandKey={brandKey}
|
||
logoUrl={appDefinitionLogoUrl(logoEntry)}
|
||
darkLogoUrl={appDefinitionDarkLogoUrl(logoEntry)}
|
||
allowRemoteFallback={allowRemoteLogo}
|
||
size={44}
|
||
/>
|
||
<div className="min-w-0">
|
||
{renaming ? (
|
||
<form
|
||
className="flex items-center gap-2"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
onRenameSubmit(nameDraft.trim());
|
||
}}
|
||
>
|
||
<Input
|
||
aria-label="App name"
|
||
value={nameDraft}
|
||
onChange={(event) => onNameDraftChange(event.target.value)}
|
||
className="h-9 w-64 text-lg font-bold"
|
||
autoFocus
|
||
/>
|
||
<Button type="submit" size="sm" disabled={renamePending || !nameDraft.trim()}>
|
||
{renamePending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save"}
|
||
</Button>
|
||
<Button type="button" size="sm" variant="ghost" onClick={onRenameCancel} disabled={renamePending}>
|
||
Cancel
|
||
</Button>
|
||
</form>
|
||
) : (
|
||
<div className="flex items-center gap-1.5">
|
||
<h1 className="truncate text-xl font-bold">{appName}</h1>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 text-muted-foreground"
|
||
aria-label="Rename app"
|
||
disabled={!canRename}
|
||
onClick={onRenameStart}
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||
<StatusBadge status={status} />
|
||
{connection.config?.provider !== "agentmail" && actionCount !== null && (
|
||
<span className="text-xs text-muted-foreground">
|
||
{actionCount} {actionCount === 1 ? "action" : "actions"} available
|
||
</span>
|
||
)}
|
||
{connectionDisplaySecondaryHint(connection) ? (
|
||
<span className="text-xs text-muted-foreground">
|
||
{connectionDisplaySecondaryHint(connection)}
|
||
</span>
|
||
) : null}
|
||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} /> : null}
|
||
<ConnectionProvenanceChip connection={connection} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function ToolsLoading({ mcpActions = false }: { mcpActions?: boolean }) {
|
||
return (
|
||
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground" role="status">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
{mcpActions ? "Loading MCP actions, this may take a minute." : "Loading tools…"}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ToolsLoadError({ onRetry }: { onRetry: () => void }) {
|
||
return (
|
||
<div className="space-y-3 py-8">
|
||
<p className="text-sm text-destructive">Couldn’t load tools for this app.</p>
|
||
<Button size="sm" variant="outline" onClick={onRetry}>Try again</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<StatusInfo["tone"], string> = {
|
||
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 (
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||
klass[status.tone],
|
||
)}
|
||
>
|
||
{status.tone === "connected" && <Check className="h-3 w-3" />}
|
||
{status.label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function enabledCatalogIds(profile: ToolProfileWithDetails | undefined): Set<string> {
|
||
const ids = new Set<string>();
|
||
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<string> {
|
||
const ids = new Set<string>();
|
||
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;
|
||
}
|