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