feat: preview governed policy impact
This commit is contained in:
@@ -19,14 +19,16 @@ import {
|
||||
type ReferenceOption,
|
||||
type SearchableSelectOption
|
||||
} from "@govoplan/core-webui";
|
||||
import { RefreshCw, Save, Trash2, Undo2 } from "lucide-react";
|
||||
import { RefreshCw, Save, ScanSearch, Trash2, Undo2 } from "lucide-react";
|
||||
import { fetchGroupsDelta, fetchUsersDelta } from "../../api/adminTargets";
|
||||
import {
|
||||
deleteViewPolicy,
|
||||
fetchViewPolicy,
|
||||
fetchViewPolicyReferences,
|
||||
previewViewPolicyImpact,
|
||||
updateViewPolicy,
|
||||
type EffectiveViewPolicy,
|
||||
type PolicyImpactPreviewResponse,
|
||||
type ViewPolicyItem,
|
||||
type ViewPolicyScope,
|
||||
type ViewPolicyScopeResponse
|
||||
@@ -91,6 +93,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [impactPreview, setImpactPreview] = useState<PolicyImpactPreviewResponse | null>(null);
|
||||
const [previewDraftKey, setPreviewDraftKey] = useState("");
|
||||
const [resetImpactPreview, setResetImpactPreview] = useState<PolicyImpactPreviewResponse | null>(null);
|
||||
|
||||
const needsTarget = scopeType === "group" || scopeType === "user";
|
||||
const parentViewIds = state?.parent_policy.allowed_view_ids;
|
||||
@@ -109,6 +114,8 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
&& stablePolicy(buildPolicy(draft))
|
||||
!== stablePolicy(buildPolicy(draftFromPolicy(state.policy)))
|
||||
);
|
||||
const draftKey = draft ? stablePolicy(buildPolicy(draft)) : "";
|
||||
const previewCurrent = Boolean(impactPreview && previewDraftKey === draftKey);
|
||||
|
||||
useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: discard });
|
||||
|
||||
@@ -165,6 +172,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
const loaded = await fetchViewPolicy(settings, scopeType, nextTargetId || null);
|
||||
setState(loaded);
|
||||
setDraft(draftFromPolicy(loaded.policy));
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setResetImpactPreview(null);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
@@ -180,19 +190,63 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
|
||||
function discard() {
|
||||
if (state) setDraft(draftFromPolicy(state.policy));
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setResetImpactPreview(null);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (!draft || !state || !dirty) return true;
|
||||
async function previewImpact() {
|
||||
if (!draft || !state || !dirty) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = await updateViewPolicy(settings, scopeType, targetId || null, buildPolicy(draft));
|
||||
const policy = buildPolicy(draft);
|
||||
const preview = await previewViewPolicyImpact(
|
||||
settings,
|
||||
scopeType,
|
||||
targetId || null,
|
||||
policy,
|
||||
{
|
||||
viewIds: viewOptions.map((option) => option.value),
|
||||
surfaceIds: surfaceOptions.map((option) => option.value)
|
||||
}
|
||||
);
|
||||
setImpactPreview(preview);
|
||||
setPreviewDraftKey(stablePolicy(policy));
|
||||
setSuccess("Policy impact preview completed without saving the draft.");
|
||||
} catch (err) {
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (!draft || !state || !dirty) return true;
|
||||
if (!previewCurrent) {
|
||||
setError("Preview the current policy draft before saving it.");
|
||||
return false;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = await updateViewPolicy(
|
||||
settings,
|
||||
scopeType,
|
||||
targetId || null,
|
||||
buildPolicy(draft),
|
||||
impactPreview
|
||||
);
|
||||
setState(loaded);
|
||||
setDraft(draftFromPolicy(loaded.policy));
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setSuccess("View policy saved.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -203,14 +257,50 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPolicy() {
|
||||
async function prepareResetPolicy() {
|
||||
if (!state?.id) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = await deleteViewPolicy(settings, scopeType, targetId || null);
|
||||
const preview = await previewViewPolicyImpact(
|
||||
settings,
|
||||
scopeType,
|
||||
targetId || null,
|
||||
{},
|
||||
{
|
||||
viewIds: viewOptions.map((option) => option.value),
|
||||
surfaceIds: surfaceOptions.map((option) => option.value)
|
||||
}
|
||||
);
|
||||
setResetImpactPreview(preview);
|
||||
setConfirmReset(true);
|
||||
setSuccess("Inherited-policy impact preview completed without removing the override.");
|
||||
} catch (err) {
|
||||
setResetImpactPreview(null);
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPolicy() {
|
||||
if (!resetImpactPreview) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = await deleteViewPolicy(
|
||||
settings,
|
||||
scopeType,
|
||||
targetId || null,
|
||||
resetImpactPreview
|
||||
);
|
||||
setState(loaded);
|
||||
setDraft(draftFromPolicy(loaded.policy));
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setResetImpactPreview(null);
|
||||
setSuccess("Local View policy removed; inherited policy now applies.");
|
||||
setConfirmReset(false);
|
||||
} catch (err) {
|
||||
@@ -236,8 +326,9 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
<Button onClick={discard} disabled={!dirty || busy}><Undo2 size={16} /> Discard</Button>
|
||||
<Button onClick={() => setConfirmReset(true)} disabled={!canWrite || !state?.id || busy}><Trash2 size={16} /> Use inherited</Button>
|
||||
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}><Save size={16} /> {busy ? "Saving..." : "Save"}</Button>
|
||||
<Button onClick={() => void prepareResetPolicy()} disabled={!canWrite || !state?.id || busy}><Trash2 size={16} /> Use inherited</Button>
|
||||
<Button helpContextId="policy.impact-preview.action.preview" onClick={() => void previewImpact()} disabled={!canWrite || !dirty || busy}><ScanSearch size={16} /> Preview impact</Button>
|
||||
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy || !previewCurrent} disabledReason={dirty && !previewCurrent ? "Preview the current draft before saving." : undefined}><Save size={16} /> {busy ? "Working..." : "Save"}</Button>
|
||||
<DocumentationHelpLink reference={DOCUMENTATION} label="Open View policy documentation" />
|
||||
</>
|
||||
}
|
||||
@@ -341,6 +432,35 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
<DescriptionItem term={<>Policy path</>}>{state.source_path.length ? state.source_path.map((step) => `${step.scope_type}${step.scope_id ? `:${step.scope_id}` : ""}`).join(" -> ") : "Platform defaults"}</DescriptionItem>
|
||||
</DescriptionList>
|
||||
</Card>
|
||||
|
||||
{impactPreview && (
|
||||
<Card title="Policy impact preview">
|
||||
<DescriptionList>
|
||||
<DescriptionItem term={<>Preview</>}><code>{impactPreview.preview_id}</code></DescriptionItem>
|
||||
<DescriptionItem term={<>Draft state</>}><StatusBadge status={previewCurrent ? "success" : "warning"} label={previewCurrent ? "Current" : "Outdated"} /></DescriptionItem>
|
||||
<DescriptionItem term={<>Newly allowed</>}>{impactPreview.counts.newly_allowed}</DescriptionItem>
|
||||
<DescriptionItem term={<>Newly denied</>}>{impactPreview.counts.newly_denied}</DescriptionItem>
|
||||
<DescriptionItem term={<>Unchanged</>}>{impactPreview.counts.unchanged}</DescriptionItem>
|
||||
<DescriptionItem term={<>Indeterminate</>}>{impactPreview.counts.indeterminate}</DescriptionItem>
|
||||
<DescriptionItem term={<>Risk</>}><StatusBadge status={impactPreview.high_impact ? "warning" : "neutral"} label={impactPreview.high_impact ? "High impact - recent login required" : "Bounded change"} /></DescriptionItem>
|
||||
<DescriptionItem term={<>Coverage</>}>{impactPreview.populations.map((population) => `${population.provider_id}: ${population.state} (${population.returned}${population.total_available == null ? "" : `/${population.total_available}`})${population.explanation ? ` - ${population.explanation}` : ""}`).join("; ")}</DescriptionItem>
|
||||
{impactPreview.details_hidden && <DescriptionItem term={<>Details</>}>{impactPreview.details_explanation || "Subject details are hidden by policy."}</DescriptionItem>}
|
||||
</DescriptionList>
|
||||
{impactPreview.effects.length > 0 && (
|
||||
<div help-context-id="policy.impact-preview.results">
|
||||
<h4>Changed subjects</h4>
|
||||
<ul>
|
||||
{impactPreview.effects.filter((effect) => effect.category !== "unchanged").slice(0, 20).map((effect) => (
|
||||
<li key={`${effect.subject.module_id}:${effect.subject.resource_type}:${effect.subject.resource_id}:${effect.subject.action}`}>
|
||||
<strong>{effect.category.replaceAll("_", " ")}</strong>: {effect.subject.label || effect.subject.resource_id} - {effect.subject.action} ({effect.rule})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{impactPreview.effects.filter((effect) => effect.category !== "unchanged").length > 20 && <p>Only the first 20 changed subjects are shown; aggregate counts cover the complete returned population.</p>}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AdminPageLayout>
|
||||
@@ -348,11 +468,14 @@ export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Pro
|
||||
<ConfirmDialog
|
||||
open={confirmReset}
|
||||
title="Use inherited View policy?"
|
||||
message="The local override will be removed. All restrictions inherited from higher scopes continue to apply."
|
||||
message={resetImpactPreview ? `The local override will be removed. The bounded preview found ${resetImpactPreview.counts.newly_allowed} newly allowed, ${resetImpactPreview.counts.newly_denied} newly denied, and ${resetImpactPreview.counts.indeterminate} indeterminate effects. All restrictions inherited from higher scopes continue to apply.` : "Previewing inherited-policy impact..."}
|
||||
confirmLabel="Use inherited policy"
|
||||
busy={busy}
|
||||
onConfirm={() => void resetPolicy()}
|
||||
onCancel={() => setConfirmReset(false)}
|
||||
onCancel={() => {
|
||||
setConfirmReset(false);
|
||||
setResetImpactPreview(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user