mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents get GitHub access through a managed GitHub App connection.
**Refresh access** reads the installations and repositories that the
user token can reach.
> - GitHub keeps listing a suspended App installation and all of its
repositories for the user token. But the token then loses write access
and cannot read private repositories.
> - `loadGitHubGrantMetadata` did not read `suspended_at`. It counted a
suspended installation as live access ("all repos") and set the
connection to healthy.
> - So the board showed "connected, all repositories" while agents got a
403 on `git push` and a 404 on private repositories. The suspend webhook
marks the connection unhealthy, but the next refresh or reconnect
reverted that.
> - This pull request skips suspended installations. When all
installations are suspended, Refresh access fails with a clear error and
a link to the installation settings page, where an owner can click
**Unsuspend**.
> - The benefit is that the board shows the real access state, and
operators get a direct path to fix it.
## Linked Issues or Issue Description
**What happened?**
A GitHub App installation on an organization was suspended. The
Paperclip GitHub connection still showed "connected" with access to all
repositories after **Refresh access**. Agents that used the managed
token got `Permission to <org>/<repo>.git denied` (403) on push,
`Resource not accessible by integration` on branch creation, and 404 on
private repositories.
**Expected behavior**
Refresh access must not count a suspended installation as repository
access. When every installation is suspended, Refresh access must fail
with a message that says the installation is suspended, and the page
must link to the installation settings page.
**Steps to reproduce**
1. Connect GitHub to Paperclip through the GitHub App, with an
installation on an organization.
2. On GitHub, suspend that installation (Organization settings → GitHub
Apps → Configure → Suspend).
3. In Paperclip, click **Refresh access** on the GitHub connection.
4. See that the connection stays healthy and reports all repositories,
while agent pushes fail with 403.
**Paperclip version or commit**
`master` at a708a1333.
**Additional context**
`GET /user/installations` returns the suspended installation with
`suspended_at` set and `repository_selection: all`, and `GET
/user/installations/{id}/repositories` still lists its repositories.
Unsuspending the installation restored push access and private reads for
the same token.
## What Changed
- `server/src/services/tool-access/github-access-discovery.ts`:
`loadGitHubGrantMetadata` skips installations that have `suspended_at`
set. It does not add their IDs, owners, repository selection, or
repositories. When there is no usable installation and at least one is
suspended, it throws `github_installation_required` with a "suspended"
message. `managementUrl` points at the suspended installation's settings
page (or the generic installations page when more than one is
suspended).
- `server/src/services/tool-access/catalog/health.ts`:
`checkConnectionHealth` (the Refresh access path) keeps
`installationUrl` and `managementUrl` when it rebuilds a
`github_installation_required` error. Before, the links were dropped.
- `server/src/services/tool-access/catalog/failures.ts`:
`github_installation_required` keeps its own code and returns 422.
Before, it was folded into a generic `paperclip_error` with HTTP 502.
- `ui/src/pages/apps/app-detail/AccessRetryBanner.tsx` (new): the
attention banner with the saved health message and **Retry access**.
After a failed Refresh access with `github_installation_required`, it
shows an **Open installation settings on GitHub** link (https
`github.com` URLs only).
- `ui/src/pages/apps/AppDetail.tsx`: uses `AccessRetryBanner`, refetches
the connection when a refresh fails so the banner shows the saved
message, and keys the page on `connectionId` so a recovery link never
carries over to another connection. The file stays under 1,000 lines.
- Tests:
- `server/src/__tests__/github-grant-metadata.test.ts`: a mix of
suspended and active installations, and an account where all
installations are suspended.
- `server/src/__tests__/github-health-recovery.test.ts`: drives
`checkConnectionHealth` with a suspended-installation GitHub response
and checks the 422, code, and recovery links.
- `ui/src/pages/apps/AppDetail.test.tsx`: a failed Retry access renders
the installation settings link, and navigating to another cached
connection does not show it.
## Verification
- Red/green: with the source changes reverted and the tests kept, the
new tests fail (suspended installation counted as `all`; recovery link
missing from the health-check error; link not rendered; link leaking to
another connection). With the changes, they pass.
- `server`: `github-grant-metadata`, `github-health-recovery`,
`tool-access-service`, `github-operation-credentials`,
`tool-access-oauth-protocol.integration`, `tool-connection-removal`,
`tool-connection-visibility`, and the other `github-*` / `tool-access-*`
suites pass locally (630 tests).
- `ui`: `AppDetail.test.tsx` passes (78 tests). `ui` `tsc --noEmit`
shows no errors in changed files.
- Not run locally: the server `tsc`. The sandbox has a 4 GB memory
limit, and the server typecheck is killed there even on clean `master`.
CI ran the full typecheck and build, and both pass.
- CI: all gates green. One unrelated `workspace-recovery.test.ts` poll
timeout in a server shard passed on re-run.
- Live check: after the suspended installation was unsuspended, a
managed-token `git push --dry-run` succeeded and a private repository
returned 200.
## Risks
- Low risk. The change only affects installations that GitHub marks as
suspended. Active installations behave as before.
- A connection that has only suspended installations now fails Refresh
access with `github_installation_required` (422) and does not report
fake access. This is the intended behavior.
- The recovery link only renders for https `github.com` URLs.
- Keying the app page on `connectionId` remounts it when moving between
connections, which resets per-page state (for example an open rename
field). That reset is intended.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through the
Claude Code agent SDK with tool use (shell, file edit, code execution).
## 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
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1000 lines
46 KiB
TypeScript
1000 lines
46 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 { 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.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) => {
|
||
// 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: 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;
|
||
}
|