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 { CustomDatePicker } from '@/components/ui/date-picker';
|
||||||
import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2, Eye, Download, IndianRupee } from 'lucide-react';
|
import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2, Eye, Download, IndianRupee } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { handleSecurityError } from '@/utils/securityToast';
|
||||||
import '@/components/common/FilePreview/FilePreview.css';
|
import '@/components/common/FilePreview/FilePreview.css';
|
||||||
import './DealerCompletionDocumentsModal.css';
|
import './DealerCompletionDocumentsModal.css';
|
||||||
import { validateHSNSAC } from '@/utils/validationUtils';
|
import { validateHSNSAC } from '@/utils/validationUtils';
|
||||||
@ -553,7 +554,9 @@ export function DealerCompletionDocumentsModal({
|
|||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to submit completion documents:', 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 {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { CustomDatePicker } from '@/components/ui/date-picker';
|
import { CustomDatePicker } from '@/components/ui/date-picker';
|
||||||
import { Upload, Plus, Minus, X, Calendar, IndianRupee, CircleAlert, CheckCircle2, FileText, Eye, Download } from 'lucide-react';
|
import { Upload, Plus, Minus, X, Calendar, IndianRupee, CircleAlert, CheckCircle2, FileText, Eye, Download } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { handleSecurityError } from '@/utils/securityToast';
|
||||||
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
|
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
|
||||||
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
|
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
|
||||||
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
|
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
|
||||||
@ -485,7 +486,9 @@ export function DealerProposalSubmissionModal({
|
|||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to submit proposal:', 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 {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { uploadDocument } from '@/services/documentApi';
|
import { uploadDocument } from '@/services/documentApi';
|
||||||
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { handleSecurityError } from '@/utils/securityToast';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom Hook: useDocumentUpload
|
* Custom Hook: useDocumentUpload
|
||||||
@ -26,7 +27,7 @@ export function useDocumentUpload(
|
|||||||
) {
|
) {
|
||||||
// State: Indicates if document is currently being uploaded
|
// State: Indicates if document is currently being uploaded
|
||||||
const [uploadingDocument, setUploadingDocument] = useState(false);
|
const [uploadingDocument, setUploadingDocument] = useState(false);
|
||||||
|
|
||||||
// State: Stores document for preview modal
|
// State: Stores document for preview modal
|
||||||
const [previewDocument, setPreviewDocument] = useState<{
|
const [previewDocument, setPreviewDocument] = useState<{
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@ -101,7 +102,7 @@ export function useDocumentUpload(
|
|||||||
// Check file extension
|
// Check file extension
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
|
||||||
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
@ -130,12 +131,12 @@ export function useDocumentUpload(
|
|||||||
*/
|
*/
|
||||||
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
|
|
||||||
// Validate: Check if file is selected
|
// Validate: Check if file is selected
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
|
|
||||||
// Validate all files against document policy
|
// Validate all files against document policy
|
||||||
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
||||||
const validFiles: File[] = [];
|
const validFiles: File[] = [];
|
||||||
@ -169,11 +170,11 @@ export function useDocumentUpload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUploadingDocument(true);
|
setUploadingDocument(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload only the first valid file (backend currently supports single file)
|
// Upload only the first valid file (backend currently supports single file)
|
||||||
const file = validFiles[0];
|
const file = validFiles[0];
|
||||||
|
|
||||||
// Validate: Ensure request ID is available
|
// Validate: Ensure request ID is available
|
||||||
// Note: Backend requires UUID, not request number
|
// Note: Backend requires UUID, not request number
|
||||||
const requestId = apiRequest?.requestId;
|
const requestId = apiRequest?.requestId;
|
||||||
@ -181,17 +182,17 @@ export function useDocumentUpload(
|
|||||||
toast.error('Request ID not found');
|
toast.error('Request ID not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Call: Upload document to backend
|
// API Call: Upload document to backend
|
||||||
// Document type is 'SUPPORTING' (as opposed to 'REQUIRED')
|
// Document type is 'SUPPORTING' (as opposed to 'REQUIRED')
|
||||||
if (file) {
|
if (file) {
|
||||||
await uploadDocument(file, requestId, 'SUPPORTING');
|
await uploadDocument(file, requestId, 'SUPPORTING');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh: Reload request details to show newly uploaded document
|
// Refresh: Reload request details to show newly uploaded document
|
||||||
// This also updates the activity timeline
|
// This also updates the activity timeline
|
||||||
await refreshDetails();
|
await refreshDetails();
|
||||||
|
|
||||||
// Success feedback
|
// Success feedback
|
||||||
if (validFiles.length < fileArray.length) {
|
if (validFiles.length < fileArray.length) {
|
||||||
toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`);
|
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) {
|
} catch (error: any) {
|
||||||
console.error('[useDocumentUpload] Upload error:', error);
|
console.error('[useDocumentUpload] Upload error:', error);
|
||||||
|
|
||||||
// Error feedback with backend error message if available
|
// Show security-specific red toast for scan errors, or generic error toast
|
||||||
toast.error(error?.response?.data?.error || 'Failed to upload document');
|
if (!handleSecurityError(error)) {
|
||||||
|
toast.error(error?.response?.data?.message || 'Failed to upload document');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingDocument(false);
|
setUploadingDocument(false);
|
||||||
|
|
||||||
// Cleanup: Clear the file input to allow re-uploading same file
|
// Cleanup: Clear the file input to allow re-uploading same file
|
||||||
if (event.target) {
|
if (event.target) {
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
|
|||||||
@ -64,10 +64,10 @@ export function OverviewTab({
|
|||||||
const isPaused = pauseInfo?.isPaused || false;
|
const isPaused = pauseInfo?.isPaused || false;
|
||||||
const pausedByUserId = pauseInfo?.pausedBy?.userId;
|
const pausedByUserId = pauseInfo?.pausedBy?.userId;
|
||||||
const currentUserId = (user as any)?.userId || '';
|
const currentUserId = (user as any)?.userId || '';
|
||||||
|
|
||||||
// Resume: Can be done by both initiator and approver
|
// Resume: Can be done by both initiator and approver
|
||||||
const canResume = isPaused && onResume && (currentUserIsApprover || isInitiator);
|
const canResume = isPaused && onResume && (currentUserIsApprover || isInitiator);
|
||||||
|
|
||||||
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
|
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
|
||||||
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
|
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
|
||||||
|
|
||||||
@ -122,8 +122,8 @@ export function OverviewTab({
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-xs sm:text-sm font-medium text-gray-700 block mb-2">Description</label>
|
<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">
|
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-300">
|
||||||
<FormattedDescription
|
<FormattedDescription
|
||||||
content={request.description || ''}
|
content={request.description || ''}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -184,17 +184,20 @@ export function OverviewTab({
|
|||||||
{pauseInfo.pauseReason && (
|
{pauseInfo.pauseReason && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pauseInfo.pausedBy && (
|
{pauseInfo.pausedBy && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused By</label>
|
<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>
|
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pausedBy.name || pauseInfo.pausedBy.email}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pauseInfo.pauseResumeDate && (
|
{pauseInfo.pauseResumeDate && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Auto-Resume Date</label>
|
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Auto-Resume Date</label>
|
||||||
@ -208,7 +211,7 @@ export function OverviewTab({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pauseInfo.pausedAt && (
|
{pauseInfo.pausedAt && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused At</label>
|
<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">
|
<div className="pt-4 border-t border-gray-300">
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Request Description</label>
|
<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">
|
<div className="mt-2 bg-gray-50 p-3 rounded-lg">
|
||||||
<FormattedDescription
|
<FormattedDescription
|
||||||
content={request.claimDetails.requestDescription}
|
content={request.claimDetails.requestDescription}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -312,8 +315,8 @@ export function OverviewTab({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
<FormattedDescription
|
<FormattedDescription
|
||||||
content={request.conclusionRemark || ''}
|
content={request.conclusionRemark || ''}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -331,23 +334,20 @@ export function OverviewTab({
|
|||||||
{/* Conclusion Remark Section */}
|
{/* Conclusion Remark Section */}
|
||||||
{needsClosure && (
|
{needsClosure && (
|
||||||
<Card data-testid="conclusion-remark-card">
|
<Card data-testid="conclusion-remark-card">
|
||||||
<CardHeader className={`bg-gradient-to-r border-b ${
|
<CardHeader className={`bg-gradient-to-r border-b ${request.status === 'rejected'
|
||||||
request.status === 'rejected'
|
? 'from-red-50 to-rose-50 border-red-200'
|
||||||
? 'from-red-50 to-rose-50 border-red-200'
|
|
||||||
: 'from-green-50 to-emerald-50 border-green-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 className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${
|
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
|
||||||
request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
|
}`}>
|
||||||
}`}>
|
<CheckCircle className={`w-5 h-5 ${request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
|
||||||
<CheckCircle className={`w-5 h-5 ${
|
}`} />
|
||||||
request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
|
|
||||||
}`} />
|
|
||||||
Conclusion Remark - Final Step
|
Conclusion Remark - Final Step
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1 text-xs sm:text-sm">
|
<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.'
|
? '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.'}
|
: 'All approvals are complete. Please review and finalize the conclusion to close this request.'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@ -365,7 +365,7 @@ export function OverviewTab({
|
|||||||
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
|
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
|
||||||
</Button>
|
</Button>
|
||||||
{aiGenerated && !maxAttemptsReached && !generationFailed && (
|
{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
|
{2 - generationAttempts} attempts remaining
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -92,8 +92,8 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
|
|||||||
</div>
|
</div>
|
||||||
{summary.description && (
|
{summary.description && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<FormattedDescription
|
<FormattedDescription
|
||||||
content={summary.description}
|
content={summary.description}
|
||||||
className="text-gray-700"
|
className="text-gray-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -163,7 +163,14 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -199,8 +206,8 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
||||||
{summary.closingRemarks ? (
|
{summary.closingRemarks ? (
|
||||||
<FormattedDescription
|
<FormattedDescription
|
||||||
content={summary.closingRemarks}
|
content={summary.closingRemarks}
|
||||||
className="text-sm text-gray-700"
|
className="text-sm text-gray-700"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
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