Block file exit points while a per-file policy run is enforcing (#6904)

## What

While a per-file policy run is in flight, the editor now blocks every
way the file can leave the app, and shows why:

- **Viewer** — a blocking overlay with live progress ("Enforcing
policy…"). Dismissible: collapses to a corner badge (top right, tinted
with the policy's accent) so the file stays readable while the run
finishes.
- **Workbench bar** — Print / Download / Save As / Share are disabled
with an explanatory tooltip and progress bar. The Ctrl+P shortcut and
the form-fill bar's "Download PDF" button are covered too.
- **File lists** — the file sidebar, file-editor thumbnails, and files
page show a spinning shield badge on the affected file, and thumbnail
hover actions (download / upload to server) are blocked with the same
tooltip.

Once a run settles, everything unblocks — including FAILED and CANCELLED
runs. A failed check surfaces through the run's activity feed; it never
locks the user out of their file.

## Why

Upload-triggered policies exist so the enforced output is what leaves
the app. Before this, a file could be printed, downloaded, or shared
while its policy run was still processing.

## Also in here

- **One shared `PolicyBadges` component** — the sidebar, thumbnails, and
files page each had their own copy of the badge markup/CSS and had
drifted (different sizes, tints, missing spinner and glow on the files
page, hardcoded English tooltips). All badge surfaces now render the
same component: accent-tinted shield, spinner while enforcing, one-off
glow when recent, i18n'd tooltips.
- **Cascade fix:** outputs imported from reconciled
(server-rediscovered) runs are now tagged `derivedFromTool`, stopping an
auto-run → import → auto-run loop that produced ever-growing
`_sanitized_sanitized…` filename chains on fresh devices.
- **Core stub for `policyRunStore`** so the core build compiles —
`WorkbenchBar` and `ViewerShareButton` resolve `usePolicyRuns` via
`@app/*`.

## Testing

- `task frontend:check` green: proprietary typecheck, ESLint + dpdm,
Prettier, 915+ editor + 81 portal unit tests.
- `typecheck:core` / `saas` / `desktop` variants all pass.
- Enforcement flow exercised manually against a live backend with an
upload-triggered policy (overlay + progress during the run, dismiss to
corner badge, unblock on completion).

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
Reece Browne
2026-07-08 14:09:23 +00:00
committed by GitHub
co-authored by James Brunton
parent a8bda9240c
commit 18b0b19a67
29 changed files with 961 additions and 265 deletions
@@ -6062,6 +6062,14 @@ stepOf = "Step {{step}} of {{total}}"
toolChainDesc = "Configure the tools this policy runs on each document."
typesSelected = "{{count}} types selected"
[policy]
badgeEnforcing = "{{name}} enforcing..."
badgeRan = "{{name}} policy ran on this file"
blockingAction = "{{action}} blocked while enforcing policy, please wait..."
dismiss = "Dismiss overlay"
enforcingTitle = "Enforcing policy..."
viewAnyway = "View file (policy still enforcing)"
[portal.accountLink.card]
billingNote = "Unattended processing bills against your org wallet."
eyebrow = "Account link"
@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, useEffect } from "react";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { flushSync } from "react-dom";
import { Center, Box, LoadingOverlay } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
@@ -19,6 +19,10 @@ import { FileId, StirlingFile } from "@app/types/fileContext";
import { alert } from "@app/components/toast";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges";
const EMPTY_POLICIES: FileItemPolicyRef[] = [];
interface FileEditorProps {
onOpenPageEditor?: () => void;
@@ -31,6 +35,8 @@ const FileEditor = ({
toolMode = false,
supportedExtensions = ["pdf"],
}: FileEditorProps) => {
const policyFileBadges = usePolicyFileBadges();
// Utility function to check if a file extension is supported
const isFileSupported = useCallback(
(fileName: string): boolean => {
@@ -52,6 +58,14 @@ const FileEditor = ({
[state.files.byId, state.files.ids],
);
// Always-current refs so callbacks can read the latest stubs/selection without
// closing over them as deps — prevents every callback from regenerating whenever
// any stub changes (e.g. thumbnail load), which would bust React.memo on every thumbnail.
const stubsRef = useRef(activeStirlingFileStubs);
stubsRef.current = activeStirlingFileStubs;
const selectedFileIdsRef = useRef(selectedFileIds);
selectedFileIdsRef.current = selectedFileIds;
// Get navigation actions
const { actions: navActions } = useNavigationActions();
@@ -141,7 +155,7 @@ const FileEditor = ({
// File reordering handler for drag and drop
const handleReorderFiles = useCallback(
(sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
const currentIds = activeStirlingFileStubs.map((r) => r.id);
const currentIds = stubsRef.current.map((r) => r.id);
// Find indices
const sourceIndex = currentIds.findIndex((id) => id === sourceFileId);
@@ -211,38 +225,27 @@ const FileEditor = ({
const moveCount = filesToMove.length;
showStatus(`${moveCount > 1 ? `${moveCount} files` : "File"} reordered`);
},
[activeStirlingFileStubs, reorderFiles, _setStatus],
[reorderFiles, showStatus],
);
// File operations using context
const handleCloseFile = useCallback(
(fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const record = stubsRef.current.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
// Remove file from context but keep in storage (close, don't delete)
const contextFileId = record.id;
removeFiles([contextFileId], false);
// Remove from context selections
const currentSelected = selectedFileIds.filter(
(id) => id !== contextFileId,
removeFiles([record.id], false);
setSelectedFiles(
selectedFileIdsRef.current.filter((id) => id !== record.id),
);
setSelectedFiles(currentSelected);
}
},
[
activeStirlingFileStubs,
selectors,
removeFiles,
setSelectedFiles,
selectedFileIds,
],
[selectors, removeFiles, setSelectedFiles],
);
const handleDownloadFile = useCallback(
async (fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const record = stubsRef.current.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
console.log("[FileEditor] handleDownloadFile called:", {
fileId,
@@ -278,12 +281,12 @@ const FileEditor = ({
}
}
},
[activeStirlingFileStubs, selectors, fileActions],
[selectors, fileActions],
);
const handleUnzipFile = useCallback(
async (fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const record = stubsRef.current.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
try {
@@ -326,24 +329,19 @@ const FileEditor = ({
}
}
},
[activeStirlingFileStubs, selectors, fileActions, removeFiles],
[selectors, fileActions, removeFiles],
);
const handleViewFile = useCallback(
(fileId: FileId) => {
const index = activeStirlingFileStubs.findIndex((r) => r.id === fileId);
const index = stubsRef.current.findIndex((r) => r.id === fileId);
if (index !== -1) {
setActiveFileId(fileId as string);
setActiveFileIndex(index);
navActions.setWorkbench("viewer");
}
},
[
activeStirlingFileStubs,
setActiveFileId,
setActiveFileIndex,
navActions.setWorkbench,
],
[setActiveFileId, setActiveFileIndex, navActions.setWorkbench],
);
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
@@ -412,6 +410,10 @@ const FileEditor = ({
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
policies={
policyFileBadges.get(record.id as string) ??
EMPTY_POLICIES
}
/>
);
})}
@@ -123,6 +123,14 @@
left: 0;
right: 0;
bottom: calc(var(--file-meta-line-height) + var(--file-meta-gap));
display: flex;
align-items: flex-start;
justify-content: center;
gap: 4px;
overflow: hidden;
}
.fileNameText {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
@@ -131,6 +139,12 @@
text-overflow: ellipsis;
overflow-wrap: break-word;
word-break: normal;
min-width: 0;
}
/* Layout-only wrapper for the shared PolicyBadges row in the file-name line. */
.fileNameBadges {
padding-top: 2px;
}
.fileMeta {
@@ -207,6 +221,7 @@
pointer-events: auto;
}
/* Policy badges pinned to top-right of thumbnail */
.statusDot {
display: inline-block;
width: 8px;
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useRef, useMemo } from "react";
import { Text, Modal, Group, Stack, Tooltip } from "@mantine/core";
import { Text, Modal, Group, Loader, Stack, Tooltip } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Button } from "@app/ui/Button";
import { useIsMobile } from "@app/hooks/useIsMobile";
@@ -16,11 +16,17 @@ import HistoryIcon from "@mui/icons-material/History";
import PushPinIcon from "@mui/icons-material/PushPin";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { StirlingFileStub } from "@app/types/fileContext";
import {
PolicyBadges,
type FileItemPolicyRef,
} from "@app/components/shared/PolicyBadges";
import { PolicyEnforcingOverlay } from "@app/components/shared/PolicyEnforcingOverlay";
import { zipFileService } from "@app/services/zipFileService";
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
@@ -57,6 +63,7 @@ interface FileEditorThumbnailProps {
onUnzipFile?: (fileId: FileId) => void;
toolMode?: boolean;
isSupported?: boolean;
policies?: FileItemPolicyRef[];
}
const FileEditorThumbnail = ({
@@ -67,6 +74,7 @@ const FileEditorThumbnail = ({
onDownloadFile,
onUnzipFile,
isSupported = true,
policies = [],
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
const { config } = useAppConfig();
@@ -286,8 +294,28 @@ const FileEditorThumbnail = ({
const [showVersionHistory, setShowVersionHistory] = useState(false);
const hoverActions = useMemo<HoverAction[]>(
() => [
const policyEnforcing = policies.some((p) => p.enforcing);
const hoverActions = useMemo<HoverAction[]>(() => {
const uploadLabel = isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server");
const enforcingTooltip = (action: string): React.ReactNode => (
<Stack gap={4} py={2} w={180}>
<Group gap={6} wrap="nowrap">
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
<Text size="xs" fw={600}>
{t(
"policy.blockingAction",
"{{action}} blocked while enforcing policy, please wait...",
{ action },
)}
</Text>
</Group>
<Loader size="xs" />
</Stack>
);
return [
{
id: "view",
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
@@ -331,6 +359,10 @@ const FileEditorThumbnail = ({
id: "download",
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
label: terminology.download,
disabled: policyEnforcing,
tooltip: policyEnforcing
? enforcingTooltip(terminology.download)
: undefined,
onClick: (e) => {
e.stopPropagation();
onDownloadFile(file.id);
@@ -341,9 +373,11 @@ const FileEditorThumbnail = ({
{
id: "upload",
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server"),
label: uploadLabel,
disabled: policyEnforcing,
tooltip: policyEnforcing
? enforcingTooltip(uploadLabel)
: undefined,
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
@@ -357,6 +391,10 @@ const FileEditorThumbnail = ({
id: "share",
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t("fileManager.share", "Share"),
disabled: policyEnforcing,
tooltip: policyEnforcing
? enforcingTooltip(t("fileManager.share", "Share"))
: undefined,
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
@@ -402,30 +440,30 @@ const FileEditorThumbnail = ({
},
color: "red",
},
],
[
t,
file.id,
file.name,
file.versionNumber,
isZipFile,
isCBZ,
isCBR,
isPinned,
actualFile,
terminology,
DownloadOutlinedIcon,
onViewFile,
onDownloadFile,
onUnzipFile,
handleCloseWithConfirmation,
canUpload,
canShare,
isUploaded,
pinFile,
unpinFile,
],
);
];
}, [
t,
file.id,
file.name,
file.versionNumber,
isZipFile,
isCBZ,
isCBR,
isPinned,
actualFile,
terminology,
DownloadOutlinedIcon,
onViewFile,
onDownloadFile,
onUnzipFile,
handleCloseWithConfirmation,
policyEnforcing,
canUpload,
canShare,
isUploaded,
pinFile,
unpinFile,
]);
const handleCardClick = () => {
if (!isSupported) return;
@@ -497,6 +535,9 @@ const FileEditorThumbnail = ({
</div>
)}
{/* Policy enforcement overlay — shown while any policy is in-flight */}
<PolicyEnforcingOverlay enforcing={policyEnforcing} zIndex={2} />
{/* Thumbnail image or loading state */}
<DocumentThumbnail
file={file}
@@ -580,7 +621,10 @@ const FileEditorThumbnail = ({
{/* File name + meta */}
<div className={styles.fileText}>
<p className={styles.fileName}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
<span className={styles.fileNameText}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
</span>
<PolicyBadges policies={policies} className={styles.fileNameBadges} />
</p>
<p className={styles.fileMeta}>{metaLine}</p>
</div>
@@ -4,7 +4,7 @@ import { Checkbox, Menu, Tooltip } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import { PolicyBadges as PolicyBadgeRow } from "@app/components/shared/PolicyBadges";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
@@ -581,26 +581,7 @@ function FolderCard({
/** Shield badges for the policies that have run on a file. */
function PolicyBadges({ fileId }: { fileId: string }) {
const badges = usePolicyFileBadges().get(fileId) ?? [];
if (badges.length === 0) return null;
return (
<span className="files-page-policy-badges" data-no-select>
{badges.slice(0, 3).map((policy) => (
<Tooltip
key={policy.id}
label={`${policy.name} policy ran on this file`}
withArrow
position="top"
>
<span
className="files-page-policy-badge"
style={{ color: policy.accentColor }}
>
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
</span>
</Tooltip>
))}
</span>
);
return <PolicyBadgeRow policies={badges} />;
}
interface FileCardProps {
@@ -484,24 +484,6 @@
gap: 0.4rem;
}
/* Policy activity badges (a shield per policy that has run on the file). */
.files-page-policy-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.files-page-policy-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 4px;
/* `color` set inline to the policy accent; tint follows it. */
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* Parent-folder breadcrumb shown on cards/rows during recursive search so
the user can tell which folder each hit lives in without navigating. */
.files-page-card-path {
@@ -0,0 +1,27 @@
/**
* Core stub — the real implementation lives in the proprietary overlay.
* Returns empty state so core-build consumers compile without a policyRunStore module.
*/
export interface PolicyRunRecord {
runId: string;
categoryId: string;
fileId: string;
fileName: string;
status: string;
currentStep?: number;
stepCount?: number;
error: string | null;
retrying?: boolean;
startedAt: number;
}
export const POLICY_IN_FLIGHT_STATUSES = [
"PENDING",
"RUNNING",
"WAITING_FOR_INPUT",
] as const;
export function usePolicyRuns(): PolicyRunRecord[] {
return [];
}
@@ -36,32 +36,6 @@
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);
}
@@ -160,25 +134,6 @@
text-overflow: ellipsis;
}
/* ---- Policy activity badges (a shield per policy that has run on the file) ---- */
.file-sidebar-policy-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.file-sidebar-policy-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 4px;
/* `color` is set inline to the policy's accent; the tint follows it. */
color: var(--text-secondary);
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* ---- Cloud badge (file saved to the server) ---- */
.file-sidebar-cloud-badge {
display: inline-flex;
@@ -1,6 +1,6 @@
import { useState, useCallback, useRef } from "react";
import React, { useState, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { Menu, Tooltip } from "@mantine/core";
import { Group, Loader, Menu, Stack, Text, Tooltip } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
@@ -13,6 +13,10 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import HistoryIcon from "@mui/icons-material/History";
import type { FileId } from "@app/types/file";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import {
PolicyBadges,
type FileItemPolicyRef,
} from "@app/components/shared/PolicyBadges";
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
@@ -132,17 +136,6 @@ export interface FileItemFolderRef {
accentColor: string;
}
/** A policy that has run on this file, used for the activity badges. */
export interface FileItemPolicyRef {
id: string;
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 {
fileId: FileId;
name: string;
@@ -178,7 +171,6 @@ export interface FileItemProps {
}
const MAX_VISIBLE_FOLDER_TAGS = 2;
const MAX_VISIBLE_POLICY_BADGES = 3;
export function FileItem({
fileId,
@@ -208,6 +200,23 @@ export function FileItem({
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
const typeLabel = ext ? ext.toUpperCase() : "File";
const policyEnforcing = policies.some((p) => p.enforcing);
const enforcingTooltip = (action: string): React.ReactNode => (
<Stack gap={6} py={2} w={200}>
<Group gap={6} wrap="nowrap">
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
<Text size="xs" fw={600}>
{t(
"policy.blockingAction",
"{{action}} blocked while enforcing policy, please wait...",
{ action },
)}
</Text>
</Group>
<Loader size="xs" />
</Stack>
);
const visibleFolders = folders.slice(0, MAX_VISIBLE_FOLDER_TAGS);
const overflowFolders = folders.slice(MAX_VISIBLE_FOLDER_TAGS);
@@ -228,9 +237,6 @@ 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
@@ -244,14 +250,7 @@ export function FileItem({
<>
<div
ref={itemRef}
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}${recentPolicy ? " policy-enforced" : ""}`}
style={
recentPolicy
? ({
"--policy-glow": recentPolicy.accentColor,
} as React.CSSProperties)
: undefined
}
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
onClick={() => onClick(fileId)}
draggable={draggable}
onDragStart={
@@ -301,25 +300,7 @@ export function FileItem({
</span>
</Tooltip>
)}
{policies.length > 0 && (
<span className="file-sidebar-policy-badges" data-no-select>
{policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => (
<Tooltip
key={policy.id}
label={`${policy.name} policy ran on this file`}
withArrow
position="top"
>
<span
className="file-sidebar-policy-badge"
style={{ color: policy.accentColor }}
>
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
</span>
</Tooltip>
))}
</span>
)}
<PolicyBadges policies={policies} />
</span>
{folders.length > 0 && (
<span className="file-sidebar-folder-tags" data-no-select>
@@ -395,7 +376,7 @@ export function FileItem({
/>
</ActionIcon>
{(onDelete ||
onSaveToCloud ||
(canSaveToCloud && onSaveToCloud) ||
(hasVersionHistory && onVersionHistory)) && (
<Menu position="bottom-end" withinPortal shadow="md" width={190}>
<Menu.Target>
@@ -425,17 +406,10 @@ export function FileItem({
{t("fileSidebar.fileItem.versionHistory", "Version history")}
</Menu.Item>
)}
{canSaveToCloud && onSaveToCloud && (
<Menu.Item
leftSection={
<CloudUploadOutlinedIcon sx={{ fontSize: 16 }} />
}
onClick={(e) => {
e.stopPropagation();
onSaveToCloud(fileId);
}}
>
{isUploadedToCloud
{canSaveToCloud &&
onSaveToCloud &&
(() => {
const uploadLabel = isUploadedToCloud
? t(
"fileSidebar.fileItem.updateOnServer",
"Update on server",
@@ -443,21 +417,64 @@ export function FileItem({
: t(
"fileSidebar.fileItem.uploadToServer",
"Upload to server",
)}
</Menu.Item>
)}
{onDelete && (
<Menu.Item
color="red"
leftSection={<DeleteOutlineIcon sx={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onDelete(fileId);
}}
>
{t("fileSidebar.fileItem.delete", "Delete")}
</Menu.Item>
)}
);
return (
<Tooltip
label={enforcingTooltip(uploadLabel)}
disabled={!policyEnforcing}
position="left"
offset={6}
withArrow
>
<div>
<Menu.Item
disabled={policyEnforcing}
leftSection={
<CloudUploadOutlinedIcon sx={{ fontSize: 16 }} />
}
onClick={(e) => {
e.stopPropagation();
onSaveToCloud(fileId);
}}
>
{uploadLabel}
</Menu.Item>
</div>
</Tooltip>
);
})()}
{onDelete &&
(() => {
const deleteLabel = t(
"fileSidebar.fileItem.delete",
"Delete",
);
return (
<Tooltip
label={enforcingTooltip(deleteLabel)}
disabled={!policyEnforcing}
position="left"
offset={6}
withArrow
>
<div>
<Menu.Item
disabled={policyEnforcing}
color="red"
leftSection={
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
}
onClick={(e) => {
e.stopPropagation();
onDelete(fileId);
}}
>
{deleteLabel}
</Menu.Item>
</div>
</Tooltip>
);
})()}
</Menu.Dropdown>
</Menu>
)}
@@ -10,6 +10,8 @@ export interface HoverAction {
label: string;
onClick: (e: React.MouseEvent) => void;
disabled?: boolean;
/** Overrides label in the tooltip — use for rich ReactNode content (e.g. enforcement messages). */
tooltip?: React.ReactNode;
color?: string;
hidden?: boolean;
dataTour?: string;
@@ -58,18 +60,22 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
onClick={(e) => e.stopPropagation()}
>
{visibleActions.map((action) => (
<Tooltip key={action.id} label={action.label}>
<ActionIcon
size="md"
variant="tertiary"
disabled={action.disabled}
onClick={action.onClick}
aria-label={action.label}
style={{ color: action.color || "var(--text-secondary)" }}
data-tour={action.dataTour}
>
{action.icon}
</ActionIcon>
<Tooltip key={action.id} label={action.tooltip ?? action.label}>
{/* Wrapper keeps the tooltip working when the button is disabled
(disabled buttons don't emit the pointer events Tooltip needs). */}
<div style={{ display: "inline-flex", alignItems: "center" }}>
<ActionIcon
size="md"
variant="tertiary"
disabled={action.disabled}
onClick={action.onClick}
aria-label={action.label}
style={{ color: action.color || "var(--text-secondary)" }}
data-tour={action.dataTour}
>
{action.icon}
</ActionIcon>
</div>
</Tooltip>
))}
</div>
@@ -0,0 +1,62 @@
/* Canonical policy badge styling — every per-file policy badge in the app
* (sidebar, thumbnails, files page, viewer indicator) uses these classes so
* colour and shape stay consistent. `color` is set inline to the policy's
* accent; the background tint follows it. */
.policy-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.policy-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 4px;
color: var(--text-secondary);
background: color-mix(in srgb, currentColor 16%, transparent);
pointer-events: auto;
}
/* Larger variant for standalone indicators (e.g. the minimised viewer overlay). */
.policy-badge--lg {
width: 28px;
height: 28px;
border-radius: 8px;
box-shadow: var(--shadow-md);
}
.policy-badge--enforcing svg {
animation: policy-badge-spin 1s linear infinite;
}
@keyframes policy-badge-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.policy-badge--recent {
animation: policy-badge-pulse 4.5s ease-in-out forwards;
}
@keyframes policy-badge-pulse {
0%,
24%,
48% {
box-shadow: 0 0 0 0 transparent;
}
12%,
36% {
box-shadow: 0 0 5px 2px currentColor;
}
60%,
100% {
box-shadow: 0 0 0 0 transparent;
}
}
@@ -0,0 +1,72 @@
import { Tooltip } from "@mantine/core";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import { useTranslation } from "react-i18next";
import "@app/components/shared/PolicyBadges.css";
/** A policy that has run on this file, used for the activity badges. */
export interface FileItemPolicyRef {
id: string;
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;
/** True while the policy run is actively in-flight on this file. */
enforcing?: boolean;
}
const MAX_VISIBLE = 3;
/**
* The canonical policy badge row: one accent-tinted shield per policy that has
* run on a file, spinning while a run is in flight, glowing briefly after it
* lands. Every surface that shows per-file policy badges (file sidebar, file
* editor thumbnails, files page) renders this so they stay identical.
*/
export function PolicyBadges({
policies,
className,
}: {
policies: FileItemPolicyRef[];
/** Appended to the row for surface-specific layout (spacing only). */
className?: string;
}) {
const { t } = useTranslation();
if (policies.length === 0) return null;
return (
<span
className={`policy-badges${className ? ` ${className}` : ""}`}
data-no-select
>
{policies.slice(0, MAX_VISIBLE).map((policy) => (
<Tooltip
key={policy.id}
label={
policy.enforcing
? t("policy.badgeEnforcing", "{{name}} enforcing…", {
name: policy.name,
})
: t("policy.badgeRan", "{{name}} policy ran on this file", {
name: policy.name,
})
}
withArrow
position="top"
>
<span
className={`policy-badge${policy.enforcing ? " policy-badge--enforcing" : ""}${policy.recent && !policy.enforcing ? " policy-badge--recent" : ""}`}
style={{ color: policy.accentColor }}
>
{policy.enforcing ? (
<AutorenewIcon sx={{ fontSize: "0.7rem" }} />
) : (
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
)}
</span>
</Tooltip>
))}
</span>
);
}
@@ -0,0 +1,7 @@
export function PolicyEnforcingOverlay(_props: {
enforcing: boolean;
progress?: number;
zIndex?: number;
}) {
return null;
}
@@ -5,6 +5,7 @@ import React, {
useRef,
useSyncExternalStore,
} from "react";
import { Group, Loader, Progress, Stack, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { SegmentedControl } from "@app/ui/SegmentedControl";
@@ -33,6 +34,11 @@ import { Tooltip } from "@app/components/shared/Tooltip";
import LocalIcon from "@app/components/shared/LocalIcon";
import ViewerShareButton from "@app/components/viewer/ViewerShareButton";
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import {
POLICY_IN_FLIGHT_STATUSES,
usePolicyRuns,
} from "@app/components/policies/policyRunStore";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import { enforceExportPolicies } from "@app/services/policyExport";
import { downloadFile as downloadRaw } from "@app/services/downloadService";
@@ -46,6 +52,7 @@ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutl
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import CloseIcon from "@mui/icons-material/Close";
import PrintIcon from "@mui/icons-material/Print";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import "@app/components/shared/WorkbenchBar.css";
const SECTION_ORDER: WorkbenchBarSection[] = ["top", "middle", "bottom"];
@@ -119,6 +126,60 @@ export default function WorkbenchBar({
const { actions: fileActions } = useFileActions();
const activeFiles = selectors.getFiles();
const { activeFileId, setActiveFileId } = useViewer();
const policyFileBadges = usePolicyFileBadges();
// Block print/export while any file the export would touch is under active
// policy enforcement: the viewer exports its active file, every other view
// exports the selection (or all files when nothing is selected).
const exportTargetIds: string[] =
currentView === "viewer"
? activeFileId
? [activeFileId]
: []
: selectedFileIds.length > 0
? selectedFileIds
: activeFiles.filter(isStirlingFile).map((f) => f.fileId);
const enforcingFileId = exportTargetIds.find((id) =>
(policyFileBadges.get(id) ?? []).some((p) => p.enforcing),
);
const policyEnforcing = enforcingFileId != null;
const policyRuns = usePolicyRuns();
const enforcingRun = policyEnforcing
? policyRuns.find(
(r) =>
r.fileId === enforcingFileId &&
(POLICY_IN_FLIGHT_STATUSES as readonly string[]).includes(r.status),
)
: undefined;
const enforcingProgress =
enforcingRun?.currentStep != null && enforcingRun.stepCount
? Math.round((enforcingRun.currentStep / enforcingRun.stepCount) * 100)
: undefined;
const makeEnforcingTooltip = (action: string): React.ReactNode => (
<Stack gap={6} py={2} w={200}>
<Group gap={6} wrap="nowrap">
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
<Text size="xs" fw={600}>
{t(
"policy.blockingAction",
"{{action}} blocked while enforcing policy, please wait",
{ action },
)}
</Text>
</Group>
{enforcingProgress != null ? (
<Progress
w="100%"
size="xs"
radius="xl"
value={enforcingProgress}
striped
animated
/>
) : (
<Loader size="xs" />
)}
</Stack>
);
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
const pageEditorSelectedCount =
pageEditorFunctions?.selectedPageIds?.length ?? 0;
@@ -504,13 +565,18 @@ export default function WorkbenchBar({
className="workbench-bar-action-icon"
onClick={handlePrint}
disabled={
totalItems === 0 || allButtonsDisabled || disableForFullscreen
totalItems === 0 ||
allButtonsDisabled ||
disableForFullscreen ||
policyEnforcing
}
aria-label={t("workbenchBar.print", "Print PDF")}
>
<PrintIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>,
t("workbenchBar.print", "Print PDF"),
policyEnforcing
? makeEnforcingTooltip(t("workbenchBar.print", "Print PDF"))
: t("workbenchBar.print", "Print PDF"),
)}
{/* Download (file-level action — not relevant in custom views) */}
@@ -522,7 +588,10 @@ export default function WorkbenchBar({
className="workbench-bar-action-icon"
onClick={() => handleExportAll()}
disabled={
disableForFullscreen || totalItems === 0 || allButtonsDisabled
disableForFullscreen ||
totalItems === 0 ||
allButtonsDisabled ||
policyEnforcing
}
aria-label={downloadTooltip}
>
@@ -532,7 +601,9 @@ export default function WorkbenchBar({
height="1rem"
/>
</ActionIcon>,
downloadTooltip,
policyEnforcing
? makeEnforcingTooltip(downloadTooltip)
: downloadTooltip,
)}
{/* Save As */}
@@ -545,7 +616,10 @@ export default function WorkbenchBar({
className="workbench-bar-action-icon"
onClick={() => handleExportAll(true)}
disabled={
disableForFullscreen || totalItems === 0 || allButtonsDisabled
disableForFullscreen ||
totalItems === 0 ||
allButtonsDisabled ||
policyEnforcing
}
aria-label={t("workbenchBar.saveAs", "Save As")}
>
@@ -555,7 +629,9 @@ export default function WorkbenchBar({
height="1rem"
/>
</ActionIcon>,
t("workbenchBar.saveAs", "Save As"),
policyEnforcing
? makeEnforcingTooltip(t("workbenchBar.saveAs", "Save As"))
: t("workbenchBar.saveAs", "Save As"),
)}
{/* Separator: export group | close */}
@@ -47,6 +47,7 @@ import { useWheelZoom } from "@app/hooks/useWheelZoom";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { FormSaveBar } from "@app/tools/formFill/FormSaveBar";
import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import { alert } from "@app/components/toast";
// ─── Measure dictionary extraction ────────────────────────────────────────────
@@ -443,6 +444,15 @@ const EmbedPdfViewerContent = ({
const viewerKeyCommand = useViewerKeyCommand();
const policyFileBadges = usePolicyFileBadges();
const policyEnforcing =
!!activeFileId &&
(policyFileBadges.get(activeFileId) ?? []).some((p) => p.enforcing);
// Use a ref so the keydown handler always reads the latest value without
// needing to be in the effect's dependency array.
const policyEnforcingRef = useRef(false);
policyEnforcingRef.current = policyEnforcing;
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -468,7 +478,9 @@ const EmbedPdfViewerContent = ({
case "p":
case "P":
event.preventDefault();
printActions.print();
if (!policyEnforcingRef.current) {
printActions.print();
}
return;
case "a":
case "A":
@@ -1333,6 +1345,7 @@ const EmbedPdfViewerContent = ({
file={currentFile ?? null}
isFormFillToolActive={isFormFillToolActive}
onApply={handleFormApply}
policyEnforcing={policyEnforcing}
/>
<StampPlacementOverlay
containerRef={pdfContainerRef}
@@ -1,9 +1,10 @@
import { useState } from "react";
import { Group, Modal, Stack, Text } from "@mantine/core";
import { Group, Loader, Modal, Progress, Stack, Text } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import ShareIcon from "@mui/icons-material/Share";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { Tooltip } from "@app/components/shared/Tooltip";
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
@@ -15,6 +16,11 @@ import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import {
POLICY_IN_FLIGHT_STATUSES,
usePolicyRuns,
} from "@app/components/policies/policyRunStore";
interface ViewerShareButtonProps {
disabled?: boolean;
@@ -48,8 +54,54 @@ export default function ViewerShareButton({
? stubs.find((s) => s.id === activeFileId)
: undefined;
const policyFileBadges = usePolicyFileBadges();
const runs = usePolicyRuns();
const enforcing =
!!activeFileId &&
(policyFileBadges.get(activeFileId) ?? []).some((p) => p.enforcing);
const enforcingRun = enforcing
? runs.find(
(r) =>
r.fileId === activeFileId &&
(POLICY_IN_FLIGHT_STATUSES as readonly string[]).includes(r.status),
)
: undefined;
const enforcingProgress =
enforcingRun?.currentStep != null && enforcingRun.stepCount
? Math.round((enforcingRun.currentStep / enforcingRun.stepCount) * 100)
: undefined;
const label = t("workbenchBar.share", "Share");
const isDisabled = Boolean(disabled) || !stub;
const isDisabled = Boolean(disabled) || !stub || enforcing;
const tooltipContent = enforcing ? (
<Stack gap={6} py={2} w={200}>
<Group gap={6} wrap="nowrap">
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
<Text size="xs" fw={600}>
{t(
"policy.blockingAction",
"{{action}} blocked while enforcing policy, please wait",
{ action: label },
)}
</Text>
</Group>
{enforcingProgress != null ? (
<Progress
w="100%"
size="xs"
radius="xl"
value={enforcingProgress}
striped
animated
/>
) : (
<Loader size="xs" />
)}
</Stack>
) : (
label
);
const openShare = (target: StirlingFileStub) => {
setShareStub(target);
@@ -125,7 +177,7 @@ export default function ViewerShareButton({
return (
<>
<Tooltip
content={label}
content={tooltipContent}
position="bottom"
offset={6}
arrow
@@ -244,6 +244,7 @@ function FileContextInner({
/** Persist to IDB without dispatching to workspace state. */
skipWorkspaceDispatch?: boolean;
skipUploadTracking?: boolean;
derivedFromTool?: boolean;
},
): Promise<StirlingFile[]> => {
const stirlingFiles = await addFiles(
@@ -254,6 +254,9 @@ interface AddFileOptions {
) => Promise<boolean>; // Optional callback to confirm extraction of large ZIP files
allowDuplicates?: boolean;
skipUploadTracking?: boolean;
/** When true, marks every added stub as derivedFromTool so the policy
* auto-run skips it — used for policy outputs imported via addFiles. */
derivedFromTool?: boolean;
}
/**
@@ -368,6 +371,7 @@ export async function addFiles(
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
const fileStub = createNewStirlingFileStub(file, fileId);
if (options.derivedFromTool) fileStub.derivedFromTool = true;
// Early encryption detection for PDFs — set the flag before dispatch so the
// viewer gate and modal queue pick it up immediately instead of after hydration
@@ -1,4 +1,4 @@
import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges";
/**
* Policies that have run on each file, keyed by fileId — drives the shield
@@ -28,12 +28,15 @@ interface FormSaveBarProps {
isFormFillToolActive: boolean;
/** Callback when form changes are applied (should reload PDF with filled values) */
onApply?: (filledBlob: Blob) => Promise<void>;
/** Disable download while an ingestion-time policy run is in flight. */
policyEnforcing?: boolean;
}
export function FormSaveBar({
file,
isFormFillToolActive,
onApply,
policyEnforcing = false,
}: FormSaveBarProps) {
const { t } = useTranslation();
const { state, submitForm } = useFormFill();
@@ -181,7 +184,7 @@ export function FormSaveBar({
size="sm"
leftSection={<DownloadIcon sx={{ fontSize: 18 }} />}
loading={saving}
disabled={applying}
disabled={applying || policyEnforcing}
onClick={handleDownload}
style={{ flex: 1 }}
>
@@ -49,6 +49,13 @@ export interface PolicyRunRecord {
startedAt: number;
}
/** Statuses of a run that is still executing (not yet settled). */
export const POLICY_IN_FLIGHT_STATUSES: readonly PolicyRunStatus[] = [
"PENDING",
"RUNNING",
"WAITING_FOR_INPUT",
];
interface RunState {
runs: PolicyRunRecord[];
dispatched: string[];
@@ -92,6 +99,10 @@ function read(): RunState {
let state: RunState = read();
const listeners = new Set<() => void>();
function notifyListeners() {
for (const l of listeners) l();
}
function emit() {
try {
if (typeof localStorage !== "undefined") {
@@ -100,7 +111,18 @@ function emit() {
} catch {
// Best-effort persistence.
}
for (const l of listeners) l();
notifyListeners();
}
// Sync in-memory state when another tab (or a test via page.evaluate + dispatchEvent)
// writes to the same localStorage key.
if (typeof window !== "undefined") {
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) {
state = read();
notifyListeners();
}
});
}
function subscribe(listener: () => void) {
@@ -27,3 +27,17 @@ export const ROW_ACCENT: Record<string, IconBadgeAccent> = {
routing: "amber",
retention: "red",
};
/** Accent name → the CSS colour var the policy badges tint with. */
const ACCENT_VAR: Record<string, string> = {
blue: "var(--color-blue)",
purple: "var(--color-purple)",
green: "var(--color-green)",
amber: "var(--color-amber)",
red: "var(--color-red)",
};
/** CSS colour var for a policy category's accent (blue for unknown categories). */
export function policyAccentVar(categoryId: string): string {
return ACCENT_VAR[ROW_ACCENT[categoryId] ?? "blue"];
}
@@ -155,7 +155,13 @@ describe("auto-run import: new-version output delivery", () => {
recordCompletedRun();
await runImport();
expect(mocks.addFiles).toHaveBeenCalled();
// derivedFromTool must ride along so the auto-run never re-enforces this
// output, even after the dispatched list is wiped (fresh device / storage
// clear) — without it the output re-triggers the policy indefinitely.
expect(mocks.addFiles).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({ derivedFromTool: true }),
);
expect(mocks.persistVersionedOutputs).not.toHaveBeenCalled();
expect(mocks.consumeFiles).not.toHaveBeenCalled();
});
@@ -182,7 +188,10 @@ describe("auto-run import: new-version output delivery", () => {
});
expect(getRun("srv-1")?.startedAt).toBe(1000);
expect(mocks.addFiles).toHaveBeenCalled();
expect(mocks.addFiles).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({ derivedFromTool: true }),
);
expect(mocks.persistVersionedOutputs).not.toHaveBeenCalled();
});
});
@@ -309,7 +309,7 @@ export function usePolicyAutoRun(): void {
interface ImportContext {
addFiles: (
files: File[],
options?: { skipUploadTracking?: boolean },
options?: { skipUploadTracking?: boolean; derivedFromTool?: boolean },
) => Promise<StirlingFile[]>;
consumeFiles: (
inputFileIds: FileId[],
@@ -498,9 +498,14 @@ async function importOutputs(
parentStub,
"automate",
);
// Mark the outputs handled BEFORE adding them, so the auto-run never enforces
// the policy on its own output — that would version endlessly in a loop.
for (const s of stubs) markDispatched(run.categoryId, s.id);
// derivedFromTool is the durable cross-session guard; markDispatched is the
// belt-and-suspenders session guard. Both are needed: dispatched lives only
// in localStorage (wiped on clear / absent on a different device), while
// derivedFromTool is stamped on the stub itself.
for (const s of stubs) {
s.derivedFromTool = true;
markDispatched(run.categoryId, s.id);
}
deliveredIds = stubs.map((s) => s.id as string);
if (ctx.parentStub) {
// Input is in the active workspace: version it there (workspace + storage).
@@ -516,9 +521,13 @@ async function importOutputs(
ctx.bumpRevision();
}
} else {
const added = await ctx.addFiles(files, { skipUploadTracking: true });
// Same loop-guard for new-file output: the produced file is a new workspace
// file the auto-run would otherwise re-enforce indefinitely.
// derivedFromTool prevents the auto-run from ever re-enforcing this output,
// even if the dispatched list is cleared (localStorage wipe / different device).
const added = await ctx.addFiles(files, {
skipUploadTracking: true,
derivedFromTool: true,
});
// Belt-and-suspenders session guard on top of derivedFromTool.
for (const f of added) markDispatched(run.categoryId, f.fileId);
deliveredIds = added.map((f) => f.fileId as string);
}
@@ -0,0 +1,91 @@
import {
Center,
Loader,
Overlay,
Progress,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import CloseIcon from "@mui/icons-material/Close";
import { useTranslation } from "react-i18next";
interface PolicyEnforcingOverlayProps {
enforcing: boolean;
/** 0-100 progress when the run reports step counts; omit for indeterminate. */
progress?: number;
zIndex?: number;
/** When provided, an × button is shown and called on click. */
onDismiss?: () => void;
}
/**
* Frosted-glass enforcement overlay. Renders into its nearest positioned ancestor
* (position: relative) — works for both the full-screen viewer and thumbnail cards.
*/
export function PolicyEnforcingOverlay({
enforcing,
progress,
zIndex = 200,
onDismiss,
}: PolicyEnforcingOverlayProps) {
const { t } = useTranslation();
if (!enforcing) return null;
return (
<Overlay
color="var(--color-bg)"
backgroundOpacity={0.9}
blur={4}
zIndex={zIndex}
>
{onDismiss && (
<Tooltip
label={t("policy.viewAnyway", "View file (policy still enforcing)")}
position="left"
withArrow
>
<ActionIcon
variant="tertiary"
accent="neutral"
size="sm"
onClick={onDismiss}
style={{
position: "absolute",
top: 12,
right: 12,
zIndex: zIndex + 1,
}}
aria-label={t("policy.dismiss", "Dismiss overlay")}
>
<CloseIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
)}
<Center style={{ height: "100%" }}>
<Stack align="center" gap="md" w={220}>
<ThemeIcon size={48} radius="xl" variant="light" color="blue">
<ShieldOutlinedIcon style={{ fontSize: 26 }} />
</ThemeIcon>
<Text fw={600} size="sm">
{t("policy.enforcingTitle", "Enforcing policy…")}
</Text>
{progress != null ? (
<Progress
w="100%"
size="xs"
radius="xl"
value={progress}
striped
animated
/>
) : (
<Loader size="xs" />
)}
</Stack>
</Center>
</Overlay>
);
}
@@ -0,0 +1,75 @@
import { useState, useEffect, useRef } from "react";
import { Tooltip } from "@mantine/core";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import { useTranslation } from "react-i18next";
import {
POLICY_IN_FLIGHT_STATUSES,
type PolicyRunRecord,
} from "@app/components/policies/policyRunStore";
import { policyAccentVar } from "@app/components/policies/policyStatus";
import { PolicyEnforcingOverlay } from "@app/components/shared/PolicyEnforcingOverlay";
import "@app/components/shared/PolicyBadges.css";
interface Props {
runs: PolicyRunRecord[];
}
export function PolicyEnforcementOverlay({ runs }: Props) {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(false);
const prevRunId = useRef<string | undefined>(undefined);
const inFlight = runs.find(
(r) => POLICY_IN_FLIGHT_STATUSES.includes(r.status) || r.retrying,
);
// Reset dismissed when a new run starts (including retries, which replace the
// run record with a new runId even while inFlight stays truthy throughout).
useEffect(() => {
if (inFlight && inFlight.runId !== prevRunId.current) setDismissed(false);
prevRunId.current = inFlight?.runId;
}, [inFlight]);
if (!inFlight) return null;
const progress =
inFlight.currentStep != null && inFlight.stepCount
? Math.round((inFlight.currentStep / inFlight.stepCount) * 100)
: undefined;
if (dismissed) {
// Overlay dismissed — collapsed to a corner badge (same design as the
// per-file policy badges, larger) so the user can read the PDF.
return (
<Tooltip
label={t("policy.enforcingTitle", "Enforcing policy…")}
position="left"
withArrow
>
<span
className="policy-badge policy-badge--lg policy-badge--enforcing"
style={{
position: "absolute",
top: 16,
// Clear of the top-right corner itself: preview mode renders its
// close button there, and the badge must never swallow its clicks.
right: 56,
zIndex: 1100,
color: policyAccentVar(inFlight.categoryId),
}}
>
<AutorenewIcon style={{ fontSize: 16 }} />
</span>
</Tooltip>
);
}
return (
<PolicyEnforcingOverlay
enforcing
zIndex={1100}
progress={progress}
onDismiss={() => setDismissed(true)}
/>
);
}
@@ -0,0 +1,59 @@
import { Box } from "@mantine/core";
import CoreViewer from "@core/components/viewer/Viewer";
import type { ViewerProps } from "@core/components/viewer/Viewer";
import type { EmbedPdfViewerProps } from "@core/components/viewer/EmbedPdfViewer";
import { useViewer } from "@app/contexts/ViewerContext";
import {
POLICY_IN_FLIGHT_STATUSES,
usePolicyRuns,
type PolicyRunRecord,
} from "@app/components/policies/policyRunStore";
import { PolicyEnforcementOverlay } from "@app/components/viewer/PolicyEnforcementOverlay";
type SignatureOverlayPassThrough = Pick<
EmbedPdfViewerProps,
| "signaturePreviews"
| "signaturePreviewsReadOnly"
| "signaturePlacementMode"
| "signaturePlacementData"
| "signaturePlacementType"
| "onSignaturePreviewsChange"
| "signatureOverlayApiRef"
>;
const Viewer = (props: ViewerProps & SignatureOverlayPassThrough) => {
const { activeFileId } = useViewer();
const allRuns = usePolicyRuns();
const activeFileRuns = activeFileId
? allRuns.filter(
(r: PolicyRunRecord) =>
r.fileId === activeFileId &&
(POLICY_IN_FLIGHT_STATUSES.includes(r.status) || r.retrying === true),
)
: [];
return (
// isolation: "isolate" keeps the overlay's z-index self-contained so it
// sits above EmbedPdfViewer's internal toolbar/sidebars regardless of
// their own z-index values.
<Box
data-testid="viewer-root"
data-file-id={activeFileId ?? ""}
style={{
position: "relative",
height: "100%",
isolation: "isolate",
}}
>
<CoreViewer {...props} />
{/* key resets dismissed state when the active file changes */}
<PolicyEnforcementOverlay
key={activeFileId ?? ""}
runs={activeFileRuns}
/>
</Box>
);
};
export default Viewer;
@@ -112,3 +112,72 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files"
expect((map.get("part") ?? [])[0].recent).toBe(false);
});
});
describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", () => {
const enforcingOn = (
map: Map<string, { enforcing?: boolean }[]>,
id: string,
) => (map.get(id) ?? []).some((b) => b.enforcing);
it("marks the input file enforcing while the run is RUNNING", () => {
const map = buildPolicyBadgeMap(
[run({ status: "RUNNING", outputFileIds: [] })],
[{ id: "in" }],
labels,
NOW,
);
expect(enforcingOn(map, "in")).toBe(true);
});
it("keeps enforcing after COMPLETED until the outputs are imported", () => {
// Status reaches COMPLETED before the async import lands — the spinner
// must survive that gap, then clear once imported.
const before = buildPolicyBadgeMap(
[run({ status: "COMPLETED" })],
[{ id: "in" }],
labels,
NOW,
);
expect(enforcingOn(before, "in")).toBe(true);
const after = buildPolicyBadgeMap(
[run({ status: "COMPLETED", imported: true })],
[{ id: "in" }],
labels,
NOW,
);
expect(enforcingOn(after, "in")).toBe(false);
});
it("clears enforcing when the run settles as FAILED or CANCELLED", () => {
for (const status of ["FAILED", "CANCELLED"] as const) {
const map = buildPolicyBadgeMap(
[run({ status, outputFileIds: [] })],
[{ id: "in" }],
labels,
NOW,
);
expect(enforcingOn(map, "in")).toBe(false);
}
});
it("keeps enforcing on a settled run that is auto-retrying", () => {
const map = buildPolicyBadgeMap(
[run({ status: "FAILED", retrying: true, outputFileIds: [] })],
[{ id: "in" }],
labels,
NOW,
);
expect(enforcingOn(map, "in")).toBe(true);
});
it("skips runs with no input fileId (server-reconciled orphans)", () => {
const map = buildPolicyBadgeMap(
[run({ status: "RUNNING", fileId: "", outputFileIds: [] })],
[{ id: "in" }],
labels,
NOW,
);
expect(enforcingOn(map, "in")).toBe(false);
});
});
@@ -3,22 +3,14 @@ import { usePolicyRuns } from "@app/components/policies/policyRunStore";
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
import { useAllFiles } from "@app/contexts/FileContext";
import { loadPolicyCatalog } from "@app/services/policyCatalog";
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
import { policyAccentVar } from "@app/components/policies/policyStatus";
import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges";
/** 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. */
const ACCENT_VAR: Record<string, string> = {
blue: "var(--color-blue)",
purple: "var(--color-purple)",
green: "var(--color-green)",
amber: "var(--color-amber)",
red: "var(--color-red)",
};
* Measured from run start — must exceed the longest realistic policy wall-clock
* time so the glow still fires after a slow run completes and imports. Old or
* reloaded runs fall outside this window, suppressing the glow on page reload. */
const RECENT_MS = 5 * 60 * 1000;
/** Minimal provenance shape needed to resolve a file's inherited badges. */
type LineageStub = {
@@ -71,7 +63,7 @@ export function buildPolicyBadgeMap(
list.push({
id: run.categoryId,
name,
accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"],
accentColor: policyAccentVar(run.categoryId),
recent,
});
directByFile.set(fileId, list);
@@ -107,6 +99,35 @@ export function buildPolicyBadgeMap(
}
}
// In-flight pass: add (or upgrade) a badge on the input file for any run that
// is currently being processed, so the sidebar shows a spinning indicator
// while the policy is actively enforcing — not just after it completes.
// Keep the spinner until `imported` is true: the status reaches COMPLETED
// before the output files are imported into the workspace, so gating on
// status alone would drop the badge during that async gap.
for (const run of runs) {
if (!run.fileId) continue;
const settled =
run.imported || run.status === "FAILED" || run.status === "CANCELLED";
if (settled && !run.retrying) continue;
const name = labelById.get(run.categoryId);
if (!name) continue;
const list = result.get(run.fileId) ?? [];
const existing = list.find((p) => p.id === run.categoryId);
if (existing) {
existing.enforcing = true;
} else {
list.push({
id: run.categoryId,
name,
accentColor: policyAccentVar(run.categoryId),
recent: false,
enforcing: true,
});
result.set(run.fileId, list);
}
}
return result;
}