Files
opencrm/frontend/src/pages/settings/DatabaseBackup.tsx
T
duffyduck 9830ac29a5 security: JWT raus aus localStorage – Refresh-Cookie-Pattern für SPA
Behebt das Pentest-Finding „JWT in localStorage (MITTEL)": bei XSS war
der Token JS-erreichbar → Angreifer könnte alle Anbieter-Credentials
abrufen. Branchenstandard-Lösung für SPAs jetzt umgesetzt.

Architektur:
- Access-Token: 15 min Lifetime, lebt NUR im JavaScript-Memory
  (api.ts tokenStore + AuthContext). Kein localStorage mehr.
- Refresh-Token: 7 Tage, im httpOnly-Cookie (Secure bei HTTPS_ENABLED,
  SameSite=Strict, Path=/api/auth). JavaScript hat keinen Zugriff →
  XSS klaut max. einen 15-min-Access-Token.

Backend:
- signAccessToken/signRefreshToken mit `type`-Claim
- Auth-Middleware verweigert Tokens mit type=refresh
- POST /api/auth/login + /customer-login: setzt refresh_token-Cookie,
  gibt access-Token im Body
- POST /api/auth/refresh: liest Cookie, rotiert ihn, gibt neuen Access
  aus. Prüft tokenInvalidatedAt (Logout/Rollenänderung = sofortige
  Invalidation auch des Refresh-Tokens)
- POST /api/auth/logout: löscht Cookie + setzt tokenInvalidatedAt
- cookie-parser als neue Dependency

Frontend:
- api.ts: in-memory tokenStore (kein localStorage); withCredentials=true
  für Cookie-Roundtrip; axios-Response-Interceptor mit
  Single-Flight-Refresh-Retry bei 401 (Original-Request wird
  transparent retried mit neuem Token)
- AuthContext: beim App-Start /auth/refresh aufrufen → wenn Cookie
  noch gültig, ist der User automatisch eingeloggt. Tab-Reload
  funktioniert weiterhin obwohl Access-Token nur in memory ist.
- 9 alte `localStorage.getItem('token')`-Stellen migriert auf
  `getAccessToken()` (PDF-Preview-iframe, Audit-Log-CSV-Export,
  DB-Backup-Download, File-Download-URLs, Portal-PDF-Link)

Live verifiziert:
- Login setzt Cookie (httpOnly, SameSite=Strict, Path=/api/auth) + Bearer
- API mit Bearer: 200; ohne: 401
- Refresh mit Cookie: rotiert sauber + neuer Access-Token im Body
- Refresh-Token als Bearer abgewiesen: 401 ("Falscher Token-Typ")
- Logout: Cookie gelöscht, danach /refresh → 401

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:06:17 +02:00

479 lines
18 KiB
TypeScript

import { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Database, Download, Upload, Trash2, RefreshCw, HardDrive, Clock, FileText, FolderOpen, Archive, AlertTriangle, Bomb } from 'lucide-react';
import { backupApi, BackupInfo, getAccessToken } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Button from '../../components/ui/Button';
export default function DatabaseBackup() {
const [showRestoreConfirm, setShowRestoreConfirm] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
const [showFactoryResetConfirm, setShowFactoryResetConfirm] = useState(false);
const [factoryResetConfirmText, setFactoryResetConfirmText] = useState('');
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const { logout } = useAuth();
// Backups laden
const { data: backupsData, isLoading } = useQuery({
queryKey: ['backups'],
queryFn: () => backupApi.list(),
});
const backups = backupsData?.data || [];
// Backup erstellen
const createMutation = useMutation({
mutationFn: () => backupApi.create(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
},
});
// Backup wiederherstellen
const restoreMutation = useMutation({
mutationFn: (name: string) => backupApi.restore(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
setShowRestoreConfirm(null);
},
});
// Backup löschen
const deleteMutation = useMutation({
mutationFn: (name: string) => backupApi.delete(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
setShowDeleteConfirm(null);
},
});
// Backup hochladen
const uploadMutation = useMutation({
mutationFn: (file: File) => backupApi.upload(file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
setUploadError(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
},
onError: (error: any) => {
setUploadError(error.message || 'Upload fehlgeschlagen');
},
});
// Werkseinstellungen
const factoryResetMutation = useMutation({
mutationFn: () => backupApi.factoryReset(),
onSuccess: () => {
setShowFactoryResetConfirm(false);
setFactoryResetConfirmText('');
// Nach Factory Reset ausloggen
logout();
},
});
// Datei-Upload Handler
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (!file.name.endsWith('.zip')) {
setUploadError('Nur ZIP-Dateien sind erlaubt');
return;
}
setUploadError(null);
uploadMutation.mutate(file);
}
};
// Download mit Auth-Token
const handleDownload = async (name: string) => {
const token = getAccessToken();
const url = backupApi.getDownloadUrl(name);
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Download fehlgeschlagen');
}
// Blob erstellen und herunterladen
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = `opencrm-backup-${name}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
} catch (error: any) {
console.error('Download error:', error);
}
};
// Formatierung
const formatDate = (timestamp: string) => {
return new Date(timestamp).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Database className="w-5 h-5" />
Datenbank & Zurücksetzen
</h2>
<p className="text-sm text-gray-500 mt-1">
Backups erstellen, wiederherstellen oder auf Werkseinstellungen zurücksetzen.
</p>
</div>
<div className="flex items-center gap-2">
{/* Upload Button */}
<input
type="file"
ref={fileInputRef}
accept=".zip"
onChange={handleFileSelect}
className="hidden"
/>
<Button
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={uploadMutation.isPending}
>
{uploadMutation.isPending ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Hochladen...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
Backup hochladen
</>
)}
</Button>
{/* Create Button */}
<Button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Wird erstellt...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Neues Backup
</>
)}
</Button>
</div>
</div>
{/* Upload Error */}
{uploadError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{uploadError}
</div>
)}
{/* Info-Box */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-medium text-blue-800 mb-2">Hinweise zur Datensicherung</h4>
<ul className="text-sm text-blue-700 space-y-1 list-disc list-inside">
<li>Backups enthalten alle Datenbankdaten und hochgeladene Dokumente</li>
<li>Erstellen Sie vor Datenbankmigrationen immer ein Backup</li>
<li>Backups können als ZIP heruntergeladen und auf einem anderen System wiederhergestellt werden</li>
<li>Bei der Wiederherstellung werden bestehende Daten mit dem Backup-Stand überschrieben</li>
</ul>
</div>
{/* Backup-Liste */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-4 py-3 bg-gray-50 border-b border-gray-200">
<h3 className="text-sm font-medium text-gray-700">Verfügbare Backups</h3>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : backups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<HardDrive className="w-12 h-12 mb-2 opacity-30" />
<p>Keine Backups vorhanden</p>
<p className="text-sm mt-1">Erstellen Sie Ihr erstes Backup</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{backups.map((backup: BackupInfo) => (
<div key={backup.name} className="p-4 hover:bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<span className="font-mono text-sm bg-gray-100 px-2 py-1 rounded">
{backup.name}
</span>
<span className="text-sm text-gray-500 flex items-center gap-1">
<Clock className="w-4 h-4" />
{formatDate(backup.timestamp)}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span className="flex items-center gap-1">
<FileText className="w-4 h-4" />
{backup.totalRecords.toLocaleString('de-DE')} Datensätze
</span>
<span className="flex items-center gap-1">
<HardDrive className="w-4 h-4" />
{formatSize(backup.sizeBytes)}
</span>
{backup.hasUploads && (
<span className="flex items-center gap-1 text-green-600">
<FolderOpen className="w-4 h-4" />
Dokumente ({formatSize(backup.uploadSizeBytes)})
</span>
)}
</div>
{/* Tabellen-Details (kollabiert) */}
<details className="mt-2">
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-700">
Tabellen anzeigen ({backup.tables.filter(t => t.count > 0).length} mit Daten)
</summary>
<div className="mt-2 flex flex-wrap gap-1">
{backup.tables
.filter(t => t.count > 0)
.map(t => (
<span
key={t.table}
className="text-xs bg-gray-100 px-2 py-0.5 rounded"
>
{t.table}: {t.count}
</span>
))}
</div>
</details>
</div>
{/* Aktionen */}
<div className="flex items-center gap-2 ml-4">
<Button
variant="secondary"
size="sm"
onClick={() => handleDownload(backup.name)}
title="Als ZIP herunterladen"
>
<Archive className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setShowRestoreConfirm(backup.name)}
disabled={restoreMutation.isPending}
>
<Upload className="w-4 h-4 mr-1" />
Wiederherstellen
</Button>
<Button
variant="danger"
size="sm"
onClick={() => setShowDeleteConfirm(backup.name)}
disabled={deleteMutation.isPending}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Wiederherstellungs-Bestätigung */}
{showRestoreConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Backup wiederherstellen?
</h3>
<p className="text-gray-600 mb-4">
Möchten Sie das Backup <strong>{showRestoreConfirm}</strong> wirklich wiederherstellen?
</p>
<p className="text-amber-600 text-sm mb-4 bg-amber-50 p-3 rounded-lg">
<strong>Achtung:</strong> Bestehende Daten und Dokumente werden mit dem Backup-Stand überschrieben.
Dies kann nicht rückgängig gemacht werden.
</p>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => setShowRestoreConfirm(null)}
disabled={restoreMutation.isPending}
>
Abbrechen
</Button>
<Button
variant="primary"
onClick={() => restoreMutation.mutate(showRestoreConfirm)}
disabled={restoreMutation.isPending}
>
{restoreMutation.isPending ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Wird wiederhergestellt...
</>
) : (
'Ja, wiederherstellen'
)}
</Button>
</div>
</div>
</div>
)}
{/* Lösch-Bestätigung */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Backup löschen?
</h3>
<p className="text-gray-600 mb-4">
Möchten Sie das Backup <strong>{showDeleteConfirm}</strong> wirklich löschen?
Dies kann nicht rückgängig gemacht werden.
</p>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => setShowDeleteConfirm(null)}
disabled={deleteMutation.isPending}
>
Abbrechen
</Button>
<Button
variant="danger"
onClick={() => deleteMutation.mutate(showDeleteConfirm)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Wird gelöscht...' : 'Ja, löschen'}
</Button>
</div>
</div>
</div>
)}
{/* Werkseinstellungen */}
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mt-8">
<div className="flex items-start gap-4">
<div className="p-2 bg-red-100 rounded-lg">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-red-800 mb-2">
Werkseinstellungen
</h3>
<p className="text-sm text-red-700 mb-4">
Setzt das System auf den Ausgangszustand zurück. <strong>Alle Daten werden unwiderruflich gelöscht</strong> -
Kunden, Verträge, Benutzer, Dokumente und Einstellungen. Nur die hier gespeicherten Backups bleiben erhalten.
</p>
<ul className="text-sm text-red-700 mb-4 list-disc list-inside space-y-1">
<li>Alle Kunden und Verträge werden gelöscht</li>
<li>Alle Benutzer werden gelöscht</li>
<li>Alle hochgeladenen Dokumente werden gelöscht</li>
<li>Ein neuer Admin-Benutzer wird erstellt (admin@admin.com / admin)</li>
<li><strong>Backups bleiben erhalten</strong> und können danach wiederhergestellt werden</li>
</ul>
<Button
variant="danger"
onClick={() => setShowFactoryResetConfirm(true)}
>
<Bomb className="w-4 h-4 mr-2" />
Werkseinstellungen
</Button>
</div>
</div>
</div>
{/* Werkseinstellungen-Bestätigung */}
{showFactoryResetConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-lg mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-red-100 rounded-lg">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
Wirklich auf Werkseinstellungen zurücksetzen?
</h3>
</div>
<p className="text-gray-600 mb-4">
Diese Aktion löscht <strong>alle Daten unwiderruflich</strong>. Es gibt kein Zurück!
</p>
<p className="text-sm text-gray-600 mb-4">
Geben Sie zur Bestätigung <strong className="font-mono bg-gray-100 px-1">LÖSCHEN</strong> ein:
</p>
<input
type="text"
value={factoryResetConfirmText}
onChange={(e) => setFactoryResetConfirmText(e.target.value)}
placeholder="LÖSCHEN"
className="w-full px-3 py-2 border border-gray-300 rounded-lg mb-4 focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => {
setShowFactoryResetConfirm(false);
setFactoryResetConfirmText('');
}}
disabled={factoryResetMutation.isPending}
>
Abbrechen
</Button>
<Button
variant="danger"
onClick={() => factoryResetMutation.mutate()}
disabled={factoryResetConfirmText !== 'LÖSCHEN' || factoryResetMutation.isPending}
>
{factoryResetMutation.isPending ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Wird zurückgesetzt...
</>
) : (
'Ja, alles löschen'
)}
</Button>
</div>
</div>
</div>
)}
</div>
);
}