713 lines
23 KiB
TypeScript
713 lines
23 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",
|
||
};
|
||
|
||
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'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>
|
||
);
|
||
}
|