From 7e2feca12f894a9d02193d85634e69d76710fc14 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Fri, 29 May 2026 13:11:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(backup):=20UI=20for=20backup-integrity=20v?= =?UTF-8?q?erifier=20=E2=80=94=20tab=20+=20post-restore=20CTA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend half of the diagnostic shipped in 4812fcd. Adds: - BackupIntegrityCard component — runs the check on demand, surfaces the five summary counters (total / verifiedOk / existsButNoHash / missing / hashMismatches), and expands collapsible result tables for missing files + hash mismatches. existsButNoHash is exposed as a separate amber-toned bucket so admins can distinguish hash- verified evidence from existence-only at a glance — the latter is explicitly weaker in a legal dispute and the UI says so. - "Integrity" tab on BackupManagement, alongside the existing Dashboard / Configuration / History / Restore tabs. Card is portable — when the System Health page (backlog item) lands it can lift the component without changes. - Post-restore CTA on the RestoreWizard success card (D2 follow- through): "Verify document integrity now" button that switches the parent tab to Integrity. 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. i18n strings added in EN + DE (per user_languages — only those two are native; other locales fall back to the English defaults and should be flagged for native-speaker review per feedback_translation_flagging if anyone picks them up). --- .../components/admin/BackupIntegrityCard.tsx | 285 ++++++++++++++++++ .../src/components/admin/RestoreWizard.jsx | 27 +- frontend/src/i18n/locales/de.json | 37 ++- frontend/src/i18n/locales/en.json | 37 ++- frontend/src/pages/admin/BackupManagement.tsx | 11 +- frontend/src/services/admin.service.ts | 57 ++++ 6 files changed, 445 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/admin/BackupIntegrityCard.tsx diff --git a/frontend/src/components/admin/BackupIntegrityCard.tsx b/frontend/src/components/admin/BackupIntegrityCard.tsx new file mode 100644 index 00000000..cf8b7438 --- /dev/null +++ b/frontend/src/components/admin/BackupIntegrityCard.tsx @@ -0,0 +1,285 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ShieldCheck, + ShieldAlert, + FileX, + Hash, + HelpCircle, + Play, + Loader2, +} from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; +import { format } from 'date-fns'; + +import { Card, Button } from '../common'; +import { adminService, BackupIntegrityReport } from '../../services/admin.service'; + +/** + * BackupIntegrityCard — on-demand verifier for CRM document artefacts. + * + * Walks every `*_path` column on quotes / contracts / invoices and + * confirms (a) the referenced file exists on disk, (b) where a SHA-256 + * is stored, the file's bytes hash to the expected value. Surfaces + * three failure buckets: + * + * - missing — `*_path` set, file not on disk (broken FK) + * - hashMismatches — file exists but bytes don't match the stored hash + * - existsButNoHash — verified by existence only; weaker evidence + * + * Designed to be portable. Currently embedded as a tab on + * `BackupManagement.tsx`; when the System Health page (backlog item) + * lands, this same component can be lifted there without changes. + */ +export const BackupIntegrityCard: React.FC = () => { + const { t } = useTranslation(); + const [report, setReport] = useState(null); + const [expanded, setExpanded] = useState<'missing' | 'hashMismatches' | null>(null); + + const runCheck = useMutation({ + mutationFn: () => adminService.getBackupIntegrity(), + onSuccess: (data) => { + setReport(data); + // Auto-expand whichever failure bucket has entries, prioritising + // the more severe one (missing > hashMismatches). + if (data.summary.missingFiles > 0) setExpanded('missing'); + else if (data.summary.hashMismatches > 0) setExpanded('hashMismatches'); + else setExpanded(null); + }, + }); + + const summary = report?.summary; + const isHealthy = report + && summary + && summary.missingFiles === 0 + && summary.hashMismatches === 0; + + return ( + +
+
+
+ {isHealthy ? ( + + ) : report ? ( + + ) : ( + + )} +

+ {t('backup.integrity.title', 'Document integrity')} +

+
+

+ {t( + 'backup.integrity.description', + 'Verifies every CRM document (quote / contract / invoice / signature) referenced from the database actually exists on disk and — where a hash is stored — its bytes still match. Read-only, on-demand.', + )} +

+
+ +
+ + {runCheck.isError && ( +
+ {t('backup.integrity.error', 'Check failed: {{message}}', { + message: (runCheck.error as Error)?.message ?? 'unknown error', + })} +
+ )} + + {report && summary && ( + <> +
+ + } + /> + } + tooltip={t( + 'backup.integrity.summary.existsButNoHashHint', + 'File found, but no SHA-256 is stored for it (quote/invoice PDFs, signature drawings). Existence-only is weaker evidence in a dispute.', + )} + /> + 0 ? 'red' : 'neutral'} + icon={} + onClick={summary.missingFiles > 0 + ? () => setExpanded(expanded === 'missing' ? null : 'missing') + : undefined} + /> + 0 ? 'red' : 'neutral'} + icon={} + onClick={summary.hashMismatches > 0 + ? () => setExpanded(expanded === 'hashMismatches' ? null : 'hashMismatches') + : undefined} + /> +
+ +

+ {t('backup.integrity.scannedAt', 'Last checked: {{when}}', { + when: format(new Date(report.scannedAt), 'yyyy-MM-dd HH:mm:ss'), + })} +

+ + {expanded === 'missing' && summary.missingFiles > 0 && ( + ({ + table: m.table, + rowId: m.rowId, + column: m.column, + detail: m.expectedPath, + }))} + /> + )} + + {expanded === 'hashMismatches' && summary.hashMismatches > 0 && ( + ({ + table: m.table, + rowId: m.rowId, + column: m.column, + detail: `${m.expectedPath} (expected ${m.expectedSha.slice(0, 12)}…, got ${m.actualSha.slice(0, 12)}…)`, + }))} + /> + )} + + )} + + {!report && !runCheck.isPending && ( +

+ {t( + 'backup.integrity.emptyState', + 'No check has been run yet in this session. Click "Run check now" to scan the document estate.', + )} +

+ )} +
+ ); +}; + +type Tone = 'neutral' | 'green' | 'amber' | 'red'; + +const TONE_CLASSES: Record = { + neutral: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-200', + green: 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300', + amber: 'bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300', + red: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300', +}; + +const Counter: React.FC<{ + label: string; + value: number; + tone: Tone; + icon?: React.ReactNode; + tooltip?: string; + onClick?: () => void; +}> = ({ label, value, tone, icon, tooltip, onClick }) => { + const interactive = Boolean(onClick); + const classes = `rounded-lg p-3 ${TONE_CLASSES[tone]} ${ + interactive ? 'cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-current/30 transition' : '' + }`; + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +}; + +const ResultTable: React.FC<{ + title: string; + caption: string; + rows: Array<{ table: string; rowId: number; column: string; detail: string }>; +}> = ({ title, caption, rows }) => { + const { t } = useTranslation(); + return ( +
+
+

{title}

+

{caption}

+
+
+ + + + + + + + + + + {rows.map((r, i) => ( + + + + + + + ))} + +
{t('backup.integrity.results.table', 'Table')}{t('backup.integrity.results.rowId', 'Row id')}{t('backup.integrity.results.column', 'Column')}{t('backup.integrity.results.detail', 'Detail')}
+ {r.table} + + {r.rowId} + + {r.column} + + {r.detail} +
+
+
+ ); +}; diff --git a/frontend/src/components/admin/RestoreWizard.jsx b/frontend/src/components/admin/RestoreWizard.jsx index 451ef000..3269e44c 100644 --- a/frontend/src/components/admin/RestoreWizard.jsx +++ b/frontend/src/components/admin/RestoreWizard.jsx @@ -21,7 +21,8 @@ import { Eye, Calendar, Clock, - AlertCircle + AlertCircle, + ShieldCheck } from 'lucide-react'; import { format } from 'date-fns'; import { toast } from 'react-toastify'; @@ -29,7 +30,7 @@ import { useQuery, useMutation } from '@tanstack/react-query'; import { Button, Card, Input, Loading } from '../common'; import { api } from '../../config/api'; -export const RestoreWizard = () => { +export const RestoreWizard = ({ onVerifyIntegrity } = {}) => { const { t } = useTranslation(); const [currentStep, setCurrentStep] = useState(0); @@ -650,13 +651,33 @@ export const RestoreWizard = () => {
-
+

{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 && ( + + )}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 11dea9ad..4c6855b1 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -230,7 +230,39 @@ "dashboard": "Dashboard", "configuration": "Konfiguration", "history": "Backup-Verlauf", - "restore": "Wiederherstellung" + "restore": "Wiederherstellung", + "integrity": "Integrität" + }, + "integrity": { + "title": "Dokumentintegrität", + "description": "Prüft, ob jedes in der Datenbank referenzierte CRM-Dokument (Angebot / Vertrag / Rechnung / Unterschrift) tatsächlich auf der Festplatte existiert und — sofern ein Hash gespeichert ist — die Datei-Bytes weiterhin übereinstimmen. Nur lesend, auf Abruf.", + "runNow": "Prüfung starten", + "running": "Prüfe…", + "error": "Prüfung fehlgeschlagen: {{message}}", + "scannedAt": "Zuletzt geprüft: {{when}}", + "emptyState": "In dieser Sitzung wurde noch keine Prüfung ausgeführt. Klicke auf \"Prüfung starten\", um den Dokumentbestand zu durchsuchen.", + "summary": { + "total": "Gesamt", + "verifiedOk": "Hash-verifiziert", + "existsButNoHash": "Nur Existenz", + "existsButNoHashHint": "Datei gefunden, aber kein SHA-256 gespeichert (Angebots-/Rechnungs-PDFs, Unterschrifts-Zeichnungen). Nur-Existenz ist im Streitfall schwächeres Beweismaterial.", + "missingFiles": "Fehlend", + "hashMismatches": "Hash-Abweichungen" + }, + "missing": { + "heading": "Fehlende Dateien", + "caption": "Diese Zeilen verweisen auf einen Pfad, der nicht auf der Festplatte existiert. Nach einer Wiederherstellung bedeutet das, dass das Artefakt aus der Sicherungskette verloren ging; bei frischen Installationen wurde die Datei meist manuell gelöscht." + }, + "hashMismatches": { + "heading": "Hash-Abweichungen", + "caption": "Die Datei existiert, aber ihre aktuellen Bytes stimmen nicht mit dem zum Ausstellungs-/Signierzeitpunkt erfassten SHA-256 überein. Deutet auf Manipulation, Bit-Rot oder eine Wiederherstellung mit einer abweichenden Kopie hin." + }, + "results": { + "table": "Tabelle", + "rowId": "Zeilen-ID", + "column": "Spalte", + "detail": "Detail" + } }, "status": { "inProgress": "Backup läuft...", @@ -517,7 +549,8 @@ "restoreLogs": "Wiederherstellungsprotokolle", "success": { "title": "Wiederherstellung erfolgreich abgeschlossen", - "message": "Ihre Daten wurden wiederhergestellt. Bitte überprüfen Sie, ob alles korrekt funktioniert." + "message": "Ihre Daten wurden wiederhergestellt. Bitte überprüfen Sie, ob alles korrekt funktioniert.", + "verifyIntegrity": "Dokumentintegrität jetzt prüfen" } }, "actions": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index fce76b4b..3e94ebf5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2121,7 +2121,39 @@ "dashboard": "Dashboard", "configuration": "Configuration", "history": "Backup History", - "restore": "Restore" + "restore": "Restore", + "integrity": "Integrity" + }, + "integrity": { + "title": "Document integrity", + "description": "Verifies every CRM document (quote / contract / invoice / signature) referenced from the database actually exists on disk and — where a hash is stored — its bytes still match. Read-only, on-demand.", + "runNow": "Run check now", + "running": "Checking…", + "error": "Check failed: {{message}}", + "scannedAt": "Last checked: {{when}}", + "emptyState": "No check has been run yet in this session. Click \"Run check now\" to scan the document estate.", + "summary": { + "total": "Total", + "verifiedOk": "Hash-verified", + "existsButNoHash": "Exists only", + "existsButNoHashHint": "File found, but no SHA-256 is stored for it (quote/invoice PDFs, signature drawings). Existence-only is weaker evidence in a dispute.", + "missingFiles": "Missing", + "hashMismatches": "Hash mismatches" + }, + "missing": { + "heading": "Missing files", + "caption": "These rows reference a path that does not exist on disk. After a restore, this means the artefact was lost from the backup chain; for fresh installs, it usually means the file was deleted manually." + }, + "hashMismatches": { + "heading": "Hash mismatches", + "caption": "The file exists but its current bytes do not match the SHA-256 captured at issue / sign time. Indicates tampering, bit-rot, or a restore that pulled in a different copy than the original." + }, + "results": { + "table": "Table", + "rowId": "Row id", + "column": "Column", + "detail": "Detail" + } }, "status": { "inProgress": "Backup in progress...", @@ -2408,7 +2440,8 @@ "restoreLogs": "Restore Logs", "success": { "title": "Restore Completed Successfully", - "message": "Your data has been restored. Please verify everything is working correctly." + "message": "Your data has been restored. Please verify everything is working correctly.", + "verifyIntegrity": "Verify document integrity now" } }, "actions": { diff --git a/frontend/src/pages/admin/BackupManagement.tsx b/frontend/src/pages/admin/BackupManagement.tsx index c5691c4c..f127149a 100644 --- a/frontend/src/pages/admin/BackupManagement.tsx +++ b/frontend/src/pages/admin/BackupManagement.tsx @@ -10,6 +10,7 @@ import { Clock, Loader2, Shield, + ShieldCheck, } from 'lucide-react'; import { toast } from 'react-toastify'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -21,9 +22,10 @@ import { BackupDashboard } from '../../components/admin/BackupDashboard'; import { BackupConfiguration } from '../../components/admin/BackupConfiguration'; import { BackupHistory } from '../../components/admin/BackupHistory'; import { RestoreWizard } from '../../components/admin/RestoreWizard'; +import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard'; import { api } from '../../config/api'; -type TabId = 'dashboard' | 'configuration' | 'history' | 'restore'; +type TabId = 'dashboard' | 'configuration' | 'history' | 'restore' | 'integrity'; export const BackupManagement: React.FC = () => { const [activeTab, setActiveTab] = useState('dashboard'); @@ -35,6 +37,7 @@ export const BackupManagement: React.FC = () => { { id: 'configuration' as const, label: t('backup.tabs.configuration'), icon: Settings }, { id: 'history' as const, label: t('backup.tabs.history'), icon: History }, { id: 'restore' as const, label: t('backup.tabs.restore'), icon: RefreshCw }, + { id: 'integrity' as const, label: t('backup.tabs.integrity', 'Integrity'), icon: ShieldCheck }, ]; const { data: backupStatus, isLoading: statusLoading } = useQuery({ @@ -218,7 +221,11 @@ export const BackupManagement: React.FC = () => { )} {activeTab === 'restore' && ( - + setActiveTab('integrity')} /> + )} + + {activeTab === 'integrity' && ( + )}
diff --git a/frontend/src/services/admin.service.ts b/frontend/src/services/admin.service.ts index 1d98b1cc..c622688f 100644 --- a/frontend/src/services/admin.service.ts +++ b/frontend/src/services/admin.service.ts @@ -201,6 +201,50 @@ export interface Activity { createdAt: string; } +// Backup-integrity verifier — diagnostic endpoint that walks every +// CRM document-artefact path column and confirms files exist on disk +// (plus SHA-256 match where the schema stores one). Mirrors the +// shape returned by backupIntegrityService.verifyDocumentArtefacts. +export type BackupIntegrityScope = + | 'quote' + | 'contract' + | 'contract-signature' + | 'invoice'; + +export interface BackupIntegrityMissingRow { + table: string; + rowId: number; + column: string; + expectedPath: string; +} + +export interface BackupIntegrityHashMismatchRow extends BackupIntegrityMissingRow { + expectedSha: string; + actualSha: string; +} + +export interface BackupIntegrityExistsButNoHashRow { + table: string; + rowId: number; + column: string; + path: string; +} + +export interface BackupIntegrityReport { + scannedAt: string; + scopes: BackupIntegrityScope[]; + summary: { + totalRows: number; + verifiedOk: number; + missingFiles: number; + hashMismatches: number; + existsButNoHash: number; + }; + missing: BackupIntegrityMissingRow[]; + hashMismatches: BackupIntegrityHashMismatchRow[]; + existsButNoHash: BackupIntegrityExistsButNoHashRow[]; +} + export interface AdminProfile { id: number; username: string; @@ -262,6 +306,19 @@ export const adminService = { return response.data; }, + // Backup-integrity verifier (read-only diagnostic). `scope` filters + // which document classes to walk; omit for a full scan. + async getBackupIntegrity( + scope?: BackupIntegrityScope[], + ): Promise { + const params = scope && scope.length > 0 ? { scope: scope.join(',') } : undefined; + const response = await api.get<{ report: BackupIntegrityReport }>( + '/admin/system-health/backup-integrity', + { params }, + ); + return response.data.report; + }, + // Format activity message formatActivityMessage(activity: Activity): string { // Feature-flag toggles carry a `changed` diff in metadata. Render