2658 lines
99 KiB
TypeScript
2658 lines
99 KiB
TypeScript
import { MetricGrid } from "@govoplan/core-webui";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Archive,
|
|
Boxes,
|
|
Building2,
|
|
KeyRound,
|
|
Eye,
|
|
Inbox,
|
|
Pencil,
|
|
Plus,
|
|
RefreshCw,
|
|
Rocket,
|
|
Save,
|
|
Trash2
|
|
} from "lucide-react";
|
|
import { FormGrid,
|
|
ActionBlockerHint,
|
|
AdminPageLayout,
|
|
Button,
|
|
ConfirmDialog,
|
|
Dialog,
|
|
DocumentationHelpLink,
|
|
FormField,
|
|
IconButton,
|
|
MetricCard,
|
|
SegmentedControl,
|
|
SelectionList,
|
|
SelectionListItem,
|
|
StatePanel,
|
|
StatusBadge,
|
|
ToggleSwitch,
|
|
i18nMessage,
|
|
useUnsavedChanges,
|
|
useUnsavedDraftGuard,
|
|
type ApiSettings
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
archivePostbox,
|
|
createPostboxProtectionTransition,
|
|
createExactPostbox,
|
|
createPostboxTemplate,
|
|
listAdminPostboxes,
|
|
listPostboxProtectionProfiles,
|
|
listPostboxProtectionTransitions,
|
|
listPostboxOrganizationTargets,
|
|
listPostboxTemplates,
|
|
materializePostboxTemplate,
|
|
previewPostboxTemplate,
|
|
publishPostboxTemplate,
|
|
retirePostboxTemplate,
|
|
revisePostboxTemplate,
|
|
updatePostboxProtectionPolicy,
|
|
updatePostboxGroupingPolicy,
|
|
type PostboxDirectoryItem,
|
|
type PostboxExactCreatePayload,
|
|
type PostboxGroupingPolicy,
|
|
type PostboxOrganizationFunction,
|
|
type PostboxOrganizationStructure,
|
|
type PostboxOrganizationUnit,
|
|
type PostboxProtectionPolicy,
|
|
type PostboxProtectionProfile,
|
|
type PostboxProtectionProfileId,
|
|
type PostboxProtectionTransition,
|
|
type PostboxRoutingPolicy,
|
|
type PostboxTemplate,
|
|
type PostboxTemplateCreatePayload,
|
|
type PostboxTemplatePreview,
|
|
type PostboxTemplateRevisionPayload
|
|
} from "../../api/postbox";
|
|
import {
|
|
POSTBOX_ADMIN_DOCUMENTATION,
|
|
POSTBOX_FIELD_DOCUMENTATION,
|
|
POSTBOX_INTERFACE_I18N,
|
|
postboxBusyReason
|
|
} from "./interfacePatterns";
|
|
|
|
|
|
type AdminMode = "templates" | "postboxes";
|
|
type TemplateDraft = PostboxTemplateCreatePayload & { templateId: string };
|
|
type ExactDraft = PostboxExactCreatePayload;
|
|
type MaterializeDraft = {
|
|
templateId: string;
|
|
organization_unit_id: string;
|
|
function_id: string;
|
|
context_key: string;
|
|
};
|
|
type ProtectionTransitionDraft = {
|
|
target_profile: PostboxProtectionProfileId;
|
|
target_vault_id: string;
|
|
history_mode: "future_only" | "migrate_history";
|
|
authority_mode: "user_consent" | "institutional_key_holders" | "dual_control";
|
|
required_quorum: number;
|
|
user_consent_refs: string;
|
|
institutional_authorization_refs: string;
|
|
reason: string;
|
|
acknowledge_irreversibility: boolean;
|
|
};
|
|
|
|
const protectionPolicyDefaults = (): PostboxProtectionPolicy => ({
|
|
new_incumbent_history: "since_assignment",
|
|
history_days: null,
|
|
ordinary_rotation: "rewrap",
|
|
compromise_rotation: "reencrypt",
|
|
recovery_authority: "institutional_key_holders",
|
|
recovery_quorum: 2,
|
|
handover_authority: "dual_control",
|
|
handover_quorum: 2,
|
|
emergency_access: "dual_control",
|
|
emergency_quorum: 2,
|
|
export_authority: "dual_control",
|
|
export_quorum: 2,
|
|
destruction_authority: "dual_control",
|
|
destruction_quorum: 2,
|
|
external_recipient_assurance: "strong_identity",
|
|
vacancy_escalation_content_access: "metadata_only"
|
|
});
|
|
|
|
const groupingPolicyDefaults = (): PostboxGroupingPolicy => ({
|
|
mode: "allow",
|
|
reason: null
|
|
});
|
|
|
|
const routingDefaults = (): PostboxRoutingPolicy => ({
|
|
linked_copy: {
|
|
enabled: false,
|
|
structure_id: null,
|
|
relation_type_ids: [],
|
|
max_depth: 1,
|
|
stop_unit_id: null,
|
|
stop_unit_type_id: null,
|
|
target_function_type_id: null,
|
|
target_template_id: null,
|
|
fanout: "nearest",
|
|
allowed_classifications: ["internal"],
|
|
allowed_producer_modules: [],
|
|
require_expiry: false,
|
|
max_retention_days: null
|
|
},
|
|
attention: {
|
|
mode: "none",
|
|
delay_minutes: null
|
|
},
|
|
shared_visibility: {
|
|
mode: "none"
|
|
}
|
|
});
|
|
|
|
const templateDefaults = (): TemplateDraft => ({
|
|
templateId: "",
|
|
slug: "",
|
|
name: "",
|
|
description: "",
|
|
function_type_id: null,
|
|
scope_kind: "tenant",
|
|
scope_id: null,
|
|
scope_structure_id: null,
|
|
scope_relation_type_ids: [],
|
|
name_pattern: "{unit_name} / {function_name}",
|
|
address_pattern: "{template_slug}.{unit_slug}.{function_slug}",
|
|
classification: "internal",
|
|
allow_vacant_delivery: true,
|
|
portal_visible: false,
|
|
encryption_profile: "server_envelope_v1",
|
|
encryption_vault_id: "",
|
|
protection_policy: protectionPolicyDefaults(),
|
|
grouping_policy: groupingPolicyDefaults(),
|
|
routing_policy: routingDefaults()
|
|
});
|
|
|
|
const exactDefaults = (): ExactDraft => ({
|
|
name: "",
|
|
description: "",
|
|
organization_unit_id: "",
|
|
function_id: "",
|
|
address_key: "",
|
|
classification: "internal",
|
|
portal_visible: false,
|
|
encryption_profile: "server_envelope_v1",
|
|
encryption_vault_id: "",
|
|
protection_policy: protectionPolicyDefaults(),
|
|
grouping_policy: groupingPolicyDefaults()
|
|
});
|
|
|
|
const protectionTransitionDefaults = (
|
|
postbox?: PostboxDirectoryItem | null
|
|
): ProtectionTransitionDraft => {
|
|
const authority = postbox?.protection_policy?.handover_authority || "dual_control";
|
|
return {
|
|
target_profile: postbox?.encryption_profile === "server_envelope_v1"
|
|
? "external_e2ee_v1"
|
|
: "server_envelope_v1",
|
|
target_vault_id: postbox?.encryption_vault_id || "",
|
|
history_mode: "future_only",
|
|
authority_mode: authority,
|
|
required_quorum: Math.max(
|
|
authority === "dual_control" ? 2 : 1,
|
|
postbox?.protection_policy?.handover_quorum || 1
|
|
),
|
|
user_consent_refs: "",
|
|
institutional_authorization_refs: "",
|
|
reason: "",
|
|
acknowledge_irreversibility: false
|
|
};
|
|
};
|
|
|
|
export default function PostboxAdminPanel({
|
|
settings,
|
|
canManageBindings,
|
|
canManageTemplates
|
|
}: {
|
|
settings: ApiSettings;
|
|
canManageBindings: boolean;
|
|
canManageTemplates: boolean;
|
|
}) {
|
|
const [mode, setMode] = useState<AdminMode>(
|
|
canManageTemplates ? "templates" : "postboxes"
|
|
);
|
|
const [templates, setTemplates] = useState<PostboxTemplate[]>([]);
|
|
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
|
|
const [units, setUnits] = useState<PostboxOrganizationUnit[]>([]);
|
|
const [structures, setStructures] = useState<PostboxOrganizationStructure[]>([]);
|
|
const [protectionProfiles, setProtectionProfiles] = useState<PostboxProtectionProfile[]>([]);
|
|
const [protectionTransitions, setProtectionTransitions] = useState<PostboxProtectionTransition[]>([]);
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState("");
|
|
const [selectedPostboxId, setSelectedPostboxId] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [success, setSuccess] = useState("");
|
|
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
|
|
const [templateDraft, setTemplateDraft] = useState<TemplateDraft>(templateDefaults);
|
|
const [templateBaseline, setTemplateBaseline] = useState<TemplateDraft>(templateDefaults);
|
|
const [templatePreview, setTemplatePreview] = useState<PostboxTemplatePreview | null>(null);
|
|
const [templatePreviewLoading, setTemplatePreviewLoading] = useState(false);
|
|
const [exactDialogOpen, setExactDialogOpen] = useState(false);
|
|
const [exactDraft, setExactDraft] = useState<ExactDraft>(exactDefaults);
|
|
const [exactBaseline, setExactBaseline] = useState<ExactDraft>(exactDefaults);
|
|
const [materializeDialogOpen, setMaterializeDialogOpen] = useState(false);
|
|
const [materializeDraft, setMaterializeDraft] = useState<MaterializeDraft>({
|
|
templateId: "",
|
|
organization_unit_id: "",
|
|
function_id: "",
|
|
context_key: ""
|
|
});
|
|
const [materializeBaseline, setMaterializeBaseline] = useState<MaterializeDraft>({
|
|
templateId: "",
|
|
organization_unit_id: "",
|
|
function_id: "",
|
|
context_key: ""
|
|
});
|
|
const [archiveTarget, setArchiveTarget] = useState<PostboxDirectoryItem | null>(null);
|
|
const [retireTarget, setRetireTarget] = useState<PostboxTemplate | null>(null);
|
|
const [protectionDialogOpen, setProtectionDialogOpen] = useState(false);
|
|
const [protectionDraft, setProtectionDraft] = useState<ProtectionTransitionDraft>(protectionTransitionDefaults);
|
|
const [protectionBaseline, setProtectionBaseline] = useState<ProtectionTransitionDraft>(protectionTransitionDefaults);
|
|
const [policyDialogOpen, setPolicyDialogOpen] = useState(false);
|
|
const [policyDraft, setPolicyDraft] = useState<PostboxProtectionPolicy>(protectionPolicyDefaults);
|
|
const [policyBaseline, setPolicyBaseline] = useState<PostboxProtectionPolicy>(protectionPolicyDefaults);
|
|
const [groupingPolicyDialogOpen, setGroupingPolicyDialogOpen] = useState(false);
|
|
const [groupingPolicyDraft, setGroupingPolicyDraft] = useState<PostboxGroupingPolicy>(groupingPolicyDefaults);
|
|
const [groupingPolicyBaseline, setGroupingPolicyBaseline] = useState<PostboxGroupingPolicy>(groupingPolicyDefaults);
|
|
const { requestDiscard } = useUnsavedChanges();
|
|
|
|
const selectedTemplate = useMemo(
|
|
() => templates.find((template) => template.id === selectedTemplateId) ?? templates[0] ?? null,
|
|
[templates, selectedTemplateId]
|
|
);
|
|
const selectedPostbox = useMemo(
|
|
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? postboxes[0] ?? null,
|
|
[postboxes, selectedPostboxId]
|
|
);
|
|
const functionTypes = useMemo(() => {
|
|
const values = new Map<string, string>();
|
|
for (const unit of units) {
|
|
for (const fn of unit.functions) {
|
|
if (fn.function_type_id && !values.has(fn.function_type_id)) {
|
|
values.set(fn.function_type_id, fn.name);
|
|
}
|
|
}
|
|
}
|
|
return [...values].map(([id, name]) => ({ id, name }));
|
|
}, [units]);
|
|
const unitTypes = useMemo(() => {
|
|
const values = new Map<string, string>();
|
|
for (const unit of units) {
|
|
if (unit.unit_type_id && !values.has(unit.unit_type_id)) {
|
|
values.set(unit.unit_type_id, unit.name);
|
|
}
|
|
}
|
|
return [...values].map(([id, example]) => ({ id, example }));
|
|
}, [units]);
|
|
const templateDirty = templateDialogOpen && draftKey(templateDraft) !== draftKey(templateBaseline);
|
|
const exactDirty = exactDialogOpen && draftKey(exactDraft) !== draftKey(exactBaseline);
|
|
const materializeDirty = materializeDialogOpen && draftKey(materializeDraft) !== draftKey(materializeBaseline);
|
|
const protectionDirty = protectionDialogOpen && draftKey(protectionDraft) !== draftKey(protectionBaseline);
|
|
const policyDirty = policyDialogOpen && draftKey(policyDraft) !== draftKey(policyBaseline);
|
|
const groupingPolicyDirty = groupingPolicyDialogOpen
|
|
&& draftKey(groupingPolicyDraft) !== draftKey(groupingPolicyBaseline);
|
|
|
|
useUnsavedDraftGuard({
|
|
dirty: templateDirty || exactDirty || materializeDirty || protectionDirty || policyDirty || groupingPolicyDirty,
|
|
title: "Unsaved Postbox administration draft",
|
|
message: "Save or discard the open Postbox administration draft before leaving this surface.",
|
|
onSave: saveActiveDraft,
|
|
onDiscard: discardAdminDraft
|
|
});
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const [nextTemplates, nextPostboxes, organizationTargets, profileCatalog] = await Promise.all([
|
|
canManageTemplates ? listPostboxTemplates(settings) : Promise.resolve([]),
|
|
canManageBindings ? listAdminPostboxes(settings) : Promise.resolve([]),
|
|
listPostboxOrganizationTargets(settings),
|
|
listPostboxProtectionProfiles(settings)
|
|
]);
|
|
setTemplates(nextTemplates);
|
|
setPostboxes(nextPostboxes);
|
|
setUnits(organizationTargets.units);
|
|
setStructures(organizationTargets.structures);
|
|
setProtectionProfiles(profileCatalog.profiles);
|
|
setSelectedTemplateId((current) =>
|
|
current && nextTemplates.some((template) => template.id === current)
|
|
? current
|
|
: nextTemplates[0]?.id ?? ""
|
|
);
|
|
setSelectedPostboxId((current) =>
|
|
current && nextPostboxes.some((postbox) => postbox.id === current)
|
|
? current
|
|
: nextPostboxes[0]?.id ?? ""
|
|
);
|
|
} catch (loadError) {
|
|
setError(errorMessage(loadError));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [canManageBindings, canManageTemplates, settings]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
if (!canManageBindings || !selectedPostboxId) {
|
|
setProtectionTransitions([]);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
void listPostboxProtectionTransitions(settings, selectedPostboxId)
|
|
.then((values) => {
|
|
if (!cancelled) setProtectionTransitions(values);
|
|
})
|
|
.catch((transitionError) => {
|
|
if (!cancelled) setError(errorMessage(transitionError));
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [canManageBindings, selectedPostboxId, settings]);
|
|
|
|
function openNewTemplate() {
|
|
const next = templateDefaults();
|
|
setTemplatePreview(null);
|
|
setTemplateDraft(next);
|
|
setTemplateBaseline(next);
|
|
setTemplateDialogOpen(true);
|
|
}
|
|
|
|
function openTemplateRevision(template: PostboxTemplate) {
|
|
const revision = currentRevision(template);
|
|
if (!revision) return;
|
|
const next = {
|
|
templateId: template.id,
|
|
slug: template.slug,
|
|
name: template.name,
|
|
description: template.description || "",
|
|
function_type_id: revision.function_type_id ?? null,
|
|
scope_kind: revision.scope_kind,
|
|
scope_id: revision.scope_id ?? null,
|
|
scope_structure_id: revision.scope_structure_id ?? null,
|
|
scope_relation_type_ids: revision.scope_relation_type_ids ?? [],
|
|
name_pattern: revision.name_pattern,
|
|
address_pattern: revision.address_pattern,
|
|
classification: revision.classification,
|
|
allow_vacant_delivery: revision.allow_vacant_delivery,
|
|
portal_visible: revision.portal_visible,
|
|
encryption_profile: revision.encryption_profile,
|
|
encryption_vault_id: revision.encryption_vault_id ?? "",
|
|
protection_policy: revision.protection_policy ?? protectionPolicyDefaults(),
|
|
grouping_policy: revision.grouping_policy ?? groupingPolicyDefaults(),
|
|
routing_policy: revision.routing_policy ?? routingDefaults()
|
|
};
|
|
setTemplatePreview(null);
|
|
setTemplateDraft(next);
|
|
setTemplateBaseline(next);
|
|
setTemplateDialogOpen(true);
|
|
}
|
|
|
|
async function saveTemplate(): Promise<boolean> {
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
if (templateDraft.templateId) {
|
|
const currentTemplate = templates.find(
|
|
(item) => item.id === templateDraft.templateId
|
|
);
|
|
if (!currentTemplate) throw new Error("The template is no longer available.");
|
|
await revisePostboxTemplate(
|
|
settings,
|
|
currentTemplate,
|
|
revisionPayload(templateDraft)
|
|
);
|
|
setSuccess("A new immutable template revision was created.");
|
|
} else {
|
|
await createPostboxTemplate(settings, {
|
|
slug: templateDraft.slug,
|
|
name: templateDraft.name,
|
|
description: templateDraft.description || null,
|
|
...revisionPayload(templateDraft)
|
|
});
|
|
setSuccess("Postbox template created as a draft.");
|
|
}
|
|
setTemplateBaseline(templateDraft);
|
|
setTemplateDialogOpen(false);
|
|
await load();
|
|
return true;
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function previewTemplate() {
|
|
setTemplatePreviewLoading(true);
|
|
setError("");
|
|
try {
|
|
const preview = await previewPostboxTemplate(settings, {
|
|
slug: templateDraft.slug,
|
|
name: templateDraft.name,
|
|
description: templateDraft.description || null,
|
|
...revisionPayload(templateDraft),
|
|
template_id: templateDraft.templateId || null,
|
|
limit: 200
|
|
});
|
|
setTemplatePreview(preview);
|
|
} catch (actionError) {
|
|
setTemplatePreview(null);
|
|
setError(errorMessage(actionError));
|
|
} finally {
|
|
setTemplatePreviewLoading(false);
|
|
}
|
|
}
|
|
|
|
async function publishSelected() {
|
|
if (!selectedTemplate) return;
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
await publishPostboxTemplate(
|
|
settings,
|
|
selectedTemplate,
|
|
selectedTemplate.current_revision
|
|
);
|
|
setSuccess(i18nMessage("i18n:govoplan-postbox.published_revision_message", {
|
|
revision: selectedTemplate.current_revision
|
|
}));
|
|
await load();
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openExact() {
|
|
const unit = units.find((item) => item.functions.length);
|
|
const next = {
|
|
...exactDefaults(),
|
|
organization_unit_id: unit?.id ?? "",
|
|
function_id: unit?.functions[0]?.id ?? ""
|
|
};
|
|
setExactDraft(next);
|
|
setExactBaseline(next);
|
|
setExactDialogOpen(true);
|
|
}
|
|
|
|
async function saveExact(): Promise<boolean> {
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
await createExactPostbox(settings, {
|
|
...exactDraft,
|
|
description: exactDraft.description || null,
|
|
address_key: exactDraft.address_key || null
|
|
});
|
|
setExactBaseline(exactDraft);
|
|
setExactDialogOpen(false);
|
|
setSuccess("Exact function-bound Postbox created.");
|
|
await load();
|
|
return true;
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openProtectionTransition() {
|
|
if (!selectedPostbox) return;
|
|
const next = protectionTransitionDefaults(selectedPostbox);
|
|
setProtectionDraft(next);
|
|
setProtectionBaseline(next);
|
|
setProtectionDialogOpen(true);
|
|
}
|
|
|
|
async function saveProtectionTransition(): Promise<boolean> {
|
|
if (!selectedPostbox) return false;
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
const transition = await createPostboxProtectionTransition(
|
|
settings,
|
|
selectedPostbox,
|
|
{
|
|
idempotency_key: crypto.randomUUID(),
|
|
target_profile: protectionDraft.target_profile,
|
|
target_vault_id: protectionDraft.target_profile === "server_envelope_v1"
|
|
? protectionDraft.target_vault_id.trim() || null
|
|
: null,
|
|
history_mode: protectionDraft.history_mode,
|
|
authority_mode: protectionDraft.authority_mode,
|
|
required_quorum: protectionDraft.required_quorum,
|
|
user_consent_refs: lineSeparated(protectionDraft.user_consent_refs),
|
|
institutional_authorization_refs: lineSeparated(
|
|
protectionDraft.institutional_authorization_refs
|
|
),
|
|
reason: protectionDraft.reason.trim(),
|
|
acknowledge_irreversibility: protectionDraft.acknowledge_irreversibility
|
|
}
|
|
);
|
|
setProtectionTransitions((current) => [
|
|
transition,
|
|
...current.filter((item) => item.id !== transition.id)
|
|
]);
|
|
setProtectionBaseline(protectionDraft);
|
|
setProtectionDialogOpen(false);
|
|
setSuccess(
|
|
transition.state === "completed"
|
|
? "Postbox protection profile changed and historical content migration completed."
|
|
: "Postbox protection profile changed for new messages. Historical content is awaiting approved client transformations."
|
|
);
|
|
await load();
|
|
return true;
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openProtectionPolicy() {
|
|
if (!selectedPostbox) return;
|
|
const next = selectedPostbox.protection_policy || protectionPolicyDefaults();
|
|
setPolicyDraft(next);
|
|
setPolicyBaseline(next);
|
|
setPolicyDialogOpen(true);
|
|
}
|
|
|
|
async function saveProtectionPolicy(): Promise<boolean> {
|
|
if (!selectedPostbox) return false;
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
await updatePostboxProtectionPolicy(settings, selectedPostbox, policyDraft);
|
|
setPolicyBaseline(policyDraft);
|
|
setPolicyDialogOpen(false);
|
|
setSuccess("Postbox protection and hand-over policy updated.");
|
|
await load();
|
|
return true;
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openGroupingPolicy() {
|
|
if (!selectedPostbox) return;
|
|
const next = selectedPostbox.grouping_policy || groupingPolicyDefaults();
|
|
setGroupingPolicyDraft(next);
|
|
setGroupingPolicyBaseline(next);
|
|
setGroupingPolicyDialogOpen(true);
|
|
}
|
|
|
|
async function saveGroupingPolicy(): Promise<boolean> {
|
|
if (!selectedPostbox) return false;
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
await updatePostboxGroupingPolicy(
|
|
settings,
|
|
selectedPostbox,
|
|
groupingPolicyDraft
|
|
);
|
|
setGroupingPolicyBaseline(groupingPolicyDraft);
|
|
setGroupingPolicyDialogOpen(false);
|
|
setSuccess("Unified-inbox separation policy updated.");
|
|
await load();
|
|
return true;
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openMaterialize(template: PostboxTemplate) {
|
|
const revision = currentRevision(template);
|
|
const compatible = compatibleTargets(units, revision?.function_type_id);
|
|
const unit = compatible[0];
|
|
const next = {
|
|
templateId: template.id,
|
|
organization_unit_id: unit?.id ?? "",
|
|
function_id: unit?.functions[0]?.id ?? "",
|
|
context_key: ""
|
|
};
|
|
setMaterializeDraft(next);
|
|
setMaterializeBaseline(next);
|
|
setMaterializeDialogOpen(true);
|
|
}
|
|
|
|
async function materialize(): Promise<boolean> {
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
await materializePostboxTemplate(settings, materializeDraft.templateId, {
|
|
organization_unit_id: materializeDraft.organization_unit_id,
|
|
function_id: materializeDraft.function_id,
|
|
context_key: materializeDraft.context_key || null
|
|
});
|
|
setMaterializeBaseline(materializeDraft);
|
|
setMaterializeDialogOpen(false);
|
|
setSuccess("Stable Postbox address resolved and materialized.");
|
|
await load();
|
|
return true;
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function confirmArchive() {
|
|
if (!archiveTarget) return;
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
await archivePostbox(settings, archiveTarget);
|
|
setSuccess("Postbox archived. Messages and delivery evidence were retained.");
|
|
setArchiveTarget(null);
|
|
await load();
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function confirmRetire() {
|
|
if (!retireTarget) return;
|
|
setSelectedTemplateId(retireTarget.id);
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
await retirePostboxTemplate(settings, retireTarget);
|
|
setSuccess("Postbox template retired. Existing addresses remain durable.");
|
|
setRetireTarget(null);
|
|
await load();
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function saveActiveDraft(): Promise<boolean> {
|
|
if (templateDirty) return saveTemplate();
|
|
if (exactDirty) return saveExact();
|
|
if (materializeDirty) return materialize();
|
|
if (protectionDirty) return saveProtectionTransition();
|
|
if (policyDirty) return saveProtectionPolicy();
|
|
if (groupingPolicyDirty) return saveGroupingPolicy();
|
|
return Promise.resolve(true);
|
|
}
|
|
|
|
function discardAdminDraft() {
|
|
if (templateDialogOpen) {
|
|
setTemplateDraft(templateBaseline);
|
|
setTemplateDialogOpen(false);
|
|
}
|
|
if (exactDialogOpen) {
|
|
setExactDraft(exactBaseline);
|
|
setExactDialogOpen(false);
|
|
}
|
|
if (materializeDialogOpen) {
|
|
setMaterializeDraft(materializeBaseline);
|
|
setMaterializeDialogOpen(false);
|
|
}
|
|
if (protectionDialogOpen) {
|
|
setProtectionDraft(protectionBaseline);
|
|
setProtectionDialogOpen(false);
|
|
}
|
|
if (policyDialogOpen) {
|
|
setPolicyDraft(policyBaseline);
|
|
setPolicyDialogOpen(false);
|
|
}
|
|
if (groupingPolicyDialogOpen) {
|
|
setGroupingPolicyDraft(groupingPolicyBaseline);
|
|
setGroupingPolicyDialogOpen(false);
|
|
}
|
|
}
|
|
|
|
function closeTemplateDialog() {
|
|
const close = () => {
|
|
setTemplateDraft(templateBaseline);
|
|
setTemplateDialogOpen(false);
|
|
};
|
|
if (templateDirty) requestDiscard(close);
|
|
else close();
|
|
}
|
|
|
|
function closeExactDialog() {
|
|
const close = () => {
|
|
setExactDraft(exactBaseline);
|
|
setExactDialogOpen(false);
|
|
};
|
|
if (exactDirty) requestDiscard(close);
|
|
else close();
|
|
}
|
|
|
|
function closeMaterializeDialog() {
|
|
const close = () => {
|
|
setMaterializeDraft(materializeBaseline);
|
|
setMaterializeDialogOpen(false);
|
|
};
|
|
if (materializeDirty) requestDiscard(close);
|
|
else close();
|
|
}
|
|
|
|
function closeProtectionDialog() {
|
|
const close = () => {
|
|
setProtectionDraft(protectionBaseline);
|
|
setProtectionDialogOpen(false);
|
|
};
|
|
if (protectionDirty) requestDiscard(close);
|
|
else close();
|
|
}
|
|
|
|
function closePolicyDialog() {
|
|
const close = () => {
|
|
setPolicyDraft(policyBaseline);
|
|
setPolicyDialogOpen(false);
|
|
};
|
|
if (policyDirty) requestDiscard(close);
|
|
else close();
|
|
}
|
|
|
|
function closeGroupingPolicyDialog() {
|
|
const close = () => {
|
|
setGroupingPolicyDraft(groupingPolicyBaseline);
|
|
setGroupingPolicyDialogOpen(false);
|
|
};
|
|
if (groupingPolicyDirty) requestDiscard(close);
|
|
else close();
|
|
}
|
|
|
|
return (
|
|
<AdminPageLayout
|
|
title="Postboxes"
|
|
description="Manage durable organization-function addresses and reusable templates."
|
|
loading={loading}
|
|
error={error}
|
|
success={success}
|
|
className="postbox-admin-page"
|
|
actions={
|
|
<>
|
|
<IconButton
|
|
label="Refresh"
|
|
icon={<RefreshCw size={16} />}
|
|
onClick={() => requestDiscard(() => void load())}
|
|
disabled={loading || busy}
|
|
disabledReason={postboxBusyReason(loading, busy)}
|
|
/>
|
|
<DocumentationHelpLink reference={POSTBOX_ADMIN_DOCUMENTATION} />
|
|
{mode === "templates" && canManageTemplates ? (
|
|
<Button variant="primary" onClick={openNewTemplate} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
|
|
<Plus size={16} /> New template
|
|
</Button>
|
|
) : null}
|
|
{mode === "postboxes" && canManageBindings ? (
|
|
<Button variant="primary" onClick={openExact} disabled={busy || !units.some((unit) => unit.functions.length)} disabledReason={postboxBusyReason(false, busy) ?? (!units.some((unit) => unit.functions.length) ? POSTBOX_INTERFACE_I18N.organizationTargetSummary : undefined)}>
|
|
<Plus size={16} /> Exact postbox
|
|
</Button>
|
|
) : null}
|
|
</>
|
|
}
|
|
>
|
|
<SegmentedControl
|
|
value={mode}
|
|
onChange={(value) => setMode(value as AdminMode)}
|
|
options={[
|
|
...(canManageTemplates ? [{ id: "templates", label: "Templates" }] : []),
|
|
...(canManageBindings ? [{ id: "postboxes", label: "Postboxes" }] : [])
|
|
]}
|
|
ariaLabel="Postbox administration section"
|
|
/>
|
|
{mode === "postboxes" && !units.some((unit) => unit.functions.length) ? (
|
|
<ActionBlockerHint
|
|
reason={{
|
|
summary: POSTBOX_INTERFACE_I18N.organizationTargetSummary,
|
|
requiredAction: POSTBOX_INTERFACE_I18N.organizationTargetAction,
|
|
actor: POSTBOX_INTERFACE_I18N.organizationTargetActor,
|
|
target: POSTBOX_INTERFACE_I18N.organizationTargetDestination
|
|
}}
|
|
labels={{
|
|
requiredAction: POSTBOX_INTERFACE_I18N.requiredAction,
|
|
actor: POSTBOX_INTERFACE_I18N.actor,
|
|
target: POSTBOX_INTERFACE_I18N.destination
|
|
}}
|
|
documentation={POSTBOX_ADMIN_DOCUMENTATION}
|
|
/>
|
|
) : null}
|
|
{mode === "templates" ? (
|
|
<TemplateWorkspace
|
|
templates={templates}
|
|
selected={selectedTemplate}
|
|
onSelect={setSelectedTemplateId}
|
|
onRevise={openTemplateRevision}
|
|
onPublish={() => void publishSelected()}
|
|
onRetire={() => selectedTemplate && setRetireTarget(selectedTemplate)}
|
|
onMaterialize={openMaterialize}
|
|
busy={busy}
|
|
canManageBindings={canManageBindings}
|
|
/>
|
|
) : (
|
|
<PostboxWorkspace
|
|
postboxes={postboxes}
|
|
selected={selectedPostbox}
|
|
transitions={protectionTransitions}
|
|
onSelect={setSelectedPostboxId}
|
|
onChangeProtection={openProtectionTransition}
|
|
onEditPolicy={openProtectionPolicy}
|
|
onEditGroupingPolicy={openGroupingPolicy}
|
|
onArchive={setArchiveTarget}
|
|
busy={busy}
|
|
/>
|
|
)}
|
|
|
|
<TemplateDialog
|
|
open={templateDialogOpen}
|
|
draft={templateDraft}
|
|
units={units}
|
|
structures={structures}
|
|
templates={templates}
|
|
protectionProfiles={protectionProfiles}
|
|
functionTypes={functionTypes}
|
|
unitTypes={unitTypes}
|
|
busy={busy}
|
|
preview={templatePreview}
|
|
previewLoading={templatePreviewLoading}
|
|
onChange={(draft) => {
|
|
setTemplateDraft(draft);
|
|
setTemplatePreview(null);
|
|
}}
|
|
onPreview={() => void previewTemplate()}
|
|
onClose={closeTemplateDialog}
|
|
onSave={() => void saveTemplate()}
|
|
/>
|
|
<ExactPostboxDialog
|
|
open={exactDialogOpen}
|
|
draft={exactDraft}
|
|
units={units}
|
|
protectionProfiles={protectionProfiles}
|
|
busy={busy}
|
|
onChange={setExactDraft}
|
|
onClose={closeExactDialog}
|
|
onSave={() => void saveExact()}
|
|
/>
|
|
<MaterializeDialog
|
|
open={materializeDialogOpen}
|
|
draft={materializeDraft}
|
|
template={templates.find((item) => item.id === materializeDraft.templateId) ?? null}
|
|
units={units}
|
|
busy={busy}
|
|
onChange={setMaterializeDraft}
|
|
onClose={closeMaterializeDialog}
|
|
onSave={() => void materialize()}
|
|
/>
|
|
<ProtectionTransitionDialog
|
|
open={protectionDialogOpen}
|
|
postbox={selectedPostbox}
|
|
draft={protectionDraft}
|
|
profiles={protectionProfiles}
|
|
busy={busy}
|
|
onChange={setProtectionDraft}
|
|
onClose={closeProtectionDialog}
|
|
onSave={() => void saveProtectionTransition()}
|
|
/>
|
|
<ProtectionPolicyDialog
|
|
open={policyDialogOpen}
|
|
postbox={selectedPostbox}
|
|
draft={policyDraft}
|
|
profiles={protectionProfiles}
|
|
busy={busy}
|
|
onChange={setPolicyDraft}
|
|
onClose={closePolicyDialog}
|
|
onSave={() => void saveProtectionPolicy()}
|
|
/>
|
|
<GroupingPolicyDialog
|
|
open={groupingPolicyDialogOpen}
|
|
postbox={selectedPostbox}
|
|
draft={groupingPolicyDraft}
|
|
busy={busy}
|
|
onChange={setGroupingPolicyDraft}
|
|
onClose={closeGroupingPolicyDialog}
|
|
onSave={() => void saveGroupingPolicy()}
|
|
/>
|
|
<ConfirmDialog
|
|
open={Boolean(archiveTarget)}
|
|
title="Archive Postbox"
|
|
message={
|
|
archiveTarget
|
|
? i18nMessage("i18n:govoplan-postbox.archive_confirmation", { name: archiveTarget.name })
|
|
: ""
|
|
}
|
|
confirmLabel="Archive"
|
|
tone="danger"
|
|
busy={busy}
|
|
onConfirm={() => void confirmArchive()}
|
|
onCancel={() => setArchiveTarget(null)}
|
|
/>
|
|
<ConfirmDialog
|
|
open={Boolean(retireTarget)}
|
|
title="Retire Postbox template"
|
|
message={retireTarget
|
|
? i18nMessage("i18n:govoplan-postbox.retire_template_confirmation", { name: retireTarget.name })
|
|
: ""}
|
|
confirmLabel="Retire"
|
|
tone="danger"
|
|
busy={busy}
|
|
onConfirm={() => void confirmRetire()}
|
|
onCancel={() => setRetireTarget(null)}
|
|
/>
|
|
</AdminPageLayout>
|
|
);
|
|
}
|
|
|
|
function TemplateWorkspace({
|
|
templates,
|
|
selected,
|
|
onSelect,
|
|
onRevise,
|
|
onPublish,
|
|
onRetire,
|
|
onMaterialize,
|
|
busy,
|
|
canManageBindings
|
|
}: {
|
|
templates: PostboxTemplate[];
|
|
selected: PostboxTemplate | null;
|
|
onSelect: (id: string) => void;
|
|
onRevise: (template: PostboxTemplate) => void;
|
|
onPublish: () => void;
|
|
onRetire: () => void;
|
|
onMaterialize: (template: PostboxTemplate) => void;
|
|
busy: boolean;
|
|
canManageBindings: boolean;
|
|
}) {
|
|
const revision = selected ? currentRevision(selected) : null;
|
|
return (
|
|
<div className="postbox-admin-workspace">
|
|
<aside className="postbox-admin-list">
|
|
{!templates.length ? <p className="postbox-note">No Postbox templates.</p> : null}
|
|
{templates.length ? (
|
|
<SelectionList label="Postbox templates">
|
|
{templates.map((template) => (
|
|
<SelectionListItem
|
|
key={template.id}
|
|
selected={selected?.id === template.id}
|
|
onClick={() => onSelect(template.id)}
|
|
>
|
|
<span className="postbox-item-title">
|
|
<strong>{template.name}</strong>
|
|
<StatusBadge status={template.status} />
|
|
</span>
|
|
<span className="postbox-item-context">
|
|
{template.slug} · {i18nMessage("i18n:govoplan-postbox.revision_label", { revision: template.current_revision })}
|
|
</span>
|
|
</SelectionListItem>
|
|
))}
|
|
</SelectionList>
|
|
) : null}
|
|
</aside>
|
|
<section className="postbox-admin-detail">
|
|
{selected && revision ? (
|
|
<>
|
|
<div className="postbox-admin-detail-heading">
|
|
<div>
|
|
<span className="postbox-detail-kicker">Template</span>
|
|
<h2>{selected.name}</h2>
|
|
<p>{selected.description || "No description."}</p>
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
<Button
|
|
onClick={() => onRevise(selected)}
|
|
disabled={busy || selected.status === "retired"}
|
|
disabledReason={postboxBusyReason(false, busy) ?? (selected.status === "retired" ? POSTBOX_INTERFACE_I18N.retiredTemplate : undefined)}
|
|
>
|
|
<Pencil size={16} /> New revision
|
|
</Button>
|
|
<Button
|
|
onClick={onPublish}
|
|
helpContextId="postbox.admin.templates"
|
|
helpModuleId="postbox"
|
|
disabled={busy || selected.status === "retired" || Boolean(revision.published_at)}
|
|
disabledReason={postboxBusyReason(false, busy) ?? (selected.status === "retired" ? POSTBOX_INTERFACE_I18N.retiredTemplate : revision.published_at ? POSTBOX_INTERFACE_I18N.publishedRevision : undefined)}
|
|
>
|
|
<Save size={16} /> Publish
|
|
</Button>
|
|
{canManageBindings ? (
|
|
<Button
|
|
onClick={() => onMaterialize(selected)}
|
|
disabled={busy || selected.status !== "published"}
|
|
disabledReason={postboxBusyReason(false, busy) ?? (selected.status !== "published" ? POSTBOX_INTERFACE_I18N.unpublishedTemplate : undefined)}
|
|
>
|
|
<Rocket size={16} /> Resolve address
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
variant="danger"
|
|
onClick={onRetire}
|
|
disabled={busy || selected.status === "retired"}
|
|
disabledReason={postboxBusyReason(false, busy) ?? (selected.status === "retired" ? POSTBOX_INTERFACE_I18N.retiredTemplate : undefined)}
|
|
>
|
|
<Trash2 size={16} /> Retire
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<dl className="postbox-admin-properties">
|
|
<div><dt>Revision</dt><dd>{revision.revision}{revision.published_at ? " · Published" : " · Draft"}</dd></div>
|
|
<div><dt>Function type</dt><dd>{revision.function_type_id || "Any function type"}</dd></div>
|
|
<div><dt>Scope</dt><dd>{revision.scope_kind}{revision.scope_id ? ` · ${revision.scope_id}` : ""}</dd></div>
|
|
<div><dt>Classification</dt><dd>{revision.classification}</dd></div>
|
|
<div><dt>Vacant delivery</dt><dd>{revision.allow_vacant_delivery ? "Accepted" : "Blocked"}</dd></div>
|
|
<div>
|
|
<dt>Hierarchy copies</dt>
|
|
<dd>
|
|
{revision.routing_policy.linked_copy.enabled
|
|
? i18nMessage("i18n:govoplan-postbox.depth_label", {
|
|
fanout: revision.routing_policy.linked_copy.fanout,
|
|
depth: revision.routing_policy.linked_copy.max_depth
|
|
})
|
|
: "Disabled"}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Vacancy escalation</dt>
|
|
<dd>
|
|
{revision.routing_policy.attention.mode === "vacancy_escalation"
|
|
? i18nMessage("i18n:govoplan-postbox.minutes_label", {
|
|
minutes: revision.routing_policy.attention.delay_minutes
|
|
})
|
|
: "Disabled"}
|
|
</dd>
|
|
</div>
|
|
<div><dt>Encryption</dt><dd>{revision.encryption_profile}</dd></div>
|
|
<div><dt>Name pattern</dt><dd>{revision.name_pattern}</dd></div>
|
|
<div><dt>Address pattern</dt><dd>{revision.address_pattern}</dd></div>
|
|
</dl>
|
|
<div className="postbox-revision-history">
|
|
<h3>Immutable revisions</h3>
|
|
{selected.revisions.map((item) => (
|
|
<div key={item.id}>
|
|
<strong>{i18nMessage("i18n:govoplan-postbox.revision_label", { revision: item.revision })}</strong>
|
|
<span>{item.classification} · {item.scope_kind}</span>
|
|
<StatusBadge
|
|
status={item.published_at ? "published" : "draft"}
|
|
label={item.published_at ? "Published" : "Draft"}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<StatePanel size="fill" icon={<Boxes size={24} />} title="Select a template" description="Published revisions lazily resolve stable unit-specific addresses." />
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PostboxWorkspace({
|
|
postboxes,
|
|
selected,
|
|
transitions,
|
|
onSelect,
|
|
onChangeProtection,
|
|
onEditPolicy,
|
|
onEditGroupingPolicy,
|
|
onArchive,
|
|
busy
|
|
}: {
|
|
postboxes: PostboxDirectoryItem[];
|
|
selected: PostboxDirectoryItem | null;
|
|
transitions: PostboxProtectionTransition[];
|
|
onSelect: (id: string) => void;
|
|
onChangeProtection: () => void;
|
|
onEditPolicy: () => void;
|
|
onEditGroupingPolicy: () => void;
|
|
onArchive: (postbox: PostboxDirectoryItem) => void;
|
|
busy: boolean;
|
|
}) {
|
|
return (
|
|
<div className="postbox-admin-workspace">
|
|
<aside className="postbox-admin-list">
|
|
{!postboxes.length ? <p className="postbox-note">No materialized Postboxes.</p> : null}
|
|
{postboxes.length ? (
|
|
<SelectionList label="Materialized Postboxes">
|
|
{postboxes.map((postbox) => (
|
|
<SelectionListItem
|
|
key={postbox.id}
|
|
selected={selected?.id === postbox.id}
|
|
onClick={() => onSelect(postbox.id)}
|
|
>
|
|
<span className="postbox-item-title">
|
|
<strong>{postbox.name}</strong>
|
|
<StatusBadge status={postbox.status} />
|
|
</span>
|
|
<span className="postbox-item-context">
|
|
{postbox.organization_unit_name} · {postbox.function_name}
|
|
</span>
|
|
</SelectionListItem>
|
|
))}
|
|
</SelectionList>
|
|
) : null}
|
|
</aside>
|
|
<section className="postbox-admin-detail">
|
|
{selected ? (
|
|
<>
|
|
<div className="postbox-admin-detail-heading">
|
|
<div>
|
|
<span className="postbox-detail-kicker">Postbox</span>
|
|
<h2>{selected.name}</h2>
|
|
<p>{selected.address}</p>
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
<Button
|
|
onClick={onEditPolicy}
|
|
disabled={busy || selected.status !== "active"}
|
|
disabledReason={postboxBusyReason(false, busy) ?? (selected.status !== "active" ? POSTBOX_INTERFACE_I18N.archivedPostbox : undefined)}
|
|
>
|
|
<Pencil size={16} /> Edit policy
|
|
</Button>
|
|
<Button
|
|
onClick={onEditGroupingPolicy}
|
|
disabled={busy || selected.status !== "active"}
|
|
disabledReason={postboxBusyReason(false, busy) ?? (selected.status !== "active" ? POSTBOX_INTERFACE_I18N.archivedPostbox : undefined)}
|
|
>
|
|
<Boxes size={16} /> Inbox separation
|
|
</Button>
|
|
<Button
|
|
onClick={onChangeProtection}
|
|
disabled={busy || selected.status !== "active"}
|
|
disabledReason={postboxBusyReason(false, busy) ?? (selected.status !== "active" ? POSTBOX_INTERFACE_I18N.archivedPostbox : undefined)}
|
|
>
|
|
<KeyRound size={16} /> Change protection
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
onClick={() => onArchive(selected)}
|
|
disabled={busy || selected.status !== "active"}
|
|
disabledReason={postboxBusyReason(false, busy) ?? (selected.status !== "active" ? POSTBOX_INTERFACE_I18N.archivedPostbox : undefined)}
|
|
>
|
|
<Archive size={16} /> Archive
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<dl className="postbox-admin-properties">
|
|
<div><dt>Organization unit</dt><dd>{selected.organization_unit_name || "None"}</dd></div>
|
|
<div><dt>Function</dt><dd>{selected.function_name || "None"}</dd></div>
|
|
<div><dt>Address key</dt><dd>{selected.address_key}</dd></div>
|
|
<div><dt>Classification</dt><dd>{selected.classification}</dd></div>
|
|
<div><dt>Current holders</dt><dd>{selected.holder_count}</dd></div>
|
|
<div><dt>Vacancy</dt><dd>{selected.vacant ? "Vacant" : "Staffed"}</dd></div>
|
|
<div><dt>Context</dt><dd>{selected.context_key || "None"}</dd></div>
|
|
<div><dt>Template revision</dt><dd>{selected.template_revision_id || "Exact Postbox"}</dd></div>
|
|
<div><dt>Protection</dt><dd>{protectionProfileLabel(selected.encryption_profile)}</dd></div>
|
|
<div><dt>Key epoch</dt><dd>{selected.key_epoch}</dd></div>
|
|
<div><dt>New incumbent history</dt><dd>{selected.protection_policy.new_incumbent_history.replaceAll("_", " ")}</dd></div>
|
|
<div><dt>External recipient assurance</dt><dd>{selected.protection_policy.external_recipient_assurance.replaceAll("_", " ")}</dd></div>
|
|
<div><dt>Unified-inbox policy</dt><dd>{selected.grouping_policy.mode.replaceAll("_", " ")}</dd></div>
|
|
</dl>
|
|
{transitions.length ? (
|
|
<div className="postbox-revision-history">
|
|
<h3>Protection transitions</h3>
|
|
{transitions.slice(0, 5).map((transition) => (
|
|
<div key={transition.id}>
|
|
<strong>{protectionProfileLabel(transition.target_profile)}</strong>
|
|
<span>
|
|
{transition.history_mode.replaceAll("_", " ")} · {transition.completed_count}/{transition.message_count} messages
|
|
</span>
|
|
<StatusBadge status={transition.state} label={transition.state.replaceAll("_", " ")} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<StatePanel size="fill" icon={<Inbox size={24} />} title="Select a Postbox" description="Materialized Postboxes remain durable through vacancy and reassignment." />
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TemplateDialog({
|
|
open,
|
|
draft,
|
|
units,
|
|
structures,
|
|
templates,
|
|
protectionProfiles,
|
|
functionTypes,
|
|
unitTypes,
|
|
busy,
|
|
preview,
|
|
previewLoading,
|
|
onChange,
|
|
onPreview,
|
|
onClose,
|
|
onSave
|
|
}: {
|
|
open: boolean;
|
|
draft: TemplateDraft;
|
|
units: PostboxOrganizationUnit[];
|
|
structures: PostboxOrganizationStructure[];
|
|
templates: PostboxTemplate[];
|
|
protectionProfiles: PostboxProtectionProfile[];
|
|
functionTypes: Array<{ id: string; name: string }>;
|
|
unitTypes: Array<{ id: string; example: string }>;
|
|
busy: boolean;
|
|
preview: PostboxTemplatePreview | null;
|
|
previewLoading: boolean;
|
|
onChange: (draft: TemplateDraft) => void;
|
|
onPreview: () => void;
|
|
onClose: () => void;
|
|
onSave: () => void;
|
|
}) {
|
|
const isRevision = Boolean(draft.templateId);
|
|
const scopeOptions = draft.scope_kind === "unit_type"
|
|
? unitTypes.map((item) => ({ id: item.id, label: `${item.id} (${item.example})` }))
|
|
: units.map((unit) => ({ id: unit.id, label: unit.name }));
|
|
const linkedCopy = draft.routing_policy.linked_copy;
|
|
const attention = draft.routing_policy.attention;
|
|
const selectedScopeStructure = structures.find(
|
|
(item) => item.id === draft.scope_structure_id
|
|
);
|
|
const selectedStructure = structures.find(
|
|
(item) => item.id === linkedCopy.structure_id
|
|
);
|
|
const selectedProtectionProfile = protectionProfiles.find(
|
|
(item) => item.id === draft.encryption_profile
|
|
);
|
|
const updateRouting = (routing_policy: PostboxRoutingPolicy) => {
|
|
onChange({ ...draft, routing_policy });
|
|
};
|
|
const updateLinkedCopy = (
|
|
next: Partial<PostboxRoutingPolicy["linked_copy"]>
|
|
) => {
|
|
updateRouting({
|
|
...draft.routing_policy,
|
|
linked_copy: {
|
|
...linkedCopy,
|
|
...next
|
|
}
|
|
});
|
|
};
|
|
const valid =
|
|
draft.name.trim() &&
|
|
draft.slug.trim() &&
|
|
draft.name_pattern.trim() &&
|
|
draft.address_pattern.trim() &&
|
|
(draft.scope_kind === "tenant" || Boolean(draft.scope_id)) &&
|
|
(draft.scope_kind !== "subtree" || Boolean(draft.scope_structure_id)) &&
|
|
selectedProtectionProfile?.available &&
|
|
(draft.encryption_profile !== "server_envelope_v1" || Boolean(draft.encryption_vault_id?.trim())) &&
|
|
(
|
|
!linkedCopy.enabled
|
|
|| Boolean(
|
|
linkedCopy.structure_id
|
|
&& linkedCopy.target_function_type_id
|
|
&& linkedCopy.target_template_id
|
|
&& linkedCopy.allowed_classifications.length
|
|
&& linkedCopy.allowed_producer_modules.length
|
|
)
|
|
);
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
title={isRevision ? "Create template revision" : "New Postbox template"}
|
|
className="postbox-dialog postbox-template-dialog"
|
|
onClose={onClose}
|
|
closeDisabled={busy}
|
|
footer={
|
|
<div className="button-row compact-actions">
|
|
<Button
|
|
onClick={onPreview}
|
|
disabled={busy || previewLoading || !valid}
|
|
disabledReason={postboxBusyReason(false, busy || previewLoading) ?? (!valid ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
|
|
>
|
|
<Eye size={16} /> {previewLoading ? "Checking impact" : "Preview impact"}
|
|
</Button>
|
|
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={onSave}
|
|
disabled={busy || !valid}
|
|
disabledReason={postboxBusyReason(false, busy) ?? (!valid ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
|
|
>
|
|
{isRevision ? "Create revision" : "Create draft"}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<FormGrid columns={2} gap="small" collapseAt="narrow" className="postbox-form-grid">
|
|
<FormField label="Name" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
value={draft.name}
|
|
disabled={isRevision}
|
|
onChange={(event) => onChange({ ...draft, name: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Slug" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
value={draft.slug}
|
|
disabled={isRevision}
|
|
onChange={(event) => onChange({ ...draft, slug: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Description" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
value={draft.description || ""}
|
|
disabled={isRevision}
|
|
onChange={(event) => onChange({ ...draft, description: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Function type" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={draft.function_type_id || ""}
|
|
onChange={(event) => onChange({
|
|
...draft,
|
|
function_type_id: event.target.value || null
|
|
})}
|
|
>
|
|
<option value="">Any function type</option>
|
|
{functionTypes.map((item) => (
|
|
<option key={item.id} value={item.id}>{item.name} · {item.id}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Scope" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={draft.scope_kind}
|
|
onChange={(event) => {
|
|
const scope_kind = event.target.value as TemplateDraft["scope_kind"];
|
|
onChange({
|
|
...draft,
|
|
scope_kind,
|
|
scope_id: scope_kind === "tenant" ? null : "",
|
|
scope_structure_id: null,
|
|
scope_relation_type_ids: []
|
|
});
|
|
}}
|
|
>
|
|
<option value="tenant">Tenant</option>
|
|
<option value="unit">One unit</option>
|
|
<option value="subtree">Unit subtree</option>
|
|
<option value="unit_type">Unit type</option>
|
|
</select>
|
|
</FormField>
|
|
{draft.scope_kind !== "tenant" ? (
|
|
<FormField label="Scope target" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={draft.scope_id || ""}
|
|
onChange={(event) => onChange({ ...draft, scope_id: event.target.value || null })}
|
|
>
|
|
<option value="">Select target</option>
|
|
{scopeOptions.map((item) => (
|
|
<option key={item.id} value={item.id}>{item.label}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
) : <div />}
|
|
{draft.scope_kind === "subtree" ? (
|
|
<>
|
|
<FormField label="Hierarchy structure" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={draft.scope_structure_id || ""}
|
|
onChange={(event) => onChange({
|
|
...draft,
|
|
scope_structure_id: event.target.value || null,
|
|
scope_relation_type_ids: []
|
|
})}
|
|
>
|
|
<option value="">Select structure</option>
|
|
{structures.filter((item) => item.status === "active").map((item) => (
|
|
<option key={item.id} value={item.id}>{item.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Hierarchy relation types" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<div className="postbox-relation-options">
|
|
{(selectedScopeStructure?.relation_types || [])
|
|
.filter((item) => item.status === "active" && item.is_hierarchical)
|
|
.map((item) => (
|
|
<label key={item.id}>
|
|
<input
|
|
type="checkbox"
|
|
checked={draft.scope_relation_type_ids.includes(item.id)}
|
|
onChange={(event) => onChange({
|
|
...draft,
|
|
scope_relation_type_ids: event.target.checked
|
|
? [...draft.scope_relation_type_ids, item.id]
|
|
: draft.scope_relation_type_ids.filter((id) => id !== item.id)
|
|
})}
|
|
/>
|
|
<span>{item.name}</span>
|
|
</label>
|
|
))}
|
|
{selectedScopeStructure && !selectedScopeStructure.relation_types.some(
|
|
(item) => item.status === "active" && item.is_hierarchical
|
|
) ? <span className="postbox-note">No active hierarchical relation type.</span> : null}
|
|
{!selectedScopeStructure ? <span className="postbox-note">All active hierarchical relations are used unless specific types are selected.</span> : null}
|
|
</div>
|
|
</FormField>
|
|
</>
|
|
) : null}
|
|
<FormField label="Name pattern" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
value={draft.name_pattern}
|
|
onChange={(event) => onChange({ ...draft, name_pattern: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Address pattern" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
value={draft.address_pattern}
|
|
onChange={(event) => onChange({ ...draft, address_pattern: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Classification" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
value={draft.classification}
|
|
onChange={(event) => onChange({ ...draft, classification: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<div className="postbox-toggle-field">
|
|
<ToggleSwitch
|
|
label="Accept delivery while vacant"
|
|
help="Messages remain institutionally retained even when no incumbent currently has access."
|
|
checked={draft.allow_vacant_delivery}
|
|
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
|
|
/>
|
|
</div>
|
|
<div className="postbox-toggle-field">
|
|
<ToggleSwitch
|
|
label="Show in Portal"
|
|
help="Portal lists this Postbox only for users whose current function assignment already grants Postbox access."
|
|
checked={draft.portal_visible}
|
|
onChange={(checked) => onChange({ ...draft, portal_visible: checked })}
|
|
/>
|
|
</div>
|
|
<ProtectionConfigurationFields
|
|
profile={draft.encryption_profile as PostboxProtectionProfileId}
|
|
vaultId={draft.encryption_vault_id || ""}
|
|
policy={draft.protection_policy}
|
|
profiles={protectionProfiles}
|
|
onProfileChange={(encryption_profile) => onChange({
|
|
...draft,
|
|
encryption_profile,
|
|
encryption_vault_id: encryption_profile === "server_envelope_v1"
|
|
? draft.encryption_vault_id
|
|
: null
|
|
})}
|
|
onVaultChange={(encryption_vault_id) => onChange({
|
|
...draft,
|
|
encryption_vault_id
|
|
})}
|
|
onPolicyChange={(protection_policy) => onChange({
|
|
...draft,
|
|
protection_policy
|
|
})}
|
|
/>
|
|
<GroupingPolicyFields
|
|
policy={draft.grouping_policy}
|
|
onChange={(grouping_policy) => onChange({ ...draft, grouping_policy })}
|
|
/>
|
|
<div className="postbox-routing-section">
|
|
<div className="postbox-routing-heading">
|
|
<div>
|
|
<strong>Hierarchy linked copies</strong>
|
|
<span>Copy to explicitly bounded function Postboxes in one selected structure.</span>
|
|
<DocumentationHelpLink reference={POSTBOX_FIELD_DOCUMENTATION} />
|
|
</div>
|
|
<ToggleSwitch
|
|
label="Enable hierarchy linked copies"
|
|
help="Copies create independent deliveries and evidence at explicitly bounded hierarchy targets."
|
|
checked={linkedCopy.enabled}
|
|
onChange={(enabled) => updateLinkedCopy({
|
|
enabled,
|
|
allowed_classifications: linkedCopy.allowed_classifications.length
|
|
? linkedCopy.allowed_classifications
|
|
: [draft.classification]
|
|
})}
|
|
/>
|
|
</div>
|
|
{linkedCopy.enabled ? (
|
|
<FormGrid columns={2} gap="small" collapseAt="narrow" className="postbox-form-grid">
|
|
<FormField label="Organization structure" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={linkedCopy.structure_id || ""}
|
|
onChange={(event) => updateLinkedCopy({
|
|
structure_id: event.target.value || null,
|
|
relation_type_ids: []
|
|
})}
|
|
>
|
|
<option value="">Select structure</option>
|
|
{structures.filter((item) => item.status === "active").map((item) => (
|
|
<option key={item.id} value={item.id}>{item.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Relation type" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={linkedCopy.relation_type_ids[0] || ""}
|
|
onChange={(event) => updateLinkedCopy({
|
|
relation_type_ids: event.target.value ? [event.target.value] : []
|
|
})}
|
|
>
|
|
<option value="">All hierarchical relations</option>
|
|
{(selectedStructure?.relation_types || [])
|
|
.filter((item) => item.status === "active" && item.is_hierarchical)
|
|
.map((item) => (
|
|
<option key={item.id} value={item.id}>{item.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Target function type" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={linkedCopy.target_function_type_id || ""}
|
|
onChange={(event) => updateLinkedCopy({
|
|
target_function_type_id: event.target.value || null
|
|
})}
|
|
>
|
|
<option value="">Select function type</option>
|
|
{functionTypes.map((item) => (
|
|
<option key={item.id} value={item.id}>{item.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Target Postbox template" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={linkedCopy.target_template_id || ""}
|
|
onChange={(event) => updateLinkedCopy({
|
|
target_template_id: event.target.value || null
|
|
})}
|
|
>
|
|
<option value="">Select published template</option>
|
|
{templates
|
|
.filter((item) => item.status === "published")
|
|
.map((item) => (
|
|
<option key={item.id} value={item.id}>{item.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Maximum hierarchy depth" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={20}
|
|
value={linkedCopy.max_depth}
|
|
onChange={(event) => updateLinkedCopy({
|
|
max_depth: Math.max(1, Math.min(20, Number(event.target.value) || 1))
|
|
})}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Copy behavior" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={linkedCopy.fanout}
|
|
onChange={(event) => {
|
|
const fanout = event.target.value as "nearest" | "all";
|
|
updateRouting({
|
|
...draft.routing_policy,
|
|
linked_copy: { ...linkedCopy, fanout },
|
|
attention: fanout === "all"
|
|
? { mode: "none", delay_minutes: null }
|
|
: attention
|
|
});
|
|
}}
|
|
>
|
|
<option value="nearest">Nearest matching ancestor</option>
|
|
<option value="all">All matching ancestors</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Optional stop unit" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={linkedCopy.stop_unit_id || ""}
|
|
onChange={(event) => updateLinkedCopy({
|
|
stop_unit_id: event.target.value || null
|
|
})}
|
|
>
|
|
<option value="">No unit stop</option>
|
|
{units.map((item) => (
|
|
<option key={item.id} value={item.id}>{item.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Optional stop unit type" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={linkedCopy.stop_unit_type_id || ""}
|
|
onChange={(event) => updateLinkedCopy({
|
|
stop_unit_type_id: event.target.value || null
|
|
})}
|
|
>
|
|
<option value="">No unit-type stop</option>
|
|
{unitTypes.map((item) => (
|
|
<option key={item.id} value={item.id}>{item.id}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Allowed classifications" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
value={linkedCopy.allowed_classifications.join(", ")}
|
|
onChange={(event) => updateLinkedCopy({
|
|
allowed_classifications: commaSeparated(event.target.value)
|
|
})}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Authorized producer modules" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
placeholder="campaigns, workflow"
|
|
value={linkedCopy.allowed_producer_modules.join(", ")}
|
|
onChange={(event) => updateLinkedCopy({
|
|
allowed_producer_modules: commaSeparated(event.target.value)
|
|
})}
|
|
/>
|
|
</FormField>
|
|
<div className="postbox-toggle-field">
|
|
<ToggleSwitch
|
|
label="Require message expiry"
|
|
help="Reject copied deliveries that do not carry an explicit expiry boundary."
|
|
checked={linkedCopy.require_expiry}
|
|
onChange={(require_expiry) => updateLinkedCopy({ require_expiry })}
|
|
/>
|
|
</div>
|
|
<FormField label="Maximum retention days" documentation={POSTBOX_FIELD_DOCUMENTATION} helpContextId="postbox.field.retention" helpModuleId="postbox">
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={36500}
|
|
value={linkedCopy.max_retention_days ?? ""}
|
|
onChange={(event) => updateLinkedCopy({
|
|
max_retention_days: event.target.value
|
|
? Math.max(1, Math.min(36500, Number(event.target.value)))
|
|
: null
|
|
})}
|
|
/>
|
|
</FormField>
|
|
<div className="postbox-toggle-field">
|
|
<ToggleSwitch
|
|
label="Escalate when the nearest target remains vacant"
|
|
help="Schedules a separate, auditable delivery only after the configured vacancy delay."
|
|
checked={attention.mode === "vacancy_escalation"}
|
|
onChange={(checked) => updateRouting({
|
|
...draft.routing_policy,
|
|
attention: checked
|
|
? {
|
|
mode: "vacancy_escalation",
|
|
delay_minutes: attention.delay_minutes || 1440
|
|
}
|
|
: { mode: "none", delay_minutes: null }
|
|
})}
|
|
/>
|
|
</div>
|
|
{attention.mode === "vacancy_escalation" ? (
|
|
<FormField label="Vacancy escalation delay (minutes)" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={43200}
|
|
value={attention.delay_minutes ?? 1440}
|
|
onChange={(event) => updateRouting({
|
|
...draft.routing_policy,
|
|
attention: {
|
|
mode: "vacancy_escalation",
|
|
delay_minutes: Math.max(
|
|
1,
|
|
Math.min(43200, Number(event.target.value) || 1)
|
|
)
|
|
}
|
|
})}
|
|
/>
|
|
</FormField>
|
|
) : <div />}
|
|
</FormGrid>
|
|
) : null}
|
|
</div>
|
|
</FormGrid>
|
|
{preview ? <TemplateImpactPreview preview={preview} /> : null}
|
|
<p className="postbox-form-note">
|
|
Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.
|
|
</p>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function TemplateImpactPreview({
|
|
preview
|
|
}: {
|
|
preview: PostboxTemplatePreview;
|
|
}) {
|
|
return (
|
|
<section className="postbox-template-preview" aria-label="Template impact preview">
|
|
<div className="postbox-routing-heading">
|
|
<div>
|
|
<strong>Dry-run impact</strong>
|
|
<span>No Postboxes or addresses were created.</span>
|
|
</div>
|
|
{preview.truncated ? <StatusBadge status="warning" label="List truncated" /> : null}
|
|
</div>
|
|
<MetricGrid columns="auto" density="compact" spacing="none" minimum="compact">
|
|
<MetricCard label="Targets" value={preview.total} tone="info" />
|
|
<MetricCard label="Ready" value={preview.ready_count} tone="good" />
|
|
<MetricCard label="Existing" value={preview.existing_count} />
|
|
<MetricCard label="Vacant" value={preview.vacant_count} tone="warning" />
|
|
<MetricCard label="Blocked" value={preview.blocked_count} tone="danger" />
|
|
</MetricGrid>
|
|
{preview.diagnostics.length ? (
|
|
<p className="postbox-form-note">{preview.diagnostics.join(", ")}</p>
|
|
) : null}
|
|
<div className="postbox-preview-targets">
|
|
{preview.targets.map((target) => (
|
|
<div key={`${target.organization_unit_id}:${target.function_id}`}>
|
|
<div>
|
|
<strong>{target.name}</strong>
|
|
<span>{target.organization_unit_name} · {target.function_name}</span>
|
|
<code>{target.address}</code>
|
|
{target.diagnostics.length ? (
|
|
<small>{target.diagnostics.join(", ")}</small>
|
|
) : null}
|
|
</div>
|
|
<div className="postbox-preview-status">
|
|
<StatusBadge status={target.status} label={target.status.replaceAll("_", " ")} />
|
|
<span>{target.holder_count} holder{target.holder_count === 1 ? "" : "s"}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{!preview.targets.length ? (
|
|
<p className="postbox-note">The scope contains no matching active function.</p>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ExactPostboxDialog({
|
|
open,
|
|
draft,
|
|
units,
|
|
protectionProfiles,
|
|
busy,
|
|
onChange,
|
|
onClose,
|
|
onSave
|
|
}: {
|
|
open: boolean;
|
|
draft: ExactDraft;
|
|
units: PostboxOrganizationUnit[];
|
|
protectionProfiles: PostboxProtectionProfile[];
|
|
busy: boolean;
|
|
onChange: (draft: ExactDraft) => void;
|
|
onClose: () => void;
|
|
onSave: () => void;
|
|
}) {
|
|
const unit = units.find((item) => item.id === draft.organization_unit_id);
|
|
const selectedProtectionProfile = protectionProfiles.find(
|
|
(item) => item.id === draft.encryption_profile
|
|
);
|
|
const valid = Boolean(
|
|
draft.name.trim()
|
|
&& draft.organization_unit_id
|
|
&& draft.function_id
|
|
&& selectedProtectionProfile?.available
|
|
&& (
|
|
draft.encryption_profile !== "server_envelope_v1"
|
|
|| draft.encryption_vault_id?.trim()
|
|
)
|
|
);
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
title="New exact Postbox"
|
|
className="postbox-dialog"
|
|
onClose={onClose}
|
|
closeDisabled={busy}
|
|
footer={
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={onSave}
|
|
disabled={busy || !valid}
|
|
disabledReason={postboxBusyReason(false, busy) ?? (!valid ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
|
|
>
|
|
Create Postbox
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<FormGrid columns={2} gap="small" collapseAt="narrow" className="postbox-form-grid">
|
|
<FormField label="Name" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input value={draft.name} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
|
|
</FormField>
|
|
<FormField label="Address key" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input value={draft.address_key || ""} placeholder="Generated when empty" onChange={(event) => onChange({ ...draft, address_key: event.target.value })} />
|
|
</FormField>
|
|
<FormField label="Organization unit" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={draft.organization_unit_id}
|
|
onChange={(event) => {
|
|
const nextUnit = units.find((item) => item.id === event.target.value);
|
|
onChange({
|
|
...draft,
|
|
organization_unit_id: event.target.value,
|
|
function_id: nextUnit?.functions[0]?.id ?? ""
|
|
});
|
|
}}
|
|
>
|
|
<option value="">Select unit</option>
|
|
{units.filter((item) => item.functions.length).map((item) => (
|
|
<option key={item.id} value={item.id}>{item.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Function" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select value={draft.function_id} onChange={(event) => onChange({ ...draft, function_id: event.target.value })}>
|
|
<option value="">Select function</option>
|
|
{(unit?.functions || []).map((fn) => (
|
|
<option key={fn.id} value={fn.id}>{fn.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Classification" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input value={draft.classification} onChange={(event) => onChange({ ...draft, classification: event.target.value })} />
|
|
</FormField>
|
|
<FormField label="Description" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input value={draft.description || ""} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
|
|
</FormField>
|
|
<div className="postbox-toggle-field">
|
|
<ToggleSwitch
|
|
label="Show in Portal"
|
|
help="Portal lists this Postbox only when the current user's function assignment grants access."
|
|
checked={draft.portal_visible}
|
|
onChange={(checked) => onChange({ ...draft, portal_visible: checked })}
|
|
/>
|
|
</div>
|
|
<ProtectionConfigurationFields
|
|
profile={draft.encryption_profile}
|
|
vaultId={draft.encryption_vault_id || ""}
|
|
policy={draft.protection_policy}
|
|
profiles={protectionProfiles}
|
|
onProfileChange={(encryption_profile) => onChange({
|
|
...draft,
|
|
encryption_profile,
|
|
encryption_vault_id: encryption_profile === "server_envelope_v1"
|
|
? draft.encryption_vault_id
|
|
: null
|
|
})}
|
|
onVaultChange={(encryption_vault_id) => onChange({
|
|
...draft,
|
|
encryption_vault_id
|
|
})}
|
|
onPolicyChange={(protection_policy) => onChange({
|
|
...draft,
|
|
protection_policy
|
|
})}
|
|
/>
|
|
<GroupingPolicyFields
|
|
policy={draft.grouping_policy}
|
|
onChange={(grouping_policy) => onChange({ ...draft, grouping_policy })}
|
|
/>
|
|
</FormGrid>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function ProtectionConfigurationFields({
|
|
profile,
|
|
vaultId,
|
|
policy,
|
|
profiles,
|
|
profileLocked = false,
|
|
onProfileChange,
|
|
onVaultChange,
|
|
onPolicyChange
|
|
}: {
|
|
profile: PostboxProtectionProfileId;
|
|
vaultId: string;
|
|
policy: PostboxProtectionPolicy;
|
|
profiles: PostboxProtectionProfile[];
|
|
profileLocked?: boolean;
|
|
onProfileChange: (profile: PostboxProtectionProfileId) => void;
|
|
onVaultChange: (vaultId: string) => void;
|
|
onPolicyChange: (policy: PostboxProtectionPolicy) => void;
|
|
}) {
|
|
const selected = profiles.find((item) => item.id === profile);
|
|
const updatePolicy = (next: Partial<PostboxProtectionPolicy>) => {
|
|
onPolicyChange({ ...policy, ...next });
|
|
};
|
|
return (
|
|
<div className="postbox-routing-section">
|
|
<div className="postbox-routing-heading">
|
|
<div>
|
|
<strong>Content protection and hand-over policy</strong>
|
|
<span>
|
|
The institution chooses the protection boundary. Managed envelope encryption is the recommended standard.
|
|
</span>
|
|
<DocumentationHelpLink reference={POSTBOX_FIELD_DOCUMENTATION} />
|
|
</div>
|
|
{selected?.standard ? <StatusBadge status="recommended" label="Standard" /> : null}
|
|
</div>
|
|
<FormGrid columns={2} gap="small" collapseAt="narrow" className="postbox-form-grid">
|
|
<FormField label="Protection profile" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={profile}
|
|
disabled={profileLocked}
|
|
onChange={(event) => onProfileChange(event.target.value as PostboxProtectionProfileId)}
|
|
>
|
|
{profiles.map((item) => (
|
|
<option key={item.id} value={item.id} disabled={!item.available}>
|
|
{item.label}{item.standard ? " · Standard" : ""}{!item.available ? " · Unavailable" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<small>{selected?.description}</small>
|
|
</FormField>
|
|
{profile === "server_envelope_v1" ? (
|
|
<FormField label="Encryption vault" documentation={POSTBOX_FIELD_DOCUMENTATION} helpContextId="postbox.field.protection-profile" helpModuleId="postbox">
|
|
<input
|
|
value={vaultId}
|
|
disabled={profileLocked}
|
|
placeholder="Institutional vault identifier"
|
|
onChange={(event) => onVaultChange(event.target.value)}
|
|
/>
|
|
</FormField>
|
|
) : (
|
|
<div className="postbox-form-note">
|
|
{profile === "external_e2ee_v1"
|
|
? "An approved external client or producer must supply ciphertext, a signed manifest, wrapped keys, and a digest. GovOPlaN cannot decrypt message content."
|
|
: "Content is stored without encryption. Transport and infrastructure controls still apply."}
|
|
</div>
|
|
)}
|
|
<FormField label="New incumbent history" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={policy.new_incumbent_history}
|
|
onChange={(event) => updatePolicy({
|
|
new_incumbent_history: event.target.value as PostboxProtectionPolicy["new_incumbent_history"],
|
|
history_days: event.target.value === "bounded_days" ? policy.history_days || 90 : null
|
|
})}
|
|
>
|
|
<option value="all_retained">All retained messages</option>
|
|
<option value="since_assignment">Only since assignment</option>
|
|
<option value="bounded_days">Bounded look-back</option>
|
|
</select>
|
|
</FormField>
|
|
{policy.new_incumbent_history === "bounded_days" ? (
|
|
<FormField label="History look-back (days)" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={36500}
|
|
value={policy.history_days || 90}
|
|
onChange={(event) => updatePolicy({
|
|
history_days: Math.max(1, Math.min(36500, Number(event.target.value) || 1))
|
|
})}
|
|
/>
|
|
</FormField>
|
|
) : <div />}
|
|
<FormField label="Ordinary key rotation" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={policy.ordinary_rotation}
|
|
onChange={(event) => updatePolicy({
|
|
ordinary_rotation: event.target.value as "rewrap" | "reencrypt"
|
|
})}
|
|
>
|
|
<option value="rewrap">Rewrap content keys</option>
|
|
<option value="reencrypt">Re-encrypt content</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Compromise rotation" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={policy.compromise_rotation}
|
|
onChange={(event) => updatePolicy({
|
|
compromise_rotation: event.target.value as "rewrap" | "reencrypt"
|
|
})}
|
|
>
|
|
<option value="reencrypt">Re-encrypt content</option>
|
|
<option value="rewrap">Rewrap content keys</option>
|
|
</select>
|
|
</FormField>
|
|
<AuthorityField
|
|
label="Recovery authority"
|
|
value={policy.recovery_authority}
|
|
includeDisabled
|
|
onChange={(recovery_authority) => updatePolicy({ recovery_authority })}
|
|
/>
|
|
<QuorumField
|
|
label="Recovery quorum"
|
|
value={policy.recovery_quorum}
|
|
onChange={(recovery_quorum) => updatePolicy({ recovery_quorum })}
|
|
/>
|
|
<AuthorityField
|
|
label="Hand-over authority"
|
|
value={policy.handover_authority}
|
|
onChange={(handover_authority) => updatePolicy({
|
|
handover_authority: handover_authority as PostboxProtectionPolicy["handover_authority"]
|
|
})}
|
|
/>
|
|
<QuorumField
|
|
label="Hand-over quorum"
|
|
value={policy.handover_quorum}
|
|
onChange={(handover_quorum) => updatePolicy({ handover_quorum })}
|
|
/>
|
|
<FormField label="Emergency access" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={policy.emergency_access}
|
|
onChange={(event) => updatePolicy({
|
|
emergency_access: event.target.value as "disabled" | "dual_control"
|
|
})}
|
|
>
|
|
<option value="dual_control">Dual control</option>
|
|
<option value="disabled">Disabled</option>
|
|
</select>
|
|
</FormField>
|
|
<QuorumField
|
|
label="Emergency quorum"
|
|
value={policy.emergency_quorum}
|
|
onChange={(emergency_quorum) => updatePolicy({ emergency_quorum })}
|
|
/>
|
|
<AuthorityField
|
|
label="Export authority"
|
|
value={policy.export_authority}
|
|
onChange={(export_authority) => updatePolicy({
|
|
export_authority: export_authority as PostboxProtectionPolicy["export_authority"]
|
|
})}
|
|
/>
|
|
<QuorumField
|
|
label="Export quorum"
|
|
value={policy.export_quorum}
|
|
onChange={(export_quorum) => updatePolicy({ export_quorum })}
|
|
/>
|
|
<FormField label="Destruction authority" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={policy.destruction_authority}
|
|
onChange={(event) => updatePolicy({
|
|
destruction_authority: event.target.value as PostboxProtectionPolicy["destruction_authority"]
|
|
})}
|
|
>
|
|
<option value="dual_control">Dual control</option>
|
|
<option value="institutional_key_holders">Institutional key holders</option>
|
|
</select>
|
|
</FormField>
|
|
<QuorumField
|
|
label="Destruction quorum"
|
|
value={policy.destruction_quorum}
|
|
onChange={(destruction_quorum) => updatePolicy({ destruction_quorum })}
|
|
/>
|
|
<FormField label="External recipient assurance" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={policy.external_recipient_assurance}
|
|
onChange={(event) => updatePolicy({
|
|
external_recipient_assurance: event.target.value as PostboxProtectionPolicy["external_recipient_assurance"]
|
|
})}
|
|
>
|
|
<option value="strong_identity">Strong identity</option>
|
|
<option value="email_otp">Email plus one-time code</option>
|
|
<option value="disabled">External retrieval disabled</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Vacancy escalation content" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input value="Metadata only" disabled />
|
|
</FormField>
|
|
</FormGrid>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AuthorityField({
|
|
label,
|
|
value,
|
|
includeDisabled = false,
|
|
onChange
|
|
}: {
|
|
label: string;
|
|
value: "disabled" | "user_consent" | "institutional_key_holders" | "dual_control";
|
|
includeDisabled?: boolean;
|
|
onChange: (value: "disabled" | "user_consent" | "institutional_key_holders" | "dual_control") => void;
|
|
}) {
|
|
return (
|
|
<FormField label={label} documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select value={value} onChange={(event) => onChange(event.target.value as typeof value)}>
|
|
<option value="dual_control">Dual control</option>
|
|
<option value="institutional_key_holders">Institutional key holders</option>
|
|
<option value="user_consent">User consent</option>
|
|
{includeDisabled ? <option value="disabled">Disabled</option> : null}
|
|
</select>
|
|
</FormField>
|
|
);
|
|
}
|
|
|
|
function GroupingPolicyFields({
|
|
policy,
|
|
onChange
|
|
}: {
|
|
policy: PostboxGroupingPolicy;
|
|
onChange: (policy: PostboxGroupingPolicy) => void;
|
|
}) {
|
|
return (
|
|
<div className="postbox-protection-section">
|
|
<div className="postbox-routing-heading">
|
|
<div>
|
|
<strong>Unified-inbox separation</strong>
|
|
<span>Keep source containers and institutional responsibilities visibly separated where required.</span>
|
|
<DocumentationHelpLink reference={POSTBOX_FIELD_DOCUMENTATION} />
|
|
</div>
|
|
</div>
|
|
<FormGrid>
|
|
<FormField label="Grouping rule" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={policy.mode}
|
|
onChange={(event) => onChange({
|
|
...policy,
|
|
mode: event.target.value as PostboxGroupingPolicy["mode"]
|
|
})}
|
|
>
|
|
<option value="allow">May be combined</option>
|
|
<option value="same_classification">Only with the same classification</option>
|
|
<option value="separate">Always keep this Postbox separate</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Explanation" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
value={policy.reason || ""}
|
|
placeholder="Why this separation is required"
|
|
onChange={(event) => onChange({
|
|
...policy,
|
|
reason: event.target.value || null
|
|
})}
|
|
/>
|
|
</FormField>
|
|
</FormGrid>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function QuorumField({
|
|
label,
|
|
value,
|
|
onChange
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
onChange: (value: number) => void;
|
|
}) {
|
|
return (
|
|
<FormField label={label} documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={20}
|
|
value={value}
|
|
onChange={(event) => onChange(Math.max(1, Math.min(20, Number(event.target.value) || 1)))}
|
|
/>
|
|
</FormField>
|
|
);
|
|
}
|
|
|
|
function ProtectionPolicyDialog({
|
|
open,
|
|
postbox,
|
|
draft,
|
|
profiles,
|
|
busy,
|
|
onChange,
|
|
onClose,
|
|
onSave
|
|
}: {
|
|
open: boolean;
|
|
postbox: PostboxDirectoryItem | null;
|
|
draft: PostboxProtectionPolicy;
|
|
profiles: PostboxProtectionProfile[];
|
|
busy: boolean;
|
|
onChange: (policy: PostboxProtectionPolicy) => void;
|
|
onClose: () => void;
|
|
onSave: () => void;
|
|
}) {
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
title={postbox ? `Edit policy for ${postbox.name}` : "Edit Postbox policy"}
|
|
className="postbox-dialog postbox-template-dialog"
|
|
onClose={onClose}
|
|
closeDisabled={busy}
|
|
footer={
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
|
|
<Button variant="primary" onClick={onSave} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
|
|
<Save size={16} /> Save policy
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
{postbox ? (
|
|
<ProtectionConfigurationFields
|
|
profile={postbox.encryption_profile}
|
|
vaultId={postbox.encryption_vault_id || ""}
|
|
policy={draft}
|
|
profiles={profiles}
|
|
profileLocked
|
|
onProfileChange={() => undefined}
|
|
onVaultChange={() => undefined}
|
|
onPolicyChange={onChange}
|
|
/>
|
|
) : null}
|
|
<p className="postbox-form-note">
|
|
Changing this policy affects future access and governance decisions. It does not change the content-protection profile or rewrite retained messages.
|
|
</p>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function GroupingPolicyDialog({
|
|
open,
|
|
postbox,
|
|
draft,
|
|
busy,
|
|
onChange,
|
|
onClose,
|
|
onSave
|
|
}: {
|
|
open: boolean;
|
|
postbox: PostboxDirectoryItem | null;
|
|
draft: PostboxGroupingPolicy;
|
|
busy: boolean;
|
|
onChange: (policy: PostboxGroupingPolicy) => void;
|
|
onClose: () => void;
|
|
onSave: () => void;
|
|
}) {
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
title={postbox ? `Unified-inbox policy for ${postbox.name}` : "Unified-inbox policy"}
|
|
className="postbox-dialog"
|
|
onClose={onClose}
|
|
closeDisabled={busy}
|
|
footer={
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
|
|
<Button variant="primary" onClick={onSave} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
|
|
<Save size={16} /> Save separation policy
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<GroupingPolicyFields policy={draft} onChange={onChange} />
|
|
<p className="postbox-form-note">
|
|
The rule is evaluated whenever a personal grouping or aggregate message projection is used. Existing preferences are retained, but a newly enforced rule prevents an unsafe combined projection and explains its configured source.
|
|
</p>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function ProtectionTransitionDialog({
|
|
open,
|
|
postbox,
|
|
draft,
|
|
profiles,
|
|
busy,
|
|
onChange,
|
|
onClose,
|
|
onSave
|
|
}: {
|
|
open: boolean;
|
|
postbox: PostboxDirectoryItem | null;
|
|
draft: ProtectionTransitionDraft;
|
|
profiles: PostboxProtectionProfile[];
|
|
busy: boolean;
|
|
onChange: (draft: ProtectionTransitionDraft) => void;
|
|
onClose: () => void;
|
|
onSave: () => void;
|
|
}) {
|
|
const selected = profiles.find((item) => item.id === draft.target_profile);
|
|
const userEvidence = lineSeparated(draft.user_consent_refs);
|
|
const institutionalEvidence = lineSeparated(draft.institutional_authorization_refs);
|
|
const evidenceCount = new Set([...userEvidence, ...institutionalEvidence]).size;
|
|
const requiresUser = ["user_consent", "dual_control"].includes(draft.authority_mode);
|
|
const requiresInstitution = ["institutional_key_holders", "dual_control"].includes(
|
|
draft.authority_mode
|
|
);
|
|
const configuredAuthority = postbox?.protection_policy?.handover_authority || "dual_control";
|
|
const authoritySatisfiesPolicy = configuredAuthority === "dual_control"
|
|
? draft.authority_mode === "dual_control"
|
|
: configuredAuthority === "user_consent"
|
|
? ["user_consent", "dual_control"].includes(draft.authority_mode)
|
|
: ["institutional_key_holders", "dual_control"].includes(draft.authority_mode);
|
|
const authoritySatisfiesSource = postbox?.encryption_profile === "external_e2ee_v1"
|
|
? ["user_consent", "dual_control"].includes(draft.authority_mode)
|
|
: postbox?.encryption_profile === "server_envelope_v1"
|
|
? ["institutional_key_holders", "dual_control"].includes(draft.authority_mode)
|
|
: true;
|
|
const valid = Boolean(
|
|
postbox
|
|
&& selected?.available
|
|
&& draft.target_profile !== postbox.encryption_profile
|
|
&& (draft.target_profile !== "server_envelope_v1" || draft.target_vault_id.trim())
|
|
&& draft.reason.trim()
|
|
&& draft.acknowledge_irreversibility
|
|
&& evidenceCount >= draft.required_quorum
|
|
&& (!requiresUser || userEvidence.length)
|
|
&& (!requiresInstitution || institutionalEvidence.length)
|
|
&& (draft.authority_mode !== "dual_control" || draft.required_quorum >= 2)
|
|
&& authoritySatisfiesPolicy
|
|
&& authoritySatisfiesSource
|
|
&& draft.required_quorum >= (postbox?.protection_policy?.handover_quorum || 1)
|
|
);
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
title={postbox ? `Change protection for ${postbox.name}` : "Change Postbox protection"}
|
|
className="postbox-dialog postbox-template-dialog"
|
|
onClose={onClose}
|
|
closeDisabled={busy}
|
|
footer={
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={onSave}
|
|
disabled={busy || !valid}
|
|
disabledReason={postboxBusyReason(false, busy) ?? (!valid ? "Select a different available profile and provide the required authority evidence, quorum, reason, vault, and acknowledgement." : undefined)}
|
|
>
|
|
Change protection
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<p className="postbox-form-note">
|
|
New messages switch immediately. Historical migration is separately tracked so interrupted work is visible and resumable.
|
|
</p>
|
|
<FormGrid columns={2} gap="small" collapseAt="narrow" className="postbox-form-grid">
|
|
<FormField label="Current profile" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input value={protectionProfileLabel(postbox?.encryption_profile || "")} disabled />
|
|
</FormField>
|
|
<FormField label="Target profile" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={draft.target_profile}
|
|
onChange={(event) => onChange({
|
|
...draft,
|
|
target_profile: event.target.value as PostboxProtectionProfileId,
|
|
target_vault_id: event.target.value === "server_envelope_v1"
|
|
? draft.target_vault_id
|
|
: ""
|
|
})}
|
|
>
|
|
{profiles.map((item) => (
|
|
<option
|
|
key={item.id}
|
|
value={item.id}
|
|
disabled={!item.available || item.id === postbox?.encryption_profile}
|
|
>
|
|
{item.label}{item.standard ? " · Standard" : ""}{!item.available ? " · Unavailable" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<small>{selected?.description}</small>
|
|
</FormField>
|
|
{draft.target_profile === "server_envelope_v1" ? (
|
|
<FormField label="Target encryption vault" documentation={POSTBOX_FIELD_DOCUMENTATION} helpContextId="postbox.action.protection-transition" helpModuleId="postbox">
|
|
<input
|
|
value={draft.target_vault_id}
|
|
onChange={(event) => onChange({ ...draft, target_vault_id: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
) : <div />}
|
|
<FormField label="Historical messages" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={draft.history_mode}
|
|
onChange={(event) => onChange({
|
|
...draft,
|
|
history_mode: event.target.value as ProtectionTransitionDraft["history_mode"]
|
|
})}
|
|
>
|
|
<option value="future_only">Keep current protection</option>
|
|
<option value="migrate_history">Migrate retained history</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Authorization route" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={draft.authority_mode}
|
|
onChange={(event) => {
|
|
const authority_mode = event.target.value as ProtectionTransitionDraft["authority_mode"];
|
|
onChange({
|
|
...draft,
|
|
authority_mode,
|
|
required_quorum: authority_mode === "dual_control"
|
|
? Math.max(2, draft.required_quorum)
|
|
: draft.required_quorum
|
|
});
|
|
}}
|
|
>
|
|
<option value="dual_control">User consent plus institutional authorization</option>
|
|
<option value="institutional_key_holders">Institutional key holders</option>
|
|
<option value="user_consent">User consent</option>
|
|
</select>
|
|
</FormField>
|
|
<QuorumField
|
|
label="Required evidence quorum"
|
|
value={draft.required_quorum}
|
|
onChange={(required_quorum) => onChange({ ...draft, required_quorum })}
|
|
/>
|
|
<FormField label="User consent evidence" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<textarea
|
|
rows={3}
|
|
placeholder="One immutable evidence reference per line"
|
|
value={draft.user_consent_refs}
|
|
onChange={(event) => onChange({ ...draft, user_consent_refs: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Institutional authorization evidence" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<textarea
|
|
rows={3}
|
|
placeholder="One key-holder approval reference per line"
|
|
value={draft.institutional_authorization_refs}
|
|
onChange={(event) => onChange({
|
|
...draft,
|
|
institutional_authorization_refs: event.target.value
|
|
})}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Reason" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<textarea
|
|
rows={3}
|
|
value={draft.reason}
|
|
onChange={(event) => onChange({ ...draft, reason: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<div className="postbox-toggle-field">
|
|
<ToggleSwitch
|
|
label="Acknowledge residual disclosure"
|
|
helpContextId="postbox.action.protection-transition"
|
|
helpModuleId="postbox"
|
|
help="Previously decrypted, copied, or exported content cannot be recalled by changing the storage profile."
|
|
checked={draft.acknowledge_irreversibility}
|
|
onChange={(acknowledge_irreversibility) => onChange({
|
|
...draft,
|
|
acknowledge_irreversibility
|
|
})}
|
|
/>
|
|
</div>
|
|
</FormGrid>
|
|
{draft.history_mode === "migrate_history" && (
|
|
postbox?.encryption_profile === "external_e2ee_v1"
|
|
|| draft.target_profile === "external_e2ee_v1"
|
|
) ? (
|
|
<p className="postbox-form-note">
|
|
E2EE history migration pauses per message until an approved client submits a verified transform. The service checks plaintext-digest continuity but never receives E2EE private keys.
|
|
</p>
|
|
) : null}
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function MaterializeDialog({
|
|
open,
|
|
draft,
|
|
template,
|
|
units,
|
|
busy,
|
|
onChange,
|
|
onClose,
|
|
onSave
|
|
}: {
|
|
open: boolean;
|
|
draft: MaterializeDraft;
|
|
template: PostboxTemplate | null;
|
|
units: PostboxOrganizationUnit[];
|
|
busy: boolean;
|
|
onChange: (draft: MaterializeDraft) => void;
|
|
onClose: () => void;
|
|
onSave: () => void;
|
|
}) {
|
|
const revision = template ? currentRevision(template) : null;
|
|
const compatible = compatibleTargets(units, revision?.function_type_id);
|
|
const unit = compatible.find((item) => item.id === draft.organization_unit_id);
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
title={template
|
|
? i18nMessage("i18n:govoplan-postbox.resolve_address_title", { name: template.name })
|
|
: "Resolve address"}
|
|
className="postbox-dialog"
|
|
onClose={onClose}
|
|
closeDisabled={busy}
|
|
footer={
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={onSave}
|
|
disabled={busy || !draft.organization_unit_id || !draft.function_id}
|
|
disabledReason={postboxBusyReason(false, busy) ?? ((!draft.organization_unit_id || !draft.function_id) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
|
|
>
|
|
Resolve address
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<FormGrid gap="small" className="postbox-form-grid">
|
|
<FormField label="Organization unit" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select
|
|
value={draft.organization_unit_id}
|
|
onChange={(event) => {
|
|
const nextUnit = compatible.find((item) => item.id === event.target.value);
|
|
onChange({
|
|
...draft,
|
|
organization_unit_id: event.target.value,
|
|
function_id: nextUnit?.functions[0]?.id ?? ""
|
|
});
|
|
}}
|
|
>
|
|
<option value="">Select unit</option>
|
|
{compatible.map((item) => (
|
|
<option key={item.id} value={item.id}>{item.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Function" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<select value={draft.function_id} onChange={(event) => onChange({ ...draft, function_id: event.target.value })}>
|
|
<option value="">Select function</option>
|
|
{(unit?.functions || []).map((fn) => (
|
|
<option key={fn.id} value={fn.id}>{fn.name}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Optional case or service context" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
|
<input value={draft.context_key} onChange={(event) => onChange({ ...draft, context_key: event.target.value })} />
|
|
</FormField>
|
|
</FormGrid>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function currentRevision(template: PostboxTemplate) {
|
|
return template.revisions.find((revision) => revision.revision === template.current_revision)
|
|
?? template.revisions.at(-1)
|
|
?? null;
|
|
}
|
|
|
|
function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
|
|
return {
|
|
function_type_id: draft.function_type_id || null,
|
|
scope_kind: draft.scope_kind,
|
|
scope_id: draft.scope_kind === "tenant" ? null : draft.scope_id || null,
|
|
scope_structure_id: draft.scope_kind === "subtree"
|
|
? draft.scope_structure_id || null
|
|
: null,
|
|
scope_relation_type_ids: draft.scope_kind === "subtree"
|
|
? draft.scope_relation_type_ids
|
|
: [],
|
|
name_pattern: draft.name_pattern,
|
|
address_pattern: draft.address_pattern,
|
|
classification: draft.classification,
|
|
allow_vacant_delivery: draft.allow_vacant_delivery,
|
|
portal_visible: draft.portal_visible,
|
|
encryption_profile: draft.encryption_profile as PostboxProtectionProfileId,
|
|
encryption_vault_id: draft.encryption_profile === "server_envelope_v1"
|
|
? draft.encryption_vault_id?.trim() || null
|
|
: null,
|
|
protection_policy: draft.protection_policy,
|
|
grouping_policy: draft.grouping_policy,
|
|
routing_policy: draft.routing_policy
|
|
};
|
|
}
|
|
|
|
function compatibleTargets(
|
|
units: PostboxOrganizationUnit[],
|
|
functionTypeId?: string | null
|
|
): PostboxOrganizationUnit[] {
|
|
return units
|
|
.map((unit) => ({
|
|
...unit,
|
|
functions: functionTypeId
|
|
? unit.functions.filter((fn) => fn.function_type_id === functionTypeId)
|
|
: unit.functions
|
|
}))
|
|
.filter((unit) => unit.functions.length);
|
|
}
|
|
|
|
function errorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : "Postbox request failed";
|
|
}
|
|
|
|
function commaSeparated(value: string): string[] {
|
|
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
|
|
}
|
|
|
|
function lineSeparated(value: string): string[] {
|
|
return [...new Set(value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean))];
|
|
}
|
|
|
|
function protectionProfileLabel(profile: string): string {
|
|
if (profile === "server_envelope_v1") return "Institution-managed envelope";
|
|
if (profile === "external_e2ee_v1") return "External end-to-end encryption";
|
|
if (profile === "plaintext_v1") return "No content encryption";
|
|
return profile || "Unknown";
|
|
}
|
|
|
|
function draftKey(value: unknown): string {
|
|
return JSON.stringify(value);
|
|
}
|