feat(postbox): enforce unified inbox separation
This commit is contained in:
@@ -51,8 +51,10 @@ import {
|
||||
retirePostboxTemplate,
|
||||
revisePostboxTemplate,
|
||||
updatePostboxProtectionPolicy,
|
||||
updatePostboxGroupingPolicy,
|
||||
type PostboxDirectoryItem,
|
||||
type PostboxExactCreatePayload,
|
||||
type PostboxGroupingPolicy,
|
||||
type PostboxOrganizationFunction,
|
||||
type PostboxOrganizationStructure,
|
||||
type PostboxOrganizationUnit,
|
||||
@@ -114,6 +116,11 @@ const protectionPolicyDefaults = (): PostboxProtectionPolicy => ({
|
||||
vacancy_escalation_content_access: "metadata_only"
|
||||
});
|
||||
|
||||
const groupingPolicyDefaults = (): PostboxGroupingPolicy => ({
|
||||
mode: "allow",
|
||||
reason: null
|
||||
});
|
||||
|
||||
const routingDefaults = (): PostboxRoutingPolicy => ({
|
||||
linked_copy: {
|
||||
enabled: false,
|
||||
@@ -157,6 +164,7 @@ const templateDefaults = (): TemplateDraft => ({
|
||||
encryption_profile: "server_envelope_v1",
|
||||
encryption_vault_id: "",
|
||||
protection_policy: protectionPolicyDefaults(),
|
||||
grouping_policy: groupingPolicyDefaults(),
|
||||
routing_policy: routingDefaults()
|
||||
});
|
||||
|
||||
@@ -170,7 +178,8 @@ const exactDefaults = (): ExactDraft => ({
|
||||
portal_visible: false,
|
||||
encryption_profile: "server_envelope_v1",
|
||||
encryption_vault_id: "",
|
||||
protection_policy: protectionPolicyDefaults()
|
||||
protection_policy: protectionPolicyDefaults(),
|
||||
grouping_policy: groupingPolicyDefaults()
|
||||
});
|
||||
|
||||
const protectionTransitionDefaults = (
|
||||
@@ -248,6 +257,9 @@ export default function PostboxAdminPanel({
|
||||
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(
|
||||
@@ -283,9 +295,11 @@ export default function PostboxAdminPanel({
|
||||
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,
|
||||
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,
|
||||
@@ -375,6 +389,7 @@ export default function PostboxAdminPanel({
|
||||
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);
|
||||
@@ -581,6 +596,38 @@ export default function PostboxAdminPanel({
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -660,6 +707,7 @@ export default function PostboxAdminPanel({
|
||||
if (materializeDirty) return materialize();
|
||||
if (protectionDirty) return saveProtectionTransition();
|
||||
if (policyDirty) return saveProtectionPolicy();
|
||||
if (groupingPolicyDirty) return saveGroupingPolicy();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -684,6 +732,10 @@ export default function PostboxAdminPanel({
|
||||
setPolicyDraft(policyBaseline);
|
||||
setPolicyDialogOpen(false);
|
||||
}
|
||||
if (groupingPolicyDialogOpen) {
|
||||
setGroupingPolicyDraft(groupingPolicyBaseline);
|
||||
setGroupingPolicyDialogOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeTemplateDialog() {
|
||||
@@ -731,6 +783,15 @@ export default function PostboxAdminPanel({
|
||||
else close();
|
||||
}
|
||||
|
||||
function closeGroupingPolicyDialog() {
|
||||
const close = () => {
|
||||
setGroupingPolicyDraft(groupingPolicyBaseline);
|
||||
setGroupingPolicyDialogOpen(false);
|
||||
};
|
||||
if (groupingPolicyDirty) requestDiscard(close);
|
||||
else close();
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
title="Postboxes"
|
||||
@@ -807,6 +868,7 @@ export default function PostboxAdminPanel({
|
||||
onSelect={setSelectedPostboxId}
|
||||
onChangeProtection={openProtectionTransition}
|
||||
onEditPolicy={openProtectionPolicy}
|
||||
onEditGroupingPolicy={openGroupingPolicy}
|
||||
onArchive={setArchiveTarget}
|
||||
busy={busy}
|
||||
/>
|
||||
@@ -872,6 +934,15 @@ export default function PostboxAdminPanel({
|
||||
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"
|
||||
@@ -1051,6 +1122,7 @@ function PostboxWorkspace({
|
||||
onSelect,
|
||||
onChangeProtection,
|
||||
onEditPolicy,
|
||||
onEditGroupingPolicy,
|
||||
onArchive,
|
||||
busy
|
||||
}: {
|
||||
@@ -1060,6 +1132,7 @@ function PostboxWorkspace({
|
||||
onSelect: (id: string) => void;
|
||||
onChangeProtection: () => void;
|
||||
onEditPolicy: () => void;
|
||||
onEditGroupingPolicy: () => void;
|
||||
onArchive: (postbox: PostboxDirectoryItem) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
@@ -1104,6 +1177,13 @@ function PostboxWorkspace({
|
||||
>
|
||||
<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"}
|
||||
@@ -1134,6 +1214,7 @@ function PostboxWorkspace({
|
||||
<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">
|
||||
@@ -1435,6 +1516,10 @@ function TemplateDialog({
|
||||
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>
|
||||
@@ -1825,6 +1910,10 @@ function ExactPostboxDialog({
|
||||
protection_policy
|
||||
})}
|
||||
/>
|
||||
<GroupingPolicyFields
|
||||
policy={draft.grouping_policy}
|
||||
onChange={(grouping_policy) => onChange({ ...draft, grouping_policy })}
|
||||
/>
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -2054,6 +2143,51 @@ function AuthorityField({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -2130,6 +2264,47 @@ function ProtectionPolicyDialog({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -2435,6 +2610,7 @@ function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
|
||||
? draft.encryption_vault_id?.trim() || null
|
||||
: null,
|
||||
protection_policy: draft.protection_policy,
|
||||
grouping_policy: draft.grouping_policy,
|
||||
routing_policy: draft.routing_policy
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user