Add hierarchical View policy administration

This commit is contained in:
2026-08-04 08:21:49 +02:00
parent 86c95f85bb
commit fc6d333a64
7 changed files with 682 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type ViewPolicyScope = "system" | "tenant" | "group" | "user";
export type ViewPolicyItem = {
allow_view?: boolean;
allow_select?: boolean;
allow_assign?: boolean;
allow_edit?: boolean;
allow_derive?: boolean;
allow_workflow_activate?: boolean;
allowed_view_ids?: string[];
visible_surface_ids?: string[];
};
export type EffectiveViewPolicy = {
allow_view: boolean;
allow_select: boolean;
allow_assign: boolean;
allow_edit: boolean;
allow_derive: boolean;
allow_workflow_activate: boolean;
allowed_view_ids?: string[] | null;
visible_surface_ids?: string[] | null;
};
export type ViewPolicyScopeResponse = {
scope_type: ViewPolicyScope;
scope_id?: string | null;
id?: string | null;
revision?: number | null;
policy: ViewPolicyItem;
effective_policy: EffectiveViewPolicy;
parent_policy: EffectiveViewPolicy;
source_path: Array<{
scope_type: string;
scope_id?: string | null;
source_id?: string | null;
fields?: string[];
}>;
diagnostics: Array<Record<string, unknown>>;
};
export type ViewPolicyReferenceData = {
views: Array<{ id: string; name: string; scope_type?: string }>;
surfaces: Array<{ id: string; label: string; module_id: string; kind: string }>;
};
export function fetchViewPolicy(
settings: ApiSettings,
scope: ViewPolicyScope,
scopeId?: string | null
): Promise<ViewPolicyScopeResponse> {
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
scope_id: scopeId || undefined
}));
}
export function updateViewPolicy(
settings: ApiSettings,
scope: ViewPolicyScope,
scopeId: string | null | undefined,
policy: ViewPolicyItem
): Promise<ViewPolicyScopeResponse> {
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
scope_id: scopeId || undefined
}), {
method: "PUT",
body: JSON.stringify({ policy })
});
}
export function deleteViewPolicy(
settings: ApiSettings,
scope: ViewPolicyScope,
scopeId?: string | null
): Promise<ViewPolicyScopeResponse> {
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
scope_id: scopeId || undefined
}), { method: "DELETE" });
}
export async function fetchViewPolicyReferences(settings: ApiSettings): Promise<ViewPolicyReferenceData> {
const [definitionResult, surfaceResult] = await Promise.allSettled([
apiFetch<{ definitions: Array<{ id: string; name: string; scope_type?: string }> }>(
settings,
apiPath("/api/v1/views/definitions", { scope_type: "tenant", include_inherited: true })
),
apiFetch<{ surfaces: Array<{ id: string; label: string; module_id: string; kind: string }> }>(
settings,
"/api/v1/views/surfaces"
)
]);
return {
views: definitionResult.status === "fulfilled" ? definitionResult.value.definitions : [],
surfaces: surfaceResult.status === "fulfilled" ? surfaceResult.value.surfaces : []
};
}
@@ -0,0 +1,440 @@
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, Trash2, Undo2 } from "lucide-react";
import { fetchGroupsDelta, fetchUsersDelta } from "../../api/adminTargets";
import {
deleteViewPolicy,
fetchViewPolicy,
fetchViewPolicyReferences,
updateViewPolicy,
type EffectiveViewPolicy,
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 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)))
);
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));
} 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));
setError("");
setSuccess("");
}
async function save(): Promise<boolean> {
if (!draft || !state || !dirty) return true;
setBusy(true);
setError("");
setSuccess("");
try {
const loaded = await updateViewPolicy(settings, scopeType, targetId || null, buildPolicy(draft));
setState(loaded);
setDraft(draftFromPolicy(loaded.policy));
setSuccess("View policy saved.");
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
}
async function resetPolicy() {
setBusy(true);
setError("");
setSuccess("");
try {
const loaded = await deleteViewPolicy(settings, scopeType, targetId || null);
setState(loaded);
setDraft(draftFromPolicy(loaded.policy));
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={() => 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>
<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">
<dl className="admin-details-grid">
<div><dt>Local override</dt><dd><StatusBadge status={state.id ? "info" : "neutral"} label={state.id ? `Revision ${state.revision}` : "Inherited"} /></dd></div>
<div><dt>Allowed Views</dt><dd>{ceilingLabel(state.effective_policy.allowed_view_ids)}</dd></div>
<div><dt>Visible surfaces</dt><dd>{ceilingLabel(state.effective_policy.visible_surface_ids)}</dd></div>
<div><dt>Policy path</dt><dd>{state.source_path.length ? state.source_path.map((step) => `${step.scope_type}${step.scope_id ? `:${step.scope_id}` : ""}`).join(" -> ") : "Platform defaults"}</dd></div>
</dl>
</Card>
</>
)}
</AdminPageLayout>
<ConfirmDialog
open={confirmReset}
title="Use inherited View policy?"
message="The local override will be removed. All restrictions inherited from higher scopes continue to apply."
confirmLabel="Use inherited policy"
busy={busy}
onConfirm={() => void resetPolicy()}
onCancel={() => setConfirmReset(false)}
/>
</>
);
}
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 };
}
+1
View File
@@ -1,4 +1,5 @@
export { default } from "./module";
export { default as ViewPoliciesPanel } from "./features/policy/ViewPoliciesPanel";
export * from "./module";
export * from "./api/adminTargets";
export { default as RetentionPoliciesPanel } from "./features/policy/RetentionPoliciesPanel";
+65
View File
@@ -2,9 +2,70 @@ import { createElement, lazy } from "react";
import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
const RetentionPoliciesPanel = lazy(() => import("./features/policy/RetentionPoliciesPanel"));
const ViewPoliciesPanel = lazy(() => import("./features/policy/ViewPoliciesPanel"));
const policyAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "system-view-policy",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.system-view-policy",
label: "View policy",
group: "SYSTEM",
order: 70,
allOf: ["system:settings:read"],
render: ({ settings, auth }) => createElement(ViewPoliciesPanel, {
settings,
scopeType: "system",
canWrite: hasScope(auth, "system:settings:write")
})
},
{
id: "tenant-view-policy",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.tenant-view-policy",
label: "View policy",
group: "TENANT",
order: 70,
allOf: ["admin:policies:read"],
render: ({ settings, auth }) => createElement(ViewPoliciesPanel, {
settings,
scopeType: "tenant",
canWrite: hasScope(auth, "admin:policies:write")
})
},
{
id: "group-view-policy",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.group-view-policy",
label: "View policy",
group: "GROUP",
order: 20,
allOf: ["admin:policies:read", "admin:groups:read"],
render: ({ settings, auth }) => createElement(ViewPoliciesPanel, {
settings,
scopeType: "group",
canWrite: hasScope(auth, "admin:policies:write")
})
},
{
id: "user-view-policy",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.user-view-policy",
label: "View policy",
group: "USER",
order: 20,
allOf: ["admin:policies:read", "admin:users:read"],
render: ({ settings, auth }) => createElement(ViewPoliciesPanel, {
settings,
scopeType: "user",
canWrite: hasScope(auth, "admin:policies:write")
})
},
{
id: "system-retention",
moduleId: "policy",
@@ -74,6 +135,10 @@ export const policyModule: PlatformWebModule = {
version: "0.1.9",
dependencies: ["access", "admin"],
viewSurfaces: [
{ id: "policy.admin.system-view-policy", moduleId: "policy", kind: "section", label: "System View policy", order: 70 },
{ id: "policy.admin.tenant-view-policy", moduleId: "policy", kind: "section", label: "Tenant View policy", order: 70 },
{ id: "policy.admin.group-view-policy", moduleId: "policy", kind: "section", label: "Group View policy", order: 70 },
{ id: "policy.admin.user-view-policy", moduleId: "policy", kind: "section", label: "User View policy", order: 70 },
{ id: "policy.admin.system-retention", moduleId: "policy", kind: "section", label: "System retention", order: 80 },
{ id: "policy.admin.tenant-retention", moduleId: "policy", kind: "section", label: "Tenant retention", order: 80 },
{ id: "policy.admin.group-retention", moduleId: "policy", kind: "section", label: "Group retention", order: 80 },