added backup and email client

This commit is contained in:
2026-02-01 00:02:35 +01:00
parent ef18381dd8
commit 8c9e61cf17
210 changed files with 24211 additions and 742 deletions
+18 -1
View File
@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Card from '../components/ui/Card';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail } from 'lucide-react';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database } from 'lucide-react';
export default function Settings() {
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
@@ -143,6 +143,23 @@ export default function Settings() {
</div>
</div>
</Link>
<Link
to="/settings/database-backup"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<Database className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Datenbank & Zurücksetzen
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Backups erstellen, wiederherstellen oder auf Werkseinstellungen zurücksetzen.</p>
</div>
</div>
</Link>
</div>
</div>
)}
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi } from '../../services/api';
import { ContractEmailsSection } from '../../components/email';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
@@ -2326,6 +2327,14 @@ export default function ContractDetail() {
isCustomerPortal={isCustomerPortal}
/>
{/* Zugeordnete E-Mails */}
{!isCustomerPortal && hasPermission('contracts:read') && c.customerId && (
<ContractEmailsSection
contractId={contractId}
customerId={c.customerId}
/>
)}
{c.notes && (
<Card title="Notizen">
<p className="whitespace-pre-wrap">{c.notes}</p>
@@ -142,6 +142,17 @@ export default function ContractForm() {
}
}, [isEdit]);
// Set preselected customer from URL params (for new contracts)
useEffect(() => {
if (!isEdit && preselectedCustomerId && customersData?.data) {
// Only set if the customer exists in the list
const customerExists = customersData.data.some(c => c.id.toString() === preselectedCustomerId);
if (customerExists) {
setValue('customerId', preselectedCustomerId);
}
}
}, [isEdit, preselectedCustomerId, customersData, setValue]);
// Reset tariffId when providerId changes (but only after initial contract load)
useEffect(() => {
// Only reset tariff if:
+266 -27
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, StressfreiEmail } from '../../services/api';
import { EmailClientTab } from '../../components/email';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
@@ -132,6 +133,13 @@ export default function CustomerDetail() {
/>
),
},
{
id: 'emails',
label: 'E-Mail-Postfach',
content: (
<EmailClientTab customerId={customerId} />
),
},
{
id: 'contracts',
label: 'Verträge',
@@ -2793,9 +2801,21 @@ function StressfreiEmailModal({
const [localPart, setLocalPart] = useState('');
const [notes, setNotes] = useState('');
const [provisionAtProvider, setProvisionAtProvider] = useState(false);
const [createMailbox, setCreateMailbox] = useState(false);
const [provisionError, setProvisionError] = useState<string | null>(null);
const [providerStatus, setProviderStatus] = useState<'idle' | 'checking' | 'exists' | 'not_exists' | 'error'>('idle');
const [isProvisioning, setIsProvisioning] = useState(false);
const [isEnablingMailbox, setIsEnablingMailbox] = useState(false);
const [mailboxEnabled, setMailboxEnabled] = useState(false);
const [showCredentials, setShowCredentials] = useState(false);
const [credentials, setCredentials] = useState<{
email: string;
password: string;
imap: { server: string; port: number; encryption: string } | null;
smtp: { server: string; port: number; encryption: string } | null;
} | null>(null);
const [isLoadingCredentials, setIsLoadingCredentials] = useState(false);
const [isResettingPassword, setIsResettingPassword] = useState(false);
const queryClient = useQueryClient();
const isEditing = !!email;
@@ -2845,6 +2865,86 @@ function StressfreiEmailModal({
}
};
// Mailbox nachträglich aktivieren
const handleEnableMailbox = async () => {
if (!email) return;
setIsEnablingMailbox(true);
setProvisionError(null);
try {
const result = await stressfreiEmailApi.enableMailbox(email.id);
if (result.success) {
setMailboxEnabled(true);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
} else {
setProvisionError(result.error || 'Mailbox-Aktivierung fehlgeschlagen');
}
} catch (error) {
setProvisionError(error instanceof Error ? error.message : 'Fehler bei der Mailbox-Aktivierung');
} finally {
setIsEnablingMailbox(false);
}
};
// Mailbox-Status mit Provider synchronisieren
const syncMailboxStatusFromProvider = async () => {
if (!email) return;
try {
const result = await stressfreiEmailApi.syncMailboxStatus(email.id);
if (result.success && result.data) {
setMailboxEnabled(result.data.hasMailbox);
if (result.data.wasUpdated) {
// DB wurde aktualisiert, Query invalidieren
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
}
}
} catch (error) {
console.error('Fehler beim Synchronisieren des Mailbox-Status:', error);
}
};
// Mailbox-Zugangsdaten laden
const loadCredentials = async () => {
if (!email) return;
setIsLoadingCredentials(true);
try {
const result = await stressfreiEmailApi.getMailboxCredentials(email.id);
if (result.success && result.data) {
setCredentials(result.data);
setShowCredentials(true);
}
} catch (error) {
console.error('Fehler beim Laden der Zugangsdaten:', error);
} finally {
setIsLoadingCredentials(false);
}
};
// Passwort zurücksetzen
const handleResetPassword = async () => {
if (!email) return;
if (!confirm('Neues Passwort generieren? Das alte Passwort wird ungültig.')) return;
setIsResettingPassword(true);
try {
const result = await stressfreiEmailApi.resetPassword(email.id);
if (result.success && result.data) {
// Credentials mit neuem Passwort aktualisieren
if (credentials) {
setCredentials({ ...credentials, password: result.data.password });
}
alert('Passwort wurde erfolgreich zurückgesetzt.');
} else {
alert(result.error || 'Fehler beim Zurücksetzen des Passworts');
}
} catch (error) {
console.error('Fehler beim Zurücksetzen des Passworts:', error);
alert(error instanceof Error ? error.message : 'Fehler beim Zurücksetzen des Passworts');
} finally {
setIsResettingPassword(false);
}
};
// Reset form when modal opens or email changes
useEffect(() => {
if (isOpen) {
@@ -2853,40 +2953,45 @@ function StressfreiEmailModal({
setLocalPart(emailLocalPart);
setNotes(email.notes || '');
setProviderStatus('idle');
setMailboxEnabled(email.hasMailbox || false);
// Status beim Provider prüfen wenn Provider vorhanden
if (hasProvider) {
checkProviderStatus(emailLocalPart);
// Mailbox-Status synchronisieren
syncMailboxStatusFromProvider();
}
} else {
setLocalPart('');
setNotes('');
setProvisionAtProvider(false);
setCreateMailbox(false);
setProviderStatus('idle');
setMailboxEnabled(false);
}
setProvisionError(null);
// Zugangsdaten zurücksetzen
setShowCredentials(false);
setCredentials(null);
}
}, [isOpen, email, hasProvider]);
const createMutation = useMutation({
mutationFn: async (data: { email: string; notes?: string; provision?: boolean }) => {
// Wenn Provisionierung aktiviert, erst beim Provider anlegen
if (data.provision && customerEmail) {
const provisionResult = await emailProviderApi.provisionEmail(localPart, customerEmail);
if (!provisionResult.data?.success) {
throw new Error(provisionResult.data?.error || provisionResult.data?.message || 'Provisionierung fehlgeschlagen');
}
}
// Dann in der Datenbank anlegen
mutationFn: async (data: { email: string; notes?: string; provision?: boolean; createMailbox?: boolean }) => {
// Verwendet die neue API-Funktion, die Provisioning und Mailbox-Erstellung unterstützt
return stressfreiEmailApi.create(customerId, {
email: data.email,
notes: data.notes,
provisionAtProvider: data.provision,
createMailbox: data.createMailbox,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
setLocalPart('');
setNotes('');
setProvisionAtProvider(false);
setCreateMailbox(false);
onClose();
},
onError: (error) => {
@@ -2918,6 +3023,7 @@ function StressfreiEmailModal({
email: fullEmail,
notes: notes || undefined,
provision: provisionAtProvider,
createMailbox: provisionAtProvider && createMailbox,
});
}
};
@@ -2966,24 +3072,48 @@ function StressfreiEmailModal({
{hasProvider && customerEmail && (
<div className="bg-blue-50 p-3 rounded-lg">
{!isEditing ? (
// Erstellen-Modus: Checkbox
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={provisionAtProvider}
onChange={(e) => setProvisionAtProvider(e.target.checked)}
className="mt-1 rounded border-gray-300"
/>
<div>
<span className="text-sm font-medium text-gray-700">
Beim E-Mail-Provider anlegen
</span>
<p className="text-xs text-gray-500 mt-1">
Die E-Mail-Weiterleitung wird automatisch auf dem konfigurierten Server erstellt.
Weiterleitungsziel: {customerEmail}
</p>
</div>
</label>
// Erstellen-Modus: Checkboxen
<div className="space-y-3">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={provisionAtProvider}
onChange={(e) => {
setProvisionAtProvider(e.target.checked);
if (!e.target.checked) setCreateMailbox(false);
}}
className="mt-1 rounded border-gray-300"
/>
<div>
<span className="text-sm font-medium text-gray-700">
Beim E-Mail-Provider anlegen
</span>
<p className="text-xs text-gray-500 mt-1">
Die E-Mail-Weiterleitung wird automatisch auf dem konfigurierten Server erstellt.
Weiterleitungsziel: {customerEmail}
</p>
</div>
</label>
{provisionAtProvider && (
<label className="flex items-start gap-2 cursor-pointer ml-6">
<input
type="checkbox"
checked={createMailbox}
onChange={(e) => setCreateMailbox(e.target.checked)}
className="mt-1 rounded border-gray-300"
/>
<div>
<span className="text-sm font-medium text-gray-700">
Echte Mailbox erstellen (IMAP/SMTP-Zugang)
</span>
<p className="text-xs text-gray-500 mt-1">
Ermöglicht E-Mails direkt im CRM zu empfangen und zu versenden.
</p>
</div>
</label>
)}
</div>
) : (
// Bearbeiten-Modus: Status anzeigen
<div className="space-y-2">
@@ -3039,6 +3169,115 @@ function StressfreiEmailModal({
Erneut prüfen
</Button>
)}
{/* Mailbox-Status anzeigen wenn Provider vorhanden */}
{providerStatus === 'exists' && (
<div className="pt-3 mt-3 border-t border-blue-100">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">
Mailbox (IMAP/SMTP)
</span>
{mailboxEnabled ? (
<span className="text-xs text-green-600 flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Mailbox aktiv
</span>
) : (
<span className="text-xs text-orange-600">Keine Mailbox</span>
)}
</div>
{!mailboxEnabled && (
<div className="mt-2">
<p className="text-xs text-gray-500 mb-2">
Aktiviere eine echte Mailbox um E-Mails direkt im CRM zu empfangen und zu versenden.
</p>
<Button
type="button"
size="sm"
onClick={handleEnableMailbox}
disabled={isEnablingMailbox}
>
{isEnablingMailbox ? 'Wird aktiviert...' : 'Mailbox aktivieren'}
</Button>
</div>
)}
{/* Zugangsdaten anzeigen wenn Mailbox aktiv */}
{mailboxEnabled && (
<div className="mt-3">
{!showCredentials ? (
<Button
type="button"
size="sm"
variant="secondary"
onClick={loadCredentials}
disabled={isLoadingCredentials}
>
{isLoadingCredentials ? (
'Laden...'
) : (
<>
<Eye className="w-4 h-4 mr-1" />
Zugangsdaten anzeigen
</>
)}
</Button>
) : credentials && (
<div className="bg-white border border-gray-200 rounded-lg p-3 space-y-2">
<div className="flex justify-between items-center mb-2">
<span className="text-xs font-medium text-gray-500 uppercase">Zugangsdaten</span>
<button
type="button"
onClick={() => setShowCredentials(false)}
className="text-gray-400 hover:text-gray-600"
>
<EyeOff className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-gray-500">E-Mail:</span>
<p className="font-mono text-gray-900 break-all">{credentials.email}</p>
</div>
<div>
<span className="text-gray-500">Passwort:</span>
<div className="flex items-center gap-2">
<p className="font-mono text-gray-900 break-all select-all flex-1">{credentials.password}</p>
<button
type="button"
onClick={handleResetPassword}
disabled={isResettingPassword}
className="text-blue-600 hover:text-blue-800 text-xs whitespace-nowrap disabled:opacity-50"
title="Neues Kennwort generieren"
>
{isResettingPassword ? 'Generiere...' : 'Neu generieren'}
</button>
</div>
</div>
</div>
{credentials.imap && (
<div className="pt-2 border-t border-gray-100">
<span className="text-xs font-medium text-gray-500">IMAP (Empfang)</span>
<p className="font-mono text-xs text-gray-900">
{credentials.imap.server}:{credentials.imap.port} ({credentials.imap.encryption})
</p>
</div>
)}
{credentials.smtp && (
<div className="pt-2 border-t border-gray-100">
<span className="text-xs font-medium text-gray-500">SMTP (Versand)</span>
<p className="font-mono text-xs text-gray-900">
{credentials.smtp.server}:{credentials.smtp.port} ({credentials.smtp.encryption})
</p>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
@@ -7,7 +7,7 @@ import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, ArrowLeft, GripVertical, Zap, Flame, Wifi, Cable, Smartphone, Tv, Car, FileText } from 'lucide-react';
import { Plus, Edit, Trash2, ArrowLeft, GripVertical, Zap, Flame, Wifi, Cable, Smartphone, Tv, Car, FileText, Network } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { ContractCategory } from '../../types';
@@ -17,6 +17,7 @@ const iconMap: Record<string, React.ReactNode> = {
Flame: <Flame className="w-5 h-5" />,
Wifi: <Wifi className="w-5 h-5" />,
Cable: <Cable className="w-5 h-5" />,
Network: <Network className="w-5 h-5" />,
Smartphone: <Smartphone className="w-5 h-5" />,
Tv: <Tv className="w-5 h-5" />,
Car: <Car className="w-5 h-5" />,
@@ -27,7 +28,8 @@ const availableIcons = [
{ value: 'Zap', label: 'Blitz (Strom)' },
{ value: 'Flame', label: 'Flamme (Gas)' },
{ value: 'Wifi', label: 'WLAN (DSL)' },
{ value: 'Cable', label: 'Kabel (Glasfaser)' },
{ value: 'Cable', label: 'Kabel' },
{ value: 'Network', label: 'Netzwerk (Glasfaser)' },
{ value: 'Smartphone', label: 'Smartphone (Mobilfunk)' },
{ value: 'Tv', label: 'TV' },
{ value: 'Car', label: 'Auto (KFZ)' },
@@ -88,7 +90,7 @@ export default function ContractCategoryList() {
</Button>
</Link>
<h1 className="text-2xl font-bold flex-1">Vertragstypen</h1>
{hasPermission('platforms:create') && (
{hasPermission('developer:access') && (
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neuer Vertragstyp
@@ -142,12 +144,12 @@ export default function ContractCategoryList() {
</div>
</div>
<div className="flex gap-2 ml-4">
{hasPermission('platforms:update') && (
{hasPermission('developer:access') && (
<Button variant="ghost" size="sm" onClick={() => handleEdit(category)} title="Bearbeiten">
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('platforms:delete') && (
{hasPermission('developer:access') && (
<Button
variant="ghost"
size="sm"
@@ -0,0 +1,478 @@
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, RotateCcw } from 'lucide-react';
import { backupApi, BackupInfo } 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 = localStorage.getItem('token');
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)}
>
<RotateCcw 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>
);
}
@@ -14,6 +14,15 @@ const PROVIDER_TYPES = [
{ value: 'DIRECTADMIN', label: 'DirectAdmin' },
];
// Verschlüsselungstyp
type MailEncryption = 'SSL' | 'STARTTLS' | 'NONE';
const ENCRYPTION_OPTIONS = [
{ value: 'SSL', label: 'SSL/TLS', description: 'Verschlüsselung von Anfang an' },
{ value: 'STARTTLS', label: 'STARTTLS', description: 'Startet unverschlüsselt, dann Upgrade' },
{ value: 'NONE', label: 'Keine', description: 'Keine Verschlüsselung' },
];
interface ProviderFormData {
name: string;
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
@@ -23,6 +32,10 @@ interface ProviderFormData {
password: string;
domain: string;
defaultForwardEmail: string;
// Verschlüsselungs-Einstellungen
imapEncryption: MailEncryption;
smtpEncryption: MailEncryption;
allowSelfSignedCerts: boolean;
isActive: boolean;
isDefault: boolean;
}
@@ -36,6 +49,9 @@ const emptyForm: ProviderFormData = {
password: '',
domain: 'stressfrei-wechseln.de',
defaultForwardEmail: '',
imapEncryption: 'SSL',
smtpEncryption: 'SSL',
allowSelfSignedCerts: false,
isActive: true,
isDefault: false,
};
@@ -109,6 +125,9 @@ export default function EmailProviders() {
password: '', // Passwort wird nicht geladen
domain: config.domain,
defaultForwardEmail: config.defaultForwardEmail || '',
imapEncryption: config.imapEncryption ?? 'SSL',
smtpEncryption: config.smtpEncryption ?? 'SSL',
allowSelfSignedCerts: config.allowSelfSignedCerts ?? false,
isActive: config.isActive,
isDefault: config.isDefault,
});
@@ -200,6 +219,9 @@ export default function EmailProviders() {
username: formData.username,
domain: formData.domain,
defaultForwardEmail: formData.defaultForwardEmail,
imapEncryption: formData.imapEncryption,
smtpEncryption: formData.smtpEncryption,
allowSelfSignedCerts: formData.allowSelfSignedCerts,
isActive: formData.isActive,
isDefault: formData.isDefault,
};
@@ -462,6 +484,69 @@ export default function EmailProviders() {
Diese E-Mail-Adresse wird zusätzlich zur Kunden-E-Mail als Weiterleitungsziel hinzugefügt.
</p>
{/* Verschlüsselungs-Einstellungen */}
<div className="pt-4 border-t">
<h4 className="text-sm font-medium text-gray-700 mb-3">E-Mail-Verbindungseinstellungen</h4>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* IMAP Verschlüsselung */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
IMAP Verschlüsselung
<span className="text-gray-400 font-normal ml-1">
(Port {formData.imapEncryption === 'SSL' ? '993' : '143'})
</span>
</label>
<select
value={formData.imapEncryption}
onChange={(e) => setFormData({ ...formData, imapEncryption: e.target.value as MailEncryption })}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
>
{ENCRYPTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label} - {opt.description}
</option>
))}
</select>
</div>
{/* SMTP Verschlüsselung */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
SMTP Verschlüsselung
<span className="text-gray-400 font-normal ml-1">
(Port {formData.smtpEncryption === 'SSL' ? '465' : formData.smtpEncryption === 'STARTTLS' ? '587' : '25'})
</span>
</label>
<select
value={formData.smtpEncryption}
onChange={(e) => setFormData({ ...formData, smtpEncryption: e.target.value as MailEncryption })}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
>
{ENCRYPTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label} - {opt.description}
</option>
))}
</select>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.allowSelfSignedCerts}
onChange={(e) => setFormData({ ...formData, allowSelfSignedCerts: e.target.checked })}
className="rounded border-gray-300"
/>
<span className="text-sm">Selbstsignierte Zertifikate erlauben</span>
</label>
<p className="text-xs text-gray-500">
Aktivieren Sie diese Option für Testumgebungen mit selbstsignierten SSL-Zertifikaten.
</p>
</div>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
+30 -15
View File
@@ -7,7 +7,7 @@ import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, Search, Code, AlertTriangle, ArrowLeft } from 'lucide-react';
import { Plus, Edit, Trash2, Search, Code, AlertTriangle, ArrowLeft, Info } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { User, Role } from '../../types';
@@ -89,6 +89,13 @@ export default function UserList() {
</div>
</Card>
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<strong>Hinweis:</strong> Bei Änderungen an Rollen oder Berechtigungen wird der betroffene Benutzer automatisch ausgeloggt und muss sich erneut anmelden.
</div>
</div>
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
@@ -305,6 +312,7 @@ function UserModal({
firstName: formData.firstName,
lastName: formData.lastName,
roleIds: formData.roleIds,
hasDeveloperAccess: formData.hasDeveloperAccess,
});
}
};
@@ -381,20 +389,7 @@ function UserModal({
)}
</label>
))}
</div>
</div>
{user && (
<div className="space-y-3 pt-3 border-t">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
{/* Entwicklerzugriff direkt unter den Rollen */}
<label className="flex items-center gap-2">
<input
type="checkbox"
@@ -409,6 +404,26 @@ function UserModal({
<span className="text-sm text-gray-500">(Datenbanktools)</span>
</label>
</div>
{user && (
<p className="mt-2 text-xs text-amber-600 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Bei Rollenänderung wird der Benutzer automatisch ausgeloggt.
</p>
)}
</div>
{user && (
<div className="space-y-3 pt-3 border-t">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
</div>
)}
<div className="flex justify-end gap-2">