merge(cross-project): intègre le frontend #260 — surface UI permissions contactables (pré-QA)

This commit is contained in:
2026-09-07 16:31:30 +02:00
6 changed files with 150 additions and 14 deletions

View File

@ -264,7 +264,7 @@ describe("request/response gateways preserve the Tauri command contract", () =>
contentMd: "# Body", contentMd: "# Body",
version: 1, version: 1,
defaultProfileId: "p1", defaultProfileId: "p1",
defaultSystemPermissions: { network: "allow" }, defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
defaultMcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, defaultMcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}; };
const spy = vi const spy = vi
@ -276,7 +276,7 @@ describe("request/response gateways preserve the Tauri command contract", () =>
await expect(gw.listTemplates()).resolves.toMatchObject([ await expect(gw.listTemplates()).resolves.toMatchObject([
{ {
systemPermissions: { network: "allow" }, systemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}, },
]); ]);
@ -285,11 +285,11 @@ describe("request/response gateways preserve the Tauri command contract", () =>
name: "Permissioned", name: "Permissioned",
content: "# Body", content: "# Body",
defaultProfileId: "p1", defaultProfileId: "p1",
systemPermissions: { network: "allow" }, systemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
systemPermissions: { network: "allow" }, systemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}); });
await expect( await expect(
@ -297,11 +297,11 @@ describe("request/response gateways preserve the Tauri command contract", () =>
name: "Permissioned", name: "Permissioned",
content: "# Body", content: "# Body",
defaultProfileId: "p1", defaultProfileId: "p1",
systemPermissions: { network: "allow" }, systemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
systemPermissions: { network: "allow" }, systemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}); });
@ -311,7 +311,7 @@ describe("request/response gateways preserve the Tauri command contract", () =>
content: "# Body", content: "# Body",
defaultProfileId: "p1", defaultProfileId: "p1",
defaultAgentName: null, defaultAgentName: null,
defaultSystemPermissions: { network: "allow" }, defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}, },
}); });
@ -322,7 +322,7 @@ describe("request/response gateways preserve the Tauri command contract", () =>
content: "# Body", content: "# Body",
defaultProfileId: "p1", defaultProfileId: "p1",
defaultAgentName: null, defaultAgentName: null,
defaultSystemPermissions: { network: "allow" }, defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}, },
}); });

View File

@ -820,6 +820,7 @@ export type NetworkPolicy = "allow" | "deny" | "ask";
/** Optional system permission bundle, distinct from file/bash permissions. */ /** Optional system permission bundle, distinct from file/bash permissions. */
export interface SystemPermissionSet { export interface SystemPermissionSet {
network?: NetworkPolicy; network?: NetworkPolicy;
crossProjectContactable?: boolean;
} }
/** One sparse agent system-permission override. */ /** One sparse agent system-permission override. */

View File

@ -54,6 +54,19 @@ const NETWORK_LABELS: Record<NetworkPolicy, string> = {
ask: "Demande", ask: "Demande",
}; };
const CROSS_PROJECT_HELP =
"Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte.";
function systemPermissionsFromDraft(
network: NetworkPolicy | "",
crossProjectContactable: boolean,
): SystemPermissionSet | null {
const permissions: SystemPermissionSet = {};
if (network) permissions.network = network;
if (crossProjectContactable) permissions.crossProjectContactable = true;
return Object.keys(permissions).length > 0 ? permissions : null;
}
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" });
@ -247,6 +260,7 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
? vm.systemDocument?.projectDefault ?? null ? vm.systemDocument?.projectDefault ?? null
: null : null
} }
showCrossProjectContactable={target.type === "agent"}
busy={vm.busy} busy={vm.busy}
onSave={(permissions) => void handleSaveNetwork(permissions)} onSave={(permissions) => void handleSaveNetwork(permissions)}
/> />
@ -320,6 +334,7 @@ interface NetworkPermissionEditorProps {
source: SystemPermissionSet | null; source: SystemPermissionSet | null;
inherited: SystemPermissionSet | null; inherited: SystemPermissionSet | null;
resolved: ResolvedAgentSystemPermissions | null; resolved: ResolvedAgentSystemPermissions | null;
showCrossProjectContactable: boolean;
busy: boolean; busy: boolean;
onSave: (permissions: SystemPermissionSet | null) => void; onSave: (permissions: SystemPermissionSet | null) => void;
} }
@ -329,18 +344,27 @@ function NetworkPermissionEditor({
source, source,
inherited, inherited,
resolved, resolved,
showCrossProjectContactable,
busy, busy,
onSave, onSave,
}: NetworkPermissionEditorProps) { }: NetworkPermissionEditorProps) {
const current = source?.network ?? ""; const current = source?.network ?? "";
const currentCrossProjectContactable =
source?.crossProjectContactable ?? false;
const [local, setLocal] = useState<NetworkPolicy | "">(current); const [local, setLocal] = useState<NetworkPolicy | "">(current);
const [localCrossProjectContactable, setLocalCrossProjectContactable] =
useState(currentCrossProjectContactable);
useEffect(() => { useEffect(() => {
setLocal(current); setLocal(current);
}, [current]); setLocalCrossProjectContactable(currentCrossProjectContactable);
}, [current, currentCrossProjectContactable]);
const readOnly = resolved?.control.mode === "readOnly"; const readOnly = resolved?.control.mode === "readOnly";
const changed = local !== current; const changed =
local !== current ||
(showCrossProjectContactable &&
localCrossProjectContactable !== currentCrossProjectContactable);
const wanted = resolved?.wanted ?? source?.network ?? inherited?.network ?? null; const wanted = resolved?.wanted ?? source?.network ?? inherited?.network ?? null;
const effective = resolved?.effective ?? null; const effective = resolved?.effective ?? null;
const runtimeLocked = resolved?.runtimeLock.state === "locked"; const runtimeLocked = resolved?.runtimeLock.state === "locked";
@ -432,12 +456,36 @@ function NetworkPermissionEditor({
</p> </p>
)} )}
{showCrossProjectContactable && (
<label className="flex items-start gap-2 rounded-md border border-border/70 bg-raised px-3 py-2">
<input
type="checkbox"
aria-label="Contactable depuis d’autres projets"
checked={localCrossProjectContactable}
onChange={(e) => setLocalCrossProjectContactable(e.target.checked)}
disabled={busy || readOnly}
className="mt-0.5 h-4 w-4 shrink-0 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
<span className="flex min-w-0 flex-col gap-1">
<span className="text-sm font-medium text-content">
Contactable depuis d’autres projets
</span>
<span className="text-xs text-muted">{CROSS_PROJECT_HELP}</span>
</span>
</label>
)}
<div className="flex flex-wrap items-center justify-end gap-2 pt-1"> <div className="flex flex-wrap items-center justify-end gap-2 pt-1">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
aria-label="Clear network" aria-label="Clear network"
disabled={busy || readOnly || !source?.network} disabled={
busy ||
readOnly ||
(!source?.network &&
!(showCrossProjectContactable && source?.crossProjectContactable))
}
onClick={() => onSave(null)} onClick={() => onSave(null)}
> >
Clear Clear
@ -448,7 +496,14 @@ function NetworkPermissionEditor({
aria-label="Save network" aria-label="Save network"
disabled={busy || readOnly || !changed} disabled={busy || readOnly || !changed}
loading={busy && changed} loading={busy && changed}
onClick={() => onSave(local ? { network: local } : null)} onClick={() =>
onSave(
systemPermissionsFromDraft(
local,
showCrossProjectContactable && localCrossProjectContactable,
),
)
}
> >
Save Save
</Button> </Button>

View File

@ -102,6 +102,33 @@ describe("PermissionsPanel", () => {
expect(screen.getAllByText("Réseau autorisé").length).toBeGreaterThan(0); expect(screen.getAllByText("Réseau autorisé").length).toBeGreaterThan(0);
}); });
it("defaults cross-project contactability off and persists the agent checkbox", async () => {
const { permission } = await renderPanel();
fireEvent.click(screen.getByText("Builder"));
const checkbox = screen.getByLabelText(
"Contactable depuis d’autres projets",
) as HTMLInputElement;
expect(checkbox.checked).toBe(false);
expect(
screen.getByText(
"Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte.",
),
).toBeTruthy();
fireEvent.click(checkbox);
fireEvent.click(screen.getByRole("button", { name: "Save network" }));
await waitFor(async () => {
const doc = await permission.getProjectSystemPermissions(PROJECT_ID);
expect(doc.agents?.[0]).toEqual({
agentId: expect.any(String),
permissions: { crossProjectContactable: true },
});
});
});
it("renders the network control read-only when persisted control says so", async () => { it("renders the network control read-only when persisted control says so", async () => {
const agent = new MockAgentGateway(); const agent = new MockAgentGateway();
const permission = new MockPermissionGateway(); const permission = new MockPermissionGateway();

View File

@ -39,6 +39,9 @@ const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [
{ value: "deny", label: "Réseau refusé" }, { value: "deny", label: "Réseau refusé" },
]; ];
const TEMPLATE_CROSS_PROJECT_HELP =
"Les agents créés avec ce template reprendront cette permission.";
const ALL_MCP_TOOLS = Array.from( const ALL_MCP_TOOLS = Array.from(
new Set(MCP_TOOL_GROUPS.flatMap((group) => group.tools)), new Set(MCP_TOOL_GROUPS.flatMap((group) => group.tools)),
); );
@ -61,6 +64,9 @@ export function TemplateEditor({
const [network, setNetwork] = useState<"" | NetworkPolicy>( const [network, setNetwork] = useState<"" | NetworkPolicy>(
template?.systemPermissions?.network ?? "", template?.systemPermissions?.network ?? "",
); );
const [crossProjectContactable, setCrossProjectContactable] = useState(
template?.systemPermissions?.crossProjectContactable ?? false,
);
const [allowedTools, setAllowedTools] = useState<string[]>( const [allowedTools, setAllowedTools] = useState<string[]>(
template?.mcpToolPermissions?.allowedTools ?? [], template?.mcpToolPermissions?.allowedTools ?? [],
); );
@ -85,7 +91,15 @@ export function TemplateEditor({
content, content,
defaultProfileId, defaultProfileId,
defaultAgentName: defaultAgentName.trim() || null, defaultAgentName: defaultAgentName.trim() || null,
systemPermissions: network ? { network } : null, systemPermissions:
network || crossProjectContactable
? {
...(network ? { network } : {}),
...(crossProjectContactable
? { crossProjectContactable: true }
: {}),
}
: null,
mcpToolPermissions: mcpToolPermissions:
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null, allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
}); });
@ -256,6 +270,25 @@ export function TemplateEditor({
options={NETWORK_OPTIONS} options={NETWORK_OPTIONS}
/> />
</div> </div>
<label className="flex min-w-0 items-start gap-2 rounded-md border border-border bg-raised px-3 py-2">
<input
type="checkbox"
aria-label="Contactable depuis d’autres projets"
checked={crossProjectContactable}
onChange={(e) => setCrossProjectContactable(e.target.checked)}
disabled={busy}
className="mt-0.5 h-4 w-4 shrink-0 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
<span className="flex min-w-0 flex-col gap-1">
<span className="text-sm font-medium text-content">
Contactable depuis d’autres projets
</span>
<span className="text-xs text-muted">
{TEMPLATE_CROSS_PROJECT_HELP}
</span>
</span>
</label>
</div> </div>
<section className="flex flex-col gap-3 rounded-md border border-border bg-raised p-3"> <section className="flex flex-col gap-3 rounded-md border border-border bg-raised p-3">

View File

@ -173,6 +173,7 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
target: { value: "profile-main" }, target: { value: "profile-main" },
}); });
chooseDropdownOption("template system network permission", "Réseau autorisé"); chooseDropdownOption("template system network permission", "Réseau autorisé");
fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets"));
fireEvent.click(screen.getByLabelText("template MCP tool idea_ticket_read")); fireEvent.click(screen.getByLabelText("template MCP tool idea_ticket_read"));
fireEvent.click(screen.getByRole("button", { name: "Save template" })); fireEvent.click(screen.getByRole("button", { name: "Save template" }));
@ -186,11 +187,30 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
expect(templates[0]).toMatchObject({ expect(templates[0]).toMatchObject({
defaultAgentName: "Main", defaultAgentName: "Main",
defaultProfileId: "profile-main", defaultProfileId: "profile-main",
systemPermissions: { network: "allow" }, systemPermissions: { network: "allow", crossProjectContactable: true },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
}); });
}); });
it("defaults template cross-project contactability off when the field is absent", async () => {
renderTemplatesPanel();
await waitForTemplatesIdle();
fireEvent.click(screen.getByRole("button", { name: "create template" }));
await waitFor(() => expect(screen.getByLabelText("template name")).toBeTruthy());
const checkbox = screen.getByLabelText(
"Contactable depuis d’autres projets",
) as HTMLInputElement;
expect(checkbox.checked).toBe(false);
expect(
screen.getByText(
"Les agents créés avec ce template reprendront cette permission.",
),
).toBeTruthy();
});
it("the Create button is disabled when the name is empty", async () => { it("the Create button is disabled when the name is empty", async () => {
renderTemplatesPanel(); renderTemplatesPanel();
await waitForTemplatesIdle(); await waitForTemplatesIdle();