feat: govern legacy archive encryption
This commit is contained in:
@@ -2,7 +2,11 @@ import { MetricGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import {
|
||||
getCampaignArchiveEncryptionPolicy,
|
||||
type CampaignArchiveEncryptionPolicy
|
||||
} from "../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||
@@ -22,14 +26,25 @@ import { updateNested } from "./utils/draftEditor";
|
||||
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
||||
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
|
||||
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
|
||||
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||
import { hasScope, insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
||||
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||
|
||||
type PathChooserState = {index: number;};
|
||||
type IndividualDisableState = {index: number;usageCount: number;};
|
||||
|
||||
export default function AttachmentsDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const UNAVAILABLE_ARCHIVE_POLICY: CampaignArchiveEncryptionPolicy = {
|
||||
available: false,
|
||||
allowed_password_encryption_methods: ["aes"],
|
||||
allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"],
|
||||
policy_hash: "",
|
||||
source_path: [],
|
||||
reason: "Archive-encryption policy is loading. Legacy ZipCrypto remains blocked.",
|
||||
diagnostics: [],
|
||||
legacy_label: "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
||||
};
|
||||
|
||||
export default function AttachmentsDataPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
||||
@@ -41,6 +56,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
const [fileSpaces, setFileSpaces] = useState<FilesFileSpace[]>([]);
|
||||
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
|
||||
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
|
||||
const [archivePolicy, setArchivePolicy] = useState<CampaignArchiveEncryptionPolicy>(UNAVAILABLE_ARCHIVE_POLICY);
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
@@ -70,7 +86,15 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
() => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION,
|
||||
[zipConfig.archives, zipConfig.enabled]
|
||||
);
|
||||
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message;
|
||||
const canUseLegacyZipCrypto = hasScope(auth, "campaigns:archive:use_legacy_zipcrypto");
|
||||
const legacyZipCryptoAllowed = archivePolicy.available && archivePolicy.allowed_password_encryption_methods.includes("zip_standard");
|
||||
const legacyConfigurationInvalid = zipConfig.archives.some((archive) =>
|
||||
archive.method === "zip_standard" && (
|
||||
!legacyZipCryptoAllowed || !canUseLegacyZipCrypto ||
|
||||
!archive.legacy_zipcrypto_acknowledged || archive.legacy_zipcrypto_reason.trim().length < 10
|
||||
)
|
||||
);
|
||||
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message && !legacyConfigurationInvalid;
|
||||
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
||||
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
||||
const attachmentPreviewEntry = useMemo(
|
||||
@@ -94,6 +118,22 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
return () => {cancelled = true;};
|
||||
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setArchivePolicy(UNAVAILABLE_ARCHIVE_POLICY);
|
||||
void getCampaignArchiveEncryptionPolicy(settings, campaignId)
|
||||
.then((policy) => { if (!cancelled) setArchivePolicy(policy); })
|
||||
.catch((cause) => {
|
||||
if (!cancelled) {
|
||||
setArchivePolicy({
|
||||
...UNAVAILABLE_ARCHIVE_POLICY,
|
||||
reason: cause instanceof Error ? cause.message : String(cause)
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||
if (locked) return;
|
||||
const normalized = ensureAttachmentBasePaths(paths);
|
||||
@@ -385,6 +425,11 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
||||
<DismissibleAlert tone={legacyZipCryptoAllowed ? "warning" : "info"} dismissible={false} compact>
|
||||
<strong>{archivePolicy.legacy_label}</strong>: {archivePolicy.reason}
|
||||
{archivePolicy.source_path.length > 0 && <> Source: {archivePolicy.source_path.map((step) => step.label).join(" → ")}.</>}
|
||||
{!canUseLegacyZipCrypto && <> Your account does not have the dedicated legacy-encryption permission.</>}
|
||||
</DismissibleAlert>
|
||||
<div className="attachment-zip-master-toggle">
|
||||
<ToggleSwitch
|
||||
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
|
||||
@@ -403,6 +448,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
|
||||
onEditName: setZipNameEditorIndex,
|
||||
passwordFields,
|
||||
legacyAllowed: legacyZipCryptoAllowed,
|
||||
canUseLegacy: canUseLegacyZipCrypto,
|
||||
allowedDeliveryChannels: archivePolicy.allowed_password_delivery_channels,
|
||||
patchArchive: patchZipArchive,
|
||||
setStandard: setStandardZipArchive,
|
||||
addArchive: addZipArchive,
|
||||
@@ -517,6 +565,9 @@ type ZipArchiveColumnContext = {
|
||||
invalidNameIndexes: ReadonlySet<number>;
|
||||
onEditName: (index: number) => void;
|
||||
passwordFields: ReturnType<typeof getDraftFields>;
|
||||
legacyAllowed: boolean;
|
||||
canUseLegacy: boolean;
|
||||
allowedDeliveryChannels: CampaignArchiveEncryptionPolicy["allowed_password_delivery_channels"];
|
||||
patchArchive: (index: number, patch: Partial<AttachmentZipArchive>) => void;
|
||||
setStandard: (index: number) => void;
|
||||
addArchive: (afterIndex?: number) => void;
|
||||
@@ -524,7 +575,7 @@ type ZipArchiveColumnContext = {
|
||||
removeArchive: (index: number) => void;
|
||||
};
|
||||
|
||||
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
|
||||
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, legacyAllowed, canUseLegacy, allowedDeliveryChannels, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
|
||||
return [
|
||||
{
|
||||
id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, sticky: "start",
|
||||
@@ -557,18 +608,65 @@ function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName,
|
||||
value: (archive) => archive.password_enabled ? "protected" : "none"
|
||||
},
|
||||
{
|
||||
id: "method", header: "ZIP mode", width: 280, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "aes", label: "AES" }, { value: "zip_standard", label: "Win-compatible" }] },
|
||||
id: "method", header: "Encryption", width: 360, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "aes", label: "AES (strong, default)" }, { value: "zip_standard", label: "Legacy ZipCrypto — Windows-compatible, weak encryption" }] },
|
||||
render: (archive, index) =>
|
||||
<ToggleSwitch
|
||||
label="Win-compatible"
|
||||
checked={archive.method === "zip_standard"}
|
||||
<select
|
||||
value={archive.method}
|
||||
disabled={disabled}
|
||||
help="Win-compatible ZIP uses the legacy ZipCrypto format so password-protected archives can be opened with Windows Explorer. Use AES when recipients can use 7-Zip, NanaZip, WinRAR, or another AES-capable ZIP tool."
|
||||
onChange={(checked) => patchArchive(index, { method: checked ? "zip_standard" : "aes" })} />,
|
||||
aria-label="Archive password encryption"
|
||||
onChange={(event) => {
|
||||
const method = event.target.value === "zip_standard" ? "zip_standard" : "aes";
|
||||
patchArchive(index, method === "zip_standard" ? {
|
||||
method,
|
||||
legacy_zipcrypto_acknowledged: false,
|
||||
legacy_zipcrypto_reason: ""
|
||||
} : {
|
||||
method,
|
||||
legacy_zipcrypto_acknowledged: false,
|
||||
legacy_zipcrypto_reason: ""
|
||||
});
|
||||
}}>
|
||||
<option value="aes">AES (strong, default)</option>
|
||||
<option value="zip_standard" disabled={!legacyAllowed || !canUseLegacy}>Legacy ZipCrypto — Windows-compatible, weak encryption</option>
|
||||
</select>,
|
||||
|
||||
value: (archive) => archive.method
|
||||
},
|
||||
{
|
||||
id: "password_delivery_channel", header: "Password delivery", width: 230, sortable: true, filterable: true,
|
||||
render: (archive, index) =>
|
||||
<select
|
||||
value={archive.password_delivery_channel}
|
||||
disabled={disabled || !archive.password_enabled}
|
||||
aria-label="Separate password-delivery channel"
|
||||
onChange={(event) => patchArchive(index, { password_delivery_channel: event.target.value as AttachmentZipArchive["password_delivery_channel"] })}>
|
||||
{(["separate_mail", "sms", "letter", "phone", "in_person"] as const).map((channel) =>
|
||||
<option key={channel} value={channel} disabled={!allowedDeliveryChannels.includes(channel)}>{passwordDeliveryChannelLabel(channel)}</option>
|
||||
)}
|
||||
</select>,
|
||||
value: (archive) => archive.password_delivery_channel
|
||||
},
|
||||
{
|
||||
id: "legacy_acknowledgement", header: "Legacy acknowledgement", width: 380,
|
||||
render: (archive, index) => archive.method === "zip_standard" ?
|
||||
<div className="campaign-legacy-zipcrypto-acknowledgement">
|
||||
<ToggleSwitch
|
||||
label="I acknowledge that ZipCrypto encryption is weak"
|
||||
checked={archive.legacy_zipcrypto_acknowledged}
|
||||
disabled={disabled || !legacyAllowed || !canUseLegacy}
|
||||
onChange={(checked) => patchArchive(index, { legacy_zipcrypto_acknowledged: checked })} />
|
||||
<input
|
||||
value={archive.legacy_zipcrypto_reason}
|
||||
disabled={disabled || !archive.legacy_zipcrypto_acknowledged}
|
||||
minLength={10}
|
||||
maxLength={1000}
|
||||
placeholder="Operational reason (at least 10 characters)"
|
||||
aria-label="Reason for weak legacy encryption"
|
||||
onChange={(event) => patchArchive(index, { legacy_zipcrypto_reason: event.target.value })} />
|
||||
</div> : <span>Not required for AES</span>,
|
||||
value: (archive) => archive.legacy_zipcrypto_reason
|
||||
},
|
||||
{
|
||||
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
|
||||
@@ -685,6 +783,16 @@ function uniqueStrings(values: string[]): string[] {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function passwordDeliveryChannelLabel(channel: AttachmentZipArchive["password_delivery_channel"]): string {
|
||||
return {
|
||||
separate_mail: "Separate email (never this campaign message)",
|
||||
sms: "SMS",
|
||||
letter: "Letter",
|
||||
phone: "Telephone",
|
||||
in_person: "In person"
|
||||
}[channel];
|
||||
}
|
||||
|
||||
type AttachmentSourceColumnContext = {
|
||||
locked: boolean;
|
||||
basePaths: AttachmentBasePath[];
|
||||
|
||||
@@ -100,7 +100,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="recipient-data" element={<Navigate to="../recipients" replace />} />
|
||||
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="files" element={<AttachmentsDataPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||
<Route path="attachments" element={<Navigate to="../files" replace />} />
|
||||
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="settings" />} />
|
||||
<Route path="mail-policy" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="policy" />} />
|
||||
|
||||
@@ -13,6 +13,11 @@ export type AttachmentZipArchive = {
|
||||
password_field: string;
|
||||
password_scope: AttachmentZipPasswordScope;
|
||||
method: "aes" | "zip_standard";
|
||||
password_delivery_channel: "separate_mail" | "sms" | "letter" | "phone" | "in_person";
|
||||
legacy_zipcrypto_acknowledged: boolean;
|
||||
legacy_zipcrypto_reason: string;
|
||||
legacy_zipcrypto_acknowledged_by?: string;
|
||||
legacy_zipcrypto_acknowledged_at?: string;
|
||||
// Read-only compatibility values retained when normalizing older campaigns.
|
||||
password_mode?: "none" | "direct" | "field" | "template";
|
||||
password?: string;
|
||||
@@ -32,7 +37,10 @@ export function createAttachmentZipArchive(name = "attachments.zip", standard =
|
||||
password_enabled: false,
|
||||
password_field: "",
|
||||
password_scope: "local",
|
||||
method: "aes"
|
||||
method: "aes",
|
||||
password_delivery_channel: "separate_mail",
|
||||
legacy_zipcrypto_acknowledged: false,
|
||||
legacy_zipcrypto_reason: ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +60,11 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC
|
||||
password_field: getText(archive, "password_field"),
|
||||
password_scope: getText(archive, "password_scope") === "global" ? "global" : "local",
|
||||
method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
||||
password_delivery_channel: normalizePasswordDeliveryChannel(getText(archive, "password_delivery_channel", "separate_mail")),
|
||||
legacy_zipcrypto_acknowledged: getBool(archive, "legacy_zipcrypto_acknowledged"),
|
||||
legacy_zipcrypto_reason: getText(archive, "legacy_zipcrypto_reason"),
|
||||
...(getText(archive, "legacy_zipcrypto_acknowledged_by") ? { legacy_zipcrypto_acknowledged_by: getText(archive, "legacy_zipcrypto_acknowledged_by") } : {}),
|
||||
...(getText(archive, "legacy_zipcrypto_acknowledged_at") ? { legacy_zipcrypto_acknowledged_at: getText(archive, "legacy_zipcrypto_acknowledged_at") } : {}),
|
||||
...(legacyMode ? { password_mode: legacyMode } : {}),
|
||||
...(getText(archive, "password") ? { password: getText(archive, "password") } : {}),
|
||||
...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {})
|
||||
@@ -76,6 +89,9 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC
|
||||
password_field: getText(zip, "password_field"),
|
||||
password_scope: "local",
|
||||
method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
||||
password_delivery_channel: normalizePasswordDeliveryChannel(getText(zip, "password_delivery_channel", "separate_mail")),
|
||||
legacy_zipcrypto_acknowledged: getBool(zip, "legacy_zipcrypto_acknowledged"),
|
||||
legacy_zipcrypto_reason: getText(zip, "legacy_zipcrypto_reason"),
|
||||
...(legacyMode ? { password_mode: legacyMode } : {}),
|
||||
...(getText(zip, "password") ? { password: getText(zip, "password") } : {}),
|
||||
...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {})
|
||||
@@ -83,6 +99,13 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePasswordDeliveryChannel(value: string): AttachmentZipArchive["password_delivery_channel"] {
|
||||
if (["sms", "letter", "phone", "in_person"].includes(value)) {
|
||||
return value as AttachmentZipArchive["password_delivery_channel"];
|
||||
}
|
||||
return "separate_mail";
|
||||
}
|
||||
|
||||
export function attachmentRuleZipSelection(rule: AttachmentRule): string {
|
||||
const zip = asRecord(rule.zip);
|
||||
const archiveId = getText(zip, "archive_id");
|
||||
|
||||
Reference in New Issue
Block a user