Merge feature/ticket82-mcp-tools-permissions-ui into develop
Ticket #82, lot UX/Frontend : surface de gestion des permissions des tools MCP par agent (Permissions panel), consommant l'API Tauri livrée en B4. QA vert (858/858, tsc propre, stable sur shuffle hormis le flake #85, hors périmètre). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -32,9 +32,11 @@ import type {
|
|||||||
MemoryIndexEntry,
|
MemoryIndexEntry,
|
||||||
MemoryLink,
|
MemoryLink,
|
||||||
MemoryType,
|
MemoryType,
|
||||||
|
McpToolPolicy,
|
||||||
ModelServerCommandPreview,
|
ModelServerCommandPreview,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
Project,
|
Project,
|
||||||
|
ProjectMcpToolPermissions,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
@ -344,6 +346,26 @@ export class HttpPermissionGateway implements PermissionGateway {
|
|||||||
request: { projectId, agentId },
|
request: { projectId, agentId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
getMcpToolPermissions(projectId: string): Promise<ProjectMcpToolPermissions> {
|
||||||
|
return this.http.invoke<ProjectMcpToolPermissions>("get_mcp_tool_permissions", { projectId });
|
||||||
|
}
|
||||||
|
updateProjectMcpToolPermissions(
|
||||||
|
projectId: string,
|
||||||
|
policy: McpToolPolicy | null,
|
||||||
|
): Promise<ProjectMcpToolPermissions> {
|
||||||
|
return this.http.invoke<ProjectMcpToolPermissions>("update_project_mcp_tool_permissions", {
|
||||||
|
request: { projectId, policy },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateAgentMcpToolPermissions(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
policy: McpToolPolicy | null,
|
||||||
|
): Promise<ProjectMcpToolPermissions> {
|
||||||
|
return this.http.invoke<ProjectMcpToolPermissions>("update_agent_mcp_tool_permissions", {
|
||||||
|
request: { projectId, agentId, policy },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HttpWorkStateGateway implements WorkStateGateway {
|
export class HttpWorkStateGateway implements WorkStateGateway {
|
||||||
|
|||||||
@ -31,11 +31,14 @@ import type {
|
|||||||
MemoryIndexEntry,
|
MemoryIndexEntry,
|
||||||
MemoryLink,
|
MemoryLink,
|
||||||
MemoryType,
|
MemoryType,
|
||||||
|
McpToolCatalogue,
|
||||||
|
McpToolPolicy,
|
||||||
EffectivePermissions,
|
EffectivePermissions,
|
||||||
PairedDevice,
|
PairedDevice,
|
||||||
PairingCode,
|
PairingCode,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
Project,
|
Project,
|
||||||
|
ProjectMcpToolPermissions,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
@ -2151,6 +2154,79 @@ export class MockPermissionGateway implements PermissionGateway {
|
|||||||
fallback: mostRestrictive(project?.fallback, agent?.fallback),
|
fallback: mostRestrictive(project?.fallback, agent?.fallback),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MCP tool permissions (ticket #82) — mirrors the backend catalogue in
|
||||||
|
// `crates/infrastructure/src/orchestrator/mcp/tools.rs`, a separate durable
|
||||||
|
// document from the file/command permissions above. ─────────────────────
|
||||||
|
private mcpCatalogue: McpToolCatalogue = {
|
||||||
|
readOnlyTools: [
|
||||||
|
"idea_list_agents",
|
||||||
|
"idea_context_read",
|
||||||
|
"idea_memory_read",
|
||||||
|
"idea_skill_read",
|
||||||
|
"idea_workstate_read",
|
||||||
|
"idea_ticket_read",
|
||||||
|
"idea_ticket_list",
|
||||||
|
"idea_ticket_read_carnet",
|
||||||
|
"idea_sprint_list",
|
||||||
|
],
|
||||||
|
writeActionTools: [
|
||||||
|
"idea_ask_agent",
|
||||||
|
"idea_run_in_background",
|
||||||
|
"idea_launch_agent",
|
||||||
|
"idea_stop_agent",
|
||||||
|
"idea_update_context",
|
||||||
|
"idea_context_propose",
|
||||||
|
"idea_memory_write",
|
||||||
|
"idea_workstate_set",
|
||||||
|
"idea_create_skill",
|
||||||
|
"idea_ticket_create",
|
||||||
|
"idea_ticket_update",
|
||||||
|
"idea_ticket_update_status",
|
||||||
|
"idea_ticket_update_priority",
|
||||||
|
"idea_ticket_update_carnet",
|
||||||
|
"idea_ticket_link",
|
||||||
|
"idea_ticket_unlink",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
private mcpDocs = new Map<string, ProjectMcpToolPermissions>();
|
||||||
|
|
||||||
|
private mcpDoc(projectId: string): ProjectMcpToolPermissions {
|
||||||
|
if (!this.mcpDocs.has(projectId)) {
|
||||||
|
this.mcpDocs.set(projectId, {
|
||||||
|
version: 1,
|
||||||
|
catalogue: this.mcpCatalogue,
|
||||||
|
projectDefault: null,
|
||||||
|
agents: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.mcpDocs.get(projectId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMcpToolPermissions(projectId: string): Promise<ProjectMcpToolPermissions> {
|
||||||
|
return structuredClone(this.mcpDoc(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProjectMcpToolPermissions(
|
||||||
|
projectId: string,
|
||||||
|
policy: McpToolPolicy | null,
|
||||||
|
): Promise<ProjectMcpToolPermissions> {
|
||||||
|
const doc = this.mcpDoc(projectId);
|
||||||
|
doc.projectDefault = policy ? structuredClone(policy) : null;
|
||||||
|
return structuredClone(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAgentMcpToolPermissions(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
policy: McpToolPolicy | null,
|
||||||
|
): Promise<ProjectMcpToolPermissions> {
|
||||||
|
const doc = this.mcpDoc(projectId);
|
||||||
|
doc.agents = doc.agents.filter((entry) => entry.agentId !== agentId);
|
||||||
|
if (policy) doc.agents.push({ agentId, policy: structuredClone(policy) });
|
||||||
|
return structuredClone(doc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MockWorkStateGateway implements WorkStateGateway {
|
export class MockWorkStateGateway implements WorkStateGateway {
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
EffectivePermissions,
|
EffectivePermissions,
|
||||||
|
McpToolPolicy,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
|
ProjectMcpToolPermissions,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type { PermissionGateway } from "@/ports";
|
import type { PermissionGateway } from "@/ports";
|
||||||
@ -40,4 +42,31 @@ export class TauriPermissionGateway implements PermissionGateway {
|
|||||||
request: { projectId, agentId },
|
request: { projectId, agentId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMcpToolPermissions(projectId: string): Promise<ProjectMcpToolPermissions> {
|
||||||
|
return invoke<ProjectMcpToolPermissions>("get_mcp_tool_permissions", {
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProjectMcpToolPermissions(
|
||||||
|
projectId: string,
|
||||||
|
policy: McpToolPolicy | null,
|
||||||
|
): Promise<ProjectMcpToolPermissions> {
|
||||||
|
return invoke<ProjectMcpToolPermissions>(
|
||||||
|
"update_project_mcp_tool_permissions",
|
||||||
|
{ request: { projectId, policy } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAgentMcpToolPermissions(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
policy: McpToolPolicy | null,
|
||||||
|
): Promise<ProjectMcpToolPermissions> {
|
||||||
|
return invoke<ProjectMcpToolPermissions>(
|
||||||
|
"update_agent_mcp_tool_permissions",
|
||||||
|
{ request: { projectId, agentId, policy } },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -705,6 +705,41 @@ export interface EffectivePermissions {
|
|||||||
fallback: PermissionPosture;
|
fallback: PermissionPosture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MCP tool permissions (ticket #82) — distinct from the file/command
|
||||||
|
// permissions above: an allowlist of exact MCP tool names, applied by the MCP
|
||||||
|
// server/bridge before dispatch, not by Landlock/sandbox.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Allowlist-based MCP tool policy: exact tool names permitted. */
|
||||||
|
export interface McpToolPolicy {
|
||||||
|
allowedTools: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One agent's MCP tool policy override, replacing the project default entirely. */
|
||||||
|
export interface AgentMcpToolPolicyOverride {
|
||||||
|
agentId: string;
|
||||||
|
policy: McpToolPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend-canonical classification of the MCP tool catalogue. The frontend
|
||||||
|
* must treat this as the source of truth for read vs write/action — never
|
||||||
|
* hardcode the split locally (ticket #82 acceptance criteria).
|
||||||
|
*/
|
||||||
|
export interface McpToolCatalogue {
|
||||||
|
readOnlyTools: string[];
|
||||||
|
writeActionTools: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full per-project MCP tool permission document, mirroring the backend DTO. */
|
||||||
|
export interface ProjectMcpToolPermissions {
|
||||||
|
version: number;
|
||||||
|
catalogue: McpToolCatalogue;
|
||||||
|
projectDefault: McpToolPolicy | null;
|
||||||
|
agents: AgentMcpToolPolicyOverride[];
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Layout (L4) — mirror of the domain `LayoutTree` (ARCHITECTURE §3, §7).
|
// Layout (L4) — mirror of the domain `LayoutTree` (ARCHITECTURE §3, §7).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Ticket #82 — the "Tools MCP IdeA" tab of the project `Permissions` panel.
|
||||||
|
*
|
||||||
|
* Pins the UX contract from carnet #82 (Conception UX/F): an agent without an
|
||||||
|
* override shows it inherits the project default with non-editable controls
|
||||||
|
* until `Créer un override`; creating one prefills the effective allowlist;
|
||||||
|
* toggling a tool updates the visible summary; saving calls the backend
|
||||||
|
* command with the exact resulting allowlist; and an unsaved draft is
|
||||||
|
* protected when the user switches target.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MockAgentGateway,
|
||||||
|
MockPermissionGateway,
|
||||||
|
MockProfileGateway,
|
||||||
|
} from "@/adapters/mock";
|
||||||
|
import { DIProvider } from "@/app/di";
|
||||||
|
import type { Gateways } from "@/ports";
|
||||||
|
import { PermissionsPanel } from "./PermissionsPanel";
|
||||||
|
|
||||||
|
const PROJECT_ID = "proj-mcp-permissions-test";
|
||||||
|
|
||||||
|
async function renderPanel() {
|
||||||
|
const agent = new MockAgentGateway();
|
||||||
|
const permission = new MockPermissionGateway();
|
||||||
|
const created = await agent.createAgent(PROJECT_ID, {
|
||||||
|
name: "DevFrontend",
|
||||||
|
profileId: "p1",
|
||||||
|
});
|
||||||
|
const gateways = {
|
||||||
|
agent,
|
||||||
|
permission,
|
||||||
|
profile: new MockProfileGateway(),
|
||||||
|
} as unknown as Gateways;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<PermissionsPanel projectId={PROJECT_ID} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("DevFrontend")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { agent, permission, agentId: created.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switches to the "Tools MCP IdeA" tab and waits past the doc load. */
|
||||||
|
async function openMcpToolsTab() {
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Tools MCP IdeA" }));
|
||||||
|
await screen.findByRole("navigation", { name: "cibles des permissions MCP" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAgentTarget(name: string) {
|
||||||
|
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
|
||||||
|
fireEvent.click(within(nav).getByRole("button", { name: new RegExp(name) }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("heading", { name: `Tools MCP IdeA — ${name}` })).toBeTruthy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PermissionsPanel — Tools MCP IdeA tab", () => {
|
||||||
|
it("has an accessible tablist and defaults to Système", async () => {
|
||||||
|
await renderPanel();
|
||||||
|
|
||||||
|
const tablist = screen.getByRole("tablist", { name: "Permissions" });
|
||||||
|
const tabs = within(tablist).getAllByRole("tab");
|
||||||
|
expect(tabs.map((t) => t.textContent)).toEqual(["Système", "Tools MCP IdeA"]);
|
||||||
|
expect(screen.getByRole("tab", { name: "Système" }).getAttribute("aria-selected")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an agent without an override shows it inherits the project default, read-only", async () => {
|
||||||
|
await renderPanel();
|
||||||
|
await openMcpToolsTab();
|
||||||
|
|
||||||
|
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
|
||||||
|
const agentButton = within(nav).getByRole("button", { name: /DevFrontend/ });
|
||||||
|
expect(within(agentButton).getByText("Hérité")).toBeTruthy();
|
||||||
|
|
||||||
|
await selectAgentTarget("DevFrontend");
|
||||||
|
|
||||||
|
expect(screen.getByText("Hérite du défaut projet")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Créer un override" })).toBeTruthy();
|
||||||
|
|
||||||
|
// Read groups are collapsed by default in agent editing (carnet #82) — expand it.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Lecture tickets/ }));
|
||||||
|
|
||||||
|
// A canonical read-only tool is shown checked but disabled (inherited display).
|
||||||
|
const readCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: /idea_ticket_read$/,
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
expect(readCheckbox.checked).toBe(true);
|
||||||
|
expect(readCheckbox.disabled).toBe(true);
|
||||||
|
|
||||||
|
// A write tool is shown unchecked (canonical read-only fallback denies it) and disabled.
|
||||||
|
const writeCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: /idea_ticket_update_carnet$/,
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
expect(writeCheckbox.checked).toBe(false);
|
||||||
|
expect(writeCheckbox.disabled).toBe(true);
|
||||||
|
|
||||||
|
// No Save action is meaningfully available before creating an override.
|
||||||
|
expect(screen.getByRole("button", { name: "Enregistrer" })).toHaveProperty("disabled", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creating an override prefills the draft with the current effective allowlist", async () => {
|
||||||
|
await renderPanel();
|
||||||
|
await openMcpToolsTab();
|
||||||
|
await selectAgentTarget("DevFrontend");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Override personnalisé")).toBeTruthy();
|
||||||
|
|
||||||
|
// Read groups are collapsed by default in agent editing (carnet #82) — expand it.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Lecture tickets/ }));
|
||||||
|
|
||||||
|
// Prefilled from the effective (inherited) allowlist: canonical read-only
|
||||||
|
// tools checked and now editable.
|
||||||
|
const readCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: /idea_ticket_read$/,
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
expect(readCheckbox.checked).toBe(true);
|
||||||
|
expect(readCheckbox.disabled).toBe(false);
|
||||||
|
|
||||||
|
const writeCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: /idea_ticket_update_carnet$/,
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
expect(writeCheckbox.checked).toBe(false);
|
||||||
|
|
||||||
|
// No edit yet: nothing to save.
|
||||||
|
expect(screen.getByRole("button", { name: "Enregistrer" })).toHaveProperty("disabled", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checking a write tool updates the effective summary and unsaved-changes flag", async () => {
|
||||||
|
await renderPanel();
|
||||||
|
await openMcpToolsTab();
|
||||||
|
await selectAgentTarget("DevFrontend");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/9 lecture autorisés · 0 écriture autorisé/)).toBeTruthy();
|
||||||
|
|
||||||
|
const writeCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: /idea_ticket_update_carnet$/,
|
||||||
|
});
|
||||||
|
fireEvent.click(writeCheckbox);
|
||||||
|
|
||||||
|
expect(screen.getByText(/9 lecture autorisés · 1 écriture autorisé/)).toBeTruthy();
|
||||||
|
expect(screen.getByText("Modifications non enregistrées")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Ajouté")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving calls updateAgentMcpToolPermissions with the exact resulting allowlist", async () => {
|
||||||
|
const { permission, agentId } = await renderPanel();
|
||||||
|
const spy = vi.spyOn(permission, "updateAgentMcpToolPermissions");
|
||||||
|
await openMcpToolsTab();
|
||||||
|
await selectAgentTarget("DevFrontend");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
|
||||||
|
const [calledProjectId, calledAgentId, calledPolicy] = spy.mock.calls[0]!;
|
||||||
|
expect(calledProjectId).toBe(PROJECT_ID);
|
||||||
|
expect(calledAgentId).toBe(agentId);
|
||||||
|
expect(calledPolicy?.allowedTools).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"idea_list_agents",
|
||||||
|
"idea_context_read",
|
||||||
|
"idea_memory_read",
|
||||||
|
"idea_skill_read",
|
||||||
|
"idea_workstate_read",
|
||||||
|
"idea_ticket_read",
|
||||||
|
"idea_ticket_list",
|
||||||
|
"idea_ticket_read_carnet",
|
||||||
|
"idea_sprint_list",
|
||||||
|
"idea_ticket_update_carnet",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(calledPolicy?.allowedTools).toHaveLength(10);
|
||||||
|
|
||||||
|
// Persisted: the left-column badge flips to Override, and Réinitialiser appears.
|
||||||
|
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
|
||||||
|
const agentButton = within(nav).getByRole("button", { name: /DevFrontend/ });
|
||||||
|
expect(within(agentButton).getByText("Override")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Réinitialiser l'override" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("draft protection on target change", () => {
|
||||||
|
const originalConfirm = window.confirm;
|
||||||
|
beforeEach(() => {
|
||||||
|
window.confirm = vi.fn();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
window.confirm = originalConfirm;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for confirmation, and keeps the draft when the user cancels", async () => {
|
||||||
|
await renderPanel();
|
||||||
|
await openMcpToolsTab();
|
||||||
|
await selectAgentTarget("DevFrontend");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }));
|
||||||
|
|
||||||
|
(window.confirm as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||||
|
|
||||||
|
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
|
||||||
|
fireEvent.click(within(nav).getByRole("button", { name: /^Défaut projet/ }));
|
||||||
|
|
||||||
|
expect(window.confirm).toHaveBeenCalled();
|
||||||
|
// Still on the agent editor, with the unsaved edit intact.
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Tools MCP IdeA — DevFrontend" }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }) as HTMLInputElement)
|
||||||
|
.checked,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards the draft and switches target when the user confirms", async () => {
|
||||||
|
await renderPanel();
|
||||||
|
await openMcpToolsTab();
|
||||||
|
await selectAgentTarget("DevFrontend");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }));
|
||||||
|
|
||||||
|
(window.confirm as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||||
|
|
||||||
|
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
|
||||||
|
fireEvent.click(within(nav).getByRole("button", { name: /^Défaut projet/ }));
|
||||||
|
|
||||||
|
expect(window.confirm).toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Tools MCP IdeA — Défaut projet" }),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not prompt when switching target with no unsaved changes", async () => {
|
||||||
|
await renderPanel();
|
||||||
|
await openMcpToolsTab();
|
||||||
|
await selectAgentTarget("DevFrontend");
|
||||||
|
|
||||||
|
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
|
||||||
|
fireEvent.click(within(nav).getByRole("button", { name: /^Défaut projet/ }));
|
||||||
|
|
||||||
|
expect(window.confirm).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Tools MCP IdeA — Défaut projet" }),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { Button, Panel, Spinner, cn } from "@/shared";
|
import { Button, Panel, Spinner, cn } from "@/shared";
|
||||||
import type { PermissionSet, PermissionPosture } from "@/domain";
|
import type { PermissionSet, PermissionPosture } from "@/domain";
|
||||||
|
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
|
||||||
import {
|
import {
|
||||||
type CapabilityChoice,
|
type CapabilityChoice,
|
||||||
type PolicyDraft,
|
type PolicyDraft,
|
||||||
@ -14,6 +15,13 @@ export interface PermissionsPanelProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PermissionsTab = "system" | "mcpTools";
|
||||||
|
|
||||||
|
const TABS: { id: PermissionsTab; label: string }[] = [
|
||||||
|
{ id: "system", label: "Système" },
|
||||||
|
{ id: "mcpTools", label: "Tools MCP IdeA" },
|
||||||
|
];
|
||||||
|
|
||||||
type EditorTarget =
|
type EditorTarget =
|
||||||
| { type: "project" }
|
| { type: "project" }
|
||||||
| { type: "agent"; agentId: string; agentName: string };
|
| { type: "agent"; agentId: string; agentName: string };
|
||||||
@ -37,6 +45,7 @@ const POSTURE_LABELS: Record<PermissionPosture, string> = {
|
|||||||
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||||
const vm = usePermissions(projectId);
|
const vm = usePermissions(projectId);
|
||||||
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
|
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
|
||||||
|
const [tab, setTab] = useState<PermissionsTab>("system");
|
||||||
|
|
||||||
const selectedAgent = target.type === "agent"
|
const selectedAgent = target.type === "agent"
|
||||||
? vm.rows.find((row) => row.agent.id === target.agentId) ?? null
|
? vm.rows.find((row) => row.agent.id === target.agentId) ?? null
|
||||||
@ -79,9 +88,39 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
|||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
className="flex flex-col"
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
flush
|
flush
|
||||||
>
|
>
|
||||||
|
<div role="tablist" aria-label="Permissions" className="flex gap-1 border-b border-border px-4 pt-2">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`permissions-tab-${t.id}`}
|
||||||
|
aria-selected={tab === t.id}
|
||||||
|
aria-controls={`permissions-tabpanel-${t.id}`}
|
||||||
|
tabIndex={tab === t.id ? 0 : -1}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
||||||
|
e.preventDefault();
|
||||||
|
const i = TABS.findIndex((x) => x.id === tab);
|
||||||
|
const next = e.key === "ArrowRight" ? (i + 1) % TABS.length : (i - 1 + TABS.length) % TABS.length;
|
||||||
|
setTab(TABS[next].id);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[32px] rounded-t-md border-b-2 px-3 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
tab === t.id
|
||||||
|
? "border-primary text-content"
|
||||||
|
: "border-transparent text-muted hover:text-content",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{vm.error && (
|
{vm.error && (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
@ -91,7 +130,13 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 p-4">
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
id="permissions-tabpanel-system"
|
||||||
|
aria-labelledby="permissions-tab-system"
|
||||||
|
hidden={tab !== "system"}
|
||||||
|
className="flex flex-col gap-4 p-4"
|
||||||
|
>
|
||||||
<PolicyCard
|
<PolicyCard
|
||||||
title="Project defaults"
|
title="Project defaults"
|
||||||
subtitle={hasProjectDefaults ? "Configured" : "Native CLI behavior"}
|
subtitle={hasProjectDefaults ? "Configured" : "Native CLI behavior"}
|
||||||
@ -158,6 +203,18 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
|||||||
onClear={() => void handleClear()}
|
onClear={() => void handleClear()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
id="permissions-tabpanel-mcpTools"
|
||||||
|
aria-labelledby="permissions-tab-mcpTools"
|
||||||
|
hidden={tab !== "mcpTools"}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
{tab === "mcpTools" && (
|
||||||
|
<McpToolPermissionsPanel projectId={projectId} agents={vm.agents} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
111
frontend/src/features/permissions/mcpToolGroups.ts
Normal file
111
frontend/src/features/permissions/mcpToolGroups.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Presentational grouping of the MCP tool catalogue (ticket #82).
|
||||||
|
*
|
||||||
|
* Purely a frontend display concern: which domain a tool "belongs to" for the
|
||||||
|
* grouped/collapsible editor, and its short French label. The read/write
|
||||||
|
* classification stays backend-canonical — always read from the
|
||||||
|
* {@link McpToolCatalogue} the API returns, never re-derived here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface McpToolGroupDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
tools: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Domain groups, in display order (carnet #82 — Conception UX/F). */
|
||||||
|
export const MCP_TOOL_GROUPS: McpToolGroupDef[] = [
|
||||||
|
{
|
||||||
|
id: "readProject",
|
||||||
|
label: "Lecture projet",
|
||||||
|
tools: [
|
||||||
|
"idea_list_agents",
|
||||||
|
"idea_context_read",
|
||||||
|
"idea_memory_read",
|
||||||
|
"idea_skill_read",
|
||||||
|
"idea_workstate_read",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "readTickets",
|
||||||
|
label: "Lecture tickets",
|
||||||
|
tools: [
|
||||||
|
"idea_ticket_read",
|
||||||
|
"idea_ticket_list",
|
||||||
|
"idea_ticket_read_carnet",
|
||||||
|
"idea_sprint_list",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delegation",
|
||||||
|
label: "Délégation agents",
|
||||||
|
tools: ["idea_ask_agent", "idea_launch_agent", "idea_stop_agent"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contextMemory",
|
||||||
|
label: "Contexte et mémoire",
|
||||||
|
tools: ["idea_update_context", "idea_context_propose", "idea_memory_write"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tickets",
|
||||||
|
label: "Tickets",
|
||||||
|
tools: [
|
||||||
|
"idea_ticket_create",
|
||||||
|
"idea_ticket_update",
|
||||||
|
"idea_ticket_update_status",
|
||||||
|
"idea_ticket_update_priority",
|
||||||
|
"idea_ticket_update_carnet",
|
||||||
|
"idea_ticket_link",
|
||||||
|
"idea_ticket_unlink",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "work",
|
||||||
|
label: "Travail et exécution",
|
||||||
|
tools: ["idea_run_in_background", "idea_workstate_set"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "skills",
|
||||||
|
label: "Skills",
|
||||||
|
tools: ["idea_create_skill"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Short, human French label per exact MCP tool name. */
|
||||||
|
export const MCP_TOOL_LABELS: Record<string, string> = {
|
||||||
|
idea_list_agents: "Lister les agents",
|
||||||
|
idea_context_read: "Lire le contexte d'un agent",
|
||||||
|
idea_memory_read: "Lire la mémoire projet",
|
||||||
|
idea_skill_read: "Lire un skill",
|
||||||
|
idea_workstate_read: "Lire l'état de travail",
|
||||||
|
idea_ticket_read: "Lire un ticket",
|
||||||
|
idea_ticket_list: "Lister les tickets",
|
||||||
|
idea_ticket_read_carnet: "Lire le carnet d'un ticket",
|
||||||
|
idea_sprint_list: "Lister les sprints",
|
||||||
|
idea_ask_agent: "Déléguer à un agent",
|
||||||
|
idea_launch_agent: "Lancer un agent",
|
||||||
|
idea_stop_agent: "Arrêter un agent",
|
||||||
|
idea_update_context: "Modifier le contexte d'un agent",
|
||||||
|
idea_context_propose: "Proposer un contexte",
|
||||||
|
idea_memory_write: "Écrire la mémoire projet",
|
||||||
|
idea_workstate_set: "Écrire l'état de travail",
|
||||||
|
idea_create_skill: "Créer un skill",
|
||||||
|
idea_ticket_create: "Créer un ticket",
|
||||||
|
idea_ticket_update: "Modifier un ticket",
|
||||||
|
idea_ticket_update_status: "Modifier le statut d'un ticket",
|
||||||
|
idea_ticket_update_priority: "Modifier la priorité d'un ticket",
|
||||||
|
idea_ticket_update_carnet: "Modifier le carnet d'un ticket",
|
||||||
|
idea_ticket_link: "Lier des tickets",
|
||||||
|
idea_ticket_unlink: "Délier des tickets",
|
||||||
|
idea_run_in_background: "Exécuter une commande en arrière-plan",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Short human label for `tool`, falling back to the exact name if unmapped. */
|
||||||
|
export function mcpToolLabel(tool: string): string {
|
||||||
|
return MCP_TOOL_LABELS[tool] ?? tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The group a tool belongs to, or `undefined` if not locally mapped. */
|
||||||
|
export function mcpToolGroupId(tool: string): string | undefined {
|
||||||
|
return MCP_TOOL_GROUPS.find((group) => group.tools.includes(tool))?.id;
|
||||||
|
}
|
||||||
141
frontend/src/features/permissions/useMcpToolPermissions.ts
Normal file
141
frontend/src/features/permissions/useMcpToolPermissions.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* `useMcpToolPermissions` — view-model hook for the "Tools MCP IdeA" tab
|
||||||
|
* (ticket #82). Owns the durable per-project MCP tool permission document
|
||||||
|
* (distinct from the file/command permissions in `usePermissions`) and
|
||||||
|
* exposes read/save/reset operations. No effective-policy resolution logic
|
||||||
|
* beyond mirroring the backend's own precedence (agent override > project
|
||||||
|
* default > canonical read-only fallback) using ONLY data the API returns —
|
||||||
|
* the read/write classification itself always comes from `catalogue`, never
|
||||||
|
* a local constant (ticket #82 acceptance criteria).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { GatewayError, McpToolPolicy, ProjectMcpToolPermissions } from "@/domain";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
|
export interface McpToolPermissionsViewModel {
|
||||||
|
doc: ProjectMcpToolPermissions | null;
|
||||||
|
loading: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
saveProjectDefault: (policy: McpToolPolicy) => Promise<void>;
|
||||||
|
saveAgentOverride: (agentId: string, policy: McpToolPolicy) => Promise<void>;
|
||||||
|
resetAgentOverride: (agentId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective allowlist for `agentId`: its stored override if any, else the
|
||||||
|
* project default, else the catalogue's read-only fallback. Pure function of
|
||||||
|
* backend-provided data (no hardcoded tool names).
|
||||||
|
*/
|
||||||
|
export function effectiveMcpAllowedTools(
|
||||||
|
doc: ProjectMcpToolPermissions,
|
||||||
|
agentId: string,
|
||||||
|
): string[] {
|
||||||
|
const override = doc.agents.find((entry) => entry.agentId === agentId);
|
||||||
|
if (override) return override.policy.allowedTools;
|
||||||
|
if (doc.projectDefault) return doc.projectDefault.allowedTools;
|
||||||
|
return doc.catalogue.readOnlyTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMcpToolPermissions(
|
||||||
|
projectId: string,
|
||||||
|
): McpToolPermissionsViewModel {
|
||||||
|
const { permission } = useGateways();
|
||||||
|
const [doc, setDoc] = useState<ProjectMcpToolPermissions | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!permission) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setDoc(await permission.getMcpToolPermissions(projectId));
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [permission, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const saveProjectDefault = useCallback(
|
||||||
|
async (policy: McpToolPolicy) => {
|
||||||
|
if (!permission) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setDoc(await permission.updateProjectMcpToolPermissions(projectId, policy));
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[permission, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveAgentOverride = useCallback(
|
||||||
|
async (agentId: string, policy: McpToolPolicy) => {
|
||||||
|
if (!permission) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setDoc(
|
||||||
|
await permission.updateAgentMcpToolPermissions(projectId, agentId, policy),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[permission, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetAgentOverride = useCallback(
|
||||||
|
async (agentId: string) => {
|
||||||
|
if (!permission) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setDoc(
|
||||||
|
await permission.updateAgentMcpToolPermissions(projectId, agentId, null),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[permission, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
doc,
|
||||||
|
loading,
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
saveProjectDefault,
|
||||||
|
saveAgentOverride,
|
||||||
|
resetAgentOverride,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -32,11 +32,13 @@ import type {
|
|||||||
MemoryIndexEntry,
|
MemoryIndexEntry,
|
||||||
MemoryLink,
|
MemoryLink,
|
||||||
MemoryType,
|
MemoryType,
|
||||||
|
McpToolPolicy,
|
||||||
OpenCodeConfig,
|
OpenCodeConfig,
|
||||||
EffectivePermissions,
|
EffectivePermissions,
|
||||||
PairedDevice,
|
PairedDevice,
|
||||||
PairingCode,
|
PairingCode,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
|
ProjectMcpToolPermissions,
|
||||||
PageDirection,
|
PageDirection,
|
||||||
Project,
|
Project,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
@ -797,6 +799,23 @@ export interface PermissionGateway {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
): Promise<EffectivePermissions | null>;
|
): Promise<EffectivePermissions | null>;
|
||||||
|
/**
|
||||||
|
* Reads the project's durable MCP tool permission document plus the
|
||||||
|
* backend-canonical catalogue classification (ticket #82). Distinct
|
||||||
|
* document from the file/command permissions above.
|
||||||
|
*/
|
||||||
|
getMcpToolPermissions(projectId: string): Promise<ProjectMcpToolPermissions>;
|
||||||
|
/** Replaces or removes the project-wide default MCP tool policy. */
|
||||||
|
updateProjectMcpToolPermissions(
|
||||||
|
projectId: string,
|
||||||
|
policy: McpToolPolicy | null,
|
||||||
|
): Promise<ProjectMcpToolPermissions>;
|
||||||
|
/** Replaces or removes one agent's MCP tool policy override. */
|
||||||
|
updateAgentMcpToolPermissions(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
policy: McpToolPolicy | null,
|
||||||
|
): Promise<ProjectMcpToolPermissions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read-only live work-state read-model for conversations/delegations. */
|
/** Read-only live work-state read-model for conversations/delegations. */
|
||||||
|
|||||||
Reference in New Issue
Block a user