feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen
- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg) - Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist - Feature-Status auf In Review gesetzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,946 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
getUsers,
|
||||
createUser,
|
||||
getAuditLog,
|
||||
getSMTPStatus,
|
||||
getHealth,
|
||||
getStorageStats,
|
||||
getServices,
|
||||
serviceAction,
|
||||
getSystemStats,
|
||||
type User,
|
||||
type AuditEntry,
|
||||
type SMTPStatus,
|
||||
type StorageStats,
|
||||
type ServiceStatus,
|
||||
type SystemStats,
|
||||
} from "@/lib/api";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
const AUDIT_PAGE_SIZE = 25;
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user, loading: authLoading } = useAuth(true);
|
||||
|
||||
// Dashboard state
|
||||
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
|
||||
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
|
||||
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
|
||||
const [apiOnline, setApiOnline] = useState<boolean | null>(null);
|
||||
const [dashLoading, setDashLoading] = useState(true);
|
||||
const [dashRefreshed, setDashRefreshed] = useState<Date | null>(null);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
|
||||
// Services state
|
||||
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||||
const [servicesLoading, setServicesLoading] = useState(false);
|
||||
const [serviceActionLoading, setServiceActionLoading] = useState<string | null>(null);
|
||||
const [serviceError, setServiceError] = useState("");
|
||||
|
||||
// Users state
|
||||
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("");
|
||||
|
||||
// Audit state
|
||||
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
|
||||
const [auditTotal, setAuditTotal] = useState(0);
|
||||
const [auditPage, setAuditPage] = useState(1);
|
||||
const [auditLoading, setAuditLoading] = useState(false);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
setDashLoading(true);
|
||||
try {
|
||||
const [smtp, health, storage, sysStats] = await Promise.allSettled([
|
||||
getSMTPStatus(),
|
||||
getHealth(),
|
||||
getStorageStats(),
|
||||
getSystemStats(),
|
||||
]);
|
||||
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);
|
||||
setDashRefreshed(new Date());
|
||||
} finally {
|
||||
setDashLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setUsersLoading(true);
|
||||
setUsersError("");
|
||||
try {
|
||||
const data = await getUsers();
|
||||
setUsers(data || []);
|
||||
} catch {
|
||||
setUsersError("Benutzer konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setUsersLoading(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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const dashIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
loadDashboard();
|
||||
loadUsers();
|
||||
loadAudit(1);
|
||||
loadServices();
|
||||
|
||||
// Auto-Refresh Dashboard alle 30 Sekunden
|
||||
setCountdown(30);
|
||||
dashIntervalRef.current = setInterval(() => {
|
||||
loadDashboard();
|
||||
setCountdown(30);
|
||||
}, 30_000);
|
||||
|
||||
// Countdown-Ticker
|
||||
const ticker = setInterval(() => {
|
||||
setCountdown((c) => (c > 0 ? c - 1 : 0));
|
||||
}, 1_000);
|
||||
|
||||
return () => {
|
||||
if (dashIntervalRef.current) clearInterval(dashIntervalRef.current);
|
||||
clearInterval(ticker);
|
||||
};
|
||||
}, [user, loadDashboard, loadUsers, loadAudit, loadServices]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar username={user.username} role={user.role} />
|
||||
<main className="mx-auto max-w-7xl px-4 py-6">
|
||||
<h1 className="mb-6 text-2xl font-bold">Administration</h1>
|
||||
|
||||
<Tabs defaultValue="dashboard">
|
||||
<TabsList>
|
||||
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
||||
<TabsTrigger value="services">Dienste</TabsTrigger>
|
||||
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
||||
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Dashboard ── */}
|
||||
<TabsContent value="dashboard" className="mt-4 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Systemstatus</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{dashRefreshed && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dashRefreshed.toLocaleTimeString("de-DE")} · nächste Aktualisierung in {countdown}s
|
||||
</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => { loadDashboard(); setCountdown(30); }} disabled={dashLoading}>
|
||||
{dashLoading ? "..." : "Jetzt aktualisieren"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dashLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Status-Kacheln */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
{/* API */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">REST API</span>
|
||||
<Badge variant={apiOnline ? "default" : "destructive"}>
|
||||
{apiOnline ? "Online" : "Offline"}
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Adresse</span>
|
||||
<span className="font-mono">:8080</span>
|
||||
<span className="text-muted-foreground">Protokoll</span>
|
||||
<span>HTTP</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SMTP */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">SMTP-Daemon</span>
|
||||
<Badge variant={smtpStatus?.running ? "default" : "destructive"}>
|
||||
{smtpStatus?.running ? "Aktiv" : smtpStatus?.enabled === false ? "Deaktiviert" : "Gestoppt"}
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
{smtpStatus ? (
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Adresse</span>
|
||||
<span className="font-mono">{smtpStatus.bind}</span>
|
||||
<span className="text-muted-foreground">Domain</span>
|
||||
<span className="font-mono">{smtpStatus.domain || "–"}</span>
|
||||
<span className="text-muted-foreground">TLS</span>
|
||||
<span>{smtpStatus.tls ? "Ja" : "Nein"}</span>
|
||||
<span className="text-muted-foreground">Max. Größe</span>
|
||||
<span>{smtpStatus.max_size_mb > 0 ? `${smtpStatus.max_size_mb} MB` : "50 MB"}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nicht erreichbar</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SMTP Statistik (nur live via SMTP-Daemon) */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">SMTP Statistik</span>
|
||||
<span className="text-xs text-muted-foreground">seit letztem Start</span>
|
||||
</div>
|
||||
<Separator />
|
||||
{smtpStatus ? (
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Empfangen</span>
|
||||
<span className="font-semibold text-green-600">{smtpStatus.received}</span>
|
||||
<span className="text-muted-foreground">Abgelehnt</span>
|
||||
<span className="font-semibold text-red-500">{smtpStatus.rejected}</span>
|
||||
<span className="text-muted-foreground">Letzte Mail</span>
|
||||
<span className="text-xs">
|
||||
{smtpStatus.last_mail_at
|
||||
? new Date(smtpStatus.last_mail_at).toLocaleString("de-DE")
|
||||
: "–"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Keine Daten</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Archiv-Speicher */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Archiv gesamt</span>
|
||||
{storageStats && (
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{storageStats.total_mails} Mails
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
{storageStats ? (
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">E-Mails</span>
|
||||
<span className="font-semibold">{storageStats.total_mails.toLocaleString("de-DE")}</span>
|
||||
<span className="text-muted-foreground">Speicher</span>
|
||||
<span>{formatBytes(storageStats.total_bytes)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Keine Daten</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* System Stats: CPU, RAM, Disks, Archivzeitraum */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Systemauslastung</h3>
|
||||
{!systemStats ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Systemdaten konnten nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft und der Endpunkt <code className="font-mono">/api/admin/system/stats</code> erreichbar ist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
{/* CPU */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">CPU Load Average</span>
|
||||
<Badge variant="secondary">{systemStats.cpu.num_cpu} CPU(s)</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">1 min</span>
|
||||
<span className="font-semibold">{systemStats.cpu.load1.toFixed(2)}</span>
|
||||
<span className="text-muted-foreground">5 min</span>
|
||||
<span className="font-semibold">{systemStats.cpu.load5.toFixed(2)}</span>
|
||||
<span className="text-muted-foreground">15 min</span>
|
||||
<span className="font-semibold">{systemStats.cpu.load15.toFixed(2)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* RAM */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Arbeitsspeicher</span>
|
||||
<Badge variant={systemStats.ram.used_pct > 90 ? "destructive" : systemStats.ram.used_pct > 70 ? "secondary" : "default"}>
|
||||
{systemStats.ram.used_pct.toFixed(1)}%
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="h-2 w-full rounded-full bg-secondary">
|
||||
<div
|
||||
className={`h-2 rounded-full ${systemStats.ram.used_pct > 90 ? "bg-destructive" : systemStats.ram.used_pct > 70 ? "bg-yellow-500" : "bg-primary"}`}
|
||||
style={{ width: `${Math.min(systemStats.ram.used_pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Belegt</span>
|
||||
<span>{formatBytes(systemStats.ram.used_bytes)}</span>
|
||||
<span className="text-muted-foreground">Gesamt</span>
|
||||
<span>{formatBytes(systemStats.ram.total_bytes)}</span>
|
||||
<span className="text-muted-foreground">Frei</span>
|
||||
<span>{formatBytes(systemStats.ram.free_bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Archivzeitraum */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Archivzeitraum</span>
|
||||
</div>
|
||||
<Separator />
|
||||
{systemStats.archive.first_mail || systemStats.archive.last_mail ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
{systemStats.archive.first_mail && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">Älteste Mail</span>
|
||||
<span className="font-semibold">{new Date(systemStats.archive.first_mail.date).toLocaleDateString("de-DE")}</span>
|
||||
<span className="block text-muted-foreground truncate">{systemStats.archive.first_mail.from || "–"}</span>
|
||||
<span className="block text-xs truncate">{systemStats.archive.first_mail.subject || "(kein Betreff)"}</span>
|
||||
</div>
|
||||
)}
|
||||
{systemStats.archive.last_mail && (
|
||||
<div className="pt-1 border-t">
|
||||
<span className="text-xs text-muted-foreground block">Neueste Mail</span>
|
||||
<span className="font-semibold">{new Date(systemStats.archive.last_mail.date).toLocaleDateString("de-DE")}</span>
|
||||
<span className="block text-muted-foreground truncate">{systemStats.archive.last_mail.from || "–"}</span>
|
||||
<span className="block text-xs truncate">{systemStats.archive.last_mail.subject || "(kein Betreff)"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Archiv leer</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Festplatten */}
|
||||
{systemStats.disks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Festplatten</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{systemStats.disks.map((disk) => (
|
||||
<Card key={disk.mount}>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium font-mono">{disk.mount}</span>
|
||||
<Badge variant={disk.used_pct > 90 ? "destructive" : disk.used_pct > 75 ? "secondary" : "outline"}>
|
||||
{disk.used_pct.toFixed(1)}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-secondary">
|
||||
<div
|
||||
className={`h-2 rounded-full ${disk.used_pct > 90 ? "bg-destructive" : disk.used_pct > 75 ? "bg-yellow-500" : "bg-primary"}`}
|
||||
style={{ width: `${Math.min(disk.used_pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Belegt</span>
|
||||
<span>{formatBytes(disk.used_bytes)}</span>
|
||||
<span className="text-muted-foreground">Gesamt</span>
|
||||
<span>{formatBytes(disk.total_bytes)}</span>
|
||||
<span className="text-muted-foreground">Frei</span>
|
||||
<span>{formatBytes(disk.free_bytes)}</span>
|
||||
<span className="text-muted-foreground">Dateisystem</span>
|
||||
<span className="font-mono text-xs">{disk.fstype}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* IP-Allowlist */}
|
||||
{smtpStatus && smtpStatus.allowed_ips?.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">SMTP IP-Allowlist</span>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{smtpStatus.allowed_ips.map((ip) => (
|
||||
<Badge key={ip} variant="outline" className="font-mono">
|
||||
{ip}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Benutzerübersicht */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Benutzer</span>
|
||||
<Separator />
|
||||
{usersLoading ? (
|
||||
<Skeleton className="h-8 w-full" />
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-4 pt-1 text-sm">
|
||||
<span>
|
||||
<span className="font-semibold">{users.filter(u => u.active).length}</span>
|
||||
<span className="text-muted-foreground ml-1">aktiv</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold">{users.filter(u => u.role === "admin").length}</span>
|
||||
<span className="text-muted-foreground ml-1">Admin</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold">{users.filter(u => u.role === "auditor").length}</span>
|
||||
<span className="text-muted-foreground ml-1">Auditor</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold">{users.filter(u => u.role === "user").length}</span>
|
||||
<span className="text-muted-foreground ml-1">User</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!smtpStatus && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
SMTP-Status konnte nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Dienste ── */}
|
||||
<TabsContent value="services" className="mt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Systemdienste</h2>
|
||||
<Button variant="outline" size="sm" onClick={loadServices} disabled={servicesLoading}>
|
||||
{servicesLoading ? "..." : "Aktualisieren"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{serviceError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{serviceError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{servicesLoading && services.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-44">Dienst</TableHead>
|
||||
<TableHead className="w-28">Status</TableHead>
|
||||
<TableHead className="w-24">Autostart</TableHead>
|
||||
<TableHead className="w-28">Externer Zugriff</TableHead>
|
||||
<TableHead>Beschreibung</TableHead>
|
||||
<TableHead className="w-72 text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.map((svc) => {
|
||||
const isActive = svc.active === "active";
|
||||
const isFailed = svc.active === "failed";
|
||||
const isEnabled = svc.enabled === "enabled" || svc.enabled === "static";
|
||||
const busy = (key: string) => serviceActionLoading === `${svc.name}:${key}`;
|
||||
const anyBusy = serviceActionLoading?.startsWith(`${svc.name}:`) ?? false;
|
||||
return (
|
||||
<TableRow key={svc.name}>
|
||||
<TableCell className="font-mono text-sm font-medium">
|
||||
{svc.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={isActive ? "default" : isFailed ? "destructive" : "secondary"}
|
||||
>
|
||||
{svc.active === "active"
|
||||
? `Aktiv (${svc.sub})`
|
||||
: svc.active === "failed"
|
||||
? "Fehler"
|
||||
: svc.active === "inactive"
|
||||
? "Gestoppt"
|
||||
: svc.active}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={isEnabled ? "default" : "outline"}>
|
||||
{svc.enabled === "enabled"
|
||||
? "Aktiviert"
|
||||
: svc.enabled === "disabled"
|
||||
? "Deaktiviert"
|
||||
: svc.enabled === "static"
|
||||
? "Statisch"
|
||||
: svc.enabled}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{svc.external_blocked !== undefined ? (
|
||||
<Badge variant={svc.external_blocked ? "destructive" : "default"}>
|
||||
{svc.external_blocked ? "Gesperrt" : "Offen"}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">–</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground truncate max-w-xs">
|
||||
{svc.description || "–"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1 flex-wrap">
|
||||
{isActive ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "restart")}
|
||||
>
|
||||
{busy("restart") ? "..." : "Neustart"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "stop")}
|
||||
>
|
||||
{busy("stop") ? "..." : "Stop"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "start")}
|
||||
>
|
||||
{busy("start") ? "..." : "Start"}
|
||||
</Button>
|
||||
)}
|
||||
{svc.enabled === "enabled" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "disable")}
|
||||
>
|
||||
{busy("disable") ? "..." : "Deaktivieren"}
|
||||
</Button>
|
||||
) : svc.enabled === "disabled" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "enable")}
|
||||
>
|
||||
{busy("enable") ? "..." : "Aktivieren"}
|
||||
</Button>
|
||||
) : null}
|
||||
{svc.external_blocked !== undefined && (
|
||||
svc.external_blocked ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "allow_external")}
|
||||
>
|
||||
{busy("allow_external") ? "..." : "Extern freigeben"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "block_external")}
|
||||
>
|
||||
{busy("block_external") ? "..." : "Extern sperren"}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="mt-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Benutzerverwaltung</h2>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Benutzer anlegen</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Erstellen Sie einen neuen Benutzer-Account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreateUser} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-username">Benutzername</Label>
|
||||
<Input
|
||||
id="new-username"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
required
|
||||
aria-label="Neuer Benutzername"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-email">E-Mail</Label>
|
||||
<Input
|
||||
id="new-email"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
required
|
||||
aria-label="Neue E-Mail-Adresse"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">Passwort</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
aria-label="Neues Passwort"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-role">Rolle</Label>
|
||||
<Select value={newRole} onValueChange={setNewRole}>
|
||||
<SelectTrigger id="new-role" aria-label="Rolle auswaehlen">
|
||||
<SelectValue placeholder="Rolle waehlen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="auditor">Auditor</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{createError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createLoading}>
|
||||
{createLoading ? "Erstellen..." : "Erstellen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{usersLoading ? (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : usersError ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-destructive">
|
||||
{usersError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : users.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Keine Benutzer vorhanden.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Benutzername</TableHead>
|
||||
<TableHead>E-Mail</TableHead>
|
||||
<TableHead>Rolle</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.username}>
|
||||
<TableCell className="font-medium">
|
||||
{u.username}
|
||||
</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{u.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={u.active ? "default" : "destructive"}
|
||||
>
|
||||
{u.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audit" className="mt-4">
|
||||
<h2 className="mb-4 text-lg font-semibold">Audit-Log</h2>
|
||||
|
||||
{auditLoading ? (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : auditEntries.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Keine Audit-Eintraege vorhanden.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Zeitstempel</TableHead>
|
||||
<TableHead>Ereignis</TableHead>
|
||||
<TableHead>Benutzer</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{auditEntries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{new Date(entry.timestamp).toLocaleString("de-DE")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{entry.event_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{entry.username}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{entry.detail}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{auditTotalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={auditPage <= 1}
|
||||
onClick={() => loadAudit(auditPage - 1)}
|
||||
>
|
||||
Zurueck
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Seite {auditPage} von {auditTotalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={auditPage >= auditTotalPages}
|
||||
onClick={() => loadAudit(auditPage + 1)}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import {
|
||||
getImapAccounts,
|
||||
createImapAccount,
|
||||
deleteImapAccount,
|
||||
testImapConnection,
|
||||
startImapImport,
|
||||
getImapProgress,
|
||||
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 { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
// Polling refs
|
||||
const pollingRefs = useRef<Map<number, ReturnType<typeof setInterval>>>(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
|
||||
useEffect(() => {
|
||||
for (const acc of accounts) {
|
||||
if (acc.status === "running" && !pollingRefs.current.has(acc.id)) {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const updated = await getImapProgress(acc.id);
|
||||
setAccounts((prev) =>
|
||||
prev.map((a) => (a.id === updated.id ? updated : a))
|
||||
);
|
||||
if (updated.status !== "running") {
|
||||
clearInterval(pollingRefs.current.get(acc.id)!);
|
||||
pollingRefs.current.delete(acc.id);
|
||||
}
|
||||
} catch {
|
||||
clearInterval(pollingRefs.current.get(acc.id)!);
|
||||
pollingRefs.current.delete(acc.id);
|
||||
}
|
||||
}, 2000);
|
||||
pollingRefs.current.set(acc.id, interval);
|
||||
}
|
||||
}
|
||||
// Cleanup intervals for accounts that are no longer running
|
||||
for (const [id, interval] of pollingRefs.current) {
|
||||
const acc = accounts.find((a) => a.id === id);
|
||||
if (!acc || acc.status !== "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) {
|
||||
try {
|
||||
const updated = await startImapImport(id);
|
||||
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await deleteImapAccount(id);
|
||||
setAccounts((prev) => prev.filter((a) => a.id !== id));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar username={user.username} role={user.role} />
|
||||
<main className="mx-auto max-w-4xl px-4 py-6">
|
||||
<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>
|
||||
|
||||
{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={
|
||||
(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>
|
||||
)}
|
||||
|
||||
{acc.excluded_folders && acc.excluded_folders.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Ausgeschlossene Ordner: {acc.excluded_folders.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={acc.status === "running"}
|
||||
onClick={() => handleStartImport(acc.id)}
|
||||
>
|
||||
Import starten
|
||||
</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>
|
||||
|
||||
<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();
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
saving ||
|
||||
!formName ||
|
||||
!formHost ||
|
||||
!formUsername ||
|
||||
!formPassword
|
||||
}
|
||||
>
|
||||
{saving ? "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>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI Coding Starter Kit",
|
||||
description: "Built with AI Agent Team System",
|
||||
title: "archivmail",
|
||||
description: "E-Mail-Archiv",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -12,8 +12,8 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="antialiased">
|
||||
<html lang="de">
|
||||
<body className="antialiased min-h-screen bg-background text-foreground">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
getMail,
|
||||
downloadMailAttachment,
|
||||
downloadMailRaw,
|
||||
type MailDetail,
|
||||
type MailAttachment,
|
||||
} from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function blockExternalSrcs(html: string): string {
|
||||
// Replace src= in img/video/audio tags with data-src= to block loading
|
||||
return html
|
||||
.replace(/<(img|video|audio|source)(\s[^>]*?\s)src(\s*=\s*["']https?:)/gi,
|
||||
"<$1$2data-src$3")
|
||||
.replace(/<(img|video|audio|source)(\s)src(\s*=\s*["']https?:)/gi,
|
||||
"<$1$2data-src$3");
|
||||
}
|
||||
|
||||
// ── Sub-components ─────────────────────────────────────────────────────────
|
||||
|
||||
function MailHeaderGrid({ mail }: { mail: MailDetail }) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-[6rem_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
<span className="font-medium text-muted-foreground">Von:</span>
|
||||
<span className="break-all">{mail.from || "–"}</span>
|
||||
<span className="font-medium text-muted-foreground">An:</span>
|
||||
<span className="break-all">{mail.to || "–"}</span>
|
||||
{mail.cc && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">CC:</span>
|
||||
<span className="break-all">{mail.cc}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="font-medium text-muted-foreground">Datum:</span>
|
||||
<span>{formatDate(mail.date)}</span>
|
||||
<span className="font-medium text-muted-foreground">Betreff:</span>
|
||||
<span className="font-semibold">{mail.subject || "(kein Betreff)"}</span>
|
||||
<span className="font-medium text-muted-foreground">Größe:</span>
|
||||
<span>{formatBytes(mail.size)}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowRaw((v) => !v)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{showRaw ? "Header ausblenden" : "Original-Header anzeigen"}
|
||||
</button>
|
||||
|
||||
{showRaw && (
|
||||
<pre className="mt-2 max-h-60 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap break-all">
|
||||
{mail.raw_headers}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MailBodyView({ mail }: { mail: MailDetail }) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [showExternal, setShowExternal] = useState(false);
|
||||
|
||||
const html = mail.body_html ?? null;
|
||||
const plain = mail.body_plain ?? null;
|
||||
|
||||
// Adjust iframe height to content
|
||||
function handleIframeLoad() {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
try {
|
||||
const body = iframe.contentDocument?.body;
|
||||
if (body) {
|
||||
iframe.style.height = `${body.scrollHeight + 32}px`;
|
||||
}
|
||||
} catch {
|
||||
iframe.style.height = "600px";
|
||||
}
|
||||
}
|
||||
|
||||
if (!html && !plain) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
Kein Inhalt vorhanden.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (html) {
|
||||
const srcdoc = showExternal ? html : blockExternalSrcs(html);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{!showExternal && (
|
||||
<Alert>
|
||||
<AlertDescription className="flex items-center justify-between gap-4 text-sm">
|
||||
<span>Externe Inhalte (Bilder, Tracker) sind blockiert.</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowExternal(true)}
|
||||
>
|
||||
Externe Inhalte laden
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={srcdoc}
|
||||
sandbox="allow-same-origin"
|
||||
title="E-Mail-Inhalt"
|
||||
className="w-full"
|
||||
style={{ minHeight: "200px", height: "600px", border: "none" }}
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Plain-text fallback
|
||||
return (
|
||||
<pre className="max-h-[600px] overflow-auto rounded-md border bg-muted p-4 text-sm whitespace-pre-wrap break-words leading-relaxed">
|
||||
{plain}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentRow({
|
||||
mailId,
|
||||
attachment,
|
||||
}: {
|
||||
mailId: string;
|
||||
attachment: MailAttachment;
|
||||
}) {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
async function handleDownload() {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { blob, filename } = await downloadMailAttachment(
|
||||
mailId,
|
||||
attachment.index
|
||||
);
|
||||
triggerDownload(blob, filename || attachment.filename);
|
||||
} catch (e) {
|
||||
alert(`Download fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-4 py-2.5 text-sm">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{attachment.filename}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{attachment.content_type} · {formatBytes(attachment.size)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? "..." : "Herunterladen"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MailViewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const [mail, setMail] = useState<MailDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
getMail(id)
|
||||
.then(setMail)
|
||||
.catch((e) =>
|
||||
setError(e instanceof Error ? e.message : "Unbekannter Fehler")
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, user]);
|
||||
|
||||
async function handleEmlDownload() {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { blob, filename } = await downloadMailRaw(id);
|
||||
triggerDownload(blob, filename);
|
||||
} catch (e) {
|
||||
alert(`Download fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar username={user.username} role={user.role} />
|
||||
<main className="mx-auto max-w-4xl px-4 py-6 space-y-4">
|
||||
|
||||
{/* Back + Actions */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/search">← Zurück zur Suche</Link>
|
||||
</Button>
|
||||
|
||||
{mail && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{id}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEmlDownload}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? "..." : "Als .eml herunterladen"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<Card>
|
||||
<CardHeader className="space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Mail content */}
|
||||
{mail && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<MailHeaderGrid mail={mail} />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Body */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<MailBodyView mail={mail} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Attachments */}
|
||||
{mail.attachments && mail.attachments.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<span className="text-sm font-medium">
|
||||
Anhänge ({mail.attachments.length})
|
||||
</span>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
{mail.attachments.map((att) => (
|
||||
<AttachmentRow
|
||||
key={att.index}
|
||||
mailId={id}
|
||||
attachment={att}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+87
-97
@@ -1,101 +1,91 @@
|
||||
import Image from 'next/image'
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { login } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("archivmail_token");
|
||||
if (token) {
|
||||
router.replace("/search");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await login(username, password);
|
||||
localStorage.setItem("archivmail_token", res.token);
|
||||
router.push("/search");
|
||||
} catch {
|
||||
setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2">
|
||||
Get started by editing{' '}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl font-bold">archivmail</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
E-Mail-Archiv Anmeldung
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Benutzername</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Benutzername"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
aria-label="Benutzername"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Passwort"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
aria-label="Passwort"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Anmelden..." : "Anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { searchEmails, type SearchHit } 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function SearchPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [fromFilter, setFromFilter] = useState("");
|
||||
const [toFilter, setToFilter] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
|
||||
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,
|
||||
page: p,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
setResults(res.hits || []);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
setSearched(true);
|
||||
} catch {
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
},
|
||||
[query, fromFilter, toFilter, dateFrom, dateTo]
|
||||
);
|
||||
|
||||
// Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setSearching(true);
|
||||
searchEmails({ page: 1, page_size: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setResults(res.hits || []);
|
||||
setTotal(res.total);
|
||||
setPage(1);
|
||||
setSearched(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
})
|
||||
.finally(() => setSearching(false));
|
||||
}, [user]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
doSearch(1);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar username={user.username} role={user.role} />
|
||||
<main className="mx-auto max-w-7xl px-4 py-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Volltextsuche..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="flex-1"
|
||||
aria-label="Suchbegriff"
|
||||
/>
|
||||
<Button type="submit" disabled={searching}>
|
||||
{searching ? "Suche..." : "Suchen"}
|
||||
</Button>
|
||||
</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>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
{searching ? (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : searched && results.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Keine E-Mails gefunden.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : results.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-2 text-sm text-muted-foreground">
|
||||
{query || fromFilter || toFilter || dateFrom || dateTo
|
||||
? `${total} Ergebnis${total !== 1 ? "se" : ""} gefunden`
|
||||
: `${total} E-Mail${total !== 1 ? "s" : ""} im Archiv`}
|
||||
</div>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-32">Datum</TableHead>
|
||||
<TableHead className="w-56">Von</TableHead>
|
||||
<TableHead>Betreff</TableHead>
|
||||
<TableHead className="w-48">An</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 className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{hit.date
|
||||
? new Date(hit.date).toLocaleDateString("de-DE")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[14rem] truncate text-sm">{hit.from || "-"}</TableCell>
|
||||
<TableCell className="font-medium">{hit.subject || "(kein Betreff)"}</TableCell>
|
||||
<TableCell className="max-w-[12rem] truncate text-sm text-muted-foreground">{hit.to || "-"}</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>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user