mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Apps Permissions page reads saved access profiles for one connection. > - The page fetched every company profile, rule, and binding. > - It used only the app profile and eligible task-granted profiles for that connection. > - This pull request adds a saved configuration read owned by the profile service. > - The route keeps current connection and per-profile management checks. > - The benefit is fewer database rows, less Node work, and a smaller response for the same displayed configuration. ## Linked Issues or Issue Description **Subsystem affected** Server profile configuration reads and the Apps Permissions and Review data caller. **Problem or motivation** One connection page loaded and serialized unrelated company profiles. In the local fixture, this produced a 32.35 MB response with 76,001 rule rows. **Proposed solution** Select the displayed connection's app profile and exact eligible connection-intent profiles before loading their entries and bindings. Return an explicit configuration DTO with complete selected arrays. Keep summary and pending-review work on the existing full API. **Alternatives considered** Filtering the full response in the browser retains the database and Node cost. Truncating the full API would break other clients. Filtering entries to the connection would lose selectors and bindings needed by saves. **Roadmap alignment** This improves the existing governed Apps capability. It does not add a new access policy. Related merged work: #18673 and #18681. Related harness cleanup: #18683. Public PR searches found no duplicate connection configuration projection. ## What Changed - Add `ToolProfileConfiguration` and `GET /api/tool-connections/{connectionId}/profile-configuration`. - Select `app:<connectionId>` at any status and exact active, correctly bound `connection_intent` profiles. Run the existing per-profile visibility filter before configuration queries. - Preserve complete ordered entries and bindings. Keep all save behavior, empty-binding install fallback, and the existing full profile API. - Switch AppDetail and AgentConnectionAccess to the configuration DTO. Use a distinct cache key below the existing profiles prefix so mutation and reconnect invalidations still refresh it. - Add real PostgreSQL differential and authenticated HTTP tests, UI caller coverage, a reproducible benchmark, and access-map documentation. ## Verification Baseline: `3f8ebfff8aa9febd0733c6f65b07485dcadfd0e2`, after both #18673 and #18681. Final head: `b40c1ed7c0833d403e20ee8a5ff9d09f1bad6d22`. Current implementation and reproduction details are in [the performance note](https://github.com/paperclipai/paperclip/blob/b40c1ed7c0833d403e20ee8a5ff9d09f1bad6d22/doc/performance/2026-11-02-connection-profile-configuration.md). - AppDetail: 79 tests passed. Existing access/action saves, install fallback, grant removal, errors, and refresh cases use the new caller. - Real PostgreSQL differential: passed. Full ordered selected configuration equals the old full-list selection. Cases cover extra bindings, sibling selectors, wrong source/key/status/binding, null and non-string agent metadata, fresh mutations, empty app bindings, visibility filtering, and company scope. - Production HTTP authentication: API key and run JWT cases passed. Hidden personal accounts omit spanning profiles. Private connection reads fail. Restored ownership restores visibility. Revoked management grants deny fresh calls. Final UID canonicalization and foreign-company 404 assertions passed on the current head. - Map structural check, token gate, and `git diff --check`: passed. - Required local `pnpm -r typecheck` and `pnpm build`: passed on the current head. `pnpm test:run` was started once. Its first chat shard passed (337 tests, 151.87 seconds); the second shard was intentionally interrupted after the complete final-head CI suite passed. This partial local attempt is not a full local test pass. The same stable-runner general and serialized partitions and Runner lanes passed in [final-head CI](https://github.com/paperclipai/paperclip/actions/runs/41538805231), including aggregate `ci / verify`. No local assertion failures were observed before interruption. - Final-head focused acceptance: four files, 85 tests passed. Full token gates, maps, and module boundaries passed. - Actual browser acceptance passed on the current checkout with isolated real PostgreSQL and a local trusted board. An action saved as Off persisted after reload. Both app agent bindings stayed intact. Removing an agent-only task grant persisted after reload. Requests used only the narrow profile endpoint. No browser exceptions occurred. No responses were mocked. No provider calls or hosted changes were made. The helper stopped the owned runtime and database after the check. - Final exact-head checks: 54 success, one clean neutral Paperclip Review, three skipped, zero pending or failures. Legacy Snyk status: success. No inline review comments. - Greptile reviewed exact head `b40c1ed7c0833d403e20ee8a5ff9d09f1bad6d22`: 5/5, no findings, completed successful check. Five alternating unprofiled pairs ran with real PostgreSQL on macOS/arm64, Node v24.22.2, and forced GC. The fixture has 210 profiles (200 heavy profiles plus ten eligibility cases), 76,001 rules, 1,900 active tools, 695 removed tools, and 12 agents. The selected configuration has two profiles and 1,391 rules. Each pair asserts complete ordered selected DTO equality. | Measurement | Full list | Configuration read | | --- | --- | --- | | Service wall ms, median [min, max] | 200.927 [193.319, 213.390] | 6.414 [5.508, 6.973] | | Node CPU ms, median [min, max] | 262.751 [246.727, 271.573] | 27.525 [22.576, 29.308] | | Select queries / returned rows | 7 / 78,139 | 3 / 1,397 | | Actual response JSON bytes | 32,353,534 | 592,233 | | Serialization wall ms, median [min, max] | 90.714 [88.715, 99.304] | 1.644 [1.637, 1.745] | | Serialization CPU ms, median [min, max] | 90.644 [88.569, 96.778] | 1.654 [1.637, 1.807] | Reproduce: `node --expose-gc --import ./server/node_modules/tsx/dist/loader.mjs server/scripts/benchmark-connection-profile-configuration.ts 5`. The benchmark measures the service and actual response JSON separately. It excludes HTTP and authorization work through an identity visibility callback. Node CPU does not include PostgreSQL CPU. No hosted or foreground-under-load improvement is claimed. Preserved failures: offline install lacked a locked tarball; frozen online install passed. Sandbox localhost and tsx IPC restrictions blocked initial DB/typecheck attempts; permitted retries use the same checks. The first HTTP suite needed the unbuilt plugin SDK; its owning build helper fixed setup. Shared typecheck caught a missing barrel export, now fixed. A broad typecheck inherited the host's unpinned nested pnpm executable; the owned stalled process was stopped and restarted with pinned pnpm 9. The complete current-head CI suite and aggregate verify passed. The separate Paperclip agent review published 5/5 with no findings at this head and completed with a clean neutral conclusion. An added foreign-company assertion expected 403; the existing guard correctly returned 404. Only the test expectation changed. The first real browser attempt saved and reloaded the action correctly, but grant deletion returned 422: the stress fixture had a company binding, which makes that profile the company default. The UI showed the existing deletion error. The second browser seed used the production agent-only intent shape and passed removal and reload. This does not change the stress fixture or product deletion gates. Initial external browser script attempts also needed an ESM extension and the correct dependency path. Logs, first-failure screenshots, traces, and result files are retained in the local evidence directory. ## Risks - SQL selection must stay aligned with connection-intent eligibility. Differential fixtures cover the exact current predicate. - A selected connection can still have many rules. This change preserves all selected configuration. - Per-profile authorization still reads current selected resources and can add work. Connection visibility does not bypass that check. - No migration, default-allow, effective-access, policy-review, provider, or deployment behavior changes. The old endpoint remains available. Rollback restores the old AppDetail caller and removes the additive endpoint. ## Model Used OpenAI Codex, `gpt-6.1-sol`, with code execution and repository tools. The host does not expose the context-window size. ## 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 (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [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>
985 lines
45 KiB
TypeScript
985 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 {
|
||
ToolConnection,
|
||
ToolPolicy,
|
||
ToolProfileConfiguration,
|
||
} 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 { retryTransientOnly } from "@/lib/query-client";
|
||
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,
|
||
galleryEntryFor,
|
||
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 <p role="status">Loading connection…</p>;
|
||
if (connection.error) return <p role="alert">{connection.error.message}</p>;
|
||
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 ? <AiConnectionPoolConnector pluginKey={pluginKey} connection={connection.data} /> : <StandardAppDetail key={connectionId} {...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.connectionProfileConfiguration(selectedCompanyId ?? "__none__", connectionId),
|
||
queryFn: () => toolsApi.listConnectionProfileConfiguration(connectionId),
|
||
enabled: !!selectedCompanyId && !!connectionId && (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: ({ signal }) => toolsApi.listConnectionGrants(connectionId, { signal }),
|
||
retry: retryTransientOnly(1),
|
||
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) => {
|
||
// 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<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 && (
|
||
<AccessRetryBanner
|
||
message={connection.healthMessage}
|
||
error={refreshGitHubAccess.error}
|
||
pending={refreshGitHubAccess.isPending}
|
||
onRetry={() => refreshGitHubAccess.mutate()}
|
||
/>
|
||
)}
|
||
{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: ToolProfileConfiguration | 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: ToolProfileConfiguration | 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]),
|
||
};
|
||
}
|