feat(PROJ-80,PROJ-81): Dark Mode Vervollständigung + Anhang-Online-Vorschau

- PROJ-80: hartkodierte Farben in ModulesTab/TenantsTab/TenantLDAPTab durch
  Theme-Tokens ersetzt, Mail-HTML-Container bewusst hell isoliert, Print
  im Dark Mode auf lesbare (nicht farbidentische) Ausgabe umgestellt
- PROJ-81: neuer AttachmentPreviewDialog für PDF/Bild-Anhänge in /mail/[id],
  MIME-Whitelist ohne SVG, sandboxed iframe ohne allow-same-origin,
  Auto-Retry-Schleife bei Fehlern behoben
- PROJ-82 angelegt: Print-Farbparität als Folge-Ticket (BUG-80-1-Rest)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WapWkrQusDuBMhaN8WyuXB
This commit is contained in:
sysops
2026-08-06 18:00:41 +02:00
co-authored by Claude Sonnet 5
parent a1bf581fdd
commit 0a02bd7112
11 changed files with 899 additions and 23 deletions
+66
View File
@@ -170,3 +170,69 @@
@apply bg-background text-foreground;
}
}
/*
* PROJ-80: Druck-/PDF-Ausgabe immer hell, unabhaengig vom Bildschirm-Theme.
* GoBD/Auditor-Kontext: Ausdrucke muessen einheitlich und lesbar sein.
*/
@media print {
html.dark {
color-scheme: light;
}
html.dark,
html.dark body {
background: #fff !important;
color: #000 !important;
}
/*
* BUG-80-1: bewusst KEIN Wildcard-Reset auf transparent. Ein
* "html.dark * { background-color: transparent }" haette auch Status-Badges
* und Dashboard-Balken entfaerbt und damit die Aussage der Seite zerstoert.
* Stattdessen werden nur die Flaechen-Container neutralisiert. Farbtraeger
* werden ueber die Klasse .print-chip bzw. role="progressbar" ausgenommen und
* unten mit einer definierten hellen Ersatzflaeche versehen.
*/
html.dark
:is(
div, section, article, header, footer, main, aside, nav,
table, thead, tbody, tr, th, td,
p, h1, h2, h3, h4, h5, h6, span, li
):not(
.print-chip,
.print-chip *,
[role="progressbar"],
[role="progressbar"] *
) {
background-color: transparent !important;
color: #000 !important;
border-color: #999 !important;
box-shadow: none !important;
}
/*
* Farbtraeger (Badges, Status-Chips, Fortschritts-/Diagrammbalken): statt sie
* zu entfaerben, bekommen sie eine definierte helle Ersatzflaeche mit Rahmen.
* So bleiben sie im Ausdruck als abgesetztes Element erkennbar und lesbar,
* ohne dass dunkle Theme-Farben auf Papier landen.
*/
html.dark .print-chip,
html.dark [role="progressbar"] {
background-color: #f2f2f2 !important;
color: #000 !important;
border: 1px solid #999 !important;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
/* Gefuellter Anteil eines Balkens bleibt als dunklere Flaeche unterscheidbar. */
html.dark [role="progressbar"] > * {
background-color: #999 !important;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
/* Mail-Inhalt und Logo-Container bleiben im Ausdruck weiss. */
html.dark iframe,
html.dark img {
background-color: #fff !important;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
+31 -9
View File
@@ -23,6 +23,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { OcrBadge } from "@/components/ocr-badge";
import { RestoreMailButton } from "@/components/mail/RestoreMailButton";
import { AttachmentPreviewDialog } from "@/components/mail/AttachmentPreviewDialog";
import { FileText } from "lucide-react";
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -299,7 +300,9 @@ function MailBodyView({ mail }: { mail: MailDetail }) {
</AlertDescription>
</Alert>
)}
<div className="overflow-hidden rounded-md border">
{/* Mail-HTML bewusst immer auf hellem Grund: fremder Content mit eigenen
Farbangaben wird durch App-Dark-Mode sonst unlesbar/verfaelscht. */}
<div className="overflow-hidden rounded-md border bg-white">
<iframe
ref={iframeRef}
srcDoc={srcdoc}
@@ -330,6 +333,7 @@ function AttachmentRow({
attachment: MailAttachment;
}) {
const [downloading, setDownloading] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
async function handleDownload() {
setDownloading(true);
@@ -354,14 +358,32 @@ function AttachmentRow({
{attachment.content_type} · {formatBytes(attachment.size)}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={downloading}
>
{downloading ? "..." : "Herunterladen"}
</Button>
<div className="flex shrink-0 items-center gap-2">
{/* BUG-81-2: Button auch fuer nicht-vorschaubare Typen (docx/xlsx),
der Dialog zeigt dann den "Keine Vorschau verfuegbar"-Hinweis. */}
<Button variant="ghost" size="sm" onClick={() => setPreviewOpen(true)}>
Vorschau
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={downloading}
>
{downloading ? "..." : "Herunterladen"}
</Button>
</div>
{previewOpen && (
<AttachmentPreviewDialog
mailId={mailId}
attachment={attachment}
open={previewOpen}
onOpenChange={setPreviewOpen}
onDownload={handleDownload}
downloading={downloading}
/>
)}
</div>
);
}
+11 -11
View File
@@ -12,11 +12,11 @@ import {
} from "@/components/ui/table";
const statusColors: Record<string, string> = {
"Planned": "bg-gray-100 text-gray-700",
"In Progress": "bg-yellow-100 text-yellow-800",
"In Review": "bg-blue-100 text-blue-800",
"Deployed": "bg-green-100 text-green-800",
"Removed": "bg-red-50 text-red-700 line-through",
"Planned": "bg-muted text-muted-foreground",
"In Progress": "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-200",
"In Review": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200",
"Deployed": "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200",
"Removed": "bg-red-50 text-red-700 line-through dark:bg-red-900/40 dark:text-red-200",
};
const statusCounts = (list: Feature[]) => ({
@@ -37,15 +37,15 @@ export function ModulesTab() {
{/* Summary bar */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "In Progress", value: counts.inProgress, color: "bg-yellow-100 text-yellow-800" },
{ label: "In Review", value: counts.inReview, color: "bg-blue-100 text-blue-800" },
{ label: "Deployed", value: counts.deployed, color: "bg-green-100 text-green-800" },
{ label: "Geplant", value: counts.planned, color: "bg-gray-100 text-gray-700" },
{ label: "In Progress", value: counts.inProgress, color: statusColors["In Progress"] },
{ label: "In Review", value: counts.inReview, color: statusColors["In Review"] },
{ label: "Deployed", value: counts.deployed, color: statusColors["Deployed"] },
{ label: "Geplant", value: counts.planned, color: statusColors["Planned"] },
].map((s) => (
<Card key={s.label}>
<CardContent className="p-4 flex items-center justify-between">
<span className="text-sm text-muted-foreground">{s.label}</span>
<span className={`text-lg font-bold px-2 py-0.5 rounded ${s.color}`}>
<span className={`print-chip text-lg font-bold px-2 py-0.5 rounded ${s.color}`}>
{s.value}
</span>
</CardContent>
@@ -75,7 +75,7 @@ export function ModulesTab() {
</TableCell>
<TableCell className="font-medium">{f.name}</TableCell>
<TableCell>
<span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[f.status]}`}>
<span className={`print-chip text-xs font-medium px-2 py-1 rounded-full ${statusColors[f.status]}`}>
{f.status}
</span>
</TableCell>
+2 -1
View File
@@ -377,7 +377,8 @@ export function TenantLDAPTab({
{ownLogoError && <p className="text-sm text-destructive">{ownLogoError}</p>}
{ownLogoPreviewUrl ? (
<div className="flex items-center gap-4">
<div className="flex items-center justify-center rounded border p-3 bg-muted/30">
{/* Neutral heller Container unabhaengig vom Theme, damit auch dunkle Logos sichtbar bleiben */}
<div className="flex items-center justify-center rounded border p-3 bg-white">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={ownLogoPreviewUrl} alt="Logo" className="max-h-20 max-w-40 object-contain" />
</div>
+1 -1
View File
@@ -325,7 +325,7 @@ export function TenantsTab({
</DialogHeader>
<div className="space-y-4">
{logoPreviewUrl && (
<div className="flex items-center justify-center rounded border p-4 bg-muted/30">
<div className="flex items-center justify-center rounded border p-4 bg-white" title="Neutral heller Hintergrund, damit dunkle Logos in beiden Themes sichtbar bleiben">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logoPreviewUrl} alt="Logo" className="max-h-32 max-w-full object-contain" />
</div>
@@ -0,0 +1,284 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Download, Loader2 } from "lucide-react";
import { downloadMailAttachment, type MailAttachment } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
/**
* PROJ-81: Anhang-Online-Vorschau.
*
* Security (PROJ-61-Kontext, Stored XSS via SVG-Upload):
* Der vom Server gelieferte Content-Type wird bewusst NICHT zum Rendern benutzt.
* Der Anhang wird per authentifiziertem fetch als Blob geholt und dann mit einem
* clientseitig erzwungenen MIME-Typ aus der Whitelist unten neu verpackt.
* Dadurch kann eine als "rechnung.pdf" getarnte HTML-/SVG-Datei nicht als aktives
* Dokument im App-Origin ausgefuehrt werden.
*
* - SVG ist absichtlich NICHT in der Whitelist (SVG kann Skripte enthalten).
* - PDFs laufen in einem sandboxed iframe MIT allow-scripts, aber ohne
* allow-same-origin: der Frame hat dadurch einen eigenen, undurchsichtigen
* Origin und keinen Zugriff auf Cookies/DOM der App. allow-scripts ist noetig,
* weil die browsereigenen PDF-Viewer sonst nicht rendern.
* - Es entsteht kein oeffentlicher Link: die Blob-URL lebt nur im Tab und wird
* beim Schliessen des Dialogs wieder freigegeben.
*/
/** Dateiendung -> erzwungener MIME-Typ. Nur was hier steht, wird gerendert. */
const IMAGE_TYPES: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
};
const PDF_EXT = "pdf";
/** Ab dieser Groesse wird nicht automatisch geladen, sondern nachgefragt. */
export const PREVIEW_SIZE_WARN_BYTES = 20 * 1024 * 1024;
type PreviewKind = "image" | "pdf" | "unsupported";
function extensionOf(filename: string): string {
const idx = filename.lastIndexOf(".");
if (idx < 0 || idx === filename.length - 1) return "";
return filename.slice(idx + 1).toLowerCase();
}
/**
* Entscheidet allein anhand der Dateiendung, ob und wie vorgeschaut wird.
* Bewusst nicht anhand des Server-Content-Type (siehe Security-Hinweis oben).
*/
export function previewKindFor(attachment: MailAttachment): PreviewKind {
const ext = extensionOf(attachment.filename);
if (ext === PDF_EXT) return "pdf";
if (ext in IMAGE_TYPES) return "image";
return "unsupported";
}
export function canPreview(attachment: MailAttachment): boolean {
return previewKindFor(attachment) !== "unsupported";
}
function forcedMimeType(attachment: MailAttachment): string {
const ext = extensionOf(attachment.filename);
if (ext === PDF_EXT) return "application/pdf";
return IMAGE_TYPES[ext] ?? "application/octet-stream";
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export interface AttachmentPreviewDialogProps {
mailId: string;
attachment: MailAttachment;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Download-Aktion der aufrufenden Zeile, bleibt im Dialog verfuegbar. */
onDownload: () => void;
downloading?: boolean;
}
export function AttachmentPreviewDialog({
mailId,
attachment,
open,
onOpenChange,
onDownload,
downloading = false,
}: AttachmentPreviewDialogProps) {
const kind = previewKindFor(attachment);
const isLarge = attachment.size > PREVIEW_SIZE_WARN_BYTES;
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmedLarge, setConfirmedLarge] = useState(false);
// Blob-URL zuverlaessig freigeben, auch bei Unmount waehrend des Ladens.
const urlRef = useRef<string | null>(null);
const revoke = useCallback(() => {
if (urlRef.current) {
URL.revokeObjectURL(urlRef.current);
urlRef.current = null;
}
setObjectUrl(null);
}, []);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const { blob } = await downloadMailAttachment(mailId, attachment.index);
// Erzwungener MIME-Typ statt Server-Angabe (siehe Security-Hinweis oben).
const safeBlob = new Blob([blob], { type: forcedMimeType(attachment) });
const url = URL.createObjectURL(safeBlob);
urlRef.current = url;
setObjectUrl(url);
} catch (e) {
setError(
e instanceof Error
? e.message
: "Anhang konnte nicht geladen werden."
);
} finally {
setLoading(false);
}
}, [mailId, attachment]);
// Laden starten, sobald der Dialog offen ist und die Groesse unkritisch bzw.
// vom User bestaetigt ist.
//
// BUG-81-1: `error` MUSS hier als Guard stehen. Ohne ihn kippt `loading` nach
// einem Fehlschlag zurueck auf false, der Effekt feuert erneut und load()
// laeuft endlos gegen den Anhang-Endpunkt (DSGVO-geloeschter Anhang, korrupte
// Datei, passwortgeschuetztes PDF). Nach einem Fehler wird bewusst NICHT
// automatisch erneut geladen — der User waehlt Download oder erneutes Oeffnen.
useEffect(() => {
if (!open) return;
if (kind === "unsupported") return;
if (isLarge && !confirmedLarge) return;
if (error) return;
if (urlRef.current || loading) return;
void load();
}, [open, kind, isLarge, confirmedLarge, error, loading, load]);
// Aufraeumen beim Schliessen und beim Unmount.
useEffect(() => {
if (!open) {
revoke();
setError(null);
setConfirmedLarge(false);
}
}, [open, revoke]);
useEffect(() => revoke, [revoke]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] w-[95vw] max-w-4xl flex-col gap-4">
<DialogHeader className="pr-6">
<DialogTitle className="truncate text-base" title={attachment.filename}>
{attachment.filename}
</DialogTitle>
<DialogDescription>
{attachment.content_type} · {formatBytes(attachment.size)}
</DialogDescription>
</DialogHeader>
<div className="min-h-[240px] flex-1 overflow-auto rounded-md border bg-muted/30">
{kind === "unsupported" && (
<div className="flex h-full min-h-[240px] flex-col items-center justify-center gap-2 p-8 text-center">
<p className="text-sm font-medium">Keine Vorschau verfügbar</p>
<p className="text-sm text-muted-foreground">
Für diesen Dateityp gibt es keine Online-Vorschau. Die Datei kann
heruntergeladen werden.
</p>
</div>
)}
{kind !== "unsupported" && isLarge && !confirmedLarge && (
<div className="flex h-full min-h-[240px] flex-col items-center justify-center gap-3 p-8 text-center">
<p className="text-sm font-medium">
Große Datei ({formatBytes(attachment.size)})
</p>
<p className="text-sm text-muted-foreground">
Die Vorschau kann längere Zeit dauern und viel Arbeitsspeicher
belegen.
</p>
<Button size="sm" onClick={() => setConfirmedLarge(true)}>
Trotzdem laden
</Button>
</div>
)}
{kind !== "unsupported" && (!isLarge || confirmedLarge) && (
<>
{loading && (
<div className="flex h-full min-h-[240px] items-center justify-center gap-2 p-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Vorschau wird geladen
</div>
)}
{!loading && error && (
<div className="p-4">
<Alert variant="destructive">
<AlertDescription>
Vorschau fehlgeschlagen: {error} Die Datei kann eventuell
trotzdem heruntergeladen werden.
</AlertDescription>
</Alert>
</div>
)}
{!loading && !error && objectUrl && kind === "image" && (
// Neutral heller Grund: transparente PNGs bleiben in beiden Themes sichtbar.
<div className="flex min-h-[240px] items-center justify-center bg-white p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={objectUrl}
alt={attachment.filename}
className="max-h-[70vh] max-w-full object-contain"
onError={() =>
setError("Datei ist beschädigt oder kein gültiges Bild.")
}
/>
</div>
)}
{/*
allow-scripts ohne allow-same-origin: der Frame laeuft in einem
eigenen, undurchsichtigen Origin (kein Zugriff auf Cookies/DOM der
App). Skripte sind noetig, weil die eingebauten PDF-Viewer der
Browser sonst nicht rendern.
*/}
{!loading && !error && objectUrl && kind === "pdf" && (
<iframe
src={objectUrl}
title={`Vorschau: ${attachment.filename}`}
sandbox="allow-scripts"
className="h-[70vh] w-full border-0 bg-white"
/>
)}
</>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="outline"
className="w-full sm:w-auto"
onClick={onDownload}
disabled={downloading}
>
<Download className="mr-2 h-4 w-4" />
{downloading ? "Wird geladen…" : "Herunterladen"}
</Button>
<Button
variant="ghost"
className="w-full sm:w-auto"
onClick={() => onOpenChange(false)}
>
Schließen
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+3
View File
@@ -83,4 +83,7 @@ export const features: Feature[] = [
{ id: "PROJ-66", name: "Backup-Strategie für Store, Keyfile, PostgreSQL", status: "Deployed", frontend: false, backend: true, lastUpdated: "2026-07-04", version: "1.0" },
{ id: "PROJ-67", name: "Manticore Search Upgrade + Auto-Upgrade-Pfad", status: "Deployed", frontend: false, backend: true, lastUpdated: "2026-07-05", version: "1.0" },
{ id: "PROJ-72", name: "Fix Superadmin kann Passwort/Rolle von Superadmin-Peers nicht ändern", status: "Deployed", frontend: false, backend: true, lastUpdated: "2026-07-27", version: "1.0" },
{ id: "PROJ-80", name: "Dark Mode Vervollständigung (Konsistenz + Default/Persistenz)", status: "In Review", frontend: true, backend: false, lastUpdated: "2026-08-06", version: "1.0" },
{ id: "PROJ-81", name: "Anhang-Online-Vorschau (PDF, Bilder)", status: "In Review", frontend: true, backend: false, lastUpdated: "2026-08-06", version: "1.0" },
{ id: "PROJ-82", name: "Print-Farbparität zwischen Hell- und Dark-Mode-Ausdrucken", status: "Planned", frontend: true, backend: false, lastUpdated: "2026-08-06", version: "1.0" },
];