/** * `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 { mcpToolGroupsForCatalogue, 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({ 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 (
); } if (vm.error && !vm.doc) { return (

{vm.error}

); } 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 (
{target.type === "project" ? ( { dirtyRef.current = dirty; }} onSaveProjectDefault={async (allowed) => { await vm.saveProjectDefault({ allowedTools: allowed }); }} /> ) : ( { dirtyRef.current = dirty; }} onSaveAgentOverride={async (allowed) => { await vm.saveAgentOverride(target.agentId, { allowedTools: allowed }); }} onResetOverride={async () => { await vm.resetAgentOverride(target.agentId); }} /> )}
); } 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; onSaveAgentOverride?: (allowed: string[]) => Promise; onResetOverride?: () => Promise; } 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(initialAllowed); const [baseline, setBaseline] = useState(initialAllowed); const [pendingConfirm, setPendingConfirm] = useState(false); const [savedFlash, setSavedFlash] = useState(false); const [saveError, setSaveError] = useState(null); const toolGroups = useMemo( () => mcpToolGroupsForCatalogue(catalogue), [catalogue], ); const [expandedGroups, setExpandedGroups] = useState>(() => Object.fromEntries( mcpToolGroupsForCatalogue(catalogue).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 (
{targetKind === "agent" && (

{overrideActive ? "Override personnalisé" : "Hérite du défaut projet"}

)} {targetKind === "agent" && !overrideActive && ( )}
Résumé effectif

{readAllowedCount} lecture autorisés · {writeAllowedCount} écriture autorisé {writeAllowedCount > 1 ? "s" : ""}

Détail des tools
{toolGroups.map((group) => ( toggleGroup(group.id)} catalogue={catalogue} editable={editable} targetKind={targetKind} draftSet={draft} displaySet={displaySet} comparisonBaseline={comparisonBaseline} onToggleTool={toggle} /> ))} {unknownTools.length > 0 && ( toggleGroup("unknown")} catalogue={catalogue} editable={false} targetKind={targetKind} draftSet={draft} displaySet={displaySet} comparisonBaseline={comparisonBaseline} onToggleTool={toggle} unknown /> )}
{pendingConfirm && (

Ce tool sera autorisé pour tous les agents sans override. Confirmer cette modification ?

)} {saveError && (

{saveError}

)} {savedFlash && (

Permissions enregistrées

)}
{dirty && ( Modifications non enregistrées )}
{targetKind === "agent" && hasPersistedOverride && ( )}
); } 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 (
{expanded && (
    {group.tools.map((tool) => ( onToggleTool(tool)} /> ))}
)}
); } 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 = { 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 (
  • {STATUS_LABEL[status]}
  • ); } function describeError(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as { message: unknown }).message); } return String(e); }