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:
+177
-734
File diff suppressed because it is too large
Load Diff
+105
-708
@@ -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 "Konto
|
||||
hinzufuegen", 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()}) · {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 "Konto
|
||||
hinzufuegen", 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
@@ -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
@@ -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 & 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>
|
||||
);
|
||||
|
||||
@@ -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()}) · {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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user