Release v0.1.7

This commit is contained in:
2026-07-11 02:34:58 +02:00
parent 423720229b
commit 97f05a083f
9 changed files with 429 additions and 8 deletions

View File

@@ -1,10 +1,12 @@
# GovOPlaN Policy
`govoplan-policy` owns policy and retention API route contributions during the
GovOPlaN module split.
`govoplan-policy` owns policy and retention API route contributions and the
retention administration WebUI sections during the GovOPlaN module split.
The current package delegates to the legacy access administration
implementation while route ownership is separated before model migration.
The `@govoplan/policy-webui` package contributes system, tenant, group, and
user retention sections through the shared `admin.sections` UI capability. The
admin shell does not render retention policy panels unless this module is
installed and enabled.
Policy decision and provenance payloads use the shared kernel DTOs documented
in [docs/POLICY_DECISION_PROVENANCE.md](docs/POLICY_DECISION_PROVENANCE.md)

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@govoplan/policy-webui",
"version": "0.1.7",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

View File

@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-policy"
version = "0.1.6"
version = "0.1.7"
description = "GovOPlaN policy platform module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.6",
"govoplan-core>=0.1.7",
]
[tool.setuptools.packages.find]

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.modules import FrontendModule, ModuleContext, ModuleManifest
def _route_factory(context: ModuleContext):
@@ -22,9 +22,13 @@ def _privacy_retention_service(context: ModuleContext) -> object:
manifest = ModuleManifest(
id="policy",
name="Policy",
version="0.1.6",
version="0.1.7",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
route_factory=_route_factory,
frontend=FrontendModule(
module_id="policy",
package_name="@govoplan/policy-webui",
),
capability_factories={
CAPABILITY_POLICY_PRIVACY_RETENTION: _privacy_retention_service,
},

27
webui/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@govoplan/policy-webui",
"version": "0.1.7",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

View File

@@ -0,0 +1,56 @@
import { apiFetch, type ApiSettings, type DeltaDeletedItem } from "@govoplan/core-webui";
export type RoleSummary = {
id: string;
slug: string;
name: string;
permissions: string[];
};
export type GroupSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
is_active: boolean;
member_count: number;
member_ids: string[];
roles: RoleSummary[];
};
export type UserAdminItem = {
id: string;
account_id: string;
tenant_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
groups: GroupSummary[];
roles: RoleSummary[];
};
type DeltaResponseFields = {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type UserListDeltaResponse = { users: UserAdminItem[] } & DeltaResponseFields;
export type GroupListDeltaResponse = { groups: GroupSummary[] } & DeltaResponseFields;
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
const params = new URLSearchParams();
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.toString();
return suffix ? `?${suffix}` : "";
}
export function fetchUsersDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<UserListDeltaResponse> {
return apiFetch(settings, `/api/v1/admin/users/delta${deltaSuffix(options)}`);
}
export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> {
return apiFetch(settings, `/api/v1/admin/groups/delta${deltaSuffix(options)}`);
}

View File

@@ -0,0 +1,226 @@
import { useEffect, useRef, useState } from "react";
import {
AdminPageLayout,
adminErrorMessage,
Button,
Card,
ConfirmDialog,
mergeDeltaRows,
RetentionPolicyScopeManager,
runRetentionPolicy,
useDeltaWatermarks,
type ApiSettings,
type DeltaDeletedItem,
type PrivacyRetentionPolicyScope,
type RetentionPolicyTargetOption,
type RetentionRunResponse
} from "@govoplan/core-webui";
import { fetchGroupsDelta, fetchUsersDelta, type GroupListDeltaResponse, type GroupSummary, type UserAdminItem, type UserListDeltaResponse } from "../../api/adminTargets";
type Props = {
settings: ApiSettings;
scopeType: Extract<PrivacyRetentionPolicyScope, "system" | "tenant" | "user" | "group">;
canWrite: boolean;
};
type DeltaResponse = {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
system: {
title: "System retention",
description: "Instance-wide privacy retention policy and lower-level override limits.",
policyTitle: "System retention policy",
policyDescription: "Set concrete system retention values. Override switches define whether lower levels may narrow each value."
},
tenant: {
title: "Tenant retention",
description: "Tenant-level privacy and retention limits for the active tenant.",
policyTitle: "Tenant retention policy",
policyDescription: "Tenant limits may only narrow the system policy where the parent policy allows overrides."
},
user: {
title: "User retention",
description: "User-scoped retention limits for campaigns owned by a user.",
targetLabel: "User",
policyTitle: "User retention policy",
policyDescription: "User limits may only narrow inherited system and tenant policy."
},
group: {
title: "Group retention",
description: "Group-scoped retention limits for campaigns owned by a group.",
targetLabel: "Group",
policyTitle: "Group retention policy",
policyDescription: "Group limits may only narrow inherited system and tenant policy."
}
};
export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }: Props) {
const [targets, setTargets] = useState<RetentionPolicyTargetOption[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group");
const [targetError, setTargetError] = useState("");
const [busy, setBusy] = useState(false);
const [success, setSuccess] = useState("");
const [runError, setRunError] = useState("");
const [confirmRetentionRun, setConfirmRetentionRun] = useState(false);
const [retentionResult, setRetentionResult] = useState<RetentionRunResponse | null>(null);
useEffect(() => {
usersRef.current = [];
groupsRef.current = [];
resetDeltaWatermark();
void loadTargets();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, resetDeltaWatermark]);
async function loadTargets() {
if (scopeType !== "user" && scopeType !== "group") {
setTargets([]);
setLoadingTargets(false);
setTargetError("");
return;
}
setLoadingTargets(true);
setTargetError("");
try {
if (scopeType === "user") {
const users = await loadDeltaRows<UserAdminItem, UserListDeltaResponse>(
usersRef.current,
"policy:retention-users",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchUsersDelta(settings, { since }),
(response) => response.users,
(user) => user.id,
"access_user",
sortUsers
);
usersRef.current = users;
setTargets(users.map((user) => ({
id: user.id,
label: user.display_name || user.email,
secondary: user.display_name ? user.email : null
})));
} else {
const groups = await loadDeltaRows<GroupSummary, GroupListDeltaResponse>(
groupsRef.current,
"policy:retention-groups",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchGroupsDelta(settings, { since }),
(response) => response.groups,
(group) => group.id,
"access_group",
sortGroups
);
groupsRef.current = groups;
setTargets(groups.map((group) => ({
id: group.id,
label: group.name,
secondary: group.slug
})));
}
} catch (err) {
setTargets([]);
setTargetError(adminErrorMessage(err));
} finally {
setLoadingTargets(false);
}
}
async function runRetention(dryRun: boolean) {
setBusy(true);
setRunError("");
setSuccess("");
try {
const response = await runRetentionPolicy(settings, dryRun);
setRetentionResult(response);
setSuccess(dryRun ? "Retention dry run completed." : "Retention policy applied.");
setConfirmRetentionRun(false);
} catch (err) {
setRunError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
const labels = copy[scopeType];
return (
<>
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError || runError} success={success}>
<RetentionPolicyScopeManager
settings={settings}
scopeType={scopeType}
targetOptions={targets}
targetLabel={labels.targetLabel}
title={labels.policyTitle}
description={labels.policyDescription}
canWrite={canWrite}
/>
{scopeType === "system" && (
<div className="retention-run-card">
<Card title="Retention execution">
<p className="muted small-note">Run the saved effective retention policy against retained platform data.</p>
<div className="button-row compact-actions subsection-bottom-actions">
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>Dry run</Button>
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>Apply retention</Button>
</div>
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
</Card>
</div>
)}
</AdminPageLayout>
<ConfirmDialog
open={confirmRetentionRun}
title="Apply retention policy"
message="This will redact or delete eligible retained data according to the saved policy."
confirmLabel="Apply retention"
tone="danger"
busy={busy}
onCancel={() => setConfirmRetentionRun(false)}
onConfirm={() => void runRetention(false)}
/>
</>
);
}
async function loadDeltaRows<TItem, TResponse extends DeltaResponse>(
current: TItem[],
key: string,
getDeltaWatermark: (key: string) => string | null,
setDeltaWatermark: (key: string, watermark: string | null | undefined) => void,
fetchDelta: (since: string | null) => Promise<TResponse>,
rowsFromResponse: (response: TResponse) => TItem[],
getKey: (item: TItem) => string,
deletedResourceType: string,
sort?: (left: TItem, right: TItem) => number
): Promise<TItem[]> {
let nextWatermark = getDeltaWatermark(key);
let merged = current;
let hasMore = false;
do {
const response = await fetchDelta(nextWatermark);
const rows = rowsFromResponse(response);
merged = response.full ? rows : mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(key, nextWatermark);
return merged;
}
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
return left.email.localeCompare(right.email);
}
function sortGroups(left: GroupSummary, right: GroupSummary): number {
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
}

5
webui/src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { default } from "./module";
export * from "./module";
export * from "./api/adminTargets";
export { default as RetentionPoliciesPanel } from "./features/policy/RetentionPoliciesPanel";
export type { PlatformWebModule } from "@govoplan/core-webui";

69
webui/src/module.ts Normal file
View File

@@ -0,0 +1,69 @@
import { createElement, lazy } from "react";
import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
const RetentionPoliciesPanel = lazy(() => import("./features/policy/RetentionPoliciesPanel"));
const policyAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "system-retention",
label: "Retention",
group: "SYSTEM",
order: 80,
allOf: ["system:settings:read"],
render: ({ settings, auth }) => createElement(RetentionPoliciesPanel, {
settings,
scopeType: "system",
canWrite: hasScope(auth, "system:settings:write")
})
},
{
id: "tenant-retention",
label: "Retention",
group: "TENANT",
order: 80,
allOf: ["admin:policies:read"],
render: ({ settings, auth }) => createElement(RetentionPoliciesPanel, {
settings,
scopeType: "tenant",
canWrite: hasScope(auth, "admin:policies:write")
})
},
{
id: "tenant-group-retention",
label: "Retention",
group: "GROUP",
order: 30,
allOf: ["admin:policies:read", "admin:groups:read"],
render: ({ settings, auth }) => createElement(RetentionPoliciesPanel, {
settings,
scopeType: "group",
canWrite: hasScope(auth, "admin:policies:write")
})
},
{
id: "tenant-user-retention",
label: "Retention",
group: "USER",
order: 30,
allOf: ["admin:policies:read", "admin:users:read"],
render: ({ settings, auth }) => createElement(RetentionPoliciesPanel, {
settings,
scopeType: "user",
canWrite: hasScope(auth, "admin:policies:write")
})
}
]
};
export const policyModule: PlatformWebModule = {
id: "policy",
label: "Policy",
version: "0.1.6",
dependencies: ["access", "admin"],
uiCapabilities: {
"admin.sections": policyAdminSections
}
};
export default policyModule;