Files
PaperClipAI/ui/src/pages/tools/PasteConfigTab.tsx
T
DottaandPaperclip f449b05bc5 feat(apps): unify permissions and action testing (#12802)
## Thinking Path

> - Paperclip is the control plane for companies that use AI agents.
> - Apps give humans and agents controlled access to external services.
> - The existing app detail flow split permissions, tests, setup, and
activity across separate pages.
> - The split made access rules harder to understand and made reconnect
work hard to find.
> - New write actions also defaulted to Ask first, which did not match
the intended connection policy.
> - This pull request combines permission control and action testing,
removes the setup page, and moves connection activity into Audit.
> - The benefit is one clear place to configure, test, reconnect, and
review each app.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The installed app Permissions, Test, Setup, and Activity views.

**Subsystem affected**

Cross-cutting. This change updates the React UI, shared app defaults,
server permission behavior, tests, smoke scripts, and connection
documentation.

**Current behavior**

App access and action testing use separate pages. The app detail view
also links to a setup page after installation. Connection activity uses
a separate tab. New write actions default to Ask first.

**Proposed behavior**

Permissions uses the connection access language from the initial flow.
It includes searchable Read and Write sections, a three-state permission
control, and a Test dialog for each action. Reconnect appears below a
Needs attention header on Permissions and Review. Old Setup and Test
links redirect to Permissions. Old Activity links redirect to the
filtered company Audit feed. New write actions default to Allowed.

**Reason and benefit**

A person can understand and test app access without moving between
several pages. Reconnect work stays visible where the person reviews the
connection. Audit events use one consistent feed and filter model. New
connections have the intended default policy.

**Breaking changes**

The Setup, Test, and app Activity tabs are removed. Existing deep links
redirect to their replacement pages. Existing saved action permissions
do not change. Only defaults for new write actions change.

**Additional context**

This builds on the managed app connection work in #12728. A search found
no duplicate open pull request or issue.

## What Changed

- Combined action testing with Permissions.
- Added searchable Read and Write action groups.
- Added Off, Ask first, and Allowed controls with tooltips.
- Added an action Test dialog with agent selection, arguments, and
formatted results.
- Removed the installed-app Setup and Activity tabs.
- Added reconnect guidance to Permissions and Review when a connection
needs attention.
- Routed connection activity into the company Audit feed and preserved
the Apps & tools filter in streamlined Audit.
- Moved connection removal to the Connectors-page management menu.
- Made new write actions default to Allowed across connection creation
paths.
- Updated regression tests, browser suites, smoke scripts, and
connection documentation.

## Verification

- `pnpm check:token-gates`
- `pnpm exec vitest run packages/shared/src/app-definitions.test.ts
server/src/__tests__/generic-mcp-connection.test.ts
server/src/__tests__/tool-access-service.test.ts
ui/src/components/AppConnectionSidebar.test.tsx
ui/src/pages/apps/AppDetail.test.tsx
ui/src/pages/apps/AppNotConnected.test.tsx
ui/src/pages/apps/AppsConnect.test.tsx ui/src/pages/apps/Browse.test.tsx
ui/src/pages/apps/Connections.test.tsx
ui/src/pages/apps/composio-services.test.ts
ui/src/pages/audit/AuditFeed.test.tsx
ui/src/pages/tools/PasteConfigTab.test.tsx` (517 tests passed)
- `pnpm exec vitest run ui/src/pages/apps/app-detail/TestPanel.test.tsx
ui/src/pages/audit/AuditHub.test.tsx
ui/src/pages/audit/AuditFeed.test.tsx
ui/src/pages/apps/AppDetail.test.tsx ui/src/pages/apps/Browse.test.tsx`
(96 tests passed)
- Targeted Playwright verification for connection removal, rename on
Permissions, inline action testing, and Smoke Lab Audit evidence (5
flows passed)
- `pnpm -r typecheck`
- `pnpm build`
- `pnpm test:run` completed with 5,755 passing tests and 20 unrelated
macOS harness failures. The failures use `/tmp` versus `/private/tmp`,
invalid ports above 65535, and workspace fixtures outside this change.

## Risks

- Low migration risk. This change has no database migration.
- Old app-detail URLs depend on redirect compatibility.
- New connections grant write actions by default. Finalization remains
configure-authorized and audited, Ask first and Off remain available per
action, and existing connections keep their saved policy.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected - check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, exact model ID `gpt-5`. The client does not expose the
context-window size. The model used reasoning, repository tools, code
execution, and browser verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-03 21:23:26 -05:00

575 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { CheckCircle2, KeyRound, Loader2, ShieldCheck } from "lucide-react";
import type {
ConnectToolAppResult,
McpJsonImportDraft,
McpJsonImportPreview,
ToolAppConnectionActionSummary,
ToolOAuthStartResult,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { toolsApi } from "@/api/tools";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { useNavigate } from "@/lib/router";
import {
OAuthConnectStateScreen,
type OAuthConnectPhase,
} from "@/features/connections/ConnectionSetupFlow";
import { endpointHost } from "@/pages/apps/generic-mcp-connect";
import { McpConfigHelpDialog } from "./McpConfigHelpDialog";
import { ErrorState } from "./shared";
const SAMPLE_CONFIG = `{
"mcpServers": {
"github": {
"command": "npx -y @modelcontextprotocol/server-github",
"env": { "GITHUB_TOKEN": "ghp_..." }
}
}
}`;
/** Turn an env/header key (e.g. `GITHUB_TOKEN`) into a friendly field label. */
function humanizeKey(raw: string): string {
const cleaned = raw.replace(/[_-]+/g, " ").trim().toLowerCase();
if (!cleaned) return "Key";
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
function draftSummary(draft: McpJsonImportDraft): string {
const keyCount = draft.credentialFields.length || draft.credentialRefs.length;
const where = draft.transport === "local_stdio" ? "Runs in your workspace" : "Connects over the web";
if (keyCount === 0) return `${where} · no keys needed`;
return `${where} · needs ${keyCount} ${keyCount === 1 ? "key" : "keys"}`;
}
/**
* A draft is connectable when it's a remote server with a real http(s) URL — that
* is exactly what the "Connect with a link" wizard step needs to land an active
* `tool_connection`. Imported stdio commands stay draft-only (they require an
* approved Paperclip template), so they get no hand-off here.
*/
function draftConnectUrl(draft: McpJsonImportDraft): string | null {
if (draft.transport !== "mcp_remote") return null;
const raw = draft.config?.url;
if (typeof raw !== "string") return null;
try {
const parsed = new URL(raw.trim());
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
return parsed.toString();
} catch {
return null;
}
}
function credentialValueKey(draft: McpJsonImportDraft, configPath: string): string {
return `${draft.name}::${configPath}`;
}
function credentialValuesForDraft(draft: McpJsonImportDraft, values: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const field of draft.credentialFields) {
const value = values[credentialValueKey(draft, field.configPath)]?.trim();
if (value) out[field.configPath] = value;
}
return out;
}
function missingCredentialFields(draft: McpJsonImportDraft, values: Record<string, string>): string[] {
return draft.credentialFields
.filter((field) => field.required)
.filter((field) => !values[credentialValueKey(draft, field.configPath)]?.trim())
.map((field) => field.configPath);
}
function askFirstLevelsFrom(result: ConnectToolAppResult): string[] {
const raw = (result.suggestedDefaults as { askFirstRiskLevels?: unknown })?.askFirstRiskLevels;
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : [];
}
/**
* M8a — "Paste a config" tab on the Advanced door (PAP-10862, plan D8).
*
* A thin, honest surface over `POST /companies/:id/tools/mcp/import-json`: paste
* the snippet a README tells you to copy, and we parse it into a friendly
* preview (humanized field labels, never the raw transport jargon). This is one
* of the two M8 screens where "MCP" vocabulary is allowed (PAP-10827 vocab map).
*/
export function PasteConfigTab({ companyId }: { companyId: string }) {
const navigate = useNavigate();
const [draftText, setDraftText] = useState("");
const [preview, setPreview] = useState<McpJsonImportPreview | null>(null);
const [connectionNames, setConnectionNames] = useState<Record<string, string>>({});
const [credentialValues, setCredentialValues] = useState<Record<string, string>>({});
const [connectResult, setConnectResult] = useState<ConnectToolAppResult | null>(null);
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
const [activatedName, setActivatedName] = useState<string | null>(null);
const [oauthPhase, setOAuthPhase] = useState<OAuthConnectPhase>("entry");
const [oauthError, setOAuthError] = useState<string | null>(null);
const [authorizationHost, setAuthorizationHost] = useState<string | null>(null);
const importMutation = useMutation({
mutationFn: (mcpJson: string) => toolsApi.importMcpJson(companyId, { mcpJson }),
onSuccess: (result) => {
setPreview(result);
setConnectionNames(Object.fromEntries(result.drafts.map((draft) => [draft.name, draft.name])));
setConnectResult(null);
setActivatedName(null);
},
});
const openAuthorizationPage = async (
authorizationUrl: string,
handoff?: ToolOAuthStartResult["handoff"],
) => {
try {
const target = await prepareOAuthNavigation({ authorizationUrl, handoff });
if (target.kind === "reauthentication" && handoff) {
savePendingCloudHandoff(handoff.session);
}
setAuthorizationHost(target.host);
setOAuthPhase(target.kind === "authorization" ? "redirecting" : "starting");
navigateTopLevel(target.url);
} catch (error) {
setOAuthPhase("error");
setOAuthError(error instanceof Error ? error.message : "Paperclip couldn’t start secure sign-in. Try again.");
}
};
const oauthStartMutation = useMutation({
mutationFn: (connectionId: string) => toolsApi.startOAuth(connectionId),
onSuccess: (start) => void openAuthorizationPage(start.authorizationUrl, start.handoff),
onError: (error) => {
setOAuthPhase("error");
setOAuthError(
error instanceof Error
? error.message
: "Paperclip couldn’t start secure sign-in. Try again.",
);
},
});
const connectMutation = useMutation({
mutationFn: (draft: McpJsonImportDraft) => {
const url = draftConnectUrl(draft);
if (!url) throw new Error("Only remote HTTP drafts can be checked and activated from pasted config.");
return toolsApi.connectApp(companyId, {
link: url,
name: connectionNames[draft.name]?.trim() || draft.name,
credentialValues: credentialValuesForDraft(draft, credentialValues),
});
},
onSuccess: (result) => {
setConnectResult(result);
if (result.auth?.kind === "oauth") {
setActivatedName(null);
setAuthorizationHost(null);
setOAuthError(null);
if (result.auth.manualClientRequired) {
setOAuthPhase("error");
setOAuthError(
"This server requires OAuth client details from its provider settings. Continue in setup to add them.",
);
return;
}
const startUrl = result.auth.startUrl?.trim();
if (startUrl) {
void openAuthorizationPage(startUrl, result.auth.handoff);
} else {
setOAuthPhase("starting");
oauthStartMutation.mutate(result.connectionId);
}
return;
}
const defaults: Record<string, boolean> = {};
for (const action of result.actions.readOnly) defaults[action.catalogEntryId] = true;
for (const action of result.actions.canMakeChanges) defaults[action.catalogEntryId] = true;
setEnabled(defaults);
setActivatedName(null);
},
});
const finishMutation = useMutation({
mutationFn: () => {
const askFirstLevels = connectResult ? askFirstLevelsFrom(connectResult) : [];
const enabledIds = Object.entries(enabled).filter(([, on]) => on).map(([id]) => id);
const askFirstIds = (connectResult?.actions.canMakeChanges ?? [])
.filter((action) => enabled[action.catalogEntryId] && askFirstLevels.includes(action.riskLevel))
.map((action) => action.catalogEntryId);
return toolsApi.finishApp(companyId, connectResult!.connectionId, {
enabledCatalogEntryIds: enabledIds,
askFirstCatalogEntryIds: askFirstIds,
access: "all_agents",
});
},
onSuccess: () => setActivatedName(connectResult?.application.name ?? "Imported app"),
});
const drafts = preview?.drafts ?? [];
const canSubmit = draftText.trim().length > 0 && !importMutation.isPending;
const localParseError = useMemo(() => {
const trimmed = draftText.trim();
if (!trimmed) return null;
try {
JSON.parse(trimmed);
return null;
} catch {
return "That doesn't look like valid JSON yet — paste the whole snippet, including the outer braces.";
}
}, [draftText]);
if (connectResult?.auth?.kind === "oauth") {
const connectionUrl = typeof connectResult.connection.config?.url === "string"
? connectResult.connection.config.url
: typeof connectResult.connection.transportConfig?.url === "string"
? connectResult.connection.transportConfig.url
: "";
return (
<OAuthConnectStateScreen
identity={{
name: connectResult.application.name,
unverifiedHost: endpointHost(connectionUrl),
}}
phase={oauthPhase}
error={oauthError}
authorizationHost={authorizationHost}
onRetry={() => {
setOAuthError(null);
setAuthorizationHost(null);
setOAuthPhase("starting");
oauthStartMutation.mutate(connectResult.connectionId);
}}
onBack={() => navigate(`/apps/${connectResult.connectionId}/permissions`)}
onCancel={() => navigate("/apps")}
/>
);
}
return (
<div className="space-y-5">
<div className="flex max-w-2xl items-start gap-1.5">
<p className="text-sm text-muted-foreground">
Paste the MCP config snippet from the tool's README and we'll turn it into a friendly setup.
</p>
<McpConfigHelpDialog />
</div>
<p className="text-xs text-muted-foreground">
Just a URL?{" "}
<Link to="/apps" className="text-primary hover:underline">
Browse planned app connections
</Link>{" "}
instead.
</p>
<div className="space-y-2">
<Textarea
value={draftText}
onChange={(event) => {
setDraftText(event.target.value);
if (preview) setPreview(null);
setConnectResult(null);
setActivatedName(null);
}}
spellCheck={false}
rows={10}
placeholder={SAMPLE_CONFIG}
className="min-h-(--sz-220px) bg-slate-900 font-mono text-(length:--text-compact) leading-relaxed text-slate-100 placeholder:text-slate-500 focus-visible:ring-slate-400"
/>
{localParseError ? (
<p className="text-xs text-amber-600">{localParseError}</p>
) : (
<p className="text-xs text-muted-foreground">
Paste an MCP config — the snippet a README tells you to copy.
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
onClick={() => importMutation.mutate(draftText)}
disabled={!canSubmit || Boolean(localParseError)}
>
{importMutation.isPending ? "Checking…" : "Check config"}
</Button>
<span className="text-xs text-muted-foreground">
We'll read it and show what we found before anything is saved.
</span>
</div>
{importMutation.isError ? <ErrorState error={importMutation.error} /> : null}
{preview ? (
drafts.length === 0 ? (
<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
We couldn't find an app in that config. Double-check you pasted the whole snippet.
</div>
) : (
<div className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
We found {drafts.length} {drafts.length === 1 ? "app" : "apps"} in that config
</h3>
{drafts.map((draft, index) => {
const url = draftConnectUrl(draft);
const missingFields = missingCredentialFields(draft, credentialValues);
return (
<DraftCard
key={`${draft.name}-${index}`}
draft={draft}
connectionName={connectionNames[draft.name] ?? draft.name}
onConnectionNameChange={(value) =>
setConnectionNames((previous) => ({ ...previous, [draft.name]: value }))
}
credentialValues={credentialValues}
onCredentialChange={(configPath, value) =>
setCredentialValues((prev) => ({ ...prev, [credentialValueKey(draft, configPath)]: value }))
}
checking={connectMutation.isPending && connectMutation.variables?.name === draft.name}
canCheck={Boolean(url) && missingFields.length === 0}
onCheck={url ? () => connectMutation.mutate(draft) : undefined}
/>
);
})}
{drafts.some((d) => draftConnectUrl(d)) ? (
<p className="text-xs text-muted-foreground">
Checking a remote app creates a draft connection, stores any header replacements as Paperclip secrets,
and runs health/catalog discovery before activation.
</p>
) : (
<p className="text-xs text-muted-foreground">
We humanized the field names from the config. These run-in-your-workspace tools stay as drafts until an
admin maps them to an approved template.
</p>
)}
</div>
)
) : null}
{connectMutation.isError ? <ErrorState error={connectMutation.error} /> : null}
{connectResult ? (
<CatalogReview
result={connectResult}
enabled={enabled}
onToggle={(id, on) => setEnabled((prev) => ({ ...prev, [id]: on }))}
onBulk={(ids, on) =>
setEnabled((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = on;
return next;
})
}
finishing={finishMutation.isPending}
activatedName={activatedName}
onFinish={() => finishMutation.mutate()}
/>
) : null}
{finishMutation.isError ? <ErrorState error={finishMutation.error} /> : null}
</div>
);
}
function DraftCard({
draft,
connectionName,
onConnectionNameChange,
credentialValues,
onCredentialChange,
checking,
canCheck,
onCheck,
}: {
draft: McpJsonImportDraft;
connectionName: string;
onConnectionNameChange: (value: string) => void;
credentialValues: Record<string, string>;
onCredentialChange: (configPath: string, value: string) => void;
checking: boolean;
canCheck: boolean;
onCheck?: () => void;
}) {
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-foreground">{draft.name}</div>
<div className="text-xs text-muted-foreground">{draftSummary(draft)}</div>
</div>
{onCheck ? (
<Button size="sm" className="shrink-0" onClick={onCheck} disabled={checking || !canCheck}>
{checking ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
Check actions
</Button>
) : null}
</div>
{onCheck ? (
<label className="mt-4 block max-w-sm space-y-1 text-xs font-medium text-foreground">
Connection name
<Input
value={connectionName}
onChange={(event) => onConnectionNameChange(event.target.value)}
placeholder={draft.name}
className="h-8 text-xs"
/>
</label>
) : null}
{draft.credentialFields.length > 0 ? (
<div className="mt-4 space-y-3">
{draft.credentialFields.map((field) => (
<div key={`${field.configPath}-${field.key}`} className="space-y-1">
<div className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
{humanizeKey(field.label || field.key)}
</div>
<div className="flex items-center gap-2">
<code className="rounded border border-border bg-muted/40 px-2 py-1 font-mono text-(length:--text-micro) text-muted-foreground">
{field.key}
</code>
<Input
type="password"
value={credentialValues[credentialValueKey(draft, field.configPath)] ?? ""}
onChange={(event) => onCredentialChange(field.configPath, event.target.value)}
placeholder="Paste replacement value"
className="h-8 max-w-sm text-xs"
/>
</div>
</div>
))}
</div>
) : draft.credentialRefs.length > 0 ? (
<p className="mt-3 text-xs text-muted-foreground">
Keys from this config stay draft-only until an admin maps them to an approved template.
</p>
) : (
<p className="mt-3 text-xs text-muted-foreground">No keys needed for this one.</p>
)}
{draft.warnings.length > 0 ? (
<ul className="mt-4 space-y-1 border-t border-border pt-3">
{draft.warnings.map((warning, i) => (
<li key={i} className="text-xs text-amber-600">
{warning}
</li>
))}
</ul>
) : null}
</div>
);
}
function CatalogReview({
result,
enabled,
onToggle,
onBulk,
finishing,
activatedName,
onFinish,
}: {
result: ConnectToolAppResult;
enabled: Record<string, boolean>;
onToggle: (id: string, on: boolean) => void;
onBulk: (ids: string[], on: boolean) => void;
finishing: boolean;
activatedName: string | null;
onFinish: () => void;
}) {
const askFirstLevels = askFirstLevelsFrom(result);
const enabledCount = Object.values(enabled).filter(Boolean).length;
const total = result.actions.readOnly.length + result.actions.canMakeChanges.length;
return (
<div className="space-y-4 border-t border-border pt-5">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<ShieldCheck className="h-4 w-4 text-emerald-600" />
Review actions for {result.application.name}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
Health and catalog checks passed. Every discovered action starts allowed; you can narrow access after activation.
</p>
</div>
<Button size="sm" onClick={onFinish} disabled={finishing || enabledCount === 0 || Boolean(activatedName)}>
{finishing ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
Activate {enabledCount} of {total}
</Button>
</div>
<ActionGroup
title="Read-only"
actions={result.actions.readOnly}
enabled={enabled}
onToggle={onToggle}
onBulk={(on) => onBulk(result.actions.readOnly.map((action) => action.catalogEntryId), on)}
askFirstLevels={askFirstLevels}
/>
<ActionGroup
title="Can make changes"
actions={result.actions.canMakeChanges}
enabled={enabled}
onToggle={onToggle}
onBulk={(on) => onBulk(result.actions.canMakeChanges.map((action) => action.catalogEntryId), on)}
askFirstLevels={askFirstLevels}
/>
{activatedName ? (
<p className="text-xs font-medium text-emerald-700">{activatedName} is active for all agents.</p>
) : null}
</div>
);
}
function ActionGroup({
title,
actions,
enabled,
onToggle,
onBulk,
askFirstLevels,
}: {
title: string;
actions: ToolAppConnectionActionSummary[];
enabled: Record<string, boolean>;
onToggle: (id: string, on: boolean) => void;
onBulk: (on: boolean) => void;
askFirstLevels: string[];
}) {
if (actions.length === 0) return null;
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</div>
<div className="flex gap-2">
<Button type="button" size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={() => onBulk(true)}>
Turn all on
</Button>
<Button type="button" size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={() => onBulk(false)}>
Turn all off
</Button>
</div>
</div>
<div className="divide-y divide-border rounded-lg border border-border">
{actions.map((action) => {
const on = enabled[action.catalogEntryId] ?? false;
return (
<div key={action.catalogEntryId} className="flex items-center gap-3 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{action.title || action.toolName}</div>
<div className="truncate text-xs text-muted-foreground">
{askFirstLevels.includes(action.riskLevel) ? "Ask first when enabled" : action.riskLevel}
</div>
</div>
<ToggleSwitch checked={on} onCheckedChange={(next) => onToggle(action.catalogEntryId, next)} />
</div>
);
})}
</div>
</div>
);
}