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 <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
EthanHealy01
2025-10-16 13:45:59 +01:00
committed by GitHub
parent f6a7b983a0
commit a8573c99b7
46 changed files with 5101 additions and 265 deletions

View File

@@ -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 };

View File

@@ -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' }}>

View File

@@ -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"

View File

@@ -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')}
>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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%);
}