Release govoplan-policy v0.1.23: unify interface contracts and documentation
Module Package Release / publish-packages (push) Successful in 11s
Module Package Release / publish-packages (push) Successful in 11s
This commit is contained in:
+4
-3
@@ -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
@@ -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")
|
||||
})
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user