feat(backup): UI for backup-integrity verifier — tab + post-restore CTA

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).
This commit is contained in:
Luca
2026-05-29 13:11:43 +02:00
parent 4812fcdec3
commit 7e2feca12f
6 changed files with 445 additions and 9 deletions
@@ -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<BackupIntegrityReport | null>(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 (
<Card className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
{isHealthy ? (
<ShieldCheck className="w-5 h-5 text-green-600 dark:text-green-400" />
) : report ? (
<ShieldAlert className="w-5 h-5 text-red-600 dark:text-red-400" />
) : (
<ShieldCheck className="w-5 h-5 text-neutral-400" />
)}
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.integrity.title', 'Document integrity')}
</h3>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 max-w-2xl">
{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.',
)}
</p>
</div>
<Button
variant="primary"
onClick={() => runCheck.mutate()}
disabled={runCheck.isPending}
leftIcon={
runCheck.isPending
? <Loader2 className="w-4 h-4 animate-spin" />
: <Play className="w-4 h-4" />
}
>
{runCheck.isPending
? t('backup.integrity.running', 'Checking…')
: t('backup.integrity.runNow', 'Run check now')}
</Button>
</div>
{runCheck.isError && (
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 text-sm text-red-700 dark:text-red-300">
{t('backup.integrity.error', 'Check failed: {{message}}', {
message: (runCheck.error as Error)?.message ?? 'unknown error',
})}
</div>
)}
{report && summary && (
<>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-4">
<Counter
label={t('backup.integrity.summary.total', 'Total')}
value={summary.totalRows}
tone="neutral"
/>
<Counter
label={t('backup.integrity.summary.verifiedOk', 'Hash-verified')}
value={summary.verifiedOk}
tone="green"
icon={<Hash className="w-4 h-4" />}
/>
<Counter
label={t('backup.integrity.summary.existsButNoHash', 'Exists only')}
value={summary.existsButNoHash}
tone="amber"
icon={<HelpCircle className="w-4 h-4" />}
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.',
)}
/>
<Counter
label={t('backup.integrity.summary.missingFiles', 'Missing')}
value={summary.missingFiles}
tone={summary.missingFiles > 0 ? 'red' : 'neutral'}
icon={<FileX className="w-4 h-4" />}
onClick={summary.missingFiles > 0
? () => setExpanded(expanded === 'missing' ? null : 'missing')
: undefined}
/>
<Counter
label={t('backup.integrity.summary.hashMismatches', 'Hash mismatches')}
value={summary.hashMismatches}
tone={summary.hashMismatches > 0 ? 'red' : 'neutral'}
icon={<ShieldAlert className="w-4 h-4" />}
onClick={summary.hashMismatches > 0
? () => setExpanded(expanded === 'hashMismatches' ? null : 'hashMismatches')
: undefined}
/>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{t('backup.integrity.scannedAt', 'Last checked: {{when}}', {
when: format(new Date(report.scannedAt), 'yyyy-MM-dd HH:mm:ss'),
})}
</p>
{expanded === 'missing' && summary.missingFiles > 0 && (
<ResultTable
title={t('backup.integrity.missing.heading', 'Missing files')}
caption={t(
'backup.integrity.missing.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.',
)}
rows={report.missing.map((m) => ({
table: m.table,
rowId: m.rowId,
column: m.column,
detail: m.expectedPath,
}))}
/>
)}
{expanded === 'hashMismatches' && summary.hashMismatches > 0 && (
<ResultTable
title={t('backup.integrity.hashMismatches.heading', 'Hash mismatches')}
caption={t(
'backup.integrity.hashMismatches.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.',
)}
rows={report.hashMismatches.map((m) => ({
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 && (
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
{t(
'backup.integrity.emptyState',
'No check has been run yet in this session. Click "Run check now" to scan the document estate.',
)}
</p>
)}
</Card>
);
};
type Tone = 'neutral' | 'green' | 'amber' | 'red';
const TONE_CLASSES: Record<Tone, string> = {
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 (
<div
className={classes}
onClick={onClick}
title={tooltip}
role={interactive ? 'button' : undefined}
tabIndex={interactive ? 0 : undefined}
>
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide opacity-80">
{icon}
<span>{label}</span>
</div>
<div className="text-2xl font-semibold mt-1 tabular-nums">{value}</div>
</div>
);
};
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 (
<div className="mt-4 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<div className="p-3 bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-700">
<h4 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{title}</h4>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">{caption}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800/30">
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
<th className="px-3 py-2">{t('backup.integrity.results.table', 'Table')}</th>
<th className="px-3 py-2">{t('backup.integrity.results.rowId', 'Row id')}</th>
<th className="px-3 py-2">{t('backup.integrity.results.column', 'Column')}</th>
<th className="px-3 py-2">{t('backup.integrity.results.detail', 'Detail')}</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr
key={`${r.table}-${r.rowId}-${r.column}-${i}`}
className="border-t border-neutral-200 dark:border-neutral-700"
>
<td className="px-3 py-2 font-mono text-xs text-neutral-700 dark:text-neutral-300">
{r.table}
</td>
<td className="px-3 py-2 tabular-nums text-neutral-700 dark:text-neutral-300">
{r.rowId}
</td>
<td className="px-3 py-2 font-mono text-xs text-neutral-700 dark:text-neutral-300">
{r.column}
</td>
<td className="px-3 py-2 font-mono text-xs text-neutral-700 dark:text-neutral-300 break-all">
{r.detail}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
@@ -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 = () => {
<div className="bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 mt-0.5" />
<div className="ml-3">
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-green-800 dark:text-green-200">
{t('backup.restore.progress.success.title')}
</h3>
<p className="mt-1 text-sm text-green-700 dark:text-green-300">
{t('backup.restore.progress.success.message')}
</p>
{/* 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 && (
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={onVerifyIntegrity}
leftIcon={<ShieldCheck className="w-4 h-4" />}
>
{t(
'backup.restore.progress.success.verifyIntegrity',
'Verify document integrity now',
)}
</Button>
)}
</div>
</div>
</div>
+35 -2
View File
@@ -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": {
+35 -2
View File
@@ -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": {
@@ -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<TabId>('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' && (
<RestoreWizard />
<RestoreWizard onVerifyIntegrity={() => setActiveTab('integrity')} />
)}
{activeTab === 'integrity' && (
<BackupIntegrityCard />
)}
</div>
</div>
+57
View File
@@ -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<BackupIntegrityReport> {
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