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:
@@ -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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user