feat: Labels-Feature vollständig entfernen (PROJ-9)

Backend:
- internal/labelstore/ gelöscht (Store, Schema, CRUD)
- internal/api/label_handlers.go gelöscht (alle Label-Routen)
- internal/api/server.go: labels-Feld + SetLabels() entfernt
- internal/api/search_handlers.go: label_id-Filter + Enrichment entfernt
- internal/index/index.go: LabelID aus SearchRequest entfernt
- internal/imapserver/server.go: labels-Feld + labelbasierte Mailboxen entfernt
- cmd/archivmail/main.go: labelstore-Init + SetLabels() entfernt
- cmd/archivmail/version.go: labelstore-Modul entfernt, index-Kommentar korrigiert

Frontend:
- LabelList.tsx, LabelPicker.tsx, LabelsTab.tsx gelöscht
- src/lib/api/system.ts: MailLabel/LabelRule-Typen + alle Label-Funktionen entfernt
- src/lib/api/index.ts: Label-Exports entfernt
- src/app/search/page.tsx: LabelList + selectedLabelId State entfernt
- src/app/mail/[id]/page.tsx: LabelPicker + Labels-State entfernt
- src/app/admin/page.tsx: LabelsTab + alle Label-Handler/State entfernt

Docs:
- features/PROJ-9: Status auf Removed gesetzt
- features/INDEX.md: PROJ-9 auf Removed gesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-04-04 21:32:13 +02:00
parent 4d1bdb6e8b
commit fdb25cb16a
19 changed files with 6 additions and 1954 deletions
-132
View File
@@ -28,12 +28,6 @@ import {
getTenantDomains,
addTenantDomain,
removeTenantDomain,
getAdminLabels,
createAdminLabel,
deleteAdminLabel,
getLabelRules,
createLabelRule,
deleteLabelRule,
getTenantLogoUrl,
uploadTenantLogo,
deleteTenantLogo,
@@ -50,8 +44,6 @@ import {
type Tenant,
type TenantDefaultUser,
type TenantDomain,
type MailLabel,
type LabelRule,
getCertInfo,
uploadCert,
generateSelfSignedCert,
@@ -72,7 +64,6 @@ import { SecurityTab } from "@/components/admin/tabs/SecurityTab";
import { LDAPTab } from "@/components/admin/tabs/LDAPTab";
import { TenantLDAPTab } from "@/components/admin/tabs/TenantLDAPTab";
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";
@@ -235,19 +226,6 @@ export default function AdminPage() {
handleSyncLDAPUsers,
} = useTenantUsers();
// Labels state
const [adminLabels, setAdminLabels] = useState<MailLabel[]>([]);
const [adminLabelsLoading, setAdminLabelsLoading] = useState(false);
const [adminLabelsError, setAdminLabelsError] = useState("");
const [newLabelName, setNewLabelName] = useState("");
const [newLabelColor, setNewLabelColor] = useState("#ef4444");
const [labelCreating, setLabelCreating] = useState(false);
const [labelRules, setLabelRules] = useState<LabelRule[]>([]);
const [labelRulesLoading, setLabelRulesLoading] = useState(false);
const [newRuleField, setNewRuleField] = useState("from_domain");
const [newRuleValue, setNewRuleValue] = useState("");
const [newRuleLabelId, setNewRuleLabelId] = useState<number | null>(null);
const [ruleCreating, setRuleCreating] = useState(false);
// Certificate state
const [certInfo, setCertInfo] = useState<CertInfo | null>(null);
@@ -535,87 +513,6 @@ export default function AdminPage() {
}
}, []);
const loadAdminLabels = useCallback(async () => {
setAdminLabelsLoading(true);
setAdminLabelsError("");
try {
const data = await getAdminLabels();
setAdminLabels(data || []);
} catch {
setAdminLabelsError("Labels konnten nicht geladen werden.");
} finally {
setAdminLabelsLoading(false);
}
}, []);
const loadLabelRules = useCallback(async () => {
setLabelRulesLoading(true);
try {
const data = await getLabelRules();
setLabelRules(data || []);
} catch {
// ignore
} finally {
setLabelRulesLoading(false);
}
}, []);
function loadLabelsTab() {
loadAdminLabels();
loadLabelRules();
}
async function handleCreateAdminLabel(e: React.FormEvent) {
e.preventDefault();
if (!newLabelName.trim()) return;
setLabelCreating(true);
try {
await createAdminLabel(newLabelName.trim(), newLabelColor);
setNewLabelName("");
setNewLabelColor("#ef4444");
await loadAdminLabels();
} catch (err) {
setAdminLabelsError(err instanceof Error ? err.message : "Fehler");
} finally {
setLabelCreating(false);
}
}
async function handleDeleteAdminLabel(id: number, name: string) {
if (!window.confirm(`Globales Label "${name}" wirklich loeschen?`)) return;
try {
await deleteAdminLabel(id);
await loadAdminLabels();
await loadLabelRules();
} catch (err) {
setAdminLabelsError(err instanceof Error ? err.message : "Fehler");
}
}
async function handleCreateRule(e: React.FormEvent) {
e.preventDefault();
if (!newRuleValue.trim() || !newRuleLabelId) return;
setRuleCreating(true);
try {
await createLabelRule(newRuleField, newRuleValue.trim(), newRuleLabelId);
setNewRuleValue("");
await loadLabelRules();
} catch (err) {
setAdminLabelsError(err instanceof Error ? err.message : "Fehler");
} finally {
setRuleCreating(false);
}
}
async function handleDeleteRule(id: number) {
if (!window.confirm("Regel wirklich loeschen?")) return;
try {
await deleteLabelRule(id);
await loadLabelRules();
} catch {
// ignore
}
}
async function handleCreateTenant(e: React.FormEvent) {
e.preventDefault();
@@ -806,7 +703,6 @@ export default function AdminPage() {
{!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>}
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
@@ -957,34 +853,6 @@ export default function AdminPage() {
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="labels">
<LabelsTab
adminLabels={adminLabels}
adminLabelsLoading={adminLabelsLoading}
adminLabelsError={adminLabelsError}
newLabelName={newLabelName}
setNewLabelName={setNewLabelName}
newLabelColor={newLabelColor}
setNewLabelColor={setNewLabelColor}
labelCreating={labelCreating}
labelRules={labelRules}
labelRulesLoading={labelRulesLoading}
newRuleField={newRuleField}
setNewRuleField={setNewRuleField}
newRuleValue={newRuleValue}
setNewRuleValue={setNewRuleValue}
newRuleLabelId={newRuleLabelId}
setNewRuleLabelId={setNewRuleLabelId}
ruleCreating={ruleCreating}
onCreateLabel={handleCreateAdminLabel}
onDeleteLabel={handleDeleteAdminLabel}
onCreateRule={handleCreateRule}
onDeleteRule={handleDeleteRule}
/>
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="cert">
<CertTab
-29
View File
@@ -7,15 +7,11 @@ import {
downloadMailAttachment,
downloadMailRaw,
exportMailPDF,
getLabels,
getMailLabelIds,
type MailDetail,
type MailAttachment,
type MailLabel,
} from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { Navbar } from "@/components/navbar";
import { LabelPicker } from "@/components/LabelPicker";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
@@ -260,10 +256,6 @@ export default function MailViewPage({
const [downloading, setDownloading] = useState(false);
const [pdfLoading, setPdfLoading] = useState(false);
// Labels state
const [allLabels, setAllLabels] = useState<MailLabel[]>([]);
const [assignedLabelIds, setAssignedLabelIds] = useState<number[]>([]);
useEffect(() => {
if (!user) return;
getMail(id)
@@ -272,16 +264,9 @@ export default function MailViewPage({
setError(e instanceof Error ? e.message : "Unbekannter Fehler")
)
.finally(() => setLoading(false));
// Load labels
getLabels().then(setAllLabels).catch(() => {});
loadMailLabels();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, user]);
function loadMailLabels() {
getMailLabelIds(id).then(setAssignedLabelIds).catch(() => setAssignedLabelIds([]));
}
async function handleEmlDownload() {
setDownloading(true);
try {
@@ -380,20 +365,6 @@ export default function MailViewPage({
</CardHeader>
</Card>
{/* Labels */}
{allLabels.length > 0 && (
<Card>
<CardContent className="pt-4 pb-4">
<LabelPicker
emailId={id}
assignedLabelIds={assignedLabelIds}
allLabels={allLabels}
onUpdate={loadMailLabels}
/>
</CardContent>
</Card>
)}
{/* Body */}
<Card>
<CardContent className="pt-6">
+1 -21
View File
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { searchEmails, exportMailsZIP, uploadMailFilesUser, getUploadProgressUser, type SearchHit, type UploadJob } from "@/lib/api";
import { Navbar } from "@/components/navbar";
import { LabelList } from "@/components/LabelList";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
@@ -57,7 +56,6 @@ export default function SearchPage() {
const [dateTo, setDateTo] = useState("");
const [sort, setSort] = useState("date_desc");
const [hasAttachment, setHasAttachment] = useState<boolean | undefined>(undefined);
const [selectedLabelId, setSelectedLabelId] = useState<number | null>(null);
const [results, setResults] = useState<SearchHit[]>([]);
const [total, setTotal] = useState(0);
@@ -96,7 +94,6 @@ export default function SearchPage() {
date_to: dateTo || undefined,
sort: sort !== "date_desc" ? sort : undefined,
has_attachment: hasAttachment,
label_id: selectedLabelId ?? undefined,
page: p,
page_size: PAGE_SIZE,
});
@@ -111,7 +108,7 @@ export default function SearchPage() {
setSearching(false);
}
},
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment, selectedLabelId]
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment]
);
// Superadmin has no mail access — redirect to admin dashboard
@@ -139,13 +136,6 @@ export default function SearchPage() {
.finally(() => setSearching(false));
}, [user]);
// Re-search when label selection changes
useEffect(() => {
if (!user || !searched) return;
doSearch(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedLabelId]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
doSearch(1);
@@ -234,16 +224,6 @@ export default function SearchPage() {
)}
{!authLoading && user && (<>
<div className="flex gap-6">
{/* Label sidebar — nur für Rollen mit eigenen Mails */}
{user.role !== "auditor" && user.role !== "domain_auditor" && (
<div className="hidden md:block w-48 shrink-0">
<LabelList
selectedLabelId={selectedLabelId}
onLabelSelect={setSelectedLabelId}
/>
</div>
)}
{/* Main content */}
<div className="flex-1 min-w-0">
<form onSubmit={handleSubmit} className="space-y-4">
-308
View File
@@ -1,308 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
getLabels,
createLabel,
updateLabel,
deleteLabel,
type MailLabel,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
const LABEL_COLORS = [
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#06b6d4",
"#6366f1",
"#a855f7",
"#ec4899",
];
interface LabelListProps {
selectedLabelId?: number | null;
onLabelSelect: (id: number | null) => void;
}
export function LabelList({ selectedLabelId, onLabelSelect }: LabelListProps) {
const [labels, setLabels] = useState<MailLabel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Create form
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [newColor, setNewColor] = useState(LABEL_COLORS[0]);
const [creating, setCreating] = useState(false);
// Edit state
const [editId, setEditId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editColor, setEditColor] = useState("");
const [saving, setSaving] = useState(false);
// Hover state
const [hoveredId, setHoveredId] = useState<number | null>(null);
const loadLabels = useCallback(async () => {
try {
const data = await getLabels();
setLabels(data || []);
setError("");
} catch (e) {
setError(e instanceof Error ? e.message : "Fehler beim Laden");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadLabels();
}, [loadLabels]);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
setCreating(true);
try {
await createLabel(newName.trim(), newColor);
setNewName("");
setNewColor(LABEL_COLORS[0]);
setShowCreate(false);
await loadLabels();
} catch (err) {
setError(err instanceof Error ? err.message : "Fehler beim Erstellen");
} finally {
setCreating(false);
}
}
async function handleUpdate(e: React.FormEvent) {
e.preventDefault();
if (!editId || !editName.trim()) return;
setSaving(true);
try {
await updateLabel(editId, editName.trim(), editColor);
setEditId(null);
await loadLabels();
} catch (err) {
setError(err instanceof Error ? err.message : "Fehler beim Speichern");
} finally {
setSaving(false);
}
}
async function handleDelete(label: MailLabel) {
if (!window.confirm(`Label "${label.name}" wirklich loeschen?`)) return;
try {
await deleteLabel(label.id);
if (selectedLabelId === label.id) onLabelSelect(null);
await loadLabels();
} catch (err) {
setError(err instanceof Error ? err.message : "Fehler beim Loeschen");
}
}
function startEdit(label: MailLabel) {
setEditId(label.id);
setEditName(label.name);
setEditColor(label.color);
}
if (loading) {
return (
<div className="space-y-2 p-2" aria-label="Labels laden">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
);
}
return (
<aside className="space-y-2" aria-label="Label-Filter">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Labels</h3>
</div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{/* Label list */}
<ul className="space-y-0.5">
{labels.map((label) => (
<li
key={label.id}
className={`group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors ${
selectedLabelId === label.id
? "bg-muted font-medium"
: "hover:bg-muted/50"
}`}
onMouseEnter={() => setHoveredId(label.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() =>
onLabelSelect(selectedLabelId === label.id ? null : label.id)
}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onLabelSelect(selectedLabelId === label.id ? null : label.id);
}
}}
aria-pressed={selectedLabelId === label.id}
aria-label={`Label ${label.name}${label.is_global ? " (global)" : ""}`}
>
<span
className="inline-block h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: label.color }}
aria-hidden="true"
/>
<span className="truncate flex-1">{label.name}</span>
{label.is_global && (
<span className="text-xs text-muted-foreground" title="Globales Label" aria-label="Globales Label">
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</span>
)}
{!label.is_global && hoveredId === label.id && (
<span className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
className="text-muted-foreground hover:text-foreground"
title="Bearbeiten"
aria-label={`Label ${label.name} bearbeiten`}
onClick={(e) => {
e.stopPropagation();
startEdit(label);
}}
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<button
className="text-muted-foreground hover:text-destructive"
title="Loeschen"
aria-label={`Label ${label.name} loeschen`}
onClick={(e) => {
e.stopPropagation();
handleDelete(label);
}}
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</span>
)}
</li>
))}
</ul>
{labels.length === 0 && !error && (
<p className="text-xs text-muted-foreground px-2">Keine Labels vorhanden.</p>
)}
{/* Edit inline form */}
{editId !== null && (
<form onSubmit={handleUpdate} className="space-y-2 rounded-md border p-2">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Label-Name"
className="h-7 text-sm"
autoFocus
aria-label="Label-Name bearbeiten"
/>
<div className="flex flex-wrap gap-1.5">
{LABEL_COLORS.map((c) => (
<button
key={c}
type="button"
className={`h-5 w-5 rounded-full border-2 transition-transform ${
editColor === c ? "border-foreground scale-110" : "border-transparent"
}`}
style={{ backgroundColor: c }}
onClick={() => setEditColor(c)}
aria-label={`Farbe ${c}`}
/>
))}
</div>
<div className="flex gap-1">
<Button type="submit" size="sm" className="h-7 text-xs" disabled={saving}>
{saving ? "..." : "Speichern"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setEditId(null)}
>
Abbrechen
</Button>
</div>
</form>
)}
{/* Create form */}
{showCreate ? (
<form onSubmit={handleCreate} className="space-y-2 rounded-md border p-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Neues Label"
className="h-7 text-sm"
autoFocus
aria-label="Neuer Label-Name"
/>
<div className="flex flex-wrap gap-1.5">
{LABEL_COLORS.map((c) => (
<button
key={c}
type="button"
className={`h-5 w-5 rounded-full border-2 transition-transform ${
newColor === c ? "border-foreground scale-110" : "border-transparent"
}`}
style={{ backgroundColor: c }}
onClick={() => setNewColor(c)}
aria-label={`Farbe ${c}`}
/>
))}
</div>
<div className="flex gap-1">
<Button type="submit" size="sm" className="h-7 text-xs" disabled={creating || !newName.trim()}>
{creating ? "..." : "Erstellen"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setShowCreate(false)}
>
Abbrechen
</Button>
</div>
</form>
) : (
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-xs text-muted-foreground h-7"
onClick={() => setShowCreate(true)}
>
+ Neues Label
</Button>
)}
</aside>
);
}
-141
View File
@@ -1,141 +0,0 @@
"use client";
import { useState } from "react";
import { assignLabel, removeLabelFromEmail, type MailLabel } from "@/lib/api";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
interface LabelPickerProps {
emailId: string;
assignedLabelIds: number[];
allLabels: MailLabel[];
onUpdate: () => void;
}
export function LabelPicker({
emailId,
assignedLabelIds,
allLabels,
onUpdate,
}: LabelPickerProps) {
const [open, setOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<number | null>(null);
const assignedSet = new Set(assignedLabelIds);
async function toggleLabel(label: MailLabel) {
setActionLoading(label.id);
try {
if (assignedSet.has(label.id)) {
await removeLabelFromEmail(emailId, label.id);
} else {
await assignLabel(emailId, label.id);
}
onUpdate();
} catch (e) {
console.error("Label toggle failed:", e);
} finally {
setActionLoading(null);
}
}
const assignedLabels = allLabels.filter((l) => assignedSet.has(l.id));
return (
<div className="flex flex-wrap items-center gap-2" aria-label="E-Mail-Labels">
{assignedLabels.map((label) => (
<Badge
key={label.id}
variant="secondary"
className="gap-1.5 text-xs font-normal"
style={{
borderColor: label.color,
borderWidth: "1px",
borderStyle: "solid",
}}
>
<span
className="inline-block h-2 w-2 rounded-full shrink-0"
style={{ backgroundColor: label.color }}
aria-hidden="true"
/>
{label.name}
</Badge>
))}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
aria-label="Label hinzufuegen oder entfernen"
>
+ Label
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Labels zuweisen
</p>
{allLabels.length === 0 && (
<p className="text-xs text-muted-foreground py-2 text-center">
Keine Labels vorhanden.
</p>
)}
<ul className="space-y-0.5 max-h-60 overflow-y-auto">
{allLabels.map((label) => {
const isAssigned = assignedSet.has(label.id);
const isLoading = actionLoading === label.id;
return (
<li key={label.id}>
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50 transition-colors disabled:opacity-50"
onClick={() => toggleLabel(label)}
disabled={isLoading}
aria-pressed={isAssigned}
aria-label={`Label ${label.name} ${isAssigned ? "entfernen" : "zuweisen"}`}
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
isAssigned
? "bg-primary border-primary text-primary-foreground"
: "border-muted-foreground/30"
}`}
>
{isAssigned && (
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</span>
<span
className="inline-block h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: label.color }}
aria-hidden="true"
/>
<span className="truncate">{label.name}</span>
{isLoading && (
<span className="ml-auto text-xs text-muted-foreground">...</span>
)}
</button>
</li>
);
})}
</ul>
</PopoverContent>
</Popover>
</div>
);
}
-282
View File
@@ -1,282 +0,0 @@
"use client";
import { type MailLabel, type LabelRule } from "@/lib/api";
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 { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface LabelsTabProps {
adminLabels: MailLabel[];
adminLabelsLoading: boolean;
adminLabelsError: string;
newLabelName: string;
setNewLabelName: (v: string) => void;
newLabelColor: string;
setNewLabelColor: (v: string) => void;
labelCreating: boolean;
labelRules: LabelRule[];
labelRulesLoading: boolean;
newRuleField: string;
setNewRuleField: (v: string) => void;
newRuleValue: string;
setNewRuleValue: (v: string) => void;
newRuleLabelId: number | null;
setNewRuleLabelId: (v: number | null) => void;
ruleCreating: boolean;
onCreateLabel: (e: React.FormEvent) => void;
onDeleteLabel: (id: number, name: string) => void;
onCreateRule: (e: React.FormEvent) => void;
onDeleteRule: (id: number) => void;
}
export function LabelsTab({
adminLabels,
adminLabelsLoading,
adminLabelsError,
newLabelName,
setNewLabelName,
newLabelColor,
setNewLabelColor,
labelCreating,
labelRules,
labelRulesLoading,
newRuleField,
setNewRuleField,
newRuleValue,
setNewRuleValue,
newRuleLabelId,
setNewRuleLabelId,
ruleCreating,
onCreateLabel,
onDeleteLabel,
onCreateRule,
onDeleteRule,
}: LabelsTabProps) {
return (
<div className="mt-4 space-y-6">
{adminLabelsError && (
<Alert variant="destructive">
<AlertDescription>{adminLabelsError}</AlertDescription>
</Alert>
)}
{/* Globale Labels */}
<Card>
<CardContent className="pt-6 space-y-4">
<h3 className="text-sm font-semibold">Globale Labels</h3>
<form onSubmit={onCreateLabel} className="flex items-end gap-3">
<div className="space-y-1">
<Label htmlFor="label-name" className="text-xs">Name</Label>
<Input
id="label-name"
value={newLabelName}
onChange={(e) => setNewLabelName(e.target.value)}
placeholder="Label-Name"
className="h-8 w-48"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Farbe</Label>
<div className="flex gap-1.5">
{["#ef4444","#f97316","#eab308","#22c55e","#06b6d4","#6366f1","#a855f7","#ec4899"].map((c) => (
<button
key={c}
type="button"
className={`h-6 w-6 rounded-full border-2 transition-transform ${
newLabelColor === c ? "border-foreground scale-110" : "border-transparent"
}`}
style={{ backgroundColor: c }}
onClick={() => setNewLabelColor(c)}
aria-label={`Farbe ${c}`}
/>
))}
</div>
</div>
<Button type="submit" size="sm" disabled={labelCreating || !newLabelName.trim()}>
{labelCreating ? "..." : "Anlegen"}
</Button>
</form>
{adminLabelsLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : adminLabels.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine globalen Labels vorhanden.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-24">Farbe</TableHead>
<TableHead className="w-24">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminLabels.map((label) => (
<TableRow key={label.id}>
<TableCell className="font-medium">{label.name}</TableCell>
<TableCell>
<span
className="inline-block h-4 w-4 rounded-full"
style={{ backgroundColor: label.color }}
aria-label={`Farbe ${label.color}`}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive h-7"
onClick={() => onDeleteLabel(label.id, label.name)}
>
Loeschen
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Auto-Regeln */}
<Card>
<CardContent className="pt-6 space-y-4">
<h3 className="text-sm font-semibold">Auto-Regeln</h3>
<form onSubmit={onCreateRule} className="flex items-end gap-3 flex-wrap">
<div className="space-y-1">
<Label htmlFor="rule-field" className="text-xs">Bedingung</Label>
<Select value={newRuleField} onValueChange={setNewRuleField}>
<SelectTrigger id="rule-field" className="h-8 w-44 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="from_domain">Absender-Domain</SelectItem>
<SelectItem value="source">Import-Quelle</SelectItem>
<SelectItem value="subject_contains">Betreff enthaelt</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="rule-value" className="text-xs">Wert</Label>
<Input
id="rule-value"
value={newRuleValue}
onChange={(e) => setNewRuleValue(e.target.value)}
placeholder="z.B. example.com"
className="h-8 w-48"
/>
</div>
<div className="space-y-1">
<Label htmlFor="rule-label" className="text-xs">Label</Label>
<Select
value={newRuleLabelId !== null ? String(newRuleLabelId) : ""}
onValueChange={(v) => setNewRuleLabelId(Number(v))}
>
<SelectTrigger id="rule-label" className="h-8 w-44 text-xs">
<SelectValue placeholder="Label waehlen..." />
</SelectTrigger>
<SelectContent>
{adminLabels.map((l) => (
<SelectItem key={l.id} value={String(l.id)}>
<span className="flex items-center gap-2">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: l.color }}
/>
{l.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={ruleCreating || !newRuleValue.trim() || !newRuleLabelId}>
{ruleCreating ? "..." : "Regel anlegen"}
</Button>
</form>
{labelRulesLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : labelRules.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Regeln vorhanden.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Bedingung</TableHead>
<TableHead>Wert</TableHead>
<TableHead>Label</TableHead>
<TableHead className="w-24">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{labelRules.map((rule) => {
const condLabels: Record<string, string> = {
from_domain: "Absender-Domain",
source: "Import-Quelle",
subject_contains: "Betreff enthaelt",
};
const matchLabel = adminLabels.find((l) => l.id === rule.label_id);
return (
<TableRow key={rule.id}>
<TableCell className="text-sm">{condLabels[rule.condition_field] || rule.condition_field}</TableCell>
<TableCell className="text-sm font-mono">{rule.condition_value}</TableCell>
<TableCell>
{matchLabel ? (
<span className="flex items-center gap-2 text-sm">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: matchLabel.color }}
/>
{matchLabel.name}
</span>
) : (
<span className="text-xs text-muted-foreground">ID {rule.label_id}</span>
)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive h-7"
onClick={() => onDeleteRule(rule.id)}
>
Loeschen
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
-15
View File
@@ -131,8 +131,6 @@ export type {
SystemStats,
SecurityCheck,
SecurityAuditResult,
MailLabel,
LabelRule,
CertInfo,
SelfSignedRequest,
ACMERequest,
@@ -147,19 +145,6 @@ export {
getAuditLog,
getSecurityAudit,
fixSecurityIssue,
getLabels,
createLabel,
updateLabel,
deleteLabel,
assignLabel,
removeLabelFromEmail,
getMailLabelIds,
createAdminLabel,
getAdminLabels,
deleteAdminLabel,
getLabelRules,
createLabelRule,
deleteLabelRule,
getCertInfo,
uploadCert,
generateSelfSignedCert,
-2
View File
@@ -123,7 +123,6 @@ export async function searchEmails(params: {
date_to?: string;
sort?: string;
has_attachment?: boolean;
label_id?: number;
page?: number;
page_size?: number;
}): Promise<SearchResponse> {
@@ -135,7 +134,6 @@ export async function searchEmails(params: {
if (params.date_to) sp.set("date_to", params.date_to);
if (params.sort) sp.set("sort", params.sort);
if (params.has_attachment !== undefined) sp.set("has_attachment", String(params.has_attachment));
if (params.label_id !== undefined) sp.set("label_id", String(params.label_id));
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()}`);
-93
View File
@@ -121,24 +121,6 @@ export interface SecurityAuditResult {
run_at: string;
}
export interface MailLabel {
id: number;
name: string;
color: string;
owner_id?: number;
tenant_id: number;
is_global: boolean;
created_at: string;
}
export interface LabelRule {
id: number;
condition_field: "from_domain" | "source" | "subject_contains";
condition_value: string;
label_id: number;
tenant_id: number;
}
export interface CertInfo {
exists: boolean;
subject?: string;
@@ -227,81 +209,6 @@ export async function fixSecurityIssue(action: string): Promise<{ message: strin
});
}
// ── Labels ────────────────────────────────────────────────────────────────────
export async function getLabels(): Promise<MailLabel[]> {
return request<MailLabel[]>("/api/labels");
}
export async function createLabel(name: string, color: string): Promise<MailLabel> {
return request<MailLabel>("/api/labels", {
method: "POST",
body: JSON.stringify({ name, color }),
});
}
export async function updateLabel(id: number, name: string, color: string): Promise<void> {
return request<void>(`/api/labels/${id}`, {
method: "PATCH",
body: JSON.stringify({ name, color }),
});
}
export async function deleteLabel(id: number): Promise<void> {
return request<void>(`/api/labels/${id}`, { method: "DELETE" });
}
export async function assignLabel(emailId: string, labelId: number): Promise<void> {
return request<void>(`/api/mails/${emailId}/labels`, {
method: "POST",
body: JSON.stringify({ label_id: labelId }),
});
}
export async function removeLabelFromEmail(emailId: string, labelId: number): Promise<void> {
return request<void>(`/api/mails/${emailId}/labels/${labelId}`, {
method: "DELETE",
});
}
export async function getMailLabelIds(emailId: string): Promise<number[]> {
return request<number[]>(`/api/mails/${emailId}/labels`);
}
export async function createAdminLabel(name: string, color: string): Promise<MailLabel> {
return request<MailLabel>("/api/admin/labels", {
method: "POST",
body: JSON.stringify({ name, color }),
});
}
export async function getAdminLabels(): Promise<MailLabel[]> {
return request<MailLabel[]>("/api/admin/labels");
}
export async function deleteAdminLabel(id: number): Promise<void> {
return request<void>(`/api/admin/labels/${id}`, { method: "DELETE" });
}
export async function getLabelRules(): Promise<LabelRule[]> {
return request<LabelRule[]>("/api/admin/label-rules");
}
export async function createLabelRule(
condition_field: string,
condition_value: string,
label_id: number
): Promise<LabelRule> {
return request<LabelRule>("/api/admin/label-rules", {
method: "POST",
body: JSON.stringify({ condition_field, condition_value, label_id }),
});
}
export async function deleteLabelRule(id: number): Promise<void> {
return request<void>(`/api/admin/label-rules/${id}`, { method: "DELETE" });
}
// ── Certificates ──────────────────────────────────────────────────────────────
export async function getCertInfo(): Promise<CertInfo> {