mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
feat(apps): improve gateway management UX
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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 <token></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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 })}>
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
Reference in new issue
Block a user