From 538782eec6b3ac2385a8ee2e4e6b3e3d7e8f4c74 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Tue, 14 Oct 2025 17:57:58 +0100 Subject: [PATCH] V2 Validate PDF Signature tool --- frontend/src/components/layout/Workbench.tsx | 16 +- frontend/src/components/shared/RightRail.tsx | 7 +- .../src/components/shared/TopControls.tsx | 33 ++- .../tools/toolPicker/FavoriteStar.tsx | 7 + .../ValidateSignatureReportView.tsx | 145 +++++++++++ .../ValidateSignatureResults.tsx | 230 +++++++++++++++++ .../ValidateSignatureSettings.tsx | 67 +++++ .../reportView/FieldBlock.tsx | 23 ++ .../reportView/FileSummaryHeader.tsx | 47 ++++ .../reportView/SignatureSection.tsx | 78 ++++++ .../reportView/SignatureStatusBadge.tsx | 40 +++ .../reportView/ThumbnailPreview.tsx | 31 +++ .../validateSignature/reportView/styles.css | 111 +++++++++ frontend/src/contexts/ToolWorkflowContext.tsx | 122 ++++++++- .../src/data/useTranslatedToolRegistry.tsx | 5 +- .../useRemoveAnnotationsOperation.ts | 5 +- .../CenteredMessageSection.ts | 68 ++++++ .../outputtedPDFSections/FieldBoxSection.ts | 70 ++++++ .../outputtedPDFSections/SignatureSection.ts | 173 +++++++++++++ .../StatusBadgeSection.ts | 38 +++ .../outputtedPDFSections/SummarySection.ts | 145 +++++++++++ .../outputtedPDFSections/ThumbnailSection.ts | 55 +++++ .../validateSignature/signatureReportPdf.ts | 139 +++++++++++ .../useValidateSignatureOperation.ts | 231 ++++++++++++++++++ .../useValidateSignatureParameters.ts | 18 ++ .../validateSignature/utils/pdfPageHelpers.ts | 101 ++++++++ .../validateSignature/utils/pdfPalette.ts | 60 +++++ .../tools/validateSignature/utils/pdfText.ts | 51 ++++ .../validateSignature/utils/reportStatus.ts | 38 +++ .../validateSignature/utils/signatureCsv.ts | 95 +++++++ .../utils/signatureReportBuilder.ts | 46 ++++ .../utils/signatureStatus.ts | 114 +++++++++ .../validateSignature/utils/signatureUtils.ts | 78 ++++++ frontend/src/styles/theme.css | 23 ++ frontend/src/tools/ValidateSignature.tsx | 163 ++++++++++++ frontend/src/types/validateSignature.ts | 69 ++++++ frontend/src/types/workbench.ts | 17 +- 37 files changed, 2735 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/tools/validateSignature/ValidateSignatureReportView.tsx create mode 100644 frontend/src/components/tools/validateSignature/ValidateSignatureResults.tsx create mode 100644 frontend/src/components/tools/validateSignature/ValidateSignatureSettings.tsx create mode 100644 frontend/src/components/tools/validateSignature/reportView/FieldBlock.tsx create mode 100644 frontend/src/components/tools/validateSignature/reportView/FileSummaryHeader.tsx create mode 100644 frontend/src/components/tools/validateSignature/reportView/SignatureSection.tsx create mode 100644 frontend/src/components/tools/validateSignature/reportView/SignatureStatusBadge.tsx create mode 100644 frontend/src/components/tools/validateSignature/reportView/ThumbnailPreview.tsx create mode 100644 frontend/src/components/tools/validateSignature/reportView/styles.css create mode 100644 frontend/src/hooks/tools/validateSignature/outputtedPDFSections/CenteredMessageSection.ts create mode 100644 frontend/src/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection.ts create mode 100644 frontend/src/hooks/tools/validateSignature/outputtedPDFSections/SignatureSection.ts create mode 100644 frontend/src/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection.ts create mode 100644 frontend/src/hooks/tools/validateSignature/outputtedPDFSections/SummarySection.ts create mode 100644 frontend/src/hooks/tools/validateSignature/outputtedPDFSections/ThumbnailSection.ts create mode 100644 frontend/src/hooks/tools/validateSignature/signatureReportPdf.ts create mode 100644 frontend/src/hooks/tools/validateSignature/useValidateSignatureOperation.ts create mode 100644 frontend/src/hooks/tools/validateSignature/useValidateSignatureParameters.ts create mode 100644 frontend/src/hooks/tools/validateSignature/utils/pdfPageHelpers.ts create mode 100644 frontend/src/hooks/tools/validateSignature/utils/pdfPalette.ts create mode 100644 frontend/src/hooks/tools/validateSignature/utils/pdfText.ts create mode 100644 frontend/src/hooks/tools/validateSignature/utils/reportStatus.ts create mode 100644 frontend/src/hooks/tools/validateSignature/utils/signatureCsv.ts create mode 100644 frontend/src/hooks/tools/validateSignature/utils/signatureReportBuilder.ts create mode 100644 frontend/src/hooks/tools/validateSignature/utils/signatureStatus.ts create mode 100644 frontend/src/hooks/tools/validateSignature/utils/signatureUtils.ts create mode 100644 frontend/src/tools/ValidateSignature.tsx create mode 100644 frontend/src/types/validateSignature.ts diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index f0f9a542ea..b50cf9559c 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -4,6 +4,7 @@ import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import { useFileHandler } from '../../hooks/useFileHandler'; import { useFileState } from '../../contexts/FileContext'; import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext'; +import { isBaseWorkbench } from '../../types/workbench'; import './Workbench.css'; import TopControls from '../shared/TopControls'; @@ -31,7 +32,8 @@ export default function Workbench() { sidebarsVisible, setPreviewFile, setPageEditorFunctions, - setSidebarsVisible + setSidebarsVisible, + customWorkbenchViews, } = useToolWorkflow(); const { handleToolSelect } = useToolWorkflow(); @@ -130,9 +132,14 @@ export default function Workbench() { ); default: - return ( - - ); + if (!isBaseWorkbench(currentView)) { + const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null); + if (customView) { + const CustomComponent = customView.component; + return ; + } + } + return ; } }; @@ -150,6 +157,7 @@ export default function Workbench() { )} diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index 44af1e215b..858e24f536 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -43,6 +43,7 @@ export default function RightRail() { // Navigation view const { workbench: currentView } = useNavigationState(); + const isCustomWorkbench = typeof currentView === 'string' && currentView.startsWith('custom:'); // File state and selection const { state, selectors } = useFileState(); @@ -183,7 +184,7 @@ export default function RightRail() { return (
- {topButtons.length > 0 && ( + {topButtons.length > 0 && !isCustomWorkbench && ( <>
{topButtons.map(btn => ( @@ -205,6 +206,7 @@ export default function RightRail() { )} {/* Group: PDF Viewer Controls - visible only in viewer mode */} + {!isCustomWorkbench && (
+ )} {/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */} + {!isCustomWorkbench && (
+ )} {/* Theme toggle and Language dropdown */}
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 ( +
+ {`${fileName} +
+ ); + } + + 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); };