Release govoplan-policy v0.1.23: unify interface contracts and documentation
Module Package Release / publish-packages (push) Successful in 11s

This commit is contained in:
2026-09-08 01:32:48 +02:00
parent 29a9aea3b1
commit 2bd7487ba7
10 changed files with 330 additions and 48 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/policy-webui",
"version": "0.1.22",
"version": "0.1.23",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -13,10 +13,11 @@
}
},
"scripts": {
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs",
"test:archive-encryption": "node --experimental-strip-types scripts/test-archive-encryption-draft.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
buildPolicy, draftFromPolicy, inheritedControlDisabled, setDraftChannel,
setDraftMethod, stable
} from "../src/features/policy/archiveEncryptionDraft.ts";
const baseline = {
allowed_password_encryption_methods: ["aes"],
allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"],
policy_hash: "baseline", source_path: [], reason: "Secure baseline", diagnostics: []
};
const initial = draftFromPolicy({}, baseline);
assert.deepEqual(buildPolicy(initial), {}, "Opening default system settings must not create an override or dirty state");
assert.equal(inheritedControlDisabled("system", initial.inheritMethods), false, "System defaults must be editable without a hidden inheritance toggle");
assert.equal(inheritedControlDisabled("system", initial.inheritChannels), false);
const enabled = setDraftMethod(initial, "zip_standard", true);
assert.deepEqual(buildPolicy(enabled), { allowed_password_encryption_methods: ["aes", "zip_standard"] }, "The first system Legacy click must produce an explicit override");
assert.notEqual(stable(buildPolicy(enabled)), stable({}));
assert.deepEqual(buildPolicy(initial), {}, "Changing a draft must preserve the original policy");
const narrowedChannels = setDraftChannel(initial, "sms", false);
assert.deepEqual(buildPolicy(narrowedChannels), { allowed_password_delivery_channels: ["separate_mail", "letter", "phone", "in_person"] });
assert.equal(inheritedControlDisabled("tenant", initial.inheritMethods), true, "Child scopes retain explicit inheritance controls");
assert.equal(inheritedControlDisabled("user", false), false);
assert.deepEqual(buildPolicy(draftFromPolicy(buildPolicy(enabled), baseline)), buildPolicy(enabled), "An explicit system policy survives save/reload");
assert.deepEqual(buildPolicy(setDraftMethod(enabled, "zip_standard", false)), { allowed_password_encryption_methods: ["aes"] });
const panel = readFileSync(new URL("../src/features/policy/ArchiveEncryptionPoliciesPanel.tsx", import.meta.url), "utf8");
assert.match(panel, /inheritedControlDisabled\(scopeType, draft\.inheritMethods\)/);
assert.match(panel, /inheritedControlDisabled\(scopeType, draft\.inheritChannels\)/);
assert.match(panel, /setDraft\(setDraftMethod\(draft, method\.id, checked\)\)/);
assert.match(panel, /setDraft\(setDraftChannel\(draft, channel\.id, checked\)\)/);
assert.match(panel, /scopeType !== "system" && !parentMethods\.includes\(method\.id\)/, "Child scopes must still respect parent ceilings");
const moduleSource = readFileSync(new URL("../src/module.ts", import.meta.url), "utf8");
assert.match(moduleSource, /scopeType: "system",\s*canWrite: hasScope\(auth, "system:settings:write"\) && hasScope\(auth, "admin:policies:write"\)/, "Tenant policy administration alone must not enable edits to the global system archive ceiling");
console.log("Archive encryption settings regressions passed.");
@@ -20,11 +20,19 @@ import {
fetchArchiveEncryptionPolicy,
updateArchiveEncryptionPolicy,
type ArchiveEncryptionMethod,
type ArchiveEncryptionPolicyItem,
type ArchiveEncryptionPolicyResponse,
type ArchiveEncryptionPolicyScope,
type PasswordDeliveryChannel
} from "../../api/archiveEncryptionPolicies";
import {
buildPolicy,
draftFromPolicy,
inheritedControlDisabled,
setDraftChannel,
setDraftMethod,
stable,
type ArchiveEncryptionDraft
} from "./archiveEncryptionDraft";
type Props = {
settings: ApiSettings;
@@ -32,13 +40,6 @@ type Props = {
canWrite: boolean;
};
type Draft = {
inheritMethods: boolean;
methods: ArchiveEncryptionMethod[];
inheritChannels: boolean;
channels: PasswordDeliveryChannel[];
};
const METHODS: Array<{ id: ArchiveEncryptionMethod; label: string; description: string }> = [
{ id: "aes", label: "AES (strong, default)", description: "Modern AES encryption for compatible ZIP clients." },
{ id: "zip_standard", label: "Legacy ZipCrypto — Windows-compatible, weak encryption", description: "Requires a separate Campaign permission and reasoned acknowledgement." }
@@ -56,7 +57,7 @@ export default function ArchiveEncryptionPoliciesPanel({ settings, scopeType, ca
const [targets, setTargets] = useState<SearchableSelectOption[]>([]);
const [targetId, setTargetId] = useState("");
const [state, setState] = useState<ArchiveEncryptionPolicyResponse | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [draft, setDraft] = useState<ArchiveEncryptionDraft | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -157,11 +158,11 @@ export default function ArchiveEncryptionPoliciesPanel({ settings, scopeType, ca
</DismissibleAlert>
<Card title="Allowed password-encryption methods">
{scopeType !== "system" && <ToggleSwitch label="Inherit methods from the parent scope" checked={draft.inheritMethods} disabled={!canWrite || busy} onChange={(checked) => setDraft({ ...draft, inheritMethods: checked, methods: checked ? [...parentMethods] : draft.methods })} />}
{METHODS.map((method) => <ToggleSwitch key={method.id} label={method.label} help={method.description} checked={draft.methods.includes(method.id)} disabled={!canWrite || busy || draft.inheritMethods || (scopeType !== "system" && !parentMethods.includes(method.id))} onChange={(checked) => setDraft({ ...draft, methods: toggle(draft.methods, method.id, checked) })} />)}
{METHODS.map((method) => <ToggleSwitch key={method.id} label={method.label} help={method.description} checked={draft.methods.includes(method.id)} disabled={!canWrite || busy || inheritedControlDisabled(scopeType, draft.inheritMethods) || (scopeType !== "system" && !parentMethods.includes(method.id))} onChange={(checked) => setDraft(setDraftMethod(draft, method.id, checked))} />)}
</Card>
<Card title="Allowed separate password-delivery channels">
{scopeType !== "system" && <ToggleSwitch label="Inherit channels from the parent scope" checked={draft.inheritChannels} disabled={!canWrite || busy} onChange={(checked) => setDraft({ ...draft, inheritChannels: checked, channels: checked ? [...parentChannels] : draft.channels })} />}
{CHANNELS.map((channel) => <ToggleSwitch key={channel.id} label={channel.label} checked={draft.channels.includes(channel.id)} disabled={!canWrite || busy || draft.inheritChannels || (scopeType !== "system" && !parentChannels.includes(channel.id))} onChange={(checked) => setDraft({ ...draft, channels: toggle(draft.channels, channel.id, checked) })} />)}
{CHANNELS.map((channel) => <ToggleSwitch key={channel.id} label={channel.label} checked={draft.channels.includes(channel.id)} disabled={!canWrite || busy || inheritedControlDisabled(scopeType, draft.inheritChannels) || (scopeType !== "system" && !parentChannels.includes(channel.id))} onChange={(checked) => setDraft(setDraftChannel(draft, channel.id, checked))} />)}
</Card>
<Card title="Effective policy evidence">
<DescriptionList>
@@ -173,33 +174,6 @@ export default function ArchiveEncryptionPoliciesPanel({ settings, scopeType, ca
</AdminPageLayout>;
}
function draftFromPolicy(policy: ArchiveEncryptionPolicyItem, parent: ArchiveEncryptionPolicyResponse["parent_policy"]): Draft {
return {
inheritMethods: policy.allowed_password_encryption_methods === undefined,
methods: [...(policy.allowed_password_encryption_methods ?? parent.allowed_password_encryption_methods)],
inheritChannels: policy.allowed_password_delivery_channels === undefined,
channels: [...(policy.allowed_password_delivery_channels ?? parent.allowed_password_delivery_channels)]
};
}
function buildPolicy(draft: Draft): ArchiveEncryptionPolicyItem {
return {
...(draft.inheritMethods ? {} : { allowed_password_encryption_methods: draft.methods }),
...(draft.inheritChannels ? {} : { allowed_password_delivery_channels: draft.channels })
};
}
function stable(value: ArchiveEncryptionPolicyItem): string {
return JSON.stringify({
methods: value.allowed_password_encryption_methods ? [...value.allowed_password_encryption_methods].sort() : null,
channels: value.allowed_password_delivery_channels ? [...value.allowed_password_delivery_channels].sort() : null
});
}
function toggle<T extends string>(values: T[], value: T, checked: boolean): T[] {
return checked ? Array.from(new Set([...values, value])) : values.filter((item) => item !== value);
}
async function loadTargets(settings: ApiSettings, scope: ArchiveEncryptionPolicyScope): Promise<SearchableSelectOption[]> {
if (scope === "group") {
const response = await fetchGroupsDelta(settings, { limit: 1000 });
@@ -0,0 +1,54 @@
import type {
ArchiveEncryptionMethod,
ArchiveEncryptionPolicyItem,
ArchiveEncryptionPolicyResponse,
ArchiveEncryptionPolicyScope,
PasswordDeliveryChannel
} from "../../api/archiveEncryptionPolicies";
export type ArchiveEncryptionDraft = {
inheritMethods: boolean;
methods: ArchiveEncryptionMethod[];
inheritChannels: boolean;
channels: PasswordDeliveryChannel[];
};
export function draftFromPolicy(policy: ArchiveEncryptionPolicyItem, parent: ArchiveEncryptionPolicyResponse["parent_policy"]): ArchiveEncryptionDraft {
return {
inheritMethods: policy.allowed_password_encryption_methods === undefined,
methods: [...(policy.allowed_password_encryption_methods ?? parent.allowed_password_encryption_methods)],
inheritChannels: policy.allowed_password_delivery_channels === undefined,
channels: [...(policy.allowed_password_delivery_channels ?? parent.allowed_password_delivery_channels)]
};
}
export function buildPolicy(draft: ArchiveEncryptionDraft): ArchiveEncryptionPolicyItem {
return {
...(draft.inheritMethods ? {} : { allowed_password_encryption_methods: draft.methods }),
...(draft.inheritChannels ? {} : { allowed_password_delivery_channels: draft.channels })
};
}
export function stable(value: ArchiveEncryptionPolicyItem): string {
return JSON.stringify({
methods: value.allowed_password_encryption_methods ? [...value.allowed_password_encryption_methods].sort() : null,
channels: value.allowed_password_delivery_channels ? [...value.allowed_password_delivery_channels].sort() : null
});
}
/** System defaults are editable even before the first explicit override exists. */
export function inheritedControlDisabled(scope: ArchiveEncryptionPolicyScope, inherited: boolean): boolean {
return scope !== "system" && inherited;
}
export function setDraftMethod(draft: ArchiveEncryptionDraft, method: ArchiveEncryptionMethod, checked: boolean): ArchiveEncryptionDraft {
return { ...draft, inheritMethods: false, methods: toggle(draft.methods, method, checked) };
}
export function setDraftChannel(draft: ArchiveEncryptionDraft, channel: PasswordDeliveryChannel, checked: boolean): ArchiveEncryptionDraft {
return { ...draft, inheritChannels: false, channels: toggle(draft.channels, channel, checked) };
}
function toggle<T extends string>(values: T[], value: T, checked: boolean): T[] {
return checked ? Array.from(new Set([...values, value])) : values.filter((item) => item !== value);
}
+1 -1
View File
@@ -79,7 +79,7 @@ const policyAdminSections: AdminSectionsUiCapability = {
render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, {
settings,
scopeType: "system",
canWrite: hasScope(auth, "admin:policies:write")
canWrite: hasScope(auth, "system:settings:write") && hasScope(auth, "admin:policies:write")
})
},
{