feat(PROJ-21): Phase 2+3+5+8 Multi-Tenancy + PROJ-2 EML/MBOX Upload

Phase 2a: userstore domain_admin/superadmin Rollen, User.TenantID,
          ListByTenant, UpsertLDAPUser mit tenantID
Phase 2b: storage.Save() mit tenantID *int64, email_refs Tabelle,
          GetTenantForMail, GetAllIDsByTenant, StatsByTenant
Phase 2c: JWT-Claims tenant_id/tenant_slug, Session.TenantID,
          Login Domain-Erkennung via E-Mail-Domain
Phase 3:  tenantMiddleware, Handler-Filterung (Users, Mail, Stats)
Phase 5:  SMTP Domain-Routing via DomainToTenantFunc Callback,
          config smtp.tenant_routing + default_tenant_id
Phase 8:  archivmail migrate-tenants Subkommando
PROJ-2:   Upload-Seite /admin/upload mit DropZone + Progress-Polling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-17 21:03:40 +01:00
parent 5250ffcd52
commit 479c27e5a8
16 changed files with 966 additions and 158 deletions
+314
View File
@@ -0,0 +1,314 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useAuth } from "@/hooks/useAuth";
import { uploadMailFiles, getUploadProgress, type UploadJob } from "@/lib/api";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
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`;
}
export default function UploadPage() {
const { loading: authLoading } = useAuth("admin");
const [dragOver, setDragOver] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [job, setJob] = useState<UploadJob | null>(null);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Stop polling when job is done
useEffect(() => {
if (job && (job.status === "done" || job.status === "error")) {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [job]);
const startPolling = useCallback((jobID: string) => {
pollRef.current = setInterval(async () => {
try {
const progress = await getUploadProgress(jobID);
setJob(progress);
} catch (e) {
console.error("poll error", e);
}
}, 1000);
}, []);
const handleFiles = useCallback((files: FileList | null) => {
if (!files) return;
const valid = Array.from(files).filter((f) => {
const name = f.name.toLowerCase();
return name.endsWith(".eml") || name.endsWith(".mbox");
});
if (valid.length === 0) {
setError("Nur .eml und .mbox Dateien werden unterstützt.");
return;
}
setError(null);
setSelectedFiles((prev) => [...prev, ...valid]);
}, []);
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
handleFiles(e.dataTransfer.files);
},
[handleFiles]
);
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(true);
}, []);
const onDragLeave = useCallback(() => {
setDragOver(false);
}, []);
const onFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
handleFiles(e.target.files);
// Reset input so same file can be re-selected
e.target.value = "";
},
[handleFiles]
);
const removeFile = useCallback((index: number) => {
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
}, []);
const startUpload = useCallback(async () => {
if (selectedFiles.length === 0) return;
setError(null);
setUploading(true);
setJob(null);
try {
const { job_id } = await uploadMailFiles(selectedFiles);
// Fetch initial state immediately
const initial = await getUploadProgress(job_id);
setJob(initial);
startPolling(job_id);
setSelectedFiles([]);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Upload fehlgeschlagen");
setUploading(false);
}
}, [selectedFiles, startPolling]);
const reset = useCallback(() => {
setJob(null);
setError(null);
setUploading(false);
setSelectedFiles([]);
}, []);
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-muted-foreground">Lade...</p>
</div>
);
}
const progressPct =
job && job.total > 0 ? Math.round((job.imported + job.skipped + job.errors) / job.total * 100) : 0;
return (
<div className="min-h-screen bg-background">
<Navbar />
<div className="container mx-auto py-8 px-4 max-w-3xl">
<h1 className="text-2xl font-semibold mb-6">E-Mail Import</h1>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Drop zone */}
{!uploading && (
<Card
className={`mb-6 border-2 border-dashed transition-colors cursor-pointer ${
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/30"
}`}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onClick={() => fileInputRef.current?.click()}
>
<CardContent className="py-12 flex flex-col items-center gap-3">
<svg
className="w-12 h-12 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm text-muted-foreground text-center">
.eml oder .mbox Dateien hierher ziehen
<br />
oder klicken zum Auswählen
</p>
<input
ref={fileInputRef}
type="file"
accept=".eml,.mbox"
multiple
className="hidden"
onChange={onFileInputChange}
/>
</CardContent>
</Card>
)}
{/* Selected files list */}
{selectedFiles.length > 0 && !uploading && (
<Card className="mb-4">
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium">
{selectedFiles.length} Datei(en) ausgewählt
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
<ul className="space-y-1 mb-4 max-h-48 overflow-y-auto">
{selectedFiles.map((f, i) => (
<li
key={i}
className="flex items-center justify-between text-sm py-1"
>
<span className="truncate max-w-[calc(100%-6rem)]">
{f.name}
</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-muted-foreground text-xs">
{formatBytes(f.size)}
</span>
<button
onClick={(e) => {
e.stopPropagation();
removeFile(i);
}}
className="text-muted-foreground hover:text-destructive transition-colors"
aria-label="Entfernen"
>
&times;
</button>
</div>
</li>
))}
</ul>
<Button onClick={startUpload} className="w-full">
Import starten
</Button>
</CardContent>
</Card>
)}
{/* Progress */}
{job && (
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">Import-Fortschritt</CardTitle>
<Badge
variant={
job.status === "done"
? "default"
: job.status === "error"
? "destructive"
: "secondary"
}
>
{job.status === "running"
? "Läuft..."
: job.status === "done"
? "Abgeschlossen"
: "Fehler"}
</Badge>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0 space-y-4">
{job.total > 0 && (
<div className="space-y-1">
<Progress value={progressPct} className="h-2" />
<p className="text-xs text-muted-foreground text-right">
{progressPct}%
</p>
</div>
)}
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-2xl font-semibold text-green-600 dark:text-green-400">
{job.imported}
</p>
<p className="text-xs text-muted-foreground">Importiert</p>
</div>
<div>
<p className="text-2xl font-semibold text-yellow-600 dark:text-yellow-400">
{job.skipped}
</p>
<p className="text-xs text-muted-foreground">Übersprungen</p>
</div>
<div>
<p className="text-2xl font-semibold text-destructive">
{job.errors}
</p>
<p className="text-xs text-muted-foreground">Fehler</p>
</div>
</div>
{job.total > 0 && (
<p className="text-xs text-muted-foreground text-center">
Gesamt: {job.total} Nachrichten
</p>
)}
{job.error_msg && (
<Alert variant="destructive">
<AlertDescription>{job.error_msg}</AlertDescription>
</Alert>
)}
{job.status === "done" && (
<Button variant="outline" onClick={reset} className="w-full">
Neuen Import starten
</Button>
)}
</CardContent>
</Card>
)}
{uploading && !job && (
<Card>
<CardContent className="py-8 flex justify-center">
<p className="text-sm text-muted-foreground">Dateien werden hochgeladen...</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}