From 9ee0bc4b32b1febb688f8a98a06e3dded37b48a3 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:12:01 +0100 Subject: [PATCH] Policies: enforce on upload or export (#6614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #6604 (merged). Builds the Security policy out so it actually enforces, driven from the editor. ## What it does - **Run on upload or export** — a single choice in the wizard: enforce when a file is uploaded, or just before it's exported. - **Output** — enforced result is a **new version** of the file (default) or a **new file**, with optional filename prefix/suffix/auto-number ("Output filename" subsection; auto-number only for new files). - **Export enforcement** — exporting an export-mode file runs the policy first and downloads the enforced result; never hard-blocks (on failure the original downloads). For new-version policies the in-editor file is versioned too. Covers every export path incl. multi-file ZIP. A toast (glowing in the policy's accent while it runs) reports progress and fades after ~10s. - **Affordances** — a freshly enforced file briefly glows its policy accent and carries a shield badge. - **Config tidy-up** — removed the unwired Security setting fields + the wizard's review step; "Upgrade to enterprise" on locked categories; category accent in the detail/wizard headers. ## Notes - Builds on the manual-only (client-driven) policy model from #6587 (`trigger: null`, metadata in `output.options`), adding the `runOn` field + export-time enforcement. - The page-editor merge-export (no single source file) enforces + downloads but doesn't version in place. ## Verification typecheck (core + proprietary), eslint, prettier; proprietary suite (105) green; flows checked in-app. --- .../core/components/fileEditor/FileEditor.tsx | 3 +- .../fileEditor/FileEditorThumbnail.tsx | 3 +- .../components/pageEditor/FileThumbnail.tsx | 3 +- .../components/shared/FileSidebarFileItem.css | 26 +++ .../components/shared/FileSidebarFileItem.tsx | 15 +- .../core/components/shared/WorkbenchBar.tsx | 4 +- .../core/components/toast/ToastRenderer.css | 21 ++ .../core/components/toast/ToastRenderer.tsx | 12 +- .../editor/src/core/components/toast/types.ts | 4 + .../src/core/services/downloadService.ts | 3 + .../src/core/services/exportWithPolicy.ts | 29 +++ .../src/core/services/pdfExportService.ts | 6 +- .../editor/src/core/services/policyExport.ts | 12 ++ .../editor/src/core/utils/downloadUtils.ts | 22 +- .../components/policies/Policies.css | 28 +++ .../policies/PoliciesSidebar.test.tsx | 6 +- .../components/policies/PoliciesSidebar.tsx | 4 +- .../components/policies/PolicySetupWizard.tsx | 144 ++++++++----- .../components/policies/PolicyToolConfig.tsx | 5 +- .../components/policies/usePolicyAutoRun.ts | 13 +- .../data/policyDefinitions.test.ts | 4 +- .../proprietary/data/policyDefinitions.tsx | 26 +-- .../src/proprietary/hooks/usePolicies.test.ts | 1 + .../src/proprietary/hooks/usePolicies.ts | 3 + .../proprietary/hooks/usePolicyFileBadges.ts | 8 + .../src/proprietary/services/policyBackend.ts | 1 + .../src/proprietary/services/policyExport.ts | 192 ++++++++++++++++++ .../services/policyPipeline.test.ts | 3 +- .../proprietary/services/policyPipeline.ts | 45 ++-- .../src/proprietary/services/policyStorage.ts | 2 + .../editor/src/proprietary/types/policies.ts | 4 + 31 files changed, 529 insertions(+), 123 deletions(-) create mode 100644 frontend/editor/src/core/services/exportWithPolicy.ts create mode 100644 frontend/editor/src/core/services/policyExport.ts create mode 100644 frontend/editor/src/proprietary/services/policyExport.ts diff --git a/frontend/editor/src/core/components/fileEditor/FileEditor.tsx b/frontend/editor/src/core/components/fileEditor/FileEditor.tsx index 3ddd5ca40..ed8944fb0 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditor.tsx @@ -17,7 +17,7 @@ import AddFileCard from "@app/components/fileEditor/AddFileCard"; import FilePickerModal from "@app/components/shared/FilePickerModal"; import { FileId, StirlingFile } from "@app/types/fileContext"; import { alert } from "@app/components/toast"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; interface FileEditorProps { @@ -256,6 +256,7 @@ const FileEditor = ({ data: file, filename: file.name, localPath: record.localFilePath, + fileId, }); console.log("[FileEditor] Download complete, checking dirty state:", { localFilePath: record.localFilePath, diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx index 0b78d4e26..632d9ea00 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -36,7 +36,7 @@ import ToolChain from "@app/components/shared/ToolChain"; import HoverActionMenu, { HoverAction, } from "@app/components/shared/HoverActionMenu"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import UploadToServerModal from "@app/components/shared/UploadToServerModal"; import ShareFileModal from "@app/components/shared/ShareFileModal"; @@ -248,6 +248,7 @@ const FileEditorThumbnail = ({ data: fileToSave, filename: file.name, localPath: file.localFilePath, + fileId: file.id, }); if (!result.cancelled && result.savedPath) { fileActions.updateStirlingFileStub(file.id, { diff --git a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx index 0356aa6c6..93ff3c2ab 100644 --- a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx +++ b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx @@ -23,7 +23,7 @@ import { FileId } from "@app/types/file"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; interface FileItem { id: FileId; @@ -98,6 +98,7 @@ const FileThumbnail = ({ void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || "download", + fileId: file.id, }); return; } diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 81ca24ae0..02e6de971 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -36,6 +36,32 @@ background-color: rgba(59, 130, 246, 0.06); } +/* A policy-enforced file glows in that policy's accent colour so it's obvious + the file has had a policy applied: it pulses a few times to catch the eye and + then fades out (no lingering glow). */ +.file-sidebar-file-item.policy-enforced { + animation: policy-glow-fade 4.5s ease-in-out forwards; +} +@keyframes policy-glow-fade { + 0%, + 24%, + 48% { + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 30%, transparent), + 0 0 6px -2px var(--policy-glow); + } + 12%, + 36%, + 60% { + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 75%, transparent), + 0 0 16px 0 var(--policy-glow); + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + .file-sidebar-file-item.selected { background-color: rgba(59, 130, 246, 0.12); } diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index 1b2f566f1..6c5ecaba3 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -132,6 +132,9 @@ export interface FileItemPolicyRef { name: string; /** CSS colour for the badge (matches the policy's accent). */ accentColor: string; + /** True only just after the policy was applied — drives the one-off glow, so + * it doesn't replay on every reload of an already-enforced file. */ + recent: boolean; } export interface FileItemProps { @@ -201,6 +204,9 @@ export function FileItem({ const handleMouseLeave = useCallback(() => setHoverRect(null), []); + // A just-applied policy (recent run) drives the one-off row glow. + const recentPolicy = policies.find((p) => p.recent); + // Reactive: tooltip appears as soon as both hover rect and thumbnail are ready const thumbPos = hoverRect && resolvedThumbnail @@ -214,7 +220,14 @@ export function FileItem({ <>
{category.desc}
-Output & retries