From 97f05a083fcc5c694e60f1939b47ee8cfbcac089 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 11 Jul 2026 02:34:58 +0200 Subject: [PATCH] Release v0.1.7 --- README.md | 10 +- package.json | 32 +++ pyproject.toml | 4 +- src/govoplan_policy/backend/manifest.py | 8 +- webui/package.json | 27 +++ webui/src/api/adminTargets.ts | 56 +++++ .../policy/RetentionPoliciesPanel.tsx | 226 ++++++++++++++++++ webui/src/index.ts | 5 + webui/src/module.ts | 69 ++++++ 9 files changed, 429 insertions(+), 8 deletions(-) create mode 100644 package.json create mode 100644 webui/package.json create mode 100644 webui/src/api/adminTargets.ts create mode 100644 webui/src/features/policy/RetentionPoliciesPanel.tsx create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts diff --git a/README.md b/README.md index e8d3963..869ff8e 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/package.json b/package.json new file mode 100644 index 0000000..6563502 --- /dev/null +++ b/package.json @@ -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 + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 412aed1..413149b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/govoplan_policy/backend/manifest.py b/src/govoplan_policy/backend/manifest.py index c7a91cd..d12d5fb 100644 --- a/src/govoplan_policy/backend/manifest.py +++ b/src/govoplan_policy/backend/manifest.py @@ -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, }, diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..7fcd2d5 --- /dev/null +++ b/webui/package.json @@ -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 + } + } +} diff --git a/webui/src/api/adminTargets.ts b/webui/src/api/adminTargets.ts new file mode 100644 index 0000000..61d2489 --- /dev/null +++ b/webui/src/api/adminTargets.ts @@ -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 { + return apiFetch(settings, `/api/v1/admin/users/delta${deltaSuffix(options)}`); +} + +export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { + return apiFetch(settings, `/api/v1/admin/groups/delta${deltaSuffix(options)}`); +} diff --git a/webui/src/features/policy/RetentionPoliciesPanel.tsx b/webui/src/features/policy/RetentionPoliciesPanel.tsx new file mode 100644 index 0000000..618a5e0 --- /dev/null +++ b/webui/src/features/policy/RetentionPoliciesPanel.tsx @@ -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; + canWrite: boolean; +}; + +type DeltaResponse = { + deleted: DeltaDeletedItem[]; + watermark?: string | null; + has_more: boolean; + full: boolean; +}; + +const copy: Record = { + 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([]); + const usersRef = useRef([]); + const groupsRef = useRef([]); + 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(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( + 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( + 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 ( + <> + + + + {scopeType === "system" && ( +
+ +

Run the saved effective retention policy against retained platform data.

+
+ + +
+ {retentionResult &&
{JSON.stringify(retentionResult.result, null, 2)}
} +
+
+ )} +
+ setConfirmRetentionRun(false)} + onConfirm={() => void runRetention(false)} + /> + + ); +} + +async function loadDeltaRows( + current: TItem[], + key: string, + getDeltaWatermark: (key: string) => string | null, + setDeltaWatermark: (key: string, watermark: string | null | undefined) => void, + fetchDelta: (since: string | null) => Promise, + rowsFromResponse: (response: TResponse) => TItem[], + getKey: (item: TItem) => string, + deletedResourceType: string, + sort?: (left: TItem, right: TItem) => number +): Promise { + 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); +} diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..692d704 --- /dev/null +++ b/webui/src/index.ts @@ -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"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..502884b --- /dev/null +++ b/webui/src/module.ts @@ -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;