import { useEffect, useCallback, useState, useRef } from 'react'; import { Modal, Stack, Text, Badge, Box, Alert } from '@mantine/core'; import { QRCodeSVG } from 'qrcode.react'; import { useTranslation } from 'react-i18next'; import { useAppConfig } from '@app/contexts/AppConfigContext'; import InfoRoundedIcon from '@mui/icons-material/InfoRounded'; import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded'; import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; import WarningRoundedIcon from '@mui/icons-material/WarningRounded'; import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; import { withBasePath } from '@app/constants/app'; import { convertImageToPdf, isImageFile } from '@app/utils/imageToPdfUtils'; interface MobileUploadModalProps { opened: boolean; onClose: () => void; onFilesReceived: (files: File[]) => void; } // Generate a cryptographically secure UUID v4-like session ID function generateSessionId(): string { // Use Web Crypto API for cryptographically secure random values const cryptoObj = typeof crypto !== 'undefined' ? crypto : (window as any).crypto; if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') { const bytes = new Uint8Array(16); cryptoObj.getRandomValues(bytes); // Set version (4) and variant bits per RFC 4122 bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 // Convert bytes to hex string in UUID format const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')); return [ hex.slice(0, 4).join(''), hex.slice(4, 6).join(''), hex.slice(6, 8).join(''), hex.slice(8, 10).join(''), hex.slice(10, 16).join(''), ].join('-'); } // If Web Crypto is not available, fail fast rather than using insecure randomness console.error('Web Crypto API not available. Cannot generate secure session ID.'); throw new Error('Web Crypto API not available. Cannot generate secure session ID.'); } interface SessionInfo { sessionId: string; createdAt: number; expiresAt: number; timeoutMs: number; } /** * MobileUploadModal * * Displays a QR code that mobile devices can scan to upload files via backend server. * Files are temporarily stored on server and retrieved by desktop. */ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: MobileUploadModalProps) { const { t } = useTranslation(); const { config } = useAppConfig(); const [sessionId, setSessionId] = useState(() => generateSessionId()); const [sessionInfo, setSessionInfo] = useState(null); const [filesReceived, setFilesReceived] = useState(0); const [error, setError] = useState(null); const [timeRemaining, setTimeRemaining] = useState(null); const [showExpiryWarning, setShowExpiryWarning] = useState(false); const pollIntervalRef = useRef(null); const timerIntervalRef = useRef(null); const processedFiles = useRef>(new Set()); // Use configured frontendUrl if set, otherwise use current origin // Combine with base path and mobile-scanner route const frontendUrl = config?.frontendUrl || window.location.origin; const mobileUrl = `${frontendUrl}${withBasePath('/mobile-scanner')}?session=${sessionId}`; // Create session on backend const createSession = useCallback(async (newSessionId: string) => { try { const response = await fetch(`/api/v1/mobile-scanner/create-session/${newSessionId}`, { method: 'POST' }); if (!response.ok) { throw new Error('Failed to create session'); } const data = await response.json(); setSessionInfo(data); setError(null); console.log('Session created:', data); } catch (err) { console.error('Failed to create session:', err); setError(t('mobileUpload.sessionCreateError', 'Failed to create session')); } }, [t]); // Regenerate session (when expired or warned) const regenerateSession = useCallback(() => { const newSessionId = generateSessionId(); setSessionId(newSessionId); setShowExpiryWarning(false); setFilesReceived(0); processedFiles.current.clear(); createSession(newSessionId); }, [createSession]); const pollForFiles = useCallback(async () => { if (!opened) return; try { const response = await fetch(`/api/v1/mobile-scanner/files/${sessionId}`); if (!response.ok) { throw new Error('Failed to check for files'); } const data = await response.json(); const files = data.files || []; // Download only files we haven't processed yet const newFiles = files.filter((f: any) => !processedFiles.current.has(f.filename)); if (newFiles.length > 0) { for (const fileMetadata of newFiles) { try { const downloadResponse = await fetch( `/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}` ); if (downloadResponse.ok) { const blob = await downloadResponse.blob(); let file = new File([blob], fileMetadata.filename, { type: fileMetadata.contentType || 'image/jpeg' }); // Convert images to PDF if enabled if (isImageFile(file) && config?.mobileScannerConvertToPdf !== false) { try { file = await convertImageToPdf(file, { imageResolution: config?.mobileScannerImageResolution as 'full' | 'reduced' | undefined, pageFormat: config?.mobileScannerPageFormat as 'keep' | 'A4' | 'letter' | undefined, stretchToFit: config?.mobileScannerStretchToFit, }); console.log('Converted image to PDF:', file.name); } catch (convertError) { console.warn('Failed to convert image to PDF, using original file:', convertError); // Continue with original image file if conversion fails } } processedFiles.current.add(fileMetadata.filename); setFilesReceived((prev) => prev + 1); onFilesReceived([file]); } } catch (err) { console.error('Failed to download file:', fileMetadata.filename, err); } } // Delete the entire session immediately after downloading all files // This ensures files are only on server for ~1 second try { await fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' }); console.log('Session cleaned up after file download'); } catch (cleanupErr) { console.warn('Failed to cleanup session after download:', cleanupErr); } } } catch (err) { console.error('Error polling for files:', err); setError(t('mobileUpload.pollingError', 'Error checking for files')); } }, [opened, sessionId, onFilesReceived, t]); // Create session when modal opens useEffect(() => { if (opened) { createSession(sessionId); setFilesReceived(0); setError(null); setShowExpiryWarning(false); processedFiles.current.clear(); } else { // Clean up session when modal closes if (sessionId) { fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' }) .catch(err => console.warn('Failed to cleanup session on close:', err)); } } }, [opened]); // Only run when opened changes // Start polling for files when modal opens useEffect(() => { if (opened && sessionInfo) { // Poll every 2 seconds pollIntervalRef.current = window.setInterval(pollForFiles, 2000); // Initial poll pollForFiles(); } else { // Stop polling when modal closes if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } } return () => { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); } }; }, [opened, sessionInfo, pollForFiles]); // Session timeout timer useEffect(() => { if (!opened || !sessionInfo) return; const updateTimer = () => { const now = Date.now(); const remaining = sessionInfo.expiresAt - now; if (remaining <= 0) { // Session expired - regenerate setShowExpiryWarning(false); regenerateSession(); } else if (remaining <= 60000 && !showExpiryWarning) { // Less than 1 minute remaining - show warning setShowExpiryWarning(true); } setTimeRemaining(Math.max(0, remaining)); }; // Update immediately updateTimer(); // Update every second timerIntervalRef.current = window.setInterval(updateTimer, 1000); return () => { if (timerIntervalRef.current) { clearInterval(timerIntervalRef.current); } }; }, [opened, sessionInfo, showExpiryWarning, regenerateSession]); return ( } color="blue" variant="light" > {config?.mobileScannerConvertToPdf !== false ? t( 'mobileUpload.description', 'Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.' ) : t( 'mobileUpload.descriptionNoConvert', 'Scan this QR code with your mobile device to upload photos.' )} {showExpiryWarning && timeRemaining !== null && ( } title={t('mobileUpload.expiryWarning', 'Session Expiring Soon')} color="orange" > {t( 'mobileUpload.expiryWarningMessage', 'This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.', { seconds: Math.ceil(timeRemaining / 1000) } )} )} {error && ( } title={t('mobileUpload.error', 'Connection Error')} color="red" > {error} )} {filesReceived > 0 && ( }> {t('mobileUpload.filesReceived', '{{count}} file(s) received', { count: filesReceived })} )} {config?.mobileScannerConvertToPdf !== false ? t( 'mobileUpload.instructions', 'Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.' ) : t( 'mobileUpload.instructionsNoConvert', 'Open the camera app on your phone and scan this code. Files will be uploaded through the server.' )} {mobileUrl} ); }