mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Policies: enforce on upload or export (#6614)
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.
This commit is contained in:
@@ -17,7 +17,7 @@ import AddFileCard from "@app/components/fileEditor/AddFileCard";
|
|||||||
import FilePickerModal from "@app/components/shared/FilePickerModal";
|
import FilePickerModal from "@app/components/shared/FilePickerModal";
|
||||||
import { FileId, StirlingFile } from "@app/types/fileContext";
|
import { FileId, StirlingFile } from "@app/types/fileContext";
|
||||||
import { alert } from "@app/components/toast";
|
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";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
|
|
||||||
interface FileEditorProps {
|
interface FileEditorProps {
|
||||||
@@ -256,6 +256,7 @@ const FileEditor = ({
|
|||||||
data: file,
|
data: file,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
localPath: record.localFilePath,
|
localPath: record.localFilePath,
|
||||||
|
fileId,
|
||||||
});
|
});
|
||||||
console.log("[FileEditor] Download complete, checking dirty state:", {
|
console.log("[FileEditor] Download complete, checking dirty state:", {
|
||||||
localFilePath: record.localFilePath,
|
localFilePath: record.localFilePath,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import ToolChain from "@app/components/shared/ToolChain";
|
|||||||
import HoverActionMenu, {
|
import HoverActionMenu, {
|
||||||
HoverAction,
|
HoverAction,
|
||||||
} from "@app/components/shared/HoverActionMenu";
|
} 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 { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
||||||
import ShareFileModal from "@app/components/shared/ShareFileModal";
|
import ShareFileModal from "@app/components/shared/ShareFileModal";
|
||||||
@@ -248,6 +248,7 @@ const FileEditorThumbnail = ({
|
|||||||
data: fileToSave,
|
data: fileToSave,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
localPath: file.localFilePath,
|
localPath: file.localFilePath,
|
||||||
|
fileId: file.id,
|
||||||
});
|
});
|
||||||
if (!result.cancelled && result.savedPath) {
|
if (!result.cancelled && result.savedPath) {
|
||||||
fileActions.updateStirlingFileStub(file.id, {
|
fileActions.updateStirlingFileStub(file.id, {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { FileId } from "@app/types/file";
|
|||||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||||
import { downloadFile } from "@app/services/downloadService";
|
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
id: FileId;
|
id: FileId;
|
||||||
@@ -98,6 +98,7 @@ const FileThumbnail = ({
|
|||||||
void downloadFile({
|
void downloadFile({
|
||||||
data: maybeFile,
|
data: maybeFile,
|
||||||
filename: maybeFile.name || file.name || "download",
|
filename: maybeFile.name || file.name || "download",
|
||||||
|
fileId: file.id,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,32 @@
|
|||||||
background-color: rgba(59, 130, 246, 0.06);
|
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 {
|
.file-sidebar-file-item.selected {
|
||||||
background-color: rgba(59, 130, 246, 0.12);
|
background-color: rgba(59, 130, 246, 0.12);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ export interface FileItemPolicyRef {
|
|||||||
name: string;
|
name: string;
|
||||||
/** CSS colour for the badge (matches the policy's accent). */
|
/** CSS colour for the badge (matches the policy's accent). */
|
||||||
accentColor: string;
|
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 {
|
export interface FileItemProps {
|
||||||
@@ -201,6 +204,9 @@ export function FileItem({
|
|||||||
|
|
||||||
const handleMouseLeave = useCallback(() => setHoverRect(null), []);
|
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
|
// Reactive: tooltip appears as soon as both hover rect and thumbnail are ready
|
||||||
const thumbPos =
|
const thumbPos =
|
||||||
hoverRect && resolvedThumbnail
|
hoverRect && resolvedThumbnail
|
||||||
@@ -214,7 +220,14 @@ export function FileItem({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={itemRef}
|
ref={itemRef}
|
||||||
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
|
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}${recentPolicy ? " policy-enforced" : ""}`}
|
||||||
|
style={
|
||||||
|
recentPolicy
|
||||||
|
? ({
|
||||||
|
"--policy-glow": recentPolicy.accentColor,
|
||||||
|
} as React.CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() => onClick(fileId)}
|
onClick={() => onClick(fileId)}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
onDragStart={
|
onDragStart={
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
|
|||||||
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
|
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
|
||||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { downloadFile } from "@app/services/downloadService";
|
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||||
import {
|
import {
|
||||||
WorkbenchBarButtonConfig,
|
WorkbenchBarButtonConfig,
|
||||||
WorkbenchBarRenderContext,
|
WorkbenchBarRenderContext,
|
||||||
@@ -150,6 +150,7 @@ export default function WorkbenchBar({
|
|||||||
data: new Blob([buffer], { type: "application/pdf" }),
|
data: new Blob([buffer], { type: "application/pdf" }),
|
||||||
filename: fileToExport.name,
|
filename: fileToExport.name,
|
||||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||||
|
fileId: stub?.id,
|
||||||
});
|
});
|
||||||
if (!forceNewFile && !result.cancelled && stub && result.savedPath) {
|
if (!forceNewFile && !result.cancelled && stub && result.savedPath) {
|
||||||
fileActions.updateStirlingFileStub(stub.id, {
|
fileActions.updateStirlingFileStub(stub.id, {
|
||||||
@@ -179,6 +180,7 @@ export default function WorkbenchBar({
|
|||||||
data: file,
|
data: file,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||||
|
fileId: stub?.id,
|
||||||
});
|
});
|
||||||
if (result.cancelled) continue;
|
if (result.cancelled) continue;
|
||||||
if (!forceNewFile && stub && result.savedPath) {
|
if (!forceNewFile && stub && result.savedPath) {
|
||||||
|
|||||||
@@ -51,6 +51,27 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A toast tied to a policy pulses a glow in that policy's accent (var set
|
||||||
|
inline as --toast-glow), so it's obvious which policy is being applied. */
|
||||||
|
.toast-item--glow {
|
||||||
|
animation: toast-glow-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes toast-glow-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow-lg),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--toast-glow) 35%, transparent),
|
||||||
|
0 0 10px -2px var(--toast-glow);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow-lg),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--toast-glow) 80%, transparent),
|
||||||
|
0 0 20px 0 var(--toast-glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Toast Alert Type Colors */
|
/* Toast Alert Type Colors */
|
||||||
.toast-item--success {
|
.toast-item--success {
|
||||||
background: var(--color-green-100);
|
background: var(--color-green-100);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useToast } from "@app/components/toast/ToastContext";
|
import { useToast } from "@app/components/toast/ToastContext";
|
||||||
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
|
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
|
||||||
@@ -64,7 +65,16 @@ export default function ToastRenderer() {
|
|||||||
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
|
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
|
||||||
{grouped[loc].map((t) => {
|
{grouped[loc].map((t) => {
|
||||||
return (
|
return (
|
||||||
<div key={t.id} role="status" className={getToastItemClass(t)}>
|
<div
|
||||||
|
key={t.id}
|
||||||
|
role="status"
|
||||||
|
className={`${getToastItemClass(t)}${t.glowColor ? " toast-item--glow" : ""}`}
|
||||||
|
style={
|
||||||
|
t.glowColor
|
||||||
|
? ({ "--toast-glow": t.glowColor } as CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{/* Top row: Icon + Title + Controls */}
|
{/* Top row: Icon + Title + Controls */}
|
||||||
<div className="toast-header">
|
<div className="toast-header">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export interface ToastOptions {
|
|||||||
id?: string;
|
id?: string;
|
||||||
/** If true, show chevron and collapse/expand animation. Defaults to true. */
|
/** If true, show chevron and collapse/expand animation. Defaults to true. */
|
||||||
expandable?: boolean;
|
expandable?: boolean;
|
||||||
|
/** Optional accent colour (any CSS colour, e.g. a `var(--color-…)`). When set,
|
||||||
|
* the toast pulses a glow in this colour — used to tie a toast to a policy's
|
||||||
|
* accent. */
|
||||||
|
glowColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToastInstance extends Omit<
|
export interface ToastInstance extends Omit<
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export interface DownloadRequest {
|
|||||||
data: Blob | File;
|
data: Blob | File;
|
||||||
filename: string;
|
filename: string;
|
||||||
localPath?: string;
|
localPath?: string;
|
||||||
|
/** Workspace fileId of the file being exported, when known. Lets export-time
|
||||||
|
* policy enforcement version the in-editor file (not just the download). */
|
||||||
|
fileId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadResult {
|
export interface DownloadResult {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Export entry points call {@link downloadFileWithPolicy} instead of
|
||||||
|
* {@link downloadFile} so any "export"-triggered policy enforces on the file
|
||||||
|
* before it's downloaded. The enforcement itself is proprietary (a no-op in the
|
||||||
|
* core build via the `@app/services/policyExport` stub), and never hard-blocks:
|
||||||
|
* on failure the original file is downloaded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
downloadFile,
|
||||||
|
type DownloadRequest,
|
||||||
|
type DownloadResult,
|
||||||
|
} from "@app/services/downloadService";
|
||||||
|
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||||
|
|
||||||
|
export async function downloadFileWithPolicy(
|
||||||
|
request: DownloadRequest,
|
||||||
|
): Promise<DownloadResult> {
|
||||||
|
// enforceExportPolicies only touches PDFs and is a no-op without an active
|
||||||
|
// export policy, so non-PDF / non-policy downloads pass straight through.
|
||||||
|
const input =
|
||||||
|
request.data instanceof File
|
||||||
|
? request.data
|
||||||
|
: new File([request.data], request.filename, {
|
||||||
|
type: request.data.type,
|
||||||
|
});
|
||||||
|
const [enforced] = await enforceExportPolicies([input], [request.fileId]);
|
||||||
|
return downloadFile({ ...request, data: enforced ?? request.data });
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
setPageRotation,
|
setPageRotation,
|
||||||
addNewPage,
|
addNewPage,
|
||||||
} from "@app/services/pdfiumService";
|
} from "@app/services/pdfiumService";
|
||||||
import { downloadFile } from "@app/services/downloadService";
|
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
|
||||||
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
|
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
|
||||||
|
|
||||||
// A4 dimensions in PDF points (72 dpi)
|
// A4 dimensions in PDF points (72 dpi)
|
||||||
@@ -259,10 +259,10 @@ export class PDFExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a single file
|
* Download a single file, applying any export-triggered policy first.
|
||||||
*/
|
*/
|
||||||
downloadFile(blob: Blob, filename: string): void {
|
downloadFile(blob: Blob, filename: string): void {
|
||||||
void downloadFile({ data: blob, filename });
|
void downloadFileWithPolicy({ data: blob, filename });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Open-source stub for export-time policy enforcement. Policies are a proprietary
|
||||||
|
* feature, so in the core build there's nothing to enforce — this returns the
|
||||||
|
* files unchanged. The proprietary build shadows this module via the `@app/*`
|
||||||
|
* alias with the real implementation.
|
||||||
|
*/
|
||||||
|
export async function enforceExportPolicies(
|
||||||
|
files: File[],
|
||||||
|
_fileIds?: (string | undefined)[],
|
||||||
|
): Promise<File[]> {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { StirlingFileStub } from "@app/types/fileContext";
|
|||||||
import { fileStorage } from "@app/services/fileStorage";
|
import { fileStorage } from "@app/services/fileStorage";
|
||||||
import { zipFileService } from "@app/services/zipFileService";
|
import { zipFileService } from "@app/services/zipFileService";
|
||||||
import { downloadFile } from "@app/services/downloadService";
|
import { downloadFile } from "@app/services/downloadService";
|
||||||
|
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
|
||||||
|
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a blob as a file using browser download API
|
* Downloads a blob as a file using browser download API
|
||||||
@@ -27,10 +29,11 @@ export async function downloadFileFromStorage(
|
|||||||
throw new Error(`File "${file.name}" not found in storage`);
|
throw new Error(`File "${file.name}" not found in storage`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await downloadFile({
|
await downloadFileWithPolicy({
|
||||||
data: stirlingFile,
|
data: stirlingFile,
|
||||||
filename: stirlingFile.name,
|
filename: stirlingFile.name,
|
||||||
localPath: file.localFilePath,
|
localPath: file.localFilePath,
|
||||||
|
fileId: file.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,15 +62,15 @@ export async function downloadFilesAsZip(
|
|||||||
throw new Error("No files provided for ZIP download");
|
throw new Error("No files provided for ZIP download");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert stored files to File objects
|
// Convert stored files to File objects (tracking ids so export policies can
|
||||||
|
// version the in-editor file).
|
||||||
const filesToZip: File[] = [];
|
const filesToZip: File[] = [];
|
||||||
|
const fileIds: (string | undefined)[] = [];
|
||||||
for (const fileWithUrl of files) {
|
for (const fileWithUrl of files) {
|
||||||
const lookupKey = fileWithUrl.id;
|
const stirlingFile = await fileStorage.getStirlingFile(fileWithUrl.id);
|
||||||
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
|
|
||||||
|
|
||||||
if (stirlingFile) {
|
if (stirlingFile) {
|
||||||
// StirlingFile is already a File object!
|
|
||||||
filesToZip.push(stirlingFile);
|
filesToZip.push(stirlingFile);
|
||||||
|
fileIds.push(fileWithUrl.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +78,9 @@ export async function downloadFilesAsZip(
|
|||||||
throw new Error("No valid files found in storage for ZIP download");
|
throw new Error("No valid files found in storage for ZIP download");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce any export-triggered policy on each PDF before they're zipped.
|
||||||
|
const enforced = await enforceExportPolicies(filesToZip, fileIds);
|
||||||
|
|
||||||
// Generate default filename if not provided
|
// Generate default filename if not provided
|
||||||
const finalZipFilename =
|
const finalZipFilename =
|
||||||
zipFilename ||
|
zipFilename ||
|
||||||
@@ -82,7 +88,7 @@ export async function downloadFilesAsZip(
|
|||||||
|
|
||||||
// Create and download ZIP
|
// Create and download ZIP
|
||||||
const { zipFile } = await zipFileService.createZipFromFiles(
|
const { zipFile } = await zipFileService.createZipFromFiles(
|
||||||
filesToZip,
|
enforced,
|
||||||
finalZipFilename,
|
finalZipFilename,
|
||||||
);
|
);
|
||||||
await downloadFile({ data: zipFile, filename: finalZipFilename });
|
await downloadFile({ data: zipFile, filename: finalZipFilename });
|
||||||
@@ -125,7 +131,7 @@ export async function downloadFiles(
|
|||||||
* @param filename - Optional custom filename
|
* @param filename - Optional custom filename
|
||||||
*/
|
*/
|
||||||
export function downloadFileObject(file: File, filename?: string): void {
|
export function downloadFileObject(file: File, filename?: string): void {
|
||||||
void downloadFile({ data: file, filename: filename || file.name });
|
void downloadFileWithPolicy({ data: file, filename: filename || file.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -222,6 +222,34 @@
|
|||||||
margin: 0 0 var(--space-2);
|
margin: 0 0 var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sub-section header inside a settings card (e.g. "Output filename"). The field
|
||||||
|
directly below it carries data-first so the borders don't double up. */
|
||||||
|
.pol-subhead {
|
||||||
|
padding: 0.55rem 0.875rem 0.4rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position dropdown + custom-text input sitting on one row under the
|
||||||
|
"Output filename" subhead — the dropdown sizes to content, text fills. */
|
||||||
|
.pol-name-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.pol-name-row > :first-child {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.pol-name-row > :last-child {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */
|
/* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */
|
||||||
.pol-field {
|
.pol-field {
|
||||||
padding: 0.7rem 0.875rem;
|
padding: 0.7rem 0.875rem;
|
||||||
|
|||||||
@@ -112,14 +112,14 @@ describe("Policies right-sidebar surface", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows Security as active and the unbuilt categories as Coming soon", () => {
|
it("shows Security as active and the unbuilt categories as upgrade-gated", () => {
|
||||||
renderHost();
|
renderHost();
|
||||||
expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1);
|
||||||
// Ingestion, Compliance, Routing, Retention are locked for this release.
|
// Ingestion, Compliance, Routing, Retention are locked for this release.
|
||||||
expect(screen.getAllByText("Coming soon")).toHaveLength(4);
|
expect(screen.getAllByText("Upgrade to enterprise")).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not open a Coming soon policy when its row is clicked", () => {
|
it("does not open an upgrade-gated policy when its row is clicked", () => {
|
||||||
renderHost();
|
renderHost();
|
||||||
fireEvent.click(screen.getByText("Ingestion"));
|
fireEvent.click(screen.getByText("Ingestion"));
|
||||||
// The locked row isn't a button — we stay on the list, nothing opens.
|
// The locked row isn't a button — we stay on the list, nothing opens.
|
||||||
|
|||||||
@@ -151,7 +151,9 @@ export function PoliciesSection({
|
|||||||
</IconBadge>
|
</IconBadge>
|
||||||
<span className="pol-row-label">{cat.label}</span>
|
<span className="pol-row-label">{cat.label}</span>
|
||||||
<span className="pol-row-trail">
|
<span className="pol-row-trail">
|
||||||
<span className="pol-row-soon">Coming soon</span>
|
<span className="pol-row-soon">
|
||||||
|
Upgrade to enterprise
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,8 +80,7 @@ interface PolicySetupWizardProps {
|
|||||||
* The shared policy wizard, used for both setup and edit. Two steps: Workflow
|
* The shared policy wizard, used for both setup and edit. Two steps: Workflow
|
||||||
* (the tool pipeline, reusing the Watch Folders builder) → Settings (the policy
|
* (the tool pipeline, reusing the Watch Folders builder) → Settings (the policy
|
||||||
* fields + output/retry config). The workflow builder is kept mounted across
|
* fields + output/retry config). The workflow builder is kept mounted across
|
||||||
* steps so the final action can trigger its save. (A Sources step exists in
|
* steps so the final action can trigger its save.
|
||||||
* code, gated off by SOURCES_IN_FLOW, for when non-editor sources return.)
|
|
||||||
*/
|
*/
|
||||||
export function PolicySetupWizard({
|
export function PolicySetupWizard({
|
||||||
category,
|
category,
|
||||||
@@ -113,8 +112,8 @@ export function PolicySetupWizard({
|
|||||||
);
|
);
|
||||||
const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0);
|
const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0);
|
||||||
const [scopeTypes, setScopeTypes] = useState<string[]>(initial.scopeTypes);
|
const [scopeTypes, setScopeTypes] = useState<string[]>(initial.scopeTypes);
|
||||||
// Reviewer is no longer configured in the flow (there's no human-review step),
|
// Reviewer isn't shown in the flow; the field is still saved on the policy,
|
||||||
// but the field is kept in the saved policy, defaulted to the signed-in user.
|
// defaulted to the signed-in user.
|
||||||
const reviewerEmail = initial.reviewerEmail || user?.email || "";
|
const reviewerEmail = initial.reviewerEmail || user?.email || "";
|
||||||
// Output + retry settings — the real, working folder settings (the engine
|
// Output + retry settings — the real, working folder settings (the engine
|
||||||
// applies them). Pre-filled from the backing folder in edit mode.
|
// applies them). Pre-filled from the backing folder in edit mode.
|
||||||
@@ -129,6 +128,10 @@ export function PolicySetupWizard({
|
|||||||
const [retryDelayMinutes, setRetryDelayMinutes] = useState(
|
const [retryDelayMinutes, setRetryDelayMinutes] = useState(
|
||||||
initialFolder?.retryDelayMinutes ?? 5,
|
initialFolder?.retryDelayMinutes ?? 5,
|
||||||
);
|
);
|
||||||
|
// The editor event this policy runs on: input on upload, or output on export.
|
||||||
|
const [runOn, setRunOn] = useState<"upload" | "export">(
|
||||||
|
initial.runOn ?? "upload",
|
||||||
|
);
|
||||||
const workflowSave = useRef<(() => void) | null>(null);
|
const workflowSave = useRef<(() => void) | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
@@ -197,6 +200,7 @@ export function PolicySetupWizard({
|
|||||||
scopeTypes: scopeNarrow ? scopeTypes : [],
|
scopeTypes: scopeNarrow ? scopeTypes : [],
|
||||||
reviewerEmail,
|
reviewerEmail,
|
||||||
folder: {
|
folder: {
|
||||||
|
runOn,
|
||||||
outputMode,
|
outputMode,
|
||||||
outputName: outputName.trim(),
|
outputName: outputName.trim(),
|
||||||
outputNamePosition,
|
outputNamePosition,
|
||||||
@@ -229,6 +233,7 @@ export function PolicySetupWizard({
|
|||||||
scopeTypes: scopeNarrow ? scopeTypes : [],
|
scopeTypes: scopeNarrow ? scopeTypes : [],
|
||||||
reviewerEmail,
|
reviewerEmail,
|
||||||
folder: {
|
folder: {
|
||||||
|
runOn,
|
||||||
outputMode,
|
outputMode,
|
||||||
outputName: outputName.trim(),
|
outputName: outputName.trim(),
|
||||||
outputNamePosition,
|
outputNamePosition,
|
||||||
@@ -329,35 +334,69 @@ export function PolicySetupWizard({
|
|||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<>
|
<>
|
||||||
<p className="pol-desc">{category.desc}</p>
|
<p className="pol-desc">{category.desc}</p>
|
||||||
<Card padding="none">
|
{config.fields.length > 0 && (
|
||||||
{config.fields.map((f, i) => (
|
<Card padding="none">
|
||||||
<PolicyFieldRow
|
{config.fields.map((f, i) => (
|
||||||
key={f.key}
|
<PolicyFieldRow
|
||||||
field={f}
|
key={f.key}
|
||||||
value={fieldValues[f.key]}
|
field={f}
|
||||||
first={i === 0}
|
value={fieldValues[f.key]}
|
||||||
onChange={(v) =>
|
first={i === 0}
|
||||||
setFieldValues((prev) => ({ ...prev, [f.key]: v }))
|
onChange={(v) =>
|
||||||
}
|
setFieldValues((prev) => ({ ...prev, [f.key]: v }))
|
||||||
/>
|
}
|
||||||
))}
|
/>
|
||||||
</Card>
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Real, working output + retry settings (applied by the engine). */}
|
{/* Real, working output + retry settings (applied by the engine). */}
|
||||||
<p className="pol-section-label">Output & retries</p>
|
<p className="pol-section-label">Output & retries</p>
|
||||||
<Card padding="none">
|
<Card padding="none">
|
||||||
|
{/* The editor event the policy runs on: input on upload, or
|
||||||
|
output on export (enforced before the file is exported). */}
|
||||||
|
<div className="pol-subhead">Run on</div>
|
||||||
<div className="pol-field" data-first>
|
<div className="pol-field" data-first>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Output"
|
label="Run on"
|
||||||
|
control={
|
||||||
|
<Select
|
||||||
|
inputSize="sm"
|
||||||
|
value={runOn}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRunOn(e.target.value as "upload" | "export")
|
||||||
|
}
|
||||||
|
aria-label="Run on"
|
||||||
|
options={[
|
||||||
|
{ value: "upload", label: "Upload" },
|
||||||
|
{ value: "export", label: "Export" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pol-subhead">Output</div>
|
||||||
|
<div className="pol-field" data-first>
|
||||||
|
<SettingsRow
|
||||||
|
label="Output as"
|
||||||
control={
|
control={
|
||||||
<Select
|
<Select
|
||||||
inputSize="sm"
|
inputSize="sm"
|
||||||
value={outputMode}
|
value={outputMode}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setOutputMode(
|
const mode = e.target.value as
|
||||||
e.target.value as "new_file" | "new_version",
|
| "new_file"
|
||||||
)
|
| "new_version";
|
||||||
}
|
setOutputMode(mode);
|
||||||
|
// Auto-number only applies to new files; a new version
|
||||||
|
// replaces the file in place, so fall back to suffix.
|
||||||
|
if (
|
||||||
|
mode === "new_version" &&
|
||||||
|
outputNamePosition === "auto-number"
|
||||||
|
) {
|
||||||
|
setOutputNamePosition("suffix");
|
||||||
|
}
|
||||||
|
}}
|
||||||
aria-label="Output mode"
|
aria-label="Output mode"
|
||||||
options={[
|
options={[
|
||||||
{ value: "new_file", label: "New file" },
|
{ value: "new_file", label: "New file" },
|
||||||
@@ -367,41 +406,40 @@ export function PolicySetupWizard({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pol-field">
|
{/* Output filename: position + custom text together as one row. */}
|
||||||
<SettingsRow
|
<div className="pol-subhead">Output filename</div>
|
||||||
label="Add to filename"
|
<div className="pol-field" data-first>
|
||||||
control={
|
<div className="pol-name-row">
|
||||||
<Select
|
<Select
|
||||||
inputSize="sm"
|
inputSize="sm"
|
||||||
value={outputNamePosition}
|
value={outputNamePosition}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setOutputNamePosition(
|
setOutputNamePosition(
|
||||||
e.target.value as "prefix" | "suffix" | "auto-number",
|
e.target.value as "prefix" | "suffix" | "auto-number",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
aria-label="Add to filename"
|
aria-label="Filename position"
|
||||||
options={[
|
options={[
|
||||||
{ value: "prefix", label: "Prefix" },
|
{ value: "prefix", label: "Prefix" },
|
||||||
{ value: "suffix", label: "Suffix" },
|
{ value: "suffix", label: "Suffix" },
|
||||||
{ value: "auto-number", label: "Auto-number" },
|
// Auto-number only makes sense for separate new files.
|
||||||
]}
|
...(outputMode === "new_file"
|
||||||
/>
|
? [{ value: "auto-number", label: "Auto-number" }]
|
||||||
}
|
: []),
|
||||||
/>
|
]}
|
||||||
</div>
|
/>
|
||||||
<div className="pol-field">
|
{/* Auto-number names the file itself, so there's no custom
|
||||||
<SettingsRow
|
text to add — only show the input for prefix/suffix. */}
|
||||||
label="Text to add"
|
{outputNamePosition !== "auto-number" && (
|
||||||
control={
|
|
||||||
<Input
|
<Input
|
||||||
inputSize="sm"
|
inputSize="sm"
|
||||||
value={outputName}
|
value={outputName}
|
||||||
onChange={(e) => setOutputName(e.target.value)}
|
onChange={(e) => setOutputName(e.target.value)}
|
||||||
placeholder="optional"
|
placeholder="Text to add (optional)"
|
||||||
aria-label="Text to add"
|
aria-label="Filename text"
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pol-field">
|
<div className="pol-field">
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ export function PolicyToolConfig({
|
|||||||
</div>
|
</div>
|
||||||
{tool.enabled &&
|
{tool.enabled &&
|
||||||
(tool.operation === "redact" ? (
|
(tool.operation === "redact" ? (
|
||||||
// Redact config is reduced to just the PII type picker.
|
|
||||||
<div className="pol-tool-body">
|
<div className="pol-tool-body">
|
||||||
<PolicyRedactConfig
|
<PolicyRedactConfig
|
||||||
parameters={tool.parameters}
|
parameters={tool.parameters}
|
||||||
@@ -109,8 +108,8 @@ export function PolicyToolConfig({
|
|||||||
// are fixed in the policy preset), so no settings are shown.
|
// are fixed in the policy preset), so no settings are shown.
|
||||||
<></>
|
<></>
|
||||||
) : tool.operation === "watermark" ? (
|
) : tool.operation === "watermark" ? (
|
||||||
// Watermark: full settings minus the "Flatten PDF pages to
|
// Watermark settings with flatten hidden + forced on (see
|
||||||
// images" toggle (hidden), with flatten forced on.
|
// PolicyWatermarkConfig).
|
||||||
<div className="pol-tool-body">
|
<div className="pol-tool-body">
|
||||||
<PolicyWatermarkConfig
|
<PolicyWatermarkConfig
|
||||||
parameters={tool.parameters}
|
parameters={tool.parameters}
|
||||||
|
|||||||
@@ -73,14 +73,19 @@ export function usePolicyAutoRun(): void {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!POLICIES_ENABLED) return;
|
if (!POLICIES_ENABLED) return;
|
||||||
const active = Object.entries(policies).filter(
|
const active = Object.entries(policies).filter(
|
||||||
([, s]) => s.configured && s.status === "active" && s.backendId,
|
([, s]) =>
|
||||||
|
s.configured &&
|
||||||
|
s.status === "active" &&
|
||||||
|
s.backendId &&
|
||||||
|
// Only auto-run on upload when the policy is set to run on upload
|
||||||
|
// (export-triggered policies enforce at export time instead).
|
||||||
|
(s.runOn ?? "upload") === "upload",
|
||||||
);
|
);
|
||||||
for (const [categoryId, s] of active) {
|
for (const [categoryId, s] of active) {
|
||||||
for (const stub of fileStubs) {
|
for (const stub of fileStubs) {
|
||||||
const key = dispatchKey(categoryId, stub.id);
|
const key = dispatchKey(categoryId, stub.id);
|
||||||
// Skip if already run (persisted) or a dispatch is mid-flight (this
|
// Skip if already run (persisted) or a dispatch is in flight — the
|
||||||
// session) — runPolicyOnFile now waits for the file's bytes to commit,
|
// in-memory guard prevents double-firing during the async wait.
|
||||||
// so the in-memory guard, not an eager mark, prevents double-firing.
|
|
||||||
if (isDispatched(categoryId, stub.id) || dispatching.current.has(key)) {
|
if (isDispatched(categoryId, stub.id) || dispatching.current.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ describe("policy definitions integrity", () => {
|
|||||||
it("every category has a matching config entry", () => {
|
it("every category has a matching config entry", () => {
|
||||||
for (const cat of POLICY_CATEGORIES) {
|
for (const cat of POLICY_CATEGORIES) {
|
||||||
expect(POLICY_CONFIG[cat.id], `config for ${cat.id}`).toBeDefined();
|
expect(POLICY_CONFIG[cat.id], `config for ${cat.id}`).toBeDefined();
|
||||||
expect(POLICY_CONFIG[cat.id].fields.length).toBeGreaterThan(0);
|
// A category may have no policy-level setting fields (e.g. Security's were
|
||||||
|
// all unwired and removed); fields is required but can be empty.
|
||||||
|
expect(Array.isArray(POLICY_CONFIG[cat.id].fields)).toBe(true);
|
||||||
expect(POLICY_CONFIG[cat.id].rules.length).toBeGreaterThan(0);
|
expect(POLICY_CONFIG[cat.id].rules.length).toBeGreaterThan(0);
|
||||||
// Every preset seeds a real, non-empty pipeline (the category→steps map).
|
// Every preset seeds a real, non-empty pipeline (the category→steps map).
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -200,29 +200,9 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
scopeLabel: "All PDFs on this device",
|
scopeLabel: "All PDFs on this device",
|
||||||
// Policy-level controls only — detection/encryption/signing/watermark are
|
// No policy-level setting fields: tool config lives in the Workflow step;
|
||||||
// per-tool and now live in the Workflow step.
|
// output naming + retries are set in the wizard.
|
||||||
fields: [
|
fields: [],
|
||||||
{
|
|
||||||
label: "User can override",
|
|
||||||
key: "userOverride",
|
|
||||||
type: "toggle",
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Default access level",
|
|
||||||
key: "defaultAccess",
|
|
||||||
type: "select",
|
|
||||||
value: "Restricted",
|
|
||||||
options: ["Open", "Restricted", "Confidential", "Top Secret"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Block external sharing",
|
|
||||||
key: "blockExternal",
|
|
||||||
type: "toggle",
|
|
||||||
value: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
compliance: {
|
compliance: {
|
||||||
summary:
|
summary:
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const wizardResult = {
|
|||||||
scopeTypes: [],
|
scopeTypes: [],
|
||||||
reviewerEmail: "[email protected]",
|
reviewerEmail: "[email protected]",
|
||||||
folder: {
|
folder: {
|
||||||
|
runOn: "upload" as const,
|
||||||
outputMode: "new_file" as const,
|
outputMode: "new_file" as const,
|
||||||
outputName: "",
|
outputName: "",
|
||||||
outputNamePosition: "prefix" as const,
|
outputNamePosition: "prefix" as const,
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export function usePolicies() {
|
|||||||
reviewerEmail: result.reviewerEmail,
|
reviewerEmail: result.reviewerEmail,
|
||||||
outputMode: result.folder.outputMode,
|
outputMode: result.folder.outputMode,
|
||||||
outputName: result.folder.outputName,
|
outputName: result.folder.outputName,
|
||||||
|
runOn: result.folder.runOn,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -165,6 +166,7 @@ export function usePolicies() {
|
|||||||
reviewerEmail: result.reviewerEmail,
|
reviewerEmail: result.reviewerEmail,
|
||||||
outputMode: result.folder.outputMode,
|
outputMode: result.folder.outputMode,
|
||||||
outputName: result.folder.outputName,
|
outputName: result.folder.outputName,
|
||||||
|
runOn: result.folder.runOn,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -228,6 +230,7 @@ export function usePolicies() {
|
|||||||
reviewerEmail: result.reviewerEmail,
|
reviewerEmail: result.reviewerEmail,
|
||||||
outputMode: result.folder.outputMode,
|
outputMode: result.folder.outputMode,
|
||||||
outputName: result.folder.outputName,
|
outputName: result.folder.outputName,
|
||||||
|
runOn: result.folder.runOn,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { loadPolicyCatalog } from "@app/services/policyCatalog";
|
|||||||
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
|
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
|
||||||
import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
|
import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
|
||||||
|
|
||||||
|
/** How long after a run a badge counts as "recent" (drives the one-off glow).
|
||||||
|
* Covers the run + import delay; old/reloaded runs fall outside it, so the glow
|
||||||
|
* fires only just after a policy is applied, not on every page reload. */
|
||||||
|
const RECENT_MS = 60_000;
|
||||||
|
|
||||||
/** Policy accent name (ROW_ACCENT) → the CSS colour var the badge uses. */
|
/** Policy accent name (ROW_ACCENT) → the CSS colour var the badge uses. */
|
||||||
const ACCENT_VAR: Record<string, string> = {
|
const ACCENT_VAR: Record<string, string> = {
|
||||||
blue: "var(--color-blue)",
|
blue: "var(--color-blue)",
|
||||||
@@ -26,10 +31,12 @@ export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
|
|||||||
const labelById = new Map(
|
const labelById = new Map(
|
||||||
loadPolicyCatalog().categories.map((c) => [c.id, c.label]),
|
loadPolicyCatalog().categories.map((c) => [c.id, c.label]),
|
||||||
);
|
);
|
||||||
|
const now = Date.now();
|
||||||
const byFile = new Map<string, FileItemPolicyRef[]>();
|
const byFile = new Map<string, FileItemPolicyRef[]>();
|
||||||
for (const run of runs) {
|
for (const run of runs) {
|
||||||
const name = labelById.get(run.categoryId);
|
const name = labelById.get(run.categoryId);
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
|
const recent = now - run.startedAt < RECENT_MS;
|
||||||
for (const fileId of run.outputFileIds ?? []) {
|
for (const fileId of run.outputFileIds ?? []) {
|
||||||
const list = byFile.get(fileId) ?? [];
|
const list = byFile.get(fileId) ?? [];
|
||||||
if (!list.some((p) => p.id === run.categoryId)) {
|
if (!list.some((p) => p.id === run.categoryId)) {
|
||||||
@@ -37,6 +44,7 @@ export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
|
|||||||
id: run.categoryId,
|
id: run.categoryId,
|
||||||
name,
|
name,
|
||||||
accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"],
|
accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"],
|
||||||
|
recent,
|
||||||
});
|
});
|
||||||
byFile.set(fileId, list);
|
byFile.set(fileId, list);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function decodedToState(
|
|||||||
fieldValues: decoded.fieldValues,
|
fieldValues: decoded.fieldValues,
|
||||||
outputMode: decoded.folder.outputMode,
|
outputMode: decoded.folder.outputMode,
|
||||||
outputName: decoded.folder.outputName,
|
outputName: decoded.folder.outputName,
|
||||||
|
runOn: decoded.folder.runOn,
|
||||||
folderId: localFolderId,
|
folderId: localFolderId,
|
||||||
backendId: decoded.id,
|
backendId: decoded.id,
|
||||||
// Catalog-category policies are built-in defaults (not deletable).
|
// Catalog-category policies are built-in defaults (not deletable).
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Export-time policy enforcement. A policy whose `runOn` is "export" runs its
|
||||||
|
* pipeline on a file just before it leaves the editor; the enforced result is
|
||||||
|
* what gets exported. Core export handlers reach this via the `@app/*` alias
|
||||||
|
* (the open-source build ships a no-op stub).
|
||||||
|
*
|
||||||
|
* Export is never hard-blocked: on failure the original file is exported and a
|
||||||
|
* warning toast is shown. For "new version" policies, the run is also recorded
|
||||||
|
* so the mounted import effect versions the in-editor file, not just the
|
||||||
|
* download. Only PDFs are enforced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadPolicies } from "@app/services/policyStorage";
|
||||||
|
import { loadPolicyCatalog } from "@app/services/policyCatalog";
|
||||||
|
import {
|
||||||
|
runStoredPolicy,
|
||||||
|
getPolicyRun,
|
||||||
|
downloadPolicyOutput,
|
||||||
|
} from "@app/services/policyApi";
|
||||||
|
import { recordRunStart } from "@app/components/policies/policyRunStore";
|
||||||
|
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
|
||||||
|
import { alert, updateToast, dismissToast } from "@app/components/toast";
|
||||||
|
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
|
||||||
|
|
||||||
|
/** Poll cadence + cap for a single export run (≈2.5 min worst case). */
|
||||||
|
const POLL_MS = 2000;
|
||||||
|
const MAX_POLLS = 75;
|
||||||
|
/** How long the result toast lingers before fading out. */
|
||||||
|
const TOAST_LINGER_MS = 10_000;
|
||||||
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const isPdf = (f: File) =>
|
||||||
|
f.type === "application/pdf" || /\.pdf$/i.test(f.name);
|
||||||
|
|
||||||
|
interface ExportPolicy {
|
||||||
|
categoryId: string;
|
||||||
|
backendId: string;
|
||||||
|
label: string;
|
||||||
|
outputMode: "new_file" | "new_version";
|
||||||
|
/** The policy's accent as a CSS colour, for the toast glow. */
|
||||||
|
accent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PolicyRunResult {
|
||||||
|
file: File;
|
||||||
|
runId: string;
|
||||||
|
outputs: { fileId: string; fileName: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Configured, active policies set to enforce on export (read from the cache). */
|
||||||
|
function activeExportPolicies(): ExportPolicy[] {
|
||||||
|
if (!POLICIES_ENABLED) return [];
|
||||||
|
const labels = new Map(
|
||||||
|
loadPolicyCatalog().categories.map((c) => [c.id, c.label]),
|
||||||
|
);
|
||||||
|
return Object.entries(loadPolicies())
|
||||||
|
.filter(
|
||||||
|
([, s]) =>
|
||||||
|
s.configured &&
|
||||||
|
s.status === "active" &&
|
||||||
|
s.backendId &&
|
||||||
|
s.runOn === "export",
|
||||||
|
)
|
||||||
|
.map(([id, s]) => ({
|
||||||
|
categoryId: id,
|
||||||
|
backendId: s.backendId as string,
|
||||||
|
label: labels.get(id) ?? "Policy",
|
||||||
|
outputMode: s.outputMode === "new_file" ? "new_file" : "new_version",
|
||||||
|
accent: `var(--color-${ROW_ACCENT[id] ?? "blue"})`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run one policy on a file and resolve the enforced bytes + run info (throws on
|
||||||
|
* failure). */
|
||||||
|
async function runToCompletion(
|
||||||
|
backendId: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<PolicyRunResult> {
|
||||||
|
const runId = await runStoredPolicy(backendId, [file]);
|
||||||
|
for (let i = 0; i < MAX_POLLS; i++) {
|
||||||
|
await delay(POLL_MS);
|
||||||
|
let view;
|
||||||
|
try {
|
||||||
|
view = await getPolicyRun(runId);
|
||||||
|
} catch {
|
||||||
|
continue; // transient — keep polling within the cap.
|
||||||
|
}
|
||||||
|
if (view.status === "COMPLETED") {
|
||||||
|
const out = view.outputs?.[0];
|
||||||
|
if (!out) throw new Error("policy produced no output");
|
||||||
|
const blob = await downloadPolicyOutput(out.fileId);
|
||||||
|
// Keep the export's filename; only the bytes are the enforced result.
|
||||||
|
const enforced = new File([blob], file.name, {
|
||||||
|
type: blob.type || file.type || "application/pdf",
|
||||||
|
});
|
||||||
|
return { file: enforced, runId, outputs: view.outputs ?? [] };
|
||||||
|
}
|
||||||
|
if (view.status === "FAILED" || view.status === "CANCELLED") {
|
||||||
|
throw new Error(view.error || `policy run ${view.status.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("policy run timed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* workspace id of `files[i]` when known — used to version the in-editor file for
|
||||||
|
* "new version" policies. Non-PDFs pass through untouched, and a single toast
|
||||||
|
* (glowing in the policy's accent while it runs) fades a few seconds after the
|
||||||
|
* result.
|
||||||
|
*/
|
||||||
|
export async function enforceExportPolicies(
|
||||||
|
files: File[],
|
||||||
|
fileIds?: (string | undefined)[],
|
||||||
|
): Promise<File[]> {
|
||||||
|
const active = activeExportPolicies();
|
||||||
|
const targets = files.flatMap((f, i) => (isPdf(f) ? [i] : []));
|
||||||
|
if (!active.length || targets.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -116,6 +116,7 @@ const samplePolicy = {
|
|||||||
reviewerEmail: "[email protected]",
|
reviewerEmail: "[email protected]",
|
||||||
fieldValues: { minConfidence: "80%" },
|
fieldValues: { minConfidence: "80%" },
|
||||||
folder: {
|
folder: {
|
||||||
|
runOn: "export" as const,
|
||||||
outputMode: "new_version" as const,
|
outputMode: "new_version" as const,
|
||||||
outputName: "secured",
|
outputName: "secured",
|
||||||
outputNamePosition: "suffix" as const,
|
outputNamePosition: "suffix" as const,
|
||||||
@@ -133,7 +134,7 @@ describe("buildBackendPolicy", () => {
|
|||||||
expect(policy.steps).toEqual([
|
expect(policy.steps).toEqual([
|
||||||
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
|
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
|
||||||
]);
|
]);
|
||||||
// Trigger is null (browser-driven); extras ride in output.options.
|
// Manual-only policy: no server-side trigger, extras ride in output.options.
|
||||||
expect(policy.trigger).toBeNull();
|
expect(policy.trigger).toBeNull();
|
||||||
expect(policy.output.options.categoryId).toBe("security");
|
expect(policy.output.options.categoryId).toBe("security");
|
||||||
expect(policy.output.options.reviewerEmail).toBe("[email protected]");
|
expect(policy.output.options.reviewerEmail).toBe("[email protected]");
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export interface BackendPipelineDefinition {
|
|||||||
output: BackendOutputSpec;
|
output: BackendOutputSpec;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A policy's automatic trigger ("schedule" | "folder-watch"); null means manual (run on demand). */
|
/** How a stored policy is triggered ("manual" | "folder" | "schedule" | "s3"). */
|
||||||
export interface BackendTriggerConfig {
|
export interface BackendTriggerConfig {
|
||||||
type: string;
|
type: string;
|
||||||
options: Record<string, unknown>;
|
options: Record<string, unknown>;
|
||||||
@@ -49,7 +49,8 @@ export interface BackendPolicy {
|
|||||||
owner: string;
|
owner: string;
|
||||||
/** Gates automatic triggering; an explicit run ignores it. */
|
/** Gates automatic triggering; an explicit run ignores it. */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Null = manual; these policies are run from the browser on uploaded files. */
|
/** Null for a manual-only (client-driven) policy — the editor fires runs on
|
||||||
|
* upload/export via /run, so there's no server-side trigger. */
|
||||||
trigger: BackendTriggerConfig | null;
|
trigger: BackendTriggerConfig | null;
|
||||||
steps: BackendPipelineStep[];
|
steps: BackendPipelineStep[];
|
||||||
output: BackendOutputSpec;
|
output: BackendOutputSpec;
|
||||||
@@ -189,7 +190,7 @@ export interface PolicyToStore {
|
|||||||
/** The decoded policy read back from the backend. */
|
/** The decoded policy read back from the backend. */
|
||||||
export interface DecodedPolicy {
|
export interface DecodedPolicy {
|
||||||
id: string;
|
id: string;
|
||||||
/** The catalog category this policy maps to (from output.options.categoryId). */
|
/** The catalog category this policy maps to (from trigger.options.categoryId). */
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -203,6 +204,7 @@ export interface DecodedPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_FOLDER: PolicyFolderSettings = {
|
const DEFAULT_FOLDER: PolicyFolderSettings = {
|
||||||
|
runOn: "upload",
|
||||||
outputMode: "new_version",
|
outputMode: "new_version",
|
||||||
outputName: "",
|
outputName: "",
|
||||||
outputNamePosition: "prefix",
|
outputNamePosition: "prefix",
|
||||||
@@ -211,11 +213,16 @@ const DEFAULT_FOLDER: PolicyFolderSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a frontend policy to the backend {@link BackendPolicy} for persistence. Trigger is null:
|
* Map a frontend policy to the backend {@link BackendPolicy} for persistence.
|
||||||
* these policies are run from the browser on uploaded files, not fired by a backend trigger. The
|
* Policies are manual-only (client-driven): the editor fires runs on upload /
|
||||||
* backend models only name/enabled/trigger/steps/output, so all policy-level extras (categoryId,
|
* before export via /run, so `trigger` is null (a server-side folder-watch or
|
||||||
* sources, scope, reviewer, fields, output/retry settings, and the full automation for a lossless
|
* schedule trigger doesn't fit the in-editor model, and a null trigger skips
|
||||||
* UI round-trip) ride in `output.options`; `steps` carries the endpoint-mapped pipeline.
|
* trigger validation on the backend). The backend models only
|
||||||
|
* name/enabled/trigger/steps/output, so the policy-level extras (categoryId,
|
||||||
|
* sources, scope, reviewer, fields) and the output + retry settings all ride in
|
||||||
|
* `output.options`; the full frontend automation is stashed in
|
||||||
|
* `output.options.automation` for a lossless UI round-trip (while `steps`
|
||||||
|
* carries the endpoint-mapped pipeline the engine runs, pre-built by the caller).
|
||||||
*/
|
*/
|
||||||
export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
|
export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
|
||||||
return {
|
return {
|
||||||
@@ -234,6 +241,8 @@ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
|
|||||||
maxRetries: input.folder.maxRetries,
|
maxRetries: input.folder.maxRetries,
|
||||||
retryDelayMinutes: input.folder.retryDelayMinutes,
|
retryDelayMinutes: input.folder.retryDelayMinutes,
|
||||||
automation: input.automation,
|
automation: input.automation,
|
||||||
|
runOn: input.folder.runOn,
|
||||||
|
// Policy-level metadata (no trigger bag to hold it any more).
|
||||||
categoryId: input.categoryId,
|
categoryId: input.categoryId,
|
||||||
sources: input.sources,
|
sources: input.sources,
|
||||||
scopeTypes: input.scopeTypes,
|
scopeTypes: input.scopeTypes,
|
||||||
@@ -247,27 +256,29 @@ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
|
|||||||
/** Decode a stored backend policy back into the frontend settings. */
|
/** Decode a stored backend policy back into the frontend settings. */
|
||||||
export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy {
|
export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy {
|
||||||
const output = policy.output.options;
|
const output = policy.output.options;
|
||||||
|
// Metadata lives in output.options; legacy records kept it in trigger.options,
|
||||||
|
// so merge both (output wins) to decode either shape.
|
||||||
|
const meta = { ...(policy.trigger?.options ?? {}), ...output };
|
||||||
const str = (v: unknown, fallback = "") =>
|
const str = (v: unknown, fallback = "") =>
|
||||||
typeof v === "string" ? v : fallback;
|
typeof v === "string" ? v : fallback;
|
||||||
const num = (v: unknown, fallback: number) =>
|
const num = (v: unknown, fallback: number) =>
|
||||||
typeof v === "number" ? v : fallback;
|
typeof v === "number" ? v : fallback;
|
||||||
return {
|
return {
|
||||||
id: policy.id,
|
id: policy.id,
|
||||||
categoryId: str(output.categoryId),
|
categoryId: str(meta.categoryId),
|
||||||
name: policy.name,
|
name: policy.name,
|
||||||
enabled: policy.enabled,
|
enabled: policy.enabled,
|
||||||
automation: (output.automation as AutomationConfig | undefined) ?? null,
|
automation: (output.automation as AutomationConfig | undefined) ?? null,
|
||||||
sources: Array.isArray(output.sources) ? (output.sources as string[]) : [],
|
sources: Array.isArray(meta.sources) ? (meta.sources as string[]) : [],
|
||||||
scopeTypes: Array.isArray(output.scopeTypes)
|
scopeTypes: Array.isArray(meta.scopeTypes)
|
||||||
? (output.scopeTypes as string[])
|
? (meta.scopeTypes as string[])
|
||||||
: [],
|
: [],
|
||||||
reviewerEmail: str(output.reviewerEmail),
|
reviewerEmail: str(meta.reviewerEmail),
|
||||||
fieldValues:
|
fieldValues:
|
||||||
(output.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {},
|
(meta.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {},
|
||||||
folder: {
|
folder: {
|
||||||
// Default to versioning unless the stored policy explicitly says new_file,
|
runOn: meta.runOn === "export" ? "export" : "upload",
|
||||||
// so a missing/legacy output.mode follows the new-version default rather
|
// Legacy/missing output.mode defaults to new_version, not new_file.
|
||||||
// than silently flipping a reconciled policy to spawning separate files.
|
|
||||||
outputMode: output.mode === "new_file" ? "new_file" : "new_version",
|
outputMode: output.mode === "new_file" ? "new_file" : "new_version",
|
||||||
outputName: str(output.name),
|
outputName: str(output.name),
|
||||||
outputNamePosition:
|
outputNamePosition:
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ function defaultState(): PolicyState {
|
|||||||
outputMode: "new_version",
|
outputMode: "new_version",
|
||||||
// No rename by default — the output keeps the input's filename.
|
// No rename by default — the output keeps the input's filename.
|
||||||
outputName: "",
|
outputName: "",
|
||||||
|
// Enforce on upload by default; export enforcement is the alternative.
|
||||||
|
runOn: "upload",
|
||||||
// Every catalog category is a shipped, built-in policy → default (not
|
// Every catalog category is a shipped, built-in policy → default (not
|
||||||
// deletable). User-created policies (later) will set this false.
|
// deletable). User-created policies (later) will set this false.
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ export interface PolicyState {
|
|||||||
* input's filename; when set, it's applied as a prefix/suffix per the policy's
|
* input's filename; when set, it's applied as a prefix/suffix per the policy's
|
||||||
* name-position setting. */
|
* name-position setting. */
|
||||||
outputName?: string;
|
outputName?: string;
|
||||||
|
/** When the policy runs: on "upload" or before "export". Defaults to "upload". */
|
||||||
|
runOn?: "upload" | "export";
|
||||||
/**
|
/**
|
||||||
* The backing folder-trigger record (a Watched Folders `WatchedFolder`) that
|
* The backing folder-trigger record (a Watched Folders `WatchedFolder`) that
|
||||||
* holds this policy's editable steps (its automation), output config and run
|
* holds this policy's editable steps (its automation), output config and run
|
||||||
@@ -160,6 +162,8 @@ export type PoliciesByCategory = Record<string, PolicyState>;
|
|||||||
* backing folder (the real, working settings reused from the folder setup).
|
* backing folder (the real, working settings reused from the folder setup).
|
||||||
*/
|
*/
|
||||||
export interface PolicyFolderSettings {
|
export interface PolicyFolderSettings {
|
||||||
|
/** The editor event the policy runs on: "upload" or "export". */
|
||||||
|
runOn: "upload" | "export";
|
||||||
outputMode: "new_file" | "new_version";
|
outputMode: "new_file" | "new_version";
|
||||||
outputName: string;
|
outputName: string;
|
||||||
outputNamePosition: "prefix" | "suffix" | "auto-number";
|
outputNamePosition: "prefix" | "suffix" | "auto-number";
|
||||||
|
|||||||
Reference in New Issue
Block a user