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:
co-authored by
Claude Sonnet 5
parent
a1bf581fdd
commit
0a02bd7112
+4
-1
@@ -95,7 +95,10 @@
|
||||
| PROJ-77 | Admin-Tab-Bundle-Optimierung (dynamic import statt 19 statische Imports) | Deployed | [PROJ-77](PROJ-77-admin-tabs-dynamic-import.md) | 2026-08-05 |
|
||||
| PROJ-78 | SearchResultsTable re-rendert bei jedem Tastenanschlag im Suchfeld | Deployed | [PROJ-78](PROJ-78-search-results-rerender-perf.md) | 2026-08-05 |
|
||||
| PROJ-79 | Frontend-Dependency-Major-Upgrades (eslint, lucide-react, tailwindcss+tailwind-merge, typescript, @types/node) | Deployed | [PROJ-79](PROJ-79-dependency-major-upgrades.md) | 2026-08-06 |
|
||||
| PROJ-80 | Dark Mode Vervollständigung (Konsistenz + Default/Persistenz) | In Review | [PROJ-80](PROJ-80-dark-mode-vervollstaendigung.md) | 2026-08-06 |
|
||||
| PROJ-81 | Anhang-Online-Vorschau (PDF, Bilder) | In Review | [PROJ-81](PROJ-81-anhang-online-vorschau.md) | 2026-08-06 |
|
||||
| PROJ-82 | Print-Farbparität zwischen Hell- und Dark-Mode-Ausdrucken | Planned | [PROJ-82](PROJ-82-print-farbparitaet-dark-mode.md) | 2026-08-06 |
|
||||
|
||||
<!-- Add features above this line -->
|
||||
|
||||
## Next Available ID: PROJ-80
|
||||
## Next Available ID: PROJ-83
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# PROJ-80: Dark Mode Vervollständigung (Konsistenz + Default/Persistenz)
|
||||
|
||||
## Status: In Review
|
||||
**Created:** 2026-08-06
|
||||
**Last Updated:** 2026-08-06
|
||||
|
||||
## Kontext
|
||||
Dark-Mode-Grundinfrastruktur existiert bereits (`ThemeProvider`, `ThemeToggle`, CSS-`dark`-Variante in `globals.css`, eingebunden in `navbar.tsx`). PROJ-80 schließt Lücken statt Grundfunktion neu zu bauen: (1) fehlende `dark:`-Klassen/Kontrastprobleme in einzelnen Komponenten/Seiten, (2) Default-Theme-Verhalten und Persistenz.
|
||||
|
||||
## Dependencies
|
||||
- Voraussetzung vorhanden: Theme-Infrastruktur (theme-provider.tsx, theme-toggle.tsx) — kein Blocker, nur Erweiterung
|
||||
|
||||
## User Stories
|
||||
- Als User (jede Rolle: user/admin/auditor) will ich, dass alle Seiten inkl. Admin-Bereich im Dark Mode korrekt lesbar sind, ohne unlesbaren Text oder falsche Hintergründe.
|
||||
- Als User will ich, dass die App standardmäßig meine Systemeinstellung (Hell/Dunkel) übernimmt, wenn ich noch keine eigene Wahl getroffen habe.
|
||||
- Als User will ich, dass meine Theme-Wahl über Sessions hinweg erhalten bleibt (Browser-Neustart, neues Gerät nicht zwingend erforderlich).
|
||||
- Als Admin will ich Tabellen, Dialoge und Formulare im Admin-Bereich (Tenant-Verwaltung, User-Verwaltung, Audit-Log) korrekt im Dark Mode sehen.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Alle Seiten unter `src/app/` (inkl. `/admin/*`, `/imap`, `/search`, `/mail/[id]`) im Dark Mode manuell durchgeklickt, keine unlesbaren Text/Hintergrund-Kombinationen (Kontrast-Check)
|
||||
- [ ] Alle shadcn/ui-Komponenten und Custom-Komponenten in `src/components/` verwenden Theme-Tokens (`bg-background`, `text-foreground` etc.) statt hartkodierter Farben (`bg-white`, `text-black`, `#fff` o.ä.)
|
||||
- [ ] Default-Theme = `system` (folgt Betriebssystem-Einstellung), wenn User noch nie manuell gewechselt hat
|
||||
- [ ] Theme-Wahl wird per `localStorage` persistiert (bereits Standardverhalten von `next-themes`) — verifizieren, dass es funktioniert
|
||||
- [ ] Kein Flackern (FOUC) beim Seitenladen (`next-themes` `suppressHydrationWarning` korrekt gesetzt in `layout.tsx`)
|
||||
- [ ] Diagramme/Charts (falls vorhanden, z.B. Dashboard-Metriken) passen Farben im Dark Mode an
|
||||
- [ ] Tenant-Logos/Uploads mit hellem Hintergrund erhalten sichtbaren Rahmen/Container im Dark Mode (Lesbarkeit)
|
||||
|
||||
## Edge Cases
|
||||
- Was passiert bei sehr hellen/dunklen Custom-Tenant-Logos (PROJ-61-Kontext)? → Container mit neutralem Hintergrund/Rahmen unabhängig vom Theme
|
||||
- Wie verhält sich Drucken/Export (PDF/EML-Ansicht) im Dark Mode? → Druckansicht immer hell erzwingen (`print:` Tailwind-Variante), keine dunklen Hintergründe im Ausdruck
|
||||
- Was passiert wenn `localStorage` blockiert ist (Privacy-Mode)? → Fällt auf `system`-Default zurück, kein Crash
|
||||
- Wie verhalten sich eingebettete HTML-Mails (Mail-Ansicht, sanitized) im Dark Mode? → Mail-Inhalt bleibt in eigenem isolierten Container mit hellem Hintergrund, damit Original-Formatierung der Mail nicht durch App-Theme verfälscht wird
|
||||
- Admin-Bereich mit vielen Tabellen (User-Verwaltung, Audit-Log) — Zebra-Streifen/Hover-States im Dark Mode ausreichend Kontrast?
|
||||
|
||||
## Technical Requirements (optional)
|
||||
- Kein neues Paket nötig (`next-themes` bereits Dependency)
|
||||
- Browser Support: identisch zu bestehendem Frontend-Support (Chrome, Firefox, Safari, Edge)
|
||||
- Keine Backend-Änderung nötig (rein Frontend/CSS)
|
||||
|
||||
---
|
||||
<!-- Sections below are added by subsequent skills -->
|
||||
|
||||
## Tech Design (Solution Architect)
|
||||
|
||||
### A) Komponentenstruktur (was wird angefasst, nichts Neues gebaut)
|
||||
|
||||
```
|
||||
Root Layout (bereits vorhanden: ThemeProvider + ThemeToggle im Navbar)
|
||||
+-- Admin-Bereich
|
||||
| +-- ModulesTab <- hartkodierte Farben ersetzen
|
||||
| +-- Alle übrigen Admin-Tabs (Tenants, Users, Audit, Retention, ...) <- Stichprobe/Review, laut Scan bereits sauber
|
||||
+-- Settings-Bereich
|
||||
| +-- TotpSection <- hartkodierte Farben ersetzen
|
||||
+-- Mail-Ansicht ([id]/page.tsx)
|
||||
| +-- HTML-Mail-Container <- bewusst IMMER hell (isoliert vom App-Theme, siehe Edge Case)
|
||||
+-- Druck-/Export-Ansicht
|
||||
| +-- Print-Stylesheet-Regel <- erzwingt helles Ausgabeschema unabhängig vom Theme
|
||||
+-- Dashboard/Charts (falls vorhanden in DashboardTab)
|
||||
+-- Farbpalette <- theme-abhängig prüfen/anpassen
|
||||
```
|
||||
|
||||
Kein neues UI-Element, keine neue Seite. Reine Härtung bestehender Komponenten auf Theme-Tokens.
|
||||
|
||||
### B) Daten-/Zustandsmodell (Klartext)
|
||||
|
||||
Kein neues Datenmodell. Theme-Zustand existiert bereits:
|
||||
- Aktuelles Theme (hell/dunkel/system) wird von `next-themes` verwaltet
|
||||
- Gespeichert im Browser (localStorage), nicht in der Datenbank — pro Gerät/Browser, nicht pro User-Account synchronisiert
|
||||
- Kein Serverkontakt nötig
|
||||
|
||||
### C) Tech-Entscheidungen (Begründung)
|
||||
|
||||
- **Kein neues State-Management:** Bestehende `next-themes`-Lösung bleibt, da sie bereits Default-Verhalten (System-Präferenz), Persistenz und FOUC-Vermeidung eingebaut mitbringt — nur korrekte Konfiguration in `layout.tsx` verifizieren statt Neubau.
|
||||
- **Farben nur über Theme-Tokens (z.B. Hintergrund/Vordergrund-Variablen), nicht fest verdrahtet:** Damit ein einziger Wechsel (Hell/Dunkel) automatisch überall greift, statt Komponente für Komponente hart zu pflegen.
|
||||
- **Mail-Inhalt bewusst vom Theme ausgenommen:** E-Mails sind fremder, oft mit fest kodierten Farben gestalteter HTML-Inhalt (Newsletter, Rechnungen). Erzwingt man dort Dark Mode, wird der Inhalt unlesbar/verfälscht. Deshalb eigener isolierter, immer heller Anzeigebereich.
|
||||
- **Druckausgabe immer hell:** Ausdrucke/PDF-Exporte (GoBD-Auditor-Kontext) müssen unabhängig vom Bildschirm-Theme des jeweiligen Nutzers immer lesbar und einheitlich sein.
|
||||
- **Kein Server-seitiges Speichern der Theme-Wahl:** Aufwand/Nutzen — Theme ist reine Anzeigepräferenz, kein Compliance- oder Sync-relevantes Datum. Pro Browser reicht.
|
||||
|
||||
### D) Abhängigkeiten (Pakete)
|
||||
|
||||
Keine neuen Pakete. `next-themes` ist bereits installiert und im Einsatz.
|
||||
|
||||
### Umfang laut Code-Scan (Stand 2026-08-06)
|
||||
Grep auf hartkodierte Farben (`bg-white`, `text-black`, `bg-gray-*`, `text-gray-*`, `#fff`, `#000`) über `src/app` und `src/components`: nur 2 Treffer — `ModulesTab.tsx` und `TotpSection.tsx`. Rest der Codebasis nutzt bereits Theme-Tokens. Umfang der Nacharbeit ist somit klein; Hauptaufwand liegt im manuellen visuellen Review (Acceptance Criteria), nicht im Massenumbau.
|
||||
|
||||
## Implementation Notes (Frontend, 2026-08-06)
|
||||
|
||||
Umgesetzt:
|
||||
- `src/components/admin/ModulesTab.tsx`: hartkodierte Statusfarben ersetzt. `Planned` nutzt jetzt Theme-Tokens (`bg-muted`/`text-muted-foreground`), die farbigen Status (In Progress/In Review/Deployed/Removed) haben `dark:`-Varianten mit ausreichendem Kontrast. Die Summary-Bar referenziert jetzt dieselbe `statusColors`-Map statt eigener Duplikate (vorher drifteten beide auseinander).
|
||||
- `src/app/mail/[id]/page.tsx`: HTML-Mail-Container (`iframe`-Wrapper) explizit auf `bg-white` gesetzt — Mail-Inhalt bleibt bewusst vom App-Theme isoliert (Edge Case aus Spec).
|
||||
- `src/app/globals.css`: `@media print`-Block ergänzt, der bei aktivem Dark Mode (`html.dark`) helle Ausgabe erzwingt (`color-scheme: light`, weißer Grund, schwarzer Text, keine Box-Shadows). Screen-Darstellung bleibt unverändert.
|
||||
- `src/components/admin/tabs/TenantsTab.tsx` + `TenantLDAPTab.tsx`: Logo-Vorschau-Container von `bg-muted/30` auf neutral hellen Hintergrund umgestellt, damit dunkle Tenant-Logos in beiden Themes sichtbar bleiben.
|
||||
|
||||
Verifiziert, keine Änderung nötig:
|
||||
- `theme-provider.tsx` hatte bereits `defaultTheme="system"`, `enableSystem`, `disableTransitionOnChange`; `layout.tsx` bereits `suppressHydrationWarning` am `<html>` — Default-Verhalten und FOUC-Vermeidung erfüllt ohne Eingriff.
|
||||
- Persistenz läuft über `next-themes`-eigenen localStorage-Key; Zugriff ist dort in try/catch gekapselt, blockiertes localStorage fällt auf `system` zurück.
|
||||
- Kein Chart-Code mit hartkodierten Farbwerten gefunden (Grep auf `recharts`/`fill="#"`/`stroke="#"` in `src/app` + `src/components/admin` leer) — Chart-Kriterium damit gegenstandslos.
|
||||
- Badges mit `bg-blue-600 text-white` / `bg-green-600 text-white` (`pop3/page.tsx`, `imap/ImapAccountCard.tsx`) bewusst belassen: gesättigte Volltonfarbe mit weißer Schrift ist in beiden Themes kontraststark.
|
||||
- `settings/TotpSection.tsx` behält `bg-white` für den QR-Code-Container — ein QR braucht hellen Grund, damit Scanner ihn lesen; das ist funktional, nicht Theme-abhängig.
|
||||
|
||||
Build: `npx tsc --noEmit` und `npm run build` fehlerfrei.
|
||||
|
||||
Offen für QA: das manuelle visuelle Durchklicken aller Seiten im Dark Mode (erstes Acceptance-Kriterium) wurde NICHT durchgeführt — das braucht einen laufenden Browser gegen eine Instanz mit Daten.
|
||||
|
||||
## QA Test Results
|
||||
|
||||
**Getestet:** 2026-08-06 | **Methode:** Statische Code-Verifikation (Grep-Audit + Konfig-Review)
|
||||
|
||||
### Testbarkeits-Einschränkung (wichtig)
|
||||
Der Code ist **uncommitted und nicht deployed** (`git status`: `M src/app/globals.css`, `M src/components/admin/ModulesTab.tsx`, `M .../TenantsTab.tsx`, `M .../TenantLDAPTab.tsx`, `M src/app/mail/[id]/page.tsx`). Auf 132 läuft der alte Stand. AC1 (manuelles Durchklicken aller Seiten im Dark Mode) und die Browser-/Responsive-Matrix konnten daher **nicht** verifiziert werden — die Implementation Notes hatten das bereits als offen markiert, das bleibt offen.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
| # | Kriterium | Ergebnis | Beleg |
|
||||
|---|---|---|---|
|
||||
| 1 | Alle Seiten im Dark Mode durchgeklickt, Kontrast geprüft | **NICHT VERIFIZIERBAR** | Code nicht deployed; erfordert Browser gegen Instanz mit Daten |
|
||||
| 2 | Komponenten nutzen Theme-Tokens statt hartkodierter Farben | PASS | Grep über `src/app`+`src/components`: alle Restfunde begründet, s.u. |
|
||||
| 3 | Default-Theme = `system` | PASS | `theme-provider.tsx`: `defaultTheme="system"` + `enableSystem` |
|
||||
| 4 | Persistenz via localStorage | PASS (Code) | `next-themes`-Standardverhalten, `attribute="class"` gesetzt; Laufzeit-Verifikation offen (Teil von AC1) |
|
||||
| 5 | Kein FOUC | PASS | `layout.tsx:16` `<html lang="de" suppressHydrationWarning>` + `disableTransitionOnChange` |
|
||||
| 6 | Diagramme/Charts passen Farben an | PASS (gegenstandslos) | Kein Chart-Framework im Einsatz. Einzige grafische Elemente: Auslastungsbalken in `DashboardTab.tsx:373/375` und `452/454` — Track `bg-secondary`, Füllung `bg-primary`/`bg-destructive`/`bg-yellow-500`, alle themetauglich |
|
||||
| 7 | Tenant-Logos erhalten sichtbaren Container | PASS | `TenantsTab.tsx:328` und `TenantLDAPTab.tsx:381` je `rounded border p-3/p-4 bg-white`. Gegengeprüft: das sind die **einzigen** Render-Stellen für Tenant-Logos (Grep auf `logo_url`/`/api/tenant/logo`-Konsumenten) — keine Stelle vergessen |
|
||||
|
||||
**Summe: 6 PASS, 1 nicht verifizierbar (0 FAIL)**
|
||||
|
||||
**Grep-Audit zu AC2** — verbliebene hartkodierte Farben, alle begründet und akzeptiert:
|
||||
- `ui/dialog.tsx:24`, `ui/sheet.tsx:24`, `ui/alert-dialog.tsx:21` — `bg-black/80` Overlays, shadcn-Standard, themeunabhängig korrekt
|
||||
- `settings/TotpSection.tsx:88` — QR-Code braucht funktional hellen Grund
|
||||
- `TenantsTab.tsx:328`, `TenantLDAPTab.tsx:381` — Logo-Container, genau AC7
|
||||
- `mail/[id]/page.tsx:308` — HTML-Mail-Container, bewusst themeisoliert (Edge Case aus Spec)
|
||||
- `mail/AttachmentPreviewDialog.tsx:222/246` — Vorschaufläche (PROJ-81), konsistent mit derselben Begründung
|
||||
- `pop3/page.tsx:224`, `imap/ImapAccountCard.tsx:19/29/33` — gesättigte Volltonbadges mit weißer Schrift, in beiden Themes kontraststark
|
||||
|
||||
### Edge Cases
|
||||
|
||||
| Edge Case | Ergebnis |
|
||||
|---|---|
|
||||
| Sehr helle/dunkle Tenant-Logos (PROJ-61-Kontext) | PASS — neutral heller Container mit Rahmen |
|
||||
| Drucken/Export im Dark Mode | TEILWEISE — hell erzwungen, aber Farbinformation geht verloren → BUG-80-1 |
|
||||
| localStorage blockiert (Privacy-Mode) | PASS (Code) — `next-themes` kapselt den Zugriff, Fallback auf `system`; Laufzeittest offen |
|
||||
| Eingebettete HTML-Mails im Dark Mode | PASS — `mail/[id]/page.tsx:308` `bg-white` am iframe-Wrapper, Mail-Dokument hat ohnehin eigenen Kontext |
|
||||
| Admin-Tabellen: Zebra/Hover-Kontrast | NICHT VERIFIZIERBAR — Teil von AC1, braucht Browser |
|
||||
|
||||
---
|
||||
|
||||
### Bugs
|
||||
|
||||
#### BUG-80-1 — Druckausgabe verliert Farbinformation, je nach Theme unterschiedlich — **Severity: Low** — Priorität 1
|
||||
`src/app/globals.css`, neuer `@media print`-Block:
|
||||
|
||||
```css
|
||||
html.dark * {
|
||||
background-color: transparent !important;
|
||||
color: #000 !important;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Der `*`-Selektor trifft auch bewusst gesetzte Signalfarben: Status-Badges in `ModulesTab` (In Progress / In Review / Deployed / Removed) und die Auslastungsbalken im Dashboard werden im Ausdruck vollständig entfärbt — Balken sind dann unsichtbar (transparenter Hintergrund auf transparentem Track).
|
||||
|
||||
**Folge:** Derselbe Report ergibt je nach Bildschirm-Theme des Nutzers einen unterschiedlichen Ausdruck — im Light Mode farbig, im Dark Mode entfärbt. Das widerspricht der eigenen Design-Begründung aus dem Tech Design ("Ausdrucke müssen unabhängig vom Bildschirm-Theme immer lesbar und **einheitlich** sein").
|
||||
|
||||
**Reproduktion:** Dark Mode aktivieren → `/admin` → Modul-Übersicht oder Dashboard → Druckvorschau (Strg+P) → Badges/Balken farblos. Dasselbe im Light Mode → farbig.
|
||||
**Vorschlag (Umsetzung durch Frontend Developer):** Die Print-Regel theme-unabhängig machen (`@media print { :root { color-scheme: light } ... }` statt nur `html.dark`) und den `*`-Holzhammer auf Container-Elemente eingrenzen, damit Signalfarben in beiden Themes gleich gedruckt werden.
|
||||
|
||||
#### BUG-80-2 — AC1 unbelegt — **Severity: Low (Prozess)** — Priorität 2
|
||||
Kein Bug im Code, aber das erste und umfangreichste Acceptance Criterion ist weiterhin ungetestet. Nach dem Deploy auf 132 muss ein visueller Durchlauf über `/`, `/search`, `/mail/[id]`, `/imap`, `/pop3`, `/admin/*` (alle Tabs), `/settings` in Dark Mode + Light Mode erfolgen, inkl. 375/768/1440 px und Chrome/Firefox/Safari/Edge. Erst dann ist AC1 abhakbar.
|
||||
|
||||
---
|
||||
|
||||
### Security Audit
|
||||
Keine sicherheitsrelevante Änderung. PROJ-80 ist rein CSS/Klassen. Geprüft:
|
||||
- Kein neuer Endpunkt, keine Backend-Änderung, keine Änderung an Auth/Tenant-Logik.
|
||||
- Der Logo-Container-Fix berührt nur das Styling um `<img>` herum, nicht das Laden/Validieren des Logos — die PROJ-61-Absicherung (`tenant_logo_handlers.go`, `nosniff`) bleibt unangetastet.
|
||||
- Print-CSS enthält keine externen Ressourcen (kein `url()`, kein `@import`).
|
||||
|
||||
### Regression
|
||||
- `npx tsc --noEmit` fehlerfrei (Exit 0).
|
||||
- `ModulesTab.tsx`: Summary-Bar referenziert jetzt dieselbe `statusColors`-Map wie die Badges — behebt einen bestehenden Drift, kein Regressionsrisiko.
|
||||
- `mail/[id]/page.tsx` wird parallel von PROJ-81 angefasst — beide Änderungen liegen in derselben uncommitteten Arbeitskopie und sind konfliktfrei (unterschiedliche Zeilenbereiche: 308 vs. 336-395).
|
||||
- PROJ-69 (Admin-Tab-Gruppierung), PROJ-77 (dynamische Tab-Ladung): Tabs nur farblich angefasst, Struktur unverändert.
|
||||
|
||||
### Produktionsreife: **JA, mit Vorbehalt**
|
||||
Keine Critical- oder High-Bugs. Deploy auf 132 ist vertretbar — AC1 muss aber **nach** dem Deploy im Browser nachgeholt werden, bevor der Status auf Deployed geht. BUG-80-1 kann parallel gefixt werden.
|
||||
|
||||
---
|
||||
|
||||
## Bugfixes nach QA (Frontend, 2026-08-06)
|
||||
|
||||
### BUG-80-1 — Wildcard-Reset entfärbt Badges/Balken im Druck — **fixed**
|
||||
`src/app/globals.css`: Der `html.dark * { background-color: transparent !important }`-Holzhammer ist raus. Stattdessen:
|
||||
- Der Neutralisierungs-Reset trifft jetzt nur noch Container-/Text-Elemente (`:is(div, section, table, td, span, li, …)`) und nimmt Farbträger per `:not(.print-chip, .print-chip *, [role="progressbar"], [role="progressbar"] *)` aus.
|
||||
- Farbträger bekommen eine **definierte helle Ersatzfläche** (`#f2f2f2` + `1px solid #999`, schwarzer Text) statt `transparent` — sie bleiben im Ausdruck als abgesetzter Chip erkennbar. Für Fortschritts-/Auslastungsbalken wird der gefüllte Anteil auf `#999` gesetzt, damit Track und Füllung unterscheidbar bleiben (vorher: unsichtbar, weil transparent auf transparent).
|
||||
- `print-color-adjust: exact` verhindert, dass der Browser diese Flächen beim Drucken wegoptimiert.
|
||||
|
||||
`src/components/admin/ModulesTab.tsx`: Die Status-Chips sind einfache `<span>`-Elemente und wären vom Reset erfasst worden. Sie haben jetzt die Marker-Klasse `print-chip`.
|
||||
Warum eine Marker-Klasse statt eines Attribut-Selektors: die installierte `badge.tsx` ist eine ältere shadcn-Version **ohne** `data-slot`-Attribut, und `src/components/ui/` darf nicht manuell editiert werden. Radix' `Progress` liefert `role="progressbar"` von sich aus, daher braucht es dort keinen Marker.
|
||||
|
||||
**Bewusst nicht umgesetzt / verbleibende Abweichung:** Der Vorschlag aus dem QA-Bericht, die Print-Regel komplett theme-unabhängig zu machen, ist mit reinem CSS nicht vollständig erreichbar. Solange die `dark`-Klasse am `<html>` hängt, greifen die `dark:`-Varianten der Tailwind-Klassen weiter; CSS kann sie nicht selektiv zurücknehmen. Der Ausdruck ist jetzt in beiden Themes **lesbar und strukturell gleich**, aber nicht farbidentisch: im Light Mode drucken Chips in ihrer Signalfarbe, im Dark Mode in der grauen Ersatzfläche. Echte Farbparität würde verlangen, die `dark`-Klasse per `beforeprint`/`afterprint`-Listener temporär zu entfernen — das greift in den `next-themes`-State ein und wurde hier bewusst nicht ohne Rücksprache gemacht. Falls Farbparität gefordert ist: eigenes Ticket.
|
||||
|
||||
### BUG-80-2 — AC1 unbelegt — **offen, wie abgestimmt**
|
||||
Bleibt für nach dem Deploy. Kein Code-Fix.
|
||||
|
||||
Nach den Fixes: `npx tsc --noEmit` und `npm run build` fehlerfrei.
|
||||
|
||||
## Deployment
|
||||
_To be added by /deploy_
|
||||
@@ -0,0 +1,252 @@
|
||||
# PROJ-81: Anhang-Online-Vorschau (PDF, Bilder)
|
||||
|
||||
## Status: In Review
|
||||
**Created:** 2026-08-06
|
||||
**Last Updated:** 2026-08-06
|
||||
|
||||
## Kontext
|
||||
Mail-Anhänge lassen sich in `/mail/[id]` aktuell nur herunterladen (`downloadMailAttachment`), keine Online-Ansicht im Browser. PROJ-81 ergänzt eine Vorschau direkt im Frontend, damit User Anhänge nicht erst lokal öffnen müssen.
|
||||
|
||||
## Dependencies
|
||||
- Baut auf PROJ-7 (E-Mail-Ansicht) auf — Anhang-Liste existiert bereits in `/mail/[id]`
|
||||
|
||||
## User Stories
|
||||
- Als User will ich einen PDF-Anhang direkt im Browser ansehen können, ohne ihn herunterzuladen.
|
||||
- Als User will ich Bild-Anhänge (jpg/png/gif) als Vorschau sehen, bevor ich sie herunterlade.
|
||||
- Als User will ich die Vorschau in einem Dialog/Overlay öffnen, ohne die Mail-Ansicht zu verlassen.
|
||||
- Als Admin/Auditor will ich, dass Vorschau nur für Anhänge funktioniert, auf die der jeweilige Tenant/User laut bestehender Zugriffsregeln berechtigt ist (keine neue Sicherheitslücke).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Klick auf Anhang öffnet Vorschau als Modal/Dialog über der aktuellen Mail-Ansicht
|
||||
- [ ] PDF-Anhänge werden inline gerendert (Browser-natives PDF-Rendering oder eingebetteter Viewer)
|
||||
- [ ] Bild-Anhänge (jpg, png, gif, webp) werden als Bildvorschau angezeigt
|
||||
- [ ] Nicht unterstützte Dateitypen (inkl. Office-Dokumente wie docx/xlsx) zeigen Hinweis "Keine Vorschau verfügbar" + Download-Button bleibt erhalten
|
||||
- [ ] Download-Option bleibt im Dialog zusätzlich verfügbar (Vorschau ersetzt Download nicht)
|
||||
- [ ] Vorschau respektiert bestehende Tenant-Isolation/Zugriffsrechte (kein direkter öffentlicher Link auf Rohdatei)
|
||||
- [ ] Große Anhänge (Performance-Grenze definieren, z.B. > 20 MB) zeigen Warnhinweis statt automatischem Laden
|
||||
|
||||
## Edge Cases
|
||||
- Sehr große PDF/Bild-Dateien (>20 MB) → Warnhinweis statt automatischer Vorschau, User muss Laden explizit bestätigen
|
||||
- Passwortgeschützte/verschlüsselte PDFs → Vorschau schlägt fehl, Fehlermeldung + Download-Fallback
|
||||
- Beschädigte/korrupte Anhänge → Fehlermeldung statt Absturz des Dialogs
|
||||
- Sehr viele Anhänge in einer Mail → Performance beim Öffnen mehrerer Vorschauen nacheinander
|
||||
- Anhang wurde per DSGVO-Löschersuchen (PROJ-50) bereits entfernt → Vorschau zeigt "nicht mehr verfügbar" statt Fehler
|
||||
|
||||
## Technical Requirements (optional)
|
||||
- Performance: PDF/Bild-Vorschau < 2s Ladezeit bei normaler Dateigröße
|
||||
- Security: Vorschau-Endpunkt muss denselben Auth-/Tenant-Check wie bestehender Download-Endpunkt durchlaufen
|
||||
- Browser Support: Chrome, Firefox, Safari, Edge
|
||||
|
||||
---
|
||||
<!-- Sections below are added by subsequent skills -->
|
||||
|
||||
## Tech Design (Solution Architect)
|
||||
|
||||
### A) Komponentenstruktur (visuell)
|
||||
|
||||
```
|
||||
Mail-Ansicht (/mail/[id])
|
||||
+-- Anhang-Liste (bestehend)
|
||||
| +-- Anhang-Zeile
|
||||
| +-- "Vorschau"-Button (NEU)
|
||||
| +-- "Herunterladen"-Button (bestehend, bleibt)
|
||||
+-- Vorschau-Dialog (NEU, Overlay über aktueller Seite)
|
||||
+-- Titelzeile (Dateiname, Schließen-Button)
|
||||
+-- Inhalt (je nach Typ):
|
||||
| +-- PDF-Ansicht (eingebetteter Viewer)
|
||||
| +-- Bild-Ansicht (Vollbild-Bild, zoombar)
|
||||
| +-- "Keine Vorschau verfügbar"-Hinweis (Fallback, u.a. für Office-Dokumente)
|
||||
+-- Ladezustand (Spinner bei großer Datei)
|
||||
+-- Warnhinweis bei sehr großen Dateien ("trotzdem laden?")
|
||||
+-- "Herunterladen"-Button (bleibt zusätzlich verfügbar)
|
||||
```
|
||||
|
||||
### B) Datenfluss (Klartext, kein neues Datenmodell)
|
||||
|
||||
- Vorschau nutzt denselben bestehenden Anhang-Endpunkt wie der heutige Download (`/api/mails/{id}/attachments/{index}`), der bereits Tenant-/Zugriffsprüfung durchläuft — kein neuer öffentlicher Link, keine neue Sicherheitsfläche.
|
||||
- PDF und Bilder: Browser stellt Datei direkt dar, keine Serververarbeitung nötig.
|
||||
- Kein neuer Datenbank-Eintrag nötig — Vorschau ist ein reiner Anzeige-Vorgang, keine dauerhaft gespeicherte Information.
|
||||
- Kein Server-seitiger Konvertierungsschritt (Office-Dokumente bewusst außerhalb des Scopes, siehe unten).
|
||||
|
||||
### C) Tech-Entscheidungen (Begründung)
|
||||
|
||||
- **Vorschau als Modal statt neue Seite/Route:** User bleibt im Kontext der Mail, kein Verlust der aktuellen Ansicht/Scroll-Position (laut Klärung des Nutzers gewünscht).
|
||||
- **Bestehenden Anhang-Endpunkt wiederverwenden statt neuen zu bauen:** Zugriffsrechte (Tenant-Isolation, Auth) sind dort bereits korrekt implementiert — Wiederverwendung vermeidet doppelte Sicherheitslogik und Inkonsistenzrisiko.
|
||||
- **Office-Dokumente (docx/xlsx) bewusst außerhalb des Scopes:** Nutzer hat Scope auf PDF+Bilder reduziert. Damit entfällt der größte Aufwandstreiber (Server-seitige Konvertierung, neue Systemabhängigkeit). Reines Frontend-Feature ohne neue Backend-Logik.
|
||||
- **Größenlimit + Warnhinweis statt hartem Blocken:** Verhindert, dass ein einzelner sehr großer Anhang den Browser blockiert, lässt dem User aber die Wahl, ihn trotzdem zu laden.
|
||||
|
||||
### D) Abhängigkeiten (Pakete)
|
||||
|
||||
- PDF-Viewer-Bibliothek im Frontend (Anzeige im Dialog, ohne Download). Bilder benötigen keine zusätzliche Bibliothek (natives `<img>`).
|
||||
- Keine neuen Server-/Systemabhängigkeiten.
|
||||
|
||||
## Implementation Notes (Frontend, 2026-08-06)
|
||||
|
||||
Neue Komponente: `src/components/mail/AttachmentPreviewDialog.tsx`
|
||||
Angebunden in `src/app/mail/[id]/page.tsx` (`AttachmentRow`): pro Anhang erscheint ein "Vorschau"-Button, aber nur wenn der Typ vorschaufähig ist; "Herunterladen" bleibt unverändert daneben und zusätzlich im Dialog.
|
||||
|
||||
Keine neue API-Funktion nötig — der Dialog nutzt das bestehende `downloadMailAttachment(mailId, index)` und damit denselben Endpunkt inkl. bestehender Auth-/Tenant-Prüfung. `src/lib/api/index.ts` musste nicht ergänzt werden.
|
||||
|
||||
### Security-Umsetzung (PROJ-61-Kontext)
|
||||
Abweichung vom Tech Design in der Umsetzung, bewusst strenger:
|
||||
- Der Anhang wird per authentifiziertem `fetch` als Blob geholt, **nicht** per direkter Navigation auf die Anhang-URL.
|
||||
- Der vom Server gelieferte `Content-Type` wird **nicht** zum Rendern verwendet. Der Blob wird clientseitig mit einem erzwungenen MIME-Typ aus einer festen Whitelist neu verpackt. Eine als `rechnung.pdf` getarnte HTML-Datei kann dadurch nicht als aktives Dokument im App-Origin ausgeführt werden.
|
||||
- Whitelist: `pdf` → `application/pdf`; `jpg/jpeg/png/gif/webp/bmp` → jeweiliger Bildtyp. **SVG ist bewusst ausgeschlossen** (kann Skripte enthalten — genau der PROJ-61-Vektor).
|
||||
- PDF rendert in `<iframe sandbox="allow-scripts">` **ohne** `allow-same-origin`: eigener undurchsichtiger Origin, kein Zugriff auf Cookies/DOM der App. `allow-scripts` ist nötig, weil die browsereigenen PDF-Viewer sonst nicht rendern.
|
||||
- Blob-URL lebt nur im Tab und wird beim Schließen des Dialogs bzw. beim Unmount per `URL.revokeObjectURL` freigegeben — kein persistenter öffentlicher Link.
|
||||
|
||||
Damit ist die Vorschau unabhängig davon sicher, welche Header das Backend setzt.
|
||||
|
||||
### Weitere Details
|
||||
- Größenlimit: `PREVIEW_SIZE_WARN_BYTES = 20 MB`. Darüber wird nicht automatisch geladen, sondern "Trotzdem laden" angeboten.
|
||||
- Zustände umgesetzt: Laden (Spinner), Fehler (Alert + Download-Fallback), "Keine Vorschau verfügbar" (u.a. docx/xlsx), Bild-`onError` fängt korrupte Dateien ab.
|
||||
- Bild-/PDF-Fläche auf neutral hellem Grund, damit transparente PNGs auch im Dark Mode sichtbar sind (Konsistenz mit PROJ-80).
|
||||
- Responsive: Dialog `w-[95vw] max-w-4xl`, Buttons stapeln unter `sm`.
|
||||
|
||||
Build: `npx tsc --noEmit` und `npm run build` fehlerfrei.
|
||||
|
||||
### Offen / abzustimmen mit Backend Developer
|
||||
Nicht blockierend für die Frontend-Funktion, aber zur Härtung des Endpunkts `/api/mails/{id}/attachments/{index}` bestätigen zu lassen:
|
||||
1. Wird `X-Content-Type-Options: nosniff` gesetzt?
|
||||
2. Wird `Content-Disposition: attachment` gesetzt (verhindert Inline-Rendering bei direktem Aufruf der URL im Browser)?
|
||||
3. Wird der gespeicherte Content-Type unverändert aus der Mail übernommen, oder serverseitig auf eine Whitelist normalisiert?
|
||||
Der Dialog ist unabhängig von den Antworten sicher, weil er den Server-Content-Type ignoriert — die Fragen betreffen den Fall, dass jemand die Anhang-URL direkt aufruft.
|
||||
|
||||
## QA Test Results
|
||||
|
||||
**Getestet:** 2026-08-06 | **Methode:** Statische Code-Verifikation + Backend-Endpunkt-Live-Test gegen 192.168.1.132
|
||||
|
||||
### Testbarkeits-Einschränkung (wichtig)
|
||||
Der Frontend-Code ist **uncommitted und nicht deployed** (`git status`: `?? src/components/mail/AttachmentPreviewDialog.tsx`, `M src/app/mail/[id]/page.tsx`). Auf 132 läuft der alte Stand. Ein visuelles/funktionales Durchklicken der Vorschau im Browser war daher NICHT möglich. Alle UI-Kriterien sind per Code-Review bewertet, nicht per Live-Test.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
| # | Kriterium | Ergebnis | Anmerkung |
|
||||
|---|---|---|---|
|
||||
| 1 | Klick auf Anhang öffnet Vorschau als Modal | PASS (mit Abweichung) | Umgesetzt als separater "Vorschau"-Button, nicht als Klick auf die Zeile → BUG-81-5 |
|
||||
| 2 | PDF inline gerendert | UNVERIFIZIERT | Code vorhanden (`<iframe sandbox="allow-scripts">`), Rendering in FF/Safari fraglich → BUG-81-3 |
|
||||
| 3 | Bilder (jpg/png/gif/webp) als Vorschau | PASS | Whitelist inkl. bmp, `<img>` mit `onError`-Fallback |
|
||||
| 4 | Nicht unterstützte Typen zeigen "Keine Vorschau verfügbar" | **FAIL** | Hinweis ist unerreichbar, Button wird gar nicht gerendert → BUG-81-2 |
|
||||
| 5 | Download bleibt im Dialog verfügbar | PASS | Download-Button im Dialog-Footer + unverändert in der Zeile |
|
||||
| 6 | Tenant-Isolation / kein öffentlicher Link | PASS | Kein neuer Endpunkt; Blob-URL tab-lokal + `revokeObjectURL` |
|
||||
| 7 | Große Anhänge (>20 MB) zeigen Warnhinweis | PASS | `PREVIEW_SIZE_WARN_BYTES = 20 MB`, "Trotzdem laden"-Bestätigung |
|
||||
|
||||
**Summe: 4 PASS, 1 FAIL, 1 PASS-mit-Abweichung, 1 unverifiziert**
|
||||
|
||||
### Edge Cases
|
||||
|
||||
| Edge Case | Ergebnis |
|
||||
|---|---|
|
||||
| >20 MB → Bestätigung statt Autoload | PASS |
|
||||
| Passwortgeschütztes PDF | FAIL (indirekt) — Fehlerpfad löst BUG-81-1 aus |
|
||||
| Beschädigter Anhang | TEILWEISE — `img onError` greift; Fetch-Fehler löst BUG-81-1 aus |
|
||||
| Viele Anhänge nacheinander | PASS — Dialog wird pro Zeile nur bei `previewOpen` gemountet, Blob wird beim Unmount freigegeben |
|
||||
| Anhang per DSGVO-Löschung entfernt (404) | **FAIL** — löst BUG-81-1 (Endlosschleife) aus |
|
||||
|
||||
---
|
||||
|
||||
### Bugs
|
||||
|
||||
#### BUG-81-1 — Endlos-Retry-Schleife bei jedem Vorschau-Fehler — **Severity: High** — Priorität 1
|
||||
`AttachmentPreviewDialog.tsx:143-149`
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (kind === "unsupported") return;
|
||||
if (isLarge && !confirmedLarge) return;
|
||||
if (urlRef.current || loading) return;
|
||||
void load();
|
||||
}, [open, kind, isLarge, confirmedLarge, loading, load]);
|
||||
```
|
||||
|
||||
Der Effekt hat **keinen `error`-Guard**. Ablauf bei einem fehlgeschlagenen Laden:
|
||||
1. `load()` schlägt fehl → `setError(...)`, `setLoading(false)`
|
||||
2. `loading` wechselt `true → false` → Effekt feuert erneut (steht in den Deps)
|
||||
3. `urlRef.current` ist `null`, `loading` ist `false` → alle Guards passieren → `load()` erneut
|
||||
4. → Endlosschleife
|
||||
|
||||
**Reproduktion:** Mail mit Anhang öffnen, Vorschau eines Anhangs starten, dessen Abruf fehlschlägt (Backend antwortet 403/404/500 — z.B. per DSGVO-Löschersuchen entfernter Anhang, oder Netzwerk offline schalten).
|
||||
**Auswirkung:** Browser-Tab blockiert/heizt, und der Client feuert unbegrenzt Requests gegen `/api/mails/{id}/attachments/{index}` — selbstverursachter API-Dauerbeschuss pro geöffnetem Dialog.
|
||||
**Trifft genau die dokumentierten Edge Cases** (korrupter Anhang, gelöschter Anhang, passwortgeschütztes PDF).
|
||||
**Blocker für Produktion.**
|
||||
|
||||
#### BUG-81-2 — AC4 nicht erfüllt: "Keine Vorschau verfügbar" ist toter Code — **Severity: Medium** — Priorität 2
|
||||
`src/app/mail/[id]/page.tsx:366` rendert den Vorschau-Button nur bei `previewable === true` (`canPreview()`). Für docx/xlsx/zip gibt es damit keinen Weg, den Dialog zu öffnen — der Zweig `kind === "unsupported"` in `AttachmentPreviewDialog.tsx:175-183` ist unerreichbar.
|
||||
AC4 verlangt explizit, dass nicht unterstützte Typen den Hinweis **zeigen**. Entweder Button immer rendern (und Hinweis anzeigen) oder AC anpassen — die Spec muss mit der Implementierung übereinstimmen.
|
||||
|
||||
#### BUG-81-3 — PDF-Rendering im Sandbox-iframe browserübergreifend unverifiziert — **Severity: Medium** — Priorität 3
|
||||
`AttachmentPreviewDialog.tsx:241-248`: `<iframe src={blobUrl} sandbox="allow-scripts">` ohne `allow-same-origin`.
|
||||
Firefox' pdf.js und Safari rendern PDFs in sandboxed iframes ohne `allow-same-origin` erfahrungsgemäß nicht zuverlässig; zusätzlich weigert sich Safari häufig, `blob:`-PDFs in iframes darzustellen. Das Technical Requirement "Browser Support: Chrome, Firefox, Safari, Edge" ist damit **nicht belegt**.
|
||||
**Erforderlich:** manuelle Verifikation in allen vier Browsern nach dem Deploy. Falls FF/Safari leer bleiben, braucht es einen sichtbaren Fallback ("PDF kann in diesem Browser nicht angezeigt werden") statt eines weißen Rahmens.
|
||||
|
||||
#### BUG-81-4 — Sicherheitskommentar widerspricht dem Code — **Severity: Low** — Priorität 4
|
||||
`AttachmentPreviewDialog.tsx:28` behauptet: "PDFs laufen in einem sandboxed iframe ohne allow-same-origin/**allow-scripts**". Tatsächlich setzt Zeile 246 `sandbox="allow-scripts"`. Der Inline-Kommentar bei Zeile 236-239 beschreibt es korrekt. Ein falscher Security-Kommentar im Datei-Header ist gefährlich, weil künftige Reviews darauf vertrauen.
|
||||
|
||||
#### BUG-81-5 — AC1-Wortlaut vs. Umsetzung — **Severity: Low** — Priorität 5
|
||||
AC1: "Klick auf Anhang öffnet Vorschau". Umgesetzt: zusätzlicher "Vorschau"-Button. Funktional gleichwertig und arguably besser (Download bleibt eindeutig), aber Spec und Code sollten angeglichen werden.
|
||||
|
||||
#### BUG-81-6 — Anhänge ohne Dateiendung bekommen nie Vorschau — **Severity: Low** — Priorität 6
|
||||
`previewKindFor()` entscheidet ausschließlich über die Dateiendung. Ein echtes PDF, das als `scan` oder `Rechnung` (ohne Endung) in der Mail steckt — bei Fax-/Scanner-Gateways nicht selten — bekommt keinen Vorschau-Button. Vorschlag: bei fehlender Endung zusätzlich den Server-`content_type` als *Hinweis* nutzen (weiterhin nicht zum Rendern, sondern nur zur Auswahl des erzwungenen MIME-Typs aus der Whitelist).
|
||||
|
||||
---
|
||||
|
||||
### Security Audit (Red Team)
|
||||
|
||||
**Ergebnis: keine neue Angriffsfläche durch PROJ-81.** Die Umsetzung ist an den kritischen Stellen strenger als das Tech Design und korrekt.
|
||||
|
||||
| Prüfpunkt | Ergebnis |
|
||||
|---|---|
|
||||
| Neuer Endpunkt? | Nein — `/api/mails/{id}/attachments/{index}` wiederverwendet, keine neue Route in `internal/api/server.go` |
|
||||
| Auth ohne Cookie | PASS — live gegen 132: `GET /api/mails/abc/attachments/0` → `401` |
|
||||
| Tenant-Isolation (PROJ-61-Klasse) | PASS — `search_handlers.go:406-413` vergleicht `sess.TenantID` gegen `GetTenantForMail()`; zusätzlich `auditorIsGlobal()`-Zweig (PROJ-55) und `RoleUser`-Ownership-Check |
|
||||
| RBAC | PASS — `requireMailAccess()` (`server.go:423-438`) blockt `superadmin`/`domain_admin` vom Mailinhalt (SEC-29) |
|
||||
| Stored XSS via getarntem Anhang | PASS — Server-`Content-Type` wird ignoriert, Blob mit erzwungenem MIME aus fester Whitelist neu verpackt; **SVG bewusst ausgeschlossen**; PDF-iframe ohne `allow-same-origin` (undurchsichtiger Origin, kein Cookie-/DOM-Zugriff) |
|
||||
| Persistenter öffentlicher Link | PASS — `URL.revokeObjectURL` bei Close und Unmount (`urlRef`-Muster deckt auch Unmount während des Ladens ab) |
|
||||
|
||||
**Antworten auf die offenen Backend-Fragen aus den Implementation Notes:**
|
||||
1. `X-Content-Type-Options: nosniff` — **ja, aber nur über nginx**, nicht im Go-Handler. Live auf 132 verifiziert, greift auch auf `/api/`-Antworten. Empfehlung (Härtung, nicht blockierend): zusätzlich im Handler setzen — der Go-Dienst auf :8080 liefert den Header sonst ohne Proxy nicht, und andere Handler wie `tenant_logo_handlers.go:44` setzen ihn bereits selbst.
|
||||
2. `Content-Disposition: attachment` — **ja**, `search_handlers.go:447`. Direkter Aufruf der URL im Browser rendert also nicht inline.
|
||||
3. Content-Type-Normalisierung — **nein**. `search_handlers.go:446` setzt `w.Header().Set("Content-Type", a.ContentType)` unverändert aus der Mail, ohne Whitelist. Risiko durch Punkt 1+2 gemindert, aber eine serverseitige Whitelist wäre die saubere Defense-in-Depth.
|
||||
|
||||
**Bestandsfunde (nicht durch PROJ-81 verursacht, aber durch das Feature relevanter):**
|
||||
- **Anhang-Abrufe werden nicht ins Audit-Log geschrieben.** `handleGetAttachment` (`search_handlers.go:378-450`) enthält keinen Audit-Aufruf. Da die Vorschau denselben Endpunkt nutzt, greifen User künftig deutlich häufiger auf Anhangsinhalte zu, ohne dass es nachvollziehbar ist — GoBD-/DSGVO-relevant. **Severity: Medium**, eigenes Ticket empfohlen.
|
||||
- **Existenz-Orakel:** unbekannte Mail-ID → `404`, fremde Mail-ID → `403`. Erlaubt Enumeration der Existenz fremder Mail-IDs. **Severity: Low**, Bestandsverhalten (auch in `handleGetRaw`).
|
||||
|
||||
---
|
||||
|
||||
### Regression
|
||||
- `npx tsc --noEmit` fehlerfrei (Exit 0).
|
||||
- Keine Backend-Änderung → keine Regression an PROJ-55 (Auditor-Scoping), PROJ-50 (DSGVO-Löschung), SEC-29 zu erwarten.
|
||||
- `src/app/mail/[id]/page.tsx` (PROJ-7) angefasst: `AttachmentRow` umgebaut, Download-Pfad unverändert; HTML-Mail-Container zusätzlich `bg-white` (PROJ-80-Änderung, siehe dort).
|
||||
- Regression an PROJ-76 (Mail-HTML-Sanitizing) und PROJ-61 (Logo-XSS) nicht berührt.
|
||||
|
||||
### Produktionsreife: **NEIN**
|
||||
Blocker: BUG-81-1 (High). Zusätzlich sollte BUG-81-2 (AC4 nicht erfüllt) vor dem Deploy geklärt werden, und BUG-81-3 verlangt eine echte Browser-Verifikation nach dem Deploy auf 132.
|
||||
|
||||
---
|
||||
|
||||
## Bugfixes nach QA (Frontend, 2026-08-06)
|
||||
|
||||
### BUG-81-1 (High, Blocker) — Endlos-Retry-Schleife — **fixed**
|
||||
`AttachmentPreviewDialog.tsx`: `if (error) return;` als Guard ergänzt, `error` in die Dep-Liste des Lade-Effekts aufgenommen. Nach einem Fehlschlag wird **nicht** mehr automatisch neu geladen — die Schleife (loading `true→false` → Effekt feuert → `load()` → Fehler → …) ist damit unterbrochen und der Dauerbeschuss von `/api/mails/{id}/attachments/{index}` beendet.
|
||||
Der User kommt weiterhin ans Ziel: Download-Button bleibt im Dialog, und beim Schließen wird `error` zurückgesetzt, ein erneutes Öffnen startet also einen sauberen neuen Versuch.
|
||||
Der Grund für den Guard steht als Kommentar direkt am Effekt, damit er bei künftigen Refactorings nicht wieder wegfällt.
|
||||
|
||||
### BUG-81-2 (Medium) — AC4-Fallback war toter Code — **fixed**
|
||||
`src/app/mail/[id]/page.tsx`: Der "Vorschau"-Button wird jetzt für **alle** Anhänge gerendert, nicht mehr nur bei `canPreview()`. docx/xlsx/zip öffnen den Dialog und erhalten dort den Hinweis "Keine Vorschau verfügbar" plus Download-Button — AC4 ist damit erfüllt und der Zweig erreichbar.
|
||||
`canPreview` wird in `page.tsx` nicht mehr importiert, bleibt aber als Export erhalten (intern weiter über `previewKindFor` genutzt).
|
||||
|
||||
### BUG-81-4 (Low) — Sicherheitskommentar widersprach dem Code — **fixed**
|
||||
Der Datei-Header behauptete "sandboxed iframe ohne allow-same-origin/allow-scripts", tatsächlich ist `sandbox="allow-scripts"` gesetzt. Header korrigiert: `allow-scripts` **ja**, `allow-same-origin` **nein** (undurchsichtiger Origin, kein Cookie-/DOM-Zugriff), inkl. Begründung warum `allow-scripts` nötig ist. Ein falscher Security-Kommentar ist gefährlich, weil künftige Reviews darauf vertrauen — daher trotz Low-Severity sofort mitgenommen.
|
||||
|
||||
### Offen, wie abgestimmt (kein Code-Fix in diesem Durchgang)
|
||||
- **BUG-81-3** — PDF-Sandbox in Firefox/Safari unbelegt: nach Deploy im Browser verifizieren. Fällt das Rendering aus, braucht es einen sichtbaren Hinweis statt eines leeren weißen Rahmens.
|
||||
- **BUG-81-5** (AC1-Wortlaut "Klick auf Anhang" vs. Vorschau-Button) und **BUG-81-6** (Anhänge ohne Dateiendung bekommen keinen Vorschau-Button) — nicht angefasst.
|
||||
- **Audit-Logging für Anhang-Abrufe** (Bestandsfund aus dem Security-Audit): bewusst **nicht** hier mitgefixt, bekommt ein eigenes Ticket.
|
||||
- Die drei Backend-Fragen sind vom QA-Lauf beantwortet (nosniff nur über nginx, `Content-Disposition: attachment` gesetzt, **keine** serverseitige Content-Type-Whitelist). Für die Vorschau unkritisch, weil der Dialog den Server-Content-Type ignoriert; die empfohlene Handler-seitige Härtung ist Backend-Scope.
|
||||
|
||||
Nach den Fixes: `npx tsc --noEmit` und `npm run build` fehlerfrei.
|
||||
|
||||
## Deployment
|
||||
_To be added by /deploy_
|
||||
@@ -0,0 +1,38 @@
|
||||
# PROJ-82: Print-Farbparität zwischen Hell- und Dark-Mode-Ausdrucken
|
||||
|
||||
## Status: Planned
|
||||
**Created:** 2026-08-06
|
||||
**Last Updated:** 2026-08-06
|
||||
|
||||
## Kontext
|
||||
Aus PROJ-80 (BUG-80-1) hervorgegangen. Die aktuelle Print-Regel (`globals.css`, `@media print`) sorgt dafür, dass Ausdrucke aus dem Dark Mode lesbar und strukturell identisch zu Ausdrucken aus dem Light Mode sind — aber nicht farbidentisch. Light Mode druckt Signalfarben (z.B. Status-Chips), Dark Mode druckt graue Ersatzflächen. Reines CSS kann die `dark`-Klasse am `<html>`-Element beim Drucken nicht selektiv zurücknehmen.
|
||||
|
||||
## Dependencies
|
||||
- Baut auf PROJ-80 (Dark Mode Vervollständigung) auf
|
||||
|
||||
## User Stories
|
||||
- Als Auditor/Admin will ich, dass ein Ausdruck (z.B. Modul-Status-Übersicht) exakt gleich aussieht, unabhängig davon ob der Ersteller im Hell- oder Dark Mode gearbeitet hat.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Ausdruck aus Dark Mode und Ausdruck aus Light Mode sind farblich identisch (nicht nur strukturell/lesbar)
|
||||
- [ ] Keine Beeinträchtigung der Bildschirmdarstellung durch die Lösung
|
||||
- [ ] Kein dauerhafter Eingriff in den `next-themes`-State (Theme-Wahl des Users bleibt nach dem Drucken unverändert)
|
||||
|
||||
## Edge Cases
|
||||
- User druckt während des Ladens/Renderns einer Seite (Race Condition zwischen Theme-Umschaltung und Druckstart)
|
||||
- Browser ohne `beforeprint`/`afterprint`-Event-Support
|
||||
|
||||
## Technical Requirements (optional)
|
||||
- Lösung voraussichtlich: `beforeprint`/`afterprint`-Listener, der die `dark`-Klasse temporär entfernt und danach wiederherstellt — Umsetzung braucht Rücksprache, da Eingriff in Theme-State über reines CSS hinausgeht
|
||||
|
||||
---
|
||||
<!-- Sections below are added by subsequent skills -->
|
||||
|
||||
## Tech Design (Solution Architect)
|
||||
_To be added by /architecture_
|
||||
|
||||
## QA Test Results
|
||||
_To be added by /qa_
|
||||
|
||||
## Deployment
|
||||
_To be added by /deploy_
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user