mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
fix(ui): name MCP action switches
Preserve the unverified-server trust marker on URL-only connection details and compact the mobile action step above the bottom nav. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5634c2b966
commit
28c0c279aa
5 files changed
+100
-29
No files matched your search
@@ -285,6 +285,23 @@ describe("AppDetail", () => {
|
||||
expect(updateConnectionMock).toHaveBeenCalledWith("conn-1", { enabled: false });
|
||||
});
|
||||
|
||||
it("keeps the unverified-server marker on URL-only connection details", async () => {
|
||||
getConnectionMock.mockResolvedValue(
|
||||
connection({
|
||||
name: "127.0.0.1",
|
||||
config: { url: "http://127.0.0.1:8848/mcp" },
|
||||
transportConfig: { url: "http://127.0.0.1:8848/mcp" },
|
||||
}),
|
||||
);
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Custom app");
|
||||
expect(container.textContent).toContain("hosted at 127.0.0.1");
|
||||
expect(container.textContent).toContain("Unverified server");
|
||||
expect(container.textContent).toContain("127.0.0.1:8848");
|
||||
});
|
||||
|
||||
it("redirects a missing tab to setup", async () => {
|
||||
mockParams.tab = undefined;
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { UnverifiedServerBadge } from "./UnverifiedServerBadge";
|
||||
import {
|
||||
appDefinitionLogoUrl,
|
||||
appDefinitionName,
|
||||
@@ -523,6 +524,8 @@ function AppDetailHeader({
|
||||
onRenameCancel: () => void;
|
||||
onRenameSubmit: (value: string) => void;
|
||||
}) {
|
||||
const unverifiedHost = unverifiedRemoteHost(connection);
|
||||
|
||||
return (
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -567,6 +570,7 @@ function AppDetailHeader({
|
||||
{connectionDisplaySecondaryHint(connection) && (
|
||||
<p className="text-xs text-muted-foreground">{connectionDisplaySecondaryHint(connection)}</p>
|
||||
)}
|
||||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-1" /> : null}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<StatusBadge status={status} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -579,6 +583,28 @@ function AppDetailHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function unverifiedRemoteHost(connection: ToolConnection): string | null {
|
||||
const sourceTemplateKey = connection.config?.sourceTemplateKey ?? connection.transportConfig.sourceTemplateKey;
|
||||
if (
|
||||
connection.transport !== "mcp_remote"
|
||||
|| (typeof sourceTemplateKey === "string" && sourceTemplateKey.trim())
|
||||
) return null;
|
||||
|
||||
const value = connection.config?.url
|
||||
?? connection.config?.endpoint
|
||||
?? connection.config?.remoteUrl
|
||||
?? connection.transportConfig.url
|
||||
?? connection.transportConfig.endpoint
|
||||
?? connection.transportConfig.remoteUrl;
|
||||
if (typeof value !== "string") return null;
|
||||
|
||||
try {
|
||||
return new URL(value).host || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type StatusInfo = { label: string; tone: "connected" | "attention" | "paused" };
|
||||
|
||||
function statusFor(connection: ToolConnection): StatusInfo {
|
||||
|
||||
@@ -1169,8 +1169,28 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
}, {
|
||||
catalogEntryId: "cat-search",
|
||||
toolName: "search_things",
|
||||
title: "Search things",
|
||||
description: null,
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
}],
|
||||
canMakeChanges: [{
|
||||
catalogEntryId: "cat-delete",
|
||||
toolName: "qa_delete_widget",
|
||||
title: null,
|
||||
description: null,
|
||||
riskLevel: "destructive",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
isDestructive: true,
|
||||
status: "active",
|
||||
}],
|
||||
canMakeChanges: [],
|
||||
},
|
||||
catalog: [],
|
||||
suggestedDefaults: { askFirstRiskLevels: ["write", "destructive"] },
|
||||
@@ -1184,6 +1204,9 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
|
||||
expect(container.textContent).toContain("List things");
|
||||
expect(container.textContent).toContain("Unverified server");
|
||||
expect(container.textContent).toContain("mcp.example.test");
|
||||
expect(
|
||||
Array.from(container.querySelectorAll('[role="switch"]')).map((toggle) => toggle.getAttribute("aria-label")),
|
||||
).toEqual(["List things allowed", "Search things allowed", "qa_delete_widget allowed"]);
|
||||
});
|
||||
|
||||
it("offers a curated setup as a convenience without leaving the generic flow", async () => {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Loader2,
|
||||
Lock,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
@@ -50,11 +49,13 @@ import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
|
||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { UnverifiedServerBadge } from "./UnverifiedServerBadge";
|
||||
import { appSourceConnectHref, isMcpDirectOAuthConnectSlug } from "./app-connect-policy";
|
||||
import { parseGoogleSheetIds } from "./google-sheets";
|
||||
import {
|
||||
canSubmitGenericConnect,
|
||||
customHeaderError,
|
||||
defaultGenericMcpName,
|
||||
endpointHost,
|
||||
genericConnectGuidance,
|
||||
genericConnectPayload,
|
||||
@@ -680,7 +681,7 @@ export function AppsConnect() {
|
||||
const appName =
|
||||
connectResult?.application.name ??
|
||||
entry?.name ??
|
||||
(linkName.trim() || defaultLinkName(linkUrl) || "this app");
|
||||
(linkName.trim() || defaultGenericMcpName(linkUrl) || "this app");
|
||||
const zapierEntry = zapierSource
|
||||
? galleryQuery.data?.apps.find((app) => app.slug === "zapier") ?? null
|
||||
: null;
|
||||
@@ -731,7 +732,7 @@ export function AppsConnect() {
|
||||
setEntry(null);
|
||||
setGalleryName("");
|
||||
setLinkUrl(url);
|
||||
setLinkName(matchedEntry?.name ?? defaultLinkName(url) ?? "");
|
||||
setLinkName(matchedEntry?.name ?? defaultGenericMcpName(url) ?? "");
|
||||
setLinkNeedsKey(false);
|
||||
setLinkKey("");
|
||||
setCredentials({});
|
||||
@@ -1378,14 +1379,6 @@ function normalizeAppLink(value: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLinkName(link: string): string | null {
|
||||
try {
|
||||
return new URL(link).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The guided universal flow for an unknown remote MCP server (PAP-17087, plan 2).
|
||||
*
|
||||
@@ -1463,6 +1456,11 @@ function LinkConnectStep({
|
||||
};
|
||||
const canSubmit = canSubmitGenericConnect(draft);
|
||||
const showSimpleKeyQuestion = authMode === "auto";
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (guidance?.focus === "name") nameInputRef.current?.focus();
|
||||
}, [guidance]);
|
||||
|
||||
const updateHeader = (id: string, patch: Partial<CustomHeaderRow>) => {
|
||||
onHeadersChange(headers.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
||||
@@ -1505,6 +1503,7 @@ function LinkConnectStep({
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-name">Name</label>
|
||||
<Input
|
||||
ref={nameInputRef}
|
||||
id="generic-mcp-name"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
@@ -1717,18 +1716,6 @@ const GENERIC_AUTH_MODE_OPTIONS: Array<{ mode: GenericMcpAuthMode; label: string
|
||||
},
|
||||
];
|
||||
|
||||
function UnverifiedServerBadge({ host, className }: { host: string | null; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 text-xs", className)}>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 font-medium text-amber-700 dark:text-amber-400">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
Unverified server
|
||||
</span>
|
||||
{host ? <span className="font-mono text-muted-foreground">{host}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StoredSecurelyNote() {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-muted/50 p-4">
|
||||
@@ -2135,7 +2122,7 @@ function ActionGroup({
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-2.5 sm:py-3">
|
||||
<div className="text-sm">
|
||||
<span className="font-bold text-foreground">{title}</span>
|
||||
<span className="ml-2 text-muted-foreground">· {hint}</span>
|
||||
@@ -2153,7 +2140,7 @@ function ActionGroup({
|
||||
const on = enabled[action.catalogEntryId] ?? false;
|
||||
const showAskFirst = on && askFirstLevels.includes(action.riskLevel);
|
||||
return (
|
||||
<div key={action.catalogEntryId} className="flex items-center gap-4 px-5 py-3">
|
||||
<div key={action.catalogEntryId} className="flex items-center gap-4 px-5 py-2.5 sm:py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{action.title ?? action.toolName}
|
||||
@@ -2167,7 +2154,11 @@ function ActionGroup({
|
||||
Ask first
|
||||
</span>
|
||||
)}
|
||||
<ToggleSwitch checked={on} onCheckedChange={(next) => onToggle(action.catalogEntryId, next)} />
|
||||
<ToggleSwitch
|
||||
aria-label={`${action.title ?? action.toolName} allowed`}
|
||||
checked={on}
|
||||
onCheckedChange={(next) => onToggle(action.catalogEntryId, next)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -2199,7 +2190,7 @@ function ActionsStep({
|
||||
const enabledCount = Object.values(enabled).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full border-2 border-emerald-500 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
@@ -2236,7 +2227,7 @@ function ActionsStep({
|
||||
askFirstLevels={askFirstLevels}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="flex items-center justify-between sm:pt-1">
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function UnverifiedServerBadge({ host, className }: { host: string | null; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 text-xs", className)}>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 font-medium text-amber-700 dark:text-amber-400">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
Unverified server
|
||||
</span>
|
||||
{host ? <span className="font-mono text-muted-foreground">{host}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user