Files
PaperClipAI/ui/src/pages/apps/gateways/EditGatewayDialog.tsx
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

124 lines
4.4 KiB
TypeScript

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<HTMLFormElement>) {
event.preventDefault();
if (!name.trim() || !profileId) return;
updateMutation.mutate();
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit gateway</DialogTitle>
<DialogDescription>
Change the label or the access profile that controls which tools this endpoint exposes.
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={submit}>
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Name</span>
<Input value={name} onChange={(event) => setName(event.target.value)} required autoFocus />
</label>
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Access profile</span>
<select
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={profileId}
onChange={(event) => setProfileId(event.target.value)}
required
>
{activeProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name} — {allowedToolsLabel(profile)}
</option>
))}
</select>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Description (optional)</span>
<textarea
className="min-h-16 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Who this endpoint is for."
/>
</label>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={updateMutation.isPending || !name.trim() || !profileId}>
{updateMutation.isPending ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}