added antivirus and new sanitization for inputs
This commit is contained in:
parent
dfe94555ab
commit
32a486d6f4
194
src/components/common/AntivirusScanStatus.tsx
Normal file
194
src/components/common/AntivirusScanStatus.tsx
Normal 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;
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -201,8 +202,10 @@ 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);
|
||||
|
||||
|
||||
@ -184,7 +184,10 @@ 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>
|
||||
)}
|
||||
|
||||
@ -331,19 +334,16 @@ export function OverviewTab({
|
||||
{/* Conclusion Remark Section */}
|
||||
{needsClosure && (
|
||||
<Card data-testid="conclusion-remark-card">
|
||||
<CardHeader className={`bg-gradient-to-r border-b ${
|
||||
request.status === 'rejected'
|
||||
<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">
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
82
src/utils/securityToast.ts
Normal file
82
src/utils/securityToast.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user