added antivirus and new sanitization for inputs

This commit is contained in:
laxmanhalaki 2026-02-24 19:47:06 +05:30
parent dfe94555ab
commit 32a486d6f4
7 changed files with 337 additions and 45 deletions

View File

@ -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<AntivirusScanStatusProps> = ({
scanResult,
compact = false,
className = '',
}) => {
const color = getStatusColor(scanResult);
const icon = getStatusIcon(scanResult);
const label = getStatusLabel(scanResult);
// Compact mode: just a badge
if (compact) {
return (
<span
className={className}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: 500,
backgroundColor: `${color}15`,
color,
border: `1px solid ${color}30`,
}}
title={label}
>
<span style={{ fontSize: '11px' }}>{icon}</span>
{label}
</span>
);
}
// Full mode: detailed card
return (
<div
className={className}
style={{
border: `1px solid ${color}30`,
borderRadius: '8px',
padding: '12px 16px',
backgroundColor: `${color}08`,
fontSize: '13px',
}}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span style={{ fontSize: '16px' }}>{icon}</span>
<span style={{ fontWeight: 600, color }}>{label}</span>
{scanResult?.malwareScan?.scanDuration && (
<span style={{ marginLeft: 'auto', fontSize: '11px', color: '#94a3b8' }}>
{scanResult.malwareScan.scanDuration}ms
</span>
)}
</div>
{/* Details */}
{scanResult && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{/* ClamAV Result */}
{scanResult.malwareScan?.scanned && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#64748b' }}>
<span>🦠</span>
<span>
ClamAV:{' '}
{scanResult.malwareScan.isInfected
? `Infected — ${scanResult.malwareScan.virusNames?.join(', ')}`
: 'Clean'}
</span>
</div>
)}
{/* XSS Result */}
{scanResult.contentScan?.scanned && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#64748b' }}>
<span>🔍</span>
<span>
Content scan ({scanResult.contentScan.scanType}):{' '}
{scanResult.contentScan.safe
? `Safe — ${scanResult.contentScan.patternsChecked} patterns checked`
: `${scanResult.contentScan.threats?.length || 0} threats found (${scanResult.contentScan.severity})`}
</span>
</div>
)}
{/* Threats list */}
{scanResult.contentScan?.threats && scanResult.contentScan.threats.length > 0 && (
<ul style={{ margin: '4px 0 0 24px', padding: 0, fontSize: '11px', color: '#ef4444' }}>
{scanResult.contentScan.threats.slice(0, 5).map((threat, i) => (
<li key={i}>
{threat.description} ({threat.severity})
</li>
))}
{scanResult.contentScan.threats.length > 5 && (
<li>and {scanResult.contentScan.threats.length - 5} more</li>
)}
</ul>
)}
{/* Scan event ID */}
{scanResult.scanEventId && (
<div style={{ fontSize: '10px', color: '#94a3b8', marginTop: '4px' }}>
Scan ID: {scanResult.scanEventId}
</div>
)}
</div>
)}
</div>
);
};
export default AntivirusScanStatus;

View File

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

View File

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

View File

@ -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<HTMLInputElement>) => {
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 = '';

View File

@ -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({
<div>
<label className="text-xs sm:text-sm font-medium text-gray-700 block mb-2">Description</label>
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-300">
<FormattedDescription
content={request.description || ''}
<FormattedDescription
content={request.description || ''}
className="text-xs sm:text-sm"
/>
</div>
@ -184,17 +184,20 @@ export function OverviewTab({
{pauseInfo.pauseReason && (
<div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label>
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pauseReason}</p>
<FormattedDescription
content={pauseInfo.pauseReason}
className="text-sm text-gray-900 mt-1"
/>
</div>
)}
{pauseInfo.pausedBy && (
<div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused By</label>
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pausedBy.name || pauseInfo.pausedBy.email}</p>
</div>
)}
{pauseInfo.pauseResumeDate && (
<div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Auto-Resume Date</label>
@ -208,7 +211,7 @@ export function OverviewTab({
</p>
</div>
)}
{pauseInfo.pausedAt && (
<div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused At</label>
@ -289,8 +292,8 @@ export function OverviewTab({
<div className="pt-4 border-t border-gray-300">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Request Description</label>
<div className="mt-2 bg-gray-50 p-3 rounded-lg">
<FormattedDescription
content={request.claimDetails.requestDescription}
<FormattedDescription
content={request.claimDetails.requestDescription}
className="text-sm"
/>
</div>
@ -312,8 +315,8 @@ export function OverviewTab({
</CardHeader>
<CardContent className="pt-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<FormattedDescription
content={request.conclusionRemark || ''}
<FormattedDescription
content={request.conclusionRemark || ''}
className="text-sm"
/>
</div>
@ -331,23 +334,20 @@ export function OverviewTab({
{/* Conclusion Remark Section */}
{needsClosure && (
<Card data-testid="conclusion-remark-card">
<CardHeader className={`bg-gradient-to-r border-b ${
request.status === 'rejected'
? 'from-red-50 to-rose-50 border-red-200'
<CardHeader className={`bg-gradient-to-r border-b ${request.status === 'rejected'
? 'from-red-50 to-rose-50 border-red-200'
: 'from-green-50 to-emerald-50 border-green-200'
}`}>
}`}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${
request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
}`}>
<CheckCircle className={`w-5 h-5 ${
request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
}`} />
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
}`}>
<CheckCircle className={`w-5 h-5 ${request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
}`} />
Conclusion Remark - Final Step
</CardTitle>
<CardDescription className="mt-1 text-xs sm:text-sm">
{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.'}
</CardDescription>
@ -365,7 +365,7 @@ export function OverviewTab({
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button>
{aiGenerated && !maxAttemptsReached && !generationFailed && (
<span className="text-[10px] text-gray-500 font-medium px-1">
<span className="text-[10px] text-gray-500 font-medium px-1">
{2 - generationAttempts} attempts remaining
</span>
)}

View File

@ -92,8 +92,8 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
</div>
{summary.description && (
<div className="mb-4">
<FormattedDescription
content={summary.description}
<FormattedDescription
content={summary.description}
className="text-gray-700"
/>
</div>
@ -163,7 +163,14 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Remarks</p>
<p className="text-sm text-gray-700">{approver.remarks || '—'}</p>
{approver.remarks ? (
<FormattedDescription
content={approver.remarks}
className="text-sm text-gray-700"
/>
) : (
<p className="text-sm text-gray-700"></p>
)}
</div>
</div>
))}
@ -199,8 +206,8 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
<div>
<p className="text-xs text-gray-500 mb-1">Remarks</p>
{summary.closingRemarks ? (
<FormattedDescription
content={summary.closingRemarks}
<FormattedDescription
content={summary.closingRemarks}
className="text-sm text-gray-700"
/>
) : (

View File

@ -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<SecurityErrorCode, string> = {
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;
}