Files
PaperClipAI/ui/src/pages/apps/gateways/ConnectClientDialog.tsx
T
DottaandPaperclip b51112798f feat(apps): improve gateway and workspace connection UX (#12340)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - App connections must work in both the operator UI and agent tool
gateway.
> - The first stack layer adds secure remote connections.
> - Operators still need clear setup, test, and recovery states.
> - This pull request adds the gateway behavior and the workspace
connection experience.
> - The benefit is a connection flow that is easier to understand and
recover.

## Linked Issues or Issue Description

Refs #11965

This is stack 2 of 11. It depends on stack 1 and replaces another
reviewable part of #11965.

## What Changed

- Improve remote tool gateway connection behavior.
- Add clearer app setup, test, and recovery states.
- Add focused server and UI tests for the new paths.
- Keep the diff isolated from later identity and catalog work.
- Stabilize DNS-pinned remote HTTP protocol fixtures and the
managed-runtime public-origin fixture for this independently tested
layer.

## Verification

- `pnpm -r typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts` (150 passed)
- `pnpm test:run`
- `pnpm check:token-gates`
- `pnpm build`

## Risks

- Gateway errors now surface through new user-facing states.
- A stale connection can require a new setup attempt.
- The change does not add a database migration.
- The injected HTTP transport and public URL are test-only fixtures;
production DNS pinning and runtime behavior are unchanged.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and 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
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [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>
2026-08-29 12:08:32 -05:00

362 lines
13 KiB
TypeScript

import { type ComponentType, useEffect, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Bot,
Braces,
Check,
Code2,
Copy,
HelpCircle,
Link as LinkIcon,
MousePointer2,
TerminalSquare,
} from "lucide-react";
import type {
ToolMcpGatewayClientSnippet,
ToolMcpGatewayTokenCreated,
ToolMcpGatewayWithTokens,
} from "@paperclipai/shared";
import { toolsApi } from "@/api/tools";
import { SearchableSelect, type SearchableSelectGroup } from "@/components/SearchableSelect";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/context/ToastContext";
import { copyTextToClipboard } from "@/lib/clipboard";
import { cn } from "@/lib/utils";
import {
defaultGatewayTokenName,
formatHydratedSnippetConfig,
maskedTokenLabel,
orderedSnippets,
tokenStatus,
} from "./gateway-helpers";
import { gatewaysQueryKey } from "./NewGatewayDialog";
type PanelKey = string;
type ClientIcon = ComponentType<{ className?: string }>;
const CLIENT_ICONS: Record<ToolMcpGatewayClientSnippet["client"], ClientIcon> = {
cursor: MousePointer2,
claude_desktop: Bot,
vscode: Code2,
claude_code: TerminalSquare,
opencode: Braces,
};
type TokenOption = {
key: string;
value: string;
label: string;
title: string;
searchText: string;
token: ToolMcpGatewayTokenCreated;
};
export function ConnectClientDialog({
gateway,
open,
onOpenChange,
createdTokens,
onTokenCreated,
}: {
gateway: ToolMcpGatewayWithTokens;
open: boolean;
onOpenChange: (open: boolean) => void;
createdTokens: ToolMcpGatewayTokenCreated[];
onTokenCreated: (token: ToolMcpGatewayTokenCreated) => void;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const snippets = useMemo(() => orderedSnippets(gateway.clientSnippets ?? []), [gateway.clientSnippets]);
const endpoint = useMemo(() => {
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}${gateway.endpointPath}`;
}, [gateway.endpointPath]);
const availableTokens = useMemo(
() => createdTokens.filter((createdToken) => {
const persisted = gateway.tokens.find((token) => token.id === createdToken.id);
const status = tokenStatus(persisted ?? createdToken);
return status === "active" || status === "expiring";
}),
[createdTokens, gateway.tokens],
);
const tokenGroups = useMemo<SearchableSelectGroup<string, TokenOption>[]>(() => [{
id: "tokens",
label: "Available this session",
options: availableTokens.map((token) => ({
key: token.id,
value: token.id,
label: token.name,
title: token.clientLabel,
searchText: `${token.name} ${token.clientLabel} ${token.tokenPrefix}`,
token,
})),
}], [availableTokens]);
const [active, setActive] = useState<PanelKey>(snippets[0]?.client ?? "raw_url");
const [selectedTokenId, setSelectedTokenId] = useState("");
const selectedToken = availableTokens.find((token) => token.id === selectedTokenId) ?? null;
useEffect(() => {
if (!open) return;
setActive(snippets[0]?.client ?? "raw_url");
setSelectedTokenId((current) =>
availableTokens.some((token) => token.id === current) ? current : availableTokens[0]?.id ?? "",
);
}, [availableTokens, open, snippets]);
const issueTokenMutation = useMutation({
mutationFn: () => {
const name = defaultGatewayTokenName(gateway);
return toolsApi.createGatewayToken(gateway.companyId, gateway.id, {
name,
clientLabel: name,
ownerNote: "",
allowedActions: ["tools/list", "tools/call"],
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
});
},
onSuccess: async (token) => {
onTokenCreated(token);
setSelectedTokenId(token.id);
pushToast({
title: "Token issued",
body: "The copy buttons now include its full Authorization header.",
tone: "success",
});
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(gateway.companyId) });
},
onError: (error) => pushToast({
title: "Token was not issued",
body: error instanceof Error ? error.message : String(error),
tone: "error",
}),
});
async function copyText(value: string, label: string) {
try {
await copyTextToClipboard(value);
pushToast({ title: "Copied", body: label, tone: "success" });
} catch (error) {
pushToast({
title: "Copy failed",
body: error instanceof Error ? error.message : "Clipboard access is unavailable.",
tone: "error",
});
}
}
const activeSnippet = snippets.find((snippet) => snippet.client === active) ?? null;
const displayConfigText = activeSnippet
? formatHydratedSnippetConfig(activeSnippet.config, {
endpointPath: gateway.endpointPath,
endpoint,
token: selectedToken ? maskedTokenLabel(selectedToken) : "pcgw_•••",
})
: "";
const copyConfigText = activeSnippet && selectedToken
? formatHydratedSnippetConfig(activeSnippet.config, {
endpointPath: gateway.endpointPath,
endpoint,
token: selectedToken.token,
})
: null;
function issueToken() {
if (!issueTokenMutation.isPending) issueTokenMutation.mutate();
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Client snippets
<Tooltip>
<TooltipTrigger asChild>
<button type="button" aria-label="About client snippets" className="text-muted-foreground hover:text-foreground">
<HelpCircle className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-xs">
Give this MCP gateway configuration to your tool. It does not give it access to Paperclip or
skills; it only gateways calls between the client and the tools exposed here.
</TooltipContent>
</Tooltip>
</DialogTitle>
<DialogDescription>
Choose a client and copy a complete, authenticated configuration.
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap items-center gap-2 border-b border-border pb-3">
<span className="text-xs font-medium text-muted-foreground">Authorization</span>
{availableTokens.length > 0 ? (
<SearchableSelect<string, TokenOption>
value={selectedTokenId}
groups={tokenGroups}
onValueChange={setSelectedTokenId}
placeholder="Issue a token"
searchPlaceholder="Search tokens…"
emptyMessage="No copyable tokens."
contentWidth="auto"
triggerClassName="h-8 w-auto max-w-xs rounded-full px-3"
renderValue={(option) => option ? `${option.label} · ${maskedTokenLabel(option.token)}` : "Issue a token"}
renderOption={(option) => (
<span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
<span className="truncate font-mono text-(length:--text-micro) text-muted-foreground">
{maskedTokenLabel(option.token)}
</span>
</span>
)}
createItem={{
render: () => <span>+ Issue a new token</span>,
onSelect: issueToken,
}}
/>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="rounded-full"
disabled={issueTokenMutation.isPending}
onClick={issueToken}
>
{issueTokenMutation.isPending ? "Issuing…" : "Issue a token"}
</Button>
)}
{selectedToken ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void copyText(`Authorization: Bearer ${selectedToken.token}`, "Authorization header")}
>
<Copy className="mr-1 h-3.5 w-3.5" />
Copy header
</Button>
) : null}
</div>
{!selectedToken ? (
<p className="text-xs text-muted-foreground">
Issue a token before copying a snippet; the full <code>Authorization: Bearer …</code> header is
required. Existing token secrets cannot be retrieved again, so only tokens issued in this page
session can fill a snippet.
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-(--gtc-10)">
<nav className="flex gap-1 overflow-x-auto sm:flex-col" aria-label="Clients">
{snippets.map((snippet) => {
const Icon = CLIENT_ICONS[snippet.client];
return (
<button
key={snippet.client}
type="button"
onClick={() => setActive(snippet.client)}
className={cn(
"flex shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
active === snippet.client
? "bg-muted font-medium text-foreground"
: "text-muted-foreground hover:bg-muted/60",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{snippet.label}
</button>
);
})}
<button
type="button"
onClick={() => setActive("raw_url")}
className={cn(
"flex shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
active === "raw_url"
? "bg-muted font-medium text-foreground"
: "text-muted-foreground hover:bg-muted/60",
)}
>
<LinkIcon className="h-4 w-4 shrink-0" />
Raw URL
</button>
</nav>
<div className="min-w-0 space-y-3">
{active === "raw_url" ? (
<div className="space-y-3">
<div className="space-y-1.5">
<div className="text-sm font-medium text-foreground">Endpoint URL</div>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
{endpoint}
</code>
<Button variant="outline" size="sm" onClick={() => void copyText(endpoint, "Endpoint URL")}>
<Copy className="mr-1 h-3.5 w-3.5" />
Copy
</Button>
</div>
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium text-foreground">Authorization header</div>
<code className="block truncate rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
{selectedToken ? `Authorization: Bearer ${maskedTokenLabel(selectedToken)}` : "Authorization: Bearer pcgw_•••"}
</code>
</div>
</div>
) : activeSnippet ? (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-foreground">{activeSnippet.label}</div>
<Button
variant="outline"
size="sm"
disabled={!copyConfigText}
onClick={() => copyConfigText && void copyText(copyConfigText, `${activeSnippet.label} config`)}
>
<Copy className="mr-1 h-3.5 w-3.5" />
Copy
</Button>
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground">
{displayConfigText}
</pre>
{activeSnippet.notes.length > 0 ? (
<ul className="space-y-1 text-xs text-muted-foreground">
{activeSnippet.notes.map((note) => <li key={note}>{note}</li>)}
</ul>
) : null}
</div>
) : (
<p className="text-sm text-muted-foreground">No client snippets available for this gateway.</p>
)}
<p className="text-xs text-muted-foreground">
Treat the token like a password. Anyone holding it can call the tools this gateway allows. Revoke
it if it leaks.
</p>
</div>
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>
<Check className="mr-1.5 h-4 w-4" />
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}