fix(webui): bind consequential campaign controls to help
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-24 11:36:33 +02:00
parent c21fb4cf7c
commit 1b32427813
12 changed files with 30 additions and 18 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.26",
"version": "0.1.27",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -615,6 +615,8 @@ function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName,
value={archive.method}
disabled={disabled}
aria-label="Archive password encryption"
data-help-context-id="campaign.archive-encryption"
data-help-module-id="campaigns"
onChange={(event) => {
const method = event.target.value === "zip_standard" ? "zip_standard" : "aes";
patchArchive(index, method === "zip_standard" ? {
@@ -640,6 +642,8 @@ function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName,
value={archive.password_delivery_channel}
disabled={disabled || !archive.password_enabled}
aria-label="Separate password-delivery channel"
data-help-context-id="campaign.archive-encryption"
data-help-module-id="campaigns"
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>
@@ -653,6 +657,8 @@ function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName,
<div className="campaign-legacy-zipcrypto-acknowledgement">
<ToggleSwitch
label="I acknowledge that ZipCrypto encryption is weak"
helpContextId="campaign.archive-encryption"
helpModuleId="campaigns"
checked={archive.legacy_zipcrypto_acknowledged}
disabled={disabled || !legacyAllowed || !canUseLegacy}
onChange={(checked) => patchArchive(index, { legacy_zipcrypto_acknowledged: checked })} />
@@ -663,6 +669,8 @@ function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName,
maxLength={1000}
placeholder="Operational reason (at least 10 characters)"
aria-label="Reason for weak legacy encryption"
data-help-context-id="campaign.archive-encryption"
data-help-module-id="campaigns"
onChange={(event) => patchArchive(index, { legacy_zipcrypto_reason: event.target.value })} />
</div> : <span>Not required for AES</span>,
value: (archive) => archive.legacy_zipcrypto_reason
@@ -433,6 +433,8 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
destructiveActions={<>
{canDelete && <Button
variant="danger"
helpContextId="campaign.overview"
helpModuleId="campaigns"
onClick={() => void prepareLifecycleAction("delete_campaign")}
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
disabledReason={identityDirty ? "Save or discard overview changes before deleting." : undefined}>
@@ -537,7 +537,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<Button onClick={() => void retryFailedSynchronously(failedRowsOnPage)} disabled={!version || Boolean(busyAction) || failedRowsOnPage.length === 0}>
{busyAction === "retry-sync-page" ? "Sending failed jobs..." : `Retry failed on this page now (${failedRowsOnPage.length})`}
</Button>
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
<Button helpContextId="campaign.report" helpModuleId="campaigns" onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
</div>
</Card>
</ContentGrid>
@@ -591,7 +591,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
footer={
<div className="button-row">
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>i18n:govoplan-campaign.send_report.a5b32af9</Button>
<Button variant="primary" helpContextId="campaign.report" helpModuleId="campaigns" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>i18n:govoplan-campaign.send_report.a5b32af9</Button>
</div>
}>
@@ -359,7 +359,7 @@ export default function CampaignWorkPage({
) : null}
{canComplete && (assignment.status === "open" || assignment.status === "in_progress") ? (
<span className="campaign-work-destructive-action">
<Button variant="danger" onClick={() => setRejecting(assignment)} disabled={Boolean(busyId)}>
<Button variant="danger" helpContextId="campaign.work.action.reject" helpModuleId="campaigns" onClick={() => setRejecting(assignment)} disabled={Boolean(busyId)}>
Reject work
</Button>
</span>
@@ -379,6 +379,8 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
<FormGrid columns={2} collapseAt="wide" className="">
<ToggleSwitch
label="Send individualized invitations"
helpContextId="campaign.global-settings"
helpModuleId="campaigns"
checked={getBool(calendarInvitation, "enabled")}
disabled={locked || !calendarCatalog.available || !mailModuleInstalled}
onChange={(checked) => patch(["delivery", "calendar_invitation", "enabled"], checked)}
@@ -383,7 +383,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
actions={<div className="button-row compact-actions">
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
<Button variant="primary" onClick={openCredentialDialog} disabled={locked || profilesLoading || !selectedProfile || !selectedSmtpServer && !selectedImapServer}>Add campaign credential</Button>
<Button variant="primary" helpContextId="campaign.server-settings" helpModuleId="campaigns" onClick={openCredentialDialog} disabled={locked || profilesLoading || !selectedProfile || !selectedSmtpServer && !selectedImapServer}>Add campaign credential</Button>
</div>}>
<p className="muted small-note">The campaign stores stable references to the envelope, selected servers, and credentials.</p>
<FormGrid columns={2} collapseAt="wide" className="">
@@ -399,8 +399,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
{smtpServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
</select>
</FormField>
<FormField label="SMTP credential">
<select value={selectedSmtpCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedSmtpServer} onChange={(event) => selectCredential("smtp", event.target.value)}>
<FormField label="SMTP credential" helpContextId="campaign.server-settings" helpModuleId="campaigns">
<select data-help-context-id="campaign.server-settings" data-help-module-id="campaigns" value={selectedSmtpCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedSmtpServer} onChange={(event) => selectCredential("smtp", event.target.value)}>
<option value="">No credential</option>
{selectedSmtpServer?.credentials.filter((item) => item.is_active).map((item) =>
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
@@ -413,8 +413,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
{imapServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
</select>
</FormField>
<FormField label="IMAP credential">
<select value={selectedImapCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedImapServer} onChange={(event) => selectCredential("imap", event.target.value)}>
<FormField label="IMAP credential" helpContextId="campaign.server-settings" helpModuleId="campaigns">
<select data-help-context-id="campaign.server-settings" data-help-module-id="campaigns" value={selectedImapCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedImapServer} onChange={(event) => selectCredential("imap", event.target.value)}>
<option value="">No credential</option>
{selectedImapServer?.credentials.filter((item) => item.is_active).map((item) =>
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
@@ -476,8 +476,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
<FormField label="Username">
<input value={credentialDraft.username} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, username: event.target.value })} />
</FormField>
<FormField label="Password">
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} generator autoComplete="new-password" />
<FormField label="Password" helpContextId="campaign.server-settings" helpModuleId="campaigns">
<PasswordField helpContextId="campaign.server-settings" helpModuleId="campaigns" value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} generator autoComplete="new-password" />
</FormField>
</FormGrid>
<FormGrid columns={2} collapseAt="standard" className="">
@@ -1638,7 +1638,7 @@ export default function ReviewSendPage({
<p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.mock_delivery_uses_the_mail_module_development_m.9d645a5f</p>
}
<div className="toggle-row mock-send-options">
<ToggleSwitch label="i18n:govoplan-campaign.require_mock_before_real_send.a1ccc330" checked={mockVerificationRequired} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockVerificationRequired} />
<ToggleSwitch label="i18n:govoplan-campaign.require_mock_before_real_send.a1ccc330" helpContextId="campaign.review-send" helpModuleId="campaigns" checked={mockVerificationRequired} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockVerificationRequired} />
<ToggleSwitch label="i18n:govoplan-campaign.show_captured_mock_mailbox.019e1e79" checked={mockMailboxPreviewEnabled && mockWorkflowAvailable} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockMailboxPreviewEnabled} />
<ToggleSwitch label="i18n:govoplan-campaign.clear_mock_mailbox_first.7627dcad" checked={mockClearFirst} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockClearFirst} />
<ToggleSwitch label="i18n:govoplan-campaign.append_mock_sent_copy.49c2cf08" checked={mockAppendSent} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockAppendSent} />
@@ -429,7 +429,7 @@ export default function CampaignAccessCard({
return (
<>
<Card title="i18n:govoplan-campaign.ownership_and_sharing.867283c0" actions={<div className="button-row compact-actions">{canProposeOwnership ? <Button onClick={openOwnerDialog}><ArrowRightLeft size={16} aria-hidden="true" /> Transfer ownership</Button> : null}{canRequestOwnership ? <Button onClick={openRequestDialog}><ArrowRightLeft size={16} aria-hidden="true" /> Request ownership</Button> : null}<Button onClick={() => setShareOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.share.09ca55ca</Button><Button onClick={() => void openAccessExplanation()} disabled={!canExplainResourceAccess}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-campaign.explain_access.4d5fac37</Button></div>}>
<Card title="i18n:govoplan-campaign.ownership_and_sharing.867283c0" actions={<div className="button-row compact-actions">{canProposeOwnership ? <Button helpContextId="campaign.overview" helpModuleId="campaigns" onClick={openOwnerDialog}><ArrowRightLeft size={16} aria-hidden="true" /> Transfer ownership</Button> : null}{canRequestOwnership ? <Button onClick={openRequestDialog}><ArrowRightLeft size={16} aria-hidden="true" /> Request ownership</Button> : null}<Button onClick={() => setShareOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.share.09ca55ca</Button><Button onClick={() => void openAccessExplanation()} disabled={!canExplainResourceAccess}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-campaign.explain_access.4d5fac37</Button></div>}>
<p><strong>i18n:govoplan-campaign.owner.719379ae</strong> {ownerLabel}</p>
<p className="muted small-note">Ownership changes require acceptance by the new owner. They never transfer private encryption keys. A completed transfer clears owner-scoped mail profile selection and requires revalidation.</p>
{ownershipTransfers.length > 0 ? (
@@ -489,7 +489,7 @@ export default function CampaignAccessCard({
)}
</Card>
<Dialog open={ownerOpen} className="campaign-access-dialog" title="Transfer campaign ownership" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId || ownerType === currentOwnerType && ownerId === currentOwnerId}>Propose transfer</Button></>}>
<Dialog open={ownerOpen} className="campaign-access-dialog" title="Transfer campaign ownership" helpContextId="campaign.overview" helpModuleId="campaigns" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" helpContextId="campaign.overview" helpModuleId="campaigns" onClick={() => void saveOwner()} disabled={busy || !ownerId || ownerType === currentOwnerType && ownerId === currentOwnerId}>Propose transfer</Button></>}>
<FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId("");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="i18n:govoplan-campaign.owner.89ff3122">
<ReferenceSelect