Add institutional provenance and approval gates
This commit is contained in:
@@ -406,6 +406,16 @@ export type CampaignDeliveryOptions = {
|
||||
version_id: string;
|
||||
worker_queue_available: boolean;
|
||||
postbox_available: boolean;
|
||||
approval_gate: {
|
||||
configured?: boolean;
|
||||
available?: boolean;
|
||||
approved?: boolean;
|
||||
state?: string;
|
||||
explanation?: string | null;
|
||||
request_id?: string;
|
||||
request_revision?: number;
|
||||
subject_digest?: string;
|
||||
};
|
||||
synchronous_send: {
|
||||
allowed?: boolean;
|
||||
reason?: string | null;
|
||||
@@ -420,6 +430,26 @@ export type CampaignDeliveryOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
export type CampaignApprovalRequestPayload = {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
steps: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
selectors: Array<{
|
||||
kind: "account" | "group" | "role" | "function_assignment" | "any_account";
|
||||
value: string;
|
||||
}>;
|
||||
required_approvals: number;
|
||||
rejection_policy: "fail_fast" | "collect";
|
||||
signature_required: boolean;
|
||||
forbidden_evidence_roles: Array<"author" | "owner" | "validator" | "builder" | "reviewer">;
|
||||
}>;
|
||||
unique_actors_across_steps: boolean;
|
||||
policy_refs: string[];
|
||||
idempotency_key: string;
|
||||
};
|
||||
|
||||
export type CampaignSendNowPayload = {
|
||||
version_id?: string | null;
|
||||
include_warnings?: boolean;
|
||||
@@ -1148,6 +1178,19 @@ versionId?: string | null)
|
||||
return apiFetch<CampaignDeliveryOptions>(settings, `/api/v1/campaigns/${campaignId}/delivery-options${query}`);
|
||||
}
|
||||
|
||||
export async function requestCampaignApproval(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignApprovalRequestPayload)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${campaignId}/versions/${versionId}/approval-request`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendCampaignNow(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
pauseCampaign,
|
||||
previewCampaignAttachments,
|
||||
queueCampaign,
|
||||
requestCampaignApproval,
|
||||
resumeCampaign,
|
||||
retryCampaignJobs,
|
||||
sendCampaignJob,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
type CampaignSummary } from
|
||||
"../../api/campaigns";
|
||||
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
||||
import { Button, Dialog, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
||||
import { Button, Dialog, FormField, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridQueryState } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
@@ -106,7 +107,7 @@ import {
|
||||
synchronousSendReason
|
||||
} from "./review/reviewPresentation";
|
||||
|
||||
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "queue" | "control" | "retry" | "imap" | "";
|
||||
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "approval" | "send" | "queue" | "control" | "retry" | "imap" | "";
|
||||
|
||||
|
||||
const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"];
|
||||
@@ -138,6 +139,7 @@ export default function ReviewSendPage({
|
||||
const canQueueForWorkers = hasScope(auth, "campaigns:campaign:queue");
|
||||
const canControlDelivery = hasScope(auth, "campaigns:campaign:control");
|
||||
const canRetryDelivery = hasScope(auth, "campaigns:campaign:retry");
|
||||
const canRequestApproval = hasScope(auth, "campaigns:campaign:review");
|
||||
const [deliveryOptions, setDeliveryOptions] = useState<CampaignDeliveryOptions | null>(null);
|
||||
const [deliveryOptionsLoading, setDeliveryOptionsLoading] = useState(false);
|
||||
const campaignJson = useMemo(() => getCampaignJson(version), [version]);
|
||||
@@ -186,6 +188,12 @@ export default function ReviewSendPage({
|
||||
const [attachmentLinking, setAttachmentLinking] = useState(false);
|
||||
const [attachmentLockConfirmOpen, setAttachmentLockConfirmOpen] = useState(false);
|
||||
const [reviewConfirmOpen, setReviewConfirmOpen] = useState(false);
|
||||
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
|
||||
const [approvalSelectorKind, setApprovalSelectorKind] = useState<"account" | "group" | "role" | "function_assignment" | "any_account">("role");
|
||||
const [approvalSelectorValue, setApprovalSelectorValue] = useState("");
|
||||
const [approvalRequiredCount, setApprovalRequiredCount] = useState(1);
|
||||
const [approvalSignatureRequired, setApprovalSignatureRequired] = useState(false);
|
||||
const [approvalExcludedRoles, setApprovalExcludedRoles] = useState<Set<"author" | "owner" | "validator" | "builder" | "reviewer">>(() => new Set());
|
||||
const [dryRun, setDryRun] = useState(false);
|
||||
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
|
||||
const [queueConfirmOpen, setQueueConfirmOpen] = useState(false);
|
||||
@@ -215,6 +223,7 @@ export default function ReviewSendPage({
|
||||
setAttachmentPreview(null);
|
||||
setAttachmentPreviewError("");
|
||||
setAttachmentLockConfirmOpen(false);
|
||||
setApprovalDialogOpen(false);
|
||||
setSendResult(null);
|
||||
setQueueResult(null);
|
||||
setImapAppendResult(null);
|
||||
@@ -343,6 +352,11 @@ export default function ReviewSendPage({
|
||||
const synchronousEligibleCount = numberFrom(synchronousSendOption, ["eligible_recipient_job_count"]);
|
||||
const synchronousSendAllowed = synchronousSendOption.allowed === true;
|
||||
const workerQueueAvailable = deliveryOptions?.worker_queue_available === true;
|
||||
const approvalGate = asRecord(deliveryOptions?.approval_gate);
|
||||
const approvalGateConfigured = approvalGate.configured === true;
|
||||
const approvalGateAvailable = approvalGate.available === true;
|
||||
const approvalGateApproved = approvalGate.approved === true;
|
||||
const approvalGateBlocksLiveDelivery = approvalGateConfigured && !approvalGateApproved;
|
||||
const persistedDeliveryMode = version?.delivery_mode ?? null;
|
||||
const persistedDeliveryModeSelectedAt = version?.delivery_mode_selected_at ?? null;
|
||||
const selectedDryRun = dryRun && !directQueuedSendAllowed;
|
||||
@@ -523,7 +537,7 @@ export default function ReviewSendPage({
|
||||
const mockStateDisplayLabel = !mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : !mockVerificationRequired ? "i18n:govoplan-campaign.optional.0c6c4102" : stateLabel(mockState);
|
||||
const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || mockPartial || downstreamDeliveryActivity);
|
||||
const singleMessageSendWorkflowBlocked = ["archived", "cancelled"].includes(currentWorkflowState);
|
||||
const canStartSingleMessageSend = Boolean(version && !historicalVersion && !userLockedVersion && readyForDelivery && hasBuild && mockGateSatisfied && !singleMessageSendWorkflowBlocked);
|
||||
const canStartSingleMessageSend = Boolean(version && !historicalVersion && !userLockedVersion && readyForDelivery && hasBuild && mockGateSatisfied && !approvalGateBlocksLiveDelivery && !singleMessageSendWorkflowBlocked);
|
||||
const sendLockReason = !inspectionSatisfied ?
|
||||
"i18n:govoplan-campaign.build_and_complete_the_required_message_review_f.c0cd00fe" :
|
||||
mockWorkflowRequired ?
|
||||
@@ -837,9 +851,43 @@ export default function ReviewSendPage({
|
||||
}
|
||||
}
|
||||
|
||||
async function createApprovalGate() {
|
||||
if (!version || busy || !canRequestApproval || !approvalGateAvailable || approvalGateConfigured || approvalSelectorKind !== "any_account" && !approvalSelectorValue.trim()) return;
|
||||
setBusy("approval");
|
||||
setError("");
|
||||
setMessage("Requesting approval for the exact built execution...");
|
||||
try {
|
||||
await requestCampaignApproval(settings, campaignId, version.id, {
|
||||
title: `Approve delivery of ${data.campaign?.name ?? "Campaign"}`,
|
||||
description: "Approval is bound to this version's immutable execution snapshot.",
|
||||
steps: [{
|
||||
key: "release",
|
||||
label: "Approve delivery",
|
||||
selectors: [{ kind: approvalSelectorKind, value: approvalSelectorKind === "any_account" ? "*" : approvalSelectorValue.trim() }],
|
||||
required_approvals: approvalRequiredCount,
|
||||
rejection_policy: "fail_fast",
|
||||
signature_required: approvalSignatureRequired,
|
||||
forbidden_evidence_roles: [...approvalExcludedRoles]
|
||||
}],
|
||||
unique_actors_across_steps: true,
|
||||
policy_refs: [],
|
||||
idempotency_key: crypto.randomUUID()
|
||||
});
|
||||
setApprovalDialogOpen(false);
|
||||
setMessage("Approval requested. Real delivery remains unavailable until the request is approved.");
|
||||
await reload();
|
||||
await refreshDeliveryOptions(false);
|
||||
} catch (err) {
|
||||
setMessage("");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function runSendNow() {
|
||||
if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted) return;
|
||||
const effectiveDryRun = dryRun && !directQueuedSendAllowed;
|
||||
if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted || !effectiveDryRun && approvalGateBlocksLiveDelivery) return;
|
||||
setBusy("send");
|
||||
setMessage(effectiveDryRun ?
|
||||
"i18n:govoplan-campaign.checking_the_built_queue_without_sending.e15bb876" :
|
||||
@@ -881,7 +929,7 @@ export default function ReviewSendPage({
|
||||
}
|
||||
|
||||
async function runQueueForWorkers() {
|
||||
if (!version || busy || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0) return;
|
||||
if (!version || busy || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || approvalGateBlocksLiveDelivery || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0) return;
|
||||
setBusy("queue");
|
||||
setMessage("i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2");
|
||||
setQueueResult(null);
|
||||
@@ -1259,6 +1307,17 @@ export default function ReviewSendPage({
|
||||
detail: messagesPerMinute > 0 ? `${messagesPerMinute} message(s) per minute; estimated minimum duration ${estimatedMinutes ?? "unknown"} minute(s).` : "No explicit rate limit is configured for this execution.",
|
||||
state: messagesPerMinute > 0 ? "ready" : "warning"
|
||||
},
|
||||
{
|
||||
label: "Approval",
|
||||
detail: approvalGateConfigured ?
|
||||
approvalGateApproved ?
|
||||
`Approval ${String(approvalGate.request_id ?? "")} covers this exact execution snapshot.` :
|
||||
String(approvalGate.explanation ?? "The configured approval request is not approved yet.") :
|
||||
approvalGateAvailable ?
|
||||
"No approval gate is configured. Add one when this delivery requires independent release authority." :
|
||||
"The optional Approvals module is not active; no Campaign approval gate is configured.",
|
||||
state: approvalGateConfigured ? approvalGateApproved ? "ready" : "blocked" : "info"
|
||||
},
|
||||
{
|
||||
label: "i18n:govoplan-campaign.delivery_mode.109ed9d1",
|
||||
detail: deliveryOptionsLoading ?
|
||||
@@ -1527,6 +1586,18 @@ export default function ReviewSendPage({
|
||||
<div><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
|
||||
</div>
|
||||
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
|
||||
<div className="review-send-controls">
|
||||
<span>Approval gate</span>
|
||||
<StatusBadge
|
||||
status={approvalGateConfigured ? approvalGateApproved ? "approved" : String(approvalGate.state ?? "pending") : "not_required"}
|
||||
label={approvalGateConfigured ? approvalGateApproved ? "Approved" : humanize(String(approvalGate.state ?? "pending")) : "Not required"} />
|
||||
{!approvalGateConfigured && approvalGateAvailable && canRequestApproval &&
|
||||
<Button disabled={Boolean(busy) || readOnlyVersion || !hasBuild} onClick={() => setApprovalDialogOpen(true)}>
|
||||
<ShieldCheck size={16} aria-hidden="true" />Request approval
|
||||
</Button>}
|
||||
{approvalGateConfigured &&
|
||||
<Button disabled={Boolean(busy)} onClick={() => navigate("/approvals")}>Open approval</Button>}
|
||||
</div>
|
||||
<div className="review-flow-fact-grid">
|
||||
<WorkflowFact label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
|
||||
@@ -1549,12 +1620,12 @@ export default function ReviewSendPage({
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setQueueConfirmOpen(true)}
|
||||
disabled={!version || Boolean(busy) || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0}>
|
||||
disabled={!version || Boolean(busy) || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || approvalGateBlocksLiveDelivery || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0}>
|
||||
{busy === "queue" ? "i18n:govoplan-campaign.queueing_for_workers_.d24584fe" : "i18n:govoplan-campaign.queue_for_workers.dae9a3e4"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)}
|
||||
disabled={!version || Boolean(busy) || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
|
||||
disabled={!version || Boolean(busy) || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || !selectedDryRun && approvalGateBlocksLiveDelivery || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
|
||||
|
||||
{busy === "send" ?
|
||||
selectedDryRun ? "i18n:govoplan-campaign.running_dry_run.779d1f54" : "i18n:govoplan-campaign.sending.cf765512" :
|
||||
@@ -1770,6 +1841,58 @@ export default function ReviewSendPage({
|
||||
|
||||
}
|
||||
|
||||
<Dialog
|
||||
open={approvalDialogOpen}
|
||||
title="Request Campaign approval"
|
||||
portal
|
||||
closeDisabled={busy === "approval"}
|
||||
onClose={() => setApprovalDialogOpen(false)}
|
||||
footer={<>
|
||||
<Button disabled={busy === "approval"} onClick={() => setApprovalDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy === "approval" || approvalSelectorKind !== "any_account" && !approvalSelectorValue.trim() || approvalRequiredCount < 1}
|
||||
onClick={() => void createApprovalGate()}>
|
||||
{busy === "approval" ? "Requesting..." : "Request approval"}
|
||||
</Button>
|
||||
</>}>
|
||||
<div className="form-grid">
|
||||
<p className="muted small-note form-grid-wide">The request covers the exact built message, attachment, recipient, policy, and transport snapshot. Rebuilding invalidates and removes this gate.</p>
|
||||
<FormField label="Approver type">
|
||||
<select value={approvalSelectorKind} disabled={busy === "approval"} onChange={(event) => setApprovalSelectorKind(event.target.value as typeof approvalSelectorKind)}>
|
||||
<option value="account">Account</option>
|
||||
<option value="group">Group</option>
|
||||
<option value="role">Role</option>
|
||||
<option value="function_assignment">Function assignment</option>
|
||||
<option value="any_account">Any account</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Approver reference">
|
||||
<input value={approvalSelectorKind === "any_account" ? "*" : approvalSelectorValue} disabled={busy === "approval" || approvalSelectorKind === "any_account"} onChange={(event) => setApprovalSelectorValue(event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Required approvals">
|
||||
<input type="number" min={1} max={500} value={approvalRequiredCount} disabled={busy === "approval"} onChange={(event) => setApprovalRequiredCount(Math.max(1, Number(event.target.value) || 1))} />
|
||||
</FormField>
|
||||
<ToggleSwitch label="Require signature evidence" checked={approvalSignatureRequired} disabled={busy === "approval"} onChange={setApprovalSignatureRequired} />
|
||||
<div className="form-grid-wide">
|
||||
<span className="field-label">Actors excluded by four-eyes policy</span>
|
||||
<div className="button-row compact-actions">
|
||||
{(["author", "owner", "validator", "builder", "reviewer"] as const).map((role) =>
|
||||
<ToggleSwitch
|
||||
key={role}
|
||||
label={humanize(role)}
|
||||
checked={approvalExcludedRoles.has(role)}
|
||||
disabled={busy === "approval"}
|
||||
onChange={(checked) => setApprovalExcludedRoles((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(role); else next.delete(role);
|
||||
return next;
|
||||
})} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={attachmentLockConfirmOpen}
|
||||
title="i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653"
|
||||
|
||||
Reference in New Issue
Block a user