diff --git a/features/PROJ-9-ordner-und-labels.md b/features/PROJ-9-ordner-und-labels.md index a108ad2..227adee 100644 --- a/features/PROJ-9-ordner-und-labels.md +++ b/features/PROJ-9-ordner-und-labels.md @@ -163,6 +163,17 @@ Suche mit Label-Filter läuft vollständig in Xapian – kein zusätzlicher DB-J **Design deviation from spec:** Label filtering uses Go-level post-filter on search results (same pattern as tenant isolation fallback) instead of Xapian boolean terms. Reason: the Xapian integration goes through CGO with a C wrapper; adding label terms would require modifying both the C wrapper and the Go bridge. The post-filter approach is simpler and consistent with existing patterns. +## Implementation Notes (Frontend) + +**Implemented 2026-03-18:** + +- `src/lib/api.ts` -- Added `MailLabel` and `LabelRule` interfaces plus all label API functions (getLabels, createLabel, updateLabel, deleteLabel, assignLabel, removeLabelFromEmail, getMailLabelIds, createAdminLabel, getAdminLabels, deleteAdminLabel, getLabelRules, createLabelRule, deleteLabelRule). Added `label_id` parameter to `searchEmails()`. Named interface `MailLabel` to avoid conflict with shadcn `Label` component. +- `src/components/LabelList.tsx` -- Sidebar component showing all labels with colored circles, selection state, create/edit/delete inline forms, color picker (8 preset colors). Global labels show lock icon and cannot be deleted. Hidden on mobile (`hidden md:block`). +- `src/components/LabelPicker.tsx` -- Popover-based label assignment widget for mail detail view. Shows assigned labels as colored badges. Popover lists all labels with checkbox-style toggles for assign/remove. +- `src/app/search/page.tsx` -- Integrated LabelList as left sidebar (w-48). Added `selectedLabelId` state. Label selection triggers re-search with `label_id` parameter. Layout uses flex with sidebar and main content area. +- `src/app/mail/[id]/page.tsx` -- Integrated LabelPicker after mail header card. Loads all labels and assigned label IDs on mount. Updates label assignments via `onUpdate` callback. +- `src/app/admin/page.tsx` -- Added "Labels" tab (superadmin only) with two sections: Global Labels management (create/delete with color picker) and Auto-Rules management (create/delete with condition field/value/label selection). + ## QA Test Results _To be added by /qa_ diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 882feea..53f6f1c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -38,6 +38,12 @@ import { saveAdminTenantLDAPConfig, deleteAdminTenantLDAPConfig, testAdminTenantLDAPConfig, + getAdminLabels, + createAdminLabel, + deleteAdminLabel, + getLabelRules, + createLabelRule, + deleteLabelRule, type User, type AuditEntry, type SMTPStatus, @@ -52,6 +58,8 @@ import { type TenantLDAPConfig, type Tenant, type TenantDomain, + type MailLabel, + type LabelRule, } from "@/lib/api"; import { Navbar } from "@/components/navbar"; import { Button } from "@/components/ui/button"; @@ -225,6 +233,20 @@ export default function AdminPage() { // Superadmin: tenant LDAP dialog const [tenantLdapDialogId, setTenantLdapDialogId] = useState(null); + // Labels state + const [adminLabels, setAdminLabels] = useState([]); + 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([]); + const [labelRulesLoading, setLabelRulesLoading] = useState(false); + const [newRuleField, setNewRuleField] = useState("from_domain"); + const [newRuleValue, setNewRuleValue] = useState(""); + const [newRuleLabelId, setNewRuleLabelId] = useState(null); + const [ruleCreating, setRuleCreating] = useState(false); + const loadDashboard = useCallback(async () => { setDashLoading(true); try { @@ -556,6 +578,88 @@ 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(); setTenantCreateLoading(true); @@ -730,6 +834,7 @@ export default function AdminPage() { {!isSuperAdmin && user?.role === "domain_admin" && ( LDAP )} + {isSuperAdmin && Labels} {isSuperAdmin && Security} {isSuperAdmin && Mandanten} {isSuperAdmin && Module} @@ -2422,6 +2527,215 @@ export default function AdminPage() { )} + {/* ── Labels (Admin) ── */} + + {adminLabelsError && ( + + {adminLabelsError} + + )} + + {/* Globale Labels */} + + +

Globale Labels

+
+
+ + setNewLabelName(e.target.value)} + placeholder="Label-Name" + className="h-8 w-48" + /> +
+
+ +
+ {["#ef4444","#f97316","#eab308","#22c55e","#06b6d4","#6366f1","#a855f7","#ec4899"].map((c) => ( +
+
+ +
+ + {adminLabelsLoading ? ( +
+ + +
+ ) : adminLabels.length === 0 ? ( +

Keine globalen Labels vorhanden.

+ ) : ( + + + + Name + Farbe + Aktionen + + + + {adminLabels.map((label) => ( + + {label.name} + + + + + + + + ))} + +
+ )} +
+
+ + {/* Auto-Regeln */} + + +

Auto-Regeln

+
+
+ + +
+
+ + setNewRuleValue(e.target.value)} + placeholder="z.B. example.com" + className="h-8 w-48" + /> +
+
+ + +
+ +
+ + {labelRulesLoading ? ( +
+ + +
+ ) : labelRules.length === 0 ? ( +

Keine Regeln vorhanden.

+ ) : ( + + + + Bedingung + Wert + Label + Aktionen + + + + {labelRules.map((rule) => { + const condLabels: Record = { + from_domain: "Absender-Domain", + source: "Import-Quelle", + subject_contains: "Betreff enthaelt", + }; + const matchLabel = adminLabels.find((l) => l.id === rule.label_id); + return ( + + {condLabels[rule.condition_field] || rule.condition_field} + {rule.condition_value} + + {matchLabel ? ( + + + {matchLabel.name} + + ) : ( + ID {rule.label_id} + )} + + + + + + ); + })} + +
+ )} +
+
+
+ diff --git a/src/app/mail/[id]/page.tsx b/src/app/mail/[id]/page.tsx index 794694f..5c2323c 100644 --- a/src/app/mail/[id]/page.tsx +++ b/src/app/mail/[id]/page.tsx @@ -7,11 +7,15 @@ 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"; @@ -256,6 +260,10 @@ export default function MailViewPage({ const [downloading, setDownloading] = useState(false); const [pdfLoading, setPdfLoading] = useState(false); + // Labels state + const [allLabels, setAllLabels] = useState([]); + const [assignedLabelIds, setAssignedLabelIds] = useState([]); + useEffect(() => { if (!user) return; getMail(id) @@ -264,8 +272,16 @@ 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 { @@ -364,6 +380,20 @@ export default function MailViewPage({ + {/* Labels */} + {allLabels.length > 0 && ( + + + + + + )} + {/* Body */} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 82529ba..6aa8f5f 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -5,6 +5,7 @@ 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"; @@ -56,6 +57,7 @@ export default function SearchPage() { const [dateTo, setDateTo] = useState(""); const [sort, setSort] = useState("date_desc"); const [hasAttachment, setHasAttachment] = useState(undefined); + const [selectedLabelId, setSelectedLabelId] = useState(null); const [results, setResults] = useState([]); const [total, setTotal] = useState(0); @@ -94,6 +96,7 @@ 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, }); @@ -108,7 +111,7 @@ export default function SearchPage() { setSearching(false); } }, - [query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment] + [query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment, selectedLabelId] ); // Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure @@ -129,6 +132,13 @@ 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); @@ -216,6 +226,17 @@ export default function SearchPage() { )} {!authLoading && user && (<> +
+ {/* Label sidebar */} +
+ +
+ + {/* Main content */} +
) : null}
+
{/* end flex-1 */} +
{/* end flex gap-6 */} diff --git a/src/components/LabelList.tsx b/src/components/LabelList.tsx new file mode 100644 index 0000000..ad3ba9b --- /dev/null +++ b/src/components/LabelList.tsx @@ -0,0 +1,308 @@ +"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([]); + 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(null); + const [editName, setEditName] = useState(""); + const [editColor, setEditColor] = useState(""); + const [saving, setSaving] = useState(false); + + // Hover state + const [hoveredId, setHoveredId] = useState(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 ( +
+ + + + +
+ ); + } + + return ( + + ); +} diff --git a/src/components/LabelPicker.tsx b/src/components/LabelPicker.tsx new file mode 100644 index 0000000..e44e69b --- /dev/null +++ b/src/components/LabelPicker.tsx @@ -0,0 +1,141 @@ +"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(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 ( +
+ {assignedLabels.map((label) => ( + + + ))} + + + + + + +

+ Labels zuweisen +

+ {allLabels.length === 0 && ( +

+ Keine Labels vorhanden. +

+ )} +
    + {allLabels.map((label) => { + const isAssigned = assignedSet.has(label.id); + const isLoading = actionLoading === label.id; + return ( +
  • + +
  • + ); + })} +
+
+
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 2b494d5..38765a8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -175,6 +175,7 @@ export async function searchEmails(params: { date_to?: string; sort?: string; has_attachment?: boolean; + label_id?: number; page?: number; page_size?: number; }): Promise { @@ -186,6 +187,7 @@ 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(`/api/search?${sp.toString()}`); @@ -840,3 +842,96 @@ export async function disableTOTP(code: string): Promise<{ ok: boolean }> { body: JSON.stringify({ code }), }); } + +// ── Labels ────────────────────────────────────────────────────────────────── + +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 async function getLabels(): Promise { + return request("/api/labels"); +} + +export async function createLabel(name: string, color: string): Promise { + return request("/api/labels", { + method: "POST", + body: JSON.stringify({ name, color }), + }); +} + +export async function updateLabel(id: number, name: string, color: string): Promise { + return request(`/api/labels/${id}`, { + method: "PATCH", + body: JSON.stringify({ name, color }), + }); +} + +export async function deleteLabel(id: number): Promise { + return request(`/api/labels/${id}`, { method: "DELETE" }); +} + +export async function assignLabel(emailId: string, labelId: number): Promise { + return request(`/api/mails/${emailId}/labels`, { + method: "POST", + body: JSON.stringify({ label_id: labelId }), + }); +} + +export async function removeLabelFromEmail(emailId: string, labelId: number): Promise { + return request(`/api/mails/${emailId}/labels/${labelId}`, { + method: "DELETE", + }); +} + +export async function getMailLabelIds(emailId: string): Promise { + return request(`/api/mails/${emailId}/labels`); +} + +export async function createAdminLabel(name: string, color: string): Promise { + return request("/api/admin/labels", { + method: "POST", + body: JSON.stringify({ name, color }), + }); +} + +export async function getAdminLabels(): Promise { + return request("/api/admin/labels"); +} + +export async function deleteAdminLabel(id: number): Promise { + return request(`/api/admin/labels/${id}`, { method: "DELETE" }); +} + +export async function getLabelRules(): Promise { + return request("/api/admin/label-rules"); +} + +export async function createLabelRule( + condition_field: string, + condition_value: string, + label_id: number +): Promise { + return request("/api/admin/label-rules", { + method: "POST", + body: JSON.stringify({ condition_field, condition_value, label_id }), + }); +} + +export async function deleteLabelRule(id: number): Promise { + return request(`/api/admin/label-rules/${id}`, { method: "DELETE" }); +}