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

@@ -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;