import { useEffect, useMemo, useRef, useState } from "react"; import { Button, Panel, Spinner, cn } from "@/shared"; import type { NetworkPolicy, PermissionSet, PermissionPosture, ResolvedAgentSystemPermissions, SystemPermissionSet, } from "@/domain"; import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel"; import { type CapabilityChoice, type PolicyDraft, draftFromSet, isCustomPolicy, usePermissions, } from "./usePermissions"; export interface PermissionsPanelProps { 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: "project" } | { type: "agent"; agentId: string; agentName: string }; const CAPABILITY_ROWS: { key: keyof Omit; label: string; }[] = [ { key: "read", label: "Read" }, { key: "write", label: "Write" }, { key: "delete", label: "Delete" }, { key: "executeBash", label: "Bash" }, ]; const POSTURE_LABELS: Record = { ask: "Ask", allow: "Allow", deny: "Deny", }; const NETWORK_LABELS: Record = { allow: "Autorisé", deny: "Interdit", ask: "Demande", }; export function PermissionsPanel({ projectId }: PermissionsPanelProps) { const vm = usePermissions(projectId); const [target, setTarget] = useState({ type: "project" }); const [tab, setTab] = useState("system"); const selectedAgent = target.type === "agent" ? vm.rows.find((row) => row.agent.id === target.agentId) ?? null : null; const activePolicy = selectedAgent?.override ?? vm.document?.projectDefaults ?? null; const activeSystemSet = target.type === "project" ? vm.systemDocument?.projectDefault ?? null : selectedAgent?.systemOverride ?? vm.systemDocument?.projectDefault ?? null; const activeResolvedSystem = target.type === "project" ? vm.rows[0]?.resolvedSystem ?? null : selectedAgent?.resolvedSystem ?? null; const activeDraft = target.type === "project" ? vm.projectDraft : draftFromSet(selectedAgent?.override ?? vm.document?.projectDefaults ?? null); const activeCustom = target.type === "project" ? isCustomPolicy(vm.document?.projectDefaults) : isCustomPolicy(selectedAgent?.override); const hasProjectDefaults = vm.document?.projectDefaults != null; async function handleSave(draft: PolicyDraft) { if (target.type === "project") { await vm.saveProjectDefaults(draft); } else { await vm.saveAgentOverride(target.agentId, draft); } } async function handleClear() { if (target.type === "project") { await vm.clearProjectDefaults(); } else { await vm.clearAgentOverride(target.agentId); } } async function handleSaveNetwork(permissions: SystemPermissionSet | null) { if (target.type === "project") { await vm.saveProjectSystemPermissions(permissions); } else { await vm.saveAgentSystemPermissions(target.agentId, permissions); } } return ( void vm.refresh()} > Refresh } className="flex min-h-0 flex-1 flex-col" flush >
{TABS.map((t) => ( ))}
{vm.error && (

{vm.error}

)}
); } interface PolicyCardProps { title: string; subtitle: string; active: boolean; custom: boolean; policy: PermissionSet | null; onSelect: () => void; } function PolicyCard({ title, subtitle, active, custom, policy, onSelect, }: PolicyCardProps) { return ( ); } interface NetworkPermissionEditorProps { title: string; source: SystemPermissionSet | null; inherited: SystemPermissionSet | null; resolved: ResolvedAgentSystemPermissions | null; busy: boolean; onSave: (permissions: SystemPermissionSet | null) => void; } function NetworkPermissionEditor({ title, source, inherited, resolved, busy, onSave, }: NetworkPermissionEditorProps) { const current = source?.network ?? ""; const [local, setLocal] = useState(current); useEffect(() => { setLocal(current); }, [current]); const readOnly = resolved?.control.mode === "readOnly"; const changed = local !== current; const wanted = resolved?.wanted ?? source?.network ?? inherited?.network ?? null; const effective = resolved?.effective ?? null; const runtimeLocked = resolved?.runtimeLock.state === "locked"; const lockReason = resolved?.runtimeLock.reason ?? resolved?.control.reason ?? null; return (

{title}

Politique voulue, état effectif et verrou runtime.

{readOnly && (

{lockReason ?? "Le runtime actif ne permet pas de modifier cette permission."}

)} {inherited?.network && !source?.network && (

Hérite du projet : Réseau {NETWORK_LABELS[inherited.network].toLowerCase()}.

)}
); } function StatusLine({ label, value }: { label: string; value: string }) { return (
{label} {value}
); } interface PermissionEditorProps { title: string; draft: PolicyDraft; sourcePolicy: PermissionSet | null; custom: boolean; canClear: boolean; busy: boolean; onSave: (draft: PolicyDraft) => void; onClear: () => void; } function PermissionEditor({ title, draft, sourcePolicy, custom, canClear, busy, onSave, onClear, }: PermissionEditorProps) { const [local, setLocal] = useState(draft); // `draft` is a NEW object every time the underlying document changes for ANY // reason (initial load landing after mount, a Refresh, another tab's save…), // even when its content matches what is already in `local`. Naively // re-syncing on every `draft` change is a genuine race: if it fires after the // user has started editing (in production: a slow initial fetch landing // right as the user types; in tests: a passive effect flushing later than a // synchronous `fireEvent` sequence — ticket #79 flake), it silently discards // the user's in-progress edit. `prevDraftRef` tracks the last draft this // effect adopted `local` from; we only re-sync when `local` still matches it // (nothing has been edited since), so a legitimate incoming draft (first // load, or the echo of the user's own just-saved edit) is adopted, but an // edit in flight never gets clobbered. const prevDraftRef = useRef(draft); useEffect(() => { setLocal((current) => JSON.stringify(current) === JSON.stringify(prevDraftRef.current) ? draft : current, ); prevDraftRef.current = draft; }, [draft]); const changed = useMemo( () => JSON.stringify(local) !== JSON.stringify(draft), [draft, local], ); function setCapability( capability: keyof Omit, choice: CapabilityChoice, ) { setLocal((prev) => ({ ...prev, [capability]: choice })); } return (

{title}

{sourcePolicy ? `${sourcePolicy.rules.length} rules` : "Not configured"}

{custom && ( Advanced )}
{CAPABILITY_ROWS.map((row) => (
{row.label} setCapability(row.key, choice)} />
))}
); } interface SegmentedChoiceProps { value: CapabilityChoice; disabled: boolean; label: string; onChange: (choice: CapabilityChoice) => void; } function SegmentedChoice({ value, disabled, label, onChange, }: SegmentedChoiceProps) { const choices: { value: CapabilityChoice; label: string }[] = [ { value: "none", label: "None" }, { value: "allow", label: "Allow" }, { value: "deny", label: "Deny" }, ]; return (
{choices.map((choice) => ( ))}
); }