From 32a486d6f425d957d7082f3a02b47b9e3578c817 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Tue, 24 Feb 2026 19:47:06 +0530 Subject: [PATCH] added antivirus and new sanitization for inputs --- src/components/common/AntivirusScanStatus.tsx | 194 ++++++++++++++++++ .../modals/DealerCompletionDocumentsModal.tsx | 5 +- .../modals/DealerProposalSubmissionModal.tsx | 5 +- src/hooks/useDocumentUpload.ts | 31 +-- .../components/tabs/OverviewTab.tsx | 48 ++--- .../components/tabs/SummaryTab.tsx | 17 +- src/utils/securityToast.ts | 82 ++++++++ 7 files changed, 337 insertions(+), 45 deletions(-) create mode 100644 src/components/common/AntivirusScanStatus.tsx create mode 100644 src/utils/securityToast.ts diff --git a/src/components/common/AntivirusScanStatus.tsx b/src/components/common/AntivirusScanStatus.tsx new file mode 100644 index 0000000..b96c944 --- /dev/null +++ b/src/components/common/AntivirusScanStatus.tsx @@ -0,0 +1,194 @@ +/** + * AntivirusScanStatus Component + * Displays the antivirus scan result badge/status for uploaded files. + * Shows ClamAV scan result and XSS content scan result. + */ + +import React from 'react'; + +// ── Types ── + +export interface ScanResultData { + malwareScan?: { + scanned: boolean; + isInfected: boolean; + skipped?: boolean; + virusNames?: string[]; + scanDuration?: number; + error?: string; + }; + contentScan?: { + scanned: boolean; + safe: boolean; + scanType: string; + severity: 'SAFE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + threats?: Array<{ description: string; severity: string }>; + patternsChecked: number; + }; + scanEventId?: string; +} + +interface AntivirusScanStatusProps { + scanResult?: ScanResultData; + compact?: boolean; + className?: string; +} + +// ── Helpers ── + +function getStatusColor(result?: ScanResultData): string { + if (!result) return '#94a3b8'; // gray — no scan data + + // Check malware first + if (result.malwareScan?.isInfected) return '#ef4444'; // red + if (result.malwareScan?.error) return '#f59e0b'; // amber + + // Then XSS + if (result.contentScan && !result.contentScan.safe) { + if (result.contentScan.severity === 'CRITICAL') return '#ef4444'; + if (result.contentScan.severity === 'HIGH') return '#ef4444'; + if (result.contentScan.severity === 'MEDIUM') return '#f59e0b'; + return '#f59e0b'; + } + + // Skipped + if (result.malwareScan?.skipped) return '#94a3b8'; + + return '#22c55e'; // green — all clear +} + +function getStatusIcon(result?: ScanResultData): string { + if (!result) return '⏳'; + if (result.malwareScan?.isInfected) return '🛑'; + if (result.contentScan && !result.contentScan.safe) return '⚠️'; + if (result.malwareScan?.skipped) return '⏭️'; + if (result.malwareScan?.error) return '❌'; + if (result.malwareScan?.scanned && result.contentScan?.scanned) return '✅'; + return '⏳'; +} + +function getStatusLabel(result?: ScanResultData): string { + if (!result) return 'Pending scan'; + if (result.malwareScan?.isInfected) return 'Malware detected'; + if (result.contentScan && !result.contentScan.safe) return 'Content threat detected'; + if (result.malwareScan?.skipped) return 'Scan skipped'; + if (result.malwareScan?.error) return 'Scan error'; + if (result.malwareScan?.scanned && result.contentScan?.scanned) return 'Clean'; + return 'Scanning…'; +} + +// ── Component ── + +const AntivirusScanStatus: React.FC = ({ + scanResult, + compact = false, + className = '', +}) => { + const color = getStatusColor(scanResult); + const icon = getStatusIcon(scanResult); + const label = getStatusLabel(scanResult); + + // Compact mode: just a badge + if (compact) { + return ( + + {icon} + {label} + + ); + } + + // Full mode: detailed card + return ( +
+ {/* Header */} +
+ {icon} + {label} + {scanResult?.malwareScan?.scanDuration && ( + + {scanResult.malwareScan.scanDuration}ms + + )} +
+ + {/* Details */} + {scanResult && ( +
+ {/* ClamAV Result */} + {scanResult.malwareScan?.scanned && ( +
+ 🦠 + + ClamAV:{' '} + {scanResult.malwareScan.isInfected + ? `Infected — ${scanResult.malwareScan.virusNames?.join(', ')}` + : 'Clean'} + +
+ )} + + {/* XSS Result */} + {scanResult.contentScan?.scanned && ( +
+ 🔍 + + Content scan ({scanResult.contentScan.scanType}):{' '} + {scanResult.contentScan.safe + ? `Safe — ${scanResult.contentScan.patternsChecked} patterns checked` + : `${scanResult.contentScan.threats?.length || 0} threats found (${scanResult.contentScan.severity})`} + +
+ )} + + {/* Threats list */} + {scanResult.contentScan?.threats && scanResult.contentScan.threats.length > 0 && ( +
    + {scanResult.contentScan.threats.slice(0, 5).map((threat, i) => ( +
  • + {threat.description} ({threat.severity}) +
  • + ))} + {scanResult.contentScan.threats.length > 5 && ( +
  • …and {scanResult.contentScan.threats.length - 5} more
  • + )} +
+ )} + + {/* Scan event ID */} + {scanResult.scanEventId && ( +
+ Scan ID: {scanResult.scanEventId} +
+ )} +
+ )} +
+ ); +}; + +export default AntivirusScanStatus; diff --git a/src/dealer-claim/components/request-detail/modals/DealerCompletionDocumentsModal.tsx b/src/dealer-claim/components/request-detail/modals/DealerCompletionDocumentsModal.tsx index dcaa94f..42a5ed9 100644 --- a/src/dealer-claim/components/request-detail/modals/DealerCompletionDocumentsModal.tsx +++ b/src/dealer-claim/components/request-detail/modals/DealerCompletionDocumentsModal.tsx @@ -21,6 +21,7 @@ import { Badge } from '@/components/ui/badge'; import { CustomDatePicker } from '@/components/ui/date-picker'; import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2, Eye, Download, IndianRupee } from 'lucide-react'; import { toast } from 'sonner'; +import { handleSecurityError } from '@/utils/securityToast'; import '@/components/common/FilePreview/FilePreview.css'; import './DealerCompletionDocumentsModal.css'; import { validateHSNSAC } from '@/utils/validationUtils'; @@ -553,7 +554,9 @@ export function DealerCompletionDocumentsModal({ onClose(); } catch (error) { console.error('Failed to submit completion documents:', error); - toast.error('Failed to submit completion documents. Please try again.'); + if (!handleSecurityError(error)) { + toast.error('Failed to submit completion documents. Please try again.'); + } } finally { setSubmitting(false); } diff --git a/src/dealer-claim/components/request-detail/modals/DealerProposalSubmissionModal.tsx b/src/dealer-claim/components/request-detail/modals/DealerProposalSubmissionModal.tsx index b0cca76..0a61e94 100644 --- a/src/dealer-claim/components/request-detail/modals/DealerProposalSubmissionModal.tsx +++ b/src/dealer-claim/components/request-detail/modals/DealerProposalSubmissionModal.tsx @@ -21,6 +21,7 @@ import { Badge } from '@/components/ui/badge'; import { CustomDatePicker } from '@/components/ui/date-picker'; import { Upload, Plus, Minus, X, Calendar, IndianRupee, CircleAlert, CheckCircle2, FileText, Eye, Download } from 'lucide-react'; import { toast } from 'sonner'; +import { handleSecurityError } from '@/utils/securityToast'; import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard'; import { FilePreview } from '@/components/common/FilePreview/FilePreview'; import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi'; @@ -485,7 +486,9 @@ export function DealerProposalSubmissionModal({ onClose(); } catch (error) { console.error('Failed to submit proposal:', error); - toast.error('Failed to submit proposal. Please try again.'); + if (!handleSecurityError(error)) { + toast.error('Failed to submit proposal. Please try again.'); + } } finally { setSubmitting(false); } diff --git a/src/hooks/useDocumentUpload.ts b/src/hooks/useDocumentUpload.ts index e57ec80..e05cf92 100644 --- a/src/hooks/useDocumentUpload.ts +++ b/src/hooks/useDocumentUpload.ts @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { uploadDocument } from '@/services/documentApi'; import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi'; import { toast } from 'sonner'; +import { handleSecurityError } from '@/utils/securityToast'; /** * Custom Hook: useDocumentUpload @@ -26,7 +27,7 @@ export function useDocumentUpload( ) { // State: Indicates if document is currently being uploaded const [uploadingDocument, setUploadingDocument] = useState(false); - + // State: Stores document for preview modal const [previewDocument, setPreviewDocument] = useState<{ fileName: string; @@ -101,7 +102,7 @@ export function useDocumentUpload( // Check file extension const fileName = file.name.toLowerCase(); const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1); - + if (!documentPolicy.allowedFileTypes.includes(fileExtension)) { return { valid: false, @@ -130,12 +131,12 @@ export function useDocumentUpload( */ const handleDocumentUpload = async (event: React.ChangeEvent) => { const files = event.target.files; - + // Validate: Check if file is selected if (!files || files.length === 0) return; - + const fileArray = Array.from(files); - + // Validate all files against document policy const validationErrors: Array<{ fileName: string; reason: string }> = []; const validFiles: File[] = []; @@ -169,11 +170,11 @@ export function useDocumentUpload( } setUploadingDocument(true); - + try { // Upload only the first valid file (backend currently supports single file) const file = validFiles[0]; - + // Validate: Ensure request ID is available // Note: Backend requires UUID, not request number const requestId = apiRequest?.requestId; @@ -181,17 +182,17 @@ export function useDocumentUpload( toast.error('Request ID not found'); return; } - + // API Call: Upload document to backend // Document type is 'SUPPORTING' (as opposed to 'REQUIRED') if (file) { await uploadDocument(file, requestId, 'SUPPORTING'); } - + // Refresh: Reload request details to show newly uploaded document // This also updates the activity timeline await refreshDetails(); - + // Success feedback if (validFiles.length < fileArray.length) { toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`); @@ -200,12 +201,14 @@ export function useDocumentUpload( } } catch (error: any) { console.error('[useDocumentUpload] Upload error:', error); - - // Error feedback with backend error message if available - toast.error(error?.response?.data?.error || 'Failed to upload document'); + + // Show security-specific red toast for scan errors, or generic error toast + if (!handleSecurityError(error)) { + toast.error(error?.response?.data?.message || 'Failed to upload document'); + } } finally { setUploadingDocument(false); - + // Cleanup: Clear the file input to allow re-uploading same file if (event.target) { event.target.value = ''; diff --git a/src/pages/RequestDetail/components/tabs/OverviewTab.tsx b/src/pages/RequestDetail/components/tabs/OverviewTab.tsx index a1b95b7..97a3eb5 100644 --- a/src/pages/RequestDetail/components/tabs/OverviewTab.tsx +++ b/src/pages/RequestDetail/components/tabs/OverviewTab.tsx @@ -64,10 +64,10 @@ export function OverviewTab({ const isPaused = pauseInfo?.isPaused || false; const pausedByUserId = pauseInfo?.pausedBy?.userId; const currentUserId = (user as any)?.userId || ''; - + // Resume: Can be done by both initiator and approver const canResume = isPaused && onResume && (currentUserIsApprover || isInitiator); - + // Retrigger: Only for initiator when approver paused (initiator asks approver to resume) const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger; @@ -122,8 +122,8 @@ export function OverviewTab({
-
@@ -184,17 +184,20 @@ export function OverviewTab({ {pauseInfo.pauseReason && (
-

{pauseInfo.pauseReason}

+
)} - + {pauseInfo.pausedBy && (

{pauseInfo.pausedBy.name || pauseInfo.pausedBy.email}

)} - + {pauseInfo.pauseResumeDate && (
@@ -208,7 +211,7 @@ export function OverviewTab({

)} - + {pauseInfo.pausedAt && (
@@ -289,8 +292,8 @@ export function OverviewTab({
-
@@ -312,8 +315,8 @@ export function OverviewTab({
-
@@ -331,23 +334,20 @@ export function OverviewTab({ {/* Conclusion Remark Section */} {needsClosure && ( - + }`}>
- - + + Conclusion Remark - Final Step - {request.status === 'rejected' + {request.status === 'rejected' ? 'This request was rejected. Please review the AI-generated closure remark and finalize it to close this request.' : 'All approvals are complete. Please review and finalize the conclusion to close this request.'} @@ -365,7 +365,7 @@ export function OverviewTab({ {aiGenerated ? 'Regenerate' : 'Generate with AI'} {aiGenerated && !maxAttemptsReached && !generationFailed && ( - + {2 - generationAttempts} attempts remaining )} diff --git a/src/pages/RequestDetail/components/tabs/SummaryTab.tsx b/src/pages/RequestDetail/components/tabs/SummaryTab.tsx index b7c3adb..b4ad5e3 100644 --- a/src/pages/RequestDetail/components/tabs/SummaryTab.tsx +++ b/src/pages/RequestDetail/components/tabs/SummaryTab.tsx @@ -92,8 +92,8 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
{summary.description && (
-
@@ -163,7 +163,14 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa

Remarks

-

{approver.remarks || '—'}

+ {approver.remarks ? ( + + ) : ( +

+ )}
))} @@ -199,8 +206,8 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa

Remarks

{summary.closingRemarks ? ( - ) : ( diff --git a/src/utils/securityToast.ts b/src/utils/securityToast.ts new file mode 100644 index 0000000..1a53d69 --- /dev/null +++ b/src/utils/securityToast.ts @@ -0,0 +1,82 @@ +/** + * Security Toast Helper + * Shows distinct, styled toast messages for antivirus/security scan errors. + * Each error type (malware, file validation, XSS, scan unavailable) gets + * its own clear message so the user knows exactly what happened. + */ + +import { toast } from 'sonner'; + +// Security error codes returned by the backend +const SECURITY_ERROR_CODES = [ + 'MALWARE_DETECTED', + 'FILE_VALIDATION_FAILED', + 'CONTENT_THREAT_DETECTED', + 'SCAN_UNAVAILABLE', + 'SCAN_ERROR', +] as const; + +type SecurityErrorCode = typeof SECURITY_ERROR_CODES[number]; + +interface SecurityErrorResponse { + error?: string; + message?: string; + details?: { + errors?: string[]; + warnings?: string[]; + scanEngine?: string; + signatures?: string[]; + scanType?: string; + threats?: Array<{ description: string; severity: string }>; + }; + scanEventId?: string; +} + +// User-friendly titles for each error type +const ERROR_TITLES: Record = { + MALWARE_DETECTED: '🛑 Malware Detected', + FILE_VALIDATION_FAILED: '⛔ File Rejected', + CONTENT_THREAT_DETECTED: '⚠️ Malicious Content Detected', + SCAN_UNAVAILABLE: '🔒 Security Scan Unavailable', + SCAN_ERROR: '❌ Security Scan Error', +}; + +/** + * Check if an API error response is a security/scan error. + * Returns true if it was handled (showed a toast), false otherwise. + */ +export function handleSecurityError(error: any): boolean { + const responseData: SecurityErrorResponse = error?.response?.data; + if (!responseData?.error) return false; + + const errorCode = responseData.error as SecurityErrorCode; + if (!SECURITY_ERROR_CODES.includes(errorCode)) return false; + + const title = ERROR_TITLES[errorCode] || 'Security Error'; + const message = responseData.message || 'File was blocked by security scan'; + + // Build detail text + let detailText = ''; + if (responseData.details) { + if (responseData.details.signatures?.length) { + detailText = `Virus: ${responseData.details.signatures.join(', ')}`; + } else if (responseData.details.errors?.length) { + detailText = responseData.details.errors[0] || ''; + } else if (responseData.details.threats?.length) { + detailText = responseData.details.threats.map(t => t.description).join(', '); + } + } + + // Show custom styled toast + toast.error(title, { + description: detailText || message, + duration: 8000, + style: { + background: '#fef2f2', + border: '1px solid #fca5a5', + color: '#991b1b', + }, + }); + + return true; +}