feat(frontend): surface MCP tool permissions per agent — Permissions panel (#82 lot UX/F)
Adds the "Tools MCP IdeA" tab to the project Permissions panel, alongside the existing "Système" (file/command) tab. Lets the user grant/revoke MCP tool capabilities per agent or project-wide default, grouped by domain (Lecture projet, Lecture tickets, Délégation agents, Contexte et mémoire, Tickets, Travail et exécution, Skills) instead of a flat 25-checkbox list, per the UX conception in carnet #82. - domain/ports/adapters (Tauri, HTTP, mock): wire get_mcp_tool_permissions, update_project_mcp_tool_permissions, update_agent_mcp_tool_permissions (already merged backend API, #82 lots B1-B4) onto PermissionGateway. - useMcpToolPermissions: view-model owning the durable MCP tool policy document, distinct from the file/command permissions in usePermissions. - McpToolPermissionsPanel: target selector (Défaut projet + agents with Hérité/Override badges) and grouped editor — inherited agents are read-only until "Créer un override" (prefilled with the effective allowlist), per-row Ajouté/Retiré diffing against the project default, inline confirmation before granting a write tool at project-default level, and unsaved-draft protection on target change. - mcpToolGroups.ts: presentational-only domain grouping and short French labels — the read/write classification itself always comes from the backend-provided catalogue, never hardcoded here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
630
frontend/src/features/permissions/McpToolPermissionsPanel.tsx
Normal file
630
frontend/src/features/permissions/McpToolPermissionsPanel.tsx
Normal file
@ -0,0 +1,630 @@
|
||||
/**
|
||||
* `McpToolPermissionsPanel` — the "Tools MCP IdeA" tab of the project
|
||||
* `Permissions` panel (ticket #82). Lets the user grant/revoke MCP tool
|
||||
* capabilities per agent (or project-wide default), grouped by domain rather
|
||||
* than as a flat 25-checkbox list (carnet #82 — Conception UX/F).
|
||||
*
|
||||
* Two-column layout: a target selector (`Défaut projet` + agents, each
|
||||
* showing `Hérité`/`Override`) on the left, and the grouped tool editor for
|
||||
* the selected target on the right. An agent without an override shows its
|
||||
* inherited effective state read-only until `Créer un override`; the draft
|
||||
* then starts prefilled with that exact effective allowlist. Unsaved edits
|
||||
* are tracked per mounted editor instance (remounted via `key` on target
|
||||
* change) and protected by a confirmation when switching target.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { Agent, McpToolCatalogue } from "@/domain";
|
||||
import { Button, Panel, cn } from "@/shared";
|
||||
import { MCP_TOOL_GROUPS, mcpToolLabel } from "./mcpToolGroups";
|
||||
import { useMcpToolPermissions } from "./useMcpToolPermissions";
|
||||
|
||||
export interface McpToolPermissionsPanelProps {
|
||||
projectId: string;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
type McpTarget =
|
||||
| { type: "project" }
|
||||
| { type: "agent"; agentId: string; agentName: string };
|
||||
|
||||
const READ_GROUP_IDS = new Set(["readProject", "readTickets"]);
|
||||
|
||||
function toggleTool(list: string[], tool: string): string[] {
|
||||
return list.includes(tool) ? list.filter((t) => t !== tool) : [...list, tool];
|
||||
}
|
||||
|
||||
function sameToolSet(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const bs = new Set(b);
|
||||
return a.every((t) => bs.has(t));
|
||||
}
|
||||
|
||||
export function McpToolPermissionsPanel({
|
||||
projectId,
|
||||
agents,
|
||||
}: McpToolPermissionsPanelProps) {
|
||||
const vm = useMcpToolPermissions(projectId);
|
||||
const [target, setTarget] = useState<McpTarget>({ type: "project" });
|
||||
// Read imperatively (not via re-render) by `selectTarget` so an unsaved
|
||||
// draft in the currently-mounted editor blocks a target switch without
|
||||
// needing the editor's dirty state to live in this component.
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
function selectTarget(next: McpTarget) {
|
||||
if (dirtyRef.current) {
|
||||
const proceed = window.confirm(
|
||||
"Des modifications non enregistrées seront perdues. Continuer ?",
|
||||
);
|
||||
if (!proceed) return;
|
||||
}
|
||||
dirtyRef.current = false;
|
||||
setTarget(next);
|
||||
}
|
||||
|
||||
if (vm.loading && !vm.doc) {
|
||||
return (
|
||||
<div data-testid="mcp-tool-permissions-skeleton" className="flex flex-col gap-3 p-4">
|
||||
<div className="h-4 w-40 animate-pulse rounded bg-raised" />
|
||||
<div className="h-8 w-full animate-pulse rounded bg-raised" />
|
||||
<div className="h-8 w-full animate-pulse rounded bg-raised" />
|
||||
<div className="h-32 w-full animate-pulse rounded bg-raised" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (vm.error && !vm.doc) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 p-4">
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{vm.error}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => void vm.refresh()}>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!vm.doc) return null;
|
||||
const doc = vm.doc;
|
||||
|
||||
const selectedAgentOverride =
|
||||
target.type === "agent"
|
||||
? doc.agents.find((entry) => entry.agentId === target.agentId) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4 md:flex-row">
|
||||
<nav aria-label="cibles des permissions MCP" className="flex w-full shrink-0 flex-col gap-2 md:w-56">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={target.type === "project"}
|
||||
onClick={() => selectTarget({ type: "project" })}
|
||||
className={cn(
|
||||
"flex min-h-[32px] w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm",
|
||||
"transition-colors hover:border-border-strong hover:bg-raised",
|
||||
target.type === "project" ? "border-primary bg-raised" : "border-border bg-surface",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-content">Défaut projet</span>
|
||||
<span className="text-xs text-muted">
|
||||
{doc.projectDefault ? "Personnalisé" : "Lecture seule"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<h4 className="mt-1 text-xs font-semibold uppercase tracking-wide text-faint">Agents</h4>
|
||||
{agents.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun agent dans ce projet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{agents.map((agent) => {
|
||||
const hasOverride = doc.agents.some((entry) => entry.agentId === agent.id);
|
||||
const active = target.type === "agent" && target.agentId === agent.id;
|
||||
return (
|
||||
<li key={agent.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() =>
|
||||
selectTarget({ type: "agent", agentId: agent.id, agentName: agent.name })
|
||||
}
|
||||
className={cn(
|
||||
"flex min-h-[32px] w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm",
|
||||
"transition-colors hover:border-border-strong hover:bg-raised",
|
||||
active ? "border-primary bg-raised" : "border-border bg-surface",
|
||||
)}
|
||||
>
|
||||
<span className="truncate font-medium text-content">{agent.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
hasOverride
|
||||
? "bg-warning/15 text-warning"
|
||||
: "bg-primary/10 text-muted",
|
||||
)}
|
||||
>
|
||||
{hasOverride ? "Override" : "Hérité"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{target.type === "project" ? (
|
||||
<McpToolEditor
|
||||
key="project"
|
||||
targetKind="project"
|
||||
title="Tools MCP IdeA — Défaut projet"
|
||||
catalogue={doc.catalogue}
|
||||
storedAllowed={doc.projectDefault?.allowedTools ?? null}
|
||||
comparisonBaseline={doc.catalogue.readOnlyTools}
|
||||
busy={vm.busy}
|
||||
onDirtyChange={(dirty) => {
|
||||
dirtyRef.current = dirty;
|
||||
}}
|
||||
onSaveProjectDefault={async (allowed) => {
|
||||
await vm.saveProjectDefault({ allowedTools: allowed });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<McpToolEditor
|
||||
key={target.agentId}
|
||||
targetKind="agent"
|
||||
title={`Tools MCP IdeA — ${target.agentName}`}
|
||||
catalogue={doc.catalogue}
|
||||
storedAllowed={selectedAgentOverride?.policy.allowedTools ?? null}
|
||||
comparisonBaseline={
|
||||
doc.projectDefault?.allowedTools ?? doc.catalogue.readOnlyTools
|
||||
}
|
||||
busy={vm.busy}
|
||||
onDirtyChange={(dirty) => {
|
||||
dirtyRef.current = dirty;
|
||||
}}
|
||||
onSaveAgentOverride={async (allowed) => {
|
||||
await vm.saveAgentOverride(target.agentId, { allowedTools: allowed });
|
||||
}}
|
||||
onResetOverride={async () => {
|
||||
await vm.resetAgentOverride(target.agentId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface McpToolEditorProps {
|
||||
targetKind: "project" | "agent";
|
||||
title: string;
|
||||
catalogue: McpToolCatalogue;
|
||||
/** Stored policy allowlist for this exact target, or `null` if unset/inherited. */
|
||||
storedAllowed: string[] | null;
|
||||
/**
|
||||
* For `project`: the canonical read-only fallback (used as the starting
|
||||
* draft when no default is stored yet). For `agent`: the effective
|
||||
* inherited allowlist (project default, or read-only fallback) — used both
|
||||
* as the read-only display when there is no override, and to prefill the
|
||||
* draft the moment `Créer un override` is clicked, and to compute the
|
||||
* per-row `Ajouté`/`Retiré` diff once editing.
|
||||
*/
|
||||
comparisonBaseline: string[];
|
||||
busy: boolean;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
onSaveProjectDefault?: (allowed: string[]) => Promise<void>;
|
||||
onSaveAgentOverride?: (allowed: string[]) => Promise<void>;
|
||||
onResetOverride?: () => Promise<void>;
|
||||
}
|
||||
|
||||
function McpToolEditor({
|
||||
targetKind,
|
||||
title,
|
||||
catalogue,
|
||||
storedAllowed,
|
||||
comparisonBaseline,
|
||||
busy,
|
||||
onDirtyChange,
|
||||
onSaveProjectDefault,
|
||||
onSaveAgentOverride,
|
||||
onResetOverride,
|
||||
}: McpToolEditorProps) {
|
||||
const initialAllowed = storedAllowed ?? comparisonBaseline;
|
||||
const [overrideActive, setOverrideActive] = useState(
|
||||
targetKind === "project" || storedAllowed != null,
|
||||
);
|
||||
const [hasPersistedOverride, setHasPersistedOverride] = useState(storedAllowed != null);
|
||||
const [draft, setDraft] = useState<string[]>(initialAllowed);
|
||||
const [baseline, setBaseline] = useState<string[]>(initialAllowed);
|
||||
const [pendingConfirm, setPendingConfirm] = useState(false);
|
||||
const [savedFlash, setSavedFlash] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(
|
||||
MCP_TOOL_GROUPS.map((group) => [
|
||||
group.id,
|
||||
READ_GROUP_IDS.has(group.id) ? targetKind === "project" : true,
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const editable = targetKind === "project" || overrideActive;
|
||||
const displaySet = editable ? draft : comparisonBaseline;
|
||||
const dirty = editable && !sameToolSet(draft, baseline);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange(dirty);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!savedFlash) return;
|
||||
const id = window.setTimeout(() => setSavedFlash(false), 2000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [savedFlash]);
|
||||
|
||||
const readAllowedCount = catalogue.readOnlyTools.filter((t) => displaySet.includes(t)).length;
|
||||
const writeAllowedCount = catalogue.writeActionTools.filter((t) =>
|
||||
displaySet.includes(t),
|
||||
).length;
|
||||
|
||||
const knownTools = useMemo(
|
||||
() => new Set([...catalogue.readOnlyTools, ...catalogue.writeActionTools]),
|
||||
[catalogue],
|
||||
);
|
||||
const unknownTools = useMemo(
|
||||
() => Array.from(new Set([...displaySet, ...comparisonBaseline])).filter((t) => !knownTools.has(t)),
|
||||
[displaySet, comparisonBaseline, knownTools],
|
||||
);
|
||||
|
||||
function toggle(tool: string) {
|
||||
setSaveError(null);
|
||||
setDraft((prev) => toggleTool(prev, tool));
|
||||
}
|
||||
|
||||
function toggleGroup(id: string) {
|
||||
setExpandedGroups((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
}
|
||||
|
||||
function createOverride() {
|
||||
setOverrideActive(true);
|
||||
setDraft(comparisonBaseline);
|
||||
setBaseline(comparisonBaseline);
|
||||
}
|
||||
|
||||
function cancelDraft() {
|
||||
setDraft(baseline);
|
||||
setPendingConfirm(false);
|
||||
setSaveError(null);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaveError(null);
|
||||
if (targetKind === "project") {
|
||||
const introducesNewWrite = draft.some(
|
||||
(t) => catalogue.writeActionTools.includes(t) && !baseline.includes(t),
|
||||
);
|
||||
if (introducesNewWrite && !pendingConfirm) {
|
||||
setPendingConfirm(true);
|
||||
return;
|
||||
}
|
||||
setPendingConfirm(false);
|
||||
try {
|
||||
await onSaveProjectDefault?.(draft);
|
||||
setBaseline(draft);
|
||||
setSavedFlash(true);
|
||||
} catch (e) {
|
||||
setSaveError(describeError(e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onSaveAgentOverride?.(draft);
|
||||
setBaseline(draft);
|
||||
setHasPersistedOverride(true);
|
||||
setSavedFlash(true);
|
||||
} catch (e) {
|
||||
setSaveError(describeError(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
setSaveError(null);
|
||||
try {
|
||||
await onResetOverride?.();
|
||||
setOverrideActive(false);
|
||||
setHasPersistedOverride(false);
|
||||
setDraft(comparisonBaseline);
|
||||
setBaseline(comparisonBaseline);
|
||||
setSavedFlash(true);
|
||||
} catch (e) {
|
||||
setSaveError(describeError(e));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title={title} className="flex h-full flex-col" flush>
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{targetKind === "agent" && (
|
||||
<p className="text-xs text-muted">
|
||||
{overrideActive ? "Override personnalisé" : "Hérite du défaut projet"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{targetKind === "agent" && !overrideActive && (
|
||||
<Button size="sm" onClick={createOverride} disabled={busy}>
|
||||
Créer un override
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Résumé effectif
|
||||
</h5>
|
||||
<p className="text-sm text-content">
|
||||
{readAllowedCount} lecture autorisés · {writeAllowedCount} écriture autorisé
|
||||
{writeAllowedCount > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Détail des tools
|
||||
</h5>
|
||||
{MCP_TOOL_GROUPS.map((group) => (
|
||||
<ToolGroupSection
|
||||
key={group.id}
|
||||
group={group}
|
||||
expanded={expandedGroups[group.id] ?? true}
|
||||
onToggle={() => toggleGroup(group.id)}
|
||||
catalogue={catalogue}
|
||||
editable={editable}
|
||||
targetKind={targetKind}
|
||||
draftSet={draft}
|
||||
displaySet={displaySet}
|
||||
comparisonBaseline={comparisonBaseline}
|
||||
onToggleTool={toggle}
|
||||
/>
|
||||
))}
|
||||
|
||||
{unknownTools.length > 0 && (
|
||||
<ToolGroupSection
|
||||
group={{ id: "unknown", label: "Tools inconnus", tools: unknownTools }}
|
||||
expanded={expandedGroups.unknown ?? true}
|
||||
onToggle={() => toggleGroup("unknown")}
|
||||
catalogue={catalogue}
|
||||
editable={false}
|
||||
targetKind={targetKind}
|
||||
draftSet={draft}
|
||||
displaySet={displaySet}
|
||||
comparisonBaseline={comparisonBaseline}
|
||||
onToggleTool={toggle}
|
||||
unknown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingConfirm && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning"
|
||||
>
|
||||
<p>
|
||||
Ce tool sera autorisé pour tous les agents sans override. Confirmer cette
|
||||
modification ?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setPendingConfirm(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onClick={() => void handleSave()}>
|
||||
Confirmer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{savedFlash && (
|
||||
<p role="status" className="text-sm text-success">
|
||||
Permissions enregistrées
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
<div>
|
||||
{dirty && (
|
||||
<span className="text-xs text-warning">Modifications non enregistrées</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{targetKind === "agent" && hasPersistedOverride && (
|
||||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void handleReset()}>
|
||||
Réinitialiser l'override
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" disabled={busy || !dirty} onClick={cancelDraft}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={busy || !editable || !dirty}
|
||||
loading={busy && dirty}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolGroupSectionProps {
|
||||
group: { id: string; label: string; tools: string[] };
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
catalogue: McpToolCatalogue;
|
||||
editable: boolean;
|
||||
targetKind: "project" | "agent";
|
||||
draftSet: string[];
|
||||
displaySet: string[];
|
||||
comparisonBaseline: string[];
|
||||
onToggleTool: (tool: string) => void;
|
||||
unknown?: boolean;
|
||||
}
|
||||
|
||||
function ToolGroupSection({
|
||||
group,
|
||||
expanded,
|
||||
onToggle,
|
||||
catalogue,
|
||||
editable,
|
||||
targetKind,
|
||||
displaySet,
|
||||
comparisonBaseline,
|
||||
onToggleTool,
|
||||
unknown,
|
||||
}: ToolGroupSectionProps) {
|
||||
const allowedCount = group.tools.filter((t) => displaySet.includes(t)).length;
|
||||
const total = group.tools.length;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${group.label}, ${allowedCount} sur ${total} autorisé${total > 1 ? "s" : ""}`}
|
||||
onClick={onToggle}
|
||||
className="flex min-h-[32px] w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-raised"
|
||||
>
|
||||
<span className="font-medium text-content">{group.label}</span>
|
||||
<span className="text-xs text-muted">
|
||||
{allowedCount}/{total} autorisés
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<ul className="flex flex-col divide-y divide-border border-t border-border">
|
||||
{group.tools.map((tool) => (
|
||||
<ToolRow
|
||||
key={tool}
|
||||
tool={tool}
|
||||
isRead={catalogue.readOnlyTools.includes(tool)}
|
||||
unknown={unknown}
|
||||
editable={editable}
|
||||
checked={displaySet.includes(tool)}
|
||||
status={rowStatus(tool, editable, targetKind, displaySet, comparisonBaseline, unknown)}
|
||||
onToggle={() => onToggleTool(tool)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RowStatus = "allowed" | "denied" | "inherited" | "added" | "removed" | "unknown";
|
||||
|
||||
function rowStatus(
|
||||
tool: string,
|
||||
editable: boolean,
|
||||
targetKind: "project" | "agent",
|
||||
displaySet: string[],
|
||||
comparisonBaseline: string[],
|
||||
unknown?: boolean,
|
||||
): RowStatus {
|
||||
if (unknown) return "unknown";
|
||||
if (targetKind === "agent" && !editable) return "inherited";
|
||||
if (targetKind === "agent" && editable) {
|
||||
const inDraft = displaySet.includes(tool);
|
||||
const inBaseline = comparisonBaseline.includes(tool);
|
||||
if (inDraft && !inBaseline) return "added";
|
||||
if (!inDraft && inBaseline) return "removed";
|
||||
return inDraft ? "allowed" : "denied";
|
||||
}
|
||||
return displaySet.includes(tool) ? "allowed" : "denied";
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<RowStatus, string> = {
|
||||
allowed: "Autorisé",
|
||||
denied: "Refusé",
|
||||
inherited: "Hérité",
|
||||
added: "Ajouté",
|
||||
removed: "Retiré",
|
||||
unknown: "Inconnu",
|
||||
};
|
||||
|
||||
interface ToolRowProps {
|
||||
tool: string;
|
||||
isRead: boolean;
|
||||
unknown?: boolean;
|
||||
editable: boolean;
|
||||
checked: boolean;
|
||||
status: RowStatus;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function ToolRow({ tool, isRead, unknown, editable, checked, status, onToggle }: ToolRowProps) {
|
||||
const label = mcpToolLabel(tool);
|
||||
const inputId = `mcp-tool-${tool}`;
|
||||
return (
|
||||
<li className="flex min-h-[32px] items-center gap-2 px-3 py-1.5">
|
||||
<input
|
||||
id={inputId}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={!editable || unknown}
|
||||
onChange={onToggle}
|
||||
aria-label={`${label}, ${tool}`}
|
||||
/>
|
||||
<label htmlFor={inputId} className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<code className="font-mono text-xs text-content">{tool}</code>
|
||||
{!unknown && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 py-0.5 text-[10px] font-medium",
|
||||
isRead ? "bg-primary/10 text-primary" : "bg-warning/15 text-warning",
|
||||
)}
|
||||
>
|
||||
{isRead ? "Lecture" : "Écriture"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted">{label}</span>
|
||||
</label>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
status === "allowed" || status === "added"
|
||||
? "bg-success/15 text-success"
|
||||
: status === "denied" || status === "removed"
|
||||
? "bg-danger/10 text-muted"
|
||||
: status === "unknown"
|
||||
? "bg-danger/15 text-danger"
|
||||
: "bg-primary/10 text-muted",
|
||||
)}
|
||||
>
|
||||
{STATUS_LABEL[status]}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function describeError(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as { message: unknown }).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
Reference in New Issue
Block a user