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:
sysops
2026-03-14 11:43:19 +01:00
parent a893084a88
commit d360c9a5ba
68 changed files with 11938 additions and 435 deletions
+946
View File
@@ -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>
);
}
+518
View File
@@ -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 &quot;Konto
hinzufuegen&quot;, um zu beginnen.
</CardContent>
</Card>
) : (
<div className="space-y-4">
{accounts.map((acc) => (
<Card key={acc.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<h3 className="font-semibold">{acc.name}</h3>
<p className="text-sm text-muted-foreground">
{acc.host}:{acc.port} ({acc.tls.toUpperCase()}) &middot; {acc.username}
</p>
</div>
{statusBadge(acc.status)}
</CardHeader>
<CardContent>
{acc.status === "running" && acc.progress_total > 0 && (
<div className="mb-3 space-y-1">
<Progress
value={
(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
View File
@@ -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>
+352
View File
@@ -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
View File
@@ -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>
)
);
}
+258
View File
@@ -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>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { logout } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
interface NavbarProps {
username: string;
role: string;
}
export function Navbar({ username, role }: NavbarProps) {
const router = useRouter();
async function handleLogout() {
try {
await logout();
} catch {
// ignore logout errors
}
localStorage.removeItem("archivmail_token");
router.push("/");
}
return (
<nav
className="border-b bg-background"
aria-label="Hauptnavigation"
>
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
<div className="flex items-center gap-6">
<Link
href="/search"
className="text-lg font-bold tracking-tight"
>
archivmail
</Link>
<Link
href="/search"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Suche
</Link>
<Link
href="/imap"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
IMAP Import
</Link>
{role === "admin" && (
<Link
href="/admin"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Admin
</Link>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm">{username}</span>
<Badge variant="secondary">{role}</Badge>
<Button variant="outline" size="sm" onClick={handleLogout}>
Abmelden
</Button>
</div>
</div>
</nav>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { getMe, type MeResponse } from "@/lib/api";
interface AuthState {
user: MeResponse | null;
loading: boolean;
error: string | null;
}
export function useAuth(requireAdmin?: boolean) {
const router = useRouter();
const [state, setState] = useState<AuthState>({
user: null,
loading: true,
error: null,
});
const checkAuth = useCallback(async () => {
const token = localStorage.getItem("archivmail_token");
if (!token) {
router.replace("/");
return;
}
try {
const user = await getMe();
if (requireAdmin && user.role !== "admin") {
router.replace("/search");
return;
}
setState({ user, loading: false, error: null });
} catch {
localStorage.removeItem("archivmail_token");
router.replace("/");
}
}, [router, requireAdmin]);
useEffect(() => {
checkAuth();
}, [checkAuth]);
return state;
}
+393
View File
@@ -0,0 +1,393 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("archivmail_token");
}
async function request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
});
if (res.status === 401) {
if (typeof window !== "undefined") {
localStorage.removeItem("archivmail_token");
window.location.href = "/";
}
throw new Error("Unauthorized");
}
if (!res.ok) {
const body = await res.text();
throw new Error(body || `Request failed: ${res.status}`);
}
if (res.status === 204) return {} as T;
return res.json();
}
// Types
export interface LoginResponse {
token: string;
username: string;
role: string;
}
export interface User {
username: string;
email: string;
role: string;
active: boolean;
}
export interface MeResponse {
username: string;
role: string;
email: string;
}
export interface SMTPStatus {
running: boolean;
enabled: boolean;
bind: string;
domain: string;
tls: boolean;
max_size_mb: number;
allowed_ips: string[];
received: number;
rejected: number;
last_mail_at?: string;
}
export interface HealthResponse {
status: string;
}
export interface SearchHit {
id: string;
score: number;
from?: string;
to?: string;
subject?: string;
date?: string;
}
export interface SearchResponse {
total: number;
hits: SearchHit[];
}
export interface MailAttachment {
index: number;
filename: string;
content_type: string;
size: number;
}
export interface MailDetail {
id: string;
from: string;
to: string;
cc?: string;
subject: string;
date: string;
size: number;
body_html?: string;
body_plain?: string;
raw_headers: string;
attachments: MailAttachment[];
}
export interface AuditEntry {
id: string;
timestamp: string;
event_type: string;
username: string;
detail: string;
}
export interface AuditResponse {
total: number;
entries: AuditEntry[];
}
export interface CreateUserRequest {
username: string;
email: string;
password: string;
role: string;
}
// API functions
export async function login(
username: string,
password: string
): Promise<LoginResponse> {
return request<LoginResponse>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export async function getMe(): Promise<MeResponse> {
return request<MeResponse>("/api/auth/me");
}
export async function logout(): Promise<void> {
await request<void>("/api/auth/logout", { method: "POST" });
}
export async function searchEmails(params: {
q?: string;
from?: string;
to?: string;
date_from?: string;
date_to?: string;
page?: number;
page_size?: number;
}): Promise<SearchResponse> {
const sp = new URLSearchParams();
if (params.q) sp.set("q", params.q);
if (params.from) sp.set("from", params.from);
if (params.to) sp.set("to", params.to);
if (params.date_from) sp.set("date_from", params.date_from);
if (params.date_to) sp.set("date_to", params.date_to);
if (params.page) sp.set("page", String(params.page));
if (params.page_size) sp.set("page_size", String(params.page_size));
return request<SearchResponse>(`/api/search?${sp.toString()}`);
}
export async function getUsers(): Promise<User[]> {
return request<User[]>("/api/users");
}
export async function createUser(data: CreateUserRequest): Promise<User> {
return request<User>("/api/users", {
method: "POST",
body: JSON.stringify(data),
});
}
export interface StorageStats {
total_mails: number;
total_bytes: number;
}
export async function getStorageStats(): Promise<StorageStats> {
return request<StorageStats>("/api/admin/storage/stats");
}
export async function getSMTPStatus(): Promise<SMTPStatus> {
return request<SMTPStatus>("/api/admin/smtp/status");
}
export async function getHealth(): Promise<HealthResponse> {
return request<HealthResponse>("/api/health");
}
export async function getMail(id: string): Promise<MailDetail> {
return request<MailDetail>(`/api/mails/${id}`);
}
export async function downloadMailAttachment(
id: string,
index: number
): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/mails/${id}/attachments/${index}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
const disposition = res.headers.get("Content-Disposition") || "";
const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
const filename = match ? match[1].replace(/['"]/g, "") : `anhang-${index}`;
return { blob: await res.blob(), filename };
}
export async function downloadMailRaw(
id: string
): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/mails/${id}/raw`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
return { blob: await res.blob(), filename: `${id}.eml` };
}
export interface ServiceStatus {
name: string;
display_name: string;
active: string; // active | inactive | failed | unknown
sub: string; // running | dead | exited | ...
enabled: string; // enabled | disabled | static | unknown
description: string;
external_blocked?: boolean; // only present for archivmail
}
export async function getServices(): Promise<ServiceStatus[]> {
return request<ServiceStatus[]>("/api/admin/services");
}
export async function serviceAction(
name: string,
action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external"
): Promise<ServiceStatus> {
return request<ServiceStatus>(`/api/admin/services/${encodeURIComponent(name)}/action`, {
method: "POST",
body: JSON.stringify({ action }),
});
}
export async function getAuditLog(params: {
page?: number;
page_size?: number;
username?: string;
event_type?: string;
}): Promise<AuditResponse> {
const sp = new URLSearchParams();
if (params.page) sp.set("page", String(params.page));
if (params.page_size) sp.set("page_size", String(params.page_size));
if (params.username) sp.set("username", params.username);
if (params.event_type) sp.set("event_type", params.event_type);
return request<AuditResponse>(`/api/audit?${sp.toString()}`);
}
// ── IMAP ──────────────────────────────────────────────────────────────────
export interface ImapFolder {
name: string;
excluded: boolean;
reason?: string;
}
export interface ImapAccount {
id: number;
owner: string;
name: string;
host: string;
port: number;
tls: string;
username: string;
excluded_folders: string[];
status: string;
error_msg: string;
last_import_at?: string;
last_import_count: number;
progress_current: number;
progress_total: number;
created_at: string;
}
export interface ImapTestResult {
ok: boolean;
folders?: ImapFolder[];
error?: string;
}
export async function getImapAccounts(): Promise<ImapAccount[]> {
return request<ImapAccount[]>("/api/imap");
}
export async function createImapAccount(data: {
name: string;
host: string;
port: number;
tls: string;
username: string;
password: string;
excluded_folders: string[];
}): Promise<ImapAccount> {
return request<ImapAccount>("/api/imap", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function deleteImapAccount(id: number): Promise<void> {
await request<void>(`/api/imap/${id}`, { method: "DELETE" });
}
export async function testImapConnection(data: {
host: string;
port: number;
tls: string;
username: string;
password: string;
}): Promise<ImapTestResult> {
return request<ImapTestResult>("/api/imap/test", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function startImapImport(id: number): Promise<ImapAccount> {
return request<ImapAccount>(`/api/imap/${id}/import`, { method: "POST" });
}
export async function getImapProgress(id: number): Promise<ImapAccount> {
return request<ImapAccount>(`/api/imap/${id}/progress`);
}
// ── System Stats ──────────────────────────────────────────────────────────
export interface SystemStatsCPU {
load1: number;
load5: number;
load15: number;
num_cpu: number;
}
export interface SystemStatsRAM {
total_bytes: number;
used_bytes: number;
free_bytes: number;
used_pct: number;
}
export interface SystemStatsDisk {
mount: string;
total_bytes: number;
used_bytes: number;
free_bytes: number;
used_pct: number;
fstype: string;
}
export interface SystemStatsMailInfo {
id: string;
date: string;
from: string;
subject: string;
}
export interface SystemStats {
cpu: SystemStatsCPU;
ram: SystemStatsRAM;
disks: SystemStatsDisk[];
archive: {
first_mail: SystemStatsMailInfo | null;
last_mail: SystemStatsMailInfo | null;
};
}
export async function getSystemStats(): Promise<SystemStats> {
return request<SystemStats>("/api/admin/system/stats");
}