Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 39ad3500e2
commit 23318c709a
98 changed files with 3432 additions and 2339 deletions

View File

@@ -1,16 +1,16 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import type { ApiSettings, AuthInfo } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import DismissibleAlert from "../../components/DismissibleAlert";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import CampaignAccessCard from "./components/CampaignAccessCard";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import { hasScope } from "../../utils/permissions";
import { ToggleSwitch } from "@govoplan/core-webui";
import { hasScope } from "@govoplan/core-webui";
import { RetentionPolicyEditor } from "../privacy/RetentionPolicyManagement";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
@@ -20,10 +20,19 @@ import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/dr
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
type EditorState = Record<string, unknown>;
type SettingsView = "settings" | "policy";
export default function GlobalSettingsPage({ settings, auth, campaignId }: { settings: ApiSettings; auth: AuthInfo; campaignId: string }) {
type GlobalSettingsPageProps = {
settings: ApiSettings;
auth: AuthInfo;
campaignId: string;
view?: SettingsView;
};
export default function GlobalSettingsPage({ settings, auth, campaignId, view = "settings" }: GlobalSettingsPageProps) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [editorState, setEditorState] = useState<EditorState>({});
const isPolicyView = view === "policy";
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
@@ -34,9 +43,11 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
locked,
reload,
setError,
currentStep: "global-settings",
unsavedTitle: "Unsaved global settings",
unsavedMessage: "Policies have unsaved changes. Save them before leaving, or discard them and continue.",
currentStep: isPolicyView ? "policies" : "global-settings",
unsavedTitle: isPolicyView ? "Unsaved campaign policy changes" : "Unsaved campaign settings",
unsavedMessage: isPolicyView
? "Campaign policies have unsaved changes. Save them before leaving, or discard them and continue."
: "Campaign settings have unsaved changes. Save them before leaving, or discard them and continue.",
extraPayload: () => ({ editor_state: editorState }),
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState))
@@ -50,8 +61,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
const optIns = asRecord(editorState.opt_ins);
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
const pageTitle = isPolicyView ? "Campaign policies" : "Campaign settings";
function patchEditor(path: string[], value: unknown) {
if (locked) return;
@@ -59,12 +69,11 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
markDirty();
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Campaign settings</PageTitle>
<PageTitle loading={loading}>{pageTitle}</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
@@ -78,81 +87,161 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
{isPolicyView ? (
<>
{canReadRetentionPolicy && (
<RetentionPolicyEditor
settings={settings}
scopeType="campaign"
scopeId={campaignId}
title="Campaign retention policy"
description="Campaign-level retention limits applied after system, tenant and owner policy. Blank values inherit."
canWrite={canWriteRetentionPolicy}
locked={locked}
/>
)}
{canReadRetentionPolicy && (
<RetentionPolicyEditor
settings={settings}
scopeType="campaign"
scopeId={campaignId}
title="Campaign retention policy"
description="Campaign-level retention limits applied after system, tenant and owner policy. Blank values inherit."
canWrite={canWriteRetentionPolicy}
locked={locked}
/>
)}
<div className="dashboard-grid below-grid">
<Card title="Validation policy" collapsible>
<PolicyTable>
<PolicyRow
label="Missing required attachment"
note="Required attachment rule found no matching file."
control={<PolicySelectControl value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "missing_required_attachment", "ask"))}
/>
<PolicyRow
label="Missing optional attachment"
note="Optional attachment rule found no matching file."
control={<PolicySelectControl value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "missing_optional_attachment", "warn"))}
/>
<PolicyRow
label="Ambiguous attachment match"
note="Attachment rule matched more files than expected."
control={<PolicySelectControl value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "ambiguous_attachment_match", "ask"))}
/>
<PolicyRow
label="Unsent attachment file"
note="A watched attachment directory contains files that were not selected."
control={<PolicySelectControl value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "unsent_attachment_files", "warn"))}
/>
<PolicyRow
label="Missing email address"
note="The effective recipient list has no To address."
control={<PolicySelectControl value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />}
effective={behaviorSummary(getText(validationPolicy, "missing_email", "block"))}
/>
<PolicyRow
label="Template error"
note="A selected template body contains unresolved placeholders."
control={<PolicySelectControl value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />}
effective={behaviorSummary(getText(validationPolicy, "template_error", "block"))}
/>
<PolicyRow
label="Ignore empty fields"
note="Controls how missing placeholder values are rendered."
control={<ToggleSwitch label="Enabled" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />}
effective={getBool(validationPolicy, "ignore_empty_fields") ? "Missing values render as empty text." : "Missing values remain visible and can trigger template errors."}
/>
</PolicyTable>
</Card>
<div className="dashboard-grid">
<Card title="Validation policy">
<PolicySelect label="Missing required attachment" value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />
<PolicySelect label="Missing optional attachment" value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />
<PolicySelect label="Ambiguous attachment match" value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />
<PolicySelect label="Unsent attachment file" value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />
<PolicySelect label="Missing email address" value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />
<PolicySelect label="Template error" value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />
<ToggleSwitch label="Ignore empty fields" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />
</Card>
<Card title="Attachment policy" collapsible>
<PolicyTable>
<PolicyRow
label="Default missing behavior"
note="Used by attachment rules that do not override missing-file behavior."
control={<PolicySelectControl value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "missing_behavior"], value)} />}
effective={behaviorSummary(getText(attachments, "missing_behavior", "ask"))}
/>
<PolicyRow
label="Default ambiguous behavior"
note="Used by attachment rules that do not override ambiguous-match behavior."
control={<PolicySelectControl value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "ambiguous_behavior"], value)} />}
effective={behaviorSummary(getText(attachments, "ambiguous_behavior", "ask"))}
/>
<PolicyRow
label="Send without attachments"
note="Campaign-wide fallback when no attachment is available."
control={<ToggleSwitch label="Allowed" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />}
effective={getBool(attachments, "send_without_attachments", true) ? "Messages may be sent when attachment rules allow it." : "Missing attachment coverage blocks sending."}
/>
</PolicyTable>
</Card>
</div>
</>
) : (
<>
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
<Card title="Attachment defaults">
<div className="form-grid compact responsive-form-grid">
<FormField label="Missing behavior">
<select value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
</FormField>
<FormField label="Ambiguous behavior">
<select value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
</FormField>
<ToggleSwitch label="Send without attachments" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />
</div>
<p className="muted small-note">The actual global and per-recipient attachment rules live in Attachments. These settings define campaign-wide behavior used by validation and review. Individual-attachment permission is configured per attachment base path.</p>
</Card>
</div>
<div className="dashboard-grid below-grid">
<Card title="Delivery defaults" collapsible>
<div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
</div>
</Card>
<div className="dashboard-grid below-grid">
<Card title="Delivery defaults">
<div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
</div>
</Card>
<Card title="Opt-ins and local assistance">
<div className="toggle-grid">
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
</div>
<p className="muted small-note">These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.</p>
</Card>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
<Card title="Opt-ins and local assistance" collapsible>
<div className="toggle-grid">
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
</div>
<p className="muted small-note">These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.</p>
</Card>
</div>
</>
)}
</LoadingFrame>
</div>
);
}
function PolicySelect({ label, value, disabled, onChange, options = behaviorOptions }: { label: string; value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
function PolicyTable({ children }: { children: ReactNode }) {
return (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</FormField>
<div className="campaign-policy-table policy-table">
<div className="campaign-policy-row policy-row campaign-policy-row-header policy-row-header">
<span>Policy</span>
<span>Setting</span>
<span>Effective behavior</span>
</div>
{children}
</div>
);
}
function PolicyRow({ label, note, control, effective }: { label: string; note: string; control: ReactNode; effective: string }) {
return (
<div className="campaign-policy-row policy-row">
<div className="campaign-policy-label policy-field-label">
<strong>{label}</strong>
<small>{note}</small>
</div>
<div className="campaign-policy-control policy-control">{control}</div>
<p className="muted small-note campaign-policy-effective-note policy-effective-note">{effective}</p>
</div>
);
}
function PolicySelectControl({ value, disabled, onChange, options = behaviorOptions }: { value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
return (
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
);
}
function behaviorSummary(value: string): string {
if (value === "block") return "Blocks the affected message.";
if (value === "ask") return "Marks the message for review.";
if (value === "drop") return "Excludes the affected message.";
if (value === "continue") return "Continues without a review issue.";
if (value === "warn") return "Keeps sending possible with a warning.";
return "Uses the configured behavior.";
}