feat(postbox): add governed content protection profiles

This commit is contained in:
2026-08-20 03:42:58 +02:00
parent 15d93aaa25
commit 174ee97719
16 changed files with 3917 additions and 383 deletions
File diff suppressed because it is too large Load Diff
+39 -5
View File
@@ -164,6 +164,10 @@ export default function PostboxPage({
() => groupings.find((grouping) => grouping.id === selectedScope) ?? null,
[groupings, selectedScope]
);
const composeTarget = useMemo(
() => postboxes.find((postbox) => postbox.id === messageDraft.postbox_id) ?? null,
[messageDraft.postbox_id, postboxes]
);
const scopePostboxIds = useMemo(() => {
if (selectedPostboxId) return [selectedPostboxId];
if (selectedGrouping) {
@@ -179,6 +183,9 @@ export default function PostboxPage({
const replyDisabledReason = postboxBusyReason(false, busy)
?? (!canReply ? POSTBOX_INTERFACE_I18N.noReplyReason : undefined)
?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : undefined)
?? (selectedMessage?.encryption_profile === "external_e2ee_v1"
? "Replies to E2EE messages must be created by an approved encryption client."
: undefined)
?? (selectedMessage?.availability !== "available"
? POSTBOX_INTERFACE_I18N.unavailableMessage
: undefined);
@@ -509,7 +516,10 @@ export default function PostboxPage({
}
function openCompose() {
const postbox = selectedPostbox ?? postboxes[0] ?? null;
const postbox = selectedPostbox
?? postboxes.find((item) => item.encryption_profile !== "external_e2ee_v1")
?? postboxes[0]
?? null;
if (!postbox) return;
openComposeFor(postbox);
}
@@ -543,7 +553,11 @@ export default function PostboxPage({
}
async function submitMessage(): Promise<boolean> {
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return false;
if (
!messageDraft.postbox_id
|| !messageDraft.subject.trim()
|| composeTarget?.encryption_profile === "external_e2ee_v1"
) return false;
setBusy(true);
setError("");
const participants = messageDraft.recipients
@@ -1034,9 +1048,10 @@ export default function PostboxPage({
disabled={
busy ||
!messageDraft.postbox_id ||
!messageDraft.subject.trim()
!messageDraft.subject.trim() ||
composeTarget?.encryption_profile === "external_e2ee_v1"
}
disabledReason={postboxBusyReason(false, busy) ?? ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
disabledReason={postboxBusyReason(false, busy) ?? (composeTarget?.encryption_profile === "external_e2ee_v1" ? "This browser editor has no E2EE private-key custody. Use an approved encryption client for this Postbox." : ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined))}
>
<Send size={16} /> Send
</Button>
@@ -1044,6 +1059,13 @@ export default function PostboxPage({
}
>
<FormGrid columns={2} gap="small" collapseAt="narrow" className="postbox-compose-grid">
{composeTarget?.encryption_profile === "external_e2ee_v1" ? (
<div className="postbox-compose-wide">
<DismissibleAlert tone="info" compact resetKey={composeTarget.id}>
This Postbox requires externally produced E2EE. Use an approved client that supplies ciphertext, a signed manifest, wrapped keys, and a verified content digest; this browser editor never asks for or stores the private key.
</DismissibleAlert>
</div>
) : null}
<FormField label="Postbox" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={messageDraft.postbox_id}
@@ -1204,7 +1226,19 @@ function MessageDetail({
</dl>
</section>
<section className="postbox-body">
<p>{message.body_text || "No plaintext body is available for this message."}</p>
{message.encryption_profile === "external_e2ee_v1" ? (
<>
<DismissibleAlert tone="info" compact resetKey={message.id}>
This message is end-to-end encrypted. GovOPlaN stores and authorizes its envelope but cannot decrypt the content; open it with the institution's approved client.
</DismissibleAlert>
<dl className="postbox-provenance">
<div><dt>Ciphertext</dt><dd>{message.ciphertext_ref || "Not recorded"}</dd></div>
<div><dt>Signed manifest</dt><dd>{message.signed_manifest_ref || "Not recorded"}</dd></div>
</dl>
</>
) : (
<p>{message.body_text || "No plaintext body is available for this message."}</p>
)}
</section>
{message.participants.length ? (
<section className="postbox-participants">