feat(PROJ-33): IMAP UID-Stabilität + Shared/Personal-Modus
Backend: - storage: uid BIGSERIAL Migration, MailWithUID, GetMailsWithUID, GetMailsByRecipient - tenantstore: imap_mode Spalte, GetIMAPMode, SetIMAPMode - imapserver: stable UIDs aus DB, personal/shared Modus, userEmail in session - api: GET/PUT /api/admin/settings/imap-mode (domain_admin only, double opt-in) Frontend: - IMAPSettingsTab: Modus-Anzeige + Toggle mit Double-Opt-In Dialog - Admin-Panel: IMAP-Tab für domain_admin Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,7 @@ import { TenantsTab } from "@/components/admin/tabs/TenantsTab";
|
||||
import { LabelsTab } from "@/components/admin/tabs/LabelsTab";
|
||||
import { CertTab } from "@/components/admin/tabs/CertTab";
|
||||
import { ModulesTab } from "@/components/admin/ModulesTab";
|
||||
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
|
||||
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
|
||||
|
||||
const AUDIT_PAGE_SIZE = 25;
|
||||
@@ -799,6 +800,9 @@ export default function AdminPage() {
|
||||
{!isSuperAdmin && user?.role === "domain_admin" && (
|
||||
<TabsTrigger value="tenant-ldap" onClick={loadTenantLDAP}>LDAP</TabsTrigger>
|
||||
)}
|
||||
{!isSuperAdmin && user?.role === "domain_admin" && (
|
||||
<TabsTrigger value="imap-settings">IMAP</TabsTrigger>
|
||||
)}
|
||||
{isSuperAdmin && <TabsTrigger value="labels" onClick={loadLabelsTab}>Labels</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
|
||||
@@ -1075,6 +1079,12 @@ export default function AdminPage() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{!isSuperAdmin && (
|
||||
<TabsContent value="imap-settings">
|
||||
<IMAPSettingsTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="modules" className="mt-4">
|
||||
<ModulesTab />
|
||||
</TabsContent>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
async function getIMAPMode(): Promise<string> {
|
||||
const res = await fetch("/api/admin/settings/imap-mode", { credentials: "include" });
|
||||
if (!res.ok) throw new Error("Fehler beim Laden");
|
||||
const data = await res.json();
|
||||
return data.mode;
|
||||
}
|
||||
|
||||
async function setIMAPMode(mode: string, confirmed: boolean): Promise<void> {
|
||||
const res = await fetch("/api/admin/settings/imap-mode", {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode, confirmed }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: string }).error ?? "Fehler beim Speichern");
|
||||
}
|
||||
}
|
||||
|
||||
export function IMAPSettingsTab() {
|
||||
const [mode, setMode] = useState<string>("personal");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [sharedDialogOpen, setSharedDialogOpen] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getIMAPMode()
|
||||
.then(setMode)
|
||||
.catch(() => setError("IMAP-Modus konnte nicht geladen werden"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSetPersonal = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await setIMAPMode("personal", false);
|
||||
setMode("personal");
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Fehler");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmShared = async () => {
|
||||
if (!confirmed) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await setIMAPMode("shared", true);
|
||||
setMode("shared");
|
||||
setSharedDialogOpen(false);
|
||||
setConfirmed(false);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Fehler");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IMAP-Zugriffsmodus</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground text-sm">Lädt...</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">Aktueller Modus:</span>
|
||||
<Badge variant={mode === "shared" ? "destructive" : "default"}>
|
||||
{mode === "shared" ? "Gemeinsames Archiv" : "Persönlicher Posteingang"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{mode === "personal"
|
||||
? "Jeder Nutzer sieht über IMAP nur seine eigenen archivierten Mails (gefiltert nach E-Mail-Adresse)."
|
||||
: "Alle Nutzer dieses Mandanten sehen über IMAP alle archivierten Mails des Mandanten."}
|
||||
</p>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
{mode !== "personal" && (
|
||||
<Button variant="outline" disabled={saving} onClick={handleSetPersonal}>
|
||||
{saving ? "Speichern..." : "Auf Persönlich wechseln"}
|
||||
</Button>
|
||||
)}
|
||||
{mode !== "shared" && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setSharedDialogOpen(true);
|
||||
setConfirmed(false);
|
||||
}}
|
||||
>
|
||||
Gemeinsames Archiv aktivieren
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={sharedDialogOpen} onOpenChange={setSharedDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gemeinsames Archiv aktivieren?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Im gemeinsamen Modus sehen <strong>alle Nutzer dieses Mandanten</strong> über IMAP
|
||||
alle archivierten Mails — unabhängig von der ursprünglichen Empfängeradresse.
|
||||
Diese Einstellung kann jederzeit zurückgestellt werden.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Checkbox
|
||||
id="confirm-shared"
|
||||
checked={confirmed}
|
||||
onCheckedChange={(v) => setConfirmed(v === true)}
|
||||
/>
|
||||
<Label htmlFor="confirm-shared">
|
||||
Ich habe verstanden, dass alle Nutzer dieses Mandanten alle Mails sehen werden.
|
||||
</Label>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSharedDialogOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!confirmed || saving}
|
||||
onClick={handleConfirmShared}
|
||||
>
|
||||
{saving ? "Aktiviere..." : "Gemeinsames Archiv aktivieren"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user