chore: Frontend-Seiten in Komponenten/Hooks aufteilen

admin/page.tsx (1019→446), search/page.tsx (871→304), imap/page.tsx
(738→142), settings/page.tsx (641→56). Reine Umstrukturierung,
keine Verhaltensänderung, Build verifiziert.
This commit is contained in:
sysops
2026-06-22 11:29:43 +02:00
parent ce197a3ab7
commit a55faf74b1
33 changed files with 4000 additions and 2764 deletions
+177 -734
View File
File diff suppressed because it is too large Load Diff
+105 -708
View File
@@ -1,737 +1,134 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useImapAccounts } from "@/hooks/useImapAccounts";
import { Navbar } from "@/components/navbar";
import {
getImapAccounts,
createImapAccount,
deleteImapAccount,
testImapConnection,
startImapImport,
getImapProgress,
triggerImapSync,
updateImapInterval,
updateImapAccount,
type ImapAccount,
type ImapFolder,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardHeader,
CardContent,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { ImapAccountCard } from "@/components/imap/ImapAccountCard";
import { ImapAccountDialog } from "@/components/imap/ImapAccountDialog";
import { ImapEditDialog } from "@/components/imap/ImapEditDialog";
import { ImapDeleteDialog } from "@/components/imap/ImapDeleteDialog";
export default function ImapPage() {
const { user, loading: authLoading } = useAuth();
const [accounts, setAccounts] = useState<ImapAccount[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
// Edit state
const [editAccount, setEditAccount] = useState<ImapAccount | null>(null);
const [editName, setEditName] = useState("");
const [editHost, setEditHost] = useState("");
const [editPort, setEditPort] = useState("993");
const [editTls, setEditTls] = useState("ssl");
const [editUsername, setEditUsername] = useState("");
const [editPassword, setEditPassword] = useState("");
const [editSaving, setEditSaving] = useState(false);
const [editError, setEditError] = useState("");
// Form state
const [formName, setFormName] = useState("");
const [formHost, setFormHost] = useState("");
const [formPort, setFormPort] = useState("993");
const [formTls, setFormTls] = useState("ssl");
const [formUsername, setFormUsername] = useState("");
const [formPassword, setFormPassword] = useState("");
// Test state
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState("");
const [testFolders, setTestFolders] = useState<ImapFolder[] | null>(null);
const [excludedFolders, setExcludedFolders] = useState<Set<string>>(new Set());
// Saving state
const [saving, setSaving] = useState(false);
// Import error state
const [importError, setImportError] = useState<string>("");
// Polling refs
const pollingRefs = useRef<Map<number, ReturnType<typeof setInterval>>>(new Map());
const pollErrorCount = useRef<Map<number, number>>(new Map());
const loadAccounts = useCallback(async () => {
try {
const data = await getImapAccounts();
setAccounts(data);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (user) loadAccounts();
}, [user, loadAccounts]);
// Start polling for running accounts (import or sync)
useEffect(() => {
for (const acc of accounts) {
const isActive = acc.status === "running" || acc.sync_running;
if (isActive && !pollingRefs.current.has(acc.id)) {
pollErrorCount.current.set(acc.id, 0);
const interval = setInterval(async () => {
try {
const updated = await getImapProgress(acc.id);
pollErrorCount.current.set(acc.id, 0);
setAccounts((prev) =>
prev.map((a) => (a.id === updated.id ? updated : a))
);
if (updated.status !== "running" && !updated.sync_running) {
clearInterval(pollingRefs.current.get(acc.id)!);
pollingRefs.current.delete(acc.id);
pollErrorCount.current.delete(acc.id);
}
} catch {
// Only stop polling after 5 consecutive failures (tolerates brief network hiccups)
const errors = (pollErrorCount.current.get(acc.id) ?? 0) + 1;
pollErrorCount.current.set(acc.id, errors);
if (errors >= 5) {
clearInterval(pollingRefs.current.get(acc.id)!);
pollingRefs.current.delete(acc.id);
pollErrorCount.current.delete(acc.id);
}
}
}, 2000);
pollingRefs.current.set(acc.id, interval);
}
}
// Cleanup intervals for accounts that are no longer active
for (const [id, interval] of pollingRefs.current) {
const acc = accounts.find((a) => a.id === id);
if (!acc || (acc.status !== "running" && !acc.sync_running)) {
clearInterval(interval);
pollingRefs.current.delete(id);
}
}
}, [accounts]);
// Cleanup on unmount
useEffect(() => {
return () => {
for (const interval of pollingRefs.current.values()) {
clearInterval(interval);
}
};
}, []);
function resetForm() {
setFormName("");
setFormHost("");
setFormPort("993");
setFormTls("ssl");
setFormUsername("");
setFormPassword("");
setTestFolders(null);
setTestError("");
setExcludedFolders(new Set());
}
async function handleTest() {
setTesting(true);
setTestError("");
setTestFolders(null);
try {
const result = await testImapConnection({
host: formHost,
port: parseInt(formPort, 10) || 993,
tls: formTls,
username: formUsername,
password: formPassword,
});
if (result.ok && result.folders) {
setTestFolders(result.folders);
const excluded = new Set<string>();
for (const f of result.folders) {
if (f.excluded) excluded.add(f.name);
}
setExcludedFolders(excluded);
} else {
setTestError(result.error || "Verbindungstest fehlgeschlagen");
}
} catch (err) {
setTestError(err instanceof Error ? err.message : "Verbindungstest fehlgeschlagen");
} finally {
setTesting(false);
}
}
async function handleSave() {
setSaving(true);
try {
await createImapAccount({
name: formName,
host: formHost,
port: parseInt(formPort, 10) || 993,
tls: formTls,
username: formUsername,
password: formPassword,
excluded_folders: Array.from(excludedFolders),
});
setDialogOpen(false);
resetForm();
await loadAccounts();
} catch (err) {
setTestError(err instanceof Error ? err.message : "Speichern fehlgeschlagen");
} finally {
setSaving(false);
}
}
async function handleStartImport(id: number) {
setImportError("");
try {
const updated = await startImapImport(id);
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
} catch (err) {
setImportError(err instanceof Error ? err.message : "Import konnte nicht gestartet werden.");
}
}
async function handleDelete(id: number) {
try {
await deleteImapAccount(id);
setAccounts((prev) => prev.filter((a) => a.id !== id));
} catch (err) {
alert("Fehler beim Löschen: " + (err instanceof Error ? err.message : String(err)));
}
setDeleteConfirm(null);
}
function openEdit(acc: ImapAccount) {
setEditAccount(acc);
setEditName(acc.name);
setEditHost(acc.host);
setEditPort(String(acc.port));
setEditTls(acc.tls);
setEditUsername(acc.username);
setEditPassword("");
setEditError("");
}
async function handleEditSave() {
if (!editAccount) return;
if (!editName || !editHost || !editUsername) {
setEditError("Name, Host und Benutzername sind Pflichtfelder.");
return;
}
setEditSaving(true);
setEditError("");
try {
const updated = await updateImapAccount(editAccount.id, {
name: editName,
host: editHost,
port: parseInt(editPort, 10),
tls: editTls,
username: editUsername,
password: editPassword || undefined,
});
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
setEditAccount(null);
} catch (err) {
setEditError(err instanceof Error ? err.message : String(err));
} finally {
setEditSaving(false);
}
}
async function handleSyncNow(id: number) {
try {
const updated = await triggerImapSync(id);
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
} catch {
// ignore — conflict means sync already running
}
}
async function handleIntervalChange(id: number, value: string) {
const intervalMin = parseInt(value, 10);
try {
const updated = await updateImapInterval(id, intervalMin);
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
} catch {
// ignore
}
}
function toggleExcluded(folderName: string) {
setExcludedFolders((prev) => {
const next = new Set(prev);
if (next.has(folderName)) {
next.delete(folderName);
} else {
next.add(folderName);
}
return next;
});
}
function statusBadge(status: string) {
switch (status) {
case "running":
return <Badge className="bg-blue-600 text-white">Importiert...</Badge>;
case "error":
return <Badge variant="destructive">Fehler</Badge>;
default:
return <Badge variant="secondary">Bereit</Badge>;
}
}
function syncBadge(acc: ImapAccount) {
if (acc.sync_running) {
return <Badge className="bg-blue-500 text-white">Sync laeuft...</Badge>;
}
if (!acc.sync_status) return null;
if (acc.sync_status === "ok") {
return <Badge className="bg-green-600 text-white">Sync OK</Badge>;
}
if (acc.sync_status === "error") {
return <Badge variant="destructive">Sync Fehler</Badge>;
}
return null;
}
const imap = useImapAccounts(user);
return (
<div className="min-h-screen">
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
<main className="mx-auto max-w-4xl px-4 py-6">
{(authLoading || !user) ? (
{authLoading || !user ? (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
) : (<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">IMAP Import</h1>
<Button
onClick={() => {
resetForm();
setDialogOpen(true);
}}
>
Konto hinzufuegen
</Button>
</div>
{importError && (
<p className="mb-4 text-sm text-destructive" role="alert">{importError}</p>
)}
{loading ? (
<div className="space-y-4">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : accounts.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Noch keine IMAP-Konten konfiguriert. Klicken Sie auf &quot;Konto
hinzufuegen&quot;, um zu beginnen.
</CardContent>
</Card>
) : (
<div className="space-y-4">
{accounts.map((acc) => (
<Card key={acc.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<h3 className="font-semibold">{acc.name}</h3>
<p className="text-sm text-muted-foreground">
{acc.host}:{acc.port} ({acc.tls.toUpperCase()}) &middot; {acc.username}
</p>
</div>
{statusBadge(acc.status)}
</CardHeader>
<CardContent>
{acc.status === "running" && acc.progress_total === 0 && (
<div className="mb-3 space-y-1">
<Progress value={undefined} className="animate-pulse" />
<p className="text-xs text-muted-foreground">
Zaehle E-Mails auf dem Server...
</p>
</div>
)}
{acc.status === "running" && acc.progress_total > 0 && (
<div className="mb-3 space-y-1">
<Progress
value={
(acc.progress_current / acc.progress_total) * 100
}
/>
<p className="text-xs text-muted-foreground">
{acc.progress_current} von {acc.progress_total} E-Mails
</p>
</div>
)}
{acc.status === "error" && acc.error_msg && (
<p className="mb-3 text-sm text-destructive">
{acc.error_msg}
</p>
)}
{acc.last_import_at && (
<p className="text-sm text-muted-foreground mb-3">
Letzter Import:{" "}
{new Date(acc.last_import_at).toLocaleString("de-DE")} (
{acc.last_import_count} E-Mails)
</p>
)}
{/* PROJ-8: Sync status */}
{acc.last_sync_at && (
<div className="flex items-center gap-2 mb-3">
<p className="text-sm text-muted-foreground">
Letzter Sync:{" "}
{new Date(acc.last_sync_at).toLocaleString("de-DE")} (
{acc.last_sync_count} neu)
</p>
{syncBadge(acc)}
</div>
)}
{acc.sync_running && !acc.last_sync_at && syncBadge(acc)}
{acc.sync_error_msg && acc.sync_status === "error" && (
<p className="mb-3 text-sm text-destructive">
Sync-Fehler: {acc.sync_error_msg}
</p>
)}
{acc.excluded_folders && acc.excluded_folders.length > 0 && (
<p className="text-xs text-muted-foreground mb-3">
Ausgeschlossene Ordner: {acc.excluded_folders.join(", ")}
</p>
)}
{/* PROJ-8: Sync interval selector */}
<div className="mb-3 flex items-center gap-3">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Auto-Sync:
</span>
<Select
value={String(acc.sync_interval_min ?? 0)}
onValueChange={(v) => handleIntervalChange(acc.id, v)}
>
<SelectTrigger className="w-40 h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Deaktiviert</SelectItem>
<SelectItem value="5">5 min</SelectItem>
<SelectItem value="15">15 min</SelectItem>
<SelectItem value="30">30 min</SelectItem>
<SelectItem value="60">1 Stunde</SelectItem>
<SelectItem value="360">6 Stunden</SelectItem>
<SelectItem value="1440">24 Stunden</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
disabled={acc.status === "running"}
onClick={() => handleStartImport(acc.id)}
>
Import starten
</Button>
<Button
size="sm"
variant="outline"
disabled={acc.status === "running" || acc.sync_running}
onClick={() => handleSyncNow(acc.id)}
>
Sync jetzt
</Button>
<Button
size="sm"
variant="outline"
onClick={() => openEdit(acc)}
>
Bearbeiten
</Button>
<Button
size="sm"
variant="destructive"
disabled={acc.status === "running"}
onClick={() => setDeleteConfirm(acc.id)}
>
Loeschen
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Add Account Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>IMAP-Konto hinzufuegen</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="imap-name">Name</Label>
<Input
id="imap-name"
placeholder="z.B. Firmen-Mail"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="imap-host">Host</Label>
<Input
id="imap-host"
placeholder="imap.example.com"
value={formHost}
onChange={(e) => setFormHost(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="imap-port">Port</Label>
<Input
id="imap-port"
type="number"
value={formPort}
onChange={(e) => setFormPort(e.target.value)}
/>
</div>
</div>
<div className="space-y-1">
<Label>Verschluesselung</Label>
<Select value={formTls} onValueChange={setFormTls}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ssl">SSL/TLS</SelectItem>
<SelectItem value="starttls">STARTTLS</SelectItem>
<SelectItem value="none">Unverschluesselt</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="imap-user">Benutzername</Label>
<Input
id="imap-user"
placeholder="user@example.com"
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="imap-pass">Passwort</Label>
<Input
id="imap-pass"
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
/>
</div>
<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">IMAP Import</h1>
<Button
variant="outline"
onClick={handleTest}
disabled={testing || !formHost || !formUsername || !formPassword}
className="w-full"
>
{testing ? "Teste Verbindung..." : "Verbindung testen"}
</Button>
{testError && (
<p className="text-sm text-destructive">{testError}</p>
)}
{testFolders && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">
Erkannte Ordner
</h4>
<div className="space-y-2 max-h-48 overflow-y-auto">
{testFolders.map((folder) => (
<div
key={folder.name}
className="flex items-center gap-2"
>
<Checkbox
id={`folder-${folder.name}`}
checked={!excludedFolders.has(folder.name)}
onCheckedChange={() =>
toggleExcluded(folder.name)
}
/>
<Label
htmlFor={`folder-${folder.name}`}
className="text-sm flex-1 cursor-pointer"
>
{folder.name}
</Label>
{folder.excluded && folder.reason && (
<span className="text-xs text-muted-foreground">
({folder.reason === "special_use"
? "IMAP-Flag"
: "Namens-Erkennung"})
</span>
)}
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">
Deaktivierte Ordner werden nicht importiert.
</p>
</div>
</>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDialogOpen(false);
resetForm();
imap.resetForm();
imap.setDialogOpen(true);
}}
>
Abbrechen
Konto hinzufuegen
</Button>
<Button
onClick={handleSave}
disabled={
saving ||
!formName ||
!formHost ||
!formUsername ||
!formPassword
}
>
{saving ? "Speichert..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Account Dialog */}
<Dialog open={editAccount !== null} onOpenChange={(open) => { if (!open) setEditAccount(null); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>IMAP-Konto bearbeiten</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label>Name</Label>
<Input value={editName} onChange={(e) => setEditName(e.target.value)} placeholder="z.B. Firmen-Mail" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Host</Label>
<Input value={editHost} onChange={(e) => setEditHost(e.target.value)} placeholder="imap.example.com" />
</div>
<div className="space-y-1">
<Label>Port</Label>
<Input value={editPort} onChange={(e) => setEditPort(e.target.value)} type="number" />
</div>
</div>
<div className="space-y-1">
<Label>TLS</Label>
<Select value={editTls} onValueChange={setEditTls}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ssl">SSL/TLS</SelectItem>
<SelectItem value="starttls">STARTTLS</SelectItem>
<SelectItem value="none">Keine</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Benutzername</Label>
<Input value={editUsername} onChange={(e) => setEditUsername(e.target.value)} placeholder="user@example.com" />
</div>
<div className="space-y-1">
<Label>Passwort <span className="text-muted-foreground text-xs">(leer lassen = unveraendert)</span></Label>
<Input value={editPassword} onChange={(e) => setEditPassword(e.target.value)} type="password" placeholder="Neues Passwort eingeben" />
</div>
{editError && <p className="text-sm text-destructive">{editError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditAccount(null)}>Abbrechen</Button>
<Button onClick={handleEditSave} disabled={editSaving}>
{editSaving ? "Speichert..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteConfirm !== null}
onOpenChange={() => setDeleteConfirm(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Konto loeschen?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Soll dieses IMAP-Konto wirklich entfernt werden? Bereits
importierte E-Mails bleiben im Archiv erhalten.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm !== null && handleDelete(deleteConfirm)}
>
Loeschen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>)}
{imap.importError && (
<p className="mb-4 text-sm text-destructive" role="alert">
{imap.importError}
</p>
)}
{imap.loading ? (
<div className="space-y-4">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : imap.accounts.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Noch keine IMAP-Konten konfiguriert. Klicken Sie auf &quot;Konto
hinzufuegen&quot;, um zu beginnen.
</CardContent>
</Card>
) : (
<div className="space-y-4">
{imap.accounts.map((acc) => (
<ImapAccountCard
key={acc.id}
acc={acc}
onStartImport={imap.handleStartImport}
onSyncNow={imap.handleSyncNow}
onEdit={imap.openEdit}
onDelete={imap.setDeleteConfirm}
onIntervalChange={imap.handleIntervalChange}
/>
))}
</div>
)}
<ImapAccountDialog
open={imap.dialogOpen}
onOpenChange={imap.setDialogOpen}
formName={imap.formName}
setFormName={imap.setFormName}
formHost={imap.formHost}
setFormHost={imap.setFormHost}
formPort={imap.formPort}
setFormPort={imap.setFormPort}
formTls={imap.formTls}
setFormTls={imap.setFormTls}
formUsername={imap.formUsername}
setFormUsername={imap.setFormUsername}
formPassword={imap.formPassword}
setFormPassword={imap.setFormPassword}
testing={imap.testing}
testError={imap.testError}
testFolders={imap.testFolders}
excludedFolders={imap.excludedFolders}
saving={imap.saving}
onTest={imap.handleTest}
onSave={imap.handleSave}
onCancel={() => {
imap.setDialogOpen(false);
imap.resetForm();
}}
onToggleExcluded={imap.toggleExcluded}
/>
<ImapEditDialog
editAccount={imap.editAccount}
onClose={() => imap.setEditAccount(null)}
editName={imap.editName}
setEditName={imap.setEditName}
editHost={imap.editHost}
setEditHost={imap.setEditHost}
editPort={imap.editPort}
setEditPort={imap.setEditPort}
editTls={imap.editTls}
setEditTls={imap.setEditTls}
editUsername={imap.editUsername}
setEditUsername={imap.setEditUsername}
editPassword={imap.editPassword}
setEditPassword={imap.setEditPassword}
editSaving={imap.editSaving}
editError={imap.editError}
onSave={imap.handleEditSave}
/>
<ImapDeleteDialog
deleteConfirm={imap.deleteConfirm}
onCancel={() => imap.setDeleteConfirm(null)}
onConfirm={imap.handleDelete}
/>
</>
)}
</main>
</div>
);
+136 -715
View File
@@ -1,86 +1,23 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { searchEmails, exportMailsZIP, exportEDiscovery, uploadMailFilesUser, getUploadProgressUser, listSavedSearches, createSavedSearch, deleteSavedSearch, type SearchHit, type SearchMatchField, type UploadJob, type SavedSearch } from "@/lib/api";
import { sanitizeSnippet } from "@/lib/sanitize";
import { useSearch, DEFAULT_PAGE_SIZE } from "@/hooks/useSearch";
import { useSavedSearches } from "@/hooks/useSavedSearches";
import { useMailUpload } from "@/hooks/useMailUpload";
import { exportMailsZIP, exportEDiscovery } from "@/lib/api";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Progress } from "@/components/ui/progress";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Bookmark, BookmarkPlus, Trash2 } from "lucide-react";
const DEFAULT_PAGE_SIZE = 25;
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const MATCH_FIELD_LABEL: Record<SearchMatchField, string> = {
subject: "📨 Subject",
body: "✉️ Body",
attachment_text: "📄 PDF-Anhang",
attachment_names: "📎 Dateiname",
from_addr: "👤 Absender",
to_addr: "📧 Empfänger",
};
function MatchSourceBadge({ field }: { field: SearchMatchField }) {
return (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground whitespace-nowrap">
{MATCH_FIELD_LABEL[field]}
</span>
);
}
function SnippetLine({ hit }: { hit: SearchHit }) {
if (!hit.snippet) return null;
return (
<div className="mt-1 flex items-start gap-2 text-xs text-muted-foreground font-normal">
{hit.match_field && <MatchSourceBadge field={hit.match_field} />}
<span
className="min-w-0 flex-1 truncate [&_b]:font-semibold [&_b]:text-foreground"
dangerouslySetInnerHTML={{ __html: sanitizeSnippet(hit.snippet) }}
/>
</div>
);
}
import { SearchFilterBar } from "@/components/search/SearchFilterBar";
import { SearchResultsTable } from "@/components/search/SearchResultsTable";
import {
ExportZipDialog,
EDiscoveryDialog,
UploadDialog,
} from "@/components/search/ExportDialogs";
export default function SearchPage() {
const { user, loading: authLoading } = useAuth();
@@ -88,19 +25,34 @@ export default function SearchPage() {
const pageSize = user?.list_page_size ?? DEFAULT_PAGE_SIZE;
const [query, setQuery] = useState("");
const [fromFilter, setFromFilter] = useState("");
const [toFilter, setToFilter] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [sort, setSort] = useState("date_desc");
const [hasAttachment, setHasAttachment] = useState<boolean | undefined>(undefined);
const [results, setResults] = useState<SearchHit[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
const search = useSearch(user, pageSize);
const {
query,
setQuery,
fromFilter,
setFromFilter,
toFilter,
setToFilter,
dateFrom,
setDateFrom,
dateTo,
setDateTo,
sort,
setSort,
hasAttachment,
setHasAttachment,
results,
setResults,
total,
setTotal,
page,
setPage,
searching,
setSearching,
searched,
setSearched,
doSearch,
} = search;
// Selection state
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -111,56 +63,35 @@ export default function SearchPage() {
const [ediscoveryCaseName, setEdiscoveryCaseName] = useState("");
const [ediscoveryLoading, setEdiscoveryLoading] = useState(false);
// Upload state
const [uploadOpen, setUploadOpen] = useState(false);
const [uploadDragging, setUploadDragging] = useState(false);
const [uploadJob, setUploadJob] = useState<UploadJob | null>(null);
const [uploadError, setUploadError] = useState("");
const [uploadLoading, setUploadLoading] = useState(false);
const uploadPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const upload = useMailUpload(() => doSearch(1));
// Saved searches state
const [savedSearches, setSavedSearches] = useState<SavedSearch[]>([]);
const [savedLoading, setSavedLoading] = useState(false);
const [savePopoverOpen, setSavePopoverOpen] = useState(false);
const [saveName, setSaveName] = useState("");
const [saving, setSaving] = useState(false);
const [savedListOpen, setSavedListOpen] = useState(false);
const saved = useSavedSearches({
user,
pageSize,
query,
fromFilter,
toFilter,
dateFrom,
dateTo,
hasAttachment,
setQuery,
setFromFilter,
setToFilter,
setDateFrom,
setDateTo,
setHasAttachment,
setResults,
setTotal,
setPage,
setSearching,
setSearched,
});
// Clear selection when results change
useEffect(() => {
setSelected(new Set());
}, [results]);
const doSearch = useCallback(
async (p: number) => {
setSearching(true);
try {
const res = await searchEmails({
q: query || undefined,
from: fromFilter || undefined,
to: toFilter || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
sort: sort !== "date_desc" ? sort : undefined,
has_attachment: hasAttachment,
page: p,
page_size: pageSize,
});
setResults(res.hits || []);
setTotal(res.total);
setPage(p);
setSearched(true);
} catch {
setResults([]);
setTotal(0);
} finally {
setSearching(false);
}
},
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment, pageSize]
);
// Superadmin has no mail access — redirect to admin dashboard
useEffect(() => {
if (user?.role === "superadmin") {
@@ -168,24 +99,6 @@ export default function SearchPage() {
}
}, [user, router]);
// Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure
useEffect(() => {
if (!user || user.role === "superadmin") return;
setSearching(true);
searchEmails({ page: 1, page_size: pageSize })
.then((res) => {
setResults(res.hits || []);
setTotal(res.total);
setPage(1);
setSearched(true);
})
.catch(() => {
setResults([]);
setTotal(0);
})
.finally(() => setSearching(false));
}, [user, pageSize]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
doSearch(1);
@@ -235,139 +148,8 @@ export default function SearchPage() {
}
}
async function handleUploadFiles(files: FileList | File[]) {
const allowed = Array.from(files).filter((f) => {
const n = f.name.toLowerCase();
return n.endsWith(".eml") || n.endsWith(".mbox");
});
if (allowed.length === 0) {
setUploadError("Nur .eml und .mbox Dateien erlaubt.");
return;
}
setUploadError("");
setUploadJob(null);
setUploadLoading(true);
try {
const { job_id } = await uploadMailFilesUser(allowed);
uploadPollRef.current = setInterval(async () => {
try {
const job = await getUploadProgressUser(job_id);
setUploadJob(job);
if (job.status !== "running") {
clearInterval(uploadPollRef.current!);
uploadPollRef.current = null;
setUploadLoading(false);
// Refresh search results after successful import
if (job.status === "done") doSearch(1);
}
} catch {
clearInterval(uploadPollRef.current!);
uploadPollRef.current = null;
setUploadLoading(false);
}
}, 1500);
} catch (e) {
setUploadError(e instanceof Error ? e.message : "Upload fehlgeschlagen.");
setUploadLoading(false);
}
}
function handleUploadClose() {
if (uploadPollRef.current) {
clearInterval(uploadPollRef.current);
uploadPollRef.current = null;
}
setUploadOpen(false);
setUploadJob(null);
setUploadError("");
setUploadLoading(false);
}
// Load saved searches on mount
useEffect(() => {
if (!user || user.role === "superadmin") return;
setSavedLoading(true);
listSavedSearches()
.then((list) => setSavedSearches(list || []))
.catch(() => setSavedSearches([]))
.finally(() => setSavedLoading(false));
}, [user]);
const hasActiveSearch = !!(query || fromFilter || toFilter || dateFrom || dateTo || hasAttachment);
function buildCurrentQuery(): Record<string, string> {
const q: Record<string, string> = {};
if (query) q.q = query;
if (fromFilter) q.from = fromFilter;
if (toFilter) q.to = toFilter;
if (dateFrom) q.date_from = dateFrom;
if (dateTo) q.date_to = dateTo;
if (hasAttachment) q.has_attachment = "true";
return q;
}
async function handleSaveSearch() {
if (!saveName.trim()) return;
setSaving(true);
try {
const saved = await createSavedSearch(saveName.trim(), buildCurrentQuery());
setSavedSearches((prev) => [saved, ...prev]);
setSaveName("");
setSavePopoverOpen(false);
} catch (e) {
alert(`Suche speichern fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
} finally {
setSaving(false);
}
}
function handleApplySavedSearch(s: SavedSearch) {
setQuery(s.query.q || "");
setFromFilter(s.query.from || "");
setToFilter(s.query.to || "");
setDateFrom(s.query.date_from || "");
setDateTo(s.query.date_to || "");
setHasAttachment(s.query.has_attachment === "true" ? true : undefined);
setSavedListOpen(false);
// Trigger search after state updates
setTimeout(() => {
// We need to search with the saved query directly since state isn't updated yet
setSearching(true);
searchEmails({
q: s.query.q || undefined,
from: s.query.from || undefined,
to: s.query.to || undefined,
date_from: s.query.date_from || undefined,
date_to: s.query.date_to || undefined,
has_attachment: s.query.has_attachment === "true" ? true : undefined,
page: 1,
page_size: pageSize,
})
.then((res) => {
setResults(res.hits || []);
setTotal(res.total);
setPage(1);
setSearched(true);
})
.catch(() => {
setResults([]);
setTotal(0);
})
.finally(() => setSearching(false));
}, 0);
}
async function handleDeleteSavedSearch(id: number) {
try {
await deleteSavedSearch(id);
setSavedSearches((prev) => prev.filter((s) => s.id !== id));
} catch (e) {
alert(`Loeschen fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
}
}
const totalPages = Math.ceil(total / pageSize);
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
return (
<div className="min-h-screen">
@@ -384,183 +166,38 @@ export default function SearchPage() {
<div className="flex gap-6">
{/* Main content */}
<div className="flex-1 min-w-0">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Volltextsuche..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full flex-1 sm:w-auto"
aria-label="Suchbegriff"
/>
<Button type="submit" disabled={searching} className="flex-1 sm:flex-none">
{searching ? "Suche..." : "Suchen"}
</Button>
<Button type="button" variant="outline" onClick={() => setUploadOpen(true)} className="flex-1 sm:flex-none">
Importieren
</Button>
{hasActiveSearch && (
<Popover open={savePopoverOpen} onOpenChange={setSavePopoverOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="icon" title="Suche speichern" aria-label="Suche speichern">
<BookmarkPlus className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<div className="space-y-3">
<p className="text-sm font-medium">Suche speichern</p>
<Input
placeholder="Name der Suche..."
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSaveSearch(); }}
aria-label="Name der gespeicherten Suche"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => setSavePopoverOpen(false)}>
Abbrechen
</Button>
<Button size="sm" onClick={handleSaveSearch} disabled={saving || !saveName.trim()}>
{saving ? "Speichern..." : "Speichern"}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
<Popover open={savedListOpen} onOpenChange={setSavedListOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="icon" title="Gespeicherte Suchen" aria-label="Gespeicherte Suchen">
<Bookmark className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<p className="text-sm font-medium mb-3">Gespeicherte Suchen</p>
{savedLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : savedSearches.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
Keine gespeicherten Suchen vorhanden.
</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{savedSearches.map((s) => (
<div
key={s.id}
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted group"
>
<button
type="button"
className="flex-1 text-left text-sm truncate"
onClick={() => handleApplySavedSearch(s)}
title={Object.entries(s.query).map(([k, v]) => `${k}: ${v}`).join(", ")}
>
{s.name}
</button>
<span className="text-xs text-muted-foreground whitespace-nowrap hidden group-hover:inline">
{new Date(s.created_at).toLocaleDateString("de-DE")}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => { e.stopPropagation(); handleDeleteSavedSearch(s.id); }}
aria-label={`Suche "${s.name}" loeschen`}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
))}
</div>
)}
</PopoverContent>
</Popover>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label htmlFor="from-filter" className="text-xs">
Von (Absender)
</Label>
<Input
id="from-filter"
placeholder="absender@example.com"
value={fromFilter}
onChange={(e) => setFromFilter(e.target.value)}
aria-label="Absender filtern"
/>
</div>
<div className="space-y-1">
<Label htmlFor="to-filter" className="text-xs">
An (Empfänger)
</Label>
<Input
id="to-filter"
placeholder="empfaenger@example.com"
value={toFilter}
onChange={(e) => setToFilter(e.target.value)}
aria-label="Empfänger filtern"
/>
</div>
<div className="space-y-1">
<Label htmlFor="date-from" className="text-xs">
Datum von
</Label>
<Input
id="date-from"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
aria-label="Datum von"
/>
</div>
<div className="space-y-1">
<Label htmlFor="date-to" className="text-xs">
Datum bis
</Label>
<Input
id="date-to"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
aria-label="Datum bis"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor="sort-select" className="text-xs whitespace-nowrap">Sortierung</Label>
<Select value={sort} onValueChange={setSort}>
<SelectTrigger id="sort-select" className="h-8 w-40 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date_desc">Datum (neu alt)</SelectItem>
<SelectItem value="date_asc">Datum (alt neu)</SelectItem>
<SelectItem value="relevance">Relevanz</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch
id="attach-toggle"
checked={hasAttachment === true}
onCheckedChange={(checked) =>
setHasAttachment(checked ? true : undefined)
}
/>
<Label htmlFor="attach-toggle" className="text-xs cursor-pointer">
Nur mit Anhang
</Label>
</div>
</div>
</form>
<SearchFilterBar
query={query}
setQuery={setQuery}
fromFilter={fromFilter}
setFromFilter={setFromFilter}
toFilter={toFilter}
setToFilter={setToFilter}
dateFrom={dateFrom}
setDateFrom={setDateFrom}
dateTo={dateTo}
setDateTo={setDateTo}
sort={sort}
setSort={setSort}
hasAttachment={hasAttachment}
setHasAttachment={setHasAttachment}
searching={searching}
onSubmit={handleSubmit}
onOpenUpload={() => upload.setUploadOpen(true)}
hasActiveSearch={hasActiveSearch}
savePopoverOpen={saved.savePopoverOpen}
setSavePopoverOpen={saved.setSavePopoverOpen}
saveName={saved.saveName}
setSaveName={saved.setSaveName}
saving={saved.saving}
onSaveSearch={saved.handleSaveSearch}
savedListOpen={saved.savedListOpen}
setSavedListOpen={saved.setSavedListOpen}
savedLoading={saved.savedLoading}
savedSearches={saved.savedSearches}
onApplySavedSearch={saved.handleApplySavedSearch}
onDeleteSavedSearch={saved.handleDeleteSavedSearch}
/>
<div className="mt-6">
{searching ? (
@@ -598,272 +235,56 @@ export default function SearchPage() {
</Button>
</div>
<Card className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => {
if (checked) setSelected(new Set(results.map((h) => h.id)));
else setSelected(new Set());
}}
aria-label="Alle auswählen"
/>
</TableHead>
<TableHead className="w-28 sm:w-32">Datum</TableHead>
<TableHead className="hidden w-56 md:table-cell">Von</TableHead>
<TableHead>Betreff</TableHead>
<TableHead className="hidden w-48 lg:table-cell">An</TableHead>
<TableHead className="w-8 text-center" title="Anhang">📎</TableHead>
<TableHead className="hidden w-20 text-right sm:table-cell">Größe</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((hit) => (
<TableRow
key={hit.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => router.push(`/mail/${hit.id}`)}
role="link"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/mail/${hit.id}`);
}}
aria-label={`E-Mail von ${hit.from || "unbekannt"}: ${hit.subject || "Kein Betreff"}`}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected.has(hit.id)}
onCheckedChange={(checked) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(hit.id);
else next.delete(hit.id);
return next;
});
}}
aria-label="Mail auswählen"
/>
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{hit.date
? new Date(hit.date).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" })
: "-"}
</TableCell>
<TableCell className="hidden max-w-[14rem] truncate text-sm md:table-cell">{hit.from || "-"}</TableCell>
<TableCell className="max-w-[60vw] font-medium sm:max-w-none">
<div className="flex items-center gap-2">
<span className="truncate">{hit.subject || "(kein Betreff)"}</span>
{hit.thread_size && hit.thread_size > 1 && (
<span className="inline-flex items-center rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground font-normal">
{hit.thread_size}
</span>
)}
</div>
{/* Absender nur auf Mobile, da Spalte "Von" dort ausgeblendet ist */}
{hit.from && (
<div className="mt-0.5 truncate text-xs text-muted-foreground md:hidden">
{hit.from}
</div>
)}
<SnippetLine hit={hit} />
</TableCell>
<TableCell className="hidden max-w-[12rem] truncate text-sm text-muted-foreground lg:table-cell">{hit.to || "-"}</TableCell>
<TableCell className="text-center text-sm">
{hit.has_attachments ? "📎" : ""}
</TableCell>
<TableCell className="hidden text-right text-xs text-muted-foreground whitespace-nowrap sm:table-cell">
{hit.size ? formatBytes(hit.size) : ""}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => doSearch(page - 1)}
>
Zurueck
</Button>
<span className="text-sm text-muted-foreground">
Seite {page} von {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => doSearch(page + 1)}
>
Weiter
</Button>
</div>
)}
<SearchResultsTable
results={results}
selected={selected}
setSelected={setSelected}
total={total}
page={page}
totalPages={totalPages}
onPageChange={(p) => doSearch(p)}
/>
</>
) : null}
</div>
</div>{/* end flex-1 */}
</div>{/* end flex gap-6 */}
<Dialog open={exportOpen} onOpenChange={setExportOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>E-Mails exportieren</DialogTitle>
<DialogDescription>
{selected.size} E-Mail{selected.size !== 1 ? "s" : ""} als ZIP herunterladen
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-3 py-2">
<Switch
id="attachments"
checked={exportAttachments}
onCheckedChange={setExportAttachments}
/>
<Label htmlFor="attachments">Anhänge einschließen</Label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setExportOpen(false)}>
Abbrechen
</Button>
<Button onClick={handleExportZIP} disabled={exporting}>
{exporting ? "Wird exportiert..." : "ZIP herunterladen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* eDiscovery Export Dialog */}
<Dialog open={ediscoveryOpen} onOpenChange={setEdiscoveryOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>eDiscovery Export</DialogTitle>
<DialogDescription>
Exportiert alle Mails der aktuellen Suche als ZIP mit Metadaten-CSV und README.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="case-name">Case-Name (optional)</Label>
<input
id="case-name"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="z.B. Ermittlung-2026-Q1"
value={ediscoveryCaseName}
onChange={(e) => setEdiscoveryCaseName(e.target.value)}
/>
</div>
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground space-y-1">
<div><span className="font-medium">Aktive Filter:</span></div>
{query && <div>Suche: <span className="font-mono">{query}</span></div>}
{fromFilter && <div>Von: <span className="font-mono">{fromFilter}</span></div>}
{toFilter && <div>An: <span className="font-mono">{toFilter}</span></div>}
{dateFrom && <div>Von Datum: {dateFrom}</div>}
{dateTo && <div>Bis Datum: {dateTo}</div>}
{!query && !fromFilter && !toFilter && !dateFrom && !dateTo && (
<div className="italic">Keine Filter alle archivierten Mails werden exportiert</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEdiscoveryOpen(false)}>
Abbrechen
</Button>
<Button onClick={handleEDiscoveryExport} disabled={ediscoveryLoading}>
{ediscoveryLoading ? "Wird exportiert..." : "ZIP herunterladen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ExportZipDialog
open={exportOpen}
onOpenChange={setExportOpen}
selectedCount={selected.size}
exportAttachments={exportAttachments}
setExportAttachments={setExportAttachments}
exporting={exporting}
onExport={handleExportZIP}
/>
{/* Upload Dialog */}
<Dialog open={uploadOpen} onOpenChange={(open) => { if (!open) handleUploadClose(); else setUploadOpen(true); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>E-Mails importieren</DialogTitle>
<DialogDescription>
EML- oder MBOX-Dateien in das Archiv hochladen
</DialogDescription>
</DialogHeader>
<EDiscoveryDialog
open={ediscoveryOpen}
onOpenChange={setEdiscoveryOpen}
caseName={ediscoveryCaseName}
setCaseName={setEdiscoveryCaseName}
loading={ediscoveryLoading}
onExport={handleEDiscoveryExport}
query={query}
fromFilter={fromFilter}
toFilter={toFilter}
dateFrom={dateFrom}
dateTo={dateTo}
/>
{!uploadJob && (
<div
className={`flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
uploadDragging ? "border-primary bg-primary/5" : "border-muted-foreground/30"
}`}
onDragOver={(e) => { e.preventDefault(); setUploadDragging(true); }}
onDragLeave={() => setUploadDragging(false)}
onDrop={(e) => {
e.preventDefault();
setUploadDragging(false);
if (e.dataTransfer.files.length > 0) handleUploadFiles(e.dataTransfer.files);
}}
>
<p className="text-sm text-muted-foreground mb-3">
Dateien hierher ziehen oder auswählen
</p>
<label>
<input
type="file"
accept=".eml,.mbox"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) handleUploadFiles(e.target.files); }}
/>
<Button type="button" variant="outline" size="sm" asChild>
<span>Dateien auswählen</span>
</Button>
</label>
<p className="mt-2 text-xs text-muted-foreground">.eml · .mbox</p>
</div>
)}
{uploadError && (
<p className="text-sm text-destructive">{uploadError}</p>
)}
{uploadJob && (
<div className="space-y-3">
<Progress
value={uploadJob.total > 0 ? Math.round(((uploadJob.imported + uploadJob.skipped + uploadJob.errors) / uploadJob.total) * 100) : 0}
/>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div>
<div className="font-semibold text-green-600">{uploadJob.imported}</div>
<div className="text-xs text-muted-foreground">Importiert</div>
</div>
<div>
<div className="font-semibold text-yellow-600">{uploadJob.skipped}</div>
<div className="text-xs text-muted-foreground">Duplikate</div>
</div>
<div>
<div className="font-semibold text-red-600">{uploadJob.errors}</div>
<div className="text-xs text-muted-foreground">Fehler</div>
</div>
</div>
{uploadJob.status === "running" && (
<p className="text-xs text-center text-muted-foreground">
Verarbeite {uploadJob.imported + uploadJob.skipped + uploadJob.errors} / {uploadJob.total}
</p>
)}
{uploadJob.status === "done" && (
<p className="text-xs text-center text-green-600 font-medium">Abgeschlossen</p>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleUploadClose} disabled={uploadLoading && uploadJob?.status === "running"}>
{uploadJob?.status === "done" ? "Schließen" : "Abbrechen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<UploadDialog
open={upload.uploadOpen}
onOpenChange={(open) => { if (!open) upload.handleUploadClose(); else upload.setUploadOpen(true); }}
dragging={upload.uploadDragging}
setDragging={upload.setUploadDragging}
job={upload.uploadJob}
error={upload.uploadError}
loading={upload.uploadLoading}
onUploadFiles={upload.handleUploadFiles}
onClose={upload.handleUploadClose}
/>
</>)}
</main>
</div>
+22 -607
View File
@@ -1,236 +1,24 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { Navbar } from "@/components/navbar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Server, Lock, Info } from "lucide-react";
import {
changePassword,
changeEmail,
updatePreferences,
getTOTPSetup,
confirmTOTPSetup,
disableTOTP,
getSystemInfo,
type SystemInfo,
} from "@/lib/api";
import { usePasswordChange } from "@/hooks/usePasswordChange";
import { useProfileSettings } from "@/hooks/useProfileSettings";
import { useTotpSettings } from "@/hooks/useTotpSettings";
import { useSystemInfo } from "@/hooks/useSystemInfo";
import { PasswordSection } from "@/components/settings/PasswordSection";
import { ProfileSection } from "@/components/settings/ProfileSection";
import { DisplaySection } from "@/components/settings/DisplaySection";
import { TotpSection } from "@/components/settings/TotpSection";
import { ImapSection } from "@/components/settings/ImapSection";
export default function SettingsPage() {
const { user, loading, refresh } = useAuth();
// ── Password state ────────────────────────────────────────────────────
const [currentPw, setCurrentPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const [pwError, setPwError] = useState("");
const [pwSuccess, setPwSuccess] = useState("");
const [pwLoading, setPwLoading] = useState(false);
// ── Email state ───────────────────────────────────────────────────────
const [email, setEmail] = useState("");
const [emailInitialized, setEmailInitialized] = useState(false);
const [emailError, setEmailError] = useState("");
const [emailSuccess, setEmailSuccess] = useState("");
const [emailLoading, setEmailLoading] = useState(false);
// ── TOTP state ────────────────────────────────────────────────────────
const [totpEnabled, setTotpEnabled] = useState(false);
const [showSetup, setShowSetup] = useState(false);
const [qrCode, setQrCode] = useState("");
const [secret, setSecret] = useState("");
const [totpCode, setTotpCode] = useState("");
const [totpError, setTotpError] = useState("");
const [totpSuccess, setTotpSuccess] = useState("");
const [totpLoading, setTotpLoading] = useState(false);
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
const [disableCode, setDisableCode] = useState("");
const [disableError, setDisableError] = useState("");
const [disableLoading, setDisableLoading] = useState(false);
// ── Listenanzahl (Eintraege pro Seite) ────────────────────────────────
const [listPageSize, setListPageSize] = useState("25");
const [listPageSizeInitialized, setListPageSizeInitialized] = useState(false);
const [listPageSizeError, setListPageSizeError] = useState("");
const [listPageSizeSuccess, setListPageSizeSuccess] = useState("");
const [listPageSizeLoading, setListPageSizeLoading] = useState(false);
// ── System info (FQDN + IMAP-Ports) ───────────────────────────────────
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [systemInfoLoading, setSystemInfoLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setSystemInfoLoading(true);
getSystemInfo()
.then((info) => {
if (!cancelled) setSystemInfo(info);
})
.catch(() => {
if (!cancelled) setSystemInfo(null);
})
.finally(() => {
if (!cancelled) setSystemInfoLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// Initialize email from user data once available
if (user && !emailInitialized) {
setEmail(user.email || "");
setEmailInitialized(true);
}
// Initialize list page size from user data once available
if (user && !listPageSizeInitialized) {
setListPageSize(String(user.list_page_size || 25));
setListPageSizeInitialized(true);
}
// ── Handlers ──────────────────────────────────────────────────────────
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setPwError("");
setPwSuccess("");
if (newPw.length < 8) {
setPwError("Das neue Passwort muss mindestens 8 Zeichen lang sein.");
return;
}
if (newPw !== confirmPw) {
setPwError("Die Passwoerter stimmen nicht ueberein.");
return;
}
setPwLoading(true);
try {
await changePassword(currentPw, newPw);
setPwSuccess("Passwort wurde erfolgreich geaendert.");
setCurrentPw("");
setNewPw("");
setConfirmPw("");
} catch (err) {
setPwError(err instanceof Error ? err.message : "Fehler beim Aendern des Passworts");
} finally {
setPwLoading(false);
}
}
async function handleChangeEmail(e: React.FormEvent) {
e.preventDefault();
setEmailError("");
setEmailSuccess("");
if (!email || !email.includes("@")) {
setEmailError("Bitte eine gueltige E-Mail-Adresse eingeben.");
return;
}
setEmailLoading(true);
try {
const result = await changeEmail(email);
setEmail(result.email);
setEmailSuccess("E-Mail-Adresse wurde erfolgreich geaendert.");
} catch (err) {
setEmailError(err instanceof Error ? err.message : "Fehler beim Aendern der E-Mail");
} finally {
setEmailLoading(false);
}
}
async function handleChangeListPageSize(value: string) {
setListPageSizeError("");
setListPageSizeSuccess("");
const parsed = Number(value);
setListPageSizeLoading(true);
try {
const result = await updatePreferences(parsed);
setListPageSize(String(result.list_page_size));
setListPageSizeSuccess("Eintraege pro Seite wurden gespeichert.");
await refresh();
} catch (err) {
setListPageSizeError(err instanceof Error ? err.message : "Fehler beim Speichern");
} finally {
setListPageSizeLoading(false);
}
}
async function handleSetupTOTP() {
setTotpError("");
setTotpSuccess("");
setTotpLoading(true);
try {
const data = await getTOTPSetup();
setQrCode(data.qr_code || "");
setSecret(data.secret);
setShowSetup(true);
setTotpCode("");
} catch (err) {
setTotpError(err instanceof Error ? err.message : "TOTP-Setup fehlgeschlagen");
} finally {
setTotpLoading(false);
}
}
async function handleConfirmTOTP(e: React.FormEvent) {
e.preventDefault();
setTotpError("");
setTotpLoading(true);
try {
await confirmTOTPSetup(totpCode);
setTotpEnabled(true);
setShowSetup(false);
setTotpSuccess("2FA wurde erfolgreich aktiviert.");
setQrCode("");
setSecret("");
setTotpCode("");
} catch (err) {
setTotpError(err instanceof Error ? err.message : "Ungueltiger Code");
} finally {
setTotpLoading(false);
}
}
async function handleDisableTOTP() {
setDisableError("");
setDisableLoading(true);
try {
await disableTOTP(disableCode);
setTotpEnabled(false);
setDisableDialogOpen(false);
setDisableCode("");
setTotpSuccess("2FA wurde deaktiviert.");
} catch (err) {
setDisableError(err instanceof Error ? err.message : "Ungueltiger Code");
} finally {
setDisableLoading(false);
}
}
// ── Loading / auth guard ──────────────────────────────────────────────
const password = usePasswordChange();
const profile = useProfileSettings(user, refresh);
const totp = useTotpSettings();
const { systemInfo, systemInfoLoading } = useSystemInfo();
if (loading) {
return (
@@ -253,388 +41,15 @@ export default function SettingsPage() {
Profil &amp; Einstellungen
</h1>
{/* ── Card 1: Passwort aendern ─────────────────────────────────── */}
<Card>
<CardHeader>
<CardTitle>Passwort aendern</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Aktuelles Passwort</Label>
<Input
id="current-password"
type="password"
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
required
autoComplete="current-password"
aria-label="Aktuelles Passwort"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Neues Passwort</Label>
<Input
id="new-password"
type="password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
required
minLength={8}
autoComplete="new-password"
aria-label="Neues Passwort"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Neues Passwort bestaetigen</Label>
<Input
id="confirm-password"
type="password"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
required
minLength={8}
autoComplete="new-password"
aria-label="Neues Passwort bestaetigen"
/>
</div>
{pwError && (
<Alert variant="destructive">
<AlertDescription>{pwError}</AlertDescription>
</Alert>
)}
{pwSuccess && (
<Alert>
<AlertDescription>{pwSuccess}</AlertDescription>
</Alert>
)}
<Button type="submit" disabled={pwLoading}>
{pwLoading ? "Speichern..." : "Passwort aendern"}
</Button>
</form>
</CardContent>
</Card>
{/* ── Card 2: E-Mail aendern ───────────────────────────────────── */}
<Card>
<CardHeader>
<CardTitle>E-Mail-Adresse aendern</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleChangeEmail} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
aria-label="E-Mail-Adresse"
/>
</div>
{emailError && (
<Alert variant="destructive">
<AlertDescription>{emailError}</AlertDescription>
</Alert>
)}
{emailSuccess && (
<Alert>
<AlertDescription>{emailSuccess}</AlertDescription>
</Alert>
)}
<Button type="submit" disabled={emailLoading}>
{emailLoading ? "Speichern..." : "E-Mail aendern"}
</Button>
</form>
</CardContent>
</Card>
{/* ── Card 2b: Listenanzahl ─────────────────────────────────────── */}
<Card>
<CardHeader>
<CardTitle>Listenansicht</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="list-page-size">Eintraege pro Seite</Label>
<Select
value={listPageSize}
onValueChange={handleChangeListPageSize}
disabled={listPageSizeLoading}
>
<SelectTrigger id="list-page-size" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
{listPageSizeError && (
<Alert variant="destructive">
<AlertDescription>{listPageSizeError}</AlertDescription>
</Alert>
)}
{listPageSizeSuccess && (
<Alert>
<AlertDescription>{listPageSizeSuccess}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* ── Card 3: Zwei-Faktor-Authentifizierung ────────────────────── */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-3">
Zwei-Faktor-Authentifizierung (2FA)
{totpEnabled ? (
<Badge variant="default" className="bg-green-600">
Aktiv
</Badge>
) : (
<Badge variant="secondary">Inaktiv</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{totpError && (
<Alert variant="destructive">
<AlertDescription>{totpError}</AlertDescription>
</Alert>
)}
{totpSuccess && (
<Alert>
<AlertDescription>{totpSuccess}</AlertDescription>
</Alert>
)}
{!totpEnabled && !showSetup && (
<div>
<p className="text-sm text-muted-foreground mb-4">
Schuetzen Sie Ihr Konto mit einem Einmalpasswort (TOTP).
Kompatibel mit Google Authenticator, Authy und anderen
TOTP-Apps.
</p>
<Button onClick={handleSetupTOTP} disabled={totpLoading}>
{totpLoading ? "Laden..." : "2FA einrichten"}
</Button>
</div>
)}
{showSetup && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Scannen Sie den QR-Code mit Ihrer Authenticator-App und geben
Sie den angezeigten Code ein.
</p>
{qrCode && (
<div className="flex justify-center p-4 bg-white rounded-lg border w-fit mx-auto">
<img
src={`data:image/png;base64,${qrCode}`}
alt="TOTP QR-Code"
width={200}
height={200}
/>
</div>
)}
{secret && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Manueller Schluessel
</Label>
<code className="block text-sm font-mono bg-muted px-3 py-2 rounded break-all select-all">
{secret}
</code>
</div>
)}
<form onSubmit={handleConfirmTOTP} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="totp-code">Bestaetigungscode</Label>
<Input
id="totp-code"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000000"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
required
autoComplete="one-time-code"
aria-label="TOTP Bestaetigungscode"
/>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={totpLoading}>
{totpLoading ? "Pruefen..." : "Bestaetigen"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setShowSetup(false);
setQrCode("");
setSecret("");
setTotpCode("");
setTotpError("");
}}
>
Abbrechen
</Button>
</div>
</form>
</div>
)}
{totpEnabled && (
<div>
<p className="text-sm text-muted-foreground mb-4">
2FA ist aktiv. Zum Deaktivieren benoetigen Sie einen
aktuellen Code aus Ihrer Authenticator-App.
</p>
<Button
variant="destructive"
onClick={() => {
setDisableDialogOpen(true);
setDisableCode("");
setDisableError("");
}}
>
2FA deaktivieren
</Button>
</div>
)}
{/* Disable TOTP dialog */}
<Dialog open={disableDialogOpen} onOpenChange={setDisableDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>2FA deaktivieren</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Geben Sie einen aktuellen Code aus Ihrer Authenticator-App
ein, um 2FA zu deaktivieren.
</p>
<div className="space-y-2">
<Label htmlFor="disable-totp-code">TOTP-Code</Label>
<Input
id="disable-totp-code"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000000"
value={disableCode}
onChange={(e) => setDisableCode(e.target.value)}
autoComplete="one-time-code"
aria-label="TOTP-Code zum Deaktivieren"
/>
</div>
{disableError && (
<Alert variant="destructive">
<AlertDescription>{disableError}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDisableDialogOpen(false)}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDisableTOTP}
disabled={disableLoading || disableCode.length !== 6}
>
{disableLoading ? "Pruefen..." : "Deaktivieren"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
{/* ── Card 4: IMAP-Zugang ──────────────────────────────────────── */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Server className="h-5 w-5" aria-hidden="true" />
IMAP-Zugang
<Badge variant="default" className="bg-green-600">
Verfuegbar
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Verbinden Sie Ihren Mail-Client (Thunderbird, Outlook, Apple Mail)
mit folgenden Zugangsdaten:
</p>
<div
className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm"
role="list"
aria-label="IMAP-Verbindungsdaten"
>
<span className="text-muted-foreground flex items-center gap-1.5">
<Server className="h-3.5 w-3.5" aria-hidden="true" />
Server
</span>
<span className="font-mono">
{systemInfoLoading
? "Laden..."
: systemInfo?.fqdn ||
(typeof window !== "undefined"
? window.location.hostname
: "")}
</span>
<span className="text-muted-foreground flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
IMAP-Port
</span>
<span className="font-mono">
{systemInfo?.imap_port ?? 9993} (SSL/TLS)
</span>
<span className="text-muted-foreground flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
Alternativ-Port
</span>
<span className="font-mono">
{systemInfo?.imap_port_alt ?? 993} (SSL/TLS)
</span>
<span className="text-muted-foreground flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
Sicherheit
</span>
<span className="font-mono">SSL/TLS</span>
<span className="text-muted-foreground">Benutzername</span>
<span className="font-mono">{user.username}</span>
<span className="text-muted-foreground">Passwort</span>
<span className="text-sm italic">Ihr archivmail-Passwort</span>
</div>
<div className="flex items-start gap-2 rounded-md border border-yellow-300 bg-yellow-50 px-3 py-2 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950 dark:text-yellow-200">
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span>
Der IMAP-Zugang ist schreibgeschuetzt. Loeschen, Verschieben und
neue Mails einlegen sind nicht moeglich.
</span>
</div>
</CardContent>
</Card>
<PasswordSection {...password} />
<ProfileSection {...profile} />
<DisplaySection {...profile} />
<TotpSection {...totp} />
<ImapSection
systemInfo={systemInfo}
systemInfoLoading={systemInfoLoading}
username={user.username}
/>
</main>
</div>
);