636 lines
20 KiB
TypeScript
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'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>
|
|
);
|
|
}
|