diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index c549a6f9a1..41a2f220e5 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -5914,12 +5914,40 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" +[policies.enforcement] +applying = "Applying {{names}}" +applyingProgress = "Applying {{names}} ({{done}} of {{total}})" +exportFailureBody = "Security policies couldn't be applied. Files were exported as-is." +exportFailureTitle = "Exported without enforcement" +failureBody = "{{failures}} of {{total}} file(s) couldn't be processed and were exported as-is." +failureTitle = "Exported without full enforcement" +printPolicyAppliedBody = "This PDF was updated to meet a policy. Review the changes, then print again." +printPolicyAppliedTitle = "Policy applied before printing" +queued = "+{{count}} queued" +successTitle = "{{names}} applied" +summaryMore = "{{first}}, {{second}} and {{more}} more" +summaryTwo = "{{first}} and {{second}}" + +[policies.enforcement.triggerVerb] +convert = "Enforcing before convert" +default = "Enforcing" +export = "Enforcing before export" +input = "Enforcing on import" +print = "Enforcing before print" + [policies.fields] selectedCount = "{{count}} selected" [policies.pii] +account = "Account numbers (labelled)" +card = "Credit / debit cards" +email = "Email addresses" fieldLabel = "PII to redact" +iban = "IBANs" +phone = "Phone numbers" placeholder = "Select PII types" +routing = "US routing numbers (ABA)" +ssn = "Social Security numbers" [policies.sidebar] activeCount = "{{count}} active" @@ -5942,6 +5970,11 @@ setup = "Set up" enableAriaLabel = "Enable {{tool}}" infoAriaLabel = "What does {{tool}} do?" +[policies.toolConfig.info] +redact = "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document." +sanitize = "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it." +watermark = "Stamps a visible mark (e.g. \"Confidential\") across every page." + [policies.wizard] allDocTypesDescription = "Enable the Classification policy to filter by document type." allDocTypesTitle = "All document types" diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 2f4cb72997..deba2f1200 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -30,6 +30,9 @@ import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench"; import { Tooltip } from "@app/components/shared/Tooltip"; import LocalIcon from "@app/components/shared/LocalIcon"; import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; +import { enforceExportPolicies } from "@app/services/policyExport"; +import { downloadFile as downloadRaw } from "@app/services/downloadService"; +import { alert as showAlert } from "@app/components/toast"; import { WorkbenchBarButtonConfig, WorkbenchBarRenderContext, @@ -171,13 +174,35 @@ export default function WorkbenchBar({ const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles; - for (const file of filesToExport) { - const stub = isStirlingFile(file) + const stubs = filesToExport.map((file) => + isStirlingFile(file) ? selectors.getStirlingFileStub(file.fileId) - : undefined; + : undefined, + ); + + // Enforce all files in one batch so the toast shows progress across the + // whole set (e.g. "report.pdf (2 of 5)") rather than N invisible solo runs. + let enforced: File[]; + try { + enforced = await enforceExportPolicies( + filesToExport as File[], + stubs.map((s) => s?.id), + ); + } catch { + enforced = filesToExport as File[]; + showAlert({ + alertType: "warning", + title: t("policies.enforcement.exportFailureTitle"), + body: t("policies.enforcement.exportFailureBody"), + }); + } + + for (let idx = 0; idx < filesToExport.length; idx++) { + const file = filesToExport[idx]; + const stub = stubs[idx]; try { - const result = await downloadFile({ - data: file, + const result = await downloadRaw({ + data: enforced[idx], filename: file.name, localPath: forceNewFile ? undefined : stub?.localFilePath, fileId: stub?.id, diff --git a/frontend/editor/src/core/contexts/ViewerContext.tsx b/frontend/editor/src/core/contexts/ViewerContext.tsx index 176b11271b..22d95606c1 100644 --- a/frontend/editor/src/core/contexts/ViewerContext.tsx +++ b/frontend/editor/src/core/contexts/ViewerContext.tsx @@ -11,6 +11,10 @@ import React, { import { useNavigation } from "@app/contexts/NavigationContext"; import { useFileState } from "@app/contexts/FileContext"; import { isStirlingFile } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; +import { enforceExportPolicies } from "@app/services/policyExport"; +import { useTranslation } from "react-i18next"; +import { alert } from "@app/components/toast"; import { preferencesService, type PdfRenderMode, @@ -216,6 +220,7 @@ interface ViewerProviderProps { } export const ViewerProvider: React.FC = ({ children }) => { + const { t } = useTranslation(); // UI state - only state directly managed by this context const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false); @@ -537,6 +542,45 @@ export const ViewerProvider: React.FC = ({ children }) => { triggerImmediateZoomUpdate, }); + // Printing is an exit path, so a "run on export" policy must enforce here too. + // Enforce the current file through the same path export uses: when a policy + // rewrites it, that path versions the in-editor file to the enforced output + // and marks it enforced, so a follow-up print of the unedited result prints + // it as-is instead of re-running the (non-idempotent) policy. Ask the user to + // review the updated doc before printing again, rather than printing bytes + // they haven't seen. With no active export policy this is a no-op and print + // runs straight away. + const printWithPolicy = useCallback(async () => { + const file = activeFileId + ? selectors.getFiles([activeFileId as FileId])[0] + : undefined; + if (!activeFileId || !file) { + printActions.print(); + return; + } + const [enforced] = await enforceExportPolicies( + [file], + [activeFileId], + "print", + ); + // Original file back means no policy rewrote it (no active policy, already + // enforced, or graceful failure fallback) — nothing new to review, print it. + if (!enforced || enforced === file) { + printActions.print(); + return; + } + alert({ + alertType: "warning", + title: t("policies.enforcement.printPolicyAppliedTitle"), + body: t("policies.enforcement.printPolicyAppliedBody"), + }); + }, [activeFileId, selectors, printActions]); + + const enforcedPrintActions = useMemo( + () => ({ print: printWithPolicy }), + [printWithPolicy], + ); + const value: ViewerContextType = { // UI state isThumbnailSidebarVisible, @@ -610,7 +654,7 @@ export const ViewerProvider: React.FC = ({ children }) => { exportActions, bookmarkActions, attachmentActions, - printActions, + printActions: enforcedPrintActions, // Bridge registration registerBridge, diff --git a/frontend/editor/src/core/services/policyExport.ts b/frontend/editor/src/core/services/policyExport.ts index dc23a40937..591075b027 100644 --- a/frontend/editor/src/core/services/policyExport.ts +++ b/frontend/editor/src/core/services/policyExport.ts @@ -7,6 +7,7 @@ export async function enforceExportPolicies( files: File[], _fileIds?: (string | undefined)[], + _trigger?: "export" | "print" | "convert" | "input", ): Promise { return files; } diff --git a/frontend/editor/src/core/tools/formFill/FormSaveBar.tsx b/frontend/editor/src/core/tools/formFill/FormSaveBar.tsx index 8ecf01d5e6..d6186af566 100644 --- a/frontend/editor/src/core/tools/formFill/FormSaveBar.tsx +++ b/frontend/editor/src/core/tools/formFill/FormSaveBar.tsx @@ -25,6 +25,7 @@ import DownloadIcon from "@mui/icons-material/Download"; import SaveIcon from "@mui/icons-material/Save"; import EditNoteIcon from "@mui/icons-material/EditNote"; import { useFormFill } from "@app/tools/formFill/FormFillContext"; +import { downloadFileWithPolicy } from "@app/services/exportWithPolicy"; interface FormSaveBarProps { /** The current file being viewed */ @@ -77,15 +78,12 @@ export function FormSaveBar({ setSaving(true); try { const blob = await submitForm(file, false); - // Trigger browser download - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = file instanceof File ? file.name : "filled-form.pdf"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + // Route through the export gateway so a "run on export" policy enforces on + // the filled PDF before it leaves the app (no-op when no such policy is set). + await downloadFileWithPolicy({ + data: blob, + filename: file instanceof File ? file.name : "filled-form.pdf", + }); } catch (err) { console.error("[FormSaveBar] Download failed:", err); } finally { diff --git a/frontend/editor/src/desktop/hooks/useSaveShortcut.ts b/frontend/editor/src/desktop/hooks/useSaveShortcut.ts index 3e20b7f15c..927d5f06cf 100644 --- a/frontend/editor/src/desktop/hooks/useSaveShortcut.ts +++ b/frontend/editor/src/desktop/hooks/useSaveShortcut.ts @@ -1,6 +1,8 @@ import { useEffect } from "react"; import { useFileState, useFileActions } from "@app/contexts/FileContext"; -import { downloadFile } from "@app/services/downloadService"; +// Save through the export gateway so a "run on export" policy enforces before +// the file is written out (no-op when no such policy is active). +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; /** * Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files diff --git a/frontend/editor/src/desktop/services/operationResultsSaveService.ts b/frontend/editor/src/desktop/services/operationResultsSaveService.ts index 89e9c9490a..86b342a33c 100644 --- a/frontend/editor/src/desktop/services/operationResultsSaveService.ts +++ b/frontend/editor/src/desktop/services/operationResultsSaveService.ts @@ -1,10 +1,9 @@ import type { FileId } from "@app/types/fileContext"; import type { OperationSaveContext } from "@core/services/operationResultsSaveService"; -import { - downloadFile, - downloadFromUrl, - DownloadResult, -} from "@app/services/downloadService"; +import { downloadFromUrl, DownloadResult } from "@app/services/downloadService"; +// Save through the export gateway so a "run on export" policy enforces before +// the file is written out (no-op when no such policy is active). +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; export type { OperationSaveContext }; diff --git a/frontend/editor/src/proprietary/components/policies/EnforcementQueueStatus.tsx b/frontend/editor/src/proprietary/components/policies/EnforcementQueueStatus.tsx new file mode 100644 index 0000000000..e06bda98a3 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/EnforcementQueueStatus.tsx @@ -0,0 +1,46 @@ +/** + * Compact status row for the {@link enforcementQueue}, shown in the Policies + * panel whenever enforcement jobs are pending or running. The queue is serial, + * so a slow policy run would otherwise be invisible — this surfaces what's being + * enforced (before export, print, convert, …) and how many jobs are waiting. + */ +import { useTranslation } from "react-i18next"; +import { Group, Text, Loader } from "@mantine/core"; +import { useEnforcementQueue } from "@app/components/policies/enforcementQueue"; + +export function EnforcementQueueStatus() { + const { t } = useTranslation(); + const jobs = useEnforcementQueue(); + const active = jobs.filter( + (j) => j.status === "pending" || j.status === "running", + ); + if (active.length === 0) return null; + + // The running job leads the row; everything else is still queued behind it. + const lead = active.find((j) => j.status === "running") ?? active[0]; + const queued = active.length - 1; + + return ( + + + + {t(`policies.enforcement.triggerVerb.${lead.trigger}`, { + defaultValue: t("policies.enforcement.triggerVerb.default"), + })} + : {lead.label} + {queued > 0 + ? ` · ${t("policies.enforcement.queued", { count: queued })}` + : "…"} + + + ); +} + +export default EnforcementQueueStatus; diff --git a/frontend/editor/src/proprietary/components/policies/enforcementQueue.ts b/frontend/editor/src/proprietary/components/policies/enforcementQueue.ts new file mode 100644 index 0000000000..ae5761e295 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/enforcementQueue.ts @@ -0,0 +1,100 @@ +/** + * Serial enforcement queue. Every policy enforcement — before export, before + * print, before a convert/extract, and (later) as files arrive — runs through + * here, one at a time. The backend rejects concurrent policy runs under load, + * and a single in-flight run keeps the queue the user sees honest. + * + * Jobs carry their {@link EnforcementTrigger} and a status so the UI can show + * what's pending/running. Input enforcement reuses this contract unchanged: it + * just submits jobs with `trigger: "input"`. + */ +import { useSyncExternalStore } from "react"; + +export type EnforcementTrigger = "export" | "print" | "convert" | "input"; +export type EnforcementStatus = "pending" | "running" | "done" | "failed"; + +export interface EnforcementJob { + id: string; + /** Human-readable label, e.g. the policy/file name, shown in the queue UI. */ + label: string; + trigger: EnforcementTrigger; + status: EnforcementStatus; +} + +/** How long a finished job lingers in the list before it's dropped. */ +const DONE_LINGER_MS = 2500; + +type Listener = () => void; +const listeners = new Set(); +let jobs: EnforcementJob[] = []; +// The tail of the serial chain — each new job runs after this resolves. +let tail: Promise = Promise.resolve(); +let seq = 0; + +function emit() { + for (const listener of listeners) listener(); +} + +function setStatus(id: string, status: EnforcementStatus) { + jobs = jobs.map((j) => (j.id === id ? { ...j, status } : j)); + emit(); +} + +function scheduleRemoval(id: string) { + setTimeout(() => { + jobs = jobs.filter((j) => j.id !== id); + emit(); + }, DONE_LINGER_MS); +} + +/** + * Run `task` after every job queued before it has finished, tracking its status + * for the UI. The returned promise resolves/rejects with the task's result, so + * callers can `await runQueued(...)` exactly as they would the bare work. + */ +export function runQueued( + meta: { label: string; trigger: EnforcementTrigger }, + task: () => Promise, +): Promise { + const id = `enf-${++seq}`; + jobs = [ + ...jobs, + { id, label: meta.label, trigger: meta.trigger, status: "pending" }, + ]; + emit(); + + const run = tail.then(async () => { + setStatus(id, "running"); + try { + const result = await task(); + setStatus(id, "done"); + return result; + } catch (error) { + setStatus(id, "failed"); + throw error; + } finally { + scheduleRemoval(id); + } + }); + + // Keep the chain alive when a task rejects so the next job still runs; callers + // still see the rejection through `run`. + tail = run.catch(() => {}); + return run; +} + +export function getQueueJobs(): EnforcementJob[] { + return jobs; +} + +export function subscribeQueue(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** React view of the live queue (pending + running + briefly-lingering jobs). */ +export function useEnforcementQueue(): EnforcementJob[] { + return useSyncExternalStore(subscribeQueue, getQueueJobs, getQueueJobs); +} diff --git a/frontend/editor/src/proprietary/services/policyExport.ts b/frontend/editor/src/proprietary/services/policyExport.ts index c4c712e3cf..f4dce74873 100644 --- a/frontend/editor/src/proprietary/services/policyExport.ts +++ b/frontend/editor/src/proprietary/services/policyExport.ts @@ -17,10 +17,18 @@ import { getPolicyRun, downloadPolicyOutput, } from "@app/services/policyApi"; -import { recordRunStart } from "@app/components/policies/policyRunStore"; +import { + recordRunStart, + isDispatched, +} from "@app/components/policies/policyRunStore"; +import { + runQueued, + type EnforcementTrigger, +} from "@app/components/policies/enforcementQueue"; import { ROW_ACCENT } from "@app/components/policies/policyStatus"; import { alert, updateToast, dismissToast } from "@app/components/toast"; import { POLICIES_ENABLED } from "@app/constants/featureFlags"; +import i18n from "@app/i18n"; /** Poll cadence + cap for a single export run (≈2.5 min worst case). */ const POLL_MS = 2000; @@ -98,10 +106,29 @@ async function runToCompletion( if (view.status === "FAILED" || view.status === "CANCELLED") { throw new Error(view.error || `policy run ${view.status.toLowerCase()}`); } + if (view.status === "WAITING_FOR_INPUT") { + throw new Error( + "policy requires interactive input and cannot run automatically", + ); + } } throw new Error("policy run timed out"); } +function enforcedFilesSummary(names: string[]): string { + if (names.length === 1) return names[0]; + if (names.length === 2) + return i18n.t("policies.enforcement.summaryTwo", { + first: names[0], + second: names[1], + }); + return i18n.t("policies.enforcement.summaryMore", { + first: names[0], + second: names[1], + more: names.length - 2, + }); +} + /** * Enforce every active export-policy on each PDF just before export, returning * the files in order (enforced, or the original on failure). `fileIds[i]` is the @@ -113,80 +140,119 @@ async function runToCompletion( export async function enforceExportPolicies( files: File[], fileIds?: (string | undefined)[], + trigger: EnforcementTrigger = "export", ): Promise { const active = activeExportPolicies(); const targets = files.flatMap((f, i) => (isPdf(f) ? [i] : [])); if (!active.length || targets.length === 0) return files; + // Policies that haven't already enforced this exact file version. Enforcing + // versions the in-editor file to the policy's output and marks that output + // dispatched, so an unedited re-export skips re-running — re-applying a + // non-idempotent policy would stack watermarks/flattens. Editing produces a + // new file id that isn't dispatched, so an edited file enforces afresh. + const pendingFor = (fileId: string | undefined) => + active.filter((p) => !(fileId && isDispatched(p.categoryId, fileId))); + if (!targets.some((i) => pendingFor(fileIds?.[i]).length > 0)) return files; + const names = active.map((p) => p.label).join(", "); - const toastId = alert({ - alertType: "neutral", - title: `Applying ${names}`, - body: `Enforcing ${ - targets.length === 1 ? "your file" : `${targets.length} files` - } before export…`, - isPersistentPopup: true, - expandable: false, - glowColor: active[0].accent, - }); - const out = [...files]; - let failures = 0; - for (const i of targets) { - const file = files[i]; - const fileId = fileIds?.[i]; - try { - let current = file; - // The last "new version" policy's output is what versions the editor file - // (recording every policy would double-consume the same input). - let versionRun: PolicyRunResult & { categoryId: string }; - let hasVersionRun = false; - for (const policy of active) { - const result = await runToCompletion(policy.backendId, current); - current = result.file; - if (policy.outputMode === "new_version" && fileId) { - versionRun = { ...result, categoryId: policy.categoryId }; - hasVersionRun = true; + // Serialise through the enforcement queue: one policy run in flight at a time + // (the backend rejects concurrent runs under load), and the user can see + // what's pending. Export, print and convert all share this queue. + return runQueued({ label: names, trigger }, async () => { + // An earlier queued job may have just enforced these same files and marked + // them dispatched, so re-check at run time before doing (or announcing) work. + if (!targets.some((i) => pendingFor(fileIds?.[i]).length > 0)) return files; + + const pending = targets.filter((i) => pendingFor(fileIds?.[i]).length > 0); + const total = pending.length; + const progressTitle = (done: number) => + total === 1 + ? i18n.t("policies.enforcement.applying", { names }) + : i18n.t("policies.enforcement.applyingProgress", { + names, + done: done + 1, + total, + }); + const progressBody = (done: number) => files[pending[done]].name; + + const toastId = alert({ + alertType: "neutral", + title: progressTitle(0), + body: progressBody(0), + isPersistentPopup: true, + expandable: false, + glowColor: active[0].accent, + }); + + const out = [...files]; + let failures = 0; + let done = 0; + for (const i of pending) { + const file = files[i]; + const fileId = fileIds?.[i]; + const toRun = pendingFor(fileId); + try { + let current = file; + // The last "new version" policy's output is what versions the editor + // file (recording every policy would double-consume the same input). + let versionRun: (PolicyRunResult & { categoryId: string }) | undefined; + for (const policy of toRun) { + const result = await runToCompletion(policy.backendId, current); + current = result.file; + if (policy.outputMode === "new_version" && fileId) { + versionRun = { ...result, categoryId: policy.categoryId }; + } } + out[i] = current; + done += 1; + if (done < total) + updateToast(toastId, { + title: progressTitle(done), + body: progressBody(done), + }); + if (versionRun && fileId) { + recordRunStart({ + runId: versionRun.runId, + categoryId: versionRun.categoryId, + fileId, + fileName: file.name, + fileSize: file.size, + status: "COMPLETED", + outputs: versionRun.outputs, + error: null, + startedAt: Date.now(), + }); + } + } catch { + failures += 1; // leave out[i] as the original — never hard-block. } - out[i] = current; - if (hasVersionRun && fileId) { - recordRunStart({ - runId: versionRun!.runId, - categoryId: versionRun!.categoryId, - fileId, - fileName: file.name, - fileSize: file.size, - status: "COMPLETED", - outputs: versionRun!.outputs, - error: null, - startedAt: Date.now(), - }); - } - } catch { - failures += 1; // leave out[i] as the original — never hard-block. } - } - updateToast( - toastId, - failures - ? { - alertType: "warning", - title: "Exported without full enforcement", - body: `${failures} of ${targets.length} file(s) couldn't be processed and were exported as-is.`, - isPersistentPopup: false, - glowColor: undefined, - } - : { - alertType: "success", - title: `${names} applied`, - body: "Enforced before export.", - isPersistentPopup: false, - glowColor: undefined, - }, - ); - // update() doesn't reschedule auto-dismiss, so fade the result out explicitly. - window.setTimeout(() => dismissToast(toastId), TOAST_LINGER_MS); - return out; + updateToast( + toastId, + failures + ? { + alertType: "warning", + title: i18n.t("policies.enforcement.failureTitle"), + body: i18n.t("policies.enforcement.failureBody", { + failures, + total, + }), + isPersistentPopup: false, + glowColor: undefined, + } + : { + alertType: "success", + title: i18n.t("policies.enforcement.successTitle", { names }), + body: enforcedFilesSummary(pending.map((i) => files[i].name)), + isPersistentPopup: false, + glowColor: undefined, + }, + ); + // update() doesn't reschedule auto-dismiss, so fade the result out explicitly. + window.setTimeout(() => dismissToast(toastId), TOAST_LINGER_MS); + return out; + }); }