mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
V2 Validate PDF Signature tool (#4679)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
parent
f6a7b983a0
commit
a8573c99b7
@@ -5,6 +5,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 { useViewer } from '../../contexts/ViewerContext';
|
||||
import './Workbench.css';
|
||||
|
||||
@@ -33,7 +34,8 @@ export default function Workbench() {
|
||||
sidebarsVisible,
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSidebarsVisible
|
||||
setSidebarsVisible,
|
||||
customWorkbenchViews,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { handleToolSelect } = useToolWorkflow();
|
||||
@@ -137,9 +139,14 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<LandingPage/>
|
||||
);
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
if (customView) {
|
||||
const CustomComponent = customView.component;
|
||||
return <CustomComponent data={customView.data} />;
|
||||
}
|
||||
}
|
||||
return <LandingPage />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,6 +164,7 @@ export default function Workbench() {
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
customViews={customWorkbenchViews}
|
||||
activeFiles={activeFiles.map(f => {
|
||||
const stub = selectors.getStirlingFileStub(f.fileId);
|
||||
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
|
||||
|
||||
@@ -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 (
|
||||
<div ref={sidebarRefs.rightRailRef} className={`right-rail`} data-sidebar="right-rail">
|
||||
<div className="right-rail-inner">
|
||||
{topButtons.length > 0 && (
|
||||
{topButtons.length > 0 && !isCustomWorkbench && (
|
||||
<>
|
||||
<div className="right-rail-section">
|
||||
{topButtons.map(btn => (
|
||||
@@ -205,6 +206,7 @@ export default function RightRail() {
|
||||
)}
|
||||
|
||||
{/* Group: PDF Viewer Controls - visible only in viewer mode */}
|
||||
{!isCustomWorkbench && (
|
||||
<div
|
||||
className={`right-rail-slot ${currentView === 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
aria-hidden={currentView !== 'viewer'}
|
||||
@@ -308,8 +310,10 @@ export default function RightRail() {
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
||||
{!isCustomWorkbench && (
|
||||
<div
|
||||
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
aria-hidden={currentView === 'viewer'}
|
||||
@@ -447,6 +451,7 @@ export default function RightRail() {
|
||||
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme toggle and Language dropdown */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
|
||||
@@ -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';
|
||||
import { FileDropdownMenu } from './FileDropdownMenu';
|
||||
|
||||
|
||||
@@ -25,7 +27,8 @@ const createViewOptions = (
|
||||
switchingTo: WorkbenchType | null,
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
|
||||
currentFileIndex: number,
|
||||
onFileSelect?: (index: number) => void
|
||||
onFileSelect?: (index: number) => void,
|
||||
customViews?: CustomWorkbenchViewInstance[]
|
||||
) => {
|
||||
const currentFile = activeFiles[currentFileIndex];
|
||||
const isInViewer = currentView === 'viewer';
|
||||
@@ -95,17 +98,35 @@ const createViewOptions = (
|
||||
value: "fileEditor",
|
||||
};
|
||||
|
||||
// Build options array conditionally
|
||||
return [
|
||||
const baseOptions = [
|
||||
viewerOption,
|
||||
pageEditorOption,
|
||||
fileEditorOption,
|
||||
];
|
||||
|
||||
const customOptions = (customViews ?? [])
|
||||
.filter((view) => view.data != null)
|
||||
.map((view) => ({
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{switchingTo === view.workbenchId ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
view.icon || <PictureAsPdfIcon fontSize="small" />
|
||||
)}
|
||||
<span>{view.label}</span>
|
||||
</div>
|
||||
),
|
||||
value: view.workbenchId,
|
||||
}));
|
||||
|
||||
return [...baseOptions, ...customOptions];
|
||||
};
|
||||
|
||||
interface TopControlsProps {
|
||||
currentView: WorkbenchType;
|
||||
setCurrentView: (view: WorkbenchType) => void;
|
||||
customViews?: CustomWorkbenchViewInstance[];
|
||||
activeFiles?: Array<{ fileId: string; name: string; versionNumber?: number }>;
|
||||
currentFileIndex?: number;
|
||||
onFileSelect?: (index: number) => void;
|
||||
@@ -114,6 +135,7 @@ interface TopControlsProps {
|
||||
const TopControls = ({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
customViews = [],
|
||||
activeFiles = [],
|
||||
currentFileIndex = 0,
|
||||
onFileSelect,
|
||||
@@ -147,7 +169,7 @@ const TopControls = ({
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="flex justify-center mt-[0.5rem]">
|
||||
<SegmentedControl
|
||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect)}
|
||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
|
||||
@@ -17,6 +17,7 @@ const FavoriteStar: React.FC<FavoriteStarProps> = ({ isFavorite, onToggle, class
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
variant="subtle"
|
||||
radius="xl"
|
||||
size={size}
|
||||
@@ -24,6 +25,12 @@ const FavoriteStar: React.FC<FavoriteStarProps> = ({ 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')}
|
||||
>
|
||||
|
||||
@@ -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 }) => (
|
||||
<Stack align="center" justify="center" gap="xs" style={{ minHeight: 360, width: '100%' }}>
|
||||
<Badge color="gray" variant="light" size="lg" style={{ textTransform: 'uppercase' }}>
|
||||
{label}
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed" style={{ textAlign: 'center' }}>
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const ValidateSignatureReportView: React.FC<ValidateSignatureReportViewProps> = ({ 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 (
|
||||
<div className="report-container">
|
||||
<Stack gap="xl" align="center">
|
||||
<Stack gap="xs" align="center">
|
||||
<Badge size="lg" color="blue" variant="light">
|
||||
{t('validateSignature.report.title', 'Signature Validation Report')}
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('validateSignature.report.generatedAt', 'Generated')}{' '}
|
||||
{new Date(data.generatedAt).toLocaleString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{pages.map((pageDef, index) => (
|
||||
<div className="simulated-page" key={`${pageDef.entry.fileId}-${index}`}>
|
||||
<Stack gap="lg" style={{ flex: 1 }}>
|
||||
{pageDef.includeSummary && (
|
||||
<>
|
||||
<Group align="flex-start" gap="lg">
|
||||
<ThumbnailPreview
|
||||
thumbnailUrl={pageDef.entry.thumbnailUrl}
|
||||
fileName={pageDef.entry.fileName}
|
||||
/>
|
||||
<Stack gap="sm" style={{ flex: 1 }}>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={700} size="xl" style={{ lineHeight: 1.1 }}>
|
||||
{pageDef.entry.fileName}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('validateSignature.report.entryLabel', 'Signature Summary')}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge color="gray" variant="light">
|
||||
{t('validateSignature.report.page', 'Page')} {index + 1}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<FileSummaryHeader
|
||||
fileSize={pageDef.entry.fileSize}
|
||||
createdAt={pageDef.entry.createdAtLabel ?? null}
|
||||
totalSignatures={pageDef.entry.signatures.length}
|
||||
lastSignatureDate={pageDef.entry.signatures[0]?.signatureDate}
|
||||
/>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{pageDef.entry.error ? (
|
||||
<NoSignatureSection
|
||||
message={pageDef.entry.error}
|
||||
label={t('validateSignature.status.invalid', 'Invalid')}
|
||||
/>
|
||||
) : pageDef.entry.signatures.length === 0 ? (
|
||||
<NoSignatureSection
|
||||
message={t(
|
||||
'validateSignature.noSignatures',
|
||||
'No digital signatures found in this document'
|
||||
)}
|
||||
label={noSignaturesLabel}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="xl">
|
||||
{pageDef.signatureIndex === null ? null : (
|
||||
<SignatureSection
|
||||
signature={pageDef.entry.signatures[pageDef.signatureIndex]}
|
||||
index={pageDef.signatureIndex}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group justify="space-between" align="center" mt="auto" pt="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('validateSignature.report.footer', 'Validated via Stirling PDF')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('validateSignature.report.page', 'Page')} {index + 1} / {pages.length}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidateSignatureReportView;
|
||||
@@ -0,0 +1,223 @@
|
||||
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';
|
||||
import FitText from '../../shared/FitText';
|
||||
import { SuggestedToolsSection } from '../shared/SuggestedToolsSection';
|
||||
|
||||
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 isValid = signature.valid;
|
||||
if (isValid) {
|
||||
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 (
|
||||
<Group justify="center" gap="sm" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text>{t('validateSignature.processing', 'Validating signatures...')}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoading && results.length === 0) {
|
||||
return (
|
||||
<Alert color="gray" variant="light" title={t('validateSignature.results', 'Validation Results')}>
|
||||
<Text size="sm">{t('validateSignature.noResults', 'Run the validation to generate a report.')}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* 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 && (
|
||||
<Group justify="center" gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm">{t('validateSignature.finalizing', 'Preparing downloads...')}</Text>
|
||||
</Group>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Alert color="yellow" variant="light">
|
||||
<Text size="sm">{errorMessage}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group gap="sm">
|
||||
<Badge color="blue" variant="light">
|
||||
{t('validateSignature.report.filesEvaluated', '{{count}} files evaluated', {
|
||||
count: summary.fileCount,
|
||||
})}
|
||||
</Badge>
|
||||
<Badge color="teal" variant="light">
|
||||
{t('validateSignature.report.signaturesFound', '{{count}} signatures detected', {
|
||||
count: summary.signatureCount,
|
||||
})}
|
||||
</Badge>
|
||||
{summary.signatureCount > 0 && (
|
||||
<Badge color="green" variant="light">
|
||||
{t('validateSignature.report.signaturesValid', '{{count}} fully valid', {
|
||||
count: summary.fullyValidCount,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm" style={{ maxHeight: '20rem', overflowY: 'auto' }}>
|
||||
{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.invalid', 'Invalid')
|
||||
: 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 (
|
||||
<Stack key={result.fileId} gap={4} p="xs" style={{ borderLeft: '2px solid var(--mantine-color-gray-4)' }}>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<FitText text={result.fileName} lines={2} as="div" minimumFontScale={0.5} style={{ fontWeight: 600 }} />
|
||||
</div>
|
||||
<Badge className={badgeClass} variant="light">
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('validateSignature.report.signatureCountLabel', '{{count}} signatures', {
|
||||
count: result.signatures.length,
|
||||
})}
|
||||
</Text>
|
||||
{!result.error && result.signatures.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('validateSignature.noSignatures', 'No digital signatures found in this document')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('validateSignature.report.downloads', 'Downloads')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={selectedType}
|
||||
onChange={(v) => setSelectedType(v as 'pdf' | 'csv' | 'json')}
|
||||
data={downloadTypeOptions}
|
||||
/>
|
||||
<Button
|
||||
color="blue"
|
||||
onClick={() => selectedFile && handleDownload(selectedFile)}
|
||||
disabled={!selectedFile}
|
||||
fullWidth
|
||||
>
|
||||
{selectedDownloadLabel}
|
||||
</Button>
|
||||
{selectedType === 'pdf' && !pdfFile && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('validateSignature.report.noPdf', 'PDF report will be available after a successful validation.')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<SuggestedToolsSection />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidateSignatureResults;
|
||||
@@ -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: <K extends keyof ValidateSignatureParameters>(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 (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<Text fw={600}>{t('validateSignature.selectCustomCert', 'Custom Certificate File X.509 (Optional)')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'validateSignature.settings.certHint',
|
||||
'Upload a trusted X.509 certificate to validate against a custom trust source.'
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Group align="center" gap="sm">
|
||||
<FileUploadButton
|
||||
file={certFile ?? undefined}
|
||||
onChange={handleCertFileChange}
|
||||
accept=".cer,.crt,.pem,.der"
|
||||
disabled={disabled}
|
||||
variant="filled"
|
||||
/>
|
||||
{certFile && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => handleCertFileChange(null)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('sign.clear', 'Clear')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{certFile && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('size', 'Size')}: {Math.round(certFile.size / 1024)} KB
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidateSignatureSettings;
|
||||
@@ -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 (
|
||||
<div className="field-container" key={label}>
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: 0.6 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<div className="field-value">
|
||||
<Text size="sm" fw={500} style={{ lineHeight: 1.35, whiteSpace: 'pre-wrap' }}>
|
||||
{displayValue}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldBlock;
|
||||
@@ -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 <div className="grid-container">{infoBlocks}</div>;
|
||||
};
|
||||
|
||||
export default FileSummaryHeader;
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap="md" key={signature.id}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="lg">
|
||||
{t('validateSignature.signature._value', 'Signature')} {index + 1}
|
||||
</Text>
|
||||
<SignatureStatusBadge signature={signature} />
|
||||
</Group>
|
||||
{signature.errorMessage && (
|
||||
<Text c="red" size="sm">{signature.errorMessage}</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<div className="grid-container">{signatureFields}</div>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Text fw={600} size="sm" c="dimmed" tt="uppercase" style={{ letterSpacing: 0.8 }}>
|
||||
{t('validateSignature.cert.details', 'Certificate Details')}
|
||||
</Text>
|
||||
<div className="grid-container">{certificateFields}</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignatureSection;
|
||||
@@ -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 (
|
||||
<Popover withinPortal position="bottom" withArrow shadow="md" disabled={status.details.length === 0}>
|
||||
<Popover.Target>
|
||||
<Badge className={classMap[status.kind]} variant="light" style={{ cursor: status.details.length ? 'pointer' : 'default' }}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</Popover.Target>
|
||||
{status.details.length > 0 && (
|
||||
<Popover.Dropdown>
|
||||
<Text size="sm" fw={600} mb={4}>{t('details', 'Details')}</Text>
|
||||
{status.details.map((d, i) => (
|
||||
<Text size="sm" key={i}>
|
||||
- {d}
|
||||
</Text>
|
||||
))}
|
||||
</Popover.Dropdown>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignatureStatusBadge;
|
||||
|
||||
@@ -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 (
|
||||
<div className="thumbnail-container">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={`${fileName} thumbnail`}
|
||||
className="thumbnail-image"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="thumbnail-placeholder">
|
||||
<PictureAsPdfIcon fontSize="large" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThumbnailPreview;
|
||||
@@ -0,0 +1,105 @@
|
||||
.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/Page Editor background */
|
||||
background: var(--bg-background) !important;
|
||||
padding: 32px 24px 48px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 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%);
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import { PageEditorFunctions } from '../types/pageEditor';
|
||||
import { ToolRegistryEntry, ToolRegistry } from '../data/toolsTaxonomy';
|
||||
import { useNavigationActions, useNavigationState } from './NavigationContext';
|
||||
import { ToolId, isValidToolId } from '../types/toolId';
|
||||
import { WorkbenchType, getDefaultWorkbench, isBaseWorkbench } from '../types/workbench';
|
||||
import { useNavigationUrlSync } from '../hooks/useUrlSync';
|
||||
import { getDefaultWorkbench } 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<ToolWorkflowContextValue | undefined>(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<ToolWorkflowContextValue | undefined> | undefined;
|
||||
const ToolWorkflowContext = existingContext ?? createContext<ToolWorkflowContextValue | undefined>(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<Record<string, () => void>>({});
|
||||
|
||||
const [customViewRegistry, setCustomViewRegistry] = React.useState<Record<string, CustomWorkbenchViewRegistration>>({});
|
||||
const [customViewData, setCustomViewData] = React.useState<Record<string, any>>({});
|
||||
|
||||
// Navigation actions and state are available since we're inside NavigationProvider
|
||||
const { actions } = useNavigationActions();
|
||||
const navigationState = useNavigationState();
|
||||
@@ -137,6 +164,73 @@ 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<CustomWorkbenchViewInstance[]>(() => {
|
||||
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]);
|
||||
|
||||
// Persisted via PreferencesContext; no direct localStorage writes needed here
|
||||
|
||||
// Keep tool panel mode in sync with user preference. This ensures the
|
||||
// Config setting (Default tool picker mode) immediately affects the app
|
||||
// and persists across reloads.
|
||||
@@ -165,11 +259,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;
|
||||
}
|
||||
@@ -179,7 +277,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setReaderMode(false);
|
||||
setLeftPanelView('hidden');
|
||||
actions.setSelectedTool('multiTool');
|
||||
actions.setWorkbench('pageEditor');
|
||||
actions.setWorkbench(wasInCustomWorkbench ? getDefaultWorkbench() : 'pageEditor');
|
||||
setSearchQuery('');
|
||||
return;
|
||||
}
|
||||
@@ -190,7 +288,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());
|
||||
@@ -200,7 +300,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');
|
||||
@@ -269,6 +369,13 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
favoriteTools,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
|
||||
// Custom workbench views
|
||||
customWorkbenchViews,
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
}), [
|
||||
state,
|
||||
navigationState.selectedTool,
|
||||
@@ -293,6 +400,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
favoriteTools,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
customWorkbenchViews,
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,6 +26,7 @@ export type ToolWorkflowAction =
|
||||
| { type: 'SET_SEARCH_QUERY'; payload: string }
|
||||
| { type: 'RESET_UI_STATE' };
|
||||
|
||||
|
||||
export const baseState: Omit<ToolWorkflowState, 'toolPanelMode'> = {
|
||||
sidebarsVisible: true,
|
||||
leftPanelView: 'toolPicker',
|
||||
|
||||
@@ -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: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
|
||||
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
|
||||
},
|
||||
|
||||
@@ -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<File[]> => {
|
||||
// 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) {
|
||||
|
||||
+68
@@ -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<typeof rgb>;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<typeof rgb>;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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<number> => {
|
||||
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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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<File> => {
|
||||
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' });
|
||||
};
|
||||
@@ -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<ValidateSignatureParameters> {
|
||||
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<string | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [downloadFilename, setDownloadFilename] = useState('');
|
||||
const [results, setResults] = useState<SignatureValidationReportEntry[]>([]);
|
||||
|
||||
const cancelRequested = useRef(false);
|
||||
const previousUrl = useRef<string | null>(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<ValidateSignatureOperationHook>(
|
||||
() => ({
|
||||
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,
|
||||
]
|
||||
);
|
||||
};
|
||||
@@ -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<ValidateSignatureParameters>;
|
||||
|
||||
export const useValidateSignatureParameters = (): ValidateSignatureParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'validate-signature',
|
||||
});
|
||||
};
|
||||
@@ -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<string, { image: PDFImage } | null>();
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { SignatureValidationReportEntry } from '../../../../types/validateSignature';
|
||||
import { colorPalette } from './pdfPalette';
|
||||
|
||||
export const deriveEntryStatus = (
|
||||
entry: Pick<SignatureValidationReportEntry, 'error' | 'signatures'>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// File-level status is Valid only if every signature is cryptographically valid.
|
||||
const allValid = entry.signatures.every((sig) => sig.valid);
|
||||
|
||||
if (allValid) {
|
||||
return {
|
||||
text: t('validateSignature.status.valid', 'Valid'),
|
||||
color: colorPalette.success,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: t('validateSignature.status.invalid', 'Invalid'),
|
||||
color: colorPalette.danger,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
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',
|
||||
'revocationChecked',
|
||||
'revocationStatus',
|
||||
'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.revocationChecked),
|
||||
signature.revocationStatus || '',
|
||||
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;' });
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
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'));
|
||||
}
|
||||
|
||||
// Use revocationStatus from backend; default to 'unknown' when absent
|
||||
const revStatus = signature.revocationStatus ?? 'unknown';
|
||||
if (revStatus === 'revoked') {
|
||||
trustIssues.push(t('validateSignature.issue.certRevoked', 'Certificate revoked'));
|
||||
} else if (revStatus === 'soft-fail') {
|
||||
trustIssues.push(t('validateSignature.issue.certRevocationUnknown', 'Certificate revocation status unknown'));
|
||||
}
|
||||
|
||||
// Aggregate all issues for details UI (ignore missing metadata fields; they are optional)
|
||||
issues.push(...trustIssues);
|
||||
|
||||
// If cryptographic validation failed, mark as Invalid
|
||||
if (!signature.valid) {
|
||||
return {
|
||||
kind: 'invalid',
|
||||
label: t('validateSignature.status.invalid', 'Invalid'),
|
||||
details: issues,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, mark as Valid regardless of optional field presence and trust warnings
|
||||
return {
|
||||
kind: 'valid',
|
||||
label: t('validateSignature.status.valid', 'Valid'),
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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),
|
||||
revocationChecked:
|
||||
item.revocationChecked === null || item.revocationChecked === undefined
|
||||
? null
|
||||
: Boolean(item.revocationChecked),
|
||||
revocationStatus: item.revocationStatus ? coerceString(item.revocationStatus) : null,
|
||||
validationTimeSource: item.validationTimeSource ? coerceString(item.validationTimeSource) : null,
|
||||
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,
|
||||
});
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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(() => <PictureAsPdfIcon fontSize="small" />, []);
|
||||
|
||||
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<SignatureValidationReportData | null>(() => {
|
||||
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<number | null>(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: (
|
||||
<ValidateSignatureSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.operation.isLoading || base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('validateSignature.results', 'Validation Results'),
|
||||
isVisible: showResultsStep,
|
||||
isCollapsed: false,
|
||||
content: (
|
||||
<ValidateSignatureResults
|
||||
operation={operation}
|
||||
results={operation.results}
|
||||
isLoading={base.operation.isLoading}
|
||||
errorMessage={base.operation.errorMessage}
|
||||
reportAvailable={Boolean(reportData)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
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;
|
||||
@@ -0,0 +1,77 @@
|
||||
export interface SignatureValidationBackendResult {
|
||||
valid: boolean;
|
||||
chainValid: boolean;
|
||||
trustValid: boolean;
|
||||
chainValidationError?: string | null;
|
||||
certPathLength?: number | null;
|
||||
notExpired: boolean;
|
||||
revocationChecked?: boolean | null;
|
||||
revocationStatus?: string | null; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
|
||||
validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp"
|
||||
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;
|
||||
chainValidationError?: string | null;
|
||||
certPathLength?: number | null;
|
||||
notExpired: boolean;
|
||||
revocationChecked?: boolean | null;
|
||||
revocationStatus?: string | null; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
|
||||
validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp"
|
||||
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[];
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user