diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx
index 8edae6e72b..86c40ab504 100644
--- a/frontend/src/components/shared/TopControls.tsx
+++ b/frontend/src/components/shared/TopControls.tsx
@@ -5,7 +5,9 @@ import rainbowStyles from '../../styles/rainbow.module.css';
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
+import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
+import type { CustomWorkbenchViewInstance } from '../../contexts/ToolWorkflowContext';
const viewOptionStyle = {
@@ -19,7 +21,11 @@ const viewOptionStyle = {
// Build view options showing text always
-const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => {
+const createViewOptions = (
+ currentView: WorkbenchType,
+ switchingTo: WorkbenchType | null,
+ customViews: CustomWorkbenchViewInstance[]
+) => {
const viewerOption = {
label: (
@@ -72,22 +78,41 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
value: "fileEditor",
};
- // Build options array conditionally
- return [
+ const baseOptions = [
viewerOption,
pageEditorOption,
fileEditorOption,
];
+
+ const customOptions = customViews
+ .filter((view) => view.data != null)
+ .map((view) => ({
+ label: (
+
+ {switchingTo === view.workbenchId ? (
+
+ ) : (
+ view.icon ||
+ )}
+
{view.label}
+
+ ),
+ value: view.workbenchId,
+ }));
+
+ return [...baseOptions, ...customOptions];
};
interface TopControlsProps {
currentView: WorkbenchType;
setCurrentView: (view: WorkbenchType) => void;
+ customViews?: CustomWorkbenchViewInstance[];
}
const TopControls = ({
currentView,
setCurrentView,
+ customViews = [],
}: TopControlsProps) => {
const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState
(null);
@@ -118,7 +143,7 @@ const TopControls = ({
= ({ isFavorite, onToggle, class
return (
= ({ isFavorite, onToggle, class
e.stopPropagation();
onToggle();
}}
+ onMouseDown={(e: React.MouseEvent) => {
+ e.stopPropagation();
+ }}
+ onKeyDown={(e: React.KeyboardEvent) => {
+ e.stopPropagation();
+ }}
className={className}
aria-label={isFavorite ? t('toolPanel.fullscreen.unfavorite', 'Remove from favourites') : t('toolPanel.fullscreen.favorite', 'Add to favourites')}
>
diff --git a/frontend/src/components/tools/validateSignature/ValidateSignatureReportView.tsx b/frontend/src/components/tools/validateSignature/ValidateSignatureReportView.tsx
new file mode 100644
index 0000000000..15a9618256
--- /dev/null
+++ b/frontend/src/components/tools/validateSignature/ValidateSignatureReportView.tsx
@@ -0,0 +1,145 @@
+import React, { useMemo } from 'react';
+import { Badge, Group, Stack, Text, Divider } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import type { SignatureValidationReportData } from '../../../types/validateSignature';
+import './reportView/styles.css';
+import ThumbnailPreview from './reportView/ThumbnailPreview';
+import FileSummaryHeader from './reportView/FileSummaryHeader';
+import SignatureSection from './reportView/SignatureSection';
+
+interface ValidateSignatureReportViewProps {
+ data: SignatureValidationReportData;
+}
+
+const NoSignatureSection = ({ message, label }: { message: string; label: string }) => (
+
+
+ {label}
+
+
+ {message}
+
+
+);
+
+const ValidateSignatureReportView: React.FC = ({ data }) => {
+ const { t } = useTranslation();
+ const noSignaturesLabel = t('validateSignature.noSignaturesShort', 'No signatures');
+
+ const pages = useMemo(() => {
+ const result: Array<{
+ entry: SignatureValidationReportData['entries'][number];
+ signatureIndex: number | null;
+ includeSummary: boolean;
+ }> = [];
+
+ for (const entry of data.entries) {
+ if (entry.signatures.length === 0 || entry.error) {
+ result.push({ entry, signatureIndex: null, includeSummary: true });
+ continue;
+ }
+
+ // First page includes summary and the first signature
+ result.push({ entry, signatureIndex: 0, includeSummary: true });
+
+ // Subsequent signatures each get their own page
+ for (let i = 1; i < entry.signatures.length; i += 1) {
+ result.push({ entry, signatureIndex: i, includeSummary: false });
+ }
+ }
+
+ return result;
+ }, [data.entries]);
+
+ return (
+
+
+
+
+ {t('validateSignature.report.title', 'Signature Validation Report')}
+
+
+ {t('validateSignature.report.generatedAt', 'Generated')}{' '}
+ {new Date(data.generatedAt).toLocaleString()}
+
+
+
+ {pages.map((pageDef, index) => (
+
+
+ {pageDef.includeSummary && (
+ <>
+
+
+
+
+
+
+ {pageDef.entry.fileName}
+
+
+ {t('validateSignature.report.entryLabel', 'Signature Summary')}
+
+
+
+ {t('validateSignature.report.page', 'Page')} {index + 1}
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ {pageDef.entry.error ? (
+
+ ) : pageDef.entry.signatures.length === 0 ? (
+
+ ) : (
+
+ {pageDef.signatureIndex === null ? null : (
+
+ )}
+
+ )}
+
+
+
+
+ {t('validateSignature.report.footer', 'Validated via Stirling PDF')}
+
+
+ {t('validateSignature.report.page', 'Page')} {index + 1} / {pages.length}
+
+
+
+ ))}
+
+
+ );
+};
+
+export default ValidateSignatureReportView;
diff --git a/frontend/src/components/tools/validateSignature/ValidateSignatureResults.tsx b/frontend/src/components/tools/validateSignature/ValidateSignatureResults.tsx
new file mode 100644
index 0000000000..33726c10cf
--- /dev/null
+++ b/frontend/src/components/tools/validateSignature/ValidateSignatureResults.tsx
@@ -0,0 +1,230 @@
+import { useCallback, useMemo, useState } from 'react';
+import { Alert, Badge, Button, Divider, Group, Loader, Stack, Text, SegmentedControl } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import type { SignatureValidationReportEntry } from '../../../types/validateSignature';
+import type { ValidateSignatureOperationHook } from '../../../hooks/tools/validateSignature/useValidateSignatureOperation';
+import './reportView/styles.css';
+
+interface ValidateSignatureResultsProps {
+ operation: ValidateSignatureOperationHook;
+ results: SignatureValidationReportEntry[];
+ isLoading: boolean;
+ errorMessage: string | null;
+ reportAvailable?: boolean;
+}
+
+const useFileSummary = (results: SignatureValidationReportEntry[]) => {
+ return useMemo(() => {
+ if (results.length === 0) {
+ return { fileCount: 0, signatureCount: 0, fullyValidCount: 0 };
+ }
+
+ let signatureCount = 0;
+ let fullyValidCount = 0;
+
+ results.forEach((result) => {
+ signatureCount += result.signatures.length;
+ result.signatures.forEach((signature) => {
+ const isFullyValid =
+ signature.valid &&
+ signature.chainValid &&
+ signature.trustValid &&
+ signature.notExpired &&
+ signature.notRevoked;
+ if (isFullyValid) {
+ fullyValidCount += 1;
+ }
+ });
+ });
+
+ return {
+ fileCount: results.length,
+ signatureCount,
+ fullyValidCount,
+ };
+ }, [results]);
+};
+
+const findFileByExtension = (files: File[], extension: string) => {
+ return files.find((file) => file.name.toLowerCase().endsWith(extension));
+};
+
+const ValidateSignatureResults = ({
+ operation,
+ results,
+ isLoading,
+ errorMessage,
+}: ValidateSignatureResultsProps) => {
+ const { t } = useTranslation();
+ const summary = useFileSummary(results);
+
+ const pdfFile = useMemo(() => findFileByExtension(operation.files, '.pdf'), [operation.files]);
+ const csvFile = useMemo(() => findFileByExtension(operation.files, '.csv'), [operation.files]);
+ const jsonFile = useMemo(() => findFileByExtension(operation.files, '.json'), [operation.files]);
+
+ const [selectedType, setSelectedType] = useState<'pdf' | 'csv' | 'json'>('pdf');
+
+ const selectedFile = useMemo(() => {
+ if (selectedType === 'pdf') return pdfFile ?? null;
+ if (selectedType === 'csv') return csvFile ?? null;
+ return jsonFile ?? null;
+ }, [selectedType, pdfFile, csvFile, jsonFile]);
+
+ const selectedDownloadLabel = useMemo(() => {
+ if (selectedType === 'pdf') return t('validateSignature.downloadPdf', 'Download PDF Report');
+ if (selectedType === 'csv') return t('validateSignature.downloadCsv', 'Download CSV');
+ return t('validateSignature.downloadJson', 'Download JSON');
+ }, [selectedType, t]);
+
+ const downloadTypeOptions = [
+ { label: t('validateSignature.downloadType.pdf', 'PDF'), value: 'pdf' },
+ { label: t('validateSignature.downloadType.csv', 'CSV'), value: 'csv' },
+ { label: t('validateSignature.downloadType.json', 'JSON'), value: 'json' },
+ ];
+
+ const handleDownload = useCallback((file: File) => {
+ const blobUrl = URL.createObjectURL(file);
+ const link = document.createElement('a');
+ link.href = blobUrl;
+ link.download = file.name;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(blobUrl);
+ }, []);
+
+ // Show the big loader only while we're still waiting for the first results.
+ if (isLoading && results.length === 0) {
+ return (
+
+
+ {t('validateSignature.processing', 'Validating signatures...')}
+
+ );
+ }
+
+ if (!isLoading && results.length === 0) {
+ return (
+
+ {t('validateSignature.noResults', 'Run the validation to generate a report.')}
+
+ );
+ }
+
+ return (
+
+ {/* While results are visible but background work continues (e.g. generating files),
+ show a light inline indicator without blocking downloads UI. */}
+ {isLoading && results.length > 0 && (
+
+
+ {t('validateSignature.finalizing', 'Preparing downloads...')}
+
+ )}
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+
+
+ {t('validateSignature.report.filesEvaluated', '{{count}} files evaluated', {
+ count: summary.fileCount,
+ })}
+
+
+ {t('validateSignature.report.signaturesFound', '{{count}} signatures detected', {
+ count: summary.signatureCount,
+ })}
+
+ {summary.signatureCount > 0 && (
+
+ {t('validateSignature.report.signaturesValid', '{{count}} fully valid', {
+ count: summary.fullyValidCount,
+ })}
+
+ )}
+
+
+
+ {results.map((result) => {
+ const hasError = Boolean(result.error);
+ const hasSignatures = result.signatures.length > 0;
+ const allValid = hasSignatures && result.signatures.every((signature) => signature.valid);
+ const badgeLabel = hasError
+ ? t('validateSignature.status.invalid', 'Invalid')
+ : hasSignatures
+ ? allValid
+ ? t('validateSignature.status.valid', 'Valid')
+ : t('validateSignature.status.needsAttention', 'Needs Attention')
+ : t('validateSignature.noSignaturesShort', 'No signatures');
+ const badgeClass = hasError
+ ? 'status-badge status-badge--invalid'
+ : hasSignatures
+ ? allValid
+ ? 'status-badge status-badge--valid'
+ : 'status-badge status-badge--warning'
+ : 'status-badge status-badge--neutral';
+
+ return (
+
+
+
+ {result.fileName}
+
+
+ {badgeLabel}
+
+
+
+ {t('validateSignature.report.signatureCountLabel', '{{count}} signatures', {
+ count: result.signatures.length,
+ })}
+
+ {result.error && (
+
+ {result.error}
+
+ )}
+ {!result.error && result.signatures.length === 0 && (
+
+ {t('validateSignature.noSignatures', 'No digital signatures found in this document')}
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+ {t('validateSignature.report.downloads', 'Downloads')}
+
+ setSelectedType(v as 'pdf' | 'csv' | 'json')}
+ data={downloadTypeOptions}
+ />
+
+
+
+ {selectedType === 'pdf' && !pdfFile && (
+
+ {t('validateSignature.report.noPdf', 'PDF report will be available after a successful validation.')}
+
+ )}
+
+
+ );
+};
+
+export default ValidateSignatureResults;
diff --git a/frontend/src/components/tools/validateSignature/ValidateSignatureSettings.tsx b/frontend/src/components/tools/validateSignature/ValidateSignatureSettings.tsx
new file mode 100644
index 0000000000..bd7a48de7b
--- /dev/null
+++ b/frontend/src/components/tools/validateSignature/ValidateSignatureSettings.tsx
@@ -0,0 +1,67 @@
+import { Card, Group, Stack, Text, Button } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import FileUploadButton from '../../shared/FileUploadButton';
+import { ValidateSignatureParameters } from '../../../hooks/tools/validateSignature/useValidateSignatureParameters';
+
+interface ValidateSignatureSettingsProps {
+ parameters: ValidateSignatureParameters;
+ onParameterChange: (parameter: K, value: ValidateSignatureParameters[K]) => void;
+ disabled?: boolean;
+}
+
+const ValidateSignatureSettings = ({
+ parameters,
+ onParameterChange,
+ disabled = false,
+}: ValidateSignatureSettingsProps) => {
+ const { t } = useTranslation();
+ const certFile = parameters.certFile;
+
+ const handleCertFileChange = (file: File | null) => {
+ onParameterChange('certFile', file);
+ };
+
+ return (
+
+
+
+ {t('validateSignature.selectCustomCert', 'Custom Certificate File X.509 (Optional)')}
+
+ {t(
+ 'validateSignature.settings.certHint',
+ 'Upload a trusted X.509 certificate to validate against a custom trust source.'
+ )}
+
+
+
+
+
+ {certFile && (
+
+ )}
+
+
+ {certFile && (
+
+ {t('size', 'Size')}: {Math.round(certFile.size / 1024)} KB
+
+ )}
+
+
+ );
+};
+
+export default ValidateSignatureSettings;
diff --git a/frontend/src/components/tools/validateSignature/reportView/FieldBlock.tsx b/frontend/src/components/tools/validateSignature/reportView/FieldBlock.tsx
new file mode 100644
index 0000000000..23d83ca28e
--- /dev/null
+++ b/frontend/src/components/tools/validateSignature/reportView/FieldBlock.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { Text } from '@mantine/core';
+import './styles.css';
+
+const FieldBlock = (label: string, value: React.ReactNode) => {
+ const displayValue =
+ value === null || value === undefined || value === '' ? '-' : value;
+
+ return (
+
+
+ {label}
+
+
+
+ {displayValue}
+
+
+
+ );
+};
+
+export default FieldBlock;
diff --git a/frontend/src/components/tools/validateSignature/reportView/FileSummaryHeader.tsx b/frontend/src/components/tools/validateSignature/reportView/FileSummaryHeader.tsx
new file mode 100644
index 0000000000..8d8f58e157
--- /dev/null
+++ b/frontend/src/components/tools/validateSignature/reportView/FileSummaryHeader.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import './styles.css';
+import FieldBlock from './FieldBlock';
+
+const formatDate = (value?: string | null) => {
+ if (!value) return '--';
+ const parsed = Date.parse(value);
+ if (!Number.isNaN(parsed)) {
+ return new Date(parsed).toLocaleString();
+ }
+ return value;
+};
+
+const formatFileSize = (bytes?: number | null) => {
+ if (bytes === undefined || bytes === null) return '--';
+ if (bytes === 0) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
+ const size = bytes / Math.pow(1024, exponent);
+ return `${size.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;
+};
+
+const FileSummaryHeader = ({
+ fileSize,
+ createdAt,
+ totalSignatures,
+ lastSignatureDate,
+}: {
+ fileSize?: number | null;
+ createdAt?: string | null;
+ totalSignatures: number;
+ lastSignatureDate?: string | null;
+}) => {
+ const { t } = useTranslation();
+ const infoBlocks = [
+ FieldBlock(t('files.size', 'File Size'), formatFileSize(fileSize ?? null)),
+ FieldBlock(t('files.created', 'Created'), createdAt || '-'),
+ FieldBlock(t('validateSignature.signatureDate', 'Signature Date'), formatDate(lastSignatureDate)),
+ FieldBlock(t('validateSignature.totalSignatures', 'Total Signatures'), totalSignatures.toString()),
+ ];
+
+ return {infoBlocks}
;
+};
+
+export default FileSummaryHeader;
+
diff --git a/frontend/src/components/tools/validateSignature/reportView/SignatureSection.tsx b/frontend/src/components/tools/validateSignature/reportView/SignatureSection.tsx
new file mode 100644
index 0000000000..e534205fa5
--- /dev/null
+++ b/frontend/src/components/tools/validateSignature/reportView/SignatureSection.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import { Divider, Group, Stack, Text } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import type { SignatureValidationSignature } from '../../../../types/validateSignature';
+import SignatureStatusBadge from './SignatureStatusBadge';
+import FieldBlock from './FieldBlock';
+import './styles.css';
+
+const formatDate = (value?: string | null) => {
+ if (!value) return '-';
+ const parsed = Date.parse(value);
+ if (!Number.isNaN(parsed)) {
+ return new Date(parsed).toLocaleString();
+ }
+ return value;
+};
+
+const SignatureSection = ({
+ signature,
+ index,
+}: {
+ signature: SignatureValidationSignature;
+ index: number;
+}) => {
+ const { t } = useTranslation();
+ const signatureFields = [
+ FieldBlock(t('validateSignature.signer', 'Signer'), signature.signerName || '-'),
+ FieldBlock(t('validateSignature.date', 'Date'), formatDate(signature.signatureDate)),
+ FieldBlock(t('validateSignature.reason', 'Reason'), signature.reason || '-'),
+ FieldBlock(t('validateSignature.location', 'Location'), signature.location || '-'),
+ ];
+
+ const certificateFields = [
+ FieldBlock(t('validateSignature.cert.issuer', 'Issuer'), signature.issuerDN || '-'),
+ FieldBlock(t('validateSignature.cert.subject', 'Subject'), signature.subjectDN || '-'),
+ FieldBlock(t('validateSignature.cert.serialNumber', 'Serial Number'), signature.serialNumber || '-'),
+ FieldBlock(t('validateSignature.cert.validFrom', 'Valid From'), formatDate(signature.validFrom)),
+ FieldBlock(t('validateSignature.cert.validUntil', 'Valid Until'), formatDate(signature.validUntil)),
+ FieldBlock(t('validateSignature.cert.algorithm', 'Algorithm'), signature.signatureAlgorithm || '-'),
+ FieldBlock(
+ t('validateSignature.cert.keySize', 'Key Size'),
+ signature.keySize != null ? `${signature.keySize} ${t('validateSignature.cert.bits', 'bits')}` : '--'
+ ),
+ FieldBlock(t('validateSignature.cert.version', 'Version'), signature.version || '-'),
+ FieldBlock(
+ t('validateSignature.cert.keyUsage', 'Key Usage'),
+ signature.keyUsages.length > 0 ? signature.keyUsages.join(', ') : '--'
+ ),
+ FieldBlock(t('validateSignature.cert.selfSigned', 'Self-Signed'), signature.selfSigned ? t('yes', 'Yes') : t('no', 'No')),
+ ];
+
+ return (
+
+
+
+
+ {t('validateSignature.signature._value', 'Signature')} {index + 1}
+
+
+
+ {signature.errorMessage && (
+ {signature.errorMessage}
+ )}
+
+
+ {signatureFields}
+
+
+
+
+ {t('validateSignature.cert.details', 'Certificate Details')}
+
+ {certificateFields}
+
+ );
+};
+
+export default SignatureSection;
diff --git a/frontend/src/components/tools/validateSignature/reportView/SignatureStatusBadge.tsx b/frontend/src/components/tools/validateSignature/reportView/SignatureStatusBadge.tsx
new file mode 100644
index 0000000000..462710cf5b
--- /dev/null
+++ b/frontend/src/components/tools/validateSignature/reportView/SignatureStatusBadge.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import { Badge, Popover, Text } from '@mantine/core';
+import './styles.css';
+import { useTranslation } from 'react-i18next';
+import { computeSignatureStatus } from '../../../../hooks/tools/validateSignature/utils/signatureStatus';
+import type { SignatureValidationSignature } from '../../../../types/validateSignature';
+
+const SignatureStatusBadge = ({ signature }: { signature: SignatureValidationSignature }) => {
+ const { t } = useTranslation();
+ const status = computeSignatureStatus(signature, t);
+ const classMap = {
+ valid: 'status-badge status-badge--valid',
+ warning: 'status-badge status-badge--warning',
+ invalid: 'status-badge status-badge--invalid',
+ neutral: 'status-badge status-badge--neutral',
+ } as const;
+
+ return (
+
+
+
+ {status.label}
+
+
+ {status.details.length > 0 && (
+
+ {t('details', 'Details')}
+ {status.details.map((d, i) => (
+
+ - {d}
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default SignatureStatusBadge;
+
diff --git a/frontend/src/components/tools/validateSignature/reportView/ThumbnailPreview.tsx b/frontend/src/components/tools/validateSignature/reportView/ThumbnailPreview.tsx
new file mode 100644
index 0000000000..3b8312b3d6
--- /dev/null
+++ b/frontend/src/components/tools/validateSignature/reportView/ThumbnailPreview.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
+import './styles.css';
+
+const ThumbnailPreview = ({
+ thumbnailUrl,
+ fileName,
+}: {
+ thumbnailUrl?: string | null;
+ fileName: string;
+}) => {
+ if (thumbnailUrl) {
+ return (
+
+

+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default ThumbnailPreview;
diff --git a/frontend/src/components/tools/validateSignature/reportView/styles.css b/frontend/src/components/tools/validateSignature/reportView/styles.css
new file mode 100644
index 0000000000..a31e6a47ca
--- /dev/null
+++ b/frontend/src/components/tools/validateSignature/reportView/styles.css
@@ -0,0 +1,111 @@
+.grid-container {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ gap: 12px;
+}
+
+.field-container {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.field-value {
+ border: 1px solid rgb(var(--pdf-light-box-border));
+ border-radius: 8px;
+ padding: 0.65rem 0.75rem;
+ background-color: rgb(var(--pdf-light-box-bg));
+ min-height: 44px;
+}
+
+/* Status badge colors sourced from light palette tokens */
+.status-badge {
+ border-radius: 9999px !important;
+ font-weight: 700 !important;
+ letter-spacing: 0.02em !important;
+}
+.status-badge--valid {
+ background-color: rgb(var(--pdf-light-status-valid-bg)) !important;
+ color: rgb(var(--pdf-light-status-valid-text)) !important;
+}
+.status-badge--warning {
+ background-color: rgb(var(--pdf-light-status-warning-bg)) !important;
+ color: rgb(var(--pdf-light-status-warning-text)) !important;
+}
+.status-badge--invalid {
+ background-color: rgb(var(--pdf-light-status-invalid-bg)) !important;
+ color: rgb(var(--pdf-light-status-invalid-text)) !important;
+}
+.status-badge--neutral {
+ background-color: rgb(var(--pdf-light-status-neutral-bg)) !important;
+ color: rgb(var(--pdf-light-status-neutral-text)) !important;
+}
+
+.simulated-page {
+ width: min(820px, 100%);
+ min-height: 1040px;
+ background-color: rgb(var(--pdf-light-simulated-page-bg)) !important;
+ box-shadow: 0 12px 32px rgba(var(--pdf-light-simulated-page-text), 0.12) !important;
+ border-radius: 12px !important;
+ padding: 48px 56px !important;
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ color: rgb(var(--pdf-light-simulated-page-text)) !important;
+}
+
+/* Container for the interactive report view */
+.report-container {
+ width: 100%;
+ height: 100%;
+ /* Match Active Files area background (light mode) */
+ background: rgb(var(--pdf-light-report-container-bg)) !important;
+ padding: 32px 24px 48px;
+ overflow-y: auto;
+}
+
+/* Keep background consistent in dark mode */
+:root[data-mantine-color-scheme='dark'] .report-container {
+ /* Match Active Files area background (dark mode) */
+ background: var(--mantine-color-dark-6, var(--mantine-color-body)) !important;
+}
+
+/* Keep field blocks stable colors across themes */
+.field-value {
+ border: 1px solid rgb(var(--pdf-light-box-border)) !important;
+ background-color: rgb(var(--pdf-light-box-bg)) !important;
+}
+
+.field-container {
+ color: rgb(var(--pdf-light-simulated-page-text)) !important;
+}
+
+/* Thumbnail preview styles */
+.thumbnail-container {
+ width: 140px;
+ height: 180px;
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 6px 18px rgba(var(--pdf-light-simulated-page-text), 0.15);
+ flex-shrink: 0;
+ background-color: rgb(var(--pdf-light-simulated-page-text));
+}
+
+.thumbnail-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.thumbnail-placeholder {
+ width: 140px;
+ height: 180px;
+ border-radius: 12px;
+ border: 1px dashed rgba(var(--pdf-light-neutral), 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: rgb(var(--pdf-light-text-muted));
+ background: linear-gradient(145deg, var(--mantine-color-gray-1) 0%, var(--mantine-color-gray-0) 100%);
+}
diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx
index d010dfdfda..710053bce4 100644
--- a/frontend/src/contexts/ToolWorkflowContext.tsx
+++ b/frontend/src/contexts/ToolWorkflowContext.tsx
@@ -9,7 +9,7 @@ import { PageEditorFunctions } from '../types/pageEditor';
import { ToolRegistryEntry, ToolRegistry } from '../data/toolsTaxonomy';
import { useNavigationActions, useNavigationState } from './NavigationContext';
import { ToolId, isValidToolId } from '../types/toolId';
-import { getDefaultWorkbench } from '../types/workbench';
+import { WorkbenchType, getDefaultWorkbench, isBaseWorkbench } from '../types/workbench';
import { filterToolRegistryByQuery } from '../utils/toolSearch';
import { useToolHistory } from '../hooks/tools/useUserToolActivity';
import {
@@ -25,6 +25,18 @@ import { usePreferences } from './PreferencesContext';
// Types and reducer/state moved to './toolWorkflow/state'
// Context value interface
+export interface CustomWorkbenchViewRegistration {
+ id: string;
+ workbenchId: WorkbenchType;
+ label: string;
+ icon?: React.ReactNode;
+ component: React.ComponentType<{ data: any }>;
+}
+
+export interface CustomWorkbenchViewInstance extends CustomWorkbenchViewRegistration {
+ data: any;
+}
+
interface ToolWorkflowContextValue extends ToolWorkflowState {
// Tool management (from hook)
selectedToolKey: ToolId | null;
@@ -63,9 +75,21 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
favoriteTools: ToolId[];
toggleFavorite: (toolId: ToolId) => void;
isFavorite: (toolId: ToolId) => boolean;
+
+ customWorkbenchViews: CustomWorkbenchViewInstance[];
+ registerCustomWorkbenchView: (view: CustomWorkbenchViewRegistration) => void;
+ unregisterCustomWorkbenchView: (id: string) => void;
+ setCustomWorkbenchViewData: (id: string, data: any) => void;
+ clearCustomWorkbenchViewData: (id: string) => void;
}
-const ToolWorkflowContext = createContext(undefined);
+// Ensure a single context instance across HMR to avoid provider/consumer mismatches
+const __GLOBAL_CONTEXT_KEY__ = '__ToolWorkflowContext__';
+const existingContext = (globalThis as any)[__GLOBAL_CONTEXT_KEY__] as React.Context | undefined;
+const ToolWorkflowContext = existingContext ?? createContext(undefined);
+if (!existingContext) {
+ (globalThis as any)[__GLOBAL_CONTEXT_KEY__] = ToolWorkflowContext;
+}
// Provider component
interface ToolWorkflowProviderProps {
@@ -79,6 +103,9 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Store reset functions for tools
const [toolResetFunctions, setToolResetFunctions] = React.useState void>>({});
+ const [customViewRegistry, setCustomViewRegistry] = React.useState>({});
+ const [customViewData, setCustomViewData] = React.useState>({});
+
// Navigation actions and state are available since we're inside NavigationProvider
const { actions } = useNavigationActions();
const navigationState = useNavigationState();
@@ -136,6 +163,71 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
dispatch({ type: 'SET_SEARCH_QUERY', payload: query });
}, []);
+ const registerCustomWorkbenchView = useCallback((view: CustomWorkbenchViewRegistration) => {
+ setCustomViewRegistry(prev => ({ ...prev, [view.id]: view }));
+ }, []);
+
+ const unregisterCustomWorkbenchView = useCallback((id: string) => {
+ let removedView: CustomWorkbenchViewRegistration | undefined;
+
+ setCustomViewRegistry(prev => {
+ const existing = prev[id];
+ if (!existing) {
+ return prev;
+ }
+ removedView = existing;
+ const updated = { ...prev };
+ delete updated[id];
+ return updated;
+ });
+
+ setCustomViewData(prev => {
+ if (!(id in prev)) {
+ return prev;
+ }
+ const updated = { ...prev };
+ delete updated[id];
+ return updated;
+ });
+
+ if (removedView && navigationState.workbench === removedView.workbenchId) {
+ actions.setWorkbench(getDefaultWorkbench());
+ }
+ }, [actions, navigationState.workbench]);
+
+ const setCustomWorkbenchViewData = useCallback((id: string, data: any) => {
+ setCustomViewData(prev => ({ ...prev, [id]: data }));
+ }, []);
+
+ const clearCustomWorkbenchViewData = useCallback((id: string) => {
+ setCustomViewData(prev => {
+ if (!(id in prev)) {
+ return prev;
+ }
+ const updated = { ...prev };
+ delete updated[id];
+ return updated;
+ });
+ }, []);
+
+ const customWorkbenchViews = useMemo(() => {
+ return Object.values(customViewRegistry).map(view => ({
+ ...view,
+ data: Object.prototype.hasOwnProperty.call(customViewData, view.id) ? customViewData[view.id] : null,
+ }));
+ }, [customViewRegistry, customViewData]);
+
+ useEffect(() => {
+ if (isBaseWorkbench(navigationState.workbench)) {
+ return;
+ }
+
+ const currentCustomView = customWorkbenchViews.find(view => view.workbenchId === navigationState.workbench);
+ if (!currentCustomView || currentCustomView.data == null) {
+ actions.setWorkbench(getDefaultWorkbench());
+ }
+ }, [actions, customWorkbenchViews, navigationState.workbench]);
+
useEffect(() => {
if (typeof window === 'undefined') {
return;
@@ -176,11 +268,15 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: ToolId) => {
+ // If we're currently on a custom workbench (e.g., Validate Signature report),
+ // selecting any tool should take the user back to the default file manager view.
+ const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
+
// Handle read tool selection - should behave exactly like QuickAccessBar read button
if (toolId === 'read') {
setReaderMode(true);
actions.setSelectedTool('read');
- actions.setWorkbench('viewer');
+ actions.setWorkbench(wasInCustomWorkbench ? getDefaultWorkbench() : 'viewer');
setSearchQuery('');
return;
}
@@ -190,7 +286,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setReaderMode(false);
setLeftPanelView('hidden');
actions.setSelectedTool('multiTool');
- actions.setWorkbench('pageEditor');
+ actions.setWorkbench(wasInCustomWorkbench ? getDefaultWorkbench() : 'pageEditor');
setSearchQuery('');
return;
}
@@ -201,7 +297,9 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Get the tool from registry to determine workbench
const tool = getSelectedTool(toolId);
- if (tool && tool.workbench) {
+ if (wasInCustomWorkbench) {
+ actions.setWorkbench(getDefaultWorkbench());
+ } else if (tool && tool.workbench) {
actions.setWorkbench(tool.workbench);
} else {
actions.setWorkbench(getDefaultWorkbench());
@@ -211,7 +309,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSearchQuery('');
setLeftPanelView('toolContent');
setReaderMode(false); // Disable read mode when selecting tools
- }, [actions, getSelectedTool, setLeftPanelView, setReaderMode, setSearchQuery]);
+ }, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]);
const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker');
@@ -272,6 +370,13 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
favoriteTools,
toggleFavorite,
isFavorite,
+
+ // Custom workbench views
+ customWorkbenchViews,
+ registerCustomWorkbenchView,
+ unregisterCustomWorkbenchView,
+ setCustomWorkbenchViewData,
+ clearCustomWorkbenchViewData,
}), [
state,
navigationState.selectedTool,
@@ -296,6 +401,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
favoriteTools,
toggleFavorite,
isFavorite,
+ customWorkbenchViews,
+ registerCustomWorkbenchView,
+ unregisterCustomWorkbenchView,
+ setCustomWorkbenchViewData,
+ clearCustomWorkbenchViewData,
]);
return (
diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx
index b695b94771..8bc0b462c6 100644
--- a/frontend/src/data/useTranslatedToolRegistry.tsx
+++ b/frontend/src/data/useTranslatedToolRegistry.tsx
@@ -108,6 +108,7 @@ import RemovePagesSettings from "../components/tools/removePages/RemovePagesSett
import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksSettings";
import AddPageNumbersAutomationSettings from "../components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
import OverlayPdfsSettings from "../components/tools/overlayPdfs/OverlayPdfsSettings";
+import ValidateSignature from "../tools/ValidateSignature";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@@ -281,10 +282,12 @@ export function useFlatToolRegistry(): ToolRegistry {
validateSignature: {
icon: ,
name: t("home.validateSignature.title", "Validate PDF Signature"),
- component: null,
+ component: ValidateSignature,
description: t("home.validateSignature.desc", "Verify digital signatures and certificates in PDF documents"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION,
+ maxFiles: -1,
+ endpoints: ["validate-signature"],
synonyms: getSynonyms(t, "validateSignature"),
automationSettings: null
},
diff --git a/frontend/src/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts b/frontend/src/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts
index d82c6c2bb0..cdf16846e7 100644
--- a/frontend/src/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts
+++ b/frontend/src/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts
@@ -2,12 +2,9 @@ import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RemoveAnnotationsParameters, defaultParameters } from './useRemoveAnnotationsParameters';
-
+import { PDFDocument, PDFName, PDFRef, PDFDict } from 'pdf-lib';
// Client-side PDF processing using PDF-lib
const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise => {
- // Dynamic import of PDF-lib for client-side processing
- const { PDFDocument, PDFName, PDFRef, PDFDict } = await import('pdf-lib');
-
const processedFiles: File[] = [];
for (const file of files) {
diff --git a/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/CenteredMessageSection.ts b/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/CenteredMessageSection.ts
new file mode 100644
index 0000000000..d17cb26848
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/CenteredMessageSection.ts
@@ -0,0 +1,68 @@
+import { PDFFont, PDFPage, rgb } from 'pdf-lib';
+import { wrapText } from '../utils/pdfText';
+import { colorPalette } from '../utils/pdfPalette';
+
+interface DrawCenteredMessageOptions {
+ page: PDFPage;
+ font: PDFFont;
+ fontBold: PDFFont;
+ text: string;
+ description: string;
+ marginX: number;
+ contentWidth: number;
+ cursorY: number;
+ badgeColor: ReturnType;
+}
+
+export const drawCenteredMessage = ({
+ page,
+ font,
+ fontBold,
+ text,
+ description,
+ marginX,
+ contentWidth,
+ cursorY,
+ badgeColor,
+}: DrawCenteredMessageOptions): number => {
+ const badgeFontSize = 10;
+ const badgePaddingX = 14;
+ const badgePaddingY = 6;
+ const badgeWidth = font.widthOfTextAtSize(text, badgeFontSize) + badgePaddingX * 2;
+ const badgeHeight = badgeFontSize + badgePaddingY * 2;
+ const badgeX = marginX + (contentWidth - badgeWidth) / 2;
+
+ page.drawRectangle({
+ x: badgeX,
+ y: cursorY - badgeHeight,
+ width: badgeWidth,
+ height: badgeHeight,
+ color: badgeColor,
+ });
+
+ page.drawText(text, {
+ x: badgeX + badgePaddingX,
+ y: cursorY - badgePaddingY - badgeFontSize + 2,
+ size: badgeFontSize,
+ font: fontBold,
+ color: rgb(1, 1, 1),
+ });
+
+ let nextCursor = cursorY - 32;
+ const lines = wrapText(description, font, 11, contentWidth * 0.75);
+
+ lines.forEach((line) => {
+ const lineWidth = font.widthOfTextAtSize(line, 11);
+ const lineX = marginX + (contentWidth - lineWidth) / 2;
+ page.drawText(line, {
+ x: lineX,
+ y: nextCursor,
+ size: 11,
+ font,
+ color: colorPalette.textPrimary,
+ });
+ nextCursor -= 18;
+ });
+
+ return nextCursor - 8;
+};
diff --git a/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection.ts b/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection.ts
new file mode 100644
index 0000000000..27402e5c37
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection.ts
@@ -0,0 +1,70 @@
+import { PDFFont, PDFPage } from 'pdf-lib';
+import { wrapText } from '../utils/pdfText';
+import { colorPalette } from '../utils/pdfPalette';
+
+interface FieldBoxOptions {
+ page: PDFPage;
+ font: PDFFont;
+ fontBold: PDFFont;
+ x: number;
+ top: number;
+ width: number;
+ label: string;
+ value: string;
+}
+
+export const drawFieldBox = ({
+ page,
+ font,
+ fontBold,
+ x,
+ top,
+ width,
+ label,
+ value,
+}: FieldBoxOptions): number => {
+ const labelFontSize = 8;
+ const valueFontSize = 11;
+ const valueLineHeight = valueFontSize * 1.25;
+ const boxPadding = 6;
+
+ page.drawText(label.toUpperCase(), {
+ x,
+ y: top - labelFontSize,
+ size: labelFontSize,
+ font: fontBold,
+ color: colorPalette.textMuted,
+ });
+
+ const boxTop = top - labelFontSize - 6;
+ const rawValue = value && value.trim().length > 0 ? value : '--';
+ const lines = wrapText(rawValue, font, valueFontSize, width - boxPadding * 2);
+ const boxHeight = Math.max(valueLineHeight, lines.length * valueLineHeight) + boxPadding * 2;
+
+ page.drawRectangle({
+ x,
+ y: boxTop - boxHeight,
+ width,
+ height: boxHeight,
+ color: colorPalette.boxBackground,
+ borderColor: colorPalette.boxBorder,
+ });
+
+ let textY = boxTop - boxPadding - valueFontSize;
+ lines.forEach((line) => {
+ const lineWidth = font.widthOfTextAtSize(line, valueFontSize);
+ const available = width - boxPadding * 2;
+ const centeredX = x + boxPadding + Math.max(0, (available - lineWidth) / 2);
+
+ page.drawText(line, {
+ x: centeredX,
+ y: textY,
+ size: valueFontSize,
+ font,
+ color: colorPalette.textPrimary,
+ });
+ textY -= valueLineHeight;
+ });
+
+ return labelFontSize + 6 + boxHeight + 6;
+};
diff --git a/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/SignatureSection.ts b/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/SignatureSection.ts
new file mode 100644
index 0000000000..0d8946e5f9
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/SignatureSection.ts
@@ -0,0 +1,173 @@
+import type { TFunction } from 'i18next';
+import { PDFFont, PDFPage } from 'pdf-lib';
+import { SignatureValidationSignature } from '../../../../types/validateSignature';
+import { drawFieldBox } from './FieldBoxSection';
+import { drawStatusBadge } from './StatusBadgeSection';
+import { computeSignatureStatus, statusKindToPdfColor } from '../utils/signatureStatus';
+import { formatDate } from '../utils/pdfText';
+import { colorPalette } from '../utils/pdfPalette';
+
+interface DrawSignatureSectionOptions {
+ page: PDFPage;
+ cursorY: number;
+ signature: SignatureValidationSignature;
+ index: number;
+ marginX: number;
+ contentWidth: number;
+ columnGap: number;
+ font: PDFFont;
+ fontBold: PDFFont;
+ t: TFunction<'translation'>;
+}
+
+export const drawSignatureSection = ({
+ page,
+ cursorY,
+ signature,
+ index,
+ marginX,
+ contentWidth,
+ columnGap,
+ font,
+ fontBold,
+ t,
+}: DrawSignatureSectionOptions): number => {
+ const columnWidth = (contentWidth - columnGap) / 2;
+
+ const heading = `${t('validateSignature.signature._value', 'Signature')} ${index + 1}`;
+ page.drawText(heading, {
+ x: marginX,
+ y: cursorY,
+ size: 14,
+ font: fontBold,
+ color: colorPalette.textPrimary,
+ });
+
+ const status = computeSignatureStatus(signature, t);
+ const statusColor = statusKindToPdfColor(status.kind);
+
+ const headingWidth = fontBold.widthOfTextAtSize(heading, 14);
+ drawStatusBadge({
+ page,
+ font,
+ fontBold,
+ text: status.label,
+ x: marginX + headingWidth + 16,
+ y: cursorY + 14,
+ color: statusColor,
+ });
+
+ let nextY = cursorY - 20;
+
+ const signatureFields = [
+ { label: t('validateSignature.signer', 'Signer'), value: signature.signerName || '-' },
+ { label: t('validateSignature.date', 'Date'), value: formatDate(signature.signatureDate) },
+ { label: t('validateSignature.reason', 'Reason'), value: signature.reason || '-' },
+ { label: t('validateSignature.location', 'Location'), value: signature.location || '-' },
+ ];
+
+ for (let i = 0; i < signatureFields.length; i += 2) {
+ const leftField = signatureFields[i];
+ const rightField = signatureFields[i + 1];
+
+ const leftHeight = drawFieldBox({
+ page,
+ font,
+ fontBold,
+ x: marginX,
+ top: nextY,
+ width: columnWidth,
+ label: leftField.label,
+ value: leftField.value,
+ });
+
+ let rowHeight = leftHeight;
+ if (rightField) {
+ const rightHeight = drawFieldBox({
+ page,
+ font,
+ fontBold,
+ x: marginX + columnWidth + columnGap,
+ top: nextY,
+ width: columnWidth,
+ label: rightField.label,
+ value: rightField.value,
+ });
+ rowHeight = Math.max(leftHeight, rightHeight);
+ }
+
+ nextY -= rowHeight + 8;
+ }
+
+ nextY -= 6;
+ page.drawLine({
+ start: { x: marginX, y: nextY },
+ end: { x: marginX + contentWidth, y: nextY },
+ thickness: 1,
+ color: colorPalette.boxBorder,
+ });
+ nextY -= 20;
+
+ const certificateFields = [
+ { label: t('validateSignature.cert.issuer', 'Issuer'), value: signature.issuerDN || '-' },
+ { label: t('validateSignature.cert.subject', 'Subject'), value: signature.subjectDN || '-' },
+ { label: t('validateSignature.cert.serialNumber', 'Serial Number'), value: signature.serialNumber || '-' },
+ { label: t('validateSignature.cert.algorithm', 'Algorithm'), value: signature.signatureAlgorithm || '-' },
+ { label: t('validateSignature.cert.validFrom', 'Valid From'), value: formatDate(signature.validFrom) },
+ { label: t('validateSignature.cert.validUntil', 'Valid Until'), value: formatDate(signature.validUntil) },
+ {
+ label: t('validateSignature.cert.keySize', 'Key Size'),
+ value:
+ signature.keySize != null
+ ? `${signature.keySize} ${t('validateSignature.cert.bits', 'bits')}`
+ : '--',
+ },
+ { label: t('validateSignature.cert.version', 'Version'), value: signature.version || '-' },
+ {
+ label: t('validateSignature.cert.keyUsage', 'Key Usage'),
+ value:
+ signature.keyUsages && signature.keyUsages.length > 0
+ ? signature.keyUsages.join(', ')
+ : '--',
+ },
+ {
+ label: t('validateSignature.cert.selfSigned', 'Self-Signed'),
+ value: signature.selfSigned ? t('yes', 'Yes') : t('no', 'No'),
+ },
+ ];
+
+ for (let i = 0; i < certificateFields.length; i += 2) {
+ const leftField = certificateFields[i];
+ const rightField = certificateFields[i + 1];
+
+ const leftHeight = drawFieldBox({
+ page,
+ font,
+ fontBold,
+ x: marginX,
+ top: nextY,
+ width: columnWidth,
+ label: leftField.label,
+ value: leftField.value,
+ });
+
+ let rowHeight = leftHeight;
+ if (rightField) {
+ const rightHeight = drawFieldBox({
+ page,
+ font,
+ fontBold,
+ x: marginX + columnWidth + columnGap,
+ top: nextY,
+ width: columnWidth,
+ label: rightField.label,
+ value: rightField.value,
+ });
+ rowHeight = Math.max(leftHeight, rightHeight);
+ }
+
+ nextY -= rowHeight + 8;
+ }
+
+ return nextY - 12;
+};
diff --git a/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection.ts b/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection.ts
new file mode 100644
index 0000000000..35832ec97a
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection.ts
@@ -0,0 +1,38 @@
+import { PDFFont, PDFPage, rgb } from 'pdf-lib';
+
+interface StatusBadgeOptions {
+ page: PDFPage;
+ font: PDFFont;
+ fontBold: PDFFont;
+ text: string;
+ x: number;
+ y: number;
+ color: ReturnType;
+}
+
+export const drawStatusBadge = ({ page, font, fontBold, text, x, y, color }: StatusBadgeOptions): number => {
+ const paddingX = 14;
+ const paddingY = 6;
+ const fontSize = 10;
+ const textWidth = font.widthOfTextAtSize(text, fontSize);
+ const width = textWidth + paddingX * 2;
+ const height = fontSize + paddingY * 2;
+
+ page.drawRectangle({
+ x,
+ y: y - height,
+ width,
+ height,
+ color,
+ });
+
+ page.drawText(text, {
+ x: x + paddingX,
+ y: y - paddingY - fontSize + 2,
+ size: fontSize,
+ font: fontBold,
+ color: rgb(1, 1, 1),
+ });
+
+ return width;
+};
diff --git a/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/SummarySection.ts b/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/SummarySection.ts
new file mode 100644
index 0000000000..820fc78cbc
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/SummarySection.ts
@@ -0,0 +1,145 @@
+import type { TFunction } from 'i18next';
+import { PDFFont, PDFImage, PDFPage } from 'pdf-lib';
+import { SignatureValidationReportEntry } from '../../../../types/validateSignature';
+import { drawFieldBox } from './FieldBoxSection';
+import { drawThumbnailImage, drawThumbnailPlaceholder } from './ThumbnailSection';
+import { colorPalette } from '../utils/pdfPalette';
+import { formatFileSize } from '../utils/pdfText';
+
+interface DrawSummarySectionOptions {
+ page: PDFPage;
+ cursorY: number;
+ entry: SignatureValidationReportEntry;
+ font: PDFFont;
+ fontBold: PDFFont;
+ marginX: number;
+ contentWidth: number;
+ columnGap: number;
+ statusText: string;
+ statusColor: (typeof colorPalette)['success'];
+ loadThumbnail: (url: string) => Promise<{ image: PDFImage } | null>;
+ t: TFunction<'translation'>;
+}
+
+export const drawSummarySection = async ({
+ page,
+ cursorY,
+ entry,
+ font,
+ fontBold,
+ marginX,
+ contentWidth,
+ columnGap,
+ loadThumbnail,
+ t,
+}: DrawSummarySectionOptions): Promise => {
+ const thumbnailWidth = 140;
+ const thumbnailHeight = 180;
+ const summaryX = marginX + thumbnailWidth + 24;
+ const summaryWidth = contentWidth - (thumbnailWidth + 24);
+ const summaryColumnWidth = (summaryWidth - columnGap) / 2;
+ const rowSpacing = 8;
+ const summaryTop = cursorY;
+ const titleFontSize = 22;
+ const subtitleFontSize = 11;
+
+ const latestSignatureTimestamp = entry.signatures
+ .map((sig) => (sig.signatureDate ? Date.parse(sig.signatureDate) : NaN))
+ .filter((value) => !Number.isNaN(value));
+
+ const latestSignatureLabel = latestSignatureTimestamp.length
+ ? new Date(Math.max(...latestSignatureTimestamp)).toLocaleString()
+ : '--';
+
+ const titleBaseline = summaryTop - 12 - titleFontSize;
+ page.drawText(entry.fileName, {
+ x: summaryX,
+ y: titleBaseline,
+ size: titleFontSize,
+ font: fontBold,
+ color: colorPalette.textPrimary,
+ });
+
+ const subtitle = t('validateSignature.report.shortTitle', 'Signature Summary');
+ const subtitleBaseline = titleBaseline - subtitleFontSize - 6;
+ page.drawText(subtitle, {
+ x: summaryX,
+ y: subtitleBaseline,
+ size: subtitleFontSize,
+ font,
+ color: colorPalette.textMuted,
+ });
+
+ const summaryRows: Array<
+ Array<{
+ label: string;
+ value: string;
+ }>
+ > = [
+ [
+ { label: t('validateSignature.report.fields.fileSize', 'File Size'), value: formatFileSize(entry.fileSize) },
+ { label: t('validateSignature.report.fields.created', 'Created'), value: entry.createdAtLabel ?? '--' },
+ ],
+ [
+ { label: t('validateSignature.report.fields.signatureDate', 'Signature Date'), value: latestSignatureLabel },
+ { label: t('validateSignature.report.fields.signatureCount', 'Total Signatures'), value: entry.signatures.length.toString() },
+ ],
+ ];
+
+ let rowTop = subtitleBaseline - subtitleFontSize - 18;
+
+ summaryRows.forEach((fields, rowIndex) => {
+ let rowHeight = 0;
+
+ const singleColumn = fields.length === 1;
+ fields.forEach((field, index) => {
+ const fieldWidth = singleColumn ? summaryWidth : summaryColumnWidth;
+ const x = singleColumn ? summaryX : summaryX + index * (summaryColumnWidth + columnGap);
+ const fieldHeight = drawFieldBox({
+ page,
+ font,
+ fontBold,
+ x,
+ top: rowTop,
+ width: fieldWidth,
+ label: field.label,
+ value: field.value,
+ });
+ rowHeight = Math.max(rowHeight, fieldHeight);
+ });
+
+ rowTop -= rowHeight;
+ if (rowIndex < summaryRows.length - 1) {
+ rowTop -= rowSpacing;
+ }
+ });
+
+ const rightContentHeight = summaryTop - rowTop;
+
+ const thumbX = marginX;
+ const thumbTop = summaryTop;
+
+ if (entry.thumbnailUrl) {
+ const thumbnail = await loadThumbnail(entry.thumbnailUrl);
+ if (thumbnail?.image) {
+ page.drawRectangle({
+ x: thumbX,
+ y: thumbTop - thumbnailHeight,
+ width: thumbnailWidth,
+ height: thumbnailHeight,
+ color: colorPalette.boxBackground,
+ borderColor: colorPalette.boxBorder,
+ borderWidth: 1,
+ });
+ drawThumbnailImage(page, thumbnail.image, thumbX, thumbTop, thumbnailWidth, thumbnailHeight);
+ } else {
+ drawThumbnailPlaceholder(page, fontBold, thumbX, thumbTop, thumbnailWidth, thumbnailHeight);
+ }
+ } else {
+ drawThumbnailPlaceholder(page, fontBold, thumbX, thumbTop, thumbnailWidth, thumbnailHeight);
+ }
+
+ const summarySectionHeight = Math.max(thumbnailHeight, rightContentHeight);
+
+ return summaryTop - summarySectionHeight - 32;
+};
diff --git a/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/ThumbnailSection.ts b/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/ThumbnailSection.ts
new file mode 100644
index 0000000000..f23ddbb121
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/outputtedPDFSections/ThumbnailSection.ts
@@ -0,0 +1,55 @@
+import { PDFFont, PDFPage, PDFImage } from 'pdf-lib';
+import { colorPalette } from '../utils/pdfPalette';
+
+export const drawThumbnailPlaceholder = (
+ page: PDFPage,
+ fontBold: PDFFont,
+ x: number,
+ top: number,
+ width: number,
+ height: number
+) => {
+ page.drawRectangle({
+ x,
+ y: top - height,
+ width,
+ height,
+ color: colorPalette.boxBackground,
+ borderColor: colorPalette.boxBorder,
+ borderWidth: 1,
+ });
+
+ const label = 'PDF';
+ const labelSize = 22;
+ const labelWidth = fontBold.widthOfTextAtSize(label, labelSize);
+ const labelX = x + (width - labelWidth) / 2;
+ const labelY = top - height / 2 - labelSize / 2;
+
+ page.drawText(label, {
+ x: labelX,
+ y: labelY,
+ size: labelSize,
+ font: fontBold,
+ color: colorPalette.textMuted,
+ });
+};
+
+export const drawThumbnailImage = (
+ page: PDFPage,
+ image: PDFImage,
+ x: number,
+ top: number,
+ width: number,
+ height: number
+) => {
+ const scaled = image.scaleToFit(width - 16, height - 16);
+ const offsetX = x + (width - scaled.width) / 2;
+ const offsetY = top - (height - scaled.height) / 2 - scaled.height;
+
+ page.drawImage(image, {
+ x: offsetX,
+ y: offsetY,
+ width: scaled.width,
+ height: scaled.height,
+ });
+};
diff --git a/frontend/src/hooks/tools/validateSignature/signatureReportPdf.ts b/frontend/src/hooks/tools/validateSignature/signatureReportPdf.ts
new file mode 100644
index 0000000000..5f026a89d7
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/signatureReportPdf.ts
@@ -0,0 +1,139 @@
+import { PDFDocument, PDFPage, StandardFonts } from 'pdf-lib';
+import type { TFunction } from 'i18next';
+import { SignatureValidationReportEntry } from '../../../types/validateSignature';
+import { REPORT_PDF_FILENAME } from './utils/signatureUtils';
+import { colorPalette } from './utils/pdfPalette';
+import { startReportPage, createThumbnailLoader } from './utils/pdfPageHelpers';
+import { deriveEntryStatus } from './utils/reportStatus';
+import { drawCenteredMessage } from './outputtedPDFSections/CenteredMessageSection';
+import { drawSummarySection } from './outputtedPDFSections/SummarySection';
+import { drawSignatureSection } from './outputtedPDFSections/SignatureSection';
+
+const PAGE_WIDTH = 612;
+const PAGE_HEIGHT = 792;
+const MARGIN_X = 52;
+const MARGIN_Y = 22;
+const CONTENT_WIDTH = PAGE_WIDTH - MARGIN_X * 2;
+const COLUMN_GAP = 18;
+
+const drawDivider = (page: PDFPage, marginX: number, contentWidth: number, y: number) => {
+ page.drawLine({
+ start: { x: marginX, y },
+ end: { x: marginX + contentWidth, y },
+ thickness: 1,
+ color: colorPalette.boxBorder,
+ });
+};
+
+export const createReportPdf = async (
+ entries: SignatureValidationReportEntry[],
+ t: TFunction<'translation'>
+): Promise => {
+ const doc = await PDFDocument.create();
+ const font = await doc.embedFont(StandardFonts.Helvetica);
+ const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
+ const loadThumbnail = createThumbnailLoader(doc);
+
+ for (const entry of entries) {
+ const { text: statusText, color: statusColor } = deriveEntryStatus(entry, t);
+
+ let { page, cursorY } = startReportPage({
+ doc,
+ font,
+ fontBold,
+ marginX: MARGIN_X,
+ marginY: MARGIN_Y,
+ contentWidth: CONTENT_WIDTH,
+ pageWidth: PAGE_WIDTH,
+ pageHeight: PAGE_HEIGHT,
+ title: entry.fileName,
+ isContinuation: false,
+ t,
+ });
+
+ cursorY = await drawSummarySection({
+ page,
+ cursorY,
+ entry,
+ font,
+ fontBold,
+ marginX: MARGIN_X,
+ contentWidth: CONTENT_WIDTH,
+ columnGap: COLUMN_GAP,
+ statusText,
+ statusColor,
+ loadThumbnail,
+ t,
+ });
+
+ cursorY -= 12;
+ drawDivider(page, MARGIN_X, CONTENT_WIDTH, cursorY);
+ cursorY -= 16;
+
+ if (entry.error) {
+ cursorY = drawCenteredMessage({
+ page,
+ font,
+ fontBold,
+ text: t('validateSignature.status.invalid', 'Invalid'),
+ description: entry.error,
+ marginX: MARGIN_X,
+ contentWidth: CONTENT_WIDTH,
+ cursorY,
+ badgeColor: colorPalette.danger,
+ });
+ continue;
+ }
+
+ if (entry.signatures.length === 0) {
+ cursorY = drawCenteredMessage({
+ page,
+ font,
+ fontBold,
+ text: t('validateSignature.noSignaturesShort', 'No signatures'),
+ description: t('validateSignature.noSignatures', 'No digital signatures found in this document'),
+ marginX: MARGIN_X,
+ contentWidth: CONTENT_WIDTH,
+ cursorY,
+ badgeColor: colorPalette.neutral,
+ });
+ continue;
+ }
+
+ for (let i = 0; i < entry.signatures.length; i += 1) {
+ // After the first signature, start a new page per signature
+ if (i > 0) {
+ ({ page, cursorY } = startReportPage({
+ doc,
+ font,
+ fontBold,
+ marginX: MARGIN_X,
+ marginY: MARGIN_Y,
+ contentWidth: CONTENT_WIDTH,
+ pageWidth: PAGE_WIDTH,
+ pageHeight: PAGE_HEIGHT,
+ title: entry.fileName,
+ isContinuation: true,
+ t,
+ }));
+ }
+
+ cursorY = drawSignatureSection({
+ page,
+ cursorY,
+ signature: entry.signatures[i],
+ index: i,
+ marginX: MARGIN_X,
+ contentWidth: CONTENT_WIDTH,
+ columnGap: COLUMN_GAP,
+ font,
+ fontBold,
+ t,
+ });
+ }
+ }
+
+ const pdfBytes = await doc.save();
+ const copy = pdfBytes.slice();
+ return new File([copy.buffer], REPORT_PDF_FILENAME, { type: 'application/pdf' });
+};
diff --git a/frontend/src/hooks/tools/validateSignature/useValidateSignatureOperation.ts b/frontend/src/hooks/tools/validateSignature/useValidateSignatureOperation.ts
new file mode 100644
index 0000000000..9f6e1c20c0
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/useValidateSignatureOperation.ts
@@ -0,0 +1,231 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import apiClient from '../../../services/apiClient';
+import { useFileContext } from '../../../contexts/file/fileHooks';
+import { ToolOperationHook } from '../shared/useToolOperation';
+import type { StirlingFile } from '../../../types/fileContext';
+import { extractErrorMessage } from '../../../utils/toolErrorHandler';
+import {
+ SignatureValidationBackendResult,
+ SignatureValidationFileResult,
+ SignatureValidationReportEntry,
+} from '../../../types/validateSignature';
+import { ValidateSignatureParameters } from './useValidateSignatureParameters';
+import { buildReportEntries } from './utils/signatureReportBuilder';
+import { createReportPdf } from './signatureReportPdf';
+import { createCsvFile as buildCsvFile } from './utils/signatureCsv';
+import { normalizeBackendResult, RESULT_JSON_FILENAME } from './utils/signatureUtils';
+
+export interface ValidateSignatureOperationHook extends ToolOperationHook {
+ results: SignatureValidationReportEntry[];
+}
+
+export const useValidateSignatureOperation = (): ValidateSignatureOperationHook => {
+ const { t } = useTranslation();
+ const { selectors } = useFileContext();
+ const [isLoading, setIsLoading] = useState(false);
+ const [status, setStatus] = useState('');
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [files, setFiles] = useState([]);
+ const [downloadUrl, setDownloadUrl] = useState(null);
+ const [downloadFilename, setDownloadFilename] = useState('');
+ const [results, setResults] = useState([]);
+
+ const cancelRequested = useRef(false);
+ const previousUrl = useRef(null);
+
+ const cleanupDownloadUrl = useCallback(() => {
+ if (previousUrl.current) {
+ URL.revokeObjectURL(previousUrl.current);
+ previousUrl.current = null;
+ }
+ }, []);
+
+ const resetResults = useCallback(() => {
+ cancelRequested.current = false;
+ setResults([]);
+ setFiles([]);
+ cleanupDownloadUrl();
+ setDownloadUrl(null);
+ setDownloadFilename('');
+ setStatus('');
+ setErrorMessage(null);
+ }, [cleanupDownloadUrl]);
+
+ const clearError = useCallback(() => {
+ setErrorMessage(null);
+ }, []);
+
+ const executeOperation = useCallback(
+ async (params: ValidateSignatureParameters, selectedFiles: StirlingFile[]) => {
+ if (selectedFiles.length === 0) {
+ setErrorMessage(t('noFileSelected', 'No files selected'));
+ return;
+ }
+
+ cancelRequested.current = false;
+ setIsLoading(true);
+ setStatus(t('validateSignature.processing', 'Validating signatures...'));
+ setErrorMessage(null);
+ setResults([]);
+ setFiles([]);
+ cleanupDownloadUrl();
+ setDownloadUrl(null);
+ setDownloadFilename('');
+
+ try {
+ const aggregated: SignatureValidationFileResult[] = [];
+
+ for (const file of selectedFiles) {
+ if (cancelRequested.current) {
+ break;
+ }
+
+ const formData = new FormData();
+ formData.append('fileInput', file);
+ if (params.certFile) {
+ formData.append('certFile', params.certFile);
+ }
+
+ try {
+ const response = await apiClient.post('/api/v1/security/validate-signature', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ });
+
+ const data = Array.isArray(response.data)
+ ? (response.data as SignatureValidationBackendResult[])
+ : [];
+ const signatures = data.map((item, index) => normalizeBackendResult(item, file, index));
+
+ aggregated.push({
+ fileId: file.fileId,
+ fileName: file.name,
+ signatures,
+ error: null,
+ fileSize: file.size ?? null,
+ lastModified: file.lastModified ?? null,
+ });
+ } catch (error) {
+ aggregated.push({
+ fileId: file.fileId,
+ fileName: file.name,
+ signatures: [],
+ error: extractErrorMessage(error),
+ fileSize: file.size ?? null,
+ lastModified: file.lastModified ?? null,
+ });
+ }
+ }
+
+ if (!cancelRequested.current) {
+ const summaryTimestamp = Date.now();
+ const enrichedEntries = buildReportEntries({
+ results: aggregated,
+ selectors,
+ generatedAt: summaryTimestamp,
+ t,
+ });
+
+ setResults(enrichedEntries);
+
+ if (enrichedEntries.length > 0) {
+ const json = JSON.stringify(enrichedEntries, null, 2);
+ const resultFile = new File([json], RESULT_JSON_FILENAME, { type: 'application/json' });
+ const csvFile = buildCsvFile(enrichedEntries);
+
+ setFiles([resultFile, csvFile]);
+
+ (async () => {
+ try {
+ const pdfFile = await createReportPdf(enrichedEntries, t);
+ setFiles((prev) => [pdfFile, ...prev.filter((f) => !f.name.toLowerCase().endsWith('.pdf'))]);
+ setDownloadFilename(pdfFile.name);
+ cleanupDownloadUrl();
+ const blobUrl = URL.createObjectURL(pdfFile);
+ previousUrl.current = blobUrl;
+ setDownloadUrl(blobUrl);
+ } catch (err) {
+ console.warn('[validateSignature] PDF report generation failed', err);
+ setErrorMessage((prev) =>
+ prev ??
+ t(
+ 'validateSignature.error.reportGeneration',
+ 'Could not generate the PDF report. JSON and CSV are available.'
+ )
+ );
+ }
+ })();
+ }
+
+ const anyError = aggregated.some((item) => item.error);
+ const anySuccess = aggregated.some((item) => item.signatures.length > 0);
+
+ if (anyError && !anySuccess) {
+ setErrorMessage(t('validateSignature.error.allFailed', 'Unable to validate the selected files.'));
+ } else if (anyError) {
+ setErrorMessage(t('validateSignature.error.partial', 'Some files could not be validated.'));
+ }
+
+ setStatus(t('validateSignature.status.complete', 'Validation complete'));
+ }
+ } catch (e) {
+ console.error('[validateSignature] unexpected failure', e);
+ setErrorMessage(t('validateSignature.error.unexpected', 'Unexpected error during validation.'));
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [cleanupDownloadUrl, selectors, t]
+ );
+
+ const cancelOperation = useCallback(() => {
+ if (isLoading) {
+ cancelRequested.current = true;
+ setIsLoading(false);
+ setStatus(t('operationCancelled', 'Operation cancelled'));
+ }
+ }, [isLoading, t]);
+
+ const undoOperation = useCallback(async () => {
+ resetResults();
+ }, [resetResults]);
+
+ useEffect(() => {
+ return () => {
+ cleanupDownloadUrl();
+ };
+ }, [cleanupDownloadUrl]);
+
+ return useMemo(
+ () => ({
+ files,
+ thumbnails: [],
+ isGeneratingThumbnails: false,
+ downloadUrl,
+ downloadFilename,
+ isLoading,
+ status,
+ errorMessage,
+ progress: null,
+ executeOperation,
+ resetResults,
+ clearError,
+ cancelOperation,
+ undoOperation,
+ results,
+ }),
+ [
+ cancelOperation,
+ clearError,
+ downloadFilename,
+ downloadUrl,
+ errorMessage,
+ executeOperation,
+ files,
+ isLoading,
+ resetResults,
+ results,
+ status,
+ ]
+ );
+};
diff --git a/frontend/src/hooks/tools/validateSignature/useValidateSignatureParameters.ts b/frontend/src/hooks/tools/validateSignature/useValidateSignatureParameters.ts
new file mode 100644
index 0000000000..7785f4c13c
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/useValidateSignatureParameters.ts
@@ -0,0 +1,18 @@
+import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
+
+export interface ValidateSignatureParameters {
+ certFile: File | null;
+}
+
+export const defaultParameters: ValidateSignatureParameters = {
+ certFile: null,
+};
+
+export type ValidateSignatureParametersHook = BaseParametersHook;
+
+export const useValidateSignatureParameters = (): ValidateSignatureParametersHook => {
+ return useBaseParameters({
+ defaultParameters,
+ endpointName: 'validate-signature',
+ });
+};
diff --git a/frontend/src/hooks/tools/validateSignature/utils/pdfPageHelpers.ts b/frontend/src/hooks/tools/validateSignature/utils/pdfPageHelpers.ts
new file mode 100644
index 0000000000..06a7d75300
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/utils/pdfPageHelpers.ts
@@ -0,0 +1,101 @@
+import { PDFDocument, PDFFont, PDFImage } from 'pdf-lib';
+import type { TFunction } from 'i18next';
+import { colorPalette } from './pdfPalette';
+
+interface StartPageParams {
+ doc: PDFDocument;
+ font: PDFFont;
+ fontBold: PDFFont;
+ marginX: number;
+ marginY: number;
+ contentWidth: number;
+ pageWidth: number;
+ pageHeight: number;
+ title: string;
+ isContinuation: boolean;
+ t: TFunction<'translation'>;
+}
+
+export const startReportPage = ({
+ doc,
+ font,
+ fontBold,
+ marginX,
+ marginY,
+ pageWidth,
+ pageHeight,
+ title,
+ isContinuation,
+ t,
+}: StartPageParams) => {
+ const page = doc.addPage([pageWidth, pageHeight]);
+ let cursorY = pageHeight - marginY;
+
+ if (isContinuation) {
+ const heading = `${title} - ${t('validateSignature.report.continued', 'Continued')}`;
+ page.drawText(heading, {
+ x: marginX,
+ y: cursorY - 18,
+ size: 12,
+ font: fontBold,
+ color: colorPalette.textMuted,
+ });
+ cursorY -= 36;
+ }
+
+ const pageNumber = doc.getPageCount();
+ page.drawText(`${t('validateSignature.report.page', 'Page')} ${pageNumber}`, {
+ x: pageWidth - marginX - 80,
+ y: marginY / 2,
+ size: 9,
+ font,
+ color: colorPalette.textMuted,
+ });
+
+ page.drawText(t('validateSignature.report.footer', 'Validated via Stirling PDF'), {
+ x: marginX,
+ y: marginY / 2,
+ size: 9,
+ font,
+ color: colorPalette.textMuted,
+ });
+
+ return { page, cursorY };
+};
+
+export const createThumbnailLoader = (doc: PDFDocument) => {
+ const cache = new Map();
+
+ return async (url: string) => {
+ if (cache.has(url)) {
+ return cache.get(url) ?? null;
+ }
+
+ try {
+ const response = await fetch(url);
+ const bytes = new Uint8Array(await response.arrayBuffer());
+ const contentType = response.headers.get('content-type') || '';
+ let image: PDFImage;
+
+ if (contentType.includes('png')) {
+ image = await doc.embedPng(bytes);
+ } else if (contentType.includes('jpeg') || contentType.includes('jpg')) {
+ image = await doc.embedJpg(bytes);
+ } else {
+ try {
+ image = await doc.embedPng(bytes);
+ } catch {
+ image = await doc.embedJpg(bytes);
+ }
+ }
+
+ const result = { image };
+ cache.set(url, result);
+ return result;
+ } catch (error) {
+ console.warn('[validateSignature] Failed to load thumbnail', error);
+ cache.set(url, null);
+ return null;
+ }
+ };
+};
diff --git a/frontend/src/hooks/tools/validateSignature/utils/pdfPalette.ts b/frontend/src/hooks/tools/validateSignature/utils/pdfPalette.ts
new file mode 100644
index 0000000000..558a96b17b
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/utils/pdfPalette.ts
@@ -0,0 +1,60 @@
+import { rgb } from 'pdf-lib';
+
+type RgbTuple = [number, number, number];
+
+const defaultLightPalette: Record<
+ 'headerBackground' | 'accent' | 'textPrimary' | 'textMuted' | 'boxBackground' | 'boxBorder' | 'warning' | 'danger' | 'success' | 'neutral',
+ RgbTuple
+> = {
+ headerBackground: [239, 246, 255],
+ accent: [59, 130, 246],
+ textPrimary: [30, 41, 59],
+ textMuted: [100, 116, 139],
+ boxBackground: [248, 250, 252],
+ boxBorder: [226, 232, 240],
+ warning: [234, 179, 8],
+ danger: [248, 113, 113],
+ success: [34, 197, 94],
+ neutral: [148, 163, 184],
+};
+
+const toRgb = ([r, g, b]: RgbTuple) => rgb(r / 255, g / 255, b / 255);
+
+/**
+ * Utility function to get CSS variable values and convert them to pdf-lib RGB format.
+ * Falls back to sensible defaults when the CSS variable cannot be resolved.
+ */
+function getCssVariableAsRgb(variableName: string, fallback: RgbTuple) {
+ if (typeof window === 'undefined') {
+ return toRgb(fallback);
+ }
+
+ const value = getComputedStyle(document.documentElement).getPropertyValue(variableName).trim();
+
+ if (!value) {
+ console.warn(`CSS variable ${variableName} not found, using fallback`);
+ return toRgb(fallback);
+ }
+
+ const [r, g, b] = value.split(' ').map(Number);
+
+ if ([r, g, b].some((component) => Number.isNaN(component))) {
+ console.warn(`Invalid CSS variable format for ${variableName}: ${value}`);
+ return toRgb(fallback);
+ }
+
+ return rgb(r / 255, g / 255, b / 255);
+}
+
+export const colorPalette = {
+ headerBackground: getCssVariableAsRgb('--pdf-light-header-bg', defaultLightPalette.headerBackground),
+ accent: getCssVariableAsRgb('--pdf-light-accent', defaultLightPalette.accent),
+ textPrimary: getCssVariableAsRgb('--pdf-light-text-primary', defaultLightPalette.textPrimary),
+ textMuted: getCssVariableAsRgb('--pdf-light-text-muted', defaultLightPalette.textMuted),
+ boxBackground: getCssVariableAsRgb('--pdf-light-box-bg', defaultLightPalette.boxBackground),
+ boxBorder: getCssVariableAsRgb('--pdf-light-box-border', defaultLightPalette.boxBorder),
+ warning: getCssVariableAsRgb('--pdf-light-warning', defaultLightPalette.warning),
+ danger: getCssVariableAsRgb('--pdf-light-danger', defaultLightPalette.danger),
+ success: getCssVariableAsRgb('--pdf-light-success', defaultLightPalette.success),
+ neutral: getCssVariableAsRgb('--pdf-light-neutral', defaultLightPalette.neutral),
+};
diff --git a/frontend/src/hooks/tools/validateSignature/utils/pdfText.ts b/frontend/src/hooks/tools/validateSignature/utils/pdfText.ts
new file mode 100644
index 0000000000..d592db70ce
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/utils/pdfText.ts
@@ -0,0 +1,51 @@
+import { PDFFont } from 'pdf-lib';
+
+export const wrapText = (text: string, font: PDFFont, fontSize: number, maxWidth: number): string[] => {
+ const lines: string[] = [];
+ const paragraphs = text.split(/\r?\n/);
+
+ paragraphs.forEach((paragraph) => {
+ const trimmed = paragraph.trim();
+ if (trimmed.length === 0) {
+ lines.push('');
+ return;
+ }
+
+ const words = trimmed.split(/\s+/);
+ let currentLine = '';
+ words.forEach((word) => {
+ const tentative = currentLine.length > 0 ? `${currentLine} ${word}` : word;
+ const width = font.widthOfTextAtSize(tentative, fontSize);
+ if (width <= maxWidth) {
+ currentLine = tentative;
+ } else {
+ if (currentLine.length > 0) {
+ lines.push(currentLine);
+ }
+ currentLine = word;
+ }
+ });
+ if (currentLine.length > 0) {
+ lines.push(currentLine);
+ }
+ });
+
+ return lines;
+};
+
+export const formatFileSize = (bytes?: number | null) => {
+ if (!bytes || bytes <= 0) return '--';
+ const units = ['B', 'KB', 'MB', 'GB'];
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
+ const size = bytes / Math.pow(1024, exponent);
+ return `${size.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;
+};
+
+export const formatDate = (value?: string | null) => {
+ if (!value) return '--';
+ const parsed = Date.parse(value);
+ if (!Number.isNaN(parsed)) {
+ return new Date(parsed).toLocaleString();
+ }
+ return value;
+};
diff --git a/frontend/src/hooks/tools/validateSignature/utils/reportStatus.ts b/frontend/src/hooks/tools/validateSignature/utils/reportStatus.ts
new file mode 100644
index 0000000000..80e89ac58a
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/utils/reportStatus.ts
@@ -0,0 +1,38 @@
+import type { TFunction } from 'i18next';
+import { SignatureValidationReportEntry } from '../../../../types/validateSignature';
+import { colorPalette } from './pdfPalette';
+
+export const deriveEntryStatus = (
+ entry: Pick,
+ t: TFunction<'translation'>
+) => {
+ if (entry.error) {
+ return {
+ text: t('validateSignature.status.invalid', 'Invalid'),
+ color: colorPalette.danger,
+ };
+ }
+
+ if (entry.signatures.length === 0) {
+ return {
+ text: t('validateSignature.noSignaturesShort', 'No signatures'),
+ color: colorPalette.neutral,
+ };
+ }
+
+ const allValid = entry.signatures.every(
+ (sig) => sig.valid && sig.chainValid && sig.trustValid && sig.notExpired && sig.notRevoked
+ );
+
+ if (allValid) {
+ return {
+ text: t('validateSignature.status.valid', 'Valid'),
+ color: colorPalette.success,
+ };
+ }
+
+ return {
+ text: t('validateSignature.status.reviewMissingFields', 'Needs Attention: Missing Fields'),
+ color: colorPalette.warning,
+ };
+};
diff --git a/frontend/src/hooks/tools/validateSignature/utils/signatureCsv.ts b/frontend/src/hooks/tools/validateSignature/utils/signatureCsv.ts
new file mode 100644
index 0000000000..24078dc198
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/utils/signatureCsv.ts
@@ -0,0 +1,95 @@
+import { SignatureValidationReportEntry } from '../../../../types/validateSignature';
+import { CSV_FILENAME, booleanToString, escapeCsvValue, keyUsagesToString } from './signatureUtils';
+
+const buildCsvRows = (entries: SignatureValidationReportEntry[]): string[][] => {
+ const headers = [
+ 'fileName',
+ 'signatureIndex',
+ 'valid',
+ 'chainValid',
+ 'trustValid',
+ 'notExpired',
+ 'notRevoked',
+ 'signerName',
+ 'signatureDate',
+ 'reason',
+ 'location',
+ 'issuerDN',
+ 'subjectDN',
+ 'serialNumber',
+ 'validFrom',
+ 'validUntil',
+ 'signatureAlgorithm',
+ 'keySize',
+ 'version',
+ 'keyUsages',
+ 'selfSigned',
+ 'errorMessage'
+ ];
+
+ const rows: string[][] = [headers];
+
+ entries.forEach((fileResult) => {
+ if (fileResult.signatures.length > 0) {
+ fileResult.signatures.forEach((signature, index) => {
+ rows.push([
+ fileResult.fileName,
+ String(index + 1),
+ booleanToString(signature.valid),
+ booleanToString(signature.chainValid),
+ booleanToString(signature.trustValid),
+ booleanToString(signature.notExpired),
+ booleanToString(signature.notRevoked),
+ signature.signerName || '',
+ signature.signatureDate || '',
+ signature.reason || '',
+ signature.location || '',
+ signature.issuerDN || '',
+ signature.subjectDN || '',
+ signature.serialNumber || '',
+ signature.validFrom || '',
+ signature.validUntil || '',
+ signature.signatureAlgorithm || '',
+ signature.keySize !== null && signature.keySize !== undefined ? String(signature.keySize) : '',
+ signature.version || '',
+ keyUsagesToString(signature.keyUsages),
+ booleanToString(signature.selfSigned),
+ signature.errorMessage || fileResult.error || ''
+ ]);
+ });
+ } else {
+ rows.push([
+ fileResult.fileName,
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ fileResult.error || ''
+ ]);
+ }
+ });
+
+ return rows;
+};
+
+export const createCsvFile = (entries: SignatureValidationReportEntry[]): File => {
+ const rows = buildCsvRows(entries);
+ const csv = rows.map((row) => row.map(escapeCsvValue).join(',')).join('\r\n');
+ return new File([csv], CSV_FILENAME, { type: 'text/csv;charset=utf-8;' });
+};
diff --git a/frontend/src/hooks/tools/validateSignature/utils/signatureReportBuilder.ts b/frontend/src/hooks/tools/validateSignature/utils/signatureReportBuilder.ts
new file mode 100644
index 0000000000..d4615b52fa
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/utils/signatureReportBuilder.ts
@@ -0,0 +1,46 @@
+import { SignatureValidationFileResult, SignatureValidationReportEntry } from '../../../../types/validateSignature';
+import { FileContextSelectors } from '../../../../types/fileContext';
+import type { FileId } from '../../../../types/file';
+import type { TFunction } from 'i18next';
+import { deriveEntryStatus } from './reportStatus';
+
+interface BuildReportEntriesOptions {
+ results: SignatureValidationFileResult[];
+ selectors: FileContextSelectors;
+ generatedAt: number;
+ t?: TFunction<'translation'>;
+}
+
+export const buildReportEntries = ({
+ results,
+ selectors,
+ generatedAt,
+ t,
+}: BuildReportEntriesOptions): SignatureValidationReportEntry[] => {
+ return results.map((entry) => {
+ const fileId = entry.fileId as FileId;
+ const stub = selectors.getStirlingFileStub(fileId);
+ const file = selectors.getFile(fileId);
+
+ let createdAtLabel: string | null = null;
+ const createdTimestamp = stub?.createdAt ?? null;
+ if (createdTimestamp) {
+ createdAtLabel = new Date(createdTimestamp).toLocaleString();
+ }
+
+ const fileSize = file?.size ?? stub?.size ?? entry.fileSize ?? null;
+ const lastModified = file?.lastModified ?? stub?.lastModified ?? entry.lastModified ?? null;
+
+ const statusMeta = t ? deriveEntryStatus(entry, t) : null;
+
+ return {
+ ...entry,
+ thumbnailUrl: stub?.thumbnailUrl ?? null,
+ fileSize,
+ lastModified,
+ createdAtLabel,
+ summaryGeneratedAt: generatedAt,
+ statusText: statusMeta?.text ?? null,
+ };
+ });
+};
diff --git a/frontend/src/hooks/tools/validateSignature/utils/signatureStatus.ts b/frontend/src/hooks/tools/validateSignature/utils/signatureStatus.ts
new file mode 100644
index 0000000000..31454d237b
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/utils/signatureStatus.ts
@@ -0,0 +1,114 @@
+import type { TFunction } from 'i18next';
+import type { SignatureValidationSignature } from '../../../../types/validateSignature';
+import { colorPalette } from './pdfPalette';
+
+export type SignatureStatusKind = 'valid' | 'warning' | 'invalid' | 'neutral';
+
+export interface SignatureStatus {
+ kind: SignatureStatusKind;
+ label: string;
+ details: string[];
+}
+
+export const computeSignatureStatus = (
+ signature: SignatureValidationSignature,
+ t: TFunction<'translation'>
+): SignatureStatus => {
+ // Start with error
+ if (signature.errorMessage) {
+ return {
+ kind: 'invalid',
+ label: t('validateSignature.status.invalid', 'Invalid'),
+ details: [signature.errorMessage],
+ };
+ }
+
+ const issues: string[] = [];
+ const trustIssues: string[] = [];
+
+ if (!signature.valid) {
+ issues.push(t('validateSignature.issue.signatureInvalid', 'Signature cryptographic check failed'));
+ }
+ if (!signature.chainValid) {
+ trustIssues.push(t('validateSignature.issue.chainInvalid', 'Certificate chain invalid'));
+ }
+ if (!signature.trustValid) {
+ trustIssues.push(t('validateSignature.issue.trustInvalid', 'Certificate not trusted'));
+ }
+ if (!signature.notExpired) {
+ trustIssues.push(t('validateSignature.issue.certExpired', 'Certificate expired'));
+ }
+ if (!signature.notRevoked) {
+ trustIssues.push(t('validateSignature.issue.certRevoked', 'Certificate revoked'));
+ }
+
+ // Check for missing common metadata fields
+ const missing: string[] = [];
+ if (!signature.signerName || signature.signerName.trim().length === 0) missing.push(t('validateSignature.signer', 'Signer'));
+ if (!signature.reason || signature.reason.trim().length === 0) missing.push(t('validateSignature.reason', 'Reason'));
+ if (!signature.location || signature.location.trim().length === 0) missing.push(t('validateSignature.location', 'Location'));
+
+ // Aggregate all issues for details UI
+ issues.push(...trustIssues);
+ if (missing.length > 0) {
+ issues.push(t('validateSignature.issue.missingFields', 'Missing fields') + `: ${missing.join(', ')}`);
+ }
+
+ if (issues.length === 0) {
+ return {
+ kind: 'valid',
+ label: t('validateSignature.status.validFull', 'Fully Valid'),
+ details: [],
+ };
+ }
+
+ // Invalid ONLY when cryptographic signature itself failed or an explicit backend error occurred
+ if (!signature.valid) {
+ return {
+ kind: 'invalid',
+ label: t('validateSignature.status.invalid', 'Invalid'),
+ details: issues,
+ };
+ }
+
+ // Otherwise, it's a signed document with issues
+ const onlyMissing = missing.length > 0 && trustIssues.length === 0;
+ const onlyTrust = missing.length === 0 && trustIssues.length > 0;
+
+ if (onlyMissing) {
+ return {
+ kind: 'warning',
+ label: t('validateSignature.status.missingFields', 'Needs Attention: Missing Fields'),
+ details: issues,
+ };
+ }
+
+ if (onlyTrust) {
+ return {
+ kind: 'warning',
+ label: t('validateSignature.status.trustIssues', 'Needs Attention: Trust/Chain'),
+ details: issues,
+ };
+ }
+
+ return {
+ kind: 'warning',
+ label: t('validateSignature.status.needsAttention', 'Needs Attention'),
+ details: issues,
+ };
+};
+
+export const statusKindToPdfColor = (kind: SignatureStatusKind) => {
+ switch (kind) {
+ case 'valid':
+ return colorPalette.success;
+ case 'warning':
+ return colorPalette.warning;
+ case 'invalid':
+ return colorPalette.danger;
+ default:
+ return colorPalette.neutral;
+ }
+};
+
+
diff --git a/frontend/src/hooks/tools/validateSignature/utils/signatureUtils.ts b/frontend/src/hooks/tools/validateSignature/utils/signatureUtils.ts
new file mode 100644
index 0000000000..7600b1297c
--- /dev/null
+++ b/frontend/src/hooks/tools/validateSignature/utils/signatureUtils.ts
@@ -0,0 +1,78 @@
+import { SignatureValidationBackendResult, SignatureValidationSignature } from '../../../../types/validateSignature';
+import type { StirlingFile } from '../../../../types/fileContext';
+
+export const RESULT_JSON_FILENAME = 'signature-validation.json';
+export const CSV_FILENAME = 'signature-validation.csv';
+export const REPORT_PDF_FILENAME = 'signature-validation-report.pdf';
+
+export const coerceString = (value: string | number | null | undefined): string => {
+ if (value === null || value === undefined) {
+ return '';
+ }
+ return String(value);
+};
+
+export const coerceNumber = (value: number | string | null | undefined): number | null => {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === 'string') {
+ const parsed = parseInt(value, 10);
+ return Number.isNaN(parsed) ? null : parsed;
+ }
+ return null;
+};
+
+export const escapeCsvValue = (raw: string): string => {
+ let value = raw ?? '';
+ value = value.replace(/\r?\n|\r/g, ' ');
+ if (value.includes('"')) {
+ value = value.replace(/"/g, '""');
+ }
+ if (value.includes(',') || value.includes('"') || value.includes(';')) {
+ value = `"${value}"`;
+ }
+ return value;
+};
+
+export const booleanToString = (value: boolean | null | undefined): string => {
+ if (value === null || value === undefined) {
+ return '';
+ }
+ return value ? 'true' : 'false';
+};
+
+export const keyUsagesToString = (keyUsages: string[] | undefined): string => {
+ if (!keyUsages || keyUsages.length === 0) {
+ return '';
+ }
+ return keyUsages.join('; ');
+};
+
+export const normalizeBackendResult = (
+ item: SignatureValidationBackendResult,
+ stirlingFile: StirlingFile,
+ index: number
+): SignatureValidationSignature => ({
+ id: `${stirlingFile.fileId}-${index}`,
+ valid: Boolean(item.valid),
+ chainValid: Boolean(item.chainValid),
+ trustValid: Boolean(item.trustValid),
+ notExpired: Boolean(item.notExpired),
+ notRevoked: Boolean(item.notRevoked),
+ signerName: coerceString(item.signerName),
+ signatureDate: coerceString(item.signatureDate),
+ reason: coerceString(item.reason),
+ location: coerceString(item.location),
+ issuerDN: coerceString(item.issuerDN),
+ subjectDN: coerceString(item.subjectDN),
+ serialNumber: coerceString(item.serialNumber),
+ validFrom: coerceString(item.validFrom),
+ validUntil: coerceString(item.validUntil),
+ signatureAlgorithm: coerceString(item.signatureAlgorithm),
+ keySize: coerceNumber(item.keySize),
+ version: coerceString(item.version),
+ keyUsages: Array.isArray(item.keyUsages) ? item.keyUsages.filter(Boolean).map(coerceString) : [],
+ selfSigned: Boolean(item.selfSigned),
+ errorMessage: item.errorMessage ? coerceString(item.errorMessage) : null,
+});
diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css
index e15e4e26d8..932fbf2838 100644
--- a/frontend/src/styles/theme.css
+++ b/frontend/src/styles/theme.css
@@ -261,6 +261,29 @@
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.08);
--modal-content-bg: #ffffff;
--modal-header-border: rgba(0, 0, 0, 0.06);
+
+ /* PDF Report Colors (always light) */
+ --pdf-light-header-bg: 239 246 255;
+ --pdf-light-accent: 59 130 246;
+ --pdf-light-text-primary: 30 41 59;
+ --pdf-light-text-muted: 100 116 139;
+ --pdf-light-box-bg: 248 250 252;
+ --pdf-light-box-border: 226 232 240;
+ --pdf-light-warning: 234 179 8;
+ --pdf-light-danger: 248 113 113;
+ --pdf-light-success: 34 197 94;
+ --pdf-light-neutral: 148 163 184;
+ --pdf-light-status-valid-bg: 209 250 229;
+ --pdf-light-status-valid-text: 6 95 70;
+ --pdf-light-status-warning-bg: 254 243 199;
+ --pdf-light-status-warning-text: 146 64 14;
+ --pdf-light-status-invalid-bg: 254 226 226;
+ --pdf-light-status-invalid-text: 153 27 27;
+ --pdf-light-status-neutral-bg: 229 231 235;
+ --pdf-light-status-neutral-text: 55 65 81;
+ --pdf-light-report-container-bg: 249 250 251;
+ --pdf-light-simulated-page-bg: 255 255 255;
+ --pdf-light-simulated-page-text: 15 23 42;
}
[data-mantine-color-scheme="dark"] {
diff --git a/frontend/src/tools/ValidateSignature.tsx b/frontend/src/tools/ValidateSignature.tsx
new file mode 100644
index 0000000000..0932d85551
--- /dev/null
+++ b/frontend/src/tools/ValidateSignature.tsx
@@ -0,0 +1,163 @@
+import { useEffect, useMemo, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
+import { createToolFlow } from '../components/tools/shared/createToolFlow';
+import { useBaseTool } from '../hooks/tools/shared/useBaseTool';
+import { BaseToolProps, ToolComponent } from '../types/tool';
+import { useValidateSignatureParameters, defaultParameters } from '../hooks/tools/validateSignature/useValidateSignatureParameters';
+import ValidateSignatureSettings from '../components/tools/validateSignature/ValidateSignatureSettings';
+import ValidateSignatureResults from '../components/tools/validateSignature/ValidateSignatureResults';
+import { useValidateSignatureOperation, ValidateSignatureOperationHook } from '../hooks/tools/validateSignature/useValidateSignatureOperation';
+import ValidateSignatureReportView from '../components/tools/validateSignature/ValidateSignatureReportView';
+import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
+import { useNavigationActions, useNavigationState } from '../contexts/NavigationContext';
+import type { SignatureValidationReportData } from '../types/validateSignature';
+
+const ValidateSignature = (props: BaseToolProps) => {
+ const { t } = useTranslation();
+ const { actions: navigationActions } = useNavigationActions();
+ const navigationState = useNavigationState();
+ const {
+ registerCustomWorkbenchView,
+ unregisterCustomWorkbenchView,
+ setCustomWorkbenchViewData,
+ clearCustomWorkbenchViewData,
+ } = useToolWorkflow();
+
+ const REPORT_VIEW_ID = 'validateSignatureReport';
+ const REPORT_WORKBENCH_ID = 'custom:validateSignatureReport' as const;
+ const reportIcon = useMemo(() => , []);
+
+ const base = useBaseTool(
+ 'validateSignature',
+ useValidateSignatureParameters,
+ useValidateSignatureOperation,
+ props
+ );
+
+ const operation = base.operation as ValidateSignatureOperationHook;
+ const hasResults = operation.results.length > 0;
+ const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage;
+
+ useEffect(() => {
+ registerCustomWorkbenchView({
+ id: REPORT_VIEW_ID,
+ workbenchId: REPORT_WORKBENCH_ID,
+ label: t('validateSignature.report.shortTitle', 'Signature Report'),
+ icon: reportIcon,
+ component: ValidateSignatureReportView,
+ });
+
+ return () => {
+ clearCustomWorkbenchViewData(REPORT_VIEW_ID);
+ unregisterCustomWorkbenchView(REPORT_VIEW_ID);
+ };
+ }, [
+ clearCustomWorkbenchViewData,
+ registerCustomWorkbenchView,
+ reportIcon,
+ t,
+ unregisterCustomWorkbenchView,
+ ]);
+
+ const reportData = useMemo(() => {
+ if (operation.results.length === 0) {
+ return null;
+ }
+
+ const generatedAt = operation.results[0].summaryGeneratedAt ?? Date.now();
+
+ return {
+ generatedAt,
+ entries: operation.results,
+ };
+ }, [operation.results]);
+
+ // Track last time we auto-navigated to the report so we don't override
+ // the user's manual tab change. Only navigate when a new report is generated.
+ const lastReportGeneratedAtRef = useRef(null);
+
+ useEffect(() => {
+ if (reportData) {
+ setCustomWorkbenchViewData(REPORT_VIEW_ID, reportData);
+
+ const generatedAt = reportData.generatedAt ?? null;
+ const isNewReport = generatedAt && generatedAt !== lastReportGeneratedAtRef.current;
+
+ if (isNewReport) {
+ lastReportGeneratedAtRef.current = generatedAt;
+ if (navigationState.selectedTool === 'validateSignature' && navigationState.workbench !== REPORT_WORKBENCH_ID) {
+ navigationActions.setWorkbench(REPORT_WORKBENCH_ID);
+ }
+ }
+ } else {
+ clearCustomWorkbenchViewData(REPORT_VIEW_ID);
+ lastReportGeneratedAtRef.current = null;
+ }
+ }, [
+ clearCustomWorkbenchViewData,
+ navigationActions,
+ navigationState.selectedTool,
+ navigationState.workbench,
+ reportData,
+ setCustomWorkbenchViewData,
+ ]);
+
+ return createToolFlow({
+ files: {
+ selectedFiles: base.selectedFiles,
+ isCollapsed: hasResults,
+ },
+ steps: [
+ {
+ title: t('validateSignature.settings.title', 'Validation Settings'),
+ isCollapsed: base.settingsCollapsed,
+ onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
+ content: (
+
+ ),
+ },
+ {
+ title: t('validateSignature.results', 'Validation Results'),
+ isVisible: showResultsStep,
+ isCollapsed: false,
+ content: (
+
+ ),
+ },
+ ],
+ executeButton: {
+ text: t('validateSignature.submit', 'Validate Signatures'),
+ loadingText: t('loading', 'Loading...'),
+ onClick: base.handleExecute,
+ disabled:
+ !base.params.validateParameters() ||
+ !base.hasFiles ||
+ base.operation.isLoading ||
+ !base.endpointEnabled,
+ isVisible: true,
+ },
+ review: {
+ isVisible: false,
+ operation: base.operation,
+ title: t('validateSignature.results', 'Validation Results'),
+ onUndo: base.handleUndo,
+ },
+ });
+};
+
+const ValidateSignatureTool = ValidateSignature as ToolComponent;
+ValidateSignatureTool.tool = () => useValidateSignatureOperation;
+ValidateSignatureTool.getDefaultParameters = () => ({ ...defaultParameters });
+
+export default ValidateSignatureTool;
diff --git a/frontend/src/types/validateSignature.ts b/frontend/src/types/validateSignature.ts
new file mode 100644
index 0000000000..6d93b6612d
--- /dev/null
+++ b/frontend/src/types/validateSignature.ts
@@ -0,0 +1,69 @@
+export interface SignatureValidationBackendResult {
+ valid: boolean;
+ chainValid: boolean;
+ trustValid: boolean;
+ notExpired: boolean;
+ notRevoked: boolean;
+ signerName?: string | null;
+ signatureDate?: string | null;
+ reason?: string | null;
+ location?: string | null;
+ issuerDN?: string | null;
+ subjectDN?: string | null;
+ serialNumber?: string | null;
+ validFrom?: string | null;
+ validUntil?: string | null;
+ signatureAlgorithm?: string | null;
+ keySize?: number | string | null;
+ version?: string | number | null;
+ keyUsages?: string[] | null;
+ selfSigned?: boolean | null;
+ errorMessage?: string | null;
+}
+
+export interface SignatureValidationSignature {
+ id: string;
+ valid: boolean;
+ chainValid: boolean;
+ trustValid: boolean;
+ notExpired: boolean;
+ notRevoked: boolean;
+ signerName: string;
+ signatureDate: string;
+ reason: string;
+ location: string;
+ issuerDN: string;
+ subjectDN: string;
+ serialNumber: string;
+ validFrom: string;
+ validUntil: string;
+ signatureAlgorithm: string;
+ keySize: number | null;
+ version: string;
+ keyUsages: string[];
+ selfSigned: boolean;
+ errorMessage: string | null;
+}
+
+export interface SignatureValidationFileResult {
+ fileId: string;
+ fileName: string;
+ signatures: SignatureValidationSignature[];
+ error?: string | null;
+ fileSize?: number | null;
+ lastModified?: number | null;
+}
+
+export interface SignatureValidationReportEntry extends SignatureValidationFileResult {
+ thumbnailUrl?: string | null;
+ fileSize?: number | null;
+ lastModified?: number | null;
+ createdAtLabel?: string | null;
+ summaryGeneratedAt?: number | null;
+ statusText?: string | null;
+}
+
+export interface SignatureValidationReportData {
+ generatedAt: number;
+ entries: SignatureValidationReportEntry[];
+}
diff --git a/frontend/src/types/workbench.ts b/frontend/src/types/workbench.ts
index 37ff0a41fe..6fdcca8587 100644
--- a/frontend/src/types/workbench.ts
+++ b/frontend/src/types/workbench.ts
@@ -1,12 +1,21 @@
// Define workbench values once as source of truth
-const WORKBENCH_TYPES = ['viewer', 'pageEditor', 'fileEditor'] as const;
+export const BASE_WORKBENCH_TYPES = ['viewer', 'pageEditor', 'fileEditor'] as const;
-// Workbench types - how the user interacts with content
-export type WorkbenchType = typeof WORKBENCH_TYPES[number];
+export type BaseWorkbenchType = typeof BASE_WORKBENCH_TYPES[number];
+
+// Workbench types including custom views
+export type WorkbenchType = BaseWorkbenchType | `custom:${string}`;
export const getDefaultWorkbench = (): WorkbenchType => 'fileEditor';
// Type guard using the same source of truth
export const isValidWorkbench = (value: string): value is WorkbenchType => {
- return WORKBENCH_TYPES.includes(value as WorkbenchType);
+ if (BASE_WORKBENCH_TYPES.includes(value as BaseWorkbenchType)) {
+ return true;
+ }
+ return value.startsWith('custom:');
+};
+
+export const isBaseWorkbench = (value: WorkbenchType): value is BaseWorkbenchType => {
+ return BASE_WORKBENCH_TYPES.includes(value as BaseWorkbenchType);
};