The draft-resync useEffect fired on every new `draft` object (initial load, refresh, another save), even when its content matched `local`. If it flushed after the user started editing, it silently discarded the edit — flaky in tests where a passive effect can settle after a synchronous fireEvent sequence, and a real risk in production if a fetch lands mid-edit. Now it only resyncs when `local` still matches the last-adopted draft. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { Button, Panel, Spinner, cn } from "@/shared";
|
|
import type { PermissionSet, PermissionPosture } from "@/domain";
|
|
import {
|
|
type CapabilityChoice,
|
|
type PolicyDraft,
|
|
draftFromSet,
|
|
isCustomPolicy,
|
|
usePermissions,
|
|
} from "./usePermissions";
|
|
|
|
export interface PermissionsPanelProps {
|
|
projectId: string;
|
|
}
|
|
|
|
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",
|
|
};
|
|
|
|
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
|
const vm = usePermissions(projectId);
|
|
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
|
|
|
|
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 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);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Panel
|
|
title="Permissions"
|
|
actions={
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={vm.busy}
|
|
onClick={() => void vm.refresh()}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
}
|
|
className="flex flex-col"
|
|
flush
|
|
>
|
|
{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 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()}
|
|
/>
|
|
</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 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>
|
|
);
|
|
}
|