Files
IdeA/frontend/src/features/permissions/PermissionsPanel.tsx

713 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<PolicyDraft, "fallback">;
label: string;
}[] = [
{ key: "read", label: "Read" },
{ key: "write", label: "Write" },
{ key: "delete", label: "Delete" },
{ key: "executeBash", label: "Bash" },
];
const POSTURE_LABELS: Record<PermissionPosture, string> = {
ask: "Ask",
allow: "Allow",
deny: "Deny",
};
const NETWORK_LABELS: Record<NetworkPolicy, string> = {
allow: "Autorisé",
deny: "Interdit",
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) {
const vm = usePermissions(projectId);
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
const [tab, setTab] = useState<PermissionsTab>("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 (
<Panel
title="Permissions"
actions={
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => void vm.refresh()}
>
Refresh
</Button>
}
className="flex min-h-0 flex-1 flex-col"
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 && (
<p
role="alert"
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
<div
role="tabpanel"
id="permissions-tabpanel-system"
aria-labelledby="permissions-tab-system"
hidden={tab !== "system"}
className="flex flex-col gap-4 p-4"
>
<PolicyCard
title="Project defaults"
subtitle={hasProjectDefaults ? "Configured" : "Native CLI behavior"}
active={target.type === "project"}
custom={isCustomPolicy(vm.document?.projectDefaults)}
onSelect={() => setTarget({ type: "project" })}
policy={vm.document?.projectDefaults ?? null}
/>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
Agents
</h4>
{vm.busy && <Spinner size={14} />}
</div>
{vm.rows.length === 0 ? (
<p className="text-sm text-muted">No agents yet.</p>
) : (
<ul className="flex flex-col gap-2">
{vm.rows.map((row) => {
const active =
target.type === "agent" && target.agentId === row.agent.id;
return (
<li key={row.agent.id}>
<PolicyCard
title={row.agent.name}
subtitle={row.override ? "Override" : "Inherited"}
active={active}
custom={isCustomPolicy(row.override)}
onSelect={() =>
setTarget({
type: "agent",
agentId: row.agent.id,
agentName: row.agent.name,
})
}
policy={row.override}
/>
</li>
);
})}
</ul>
)}
</div>
<PermissionEditor
key={target.type === "project" ? "project" : target.agentId}
title={
target.type === "project"
? "Project defaults"
: `Override — ${target.agentName}`
}
draft={activeDraft}
sourcePolicy={activePolicy}
custom={activeCustom}
canClear={
target.type === "project"
? vm.document?.projectDefaults != null
: selectedAgent?.override != null
}
busy={vm.busy}
onSave={(draft) => void handleSave(draft)}
onClear={() => void handleClear()}
/>
<NetworkPermissionEditor
key={`network-${target.type === "project" ? "project" : target.agentId}`}
title={
target.type === "project"
? "Réseau — defaults projet"
: `Réseau — ${target.agentName}`
}
source={activeSystemSet}
resolved={activeResolvedSystem}
inherited={
target.type === "agent" && selectedAgent?.systemOverride == null
? vm.systemDocument?.projectDefault ?? null
: null
}
showCrossProjectContactable={target.type === "agent"}
busy={vm.busy}
onSave={(permissions) => void handleSaveNetwork(permissions)}
/>
</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>
);
}
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 (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
className={cn(
"flex w-full min-w-0 items-center justify-between gap-3 rounded-md border px-3 py-2 text-left",
"transition-colors hover:border-border-strong hover:bg-raised",
active ? "border-primary bg-raised" : "border-border bg-surface",
)}
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium text-content">{title}</span>
<span className="text-xs text-muted">{subtitle}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5">
{custom && (
<span className="rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
Custom
</span>
)}
{policy && (
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary">
{POSTURE_LABELS[policy.fallback]}
</span>
)}
</span>
</button>
);
}
interface NetworkPermissionEditorProps {
title: string;
source: SystemPermissionSet | null;
inherited: SystemPermissionSet | null;
resolved: ResolvedAgentSystemPermissions | null;
showCrossProjectContactable: boolean;
busy: boolean;
onSave: (permissions: SystemPermissionSet | null) => void;
}
function NetworkPermissionEditor({
title,
source,
inherited,
resolved,
showCrossProjectContactable,
busy,
onSave,
}: NetworkPermissionEditorProps) {
const current = source?.network ?? "";
const currentCrossProjectContactable =
source?.crossProjectContactable ?? false;
const [local, setLocal] = useState<NetworkPolicy | "">(current);
const [localCrossProjectContactable, setLocalCrossProjectContactable] =
useState(currentCrossProjectContactable);
useEffect(() => {
setLocal(current);
setLocalCrossProjectContactable(currentCrossProjectContactable);
}, [current, currentCrossProjectContactable]);
const readOnly = resolved?.control.mode === "readOnly";
const changed =
local !== current ||
(showCrossProjectContactable &&
localCrossProjectContactable !== currentCrossProjectContactable);
const wanted = resolved?.wanted ?? source?.network ?? inherited?.network ?? null;
const effective = resolved?.effective ?? null;
const runtimeLocked = resolved?.runtimeLock.state === "locked";
const runtimeReadOnly = resolved?.runtimeControl.mode === "readOnly";
const runtimeReason = resolved?.runtimeLock.reason ?? resolved?.runtimeControl.reason ?? null;
const runtimeUnobserved = resolved?.runtimeLock.state === "none" && runtimeReadOnly;
return (
<section className="rounded-md border border-border bg-surface">
<header className="border-b border-border px-3 py-2.5">
<h4 className="text-sm font-semibold text-content">{title}</h4>
<p className="text-xs text-muted">
Politique voulue, état effectif et verrou runtime.
</p>
</header>
<div className="flex flex-col gap-3 p-3">
<div className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
<StatusLine
label="Wanted"
value={wanted ? `Réseau ${NETWORK_LABELS[wanted].toLowerCase()}` : "Non configuré"}
/>
<StatusLine
label="Effective"
value={
effective
? `Réseau ${NETWORK_LABELS[effective].toLowerCase()}`
: "Indisponible"
}
/>
<StatusLine
label="Runtime"
value={
runtimeLocked
? "Runtime verrouillé"
: runtimeUnobserved
? "Aucun runtime actif"
: runtimeReadOnly
? "Runtime en lecture seule"
: "Runtime modifiable"
}
/>
<StatusLine
label="Control"
value={readOnly ? "Lecture seule" : "Modifiable"}
/>
</div>
{readOnly && (
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{resolved?.control.reason ?? "Cette configuration réseau est en lecture seule."}
</p>
)}
{!readOnly && runtimeUnobserved && (
<p className="rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-xs text-primary">
Aucun runtime actif. La configuration sera appliquée au prochain lancement.
</p>
)}
{!readOnly && runtimeLocked && (
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{runtimeReason ?? "Le runtime actif expose un état réseau en lecture seule."}
</p>
)}
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Réseau</span>
<select
aria-label={`${title} network`}
value={local}
disabled={busy || readOnly}
onChange={(e) => setLocal(e.target.value as NetworkPolicy | "")}
className={cn(
"h-9 rounded-md border border-border bg-raised px-3 text-sm text-content",
"outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<option value="">Non configuré</option>
<option value="allow">Réseau autorisé</option>
<option value="deny">Réseau interdit</option>
<option value="ask">Demande d&apos;autorisation</option>
</select>
</label>
{inherited?.network && !source?.network && (
<p className="text-xs text-muted">
Hérite du projet : Réseau {NETWORK_LABELS[inherited.network].toLowerCase()}.
</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">
<Button
size="sm"
variant="ghost"
aria-label="Clear network"
disabled={
busy ||
readOnly ||
(!source?.network &&
!(showCrossProjectContactable && source?.crossProjectContactable))
}
onClick={() => onSave(null)}
>
Clear
</Button>
<Button
size="sm"
variant="primary"
aria-label="Save network"
disabled={busy || readOnly || !changed}
loading={busy && changed}
onClick={() =>
onSave(
systemPermissionsFromDraft(
local,
showCrossProjectContactable && localCrossProjectContactable,
),
)
}
>
Save
</Button>
</div>
</div>
</section>
);
}
function StatusLine({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border/70 bg-raised px-3 py-2">
<span className="block text-[11px] font-medium uppercase text-faint">
{label}
</span>
<span className="text-xs text-content">{value}</span>
</div>
);
}
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<PolicyDraft>(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<PolicyDraft, "fallback">,
choice: CapabilityChoice,
) {
setLocal((prev) => ({ ...prev, [capability]: choice }));
}
return (
<section className="rounded-md border border-border bg-surface">
<header className="flex items-start justify-between gap-3 border-b border-border px-3 py-2.5">
<div className="min-w-0">
<h4 className="truncate text-sm font-semibold text-content">{title}</h4>
<p className="text-xs text-muted">
{sourcePolicy ? `${sourcePolicy.rules.length} rules` : "Not configured"}
</p>
</div>
{custom && (
<span className="shrink-0 rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
Advanced
</span>
)}
</header>
<div className="flex flex-col gap-3 p-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Fallback</span>
<select
aria-label={`${title} fallback`}
value={local.fallback}
disabled={busy}
onChange={(e) =>
setLocal((prev) => ({
...prev,
fallback: e.target.value as PermissionPosture,
}))
}
className={cn(
"h-9 rounded-md border border-border bg-raised px-3 text-sm text-content",
"outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<option value="ask">Ask</option>
<option value="allow">Allow</option>
<option value="deny">Deny</option>
</select>
</label>
<div className="grid grid-cols-1 gap-2">
{CAPABILITY_ROWS.map((row) => (
<div
key={row.key}
className="grid grid-cols-[4.5rem_1fr] items-center gap-2"
>
<span className="text-xs font-medium text-muted">{row.label}</span>
<SegmentedChoice
value={local[row.key]}
disabled={busy}
label={row.label}
onChange={(choice) => setCapability(row.key, choice)}
/>
</div>
))}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
<Button
size="sm"
variant="ghost"
disabled={busy || !canClear}
onClick={onClear}
>
Clear
</Button>
<Button
size="sm"
variant="primary"
disabled={busy || !changed}
loading={busy && changed}
onClick={() => onSave(local)}
>
Save
</Button>
</div>
</div>
</section>
);
}
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 (
<div
role="group"
aria-label={`${label} permission`}
className="grid grid-cols-3 overflow-hidden rounded-md border border-border"
>
{choices.map((choice) => (
<button
key={choice.value}
type="button"
disabled={disabled}
aria-pressed={value === choice.value}
onClick={() => onChange(choice.value)}
className={cn(
"h-8 min-w-0 border-r border-border px-2 text-xs font-medium last:border-r-0",
"transition-colors disabled:cursor-not-allowed disabled:opacity-50",
value === choice.value
? choice.value === "deny"
? "bg-danger/20 text-danger"
: choice.value === "allow"
? "bg-success/20 text-success"
: "bg-raised text-content"
: "bg-surface text-muted hover:bg-raised hover:text-content",
)}
>
{choice.label}
</button>
))}
</div>
);
}