import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Upload, HardDrive, Cloud, Server, Database, Image, FileArchive, Info, ChevronRight, ChevronLeft, Loader2, Shield, Download, Eye, Calendar, Clock, AlertCircle, ShieldCheck } from 'lucide-react'; import { toast } from 'react-toastify'; import { useQuery, useMutation } from '@tanstack/react-query'; import { Button, Card, Input, Loading } from '../common'; import { api } from '../../config/api'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; export const RestoreWizard = ({ onVerifyIntegrity } = {}) => { const { t } = useTranslation(); const { format: fmtDate, formatTime: fmtTime, formatDateTime: fmtDateTime } = useLocalizedDate(); const [currentStep, setCurrentStep] = useState(0); const steps = [ { id: 'source', title: t('backup.restore.steps.selectSource') }, { id: 'backup', title: t('backup.restore.steps.chooseBackup') }, { id: 'options', title: t('backup.restore.steps.restoreOptions') }, { id: 'confirm', title: t('backup.restore.steps.reviewConfirm') }, { id: 'progress', title: t('backup.restore.steps.progress') } ]; const restoreTypes = [ { id: 'full', name: t('backup.restore.restoreTypes.full.name'), description: t('backup.restore.restoreTypes.full.description'), icon: RefreshCw, warning: t('backup.restore.restoreTypes.full.warning') }, { id: 'database', name: t('backup.restore.restoreTypes.database.name'), description: t('backup.restore.restoreTypes.database.description'), icon: Database, warning: t('backup.restore.restoreTypes.database.warning') }, { id: 'files', name: t('backup.restore.restoreTypes.files.name'), description: t('backup.restore.restoreTypes.files.description'), icon: Image, warning: t('backup.restore.restoreTypes.files.warning') }, { id: 'selective', name: t('backup.restore.restoreTypes.selective.name'), description: t('backup.restore.restoreTypes.selective.description'), icon: CheckCircle, warning: t('backup.restore.restoreTypes.selective.warning') } ]; const [restoreData, setRestoreData] = useState({ source: null, sourceConfig: {}, selectedBackup: null, restoreType: 'full', selectedItems: [], skipPreBackup: false, force: false, encryptionPassphrase: '' }); const [validationResult, setValidationResult] = useState(null); // Fetch restore status const { data: restoreStatus } = useQuery({ queryKey: ['restore-status'], queryFn: async () => { const response = await api.get('/admin/restore/status'); return response.data.data; }, refetchInterval: currentStep === 4 ? 2000 : false // Poll during restore }); // Fetch available backups const { data: availableBackups, isLoading: loadingBackups } = useQuery({ queryKey: ['available-backups', restoreData.source, restoreData.sourceConfig], queryFn: async () => { const response = await api.post('/admin/restore/list-backups', { source: restoreData.source, ...restoreData.sourceConfig }); return response.data.data; }, enabled: currentStep === 1 && !!restoreData.source }); // Validate restore const validateMutation = useMutation({ mutationFn: async () => { const response = await api.post('/admin/restore/validate', { source: restoreData.source, manifestPath: restoreData.selectedBackup.manifest_path, restoreType: restoreData.restoreType, selectedItems: restoreData.selectedItems, ...restoreData.sourceConfig }); return response.data.data; }, onSuccess: (data) => { setValidationResult(data); setCurrentStep(3); }, onError: (error) => { toast.error(error.response?.data?.error || 'Validation failed'); } }); // Start restore const restoreMutation = useMutation({ mutationFn: async () => { const response = await api.post('/admin/restore/start', { source: restoreData.source, manifestPath: restoreData.selectedBackup.manifest_path, restoreType: restoreData.restoreType, selectedItems: restoreData.selectedItems, skipPreBackup: restoreData.skipPreBackup, force: restoreData.force, encryptionPassphrase: restoreData.encryptionPassphrase, ...restoreData.sourceConfig }); return response.data; }, onSuccess: () => { setCurrentStep(4); toast.success('Restore started successfully'); }, onError: (error) => { toast.error(error.response?.data?.error || 'Failed to start restore'); } }); const handleNext = () => { if (currentStep === 2) { // Validate before confirmation validateMutation.mutate(); } else if (currentStep === 3) { // Start restore restoreMutation.mutate(); } else { setCurrentStep(prev => Math.min(prev + 1, steps.length - 1)); } }; const handleBack = () => { setCurrentStep(prev => Math.max(prev - 1, 0)); }; const canProceed = () => { switch (currentStep) { case 0: return !!restoreData.source; case 1: return !!restoreData.selectedBackup; case 2: return !!restoreData.restoreType; case 3: return !!validationResult && !validateMutation.isLoading; default: return false; } }; // Step Components const renderSourceSelection = () => (

{t('backup.restore.source.title')}

{t('backup.restore.source.subtitle')}

{/* Source-specific configuration */} {restoreData.source === 's3' && (

{t('backup.restore.source.configuration.s3')}

setRestoreData(prev => ({ ...prev, sourceConfig: { ...prev.sourceConfig, s3Endpoint: e.target.value } }))} /> setRestoreData(prev => ({ ...prev, sourceConfig: { ...prev.sourceConfig, s3Bucket: e.target.value } }))} /> setRestoreData(prev => ({ ...prev, sourceConfig: { ...prev.sourceConfig, s3AccessKey: e.target.value } }))} /> setRestoreData(prev => ({ ...prev, sourceConfig: { ...prev.sourceConfig, s3SecretKey: e.target.value } }))} />
)} {restoreData.source === 'upload' && (

{t('backup.restore.source.upload.comingSoon')}

)}
); const renderBackupSelection = () => (

{t('backup.restore.backup.title')}

{t('backup.restore.backup.subtitle')}

{loadingBackups ? ( ) : availableBackups?.length === 0 ? (

{t('backup.restore.backup.noBackupsFound')}

) : (
{availableBackups?.map((backup) => ( setRestoreData(prev => ({ ...prev, selectedBackup: backup }))} >
{backup.status === 'completed' ? ( ) : ( )}

{fmtDate(backup.created_at)} {t('backup.restore.backup.at')} {fmtTime(backup.created_at)}

{t('backup.dashboard.backupType', { type: backup.backup_type })} • {formatBytes(backup.total_size || 0)}

{/* Files-only warning — backend's /list-backups now returns `database_included: boolean` parsed from the manifest's database.backup_file field. A row where this is false is exactly the data-loss scenario the Stage A guard prevents going forward: a manifest written without an inline DB dump. Restoring it would NOT bring CRM data back. */} {backup.database_included === false && ( {t('backup.restore.backup.filesOnlyBadge', 'No DB')} )} {backup.corrupt && ( {t('backup.restore.backup.corruptBadge', 'Corrupt')} )} {backup.encrypted && ( )}
))}
)} {/* Files-only callout below the selected card. Reinforces the badge with a longer explanation + reminds the admin that restoring this WILL still proceed — they just won't get the DB back. Stops the silent-failure class that originally caused Ralf's 2026-05-29 data loss (four files-only manifests mistaken for full backups). */} {restoreData.selectedBackup && restoreData.selectedBackup.database_included === false && (

{t('backup.restore.backup.filesOnlyWarning.title', 'Selected backup has no database dump')}

{t('backup.restore.backup.filesOnlyWarning.message', 'Restoring this backup will recover files (photos, PDFs) but the database — including admin users, customers, quotes, invoices, contracts, and settings — will NOT come back. Pick a different backup if you have one with a database dump, or proceed only if files-only is what you want.')}

)} {restoreData.selectedBackup?.encrypted && (

{t('backup.restore.backup.encrypted')}

{t('backup.restore.backup.encryptedMessage')}

setRestoreData(prev => ({ ...prev, encryptionPassphrase: e.target.value }))} />
)}
); const renderRestoreOptions = () => (

{t('backup.restore.options.title')}

{t('backup.restore.options.subtitle')}

{restoreTypes.map((type) => { const Icon = type.icon; return ( ); })}
{/* Additional Options */}

{t('backup.restore.options.additionalOptions.title')}

); const renderConfirmation = () => (

{t('backup.restore.confirmation.title')}

{t('backup.restore.confirmation.subtitle')}

{validationResult ? ( <> {/* Validation Results */}
{validationResult.validation?.isValid ? ( ) : ( )}

{validationResult.validation?.isValid ? t('backup.restore.confirmation.validation.passed') : t('backup.restore.confirmation.validation.failed')}

{validationResult.validation?.errors?.length > 0 && (
    {validationResult.validation.errors.map((error, idx) => (
  • {error}
  • ))}
)}
{/* Space Check */} {validationResult.spaceCheck && (

{t('backup.restore.confirmation.spaceCheck.title')}

{t('backup.restore.confirmation.spaceCheck.required')}: {validationResult.spaceCheck.requiredFormatted || formatBytes(validationResult.spaceCheck.required || 0)}
{t('backup.restore.confirmation.spaceCheck.available')}: {validationResult.spaceCheck.availableFormatted || (validationResult.spaceCheck.available != null ? formatBytes(validationResult.spaceCheck.available) : t('common.unknown', 'Unknown'))}
{validationResult.spaceCheck.sufficient === false && (

{t('backup.restore.confirmation.spaceCheck.insufficient')}

)}
)} {/* Summary */}

{t('backup.restore.confirmation.summary.title')}

{t('backup.restore.confirmation.summary.source')}:
{restoreData.source}
{t('backup.restore.confirmation.summary.backupDate')}:
{fmtDateTime(restoreData.selectedBackup.created_at)}
{t('backup.restore.confirmation.summary.restoreType')}:
{restoreData.restoreType}
{t('backup.restore.confirmation.summary.preBackup')}:
{restoreData.skipPreBackup ? t('backup.restore.confirmation.summary.skipped') : t('backup.restore.confirmation.summary.enabled')}
{/* Warning */}

{t('backup.restore.confirmation.warning.title')}

{t('backup.restore.confirmation.warning.message')}

) : (

{t('backup.restore.confirmation.validation.checking')}

)}
); const renderProgress = () => { const progress = restoreStatus?.currentProgress || {}; const isRunning = restoreStatus?.isRunning; // Pull the most recent restore_runs row from history so we can // tell whether the "not running" state means success, failure, or // never-started. The history endpoint already returns rows newest // first. const lastRun = restoreStatus?.history?.[0]; const lastRunFailed = !isRunning && lastRun && (lastRun.status === 'failed' || lastRun.was_successful === false); const lastRunSucceeded = !isRunning && lastRun && lastRun.status === 'completed' && lastRun.was_successful === true; // Strip the noisy stack-trace tail from the error message so the // user sees the actionable line first. const lastRunError = lastRun?.error_message ? lastRun.error_message.split('\n')[0].slice(0, 500) : null; const subtitle = isRunning ? t('backup.restore.progress.inProgress') : lastRunFailed ? t('backup.restore.progress.failedSubtitle', 'Restore failed — see error below. Destination has been rolled back to its pre-restore state.') : lastRunSucceeded ? t('backup.restore.progress.completed') : t('backup.restore.progress.idle', 'No restore in progress.'); return (

{t('backup.restore.progress.title')}

{subtitle}

{lastRunFailed && (

{t('backup.restore.progress.errorTitle', 'Restore did not complete')}

{lastRunError || t('backup.restore.progress.errorUnknown', 'No error message recorded.')}

{lastRun.was_rollback_attempted && (

{t('backup.restore.progress.rolledBack', 'Pre-restore safety backup was used to roll back. Destination is in its pre-restore state — safe to retry once the issue above is resolved.')}

)}
)} {/* Progress Bar */}
{t('backup.restore.progress.overallProgress')} {progress.percentage || 0}%
{progress.currentFile && (

{t('backup.restore.progress.current')}: {progress.currentFile}

)}
{/* Status Details */}

{t('backup.restore.progress.statusDetails')}

{progress.steps?.map((step, idx) => (
{step.status === 'completed' ? ( ) : step.status === 'running' ? ( ) : step.status === 'failed' ? ( ) : ( )}

{step.name}

{step.message && (

{step.message}

)}
{step.duration && ( {step.duration} )}
))}
{/* Logs */} {progress.logs && progress.logs.length > 0 && (

{t('backup.restore.progress.restoreLogs')}

                {progress.logs.join('\n')}
              
)} {/* Completion Actions — only when the most recent run actually succeeded. Previously this gated on `progress.status` which could be null between runs, so the green "Restore completed successfully" banner could render alongside a silent failure. */} {lastRunSucceeded && (

{t('backup.restore.progress.success.title')}

{t('backup.restore.progress.success.message')}

{/* Post-restore CTA: jump to the integrity check (D2). The audit trail captured at sign / issue time is worth nothing if the documents it refers to are missing from the restored copy — verifier surfaces that drift in one click before the admin trusts the restored state. */} {onVerifyIntegrity && ( )}
)}
); }; const formatBytes = (bytes) => { if (!bytes) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; }; return (
{/* Progress Steps */}
{/* Step Content */} {currentStep === 0 && renderSourceSelection()} {currentStep === 1 && renderBackupSelection()} {currentStep === 2 && renderRestoreOptions()} {currentStep === 3 && renderConfirmation()} {currentStep === 4 && renderProgress()} {/* Navigation Buttons */}
{currentStep < 4 && ( )} {currentStep === 4 && !restoreStatus?.isRunning && ( )}
); };