chore: Frontend-Seiten in Komponenten/Hooks aufteilen

admin/page.tsx (1019→446), search/page.tsx (871→304), imap/page.tsx
(738→142), settings/page.tsx (641→56). Reine Umstrukturierung,
keine Verhaltensänderung, Build verifiziert.
This commit is contained in:
sysops
2026-06-22 11:29:43 +02:00
parent ce197a3ab7
commit a55faf74b1
33 changed files with 4000 additions and 2764 deletions
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { type ImapAccount } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
function statusBadge(status: string) {
switch (status) {
case "running":
return <Badge className="bg-blue-600 text-white">Importiert...</Badge>;
case "error":
return <Badge variant="destructive">Fehler</Badge>;
default:
return <Badge variant="secondary">Bereit</Badge>;
}
}
function syncBadge(acc: ImapAccount) {
if (acc.sync_running) {
return <Badge className="bg-blue-500 text-white">Sync laeuft...</Badge>;
}
if (!acc.sync_status) return null;
if (acc.sync_status === "ok") {
return <Badge className="bg-green-600 text-white">Sync OK</Badge>;
}
if (acc.sync_status === "error") {
return <Badge variant="destructive">Sync Fehler</Badge>;
}
return null;
}
interface ImapAccountCardProps {
acc: ImapAccount;
onStartImport: (id: number) => void;
onSyncNow: (id: number) => void;
onEdit: (acc: ImapAccount) => void;
onDelete: (id: number) => void;
onIntervalChange: (id: number, value: string) => void;
}
export function ImapAccountCard({
acc,
onStartImport,
onSyncNow,
onEdit,
onDelete,
onIntervalChange,
}: ImapAccountCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<h3 className="font-semibold">{acc.name}</h3>
<p className="text-sm text-muted-foreground">
{acc.host}:{acc.port} ({acc.tls.toUpperCase()}) &middot; {acc.username}
</p>
</div>
{statusBadge(acc.status)}
</CardHeader>
<CardContent>
{acc.status === "running" && acc.progress_total === 0 && (
<div className="mb-3 space-y-1">
<Progress value={undefined} className="animate-pulse" />
<p className="text-xs text-muted-foreground">
Zaehle E-Mails auf dem Server...
</p>
</div>
)}
{acc.status === "running" && acc.progress_total > 0 && (
<div className="mb-3 space-y-1">
<Progress
value={(acc.progress_current / acc.progress_total) * 100}
/>
<p className="text-xs text-muted-foreground">
{acc.progress_current} von {acc.progress_total} E-Mails
</p>
</div>
)}
{acc.status === "error" && acc.error_msg && (
<p className="mb-3 text-sm text-destructive">{acc.error_msg}</p>
)}
{acc.last_import_at && (
<p className="text-sm text-muted-foreground mb-3">
Letzter Import:{" "}
{new Date(acc.last_import_at).toLocaleString("de-DE")} (
{acc.last_import_count} E-Mails)
</p>
)}
{/* PROJ-8: Sync status */}
{acc.last_sync_at && (
<div className="flex items-center gap-2 mb-3">
<p className="text-sm text-muted-foreground">
Letzter Sync:{" "}
{new Date(acc.last_sync_at).toLocaleString("de-DE")} (
{acc.last_sync_count} neu)
</p>
{syncBadge(acc)}
</div>
)}
{acc.sync_running && !acc.last_sync_at && syncBadge(acc)}
{acc.sync_error_msg && acc.sync_status === "error" && (
<p className="mb-3 text-sm text-destructive">
Sync-Fehler: {acc.sync_error_msg}
</p>
)}
{acc.excluded_folders && acc.excluded_folders.length > 0 && (
<p className="text-xs text-muted-foreground mb-3">
Ausgeschlossene Ordner: {acc.excluded_folders.join(", ")}
</p>
)}
{/* PROJ-8: Sync interval selector */}
<div className="mb-3 flex items-center gap-3">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Auto-Sync:
</span>
<Select
value={String(acc.sync_interval_min ?? 0)}
onValueChange={(v) => onIntervalChange(acc.id, v)}
>
<SelectTrigger className="w-40 h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Deaktiviert</SelectItem>
<SelectItem value="5">5 min</SelectItem>
<SelectItem value="15">15 min</SelectItem>
<SelectItem value="30">30 min</SelectItem>
<SelectItem value="60">1 Stunde</SelectItem>
<SelectItem value="360">6 Stunden</SelectItem>
<SelectItem value="1440">24 Stunden</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
disabled={acc.status === "running"}
onClick={() => onStartImport(acc.id)}
>
Import starten
</Button>
<Button
size="sm"
variant="outline"
disabled={acc.status === "running" || acc.sync_running}
onClick={() => onSyncNow(acc.id)}
>
Sync jetzt
</Button>
<Button size="sm" variant="outline" onClick={() => onEdit(acc)}>
Bearbeiten
</Button>
<Button
size="sm"
variant="destructive"
disabled={acc.status === "running"}
onClick={() => onDelete(acc.id)}
>
Loeschen
</Button>
</div>
</CardContent>
</Card>
);
}
+206
View File
@@ -0,0 +1,206 @@
"use client";
import { type ImapFolder } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
interface ImapAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
formName: string;
setFormName: (v: string) => void;
formHost: string;
setFormHost: (v: string) => void;
formPort: string;
setFormPort: (v: string) => void;
formTls: string;
setFormTls: (v: string) => void;
formUsername: string;
setFormUsername: (v: string) => void;
formPassword: string;
setFormPassword: (v: string) => void;
testing: boolean;
testError: string;
testFolders: ImapFolder[] | null;
excludedFolders: Set<string>;
saving: boolean;
onTest: () => void;
onSave: () => void;
onCancel: () => void;
onToggleExcluded: (name: string) => void;
}
export function ImapAccountDialog({
open,
onOpenChange,
formName,
setFormName,
formHost,
setFormHost,
formPort,
setFormPort,
formTls,
setFormTls,
formUsername,
setFormUsername,
formPassword,
setFormPassword,
testing,
testError,
testFolders,
excludedFolders,
saving,
onTest,
onSave,
onCancel,
onToggleExcluded,
}: ImapAccountDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>IMAP-Konto hinzufuegen</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="imap-name">Name</Label>
<Input
id="imap-name"
placeholder="z.B. Firmen-Mail"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="imap-host">Host</Label>
<Input
id="imap-host"
placeholder="imap.example.com"
value={formHost}
onChange={(e) => setFormHost(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="imap-port">Port</Label>
<Input
id="imap-port"
type="number"
value={formPort}
onChange={(e) => setFormPort(e.target.value)}
/>
</div>
</div>
<div className="space-y-1">
<Label>Verschluesselung</Label>
<Select value={formTls} onValueChange={setFormTls}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ssl">SSL/TLS</SelectItem>
<SelectItem value="starttls">STARTTLS</SelectItem>
<SelectItem value="none">Unverschluesselt</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="imap-user">Benutzername</Label>
<Input
id="imap-user"
placeholder="user@example.com"
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="imap-pass">Passwort</Label>
<Input
id="imap-pass"
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
/>
</div>
<Button
variant="outline"
onClick={onTest}
disabled={testing || !formHost || !formUsername || !formPassword}
className="w-full"
>
{testing ? "Teste Verbindung..." : "Verbindung testen"}
</Button>
{testError && <p className="text-sm text-destructive">{testError}</p>}
{testFolders && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Erkannte Ordner</h4>
<div className="space-y-2 max-h-48 overflow-y-auto">
{testFolders.map((folder) => (
<div key={folder.name} className="flex items-center gap-2">
<Checkbox
id={`folder-${folder.name}`}
checked={!excludedFolders.has(folder.name)}
onCheckedChange={() => onToggleExcluded(folder.name)}
/>
<Label
htmlFor={`folder-${folder.name}`}
className="text-sm flex-1 cursor-pointer"
>
{folder.name}
</Label>
{folder.excluded && folder.reason && (
<span className="text-xs text-muted-foreground">
({folder.reason === "special_use"
? "IMAP-Flag"
: "Namens-Erkennung"})
</span>
)}
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">
Deaktivierte Ordner werden nicht importiert.
</p>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Abbrechen
</Button>
<Button
onClick={onSave}
disabled={
saving || !formName || !formHost || !formUsername || !formPassword
}
>
{saving ? "Speichert..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
interface ImapDeleteDialogProps {
deleteConfirm: number | null;
onCancel: () => void;
onConfirm: (id: number) => void;
}
export function ImapDeleteDialog({
deleteConfirm,
onCancel,
onConfirm,
}: ImapDeleteDialogProps) {
return (
<Dialog open={deleteConfirm !== null} onOpenChange={onCancel}>
<DialogContent>
<DialogHeader>
<DialogTitle>Konto loeschen?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Soll dieses IMAP-Konto wirklich entfernt werden? Bereits importierte
E-Mails bleiben im Archiv erhalten.
</p>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm !== null && onConfirm(deleteConfirm)}
>
Loeschen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { type ImapAccount } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface ImapEditDialogProps {
editAccount: ImapAccount | null;
onClose: () => void;
editName: string;
setEditName: (v: string) => void;
editHost: string;
setEditHost: (v: string) => void;
editPort: string;
setEditPort: (v: string) => void;
editTls: string;
setEditTls: (v: string) => void;
editUsername: string;
setEditUsername: (v: string) => void;
editPassword: string;
setEditPassword: (v: string) => void;
editSaving: boolean;
editError: string;
onSave: () => void;
}
export function ImapEditDialog({
editAccount,
onClose,
editName,
setEditName,
editHost,
setEditHost,
editPort,
setEditPort,
editTls,
setEditTls,
editUsername,
setEditUsername,
editPassword,
setEditPassword,
editSaving,
editError,
onSave,
}: ImapEditDialogProps) {
return (
<Dialog
open={editAccount !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>IMAP-Konto bearbeiten</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label>Name</Label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="z.B. Firmen-Mail"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Host</Label>
<Input
value={editHost}
onChange={(e) => setEditHost(e.target.value)}
placeholder="imap.example.com"
/>
</div>
<div className="space-y-1">
<Label>Port</Label>
<Input
value={editPort}
onChange={(e) => setEditPort(e.target.value)}
type="number"
/>
</div>
</div>
<div className="space-y-1">
<Label>TLS</Label>
<Select value={editTls} onValueChange={setEditTls}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ssl">SSL/TLS</SelectItem>
<SelectItem value="starttls">STARTTLS</SelectItem>
<SelectItem value="none">Keine</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Benutzername</Label>
<Input
value={editUsername}
onChange={(e) => setEditUsername(e.target.value)}
placeholder="user@example.com"
/>
</div>
<div className="space-y-1">
<Label>
Passwort{" "}
<span className="text-muted-foreground text-xs">
(leer lassen = unveraendert)
</span>
</Label>
<Input
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
type="password"
placeholder="Neues Passwort eingeben"
/>
</div>
{editError && <p className="text-sm text-destructive">{editError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={onSave} disabled={editSaving}>
{editSaving ? "Speichert..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
import { type UploadJob } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface ExportZipDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedCount: number;
exportAttachments: boolean;
setExportAttachments: (v: boolean) => void;
exporting: boolean;
onExport: () => void;
}
export function ExportZipDialog({
open,
onOpenChange,
selectedCount,
exportAttachments,
setExportAttachments,
exporting,
onExport,
}: ExportZipDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>E-Mails exportieren</DialogTitle>
<DialogDescription>
{selectedCount} E-Mail{selectedCount !== 1 ? "s" : ""} als ZIP herunterladen
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-3 py-2">
<Switch
id="attachments"
checked={exportAttachments}
onCheckedChange={setExportAttachments}
/>
<Label htmlFor="attachments">Anhänge einschließen</Label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Abbrechen
</Button>
<Button onClick={onExport} disabled={exporting}>
{exporting ? "Wird exportiert..." : "ZIP herunterladen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface EDiscoveryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
caseName: string;
setCaseName: (v: string) => void;
loading: boolean;
onExport: () => void;
query: string;
fromFilter: string;
toFilter: string;
dateFrom: string;
dateTo: string;
}
export function EDiscoveryDialog({
open,
onOpenChange,
caseName,
setCaseName,
loading,
onExport,
query,
fromFilter,
toFilter,
dateFrom,
dateTo,
}: EDiscoveryDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>eDiscovery Export</DialogTitle>
<DialogDescription>
Exportiert alle Mails der aktuellen Suche als ZIP mit Metadaten-CSV und README.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="case-name">Case-Name (optional)</Label>
<input
id="case-name"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="z.B. Ermittlung-2026-Q1"
value={caseName}
onChange={(e) => setCaseName(e.target.value)}
/>
</div>
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground space-y-1">
<div><span className="font-medium">Aktive Filter:</span></div>
{query && <div>Suche: <span className="font-mono">{query}</span></div>}
{fromFilter && <div>Von: <span className="font-mono">{fromFilter}</span></div>}
{toFilter && <div>An: <span className="font-mono">{toFilter}</span></div>}
{dateFrom && <div>Von Datum: {dateFrom}</div>}
{dateTo && <div>Bis Datum: {dateTo}</div>}
{!query && !fromFilter && !toFilter && !dateFrom && !dateTo && (
<div className="italic">Keine Filter alle archivierten Mails werden exportiert</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Abbrechen
</Button>
<Button onClick={onExport} disabled={loading}>
{loading ? "Wird exportiert..." : "ZIP herunterladen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface UploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
dragging: boolean;
setDragging: (v: boolean) => void;
job: UploadJob | null;
error: string;
loading: boolean;
onUploadFiles: (files: FileList | File[]) => void;
onClose: () => void;
}
export function UploadDialog({
open,
onOpenChange,
dragging,
setDragging,
job,
error,
loading,
onUploadFiles,
onClose,
}: UploadDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>E-Mails importieren</DialogTitle>
<DialogDescription>
EML- oder MBOX-Dateien in das Archiv hochladen
</DialogDescription>
</DialogHeader>
{!job && (
<div
className={`flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
dragging ? "border-primary bg-primary/5" : "border-muted-foreground/30"
}`}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files.length > 0) onUploadFiles(e.dataTransfer.files);
}}
>
<p className="text-sm text-muted-foreground mb-3">
Dateien hierher ziehen oder auswählen
</p>
<label>
<input
type="file"
accept=".eml,.mbox"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) onUploadFiles(e.target.files); }}
/>
<Button type="button" variant="outline" size="sm" asChild>
<span>Dateien auswählen</span>
</Button>
</label>
<p className="mt-2 text-xs text-muted-foreground">.eml · .mbox</p>
</div>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{job && (
<div className="space-y-3">
<Progress
value={job.total > 0 ? Math.round(((job.imported + job.skipped + job.errors) / job.total) * 100) : 0}
/>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div>
<div className="font-semibold text-green-600">{job.imported}</div>
<div className="text-xs text-muted-foreground">Importiert</div>
</div>
<div>
<div className="font-semibold text-yellow-600">{job.skipped}</div>
<div className="text-xs text-muted-foreground">Duplikate</div>
</div>
<div>
<div className="font-semibold text-red-600">{job.errors}</div>
<div className="text-xs text-muted-foreground">Fehler</div>
</div>
</div>
{job.status === "running" && (
<p className="text-xs text-center text-muted-foreground">
Verarbeite {job.imported + job.skipped + job.errors} / {job.total}
</p>
)}
{job.status === "done" && (
<p className="text-xs text-center text-green-600 font-medium">Abgeschlossen</p>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading && job?.status === "running"}>
{job?.status === "done" ? "Schließen" : "Abbrechen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+270
View File
@@ -0,0 +1,270 @@
"use client";
import { type SavedSearch } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Bookmark, BookmarkPlus, Trash2 } from "lucide-react";
interface SearchFilterBarProps {
query: string;
setQuery: (v: string) => void;
fromFilter: string;
setFromFilter: (v: string) => void;
toFilter: string;
setToFilter: (v: string) => void;
dateFrom: string;
setDateFrom: (v: string) => void;
dateTo: string;
setDateTo: (v: string) => void;
sort: string;
setSort: (v: string) => void;
hasAttachment: boolean | undefined;
setHasAttachment: (v: boolean | undefined) => void;
searching: boolean;
onSubmit: (e: React.FormEvent) => void;
onOpenUpload: () => void;
// Saved searches
hasActiveSearch: boolean;
savePopoverOpen: boolean;
setSavePopoverOpen: (v: boolean) => void;
saveName: string;
setSaveName: (v: string) => void;
saving: boolean;
onSaveSearch: () => void;
savedListOpen: boolean;
setSavedListOpen: (v: boolean) => void;
savedLoading: boolean;
savedSearches: SavedSearch[];
onApplySavedSearch: (s: SavedSearch) => void;
onDeleteSavedSearch: (id: number) => void;
}
export function SearchFilterBar(props: SearchFilterBarProps) {
const {
query,
setQuery,
fromFilter,
setFromFilter,
toFilter,
setToFilter,
dateFrom,
setDateFrom,
dateTo,
setDateTo,
sort,
setSort,
hasAttachment,
setHasAttachment,
searching,
onSubmit,
onOpenUpload,
hasActiveSearch,
savePopoverOpen,
setSavePopoverOpen,
saveName,
setSaveName,
saving,
onSaveSearch,
savedListOpen,
setSavedListOpen,
savedLoading,
savedSearches,
onApplySavedSearch,
onDeleteSavedSearch,
} = props;
return (
<form onSubmit={onSubmit} className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Volltextsuche..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full flex-1 sm:w-auto"
aria-label="Suchbegriff"
/>
<Button type="submit" disabled={searching} className="flex-1 sm:flex-none">
{searching ? "Suche..." : "Suchen"}
</Button>
<Button type="button" variant="outline" onClick={onOpenUpload} className="flex-1 sm:flex-none">
Importieren
</Button>
{hasActiveSearch && (
<Popover open={savePopoverOpen} onOpenChange={setSavePopoverOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="icon" title="Suche speichern" aria-label="Suche speichern">
<BookmarkPlus className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<div className="space-y-3">
<p className="text-sm font-medium">Suche speichern</p>
<Input
placeholder="Name der Suche..."
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") onSaveSearch(); }}
aria-label="Name der gespeicherten Suche"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => setSavePopoverOpen(false)}>
Abbrechen
</Button>
<Button size="sm" onClick={onSaveSearch} disabled={saving || !saveName.trim()}>
{saving ? "Speichern..." : "Speichern"}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
<Popover open={savedListOpen} onOpenChange={setSavedListOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="icon" title="Gespeicherte Suchen" aria-label="Gespeicherte Suchen">
<Bookmark className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<p className="text-sm font-medium mb-3">Gespeicherte Suchen</p>
{savedLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : savedSearches.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
Keine gespeicherten Suchen vorhanden.
</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{savedSearches.map((s) => (
<div
key={s.id}
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted group"
>
<button
type="button"
className="flex-1 text-left text-sm truncate"
onClick={() => onApplySavedSearch(s)}
title={Object.entries(s.query).map(([k, v]) => `${k}: ${v}`).join(", ")}
>
{s.name}
</button>
<span className="text-xs text-muted-foreground whitespace-nowrap hidden group-hover:inline">
{new Date(s.created_at).toLocaleDateString("de-DE")}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => { e.stopPropagation(); onDeleteSavedSearch(s.id); }}
aria-label={`Suche "${s.name}" loeschen`}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
))}
</div>
)}
</PopoverContent>
</Popover>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label htmlFor="from-filter" className="text-xs">
Von (Absender)
</Label>
<Input
id="from-filter"
placeholder="absender@example.com"
value={fromFilter}
onChange={(e) => setFromFilter(e.target.value)}
aria-label="Absender filtern"
/>
</div>
<div className="space-y-1">
<Label htmlFor="to-filter" className="text-xs">
An (Empfänger)
</Label>
<Input
id="to-filter"
placeholder="empfaenger@example.com"
value={toFilter}
onChange={(e) => setToFilter(e.target.value)}
aria-label="Empfänger filtern"
/>
</div>
<div className="space-y-1">
<Label htmlFor="date-from" className="text-xs">
Datum von
</Label>
<Input
id="date-from"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
aria-label="Datum von"
/>
</div>
<div className="space-y-1">
<Label htmlFor="date-to" className="text-xs">
Datum bis
</Label>
<Input
id="date-to"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
aria-label="Datum bis"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor="sort-select" className="text-xs whitespace-nowrap">Sortierung</Label>
<Select value={sort} onValueChange={setSort}>
<SelectTrigger id="sort-select" className="h-8 w-40 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date_desc">Datum (neu alt)</SelectItem>
<SelectItem value="date_asc">Datum (alt neu)</SelectItem>
<SelectItem value="relevance">Relevanz</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch
id="attach-toggle"
checked={hasAttachment === true}
onCheckedChange={(checked) =>
setHasAttachment(checked ? true : undefined)
}
/>
<Label htmlFor="attach-toggle" className="text-xs cursor-pointer">
Nur mit Anhang
</Label>
</div>
</div>
</form>
);
}
@@ -0,0 +1,187 @@
"use client";
import { useRouter } from "next/navigation";
import { type SearchHit, type SearchMatchField } from "@/lib/api";
import { sanitizeSnippet } from "@/lib/sanitize";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const MATCH_FIELD_LABEL: Record<SearchMatchField, string> = {
subject: "📨 Subject",
body: "✉️ Body",
attachment_text: "📄 PDF-Anhang",
attachment_names: "📎 Dateiname",
from_addr: "👤 Absender",
to_addr: "📧 Empfänger",
};
function MatchSourceBadge({ field }: { field: SearchMatchField }) {
return (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground whitespace-nowrap">
{MATCH_FIELD_LABEL[field]}
</span>
);
}
function SnippetLine({ hit }: { hit: SearchHit }) {
if (!hit.snippet) return null;
return (
<div className="mt-1 flex items-start gap-2 text-xs text-muted-foreground font-normal">
{hit.match_field && <MatchSourceBadge field={hit.match_field} />}
<span
className="min-w-0 flex-1 truncate [&_b]:font-semibold [&_b]:text-foreground"
dangerouslySetInnerHTML={{ __html: sanitizeSnippet(hit.snippet) }}
/>
</div>
);
}
interface SearchResultsTableProps {
results: SearchHit[];
selected: Set<string>;
setSelected: React.Dispatch<React.SetStateAction<Set<string>>>;
total: number;
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function SearchResultsTable({
results,
selected,
setSelected,
page,
totalPages,
onPageChange,
}: SearchResultsTableProps) {
const router = useRouter();
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
return (
<>
<Card className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => {
if (checked) setSelected(new Set(results.map((h) => h.id)));
else setSelected(new Set());
}}
aria-label="Alle auswählen"
/>
</TableHead>
<TableHead className="w-28 sm:w-32">Datum</TableHead>
<TableHead className="hidden w-56 md:table-cell">Von</TableHead>
<TableHead>Betreff</TableHead>
<TableHead className="hidden w-48 lg:table-cell">An</TableHead>
<TableHead className="w-8 text-center" title="Anhang">📎</TableHead>
<TableHead className="hidden w-20 text-right sm:table-cell">Größe</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((hit) => (
<TableRow
key={hit.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => router.push(`/mail/${hit.id}`)}
role="link"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/mail/${hit.id}`);
}}
aria-label={`E-Mail von ${hit.from || "unbekannt"}: ${hit.subject || "Kein Betreff"}`}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected.has(hit.id)}
onCheckedChange={(checked) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(hit.id);
else next.delete(hit.id);
return next;
});
}}
aria-label="Mail auswählen"
/>
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{hit.date
? new Date(hit.date).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" })
: "-"}
</TableCell>
<TableCell className="hidden max-w-[14rem] truncate text-sm md:table-cell">{hit.from || "-"}</TableCell>
<TableCell className="max-w-[60vw] font-medium sm:max-w-none">
<div className="flex items-center gap-2">
<span className="truncate">{hit.subject || "(kein Betreff)"}</span>
{hit.thread_size && hit.thread_size > 1 && (
<span className="inline-flex items-center rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground font-normal">
{hit.thread_size}
</span>
)}
</div>
{/* Absender nur auf Mobile, da Spalte "Von" dort ausgeblendet ist */}
{hit.from && (
<div className="mt-0.5 truncate text-xs text-muted-foreground md:hidden">
{hit.from}
</div>
)}
<SnippetLine hit={hit} />
</TableCell>
<TableCell className="hidden max-w-[12rem] truncate text-sm text-muted-foreground lg:table-cell">{hit.to || "-"}</TableCell>
<TableCell className="text-center text-sm">
{hit.has_attachments ? "📎" : ""}
</TableCell>
<TableCell className="hidden text-right text-xs text-muted-foreground whitespace-nowrap sm:table-cell">
{hit.size ? formatBytes(hit.size) : ""}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
Zurueck
</Button>
<span className="text-sm text-muted-foreground">
Seite {page} von {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Weiter
</Button>
</div>
)}
</>
);
}
@@ -0,0 +1,68 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { useProfileSettings } from "@/hooks/useProfileSettings";
type DisplaySectionProps = Pick<
ReturnType<typeof useProfileSettings>,
| "listPageSize"
| "listPageSizeError"
| "listPageSizeSuccess"
| "listPageSizeLoading"
| "handleChangeListPageSize"
>;
export function DisplaySection({
listPageSize,
listPageSizeError,
listPageSizeSuccess,
listPageSizeLoading,
handleChangeListPageSize,
}: DisplaySectionProps) {
return (
<Card>
<CardHeader>
<CardTitle>Listenansicht</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="list-page-size">Eintraege pro Seite</Label>
<Select
value={listPageSize}
onValueChange={handleChangeListPageSize}
disabled={listPageSizeLoading}
>
<SelectTrigger id="list-page-size" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
{listPageSizeError && (
<Alert variant="destructive">
<AlertDescription>{listPageSizeError}</AlertDescription>
</Alert>
)}
{listPageSizeSuccess && (
<Alert>
<AlertDescription>{listPageSizeSuccess}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Server, Lock, Info } from "lucide-react";
import type { SystemInfo } from "@/lib/api";
interface ImapSectionProps {
systemInfo: SystemInfo | null;
systemInfoLoading: boolean;
username: string;
}
export function ImapSection({
systemInfo,
systemInfoLoading,
username,
}: ImapSectionProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Server className="h-5 w-5" aria-hidden="true" />
IMAP-Zugang
<Badge variant="default" className="bg-green-600">
Verfuegbar
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Verbinden Sie Ihren Mail-Client (Thunderbird, Outlook, Apple Mail)
mit folgenden Zugangsdaten:
</p>
<div
className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm"
role="list"
aria-label="IMAP-Verbindungsdaten"
>
<span className="text-muted-foreground flex items-center gap-1.5">
<Server className="h-3.5 w-3.5" aria-hidden="true" />
Server
</span>
<span className="font-mono">
{systemInfoLoading
? "Laden..."
: systemInfo?.fqdn ||
(typeof window !== "undefined"
? window.location.hostname
: "")}
</span>
<span className="text-muted-foreground flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
IMAP-Port
</span>
<span className="font-mono">
{systemInfo?.imap_port ?? 9993} (SSL/TLS)
</span>
<span className="text-muted-foreground flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
Alternativ-Port
</span>
<span className="font-mono">
{systemInfo?.imap_port_alt ?? 993} (SSL/TLS)
</span>
<span className="text-muted-foreground flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
Sicherheit
</span>
<span className="font-mono">SSL/TLS</span>
<span className="text-muted-foreground">Benutzername</span>
<span className="font-mono">{username}</span>
<span className="text-muted-foreground">Passwort</span>
<span className="text-sm italic">Ihr archivmail-Passwort</span>
</div>
<div className="flex items-start gap-2 rounded-md border border-yellow-300 bg-yellow-50 px-3 py-2 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950 dark:text-yellow-200">
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span>
Der IMAP-Zugang ist schreibgeschuetzt. Loeschen, Verschieben und
neue Mails einlegen sind nicht moeglich.
</span>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,86 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import type { usePasswordChange } from "@/hooks/usePasswordChange";
type PasswordSectionProps = ReturnType<typeof usePasswordChange>;
export function PasswordSection({
currentPw,
setCurrentPw,
newPw,
setNewPw,
confirmPw,
setConfirmPw,
pwError,
pwSuccess,
pwLoading,
handleChangePassword,
}: PasswordSectionProps) {
return (
<Card>
<CardHeader>
<CardTitle>Passwort aendern</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Aktuelles Passwort</Label>
<Input
id="current-password"
type="password"
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
required
autoComplete="current-password"
aria-label="Aktuelles Passwort"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Neues Passwort</Label>
<Input
id="new-password"
type="password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
required
minLength={8}
autoComplete="new-password"
aria-label="Neues Passwort"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Neues Passwort bestaetigen</Label>
<Input
id="confirm-password"
type="password"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
required
minLength={8}
autoComplete="new-password"
aria-label="Neues Passwort bestaetigen"
/>
</div>
{pwError && (
<Alert variant="destructive">
<AlertDescription>{pwError}</AlertDescription>
</Alert>
)}
{pwSuccess && (
<Alert>
<AlertDescription>{pwSuccess}</AlertDescription>
</Alert>
)}
<Button type="submit" disabled={pwLoading}>
{pwLoading ? "Speichern..." : "Passwort aendern"}
</Button>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,59 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import type { useProfileSettings } from "@/hooks/useProfileSettings";
type ProfileSectionProps = Pick<
ReturnType<typeof useProfileSettings>,
"email" | "setEmail" | "emailError" | "emailSuccess" | "emailLoading" | "handleChangeEmail"
>;
export function ProfileSection({
email,
setEmail,
emailError,
emailSuccess,
emailLoading,
handleChangeEmail,
}: ProfileSectionProps) {
return (
<Card>
<CardHeader>
<CardTitle>E-Mail-Adresse aendern</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleChangeEmail} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
aria-label="E-Mail-Adresse"
/>
</div>
{emailError && (
<Alert variant="destructive">
<AlertDescription>{emailError}</AlertDescription>
</Alert>
)}
{emailSuccess && (
<Alert>
<AlertDescription>{emailSuccess}</AlertDescription>
</Alert>
)}
<Button type="submit" disabled={emailLoading}>
{emailLoading ? "Speichern..." : "E-Mail aendern"}
</Button>
</form>
</CardContent>
</Card>
);
}
+200
View File
@@ -0,0 +1,200 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import type { useTotpSettings } from "@/hooks/useTotpSettings";
type TotpSectionProps = ReturnType<typeof useTotpSettings>;
export function TotpSection({
totpEnabled,
showSetup,
qrCode,
secret,
totpCode,
setTotpCode,
totpError,
totpSuccess,
totpLoading,
disableDialogOpen,
setDisableDialogOpen,
disableCode,
setDisableCode,
disableError,
disableLoading,
handleSetupTOTP,
handleConfirmTOTP,
handleDisableTOTP,
cancelSetup,
openDisableDialog,
}: TotpSectionProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-3">
Zwei-Faktor-Authentifizierung (2FA)
{totpEnabled ? (
<Badge variant="default" className="bg-green-600">
Aktiv
</Badge>
) : (
<Badge variant="secondary">Inaktiv</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{totpError && (
<Alert variant="destructive">
<AlertDescription>{totpError}</AlertDescription>
</Alert>
)}
{totpSuccess && (
<Alert>
<AlertDescription>{totpSuccess}</AlertDescription>
</Alert>
)}
{!totpEnabled && !showSetup && (
<div>
<p className="text-sm text-muted-foreground mb-4">
Schuetzen Sie Ihr Konto mit einem Einmalpasswort (TOTP).
Kompatibel mit Google Authenticator, Authy und anderen
TOTP-Apps.
</p>
<Button onClick={handleSetupTOTP} disabled={totpLoading}>
{totpLoading ? "Laden..." : "2FA einrichten"}
</Button>
</div>
)}
{showSetup && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Scannen Sie den QR-Code mit Ihrer Authenticator-App und geben
Sie den angezeigten Code ein.
</p>
{qrCode && (
<div className="flex justify-center p-4 bg-white rounded-lg border w-fit mx-auto">
<img
src={`data:image/png;base64,${qrCode}`}
alt="TOTP QR-Code"
width={200}
height={200}
/>
</div>
)}
{secret && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Manueller Schluessel
</Label>
<code className="block text-sm font-mono bg-muted px-3 py-2 rounded break-all select-all">
{secret}
</code>
</div>
)}
<form onSubmit={handleConfirmTOTP} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="totp-code">Bestaetigungscode</Label>
<Input
id="totp-code"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000000"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
required
autoComplete="one-time-code"
aria-label="TOTP Bestaetigungscode"
/>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={totpLoading}>
{totpLoading ? "Pruefen..." : "Bestaetigen"}
</Button>
<Button type="button" variant="outline" onClick={cancelSetup}>
Abbrechen
</Button>
</div>
</form>
</div>
)}
{totpEnabled && (
<div>
<p className="text-sm text-muted-foreground mb-4">
2FA ist aktiv. Zum Deaktivieren benoetigen Sie einen
aktuellen Code aus Ihrer Authenticator-App.
</p>
<Button variant="destructive" onClick={openDisableDialog}>
2FA deaktivieren
</Button>
</div>
)}
{/* Disable TOTP dialog */}
<Dialog open={disableDialogOpen} onOpenChange={setDisableDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>2FA deaktivieren</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Geben Sie einen aktuellen Code aus Ihrer Authenticator-App
ein, um 2FA zu deaktivieren.
</p>
<div className="space-y-2">
<Label htmlFor="disable-totp-code">TOTP-Code</Label>
<Input
id="disable-totp-code"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000000"
value={disableCode}
onChange={(e) => setDisableCode(e.target.value)}
autoComplete="one-time-code"
aria-label="TOTP-Code zum Deaktivieren"
/>
</div>
{disableError && (
<Alert variant="destructive">
<AlertDescription>{disableError}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDisableDialogOpen(false)}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDisableTOTP}
disabled={disableLoading || disableCode.length !== 6}
>
{disableLoading ? "Pruefen..." : "Deaktivieren"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}