Files
govoplan-policy/webui/src/features/policy/ViewPoliciesPanel.tsx
T

565 lines
23 KiB
TypeScript

import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { useEffect, useMemo, useState } from "react";
import {
AdminPageLayout,
adminErrorMessage,
Button,
Card,
ConfirmDialog,
DocumentationHelpLink,
FormField,
ReferenceMultiSelect,
SearchableSelect,
SegmentedControl,
staticReferenceOptionProvider,
StatusBadge,
ToggleSwitch,
useUnsavedDraftGuard,
type ApiSettings,
type ReferenceOption,
type SearchableSelectOption
} from "@govoplan/core-webui";
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
} from "../../api/viewPolicies";
type Props = {
settings: ApiSettings;
scopeType: ViewPolicyScope;
canWrite: boolean;
};
type Decision = "inherit" | "allow" | "block";
type Draft = {
allow_view: Decision;
allow_select: Decision;
allow_assign: Decision;
allow_edit: Decision;
allow_derive: Decision;
allow_workflow_activate: Decision;
limitViews: boolean;
allowedViewIds: string[];
limitSurfaces: boolean;
visibleSurfaceIds: string[];
};
type Target = SearchableSelectOption;
const BOOLEAN_FIELDS: Array<{
id: keyof Pick<Draft, "allow_view" | "allow_select" | "allow_assign" | "allow_edit" | "allow_derive" | "allow_workflow_activate">;
label: string;
description: string;
}> = [
{ id: "allow_view", label: "View", description: "Allow affected accounts to apply and use Views." },
{ id: "allow_select", label: "Select", description: "Allow affected accounts to choose among available Views." },
{ id: "allow_assign", label: "Assign", description: "Allow administrators at this scope to assign Views." },
{ id: "allow_edit", label: "Edit", description: "Allow View definitions to be edited at this scope." },
{ id: "allow_derive", label: "Derive", description: "Allow a new View to derive from an inherited definition." },
{ id: "allow_workflow_activate", label: "Workflow activation", description: "Allow workflows to activate a View for affected accounts." }
];
const DOCUMENTATION = {
contextId: "policy.view-governance",
documentationType: "admin" as const
};
const DECISION_OPTIONS = [
{ id: "inherit" as const, label: "Inherit" },
{ id: "allow" as const, label: "Allow" },
{ id: "block" as const, label: "Block" }
];
export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Props) {
const [targets, setTargets] = useState<Target[]>([]);
const [targetId, setTargetId] = useState("");
const [state, setState] = useState<ViewPolicyScopeResponse | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [viewOptions, setViewOptions] = useState<ReferenceOption[]>([]);
const [surfaceOptions, setSurfaceOptions] = useState<ReferenceOption[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
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;
const parentSurfaceIds = state?.parent_policy.visible_surface_ids;
const viewProvider = useMemo(
() => staticReferenceOptionProvider(optionsWithinCeiling(viewOptions, parentViewIds)),
[parentViewIds, viewOptions]
);
const surfaceProvider = useMemo(
() => staticReferenceOptionProvider(optionsWithinCeiling(surfaceOptions, parentSurfaceIds)),
[parentSurfaceIds, surfaceOptions]
);
const dirty = Boolean(
state
&& draft
&& 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 });
useEffect(() => {
void initialize();
}, [scopeType, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function initialize() {
setLoading(true);
setError("");
setSuccess("");
try {
const [references, loadedTargets] = await Promise.all([
fetchViewPolicyReferences(settings),
loadTargets(settings, scopeType)
]);
setViewOptions(references.views.map((view) => ({
value: view.id,
label: view.name,
description: view.scope_type ? `${view.scope_type} View` : "View",
searchText: `${view.name} ${view.id}`
})));
setSurfaceOptions(references.surfaces.map((surface) => ({
value: surface.id,
label: surface.label || surface.id,
description: `${surface.module_id} - ${surface.kind}`,
searchText: `${surface.id} ${surface.module_id} ${surface.label}`
})));
setTargets(loadedTargets);
const nextTarget = needsTarget
? (loadedTargets.some((target) => target.value === targetId) ? targetId : loadedTargets[0]?.value ?? "")
: "";
setTargetId(nextTarget);
if (!needsTarget || nextTarget) await load(nextTarget, false);
else {
setState(null);
setDraft(null);
}
} catch (err) {
setError(adminErrorMessage(err));
setState(null);
setDraft(null);
} finally {
setLoading(false);
}
}
async function load(nextTargetId = targetId, manageLoading = true) {
if (needsTarget && !nextTargetId) return;
if (manageLoading) setLoading(true);
setError("");
setSuccess("");
try {
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 {
if (manageLoading) setLoading(false);
}
}
async function selectTarget(nextTargetId: string) {
if (!nextTargetId || nextTargetId === targetId) return;
setTargetId(nextTargetId);
await load(nextTargetId);
}
function discard() {
if (state) setDraft(draftFromPolicy(state.policy));
setImpactPreview(null);
setPreviewDraftKey("");
setResetImpactPreview(null);
setError("");
setSuccess("");
}
async function previewImpact() {
if (!draft || !state || !dirty) return;
setBusy(true);
setError("");
setSuccess("");
try {
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) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
}
async function prepareResetPolicy() {
if (!state?.id) return;
setBusy(true);
setError("");
setSuccess("");
try {
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) {
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
const scopeLabel = scopeType === "system" ? "System" : scopeType === "tenant" ? "Tenant" : scopeType === "group" ? "Group" : "User";
return (
<>
<AdminPageLayout
title={`${scopeLabel} View policy`}
description="Control which Views and surfaces are available, forced by assignment, selectable, editable, derivable, or workflow-activatable at this scope."
loading={loading}
error={error}
success={success}
actions={
<>
<Button title="Reload saved View policy" aria-label="Reload saved View policy" onClick={() => void load()} disabled={loading || busy || (needsTarget && !targetId)}>
<RefreshCw size={16} />
</Button>
<Button onClick={discard} disabled={!dirty || busy}><Undo2 size={16} /> Discard</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" />
</>
}
>
{needsTarget && (
<FormField label={scopeType === "group" ? "Group" : "User"} documentation={DOCUMENTATION}>
<SearchableSelect
value={targetId}
options={targets}
onChange={(value) => void selectTarget(value)}
placeholder={`Select ${scopeType}`}
searchPlaceholder={`Search ${scopeType}s...`}
disabled={loading || busy || targets.length === 0}
/>
</FormField>
)}
{state && draft && (
<>
<Card title="Actions">
<div className="settings-list">
{BOOLEAN_FIELDS.map((field) => (
<div className="admin-tenant-assignment-row" key={field.id}>
<span>
<strong>{field.label}</strong>
<small>{field.description} Effective: {effectiveLabel(state.effective_policy, field.id)}.</small>
</span>
<SegmentedControl
options={DECISION_OPTIONS.map((option) => (
option.id === "allow" && state.parent_policy[field.id] === false
? {
...option,
disabled: true,
title: "A parent policy blocks this action."
}
: option
))}
value={draft[field.id]}
onChange={(value) => setDraft({ ...draft, [field.id]: value })}
role="group"
size="equal"
width="fill"
disabled={!canWrite || busy}
ariaLabel={`${field.label} policy`}
/>
</div>
))}
</div>
</Card>
<Card title="Availability ceilings">
<div className="settings-list">
<ToggleSwitch
label="Limit available Views"
checked={draft.limitViews}
onChange={(checked) => setDraft({ ...draft, limitViews: checked, allowedViewIds: checked ? draft.allowedViewIds : [] })}
disabled={!canWrite || busy}
help="A lower scope may narrow this list but cannot add Views excluded by an ancestor."
/>
{draft.limitViews && (
<FormField label="Available Views" documentation={DOCUMENTATION}>
<ReferenceMultiSelect
values={draft.allowedViewIds}
onChange={(values) => setDraft({ ...draft, allowedViewIds: values })}
provider={viewProvider}
createCustomOption={(value) => customReference(value, parentViewIds)}
placeholder="Add View"
searchPlaceholder="Search Views or enter an ID..."
disabled={!canWrite || busy}
/>
</FormField>
)}
<ToggleSwitch
label="Limit visible surfaces"
checked={draft.limitSurfaces}
onChange={(checked) => setDraft({ ...draft, limitSurfaces: checked, visibleSurfaceIds: checked ? draft.visibleSurfaceIds : [] })}
disabled={!canWrite || busy}
help="The effective View may only expose surfaces retained by every ancestor policy."
/>
{draft.limitSurfaces && (
<FormField label="Visible surfaces" documentation={DOCUMENTATION}>
<ReferenceMultiSelect
values={draft.visibleSurfaceIds}
onChange={(values) => setDraft({ ...draft, visibleSurfaceIds: values })}
provider={surfaceProvider}
createCustomOption={(value) => customReference(value, parentSurfaceIds)}
placeholder="Add surface"
searchPlaceholder="Search surfaces or enter an ID..."
disabled={!canWrite || busy}
/>
</FormField>
)}
</div>
</Card>
<Card title="Effective policy and provenance">
<DescriptionList>
<DescriptionItem term={<>Local override</>}><StatusBadge status={state.id ? "info" : "neutral"} label={state.id ? `Revision ${state.revision}` : "Inherited"} /></DescriptionItem>
<DescriptionItem term={<>Allowed Views</>}>{ceilingLabel(state.effective_policy.allowed_view_ids)}</DescriptionItem>
<DescriptionItem term={<>Visible surfaces</>}>{ceilingLabel(state.effective_policy.visible_surface_ids)}</DescriptionItem>
<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>
<ConfirmDialog
open={confirmReset}
title="Use inherited View policy?"
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);
setResetImpactPreview(null);
}}
/>
</>
);
}
async function loadTargets(settings: ApiSettings, scope: ViewPolicyScope): Promise<Target[]> {
if (scope === "user") {
const response = await fetchUsersDelta(settings, { limit: 200 });
return response.users.map((user) => ({
value: user.id,
label: user.display_name || user.email,
description: user.display_name ? user.email : undefined,
searchText: `${user.display_name ?? ""} ${user.email}`
}));
}
if (scope === "group") {
const response = await fetchGroupsDelta(settings, { limit: 200 });
return response.groups.map((group) => ({
value: group.id,
label: group.name,
description: group.slug,
searchText: `${group.name} ${group.slug}`
}));
}
return [];
}
function draftFromPolicy(policy: ViewPolicyItem): Draft {
return {
allow_view: decision(policy.allow_view),
allow_select: decision(policy.allow_select),
allow_assign: decision(policy.allow_assign),
allow_edit: decision(policy.allow_edit),
allow_derive: decision(policy.allow_derive),
allow_workflow_activate: decision(policy.allow_workflow_activate),
limitViews: Array.isArray(policy.allowed_view_ids),
allowedViewIds: [...(policy.allowed_view_ids ?? [])].sort(),
limitSurfaces: Array.isArray(policy.visible_surface_ids),
visibleSurfaceIds: [...(policy.visible_surface_ids ?? [])].sort()
};
}
function buildPolicy(draft: Draft): ViewPolicyItem {
const policy: ViewPolicyItem = {};
for (const field of BOOLEAN_FIELDS) {
const value = draft[field.id];
if (value !== "inherit") policy[field.id] = value === "allow";
}
if (draft.limitViews) policy.allowed_view_ids = [...new Set(draft.allowedViewIds)].sort();
if (draft.limitSurfaces) policy.visible_surface_ids = [...new Set(draft.visibleSurfaceIds)].sort();
return policy;
}
function decision(value: boolean | undefined): Decision {
return value === undefined ? "inherit" : value ? "allow" : "block";
}
function stablePolicy(policy: ViewPolicyItem): string {
return JSON.stringify(Object.fromEntries(Object.entries(policy).sort(([left], [right]) => left.localeCompare(right))));
}
function effectiveLabel(policy: EffectiveViewPolicy, field: keyof EffectiveViewPolicy): string {
return policy[field] === true ? "allowed" : "blocked";
}
function ceilingLabel(values: string[] | null | undefined): string {
return values == null ? "Unrestricted by ID" : values.length ? `${values.length} entries` : "None";
}
function optionsWithinCeiling(
options: ReferenceOption[],
ceiling: string[] | null | undefined
): ReferenceOption[] {
if (!Array.isArray(ceiling)) return options;
const allowed = new Set(ceiling);
return options.filter((option) => allowed.has(option.value));
}
function customReference(
value: string,
ceiling: string[] | null | undefined
): ReferenceOption | null {
const clean = value.trim();
if (!clean || (Array.isArray(ceiling) && !ceiling.includes(clean))) return null;
return { value: clean, label: clean, description: "Unresolved identifier", custom: true };
}