feat(permissions): expose network permission state (#103)
This commit is contained in:
@ -1,7 +1,13 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Button, Panel, Spinner, cn } from "@/shared";
|
||||
import type { PermissionSet, PermissionPosture } from "@/domain";
|
||||
import type {
|
||||
NetworkPolicy,
|
||||
PermissionSet,
|
||||
PermissionPosture,
|
||||
ResolvedAgentSystemPermissions,
|
||||
SystemPermissionSet,
|
||||
} from "@/domain";
|
||||
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
|
||||
import {
|
||||
type CapabilityChoice,
|
||||
@ -42,6 +48,12 @@ const POSTURE_LABELS: Record<PermissionPosture, string> = {
|
||||
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" });
|
||||
@ -51,6 +63,16 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
? 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);
|
||||
@ -75,6 +97,14 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
@ -202,6 +232,24 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
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
|
||||
@ -267,6 +315,138 @@ function PolicyCard({
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user