fix(permissions): tools MCP inter-projets exposés (idea_project_*) + contactable agent/template persisté — update_agent_contactable (application/web-server/app-tauri), defaultContactable TemplateEditor save/reopen, groupes MCP 'IdeA inter-projets' avec fallback, permissions agent Contactable — #267 (QA verte)
This commit is contained in:
@ -4,12 +4,16 @@ import remarkGfm from "remark-gfm";
|
||||
|
||||
import type {
|
||||
AgentProfile,
|
||||
McpToolCatalogue,
|
||||
McpToolPolicy,
|
||||
NetworkPolicy,
|
||||
SystemPermissionSet,
|
||||
Template,
|
||||
} from "@/domain";
|
||||
import { MCP_TOOL_GROUPS, mcpToolLabel } from "@/features/permissions/mcpToolGroups";
|
||||
import {
|
||||
mcpToolGroupsForCatalogue,
|
||||
mcpToolLabel,
|
||||
} from "@/features/permissions/mcpToolGroups";
|
||||
import { Button, Input, SmallDropdown, cn } from "@/shared";
|
||||
import { profileLabel } from "./templatePresentation";
|
||||
|
||||
@ -18,6 +22,7 @@ export interface TemplateEditorSaveInput {
|
||||
content: string;
|
||||
defaultProfileId: string;
|
||||
defaultAgentName: string | null;
|
||||
defaultContactable: boolean;
|
||||
systemPermissions: SystemPermissionSet | null;
|
||||
mcpToolPermissions: McpToolPolicy | null;
|
||||
}
|
||||
@ -25,6 +30,7 @@ export interface TemplateEditorSaveInput {
|
||||
export interface TemplateEditorProps {
|
||||
template?: Template | null;
|
||||
profiles: AgentProfile[];
|
||||
mcpCatalogue: McpToolCatalogue;
|
||||
onSave: (input: TemplateEditorSaveInput) => Promise<void>;
|
||||
onClose: () => void;
|
||||
busy?: boolean;
|
||||
@ -42,13 +48,10 @@ const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [
|
||||
const TEMPLATE_CROSS_PROJECT_HELP =
|
||||
"Les agents créés avec ce template reprendront cette permission.";
|
||||
|
||||
const ALL_MCP_TOOLS = Array.from(
|
||||
new Set(MCP_TOOL_GROUPS.flatMap((group) => group.tools)),
|
||||
);
|
||||
|
||||
export function TemplateEditor({
|
||||
template,
|
||||
profiles,
|
||||
mcpCatalogue,
|
||||
onSave,
|
||||
onClose,
|
||||
busy = false,
|
||||
@ -64,13 +67,21 @@ export function TemplateEditor({
|
||||
const [network, setNetwork] = useState<"" | NetworkPolicy>(
|
||||
template?.systemPermissions?.network ?? "",
|
||||
);
|
||||
const [crossProjectContactable, setCrossProjectContactable] = useState(
|
||||
template?.systemPermissions?.crossProjectContactable ?? false,
|
||||
const [defaultContactable, setDefaultContactable] = useState(
|
||||
template?.defaultContactable ?? false,
|
||||
);
|
||||
const [allowedTools, setAllowedTools] = useState<string[]>(
|
||||
template?.mcpToolPermissions?.allowedTools ?? [],
|
||||
);
|
||||
const [tab, setTab] = useState<EditorTab>("edit");
|
||||
const toolGroups = useMemo(
|
||||
() => mcpToolGroupsForCatalogue(mcpCatalogue),
|
||||
[mcpCatalogue],
|
||||
);
|
||||
const allMcpTools = useMemo(
|
||||
() => toolGroups.flatMap((group) => group.tools),
|
||||
[toolGroups],
|
||||
);
|
||||
|
||||
const allowed = useMemo(() => new Set(allowedTools), [allowedTools]);
|
||||
const canSave = name.trim().length > 0 && !busy;
|
||||
@ -91,15 +102,8 @@ export function TemplateEditor({
|
||||
content,
|
||||
defaultProfileId,
|
||||
defaultAgentName: defaultAgentName.trim() || null,
|
||||
systemPermissions:
|
||||
network || crossProjectContactable
|
||||
? {
|
||||
...(network ? { network } : {}),
|
||||
...(crossProjectContactable
|
||||
? { crossProjectContactable: true }
|
||||
: {}),
|
||||
}
|
||||
: null,
|
||||
defaultContactable,
|
||||
systemPermissions: network ? { network } : null,
|
||||
mcpToolPermissions:
|
||||
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
|
||||
});
|
||||
@ -275,8 +279,8 @@ export function TemplateEditor({
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Contactable depuis d’autres projets"
|
||||
checked={crossProjectContactable}
|
||||
onChange={(e) => setCrossProjectContactable(e.target.checked)}
|
||||
checked={defaultContactable}
|
||||
onChange={(e) => setDefaultContactable(e.target.checked)}
|
||||
disabled={busy}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
@ -298,7 +302,7 @@ export function TemplateEditor({
|
||||
</h3>
|
||||
<span className="text-xs text-faint">{allowedTools.length} autorisé(s)</span>
|
||||
</div>
|
||||
{ALL_MCP_TOOLS.length === 0 ? (
|
||||
{allMcpTools.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun tool MCP IdeA disponible.</p>
|
||||
) : (
|
||||
<>
|
||||
@ -308,7 +312,7 @@ export function TemplateEditor({
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{MCP_TOOL_GROUPS.map((group) => (
|
||||
{toolGroups.map((group) => (
|
||||
<div key={group.id} className="flex min-w-0 flex-col gap-2">
|
||||
<h4 className="text-xs font-semibold text-content">{group.label}</h4>
|
||||
{group.tools.map((tool) => (
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { AgentProfile, Template } from "@/domain";
|
||||
import type { AgentProfile, McpToolCatalogue, Template } from "@/domain";
|
||||
import { Button, Panel, Spinner } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { TemplateEditor, type TemplateEditorSaveInput } from "./TemplateEditor";
|
||||
@ -33,6 +33,11 @@ export interface TemplatesPanelProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const EMPTY_MCP_CATALOGUE: McpToolCatalogue = {
|
||||
readOnlyTools: [],
|
||||
writeActionTools: [],
|
||||
};
|
||||
|
||||
/** Editor open-state: "create" or edit-mode for a specific template. */
|
||||
type EditorState =
|
||||
| { mode: "create" }
|
||||
@ -40,11 +45,12 @@ type EditorState =
|
||||
|
||||
export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
|
||||
const vm = useTemplates();
|
||||
const { profile } = useGateways();
|
||||
const { permission, profile } = useGateways();
|
||||
|
||||
// Profiles list for the editor's default-profile selector
|
||||
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
||||
const [profilesLoaded, setProfilesLoaded] = useState(false);
|
||||
const [mcpCatalogue, setMcpCatalogue] = useState<McpToolCatalogue | null>(null);
|
||||
|
||||
const [editorState, setEditorState] = useState<EditorState | null>(null);
|
||||
const [editorBusy, setEditorBusy] = useState(false);
|
||||
@ -69,6 +75,25 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
|
||||
};
|
||||
}, [profile, profilesLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!permission) {
|
||||
setMcpCatalogue(EMPTY_MCP_CATALOGUE);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void permission
|
||||
.getMcpToolPermissions(projectId)
|
||||
.then((doc) => {
|
||||
if (!cancelled) setMcpCatalogue(doc.catalogue);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setMcpCatalogue(EMPTY_MCP_CATALOGUE);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [permission, projectId]);
|
||||
|
||||
/** Lazily load profiles when the editor is first opened. */
|
||||
async function openEditor(state: EditorState) {
|
||||
if (!profilesLoaded) {
|
||||
@ -105,6 +130,7 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
|
||||
<TemplateEditor
|
||||
template={editorState.mode === "edit" ? editorState.template : null}
|
||||
profiles={profiles}
|
||||
mcpCatalogue={mcpCatalogue ?? EMPTY_MCP_CATALOGUE}
|
||||
onSave={handleSave}
|
||||
onClose={() => setEditorState(null)}
|
||||
busy={editorBusy}
|
||||
|
||||
@ -17,7 +17,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import { MockAgentGateway, MockProfileGateway, MockTemplateGateway } from "@/adapters/mock";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockPermissionGateway,
|
||||
MockProfileGateway,
|
||||
MockTemplateGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { TemplatesPanel } from "./TemplatesPanel";
|
||||
@ -33,6 +38,7 @@ interface RenderOpts {
|
||||
agent?: MockAgentGateway;
|
||||
template?: MockTemplateGateway;
|
||||
profile?: MockProfileGateway;
|
||||
permission?: MockPermissionGateway;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -43,11 +49,13 @@ function renderTemplatesPanel(opts: RenderOpts = {}) {
|
||||
const agent = opts.agent ?? new MockAgentGateway();
|
||||
const tmpl = opts.template ?? new MockTemplateGateway(agent);
|
||||
const profile = opts.profile ?? new MockProfileGateway();
|
||||
const gateways = { agent, profile, template: tmpl } as unknown as Gateways;
|
||||
const permission = opts.permission ?? new MockPermissionGateway();
|
||||
const gateways = { agent, permission, profile, template: tmpl } as unknown as Gateways;
|
||||
return {
|
||||
agent,
|
||||
tmpl,
|
||||
profile,
|
||||
permission,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TemplatesPanel projectId={PROJECT_ID} />
|
||||
@ -187,11 +195,40 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
|
||||
expect(templates[0]).toMatchObject({
|
||||
defaultAgentName: "Main",
|
||||
defaultProfileId: "profile-main",
|
||||
systemPermissions: { network: "allow", crossProjectContactable: true },
|
||||
defaultContactable: true,
|
||||
systemPermissions: { network: "allow" },
|
||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves defaultContactable after save and reopen", async () => {
|
||||
const { tmpl } = renderTemplatesPanel();
|
||||
await waitForTemplatesIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "create template" }));
|
||||
await screen.findByLabelText("template name");
|
||||
fireEvent.change(screen.getByLabelText("template name"), {
|
||||
target: { value: "Contactable" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
|
||||
|
||||
await screen.findByText("Contactable");
|
||||
expect((await tmpl.listTemplates())[0]).toMatchObject({
|
||||
defaultContactable: true,
|
||||
systemPermissions: null,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "edit Contactable" }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(screen.getByLabelText(
|
||||
"Contactable depuis d’autres projets",
|
||||
) as HTMLInputElement).checked,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults template cross-project contactability off when the field is absent", async () => {
|
||||
renderTemplatesPanel();
|
||||
await waitForTemplatesIdle();
|
||||
@ -211,6 +248,28 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows cross-project and ungrouped catalogued MCP tools in the template editor", async () => {
|
||||
const permission = new MockPermissionGateway();
|
||||
permission.setMcpToolCatalogue({
|
||||
readOnlyTools: ["idea_project_list", "idea_future_read"],
|
||||
writeActionTools: ["idea_project_cancel_agent"],
|
||||
});
|
||||
renderTemplatesPanel({ permission });
|
||||
await waitForTemplatesIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "create template" }));
|
||||
|
||||
expect(await screen.findByText("IdeA inter-projets")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByLabelText("template MCP tool idea_project_cancel_agent"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("Autres")).toBeTruthy();
|
||||
const fallback = screen.getByLabelText(
|
||||
"template MCP tool idea_future_read",
|
||||
) as HTMLInputElement;
|
||||
expect(fallback.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("the Create button is disabled when the name is empty", async () => {
|
||||
renderTemplatesPanel();
|
||||
await waitForTemplatesIdle();
|
||||
|
||||
Reference in New Issue
Block a user