mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
co-authored by
James Brunton
parent
a8bda9240c
commit
18b0b19a67
@@ -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"];
|
||||
}
|
||||
|
||||
+11
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user