From ab5e821d6aac37841266a95da9e449aaaf0d1e82 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 18 Aug 2026 14:46:57 +0000 Subject: [PATCH] feat(apps): improve gateway management UX Co-Authored-By: Paperclip --- .../shared/src/validators/tool-access.test.ts | 11 + packages/shared/src/validators/tool-access.ts | 2 +- .../apps/agent-selector-contract.test.ts | 32 ++ .../gateways/ConnectClientDialog.test.tsx | 190 +++++++++ .../apps/gateways/ConnectClientDialog.tsx | 339 +++++++++++----- .../apps/gateways/EditGatewayDialog.test.tsx | 168 ++++++++ .../pages/apps/gateways/EditGatewayDialog.tsx | 123 ++++++ ui/src/pages/apps/gateways/GatewayDetail.tsx | 36 +- .../apps/gateways/gateway-helpers.test.ts | 28 ++ ui/src/pages/apps/gateways/gateway-helpers.ts | 39 ++ .../apps/gateways/panels/TokensPanel.test.tsx | 63 ++- .../apps/gateways/panels/TokensPanel.tsx | 364 ++++++++++-------- ui/src/pages/tools/AuditTab.tsx | 20 +- ui/src/pages/tools/PoliciesTab.tsx | 27 +- ui/src/pages/tools/ProfilesTab.tsx | 21 +- 15 files changed, 1151 insertions(+), 312 deletions(-) create mode 100644 ui/src/pages/apps/agent-selector-contract.test.ts create mode 100644 ui/src/pages/apps/gateways/ConnectClientDialog.test.tsx create mode 100644 ui/src/pages/apps/gateways/EditGatewayDialog.test.tsx create mode 100644 ui/src/pages/apps/gateways/EditGatewayDialog.tsx diff --git a/packages/shared/src/validators/tool-access.test.ts b/packages/shared/src/validators/tool-access.test.ts index f8e1c5257a8..7018f750dec 100644 --- a/packages/shared/src/validators/tool-access.test.ts +++ b/packages/shared/src/validators/tool-access.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { connectionTokenRequestSchema, connectToolAppSchema, + createToolMcpGatewayTokenSchema, createToolConnectionSchema, startConnectionAuthorizationSchema, toolCredentialSecretRefSchema, @@ -10,6 +11,16 @@ import { } from "./tool-access.js"; describe("tool access validators", () => { + it("treats a gateway token owner note as optional", () => { + const parsed = createToolMcpGatewayTokenSchema.parse({ + name: "cursor-client", + clientLabel: "cursor-client", + expiresAt: "2026-12-01T00:00:00.000Z", + }); + + expect(parsed.ownerNote).toBe(""); + }); + it("defaults connection token subjects to app", () => { expect(connectionTokenRequestSchema.parse({})).toEqual({ subject: { type: "app" } }); }); diff --git a/packages/shared/src/validators/tool-access.ts b/packages/shared/src/validators/tool-access.ts index e86312e1232..007ee03a000 100644 --- a/packages/shared/src/validators/tool-access.ts +++ b/packages/shared/src/validators/tool-access.ts @@ -598,7 +598,7 @@ export const createToolMcpGatewayTokenSchema = z.object({ subjectType: toolMcpGatewayTokenSubjectTypeSchema.default("gateway_client").optional(), subjectId: z.string().trim().min(1).max(240).optional().nullable(), clientLabel: z.string().trim().min(1).max(160), - ownerNote: z.string().trim().min(1).max(1000), + ownerNote: z.string().trim().max(1000).default(""), allowedActions: z.array(toolMcpGatewayTokenActionSchema).min(1).max(TOOL_MCP_GATEWAY_TOKEN_ACTIONS.length).default(["tools/list", "tools/call"]).optional(), expiresAt: z.coerce.date().optional().nullable(), expiryOverrideReason: z.string().trim().min(1).max(1000).optional().nullable(), diff --git a/ui/src/pages/apps/agent-selector-contract.test.ts b/ui/src/pages/apps/agent-selector-contract.test.ts new file mode 100644 index 00000000000..a6bc905ca83 --- /dev/null +++ b/ui/src/pages/apps/agent-selector-contract.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +function source(relativePath: string) { + return readFileSync(new URL(relativePath, import.meta.url), "utf8"); +} + +describe("Apps agent selector contract", () => { + it("keeps every agent chooser under /apps searchable", () => { + const appConnect = source("./AppsConnect.tsx"); + const permissions = source("./app-detail/PermissionsPanel.tsx"); + const tester = source("./app-detail/TestPanel.tsx"); + const profiles = source("../tools/ProfilesTab.tsx"); + const policies = source("../tools/PoliciesTab.tsx"); + const audit = source("../tools/AuditTab.tsx"); + + expect(appConnect).toContain(" vi.fn()); +const pushToastMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/tools", () => ({ + toolsApi: { createGatewayToken: vi.fn() }, +})); + +vi.mock("@/lib/clipboard", () => ({ + copyTextToClipboard: (value: string) => copyTextMock(value), +})); + +vi.mock("@/context/ToastContext", () => ({ + useToast: () => ({ pushToast: pushToastMock }), +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => open ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => {children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/components/SearchableSelect", () => ({ + SearchableSelect: ({ value, groups, onValueChange }: { + value: string; + groups: Array<{ options: Array<{ key: string; value: string; label: string }> }>; + onValueChange: (value: string, option: { key: string; value: string; label: string }) => void; + }) => { + const options = groups.flatMap((group) => group.options); + return ( + + ); + }, +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function storedToken(): ToolMcpGatewayToken { + return { + id: "token-1", + companyId: "company-1", + gatewayId: "gateway-1", + name: "research-client", + tokenPrefix: "pcgw_abcd1234", + subjectType: "gateway_client", + subjectId: null, + clientLabel: "research-client", + ownerNote: "", + allowedActions: ["tools/list", "tools/call"], + expiresAt: "2026-12-01T00:00:00.000Z", + expiryOverrideReason: null, + expiryOverrideByUserId: null, + expiryOverrideByAgentId: null, + expiryOverrideAt: null, + lastUsedAt: null, + revokedAt: null, + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: "2026-08-18T00:00:00.000Z", + updatedAt: "2026-08-18T00:00:00.000Z", + }; +} + +function gateway(token: ToolMcpGatewayToken): ToolMcpGatewayWithTokens { + return { + id: "gateway-1", + companyId: "company-1", + gatewayPublicId: "public-1", + name: "Research gateway", + displaySlug: "research", + slug: "research", + description: null, + status: "active", + profileId: "profile-1", + defaultProfileMode: "gateway_only", + contextScopeType: "none", + contextScopeId: null, + agentId: null, + projectId: null, + issueId: null, + approvalIssueId: null, + endpointPath: "/api/tool-gateway/gateways/public-1/mcp", + authConfig: {} as ToolMcpGatewayWithTokens["authConfig"], + headerPolicy: {} as ToolMcpGatewayWithTokens["headerPolicy"], + metadataPolicy: {} as ToolMcpGatewayWithTokens["metadataPolicy"], + onDemandToolsConfig: {} as ToolMcpGatewayWithTokens["onDemandToolsConfig"], + metadata: null, + createdByAgentId: null, + createdByUserId: "user-1", + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + tokens: [token], + clientSnippets: [{ + client: "vscode", + label: "VS Code", + config: { + servers: { + Paperclip: { + url: "/api/tool-gateway/gateways/public-1/mcp", + headers: { Authorization: "Bearer pcgw_..." }, + }, + }, + }, + notes: [], + }], + }; +} + +describe("ConnectClientDialog", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + copyTextMock.mockResolvedValue(undefined); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); + }); + + it("copies a complete client snippet with the selected token and explains the gateway boundary", async () => { + const persisted = storedToken(); + const created = { ...persisted, token: "pcgw_FULL_SECRET" } satisfies ToolMcpGatewayTokenCreated; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + flushSync(() => { + root.render( + + + , + ); + }); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(container.textContent).toContain("does not give it access to Paperclip or skills"); + const copyButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Copy"); + if (!copyButton) throw new Error("snippet copy button missing"); + copyButton.click(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(copyTextMock).toHaveBeenCalledWith(expect.stringContaining('"Authorization": "Bearer pcgw_FULL_SECRET"')); + expect(copyTextMock).toHaveBeenCalledWith(expect.stringContaining( + `${window.location.origin}/api/tool-gateway/gateways/public-1/mcp`, + )); + expect(container.textContent).not.toContain("pcgw_FULL_SECRET"); + }); +}); diff --git a/ui/src/pages/apps/gateways/ConnectClientDialog.tsx b/ui/src/pages/apps/gateways/ConnectClientDialog.tsx index 111ba524542..406a0fea153 100644 --- a/ui/src/pages/apps/gateways/ConnectClientDialog.tsx +++ b/ui/src/pages/apps/gateways/ConnectClientDialog.tsx @@ -1,6 +1,23 @@ -import { useEffect, useMemo, useState } from "react"; -import { Check, Copy } from "lucide-react"; -import type { ToolMcpGatewayTokenCreated, ToolMcpGatewayWithTokens } from "@paperclipai/shared"; +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, @@ -10,30 +27,53 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useToast } from "@/context/ToastContext"; -import { cn } from "@/lib/utils"; import { copyTextToClipboard } from "@/lib/clipboard"; -import { formatSnippetConfig, maskedTokenLabel, orderedSnippets } from "./gateway-helpers"; +import { cn } from "@/lib/utils"; +import { + defaultGatewayTokenName, + formatHydratedSnippetConfig, + maskedTokenLabel, + orderedSnippets, + tokenStatus, +} from "./gateway-helpers"; +import { gatewaysQueryKey } from "./NewGatewayDialog"; -type PanelKey = string; // snippet client key, or "raw_url" +type PanelKey = string; +type ClientIcon = ComponentType<{ className?: string }>; + +const CLIENT_ICONS: Record = { + 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; +}; -/** - * "Connect a client" dialog (PAP-11178 design of record). Shows the copy-paste - * config for each supported client plus a raw URL fallback. If a token was just - * minted it can be revealed once here; otherwise the config carries a masked - * placeholder and the value never persists in the DOM. - */ export function ConnectClientDialog({ gateway, open, onOpenChange, - createdToken, + createdTokens, + onTokenCreated, }: { gateway: ToolMcpGatewayWithTokens; open: boolean; onOpenChange: (open: boolean) => void; - createdToken?: ToolMcpGatewayTokenCreated | null; + createdTokens: ToolMcpGatewayTokenCreated[]; + onTokenCreated: (token: ToolMcpGatewayTokenCreated) => void; }) { + const queryClient = useQueryClient(); const { pushToast } = useToast(); const snippets = useMemo(() => orderedSnippets(gateway.clientSnippets ?? []), [gateway.clientSnippets]); const endpoint = useMemo(() => { @@ -41,15 +81,66 @@ export function ConnectClientDialog({ 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[]>(() => [{ + 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(snippets[0]?.client ?? "raw_url"); - const [revealed, setRevealed] = useState(false); + const [selectedTokenId, setSelectedTokenId] = useState(""); + const selectedToken = availableTokens.find((token) => token.id === selectedTokenId) ?? null; useEffect(() => { - if (open) { - setActive(snippets[0]?.client ?? "raw_url"); - setRevealed(false); - } - }, [open, snippets]); + 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 { @@ -65,65 +156,164 @@ export function ConnectClientDialog({ } const activeSnippet = snippets.find((snippet) => snippet.client === active) ?? null; - const configText = activeSnippet ? formatSnippetConfig(activeSnippet.config) : ""; + 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 ( - Connect a client + + Client snippets + + + + + + 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. + + + - Pick how you’ll point your client at this gateway. + Choose a client and copy a complete, authenticated configuration. +
+ Authorization + {availableTokens.length > 0 ? ( + + 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) => ( + + {option.label} + + {maskedTokenLabel(option.token)} + + + )} + createItem={{ + render: () => + Issue a new token, + onSelect: issueToken, + }} + /> + ) : ( + + )} + {selectedToken ? ( + + ) : null} +
+ + {!selectedToken ? ( +

+ Issue a token before copying a snippet; the full Authorization: Bearer … header is + required. Existing token secrets cannot be retrieved again, so only tokens issued in this page + session can fill a snippet. +

+ ) : null} +
{active === "raw_url" ? ( -
-
Endpoint URL
-
- - {endpoint} +
+
+
Endpoint URL
+
+ + {endpoint} + + +
+
+
+
Authorization header
+ + {selectedToken ? `Authorization: Bearer ${maskedTokenLabel(selectedToken)}` : "Authorization: Bearer pcgw_•••"} -
-

- Authenticate with Authorization: Bearer <token> over streamable HTTP. -

) : activeSnippet ? (
@@ -132,20 +322,19 @@ export function ConnectClientDialog({
-                  {configText}
+                  {displayConfigText}
                 
{activeSnippet.notes.length > 0 ? (
    - {activeSnippet.notes.map((note) => ( -
  • {note}
  • - ))} + {activeSnippet.notes.map((note) =>
  • {note}
  • )}
) : null}
@@ -153,40 +342,10 @@ export function ConnectClientDialog({

No client snippets available for this gateway.

)} -
-
Token
- {createdToken ? ( -
- - {revealed ? createdToken.token : maskedTokenLabel(createdToken)} - - {revealed ? ( - - ) : ( - - )} -
- ) : ( -

- Mint a token on the Tokens tab, then paste it where the - snippet shows Bearer …. You won’t see a token’s full value again after it’s - created. -

- )} -

- Treat this like a password. Anyone with the token can call exactly the tools this gateway - allows. If it leaks, revoke it — the client goes silent immediately. -

-
+

+ Treat the token like a password. Anyone holding it can call the tools this gateway allows. Revoke + it if it leaks. +

diff --git a/ui/src/pages/apps/gateways/EditGatewayDialog.test.tsx b/ui/src/pages/apps/gateways/EditGatewayDialog.test.tsx new file mode 100644 index 00000000000..8c562c49fb5 --- /dev/null +++ b/ui/src/pages/apps/gateways/EditGatewayDialog.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom + +import { type ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ToolMcpGatewayWithTokens, ToolProfileWithDetails } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditGatewayDialog } from "./EditGatewayDialog"; + +const updateGatewayMock = vi.hoisted(() => vi.fn()); +const pushToastMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/tools", () => ({ + toolsApi: { updateGateway: (...args: unknown[]) => updateGatewayMock(...args) }, +})); + +vi.mock("@/context/ToastContext", () => ({ + useToast: () => ({ pushToast: pushToastMock }), +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => open ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function gateway(): ToolMcpGatewayWithTokens { + return { + id: "gateway-1", + companyId: "company-1", + gatewayPublicId: "public-1", + name: "Runtime Notion", + displaySlug: "runtime-notion", + slug: "runtime-notion", + description: "Original description", + status: "active", + profileId: "profile-1", + defaultProfileMode: "gateway_only", + contextScopeType: "none", + contextScopeId: null, + agentId: null, + projectId: null, + issueId: null, + approvalIssueId: null, + endpointPath: "/api/tool-gateway/gateways/public-1/mcp", + authConfig: {} as ToolMcpGatewayWithTokens["authConfig"], + headerPolicy: {} as ToolMcpGatewayWithTokens["headerPolicy"], + metadataPolicy: {} as ToolMcpGatewayWithTokens["metadataPolicy"], + onDemandToolsConfig: {} as ToolMcpGatewayWithTokens["onDemandToolsConfig"], + metadata: null, + createdByAgentId: null, + createdByUserId: "user-1", + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + tokens: [], + clientSnippets: [], + }; +} + +function profile(id: string, name: string): ToolProfileWithDetails { + return { + id, + companyId: "company-1", + profileKey: id, + name, + description: null, + status: "active", + defaultAction: "deny", + newToolsReviewedAt: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + entries: [], + bindings: [], + summary: { + accessMode: "selected", + allowedToolCount: 2, + allowedApplicationCount: 1, + excludedToolCount: 0, + totalToolCount: 2, + assignmentCount: 1, + appliesToAgentCount: 1, + isCompanyDefault: false, + }, + } as ToolProfileWithDetails; +} + +async function flushReact() { + for (let index = 0; index < 3; index += 1) { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } +} + +describe("EditGatewayDialog", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); + }); + + it("updates an editable gateway name, description, and access profile", async () => { + const existing = gateway(); + updateGatewayMock.mockResolvedValue({ ...existing, name: "Shared Notion", profileId: "profile-2" }); + const onOpenChange = vi.fn(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + flushSync(() => { + root.render( + + + , + ); + }); + + const nameInput = container.querySelector('input[required]'); + const descriptionInput = container.querySelector("textarea"); + const profileSelect = container.querySelector("select"); + const form = container.querySelector("form"); + if (!nameInput || !descriptionInput || !profileSelect || !form) throw new Error("gateway edit form missing"); + + const inputSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + const textareaSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set; + flushSync(() => { + inputSetter?.call(nameInput, "Shared Notion"); + nameInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + flushSync(() => { + textareaSetter?.call(descriptionInput, "For the research team"); + descriptionInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + flushSync(() => { + profileSelect.value = "profile-2"; + profileSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await flushReact(); + + expect(updateGatewayMock).toHaveBeenCalledWith("company-1", "gateway-1", { + name: "Shared Notion", + description: "For the research team", + profileId: "profile-2", + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/ui/src/pages/apps/gateways/EditGatewayDialog.tsx b/ui/src/pages/apps/gateways/EditGatewayDialog.tsx new file mode 100644 index 00000000000..892cfabae10 --- /dev/null +++ b/ui/src/pages/apps/gateways/EditGatewayDialog.tsx @@ -0,0 +1,123 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ToolMcpGatewayWithTokens, ToolProfileWithDetails } from "@paperclipai/shared"; +import { toolsApi } from "@/api/tools"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useToast } from "@/context/ToastContext"; +import { allowedToolsLabel } from "./gateway-helpers"; +import { gatewaysQueryKey } from "./NewGatewayDialog"; + +export function EditGatewayDialog({ + companyId, + gateway, + profiles, + open, + onOpenChange, +}: { + companyId: string; + gateway: ToolMcpGatewayWithTokens; + profiles: ToolProfileWithDetails[]; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToast(); + const [name, setName] = useState(gateway.name); + const [description, setDescription] = useState(gateway.description ?? ""); + const [profileId, setProfileId] = useState(gateway.profileId); + + useEffect(() => { + if (!open) return; + setName(gateway.name); + setDescription(gateway.description ?? ""); + setProfileId(gateway.profileId); + }, [gateway, open]); + + const activeProfiles = profiles.filter((profile) => profile.status !== "archived"); + const updateMutation = useMutation({ + mutationFn: () => + toolsApi.updateGateway(companyId, gateway.id, { + name: name.trim(), + description: description.trim() || null, + profileId, + }), + onSuccess: async (updated) => { + pushToast({ title: "Gateway updated", body: updated.name, tone: "success" }); + await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(companyId) }); + onOpenChange(false); + }, + onError: (error) => { + pushToast({ + title: "Gateway was not updated", + body: error instanceof Error ? error.message : String(error), + tone: "error", + }); + }, + }); + + function submit(event: FormEvent) { + event.preventDefault(); + if (!name.trim() || !profileId) return; + updateMutation.mutate(); + } + + return ( + + + + Edit gateway + + Change the label or the access profile that controls which tools this endpoint exposes. + + +
+ + +