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

636 lines
20 KiB
TypeScript

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",
};
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
}
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;
busy: boolean;
onSave: (permissions: SystemPermissionSet | null) => void;
}
function NetworkPermissionEditor({
title,
source,
inherited,
resolved,
busy,
onSave,
}: NetworkPermissionEditorProps) {
const current = source?.network ?? "";
const [local, setLocal] = useState<NetworkPolicy | "">(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 (
<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 ? "Verrouillé par le runtime" : "Aucun verrou connu"}
/>
<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">
{lockReason ?? "Le runtime actif ne permet pas de modifier cette permission."}
</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>
)}
<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}
onClick={() => onSave(null)}
>
Clear
</Button>
<Button
size="sm"
variant="primary"
aria-label="Save network"
disabled={busy || readOnly || !changed}
loading={busy && changed}
onClick={() => onSave(local ? { network: local } : null)}
>
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>
);
}