feat(apps): improve gateway management UX

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-20 21:20:02 +00:00
1 parent 7d758d2963
commit ab5e821d6a
15 files changed
+1151 -312

No files matched your search

@@ -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" } });
});
@@ -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(),
@@ -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("<AgentMultiSelect");
expect(permissions).toContain("<AgentMultiSelect");
expect(tester).toContain('placeholder="Search agents…"');
expect(profiles.match(/<AgentSelect/g)).toHaveLength(2);
expect(profiles).not.toContain("<Select value={agentId}");
expect(profiles).not.toContain("<Select value={targetAgentId}");
expect(policies.match(/<AgentSelect/g)).toHaveLength(2);
expect(policies).not.toContain("<Select value={agentId}");
expect(policies).not.toContain("<Select value={form.agentId}");
expect(audit).toContain("<AgentSelect");
expect(audit).not.toContain("<Select value={agent}");
});
});
@@ -0,0 +1,190 @@
// @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 {
ToolMcpGatewayToken,
ToolMcpGatewayTokenCreated,
ToolMcpGatewayWithTokens,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConnectClientDialog } from "./ConnectClientDialog";
const copyTextMock = vi.hoisted(() => 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 ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
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 (
<select
aria-label="Available token"
value={value}
onChange={(event) => {
const option = options.find((candidate) => candidate.value === event.target.value);
if (option) onValueChange(option.value, option);
}}
>
{options.map((option) => <option key={option.key} value={option.value}>{option.label}</option>)}
</select>
);
},
}));
(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<typeof createRoot>;
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(
<QueryClientProvider client={queryClient}>
<ConnectClientDialog
gateway={gateway(persisted)}
open
onOpenChange={vi.fn()}
createdTokens={[created]}
onTokenCreated={vi.fn()}
/>
</QueryClientProvider>,
);
});
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");
});
});
@@ -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<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;
};
/**
* "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<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 [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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Connect a client</DialogTitle>
<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>
Pick how you’ll point your client at this gateway.
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) => (
<button
key={snippet.client}
type="button"
onClick={() => setActive(snippet.client)}
className={cn(
"shrink-0 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",
)}
>
{snippet.label}
</button>
))}
{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(
"shrink-0 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
"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-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}
<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>
<Button variant="outline" size="sm" onClick={() => void copyText(endpoint, "Endpoint URL")}>
<Copy className="mr-1 h-3.5 w-3.5" />
Copy
</Button>
</div>
<p className="text-xs text-muted-foreground">
Authenticate with <code>Authorization: Bearer &lt;token&gt;</code> over streamable HTTP.
</p>
</div>
) : activeSnippet ? (
<div className="space-y-1.5">
@@ -132,20 +322,19 @@ export function ConnectClientDialog({
<Button
variant="outline"
size="sm"
onClick={() => void copyText(configText, `${activeSnippet.label} config`)}
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">
{configText}
{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>
))}
{activeSnippet.notes.map((note) => <li key={note}>{note}</li>)}
</ul>
) : null}
</div>
@@ -153,40 +342,10 @@ export function ConnectClientDialog({
<p className="text-sm text-muted-foreground">No client snippets available for this gateway.</p>
)}
<div className="space-y-1.5 rounded-md border border-border p-3">
<div className="text-xs font-medium text-muted-foreground">Token</div>
{createdToken ? (
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded bg-background px-2 py-1.5 font-mono text-xs text-foreground">
{revealed ? createdToken.token : maskedTokenLabel(createdToken)}
</code>
{revealed ? (
<Button
variant="outline"
size="sm"
onClick={() => void copyText(createdToken.token, "Access token")}
>
<Copy className="mr-1 h-3.5 w-3.5" />
Copy
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setRevealed(true)}>
Show
</Button>
)}
</div>
) : (
<p className="text-xs text-muted-foreground">
Mint a token on the <span className="font-medium">Tokens</span> tab, then paste it where the
snippet shows <code>Bearer …</code>. You won’t see a token’s full value again after it’s
created.
</p>
)}
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<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>
@@ -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 ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}));
(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<typeof createRoot>;
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(
<QueryClientProvider client={queryClient}>
<EditGatewayDialog
companyId="company-1"
gateway={existing}
profiles={[profile("profile-1", "Original"), profile("profile-2", "Shared tools")]}
open
onOpenChange={onOpenChange}
/>
</QueryClientProvider>,
);
});
const nameInput = container.querySelector<HTMLInputElement>('input[required]');
const descriptionInput = container.querySelector<HTMLTextAreaElement>("textarea");
const profileSelect = container.querySelector<HTMLSelectElement>("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);
});
});
@@ -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<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>
);
}
+28 -8
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Send } from "lucide-react";
import { Pencil, Send } from "lucide-react";
import type { ToolMcpGatewayTokenCreated } from "@paperclipai/shared";
import { Link, Navigate, useNavigate, useParams } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
@@ -17,6 +17,7 @@ import { ErrorState } from "@/pages/tools/shared";
import { GATEWAY_TABS, gatewayTabHref, isGatewayTabKey, type GatewayTabKey } from "./gateway-tabs";
import { gatewaysQueryKey } from "./NewGatewayDialog";
import { ConnectClientDialog } from "./ConnectClientDialog";
import { EditGatewayDialog } from "./EditGatewayDialog";
import { deriveGatewayApps, isGatewayOn } from "./gateway-helpers";
import { OverviewPanel } from "./panels/OverviewPanel";
import { AppsToolsPanel } from "./panels/AppsToolsPanel";
@@ -32,7 +33,8 @@ export function GatewayDetail() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [snippetOpen, setSnippetOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<ToolMcpGatewayTokenCreated | null>(null);
const [editing, setEditing] = useState(false);
const [createdTokens, setCreatedTokens] = useState<ToolMcpGatewayTokenCreated[]>([]);
const activeTab: GatewayTabKey | null = isGatewayTabKey(tab) ? tab : null;
@@ -93,6 +95,10 @@ export function GatewayDetail() {
[projectsQuery.data],
);
function rememberCreatedToken(token: ToolMcpGatewayTokenCreated) {
setCreatedTokens((current) => [token, ...current.filter((item) => item.id !== token.id)]);
}
useEffect(() => {
if (!gateway) return;
setBreadcrumbs([
@@ -177,10 +183,16 @@ export function GatewayDetail() {
<h1 className="mt-1 text-2xl font-bold tracking-tight">{gateway.name}</h1>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{endpointHost}</p>
</div>
<Button onClick={() => setSnippetOpen(true)}>
<Send className="mr-1.5 h-4 w-4" />
Show snippet
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setEditing(true)}>
<Pencil className="mr-1.5 h-4 w-4" />
Edit
</Button>
<Button onClick={() => setSnippetOpen(true)}>
<Send className="mr-1.5 h-4 w-4" />
Client snippets
</Button>
</div>
</div>
<nav className="flex items-center gap-6 overflow-x-auto border-b border-border text-sm" aria-label="Gateway tabs">
@@ -220,7 +232,7 @@ export function GatewayDetail() {
<TokensPanel
companyId={selectedCompanyId}
gateway={gateway}
onTokenCreated={(token) => setCreatedToken(token)}
onTokenCreated={rememberCreatedToken}
/>
)}
{activeTab === "activity" && (
@@ -234,7 +246,15 @@ export function GatewayDetail() {
gateway={gateway}
open={snippetOpen}
onOpenChange={setSnippetOpen}
createdToken={createdToken}
createdTokens={createdTokens}
onTokenCreated={rememberCreatedToken}
/>
<EditGatewayDialog
companyId={selectedCompanyId}
gateway={gateway}
profiles={profilesQuery.data?.profiles ?? []}
open={editing}
onOpenChange={setEditing}
/>
</div>
);
@@ -9,8 +9,10 @@ import type {
import { describe, expect, it } from "vitest";
import {
activeTokenCount,
defaultGatewayTokenName,
deriveGatewayApps,
expiringTokenCount,
formatHydratedSnippetConfig,
formatScope,
isGatewayOn,
maskedTokenLabel,
@@ -113,6 +115,32 @@ describe("maskedTokenLabel", () => {
});
});
describe("gateway client snippets", () => {
it("autofills a stable token name from the gateway and current minute", () => {
expect(defaultGatewayTokenName(gateway(), new Date("2026-08-18T14:37:42.000Z"))).toBe(
"cto-agents-202608181437",
);
});
it("hydrates the full origin and bearer token into copied client configuration", () => {
expect(formatHydratedSnippetConfig(
{
mcpServers: {
Paperclip: {
url: "/mcp/gateways/public-id",
headers: { Authorization: "Bearer pcgw_..." },
},
},
},
{
endpointPath: "/mcp/gateways/public-id",
endpoint: "https://paperclip.example/mcp/gateways/public-id",
token: "pcgw_full_secret",
},
)).toContain('"Authorization": "Bearer pcgw_full_secret"');
});
});
describe("isGatewayOn", () => {
it("is on only when status is active", () => {
expect(isGatewayOn(gateway({ status: "active" }))).toBe(true);
@@ -207,3 +207,42 @@ export function orderedSnippets(
export function formatSnippetConfig(config: Record<string, unknown>): string {
return JSON.stringify(config, null, 2);
}
export function defaultGatewayTokenName(
gateway: Pick<ToolMcpGatewayWithTokens, "displaySlug">,
now: Date = new Date(),
): string {
const stamp = now.toISOString().slice(0, 16).replace(/[-:T]/g, "");
return `${gateway.displaySlug}-${stamp}`;
}
type SnippetConfigReplacements = {
endpointPath: string;
endpoint: string;
token: string;
};
function replaceSnippetConfigValue(value: unknown, replacements: SnippetConfigReplacements): unknown {
if (typeof value === "string") {
return value
.split(replacements.endpointPath)
.join(replacements.endpoint)
.replaceAll("pcgw_...", replacements.token);
}
if (Array.isArray(value)) {
return value.map((item) => replaceSnippetConfigValue(item, replacements));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, replaceSnippetConfigValue(item, replacements)]),
);
}
return value;
}
export function formatHydratedSnippetConfig(
config: Record<string, unknown>,
replacements: SnippetConfigReplacements,
): string {
return JSON.stringify(replaceSnippetConfigValue(config, replacements), null, 2);
}
@@ -111,6 +111,7 @@ describe("TokensPanel", () => {
beforeEach(() => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-16T12:00:00.000Z").getTime());
vi.clearAllMocks();
container = document.createElement("div");
document.body.appendChild(container);
});
@@ -140,6 +141,16 @@ describe("TokensPanel", () => {
});
}
function clickButtonContaining(label: string) {
const button = [...container.querySelectorAll("button")].find(
(el) => el.textContent?.includes(label),
);
if (!button) throw new Error(`Button containing "${label}" not found`);
return act(async () => {
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
function setInput(selectorText: string, value: string) {
const input = container.querySelector<HTMLInputElement>(selectorText);
if (!input) throw new Error(`Input ${selectorText} not found`);
@@ -152,11 +163,16 @@ describe("TokensPanel", () => {
it("masks existing tokens and never renders the full secret at rest", async () => {
await render(<TokensPanel companyId="company-1" gateway={gateway({ tokens: [token()] })} />);
expect(container.textContent).not.toContain("pcgw_live_8x4Pa•••");
expect(container.textContent).toContain("fresh one-hour runtime token");
expect(container.textContent).toContain("one run may leave two revoked rows");
await clickButtonContaining("Token history");
expect(container.textContent).toContain("pcgw_live_8x4Pa•••");
expect(container.textContent).toContain("Active");
});
it("mints a token and reveals it once, then copies the full value", async () => {
it("autofills a token name, reuses it as the client label, and reveals the token once", async () => {
const created = { ...token({ id: "new-token" }), token: "pcgw_live_FULLSECRETVALUE" };
createGatewayTokenMock.mockResolvedValue(created);
const writeText = vi.fn().mockResolvedValue(undefined);
@@ -164,16 +180,27 @@ describe("TokensPanel", () => {
await render(<TokensPanel companyId="company-1" gateway={gateway()} />);
await clickButton("Mint token"); // open the mint form
await setInput('input[placeholder="cto-cursor"]', "cto-cursor");
await clickButton("Issue token");
const form = container.querySelector("form");
if (!form) throw new Error("mint form not found");
if (!form) throw new Error("token form not found");
const nameInput = form.querySelector<HTMLInputElement>('input:not([type="date"])');
if (!nameInput) throw new Error("token name input not found");
expect(nameInput.value).toMatch(/^cto-agents-\d{12}$/);
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
expect(createGatewayTokenMock).toHaveBeenCalledTimes(1);
expect(createGatewayTokenMock).toHaveBeenCalledWith(
"company-1",
"gateway-1",
expect.objectContaining({
name: nameInput.value,
clientLabel: nameInput.value,
ownerNote: "",
}),
);
expect(container.textContent).toContain("New token — copy now");
// Reveal-once banner shows the full value immediately after creation.
expect(container.textContent).toContain("pcgw_live_FULLSECRETVALUE");
@@ -186,6 +213,7 @@ describe("TokensPanel", () => {
revokeGatewayTokenMock.mockResolvedValue(token({ revokedAt: "2026-06-16T12:00:00.000Z" }));
await render(<TokensPanel companyId="company-1" gateway={gateway({ tokens: [token()] })} />);
await clickButtonContaining("Token history");
await clickButton("Revoke");
const confirmButton = [...container.querySelectorAll("button")].find(
(el) => el.textContent?.trim() === "Revoke token",
@@ -199,4 +227,31 @@ describe("TokensPanel", () => {
await flushReact();
expect(revokeGatewayTokenMock).toHaveBeenCalledWith("company-1", "token-1");
});
it("paginates the folded token log and labels past expiry as expired", async () => {
const tokens = Array.from({ length: 11 }, (_, index) => token({
id: `token-${index}`,
name: `client-${index}`,
tokenPrefix: `pcgw_${index}`,
expiresAt: index === 0 ? "2026-06-01T00:00:00.000Z" : "2026-09-01T00:00:00.000Z",
revokedAt: index === 0 ? "2026-06-10T00:00:00.000Z" : null,
createdAt: new Date(Date.UTC(2026, 5, index + 1)).toISOString(),
}));
await render(<TokensPanel companyId="company-1" gateway={gateway({ tokens })} />);
expect(container.textContent).not.toContain("client-10");
await clickButtonContaining("Token history");
expect(container.textContent).toContain("Page 1 of 2");
expect(container.textContent).toContain("client-10");
expect(container.textContent).not.toContain("client-0");
const next = container.querySelector<HTMLButtonElement>('button[aria-label="Next token page"]');
if (!next) throw new Error("next token page button not found");
await act(async () => next.click());
expect(container.textContent).toContain("Page 2 of 2");
expect(container.textContent).toContain("client-0");
expect(container.textContent).toContain("Expired");
expect(container.textContent).not.toContain("Expires 2 months ago");
});
});
+205 -159
View File
@@ -1,22 +1,36 @@
import { type FormEvent, type ReactNode, useMemo, useState } from "react";
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Copy, KeyRound, Plus } from "lucide-react";
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, KeyRound, Plus } from "lucide-react";
import type {
ToolMcpGatewayToken,
ToolMcpGatewayTokenAction,
ToolMcpGatewayTokenCreated,
ToolMcpGatewayWithTokens,
} from "@paperclipai/shared";
import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { useToast } from "@/context/ToastContext";
import { RelativeTime } from "@/pages/tools/shared";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { cn } from "@/lib/utils";
import { RelativeTime } from "@/pages/tools/shared";
import { gatewaysQueryKey } from "../NewGatewayDialog";
import { maskedTokenLabel, TOKEN_STATUS_LABEL, tokenStatus, type TokenStatus } from "../gateway-helpers";
import {
defaultGatewayTokenName,
maskedTokenLabel,
TOKEN_STATUS_LABEL,
toDate,
tokenStatus,
type TokenStatus,
} from "../gateway-helpers";
const DEFAULT_ACTIONS: ToolMcpGatewayTokenAction[] = ["tools/list", "tools/call"];
const TOKEN_PAGE_SIZE = 10;
function defaultExpiry(): string {
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
@@ -29,7 +43,6 @@ const STATUS_CLASS: Record<TokenStatus, string> = {
revoked: "border-foreground bg-foreground text-background",
};
/** Token status pill, shared by the desktop table and mobile cards. */
function StatusBadge({ status }: { status: TokenStatus }) {
return (
<span
@@ -43,7 +56,6 @@ function StatusBadge({ status }: { status: TokenStatus }) {
);
}
/** One label:value pair inside a mobile stacked card. */
function TokenField({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="min-w-0">
@@ -53,6 +65,15 @@ function TokenField({ label, value }: { label: string; value: ReactNode }) {
);
}
function ExpiryValue({ token }: { token: ToolMcpGatewayToken }) {
const expiresAt = toDate(token.expiresAt);
if (!expiresAt) return <>No expiry</>;
if (expiresAt.getTime() <= Date.now()) {
return <><span className="font-medium text-foreground">Expired</span> <RelativeTime value={token.expiresAt} /></>;
}
return <>Expires <RelativeTime value={token.expiresAt} /></>;
}
export function TokensPanel({
companyId,
gateway,
@@ -66,22 +87,33 @@ export function TokensPanel({
const { pushToast } = useToast();
const [minting, setMinting] = useState(false);
const [name, setName] = useState("");
const [clientLabel, setClientLabel] = useState("");
const [ownerNote, setOwnerNote] = useState("");
const [expiresAt, setExpiresAt] = useState(defaultExpiry());
const [created, setCreated] = useState<ToolMcpGatewayTokenCreated | null>(null);
const [revealed, setRevealed] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyPage, setHistoryPage] = useState(1);
const [revokeName, setRevokeName] = useState("");
const [confirmToken, setConfirmToken] = useState<{ id: string; name: string } | null>(null);
const invalidate = () => queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(companyId) });
const tokens = useMemo(
() => [...gateway.tokens].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
[gateway.tokens],
);
const pageCount = Math.max(1, Math.ceil(tokens.length / TOKEN_PAGE_SIZE));
const visibleTokens = tokens.slice((historyPage - 1) * TOKEN_PAGE_SIZE, historyPage * TOKEN_PAGE_SIZE);
useEffect(() => {
setHistoryPage((current) => Math.min(current, pageCount));
}, [pageCount]);
const createMutation = useMutation({
mutationFn: () =>
toolsApi.createGatewayToken(companyId, gateway.id, {
name: name.trim(),
clientLabel: clientLabel.trim() || name.trim(),
ownerNote: ownerNote.trim() || name.trim(),
clientLabel: name.trim(),
ownerNote: ownerNote.trim(),
allowedActions: DEFAULT_ACTIONS,
expiresAt: expiresAt ? `${expiresAt}T23:59:59.000Z` : null,
}),
@@ -90,12 +122,11 @@ export function TokensPanel({
setRevealed(true);
setMinting(false);
setName("");
setClientLabel("");
setOwnerNote("");
setExpiresAt(defaultExpiry());
pushToast({
title: "Token minted",
body: "Copy it now — you won’t see the full value again.",
title: "Token issued",
body: "Copy it now — you won’t see the full value again after leaving this page.",
tone: "success",
});
onTokenCreated?.(token);
@@ -103,7 +134,7 @@ export function TokensPanel({
},
onError: (error) =>
pushToast({
title: "Token was not minted",
title: "Token was not issued",
body: error instanceof Error ? error.message : String(error),
tone: "error",
}),
@@ -138,76 +169,80 @@ export function TokensPanel({
}
}
function startIssuing() {
if (!minting && !name.trim()) setName(defaultGatewayTokenName(gateway));
setMinting((value) => !value);
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!name.trim()) return;
createMutation.mutate();
}
const tokens = useMemo(
() =>
[...gateway.tokens].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
),
[gateway.tokens],
);
function startRevoke(token: ToolMcpGatewayToken) {
setConfirmToken({ id: token.id, name: token.name });
setRevokeName("");
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
Each token is a separate way in. Revoke any one without breaking the others.
</p>
<Button size="sm" onClick={() => setMinting((value) => !value)}>
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="max-w-2xl space-y-1">
<p className="text-sm text-muted-foreground">
Issue a reusable token for an external client. Its name is also used as the client label.
</p>
<p className="text-xs text-muted-foreground">
Paperclip creates a fresh one-hour runtime token when an agent run starts, then revokes it when the
run ends. Codex can receive the same gateway through both an app connection and the managed-gateway
path, so one run may leave two revoked rows. These are audit history, not repeated manual tokens.
</p>
</div>
<Button size="sm" onClick={startIssuing}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Mint token
Issue token
</Button>
</div>
{minting ? (
<form className="space-y-3 rounded-md border border-border p-4" onSubmit={submit}>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1.5 text-sm">
<span className="text-xs font-medium text-muted-foreground">Name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="cto-cursor" required autoFocus />
</label>
<label className="space-y-1.5 text-sm">
<span className="text-xs font-medium text-muted-foreground">Owner / client</span>
<Input
value={clientLabel}
onChange={(e) => setClientLabel(e.target.value)}
placeholder="Cursor on work laptop"
/>
</label>
</div>
<form className="space-y-3" onSubmit={submit}>
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
<label className="space-y-1.5 text-sm">
<span className="text-xs font-medium text-muted-foreground">Note (why it exists)</span>
<Input value={ownerNote} onChange={(e) => setOwnerNote(e.target.value)} placeholder="Dotta’s MacBook" />
<span className="text-xs font-medium text-muted-foreground">Token name</span>
<Input value={name} onChange={(event) => setName(event.target.value)} required autoFocus />
<span className="text-xs text-muted-foreground">Also used as the client label.</span>
</label>
<label className="space-y-1.5 text-sm">
<span className="text-xs font-medium text-muted-foreground">Expires</span>
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} required />
<Input type="date" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} required />
</label>
</div>
<label className="block space-y-1.5 text-sm">
<span className="text-xs font-medium text-muted-foreground">Owner note (optional)</span>
<Input
value={ownerNote}
onChange={(event) => setOwnerNote(event.target.value)}
placeholder="Who uses this token or why it exists"
/>
</label>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => setMinting(false)}>
Cancel
</Button>
<Button type="submit" size="sm" disabled={createMutation.isPending || !name.trim()}>
{createMutation.isPending ? "Minting…" : "Mint token"}
{createMutation.isPending ? "Issuing…" : "Issue token"}
</Button>
</div>
</form>
) : null}
{created ? (
<div className="space-y-2 rounded-md border-2 border-foreground/80 bg-muted/40 p-4">
<div className="space-y-2 border-y border-border py-4">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold text-foreground">New token — copy now</div>
<div className="text-xs text-muted-foreground">
You won’t see the full value again. Store it in your client’s config or your secret manager.
It is now available in Client snippets for a copy-ready configuration.
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => setCreated(null)} aria-label="Dismiss new token">
@@ -215,7 +250,7 @@ export function TokensPanel({
</Button>
</div>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded bg-background px-3 py-2 font-mono text-xs text-foreground">
<code className="min-w-0 flex-1 truncate rounded bg-muted px-3 py-2 font-mono text-xs text-foreground">
{revealed ? created.token : maskedTokenLabel(created)}
</code>
{revealed ? (
@@ -224,129 +259,140 @@ export function TokensPanel({
Copy
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setRevealed(true)}>
Show
</Button>
<Button variant="outline" size="sm" onClick={() => setRevealed(true)}>Show</Button>
)}
</div>
</div>
) : null}
{tokens.length === 0 ? (
<div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
No tokens yet. Mint one for the client that will connect to this gateway.
</div>
) : (
<>
{/* Desktop / tablet: full table. */}
<div className="hidden overflow-x-auto rounded-lg border border-border sm:block">
<table className="w-full min-w-(--sz-44rem) text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
<th className="px-4 py-2.5">Token</th>
<th className="px-4 py-2.5">Owner</th>
<th className="px-4 py-2.5">Created</th>
<th className="px-4 py-2.5">Last used</th>
<th className="px-4 py-2.5">Expires</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5 text-right" />
</tr>
</thead>
<tbody>
{tokens.map((token) => {
const status = tokenStatus(token);
const canRevoke = status !== "revoked";
return (
<tr key={token.id} className="border-b border-border last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-foreground">{token.name}</div>
<div className="font-mono text-xs text-muted-foreground">{maskedTokenLabel(token)}</div>
</td>
<td className="px-4 py-3 text-muted-foreground">{token.clientLabel || token.ownerNote || "—"}</td>
<td className="px-4 py-3 text-muted-foreground"><RelativeTime value={token.createdAt} /></td>
<td className="px-4 py-3 text-muted-foreground">
{token.lastUsedAt ? <RelativeTime value={token.lastUsedAt} /> : "—"}
</td>
<td className="px-4 py-3 text-muted-foreground">
{token.revokedAt ? "—" : token.expiresAt ? <RelativeTime value={token.expiresAt} /> : "no expiry"}
</td>
<td className="px-4 py-3">
<StatusBadge status={status} />
</td>
<td className="px-4 py-3 text-right">
{canRevoke ? (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => {
setConfirmToken({ id: token.id, name: token.name });
setRevokeName("");
}}
>
Revoke
</Button>
) : null}
</td>
<Collapsible open={historyOpen} onOpenChange={setHistoryOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="h-auto w-full justify-between px-0 py-1 hover:bg-transparent">
<span className="flex items-center gap-2 text-sm font-semibold text-foreground">
<KeyRound className="h-4 w-4 text-muted-foreground" />
Token history
<span className="font-normal text-muted-foreground">{tokens.length}</span>
</span>
{historyOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
{tokens.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">No tokens have been issued for this gateway.</p>
) : (
<>
<div className="hidden overflow-x-auto rounded-lg border border-border sm:block">
<table className="w-full min-w-(--sz-44rem) text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
<th className="px-4 py-2.5">Token</th>
<th className="px-4 py-2.5">Owner</th>
<th className="px-4 py-2.5">Created</th>
<th className="px-4 py-2.5">Last used</th>
<th className="px-4 py-2.5">Expiry</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5 text-right" />
</tr>
</thead>
<tbody>
{visibleTokens.map((token) => {
const status = tokenStatus(token);
return (
<tr key={token.id} className="border-b border-border last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-foreground">{token.name}</div>
<div className="font-mono text-xs text-muted-foreground">{maskedTokenLabel(token)}</div>
</td>
<td className="px-4 py-3 text-muted-foreground">{token.clientLabel || token.ownerNote || "—"}</td>
<td className="px-4 py-3 text-muted-foreground"><RelativeTime value={token.createdAt} /></td>
<td className="px-4 py-3 text-muted-foreground">
{token.lastUsedAt ? <RelativeTime value={token.lastUsedAt} /> : "—"}
</td>
<td className="px-4 py-3 text-muted-foreground"><ExpiryValue token={token} /></td>
<td className="px-4 py-3"><StatusBadge status={status} /></td>
<td className="px-4 py-3 text-right">
{status !== "revoked" ? (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => startRevoke(token)}
>
Revoke
</Button>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="space-y-3 sm:hidden">
{visibleTokens.map((token) => {
const status = tokenStatus(token);
return (
<div key={token.id} className="border-b border-border py-3 last:border-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-foreground">{token.name}</div>
<div className="font-mono text-xs text-muted-foreground">{maskedTokenLabel(token)}</div>
</div>
<StatusBadge status={status} />
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<TokenField label="Owner" value={token.clientLabel || token.ownerNote || "—"} />
<TokenField label="Created" value={<RelativeTime value={token.createdAt} />} />
<TokenField label="Last used" value={token.lastUsedAt ? <RelativeTime value={token.lastUsedAt} /> : "—"} />
<TokenField label="Expiry" value={<ExpiryValue token={token} />} />
</dl>
{status !== "revoked" ? (
<Button
variant="ghost"
size="sm"
className="mt-3 w-full text-xs text-destructive hover:text-destructive"
onClick={() => startRevoke(token)}
>
Revoke
</Button>
) : null}
</div>
);
})}
</tbody>
</table>
</div>
</div>
{/* Mobile: stacked cards so status + Revoke stay reachable. */}
<div className="space-y-3 sm:hidden">
{tokens.map((token) => {
const status = tokenStatus(token);
const canRevoke = status !== "revoked";
return (
<div key={token.id} className="rounded-lg border border-border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-foreground">{token.name}</div>
<div className="font-mono text-xs text-muted-foreground">{maskedTokenLabel(token)}</div>
</div>
<StatusBadge status={status} />
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<TokenField label="Owner" value={token.clientLabel || token.ownerNote || "—"} />
<TokenField label="Created" value={<RelativeTime value={token.createdAt} />} />
<TokenField
label="Last used"
value={token.lastUsedAt ? <RelativeTime value={token.lastUsedAt} /> : "—"}
/>
<TokenField
label="Expires"
value={
token.revokedAt ? "—" : token.expiresAt ? <RelativeTime value={token.expiresAt} /> : "no expiry"
}
/>
</dl>
{canRevoke ? (
{pageCount > 1 ? (
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
Page {historyPage} of {pageCount} · {tokens.length} tokens
</p>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="mt-3 w-full text-xs text-destructive hover:text-destructive"
onClick={() => {
setConfirmToken({ id: token.id, name: token.name });
setRevokeName("");
}}
disabled={historyPage === 1}
onClick={() => setHistoryPage((page) => Math.max(1, page - 1))}
aria-label="Previous token page"
>
Revoke
<ChevronLeft className="h-4 w-4" />
</Button>
) : null}
<Button
variant="ghost"
size="sm"
disabled={historyPage === pageCount}
onClick={() => setHistoryPage((page) => Math.min(pageCount, page + 1))}
aria-label="Next token page"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
</>
)}
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<KeyRound className="h-3.5 w-3.5" />
Every mint, reveal, and revoke is recorded in Activity.
</p>
) : null}
</>
)}
</CollapsibleContent>
</Collapsible>
{confirmToken ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
@@ -360,7 +406,7 @@ export function TokensPanel({
</div>
<Input
value={revokeName}
onChange={(e) => setRevokeName(e.target.value)}
onChange={(event) => setRevokeName(event.target.value)}
placeholder={confirmToken.name}
aria-label="Type the token name to confirm"
autoFocus
+7 -13
View File
@@ -22,6 +22,7 @@ import {
type ToolGatewayActivityEvent,
} from "@/api/tools";
import { agentsApi } from "@/api/agents";
import { AgentSelect } from "@/components/AgentMultiSelect";
import { ToolsPageHeader, LoadingState, ErrorState, RelativeTime } from "./shared";
import { advancedTabHref } from "./tool-tabs";
@@ -382,19 +383,12 @@ export function AuditTab({ companyId }: { companyId: string }) {
))}
</SelectContent>
</Select>
<Select value={agent} onValueChange={setAgent}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Agent" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All agents</SelectItem>
{(agents.data ?? []).map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
<AgentSelect
agents={[{ id: ALL, name: "All agents" }, ...(agents.data ?? [])]}
value={agent}
onChange={setAgent}
triggerClassName="w-40"
/>
<Select value={outcome} onValueChange={setOutcome}>
<SelectTrigger className="w-40">
<SelectValue />
+8 -19
View File
@@ -25,6 +25,7 @@ import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
import { projectsApi } from "@/api/projects";
import { ApiError } from "@/api/client";
import { AgentSelect } from "@/components/AgentMultiSelect";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
@@ -448,18 +449,7 @@ function PolicySimulator({
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Agent</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger>
<SelectValue placeholder="Select an agent" />
</SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
<AgentSelect agents={agents} value={agentId} onChange={setAgentId} />
</div>
<div className="space-y-1.5">
<Label htmlFor="test-action">Action</Label>
@@ -596,13 +586,12 @@ function RuleBuilder({
))}
</div>
{form.whenMode === "agent" ? (
<Select value={form.agentId} onValueChange={(agentId) => setForm({ ...form, agentId })}>
<SelectTrigger><SelectValue placeholder="Choose agent" /></SelectTrigger>
<SelectContent>
<SelectItem value={ANY_VALUE}>Choose agent</SelectItem>
{agents.map((agent) => <SelectItem key={agent.id} value={agent.id}>{agent.name}</SelectItem>)}
</SelectContent>
</Select>
<AgentSelect
agents={agents}
value={form.agentId === ANY_VALUE ? "" : form.agentId}
onChange={(agentId) => setForm({ ...form, agentId })}
placeholder="Choose agent"
/>
) : null}
{form.whenMode === "project" ? (
<Select value={form.projectId} onValueChange={(projectId) => setForm({ ...form, projectId })}>
+3 -18
View File
@@ -29,6 +29,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { AgentSelect } from "@/components/AgentMultiSelect";
import {
Dialog,
DialogContent,
@@ -484,18 +485,7 @@ export function EffectiveAgentPanel({ companyId, agentOptions }: { companyId: st
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="space-y-1.5">
<Label>Agent</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger>
<SelectValue placeholder="Select an agent" />
</SelectTrigger>
<SelectContent>
{agentOptions.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
<AgentSelect agents={agentOptions} value={agentId} onChange={setAgentId} />
</div>
{!agentId ? (
<div className="rounded-lg border border-dashed border-border px-4 py-8 text-sm text-muted-foreground">
@@ -1181,12 +1171,7 @@ export function ProfilesTab({ companyId }: { companyId: string }) {
{targetType === "agent" ? (
<div className="space-y-1.5">
<Label>Agent</Label>
<Select value={targetAgentId} onValueChange={setTargetAgentId}>
<SelectTrigger><SelectValue placeholder="Select an agent" /></SelectTrigger>
<SelectContent>
{agentOptions.map((agent) => <SelectItem key={agent.id} value={agent.id}>{agent.name}</SelectItem>)}
</SelectContent>
</Select>
<AgentSelect agents={agentOptions} value={targetAgentId} onChange={setTargetAgentId} />
</div>
) : null}
{targetType === "project" ? (