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>
);
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { type ImapAccount } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
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;
}
interface ImapAccountCardProps {
acc: ImapAccount;
onStartImport: (id: number) => void;
onSyncNow: (id: number) => void;
onEdit: (acc: ImapAccount) => void;
onDelete: (id: number) => void;
onIntervalChange: (id: number, value: string) => void;
}
export function ImapAccountCard({
acc,
onStartImport,
onSyncNow,
onEdit,
onDelete,
onIntervalChange,
}: ImapAccountCardProps) {
return (
<Card>
<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) => onIntervalChange(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={() => onStartImport(acc.id)}
>
Import starten
</Button>
<Button
size="sm"
variant="outline"
disabled={acc.status === "running" || acc.sync_running}
onClick={() => onSyncNow(acc.id)}
>
Sync jetzt
</Button>
<Button size="sm" variant="outline" onClick={() => onEdit(acc)}>
Bearbeiten
</Button>
<Button
size="sm"
variant="destructive"
disabled={acc.status === "running"}
onClick={() => onDelete(acc.id)}
>
Loeschen
</Button>
</div>
</CardContent>
</Card>
);
}
+206
View File
@@ -0,0 +1,206 @@
"use client";
import { type ImapFolder } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
interface ImapAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
formName: string;
setFormName: (v: string) => void;
formHost: string;
setFormHost: (v: string) => void;
formPort: string;
setFormPort: (v: string) => void;
formTls: string;
setFormTls: (v: string) => void;
formUsername: string;
setFormUsername: (v: string) => void;
formPassword: string;
setFormPassword: (v: string) => void;
testing: boolean;
testError: string;
testFolders: ImapFolder[] | null;
excludedFolders: Set<string>;
saving: boolean;
onTest: () => void;
onSave: () => void;
onCancel: () => void;
onToggleExcluded: (name: string) => void;
}
export function ImapAccountDialog({
open,
onOpenChange,
formName,
setFormName,
formHost,
setFormHost,
formPort,
setFormPort,
formTls,
setFormTls,
formUsername,
setFormUsername,
formPassword,
setFormPassword,
testing,
testError,
testFolders,
excludedFolders,
saving,
onTest,
onSave,
onCancel,
onToggleExcluded,
}: ImapAccountDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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>
<Button
variant="outline"
onClick={onTest}
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={() => onToggleExcluded(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={onCancel}>
Abbrechen
</Button>
<Button
onClick={onSave}
disabled={
saving || !formName || !formHost || !formUsername || !formPassword
}
>
{saving ? "Speichert..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
interface ImapDeleteDialogProps {
deleteConfirm: number | null;
onCancel: () => void;
onConfirm: (id: number) => void;
}
export function ImapDeleteDialog({
deleteConfirm,
onCancel,
onConfirm,
}: ImapDeleteDialogProps) {
return (
<Dialog open={deleteConfirm !== null} onOpenChange={onCancel}>
<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={onCancel}>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm !== null && onConfirm(deleteConfirm)}
>
Loeschen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { type ImapAccount } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface ImapEditDialogProps {
editAccount: ImapAccount | null;
onClose: () => void;
editName: string;
setEditName: (v: string) => void;
editHost: string;
setEditHost: (v: string) => void;
editPort: string;
setEditPort: (v: string) => void;
editTls: string;
setEditTls: (v: string) => void;
editUsername: string;
setEditUsername: (v: string) => void;
editPassword: string;
setEditPassword: (v: string) => void;
editSaving: boolean;
editError: string;
onSave: () => void;
}
export function ImapEditDialog({
editAccount,
onClose,
editName,
setEditName,
editHost,
setEditHost,
editPort,
setEditPort,
editTls,
setEditTls,
editUsername,
setEditUsername,
editPassword,
setEditPassword,
editSaving,
editError,
onSave,
}: ImapEditDialogProps) {
return (
<Dialog
open={editAccount !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<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={onClose}>
Abbrechen
</Button>
<Button onClick={onSave} disabled={editSaving}>
{editSaving ? "Speichert..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
import { type UploadJob } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface ExportZipDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedCount: number;
exportAttachments: boolean;
setExportAttachments: (v: boolean) => void;
exporting: boolean;
onExport: () => void;
}
export function ExportZipDialog({
open,
onOpenChange,
selectedCount,
exportAttachments,
setExportAttachments,
exporting,
onExport,
}: ExportZipDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>E-Mails exportieren</DialogTitle>
<DialogDescription>
{selectedCount} E-Mail{selectedCount !== 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={() => onOpenChange(false)}>
Abbrechen
</Button>
<Button onClick={onExport} disabled={exporting}>
{exporting ? "Wird exportiert..." : "ZIP herunterladen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface EDiscoveryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
caseName: string;
setCaseName: (v: string) => void;
loading: boolean;
onExport: () => void;
query: string;
fromFilter: string;
toFilter: string;
dateFrom: string;
dateTo: string;
}
export function EDiscoveryDialog({
open,
onOpenChange,
caseName,
setCaseName,
loading,
onExport,
query,
fromFilter,
toFilter,
dateFrom,
dateTo,
}: EDiscoveryDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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={caseName}
onChange={(e) => setCaseName(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={() => onOpenChange(false)}>
Abbrechen
</Button>
<Button onClick={onExport} disabled={loading}>
{loading ? "Wird exportiert..." : "ZIP herunterladen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface UploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
dragging: boolean;
setDragging: (v: boolean) => void;
job: UploadJob | null;
error: string;
loading: boolean;
onUploadFiles: (files: FileList | File[]) => void;
onClose: () => void;
}
export function UploadDialog({
open,
onOpenChange,
dragging,
setDragging,
job,
error,
loading,
onUploadFiles,
onClose,
}: UploadDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>E-Mails importieren</DialogTitle>
<DialogDescription>
EML- oder MBOX-Dateien in das Archiv hochladen
</DialogDescription>
</DialogHeader>
{!job && (
<div
className={`flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
dragging ? "border-primary bg-primary/5" : "border-muted-foreground/30"
}`}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files.length > 0) onUploadFiles(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) onUploadFiles(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>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{job && (
<div className="space-y-3">
<Progress
value={job.total > 0 ? Math.round(((job.imported + job.skipped + job.errors) / job.total) * 100) : 0}
/>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div>
<div className="font-semibold text-green-600">{job.imported}</div>
<div className="text-xs text-muted-foreground">Importiert</div>
</div>
<div>
<div className="font-semibold text-yellow-600">{job.skipped}</div>
<div className="text-xs text-muted-foreground">Duplikate</div>
</div>
<div>
<div className="font-semibold text-red-600">{job.errors}</div>
<div className="text-xs text-muted-foreground">Fehler</div>
</div>
</div>
{job.status === "running" && (
<p className="text-xs text-center text-muted-foreground">
Verarbeite {job.imported + job.skipped + job.errors} / {job.total}
</p>
)}
{job.status === "done" && (
<p className="text-xs text-center text-green-600 font-medium">Abgeschlossen</p>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading && job?.status === "running"}>
{job?.status === "done" ? "Schließen" : "Abbrechen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+270
View File
@@ -0,0 +1,270 @@
"use client";
import { type SavedSearch } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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";
interface SearchFilterBarProps {
query: string;
setQuery: (v: string) => void;
fromFilter: string;
setFromFilter: (v: string) => void;
toFilter: string;
setToFilter: (v: string) => void;
dateFrom: string;
setDateFrom: (v: string) => void;
dateTo: string;
setDateTo: (v: string) => void;
sort: string;
setSort: (v: string) => void;
hasAttachment: boolean | undefined;
setHasAttachment: (v: boolean | undefined) => void;
searching: boolean;
onSubmit: (e: React.FormEvent) => void;
onOpenUpload: () => void;
// Saved searches
hasActiveSearch: boolean;
savePopoverOpen: boolean;
setSavePopoverOpen: (v: boolean) => void;
saveName: string;
setSaveName: (v: string) => void;
saving: boolean;
onSaveSearch: () => void;
savedListOpen: boolean;
setSavedListOpen: (v: boolean) => void;
savedLoading: boolean;
savedSearches: SavedSearch[];
onApplySavedSearch: (s: SavedSearch) => void;
onDeleteSavedSearch: (id: number) => void;
}
export function SearchFilterBar(props: SearchFilterBarProps) {
const {
query,
setQuery,
fromFilter,
setFromFilter,
toFilter,
setToFilter,
dateFrom,
setDateFrom,
dateTo,
setDateTo,
sort,
setSort,
hasAttachment,
setHasAttachment,
searching,
onSubmit,
onOpenUpload,
hasActiveSearch,
savePopoverOpen,
setSavePopoverOpen,
saveName,
setSaveName,
saving,
onSaveSearch,
savedListOpen,
setSavedListOpen,
savedLoading,
savedSearches,
onApplySavedSearch,
onDeleteSavedSearch,
} = props;
return (
<form onSubmit={onSubmit} 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={onOpenUpload} 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") onSaveSearch(); }}
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={onSaveSearch} 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={() => onApplySavedSearch(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(); onDeleteSavedSearch(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>
);
}
@@ -0,0 +1,187 @@
"use client";
import { useRouter } from "next/navigation";
import { type SearchHit, type SearchMatchField } from "@/lib/api";
import { sanitizeSnippet } from "@/lib/sanitize";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
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>
);
}
interface SearchResultsTableProps {
results: SearchHit[];
selected: Set<string>;
setSelected: React.Dispatch<React.SetStateAction<Set<string>>>;
total: number;
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function SearchResultsTable({
results,
selected,
setSelected,
page,
totalPages,
onPageChange,
}: SearchResultsTableProps) {
const router = useRouter();
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
return (
<>
<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={() => onPageChange(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={() => onPageChange(page + 1)}
>
Weiter
</Button>
</div>
)}
</>
);
}
@@ -0,0 +1,68 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { useProfileSettings } from "@/hooks/useProfileSettings";
type DisplaySectionProps = Pick<
ReturnType<typeof useProfileSettings>,
| "listPageSize"
| "listPageSizeError"
| "listPageSizeSuccess"
| "listPageSizeLoading"
| "handleChangeListPageSize"
>;
export function DisplaySection({
listPageSize,
listPageSizeError,
listPageSizeSuccess,
listPageSizeLoading,
handleChangeListPageSize,
}: DisplaySectionProps) {
return (
<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>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Server, Lock, Info } from "lucide-react";
import type { SystemInfo } from "@/lib/api";
interface ImapSectionProps {
systemInfo: SystemInfo | null;
systemInfoLoading: boolean;
username: string;
}
export function ImapSection({
systemInfo,
systemInfoLoading,
username,
}: ImapSectionProps) {
return (
<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">{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>
);
}
@@ -0,0 +1,86 @@
"use client";
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 type { usePasswordChange } from "@/hooks/usePasswordChange";
type PasswordSectionProps = ReturnType<typeof usePasswordChange>;
export function PasswordSection({
currentPw,
setCurrentPw,
newPw,
setNewPw,
confirmPw,
setConfirmPw,
pwError,
pwSuccess,
pwLoading,
handleChangePassword,
}: PasswordSectionProps) {
return (
<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>
);
}
@@ -0,0 +1,59 @@
"use client";
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 type { useProfileSettings } from "@/hooks/useProfileSettings";
type ProfileSectionProps = Pick<
ReturnType<typeof useProfileSettings>,
"email" | "setEmail" | "emailError" | "emailSuccess" | "emailLoading" | "handleChangeEmail"
>;
export function ProfileSection({
email,
setEmail,
emailError,
emailSuccess,
emailLoading,
handleChangeEmail,
}: ProfileSectionProps) {
return (
<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>
);
}
+200
View File
@@ -0,0 +1,200 @@
"use client";
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 type { useTotpSettings } from "@/hooks/useTotpSettings";
type TotpSectionProps = ReturnType<typeof useTotpSettings>;
export function TotpSection({
totpEnabled,
showSetup,
qrCode,
secret,
totpCode,
setTotpCode,
totpError,
totpSuccess,
totpLoading,
disableDialogOpen,
setDisableDialogOpen,
disableCode,
setDisableCode,
disableError,
disableLoading,
handleSetupTOTP,
handleConfirmTOTP,
handleDisableTOTP,
cancelSetup,
openDisableDialog,
}: TotpSectionProps) {
return (
<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={cancelSetup}>
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={openDisableDialog}>
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>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useState, useCallback } from "react";
import { getAuditLog, type AuditEntry } from "@/lib/api";
const AUDIT_PAGE_SIZE = 25;
export function useAdminAudit() {
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const [auditTotal, setAuditTotal] = useState(0);
const [auditPage, setAuditPage] = useState(1);
const [auditLoading, setAuditLoading] = useState(false);
const loadAudit = useCallback(async (p: number) => {
setAuditLoading(true);
try {
const data = await getAuditLog({ page: p, page_size: AUDIT_PAGE_SIZE });
setAuditEntries(data.entries || []);
setAuditTotal(data.total);
setAuditPage(p);
} catch {
setAuditEntries([]);
} finally {
setAuditLoading(false);
}
}, []);
return {
auditEntries,
auditTotal,
auditPage,
auditLoading,
loadAudit,
};
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useState, useCallback } from "react";
import { getCertInfo, type CertInfo } from "@/lib/api";
export function useAdminCert() {
const [certInfo, setCertInfo] = useState<CertInfo | null>(null);
const [certLoading, setCertLoading] = useState(false);
const [certError, setCertError] = useState("");
const [certSuccess, setCertSuccess] = useState("");
const [certFile, setCertFile] = useState<File | null>(null);
const [keyFile, setKeyFile] = useState<File | null>(null);
const [certUploadLoading, setCertUploadLoading] = useState(false);
const [selfSignedCN, setSelfSignedCN] = useState("archivmail");
const [selfSignedDNS, setSelfSignedDNS] = useState("archivmail");
const [selfSignedIPs, setSelfSignedIPs] = useState("192.168.1.131");
const [selfSignedYears, setSelfSignedYears] = useState("10");
const [selfSignedLoading, setSelfSignedLoading] = useState(false);
const [acmeDomain, setAcmeDomain] = useState("");
const [acmeEmail, setAcmeEmail] = useState("");
const [acmeLoading, setAcmeLoading] = useState(false);
const [acmeOutput, setAcmeOutput] = useState("");
const loadCert = useCallback(async () => {
setCertLoading(true);
setCertError("");
try {
const info = await getCertInfo();
setCertInfo(info);
} catch (e) {
setCertError(String(e));
} finally {
setCertLoading(false);
}
}, []);
return {
certInfo,
setCertInfo,
certLoading,
certError,
setCertError,
certSuccess,
setCertSuccess,
certFile,
setCertFile,
keyFile,
setKeyFile,
certUploadLoading,
setCertUploadLoading,
selfSignedCN,
setSelfSignedCN,
selfSignedDNS,
setSelfSignedDNS,
selfSignedIPs,
setSelfSignedIPs,
selfSignedYears,
setSelfSignedYears,
selfSignedLoading,
setSelfSignedLoading,
acmeDomain,
setAcmeDomain,
acmeEmail,
setAcmeEmail,
acmeLoading,
setAcmeLoading,
acmeOutput,
setAcmeOutput,
loadCert,
};
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import {
getSMTPStatus,
getHealth,
getStorageStats,
getSystemStats,
getMailTimeseries,
type SMTPStatus,
type StorageStats,
type SystemStats,
type TimeseriesPoint,
} from "@/lib/api";
export function useAdminDashboard(active: boolean) {
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [timeseries, setTimeseries] = useState<TimeseriesPoint[]>([]);
const [apiOnline, setApiOnline] = useState<boolean | null>(null);
const [dashLoading, setDashLoading] = useState(true);
const [dashRefreshed, setDashRefreshed] = useState<Date | null>(null);
const [countdown, setCountdown] = useState(30);
const dashIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadDashboard = useCallback(async () => {
setDashLoading(true);
try {
const [smtp, health, storage, sysStats, ts] = await Promise.allSettled([
getSMTPStatus(),
getHealth(),
getStorageStats(),
getSystemStats(),
getMailTimeseries(30),
]);
setSmtpStatus(smtp.status === "fulfilled" ? smtp.value : null);
setApiOnline(health.status === "fulfilled" && health.value.status === "ok");
setStorageStats(storage.status === "fulfilled" ? storage.value : null);
setSystemStats(sysStats.status === "fulfilled" ? sysStats.value : null);
setTimeseries(ts.status === "fulfilled" ? ts.value.points : []);
setDashRefreshed(new Date());
} finally {
setDashLoading(false);
}
}, []);
useEffect(() => {
if (!active) return;
loadDashboard();
setCountdown(30);
dashIntervalRef.current = setInterval(() => {
loadDashboard();
setCountdown(30);
}, 30_000);
const ticker = setInterval(() => {
setCountdown((c) => (c > 0 ? c - 1 : 0));
}, 1_000);
return () => {
if (dashIntervalRef.current) clearInterval(dashIntervalRef.current);
clearInterval(ticker);
};
}, [active, loadDashboard]);
const refresh = useCallback(() => {
loadDashboard();
setCountdown(30);
}, [loadDashboard]);
return {
smtpStatus,
storageStats,
systemStats,
timeseries,
apiOnline,
dashLoading,
dashRefreshed,
countdown,
loadDashboard,
refresh,
};
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import {
getSecurityAudit,
fixSecurityIssue,
type SecurityAuditResult,
} from "@/lib/api";
export function useAdminSecurity() {
const [securityAudit, setSecurityAudit] = useState<SecurityAuditResult | null>(null);
const [securityLoading, setSecurityLoading] = useState(false);
const [securityError, setSecurityError] = useState("");
const [fixLoading, setFixLoading] = useState<string | null>(null);
const [fixMessage, setFixMessage] = useState("");
async function runSecurityAudit() {
setSecurityLoading(true);
setSecurityError("");
setFixMessage("");
try {
const result = await getSecurityAudit();
setSecurityAudit(result);
} catch {
setSecurityError("Security-Audit konnte nicht ausgeführt werden.");
} finally {
setSecurityLoading(false);
}
}
async function runFix(action: string) {
setFixLoading(action);
setFixMessage("");
setSecurityError("");
try {
const res = await fixSecurityIssue(action);
setFixMessage(res.message);
await runSecurityAudit();
} catch (e: unknown) {
setSecurityError(e instanceof Error ? e.message : "Fix fehlgeschlagen.");
} finally {
setFixLoading(null);
}
}
return {
securityAudit,
securityLoading,
securityError,
fixLoading,
fixMessage,
runSecurityAudit,
runFix,
};
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useState, useCallback } from "react";
import { getServices, serviceAction, type ServiceStatus } from "@/lib/api";
export function useAdminServices() {
const [services, setServices] = useState<ServiceStatus[]>([]);
const [servicesLoading, setServicesLoading] = useState(false);
const [serviceActionLoading, setServiceActionLoading] = useState<string | null>(null);
const [serviceError, setServiceError] = useState("");
const loadServices = useCallback(async () => {
setServicesLoading(true);
setServiceError("");
try {
const data = await getServices();
setServices(data || []);
} catch {
setServiceError("Dienste konnten nicht abgerufen werden.");
} finally {
setServicesLoading(false);
}
}, []);
async function handleServiceAction(name: string, action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external") {
setServiceActionLoading(`${name}:${action}`);
setServiceError("");
try {
const updated = await serviceAction(name, action);
setServices((prev) => prev.map((s) => (s.name === updated.name ? updated : s)));
} catch (e: unknown) {
setServiceError(e instanceof Error ? e.message : "Aktion fehlgeschlagen.");
} finally {
setServiceActionLoading(null);
}
}
return {
services,
servicesLoading,
serviceActionLoading,
serviceError,
loadServices,
handleServiceAction,
};
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useState, useCallback, type Dispatch, type SetStateAction } from "react";
import {
getTenants,
createTenant,
updateTenant,
deleteTenant,
getTenantDomains,
addTenantDomain,
removeTenantDomain,
type Tenant,
type TenantDefaultUser,
type TenantDomain,
} from "@/lib/api";
export function useAdminTenants() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [tenantsLoading, setTenantsLoading] = useState(false);
const [tenantsError, setTenantsError] = useState("");
const [tenantDialogOpen, setTenantDialogOpen] = useState(false);
const [newTenantName, setNewTenantName] = useState("");
const [newTenantSlug, setNewTenantSlug] = useState("");
const [tenantCreateLoading, setTenantCreateLoading] = useState(false);
const [tenantCreateError, setTenantCreateError] = useState("");
const [tenantCreatedUsers, setTenantCreatedUsers] = useState<TenantDefaultUser[]>([]);
const [tenantCreatedName, setTenantCreatedName] = useState("");
const [tenantCredDialogOpen, setTenantCredDialogOpen] = useState(false);
const [tenantDeleteId, setTenantDeleteId] = useState<number | null>(null);
const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false);
const [domainDialogTenant, setDomainDialogTenant] = useState<Tenant | null>(null);
const [tenantDomains, setTenantDomains] = useState<TenantDomain[]>([]);
const [domainsLoading, setDomainsLoading] = useState(false);
const [newDomain, setNewDomain] = useState("");
const [addDomainLoading, setAddDomainLoading] = useState(false);
const [domainError, setDomainError] = useState("");
// Superadmin: tenant LDAP dialog
const [tenantLdapDialogId, setTenantLdapDialogId] = useState<number | null>(null);
const loadTenants = useCallback(async () => {
setTenantsLoading(true);
setTenantsError("");
try {
const data = await getTenants();
setTenants(data || []);
} catch {
setTenantsError("Mandanten konnten nicht geladen werden.");
} finally {
setTenantsLoading(false);
}
}, []);
async function handleCreateTenant(e: React.FormEvent) {
e.preventDefault();
setTenantCreateLoading(true);
setTenantCreateError("");
try {
const result = await createTenant(newTenantName, newTenantSlug);
setTenantDialogOpen(false);
setTenantCreatedName(result.name);
setTenantCreatedUsers(result.default_users ?? []);
setTenantCredDialogOpen(true);
setNewTenantName("");
setNewTenantSlug("");
await loadTenants();
} catch (err: unknown) {
setTenantCreateError(err instanceof Error ? err.message : "Erstellen fehlgeschlagen.");
} finally {
setTenantCreateLoading(false);
}
}
async function handleToggleTenant(t: Tenant) {
try {
await updateTenant(t.id, { active: !t.active });
await loadTenants();
} catch { /* ignore */ }
}
async function handleDeleteTenant() {
if (!tenantDeleteId) return;
setTenantDeleteLoading(true);
try {
await deleteTenant(tenantDeleteId);
setTenantDeleteId(null);
await loadTenants();
} catch { /* ignore */ } finally {
setTenantDeleteLoading(false);
}
}
async function openDomainDialog(t: Tenant) {
setDomainDialogTenant(t);
setDomainsLoading(true);
setDomainError("");
setNewDomain("");
try {
const domains = await getTenantDomains(t.id);
setTenantDomains(domains || []);
} catch { setDomainError("Domains konnten nicht geladen werden."); }
finally { setDomainsLoading(false); }
}
async function handleAddDomain() {
if (!domainDialogTenant || !newDomain) return;
setAddDomainLoading(true);
setDomainError("");
try {
await addTenantDomain(domainDialogTenant.id, newDomain);
setNewDomain("");
const domains = await getTenantDomains(domainDialogTenant.id);
setTenantDomains(domains || []);
} catch (err: unknown) {
setDomainError(err instanceof Error ? err.message : "Domain konnte nicht hinzugefügt werden.");
} finally {
setAddDomainLoading(false);
}
}
async function handleRemoveDomain(domainId: number) {
if (!domainDialogTenant) return;
setDomainError("");
try {
await removeTenantDomain(domainDialogTenant.id, domainId);
const domains = await getTenantDomains(domainDialogTenant.id);
setTenantDomains(domains || []);
} catch (err: unknown) {
setDomainError(err instanceof Error ? err.message : "Domain konnte nicht entfernt werden.");
}
}
return {
tenants,
setTenants: setTenants as Dispatch<SetStateAction<Tenant[]>>,
tenantsLoading,
tenantsError,
tenantDialogOpen,
setTenantDialogOpen,
newTenantName,
setNewTenantName,
newTenantSlug,
setNewTenantSlug,
tenantCreateLoading,
tenantCreateError,
tenantCreatedUsers,
tenantCreatedName,
tenantCredDialogOpen,
setTenantCredDialogOpen,
tenantDeleteId,
setTenantDeleteId,
tenantDeleteLoading,
domainDialogTenant,
setDomainDialogTenant,
tenantDomains,
domainsLoading,
newDomain,
setNewDomain,
addDomainLoading,
domainError,
tenantLdapDialogId,
setTenantLdapDialogId,
loadTenants,
handleCreateTenant,
handleToggleTenant,
handleDeleteTenant,
openDomainDialog,
handleAddDomain,
handleRemoveDomain,
};
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useState, useRef } from "react";
import {
uploadMailFiles,
getUploadProgress,
type UploadJob,
} from "@/lib/api";
export function useAdminUpload() {
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);
async function handleUploadFiles(files: File[]) {
const valid = files.filter(f => f.name.toLowerCase().endsWith(".eml") || f.name.toLowerCase().endsWith(".mbox"));
if (valid.length === 0) {
setUploadError("Nur .eml und .mbox Dateien erlaubt.");
return;
}
setUploadError("");
setUploadJob(null);
setUploadLoading(true);
try {
const { job_id } = await uploadMailFiles(valid);
const poll = setInterval(async () => {
try {
const job = await getUploadProgress(job_id);
setUploadJob(job);
if (job.status !== "running") {
clearInterval(poll);
uploadPollRef.current = null;
setUploadLoading(false);
}
} catch {
clearInterval(poll);
uploadPollRef.current = null;
setUploadLoading(false);
}
}, 1500);
uploadPollRef.current = poll;
} catch (e: unknown) {
setUploadError(e instanceof Error ? e.message : "Upload fehlgeschlagen.");
setUploadLoading(false);
}
}
return {
uploadDragging,
setUploadDragging,
uploadJob,
uploadError,
uploadLoading,
handleUploadFiles,
};
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useState, useCallback } from "react";
import {
getUsers,
createUser,
updateUser,
deleteUser,
type User,
} from "@/lib/api";
export function useAdminUsers() {
const [users, setUsers] = useState<User[]>([]);
const [usersLoading, setUsersLoading] = useState(true);
const [usersError, setUsersError] = useState("");
// Create user dialog
const [dialogOpen, setDialogOpen] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newEmail, setNewEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newRole, setNewRole] = useState("user");
const [createLoading, setCreateLoading] = useState(false);
const [createError, setCreateError] = useState("");
// User action state
const [userActionLoading, setUserActionLoading] = useState<number | null>(null);
const [resetPasswordUserId, setResetPasswordUserId] = useState<number | null>(null);
const [resetPasswordValue, setResetPasswordValue] = useState("");
const [resetPasswordError, setResetPasswordError] = useState("");
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
// Delete confirmation dialog
const [deleteDialogUser, setDeleteDialogUser] = useState<User | null>(null);
const [deleteActionLoading, setDeleteActionLoading] = useState<"deactivate" | "delete" | null>(null);
const [deleteDialogError, setDeleteDialogError] = useState("");
const loadUsers = useCallback(async () => {
setUsersLoading(true);
setUsersError("");
try {
const data = await getUsers();
setUsers(data || []);
} catch {
setUsersError("Benutzer konnten nicht geladen werden.");
} finally {
setUsersLoading(false);
}
}, []);
async function handleCreateUser(e: React.FormEvent) {
e.preventDefault();
setCreateLoading(true);
setCreateError("");
try {
await createUser({
username: newUsername,
email: newEmail,
password: newPassword,
role: newRole,
});
setDialogOpen(false);
setNewUsername("");
setNewEmail("");
setNewPassword("");
setNewRole("user");
loadUsers();
} catch {
setCreateError("Benutzer konnte nicht erstellt werden.");
} finally {
setCreateLoading(false);
}
}
async function handleToggleActive(u: User) {
setUserActionLoading(u.id);
try {
await updateUser(u.id, { active: !u.active });
loadUsers();
} catch {
// ignore
} finally {
setUserActionLoading(null);
}
}
async function handleDeactivateConfirmed() {
if (!deleteDialogUser) return;
setDeleteActionLoading("deactivate");
setDeleteDialogError("");
try {
await updateUser(deleteDialogUser.id, { active: false });
setDeleteDialogUser(null);
loadUsers();
} catch {
setDeleteDialogError("Deaktivierung fehlgeschlagen.");
} finally {
setDeleteActionLoading(null);
}
}
async function handleDeleteConfirmed() {
if (!deleteDialogUser) return;
setDeleteActionLoading("delete");
setDeleteDialogError("");
try {
await deleteUser(deleteDialogUser.id);
setDeleteDialogUser(null);
loadUsers();
} catch (err: unknown) {
setDeleteDialogError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
} finally {
setDeleteActionLoading(null);
}
}
async function handleResetPassword(e: React.FormEvent) {
e.preventDefault();
if (!resetPasswordUserId) return;
setResetPasswordLoading(true);
setResetPasswordError("");
try {
await updateUser(resetPasswordUserId, { password: resetPasswordValue });
setResetPasswordUserId(null);
setResetPasswordValue("");
} catch {
setResetPasswordError("Passwort konnte nicht geändert werden.");
} finally {
setResetPasswordLoading(false);
}
}
return {
users,
usersLoading,
usersError,
dialogOpen,
setDialogOpen,
newUsername,
setNewUsername,
newEmail,
setNewEmail,
newPassword,
setNewPassword,
newRole,
setNewRole,
createLoading,
createError,
userActionLoading,
resetPasswordUserId,
setResetPasswordUserId,
resetPasswordValue,
setResetPasswordValue,
resetPasswordError,
setResetPasswordError,
resetPasswordLoading,
deleteDialogUser,
setDeleteDialogUser,
deleteActionLoading,
deleteDialogError,
setDeleteDialogError,
loadUsers,
handleCreateUser,
handleToggleActive,
handleDeactivateConfirmed,
handleDeleteConfirmed,
handleResetPassword,
};
}
+334
View File
@@ -0,0 +1,334 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
getImapAccounts,
createImapAccount,
deleteImapAccount,
testImapConnection,
startImapImport,
getImapProgress,
triggerImapSync,
updateImapInterval,
updateImapAccount,
type ImapAccount,
type ImapFolder,
} from "@/lib/api";
export function useImapAccounts(user: unknown) {
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(() => {
const refs = pollingRefs.current;
return () => {
for (const interval of refs.values()) {
clearInterval(interval);
}
};
}, []);
const resetForm = useCallback(() => {
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;
});
}
return {
accounts,
loading,
dialogOpen,
setDialogOpen,
deleteConfirm,
setDeleteConfirm,
// edit
editAccount,
setEditAccount,
editName,
setEditName,
editHost,
setEditHost,
editPort,
setEditPort,
editTls,
setEditTls,
editUsername,
setEditUsername,
editPassword,
setEditPassword,
editSaving,
editError,
// form
formName,
setFormName,
formHost,
setFormHost,
formPort,
setFormPort,
formTls,
setFormTls,
formUsername,
setFormUsername,
formPassword,
setFormPassword,
// test
testing,
testError,
testFolders,
excludedFolders,
// saving
saving,
importError,
// handlers
resetForm,
handleTest,
handleSave,
handleStartImport,
handleDelete,
openEdit,
handleEditSave,
handleSyncNow,
handleIntervalChange,
toggleExcluded,
};
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useState, useRef } from "react";
import {
uploadMailFilesUser,
getUploadProgressUser,
type UploadJob,
} from "@/lib/api";
export function useMailUpload(onImportDone: () => void) {
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);
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") onImportDone();
}
} 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);
}
return {
uploadOpen,
setUploadOpen,
uploadDragging,
setUploadDragging,
uploadJob,
uploadError,
uploadLoading,
handleUploadFiles,
handleUploadClose,
};
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { changePassword } from "@/lib/api";
export function usePasswordChange() {
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);
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);
}
}
return {
currentPw,
setCurrentPw,
newPw,
setNewPw,
confirmPw,
setConfirmPw,
pwError,
pwSuccess,
pwLoading,
handleChangePassword,
};
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { changeEmail, updatePreferences, type MeResponse } from "@/lib/api";
export function useProfileSettings(
user: MeResponse | null,
refresh: () => Promise<void> | void
) {
// ── Email state ───────────────────────────────────────────────────────
const [email, setEmail] = useState("");
const [emailInitialized, setEmailInitialized] = useState(false);
const [emailError, setEmailError] = useState("");
const [emailSuccess, setEmailSuccess] = useState("");
const [emailLoading, setEmailLoading] = 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);
// 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);
}
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);
}
}
return {
email,
setEmail,
emailError,
emailSuccess,
emailLoading,
handleChangeEmail,
listPageSize,
listPageSizeError,
listPageSizeSuccess,
listPageSizeLoading,
handleChangeListPageSize,
};
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { useState, useEffect } from "react";
import {
searchEmails,
listSavedSearches,
createSavedSearch,
deleteSavedSearch,
type SearchHit,
type SavedSearch,
} from "@/lib/api";
type SavedSearchUser = { role: string } | null;
interface UseSavedSearchesParams {
user: SavedSearchUser;
pageSize: number;
query: string;
fromFilter: string;
toFilter: string;
dateFrom: string;
dateTo: string;
hasAttachment: boolean | undefined;
setQuery: (v: string) => void;
setFromFilter: (v: string) => void;
setToFilter: (v: string) => void;
setDateFrom: (v: string) => void;
setDateTo: (v: string) => void;
setHasAttachment: (v: boolean | undefined) => void;
setResults: (v: SearchHit[]) => void;
setTotal: (v: number) => void;
setPage: (v: number) => void;
setSearching: (v: boolean) => void;
setSearched: (v: boolean) => void;
}
export function useSavedSearches(params: UseSavedSearchesParams) {
const {
user,
pageSize,
query,
fromFilter,
toFilter,
dateFrom,
dateTo,
hasAttachment,
setQuery,
setFromFilter,
setToFilter,
setDateFrom,
setDateTo,
setHasAttachment,
setResults,
setTotal,
setPage,
setSearching,
setSearched,
} = params;
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);
// 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]);
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}`);
}
}
return {
savedSearches,
savedLoading,
savePopoverOpen,
setSavePopoverOpen,
saveName,
setSaveName,
saving,
savedListOpen,
setSavedListOpen,
buildCurrentQuery,
handleSaveSearch,
handleApplySavedSearch,
handleDeleteSavedSearch,
};
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { searchEmails, type SearchHit } from "@/lib/api";
const DEFAULT_PAGE_SIZE = 25;
type SearchUser = { role: string } | null;
export function useSearch(user: SearchUser, pageSize: number) {
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 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]
);
// 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]);
return {
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,
};
}
export { DEFAULT_PAGE_SIZE };
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useEffect, useState } from "react";
import { getSystemInfo, type SystemInfo } from "@/lib/api";
export function useSystemInfo() {
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;
};
}, []);
return { systemInfo, systemInfoLoading };
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useState, type Dispatch, type SetStateAction } from "react";
import {
getTenantLogoUrl,
uploadTenantLogo,
deleteTenantLogo,
uploadMyTenantLogo,
deleteMyTenantLogo,
type Tenant,
} from "@/lib/api";
interface OwnLogoController {
setOwnLogoPreviewUrl: Dispatch<SetStateAction<string | null>>;
setOwnLogoUploading: Dispatch<SetStateAction<boolean>>;
setOwnLogoError: Dispatch<SetStateAction<string>>;
}
export function useTenantLogos(
setTenants: Dispatch<SetStateAction<Tenant[]>>,
own: OwnLogoController,
) {
// Logo dialog (superadmin: any tenant)
const [logoDialogTenant, setLogoDialogTenant] = useState<Tenant | null>(null);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [logoUploading, setLogoUploading] = useState(false);
const [logoError, setLogoError] = useState("");
async function openLogoDialog(t: Tenant) {
setLogoDialogTenant(t);
setLogoError("");
setLogoPreviewUrl(null);
if (t.has_logo) {
try {
const res = await fetch(getTenantLogoUrl(t.id), { credentials: "include" });
if (res.ok) {
const blob = await res.blob();
setLogoPreviewUrl(URL.createObjectURL(blob));
}
} catch {
// preview not critical
}
}
}
async function handleLogoUpload(file: File) {
if (!logoDialogTenant) return;
setLogoUploading(true);
setLogoError("");
try {
await uploadTenantLogo(logoDialogTenant.id, file);
const res = await fetch(getTenantLogoUrl(logoDialogTenant.id), { credentials: "include" });
if (res.ok) {
const blob = await res.blob();
setLogoPreviewUrl(URL.createObjectURL(blob));
}
setTenants((prev) => prev.map((t) => t.id === logoDialogTenant.id ? { ...t, has_logo: true } : t));
} catch (err: unknown) {
setLogoError(err instanceof Error ? err.message : "Upload fehlgeschlagen.");
} finally {
setLogoUploading(false);
}
}
async function handleLogoDelete() {
if (!logoDialogTenant) return;
setLogoUploading(true);
setLogoError("");
try {
await deleteTenantLogo(logoDialogTenant.id);
setLogoPreviewUrl(null);
setTenants((prev) => prev.map((t) => t.id === logoDialogTenant.id ? { ...t, has_logo: false } : t));
} catch (err: unknown) {
setLogoError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
} finally {
setLogoUploading(false);
}
}
// Logo handlers (domain_admin: own tenant)
async function handleOwnLogoUpload(file: File) {
own.setOwnLogoUploading(true);
own.setOwnLogoError("");
try {
await uploadMyTenantLogo(file);
const res = await fetch(`/api/tenant/logo`, { credentials: "include" });
if (res.ok) {
const blob = await res.blob();
own.setOwnLogoPreviewUrl(URL.createObjectURL(blob));
}
} catch (err: unknown) {
own.setOwnLogoError(err instanceof Error ? err.message : "Upload fehlgeschlagen.");
} finally {
own.setOwnLogoUploading(false);
}
}
async function handleOwnLogoDelete() {
own.setOwnLogoUploading(true);
own.setOwnLogoError("");
try {
await deleteMyTenantLogo();
own.setOwnLogoPreviewUrl(null);
} catch (err: unknown) {
own.setOwnLogoError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
} finally {
own.setOwnLogoUploading(false);
}
}
return {
logoDialogTenant,
setLogoDialogTenant,
logoPreviewUrl,
setLogoPreviewUrl,
logoUploading,
logoError,
openLogoDialog,
handleLogoUpload,
handleLogoDelete,
handleOwnLogoUpload,
handleOwnLogoDelete,
};
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { getTOTPSetup, confirmTOTPSetup, disableTOTP } from "@/lib/api";
export function useTotpSettings() {
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);
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);
}
}
function cancelSetup() {
setShowSetup(false);
setQrCode("");
setSecret("");
setTotpCode("");
setTotpError("");
}
function openDisableDialog() {
setDisableDialogOpen(true);
setDisableCode("");
setDisableError("");
}
return {
totpEnabled,
showSetup,
qrCode,
secret,
totpCode,
setTotpCode,
totpError,
totpSuccess,
totpLoading,
disableDialogOpen,
setDisableDialogOpen,
disableCode,
setDisableCode,
disableError,
disableLoading,
handleSetupTOTP,
handleConfirmTOTP,
handleDisableTOTP,
cancelSetup,
openDisableDialog,
};
}