30 Commits

Author SHA1 Message Date
schalli 645c5e5887 docs(changelog): Version 1.9.1 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m47s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 12m17s
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m33s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 14:30:17 +02:00
schalli 2dd11b439d fix(favorites): Logo auch fuer Seiten, die es per JavaScript setzen
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 1m28s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m18s
hosteurope.de liefert im HTML nur einen leeren data:-Platzhalter, das
echte Symbol setzt erst JavaScript; /favicon.ico antwortet mit HTML. Die
Kachel zeigte deshalb nur den Buchstaben. Scheitert das gespeicherte
Symbol, fragt getIconBytes jetzt einmal den DuckDuckGo-Symboldienst -
nur fuer oeffentlich erreichbare Seiten, interne Hostnamen verlassen das
Haus nicht; kennt der Dienst nichts (404), bleibt es beim Buchstaben.
Wirkt auch fuer bestehende Favoriten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:28:32 +02:00
schalli 59b8cd43fc fix(reminders): Cursor springt beim Schreiben nicht mehr in den Titel
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 1m24s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m56s
Der Fokus auf das Titelfeld lag im selben Effekt wie der Escape-Listener
mit Abhaengigkeit onClose. Die Kachel uebergibt onClose inline und
zeichnet alle 10 s neu (NOW_TICK_MS) - der Effekt lief jedes Mal erneut
und holte den Cursor aus der Beschreibung zurueck in den Titel. Fokus
jetzt nur beim Oeffnen, Escape ueber eine Ref.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:38:58 +02:00
schalli 5919a55cbf fix(desktop): Link-Klicks vor dem Opener-Skript des Clients abfangen
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m33s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m13s
Die erste Fassung lauschte auf window und lief damit nach dem von
tauri-plugin-opener eingeschleusten Link-Skript. Dieses ruft bei
target=_blank preventDefault und plugin:opener|open_url auf, was von der
Server-Seite aus nicht freigegeben ist - der Klick verpuffte weiter
(auf VM 8233 per Klick-Protokoll gemessen). Der Helfer lauscht jetzt auf
document: nach Reacts Handlern, vor dem Opener-Skript.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 10:20:42 +02:00
schalli 7edaf8c00b docs(quick-261001-cxo): Summary und STATE
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 09:20:49 +02:00
schalli 61a971ccc0 fix(desktop): Links mit target=_blank im Client ueber window.open oeffnen
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m36s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m32s
Im Desktop-Client unter Windows tat ein Klick auf Favoriten und andere
Links mit target=_blank nichts, waehrend window.open (Such-Widget) ueber
on_new_window im System-Browser landete (VM 8233 nachgestellt).
DesktopExternalLinks leitet im Client Links- und Mittelklicks auf solche
http/https-Links auf window.open um; von der Seite verhinderte Klicks
(Favoriten im Bearbeiten-Modus) bleiben verhindert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 09:20:11 +02:00
schalli 471cfbf98b wip: pausiert nach Freigabe 1.9.0
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m29s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m5s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:21:01 +02:00
schalli a257bc3f86 docs(changelog): Version 1.9.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m8s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m52s
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 1m22s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:54:25 +02:00
schalli 714f731ac9 feat(admin): Anmeldehinweise der Willkommensmail in der Vorlage bearbeitbar
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m29s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m14s
Neue Felder loginHintDirectory/loginHintLocal (Platzhalter, Pflicht, max. 1000),
Migration 20260930170000 (nullable, leer = Standard aus @tessera/shared).
Knopf Passwort festlegen und Gueltigkeitshinweis bleiben fest; local-no-link
wird nie erzeugt und bleibt fest. Vorschau springt beim Bearbeiten auf die
passende Kontoart. Lokal im Browser nachgewiesen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:56:05 +02:00
schalli e10da76259 feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m22s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m10s
Administrator -> Willkommensmail: Betreff, Ueberschrift, Einleitung, Abschluss je
Mandant (Tabelle WelcomeMailTemplate, RLS je Mandant, Migration 20260930150000);
Platzhalter {{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}},
unbekannte -> 400 bzw. Hinweis beim Tippen; Werte escaped, Vorlage reiner Text.
Live-Vorschau per API gerendert, Testmail an die eigene Adresse ohne Token,
Zuruecksetzen auf Standard. Feste Bausteine (Kopf, Zugangsdaten, Anmeldehinweis,
Knoepfe, Fusszeile) bleiben immer drin.
Kopf: Wellenzelle dunkel statt weiss, Streifen 600x40, Inhalt 24 px naeher –
keine weisse Luecke, wenn OWA das CID-Bild nicht zeigt.
Lokal nachgewiesen: Hinweis/Sperre bei {{xyz}}, Speichern, Testmail (Link nur
/login), echte Mail mit eigener Vorlage und 7-Tage-Link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:49:17 +02:00
schalli 31d514b7ca fix(web): /login leitet angemeldete Benutzer aufs Dashboard (bzw. sicheres next)
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m30s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m28s
Nur bei gueltiger Signatur und ohne ausstehenden Kennwortwechsel; next ueber
sanitizeNextPath, /login als Ziel -> Dashboard. Gesperrte Konten: API lehnt
ab, Oberflaeche loescht das Cookie serverseitig, keine Schleife.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:30:44 +02:00
schalli 52f538c432 fix(mail): Willkommensmail-Kopf ohne Bildzwang – Logo als HTML, Welle als Streifen
Rueckmeldung des Nutzers: in Outlook nur ein grosser schwarzer Kasten, kein
Logo (das eingebettete Kopfbild wurde nicht angezeigt; Logo und Schriftzug
steckten nur darin). Jetzt Bildmarke aus Tabellenzellen und Schriftzug als
Text in einer niedrigen dunklen Leiste, darunter die Duenen-Welle als
600x56-Streifen, der ins Weiss auslaeuft; fehlt das Bild, bleibt nur Abstand.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:29:16 +02:00
schalli 32441d77c7 feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s
POST /users/:id/welcome-mail (gleiche Rechte wie Bearbeiten, jederzeit sendbar),
GET /users/welcome-mail/status; HTML-Mail (Tabellenlayout, Inline-Stile,
Kopfbild als CID-PNG aus assets/mail/welcome-header.svg, erzeugt mit
scripts/render-mail-header.mjs) plus Textfassung. Verzeichniskonten: Hinweis
auf Windows-Passwort; lokale Konten: Link Passwort festlegen (7 Tage, einmalig).
Neue Spalte User.welcomeMailSentAt (Migration 20260930120000). Benutzerliste:
Spalte Letzte Anmeldung, Zeilenaktionen als Symbole. Dockerfile kopiert
apps/api/assets. Lokal per MailHog nachgewiesen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:28:52 +02:00
schalli 0b34e82b21 fix(auth): Rolle, Aktiv-Status und Kennwort-Pflicht je Anfrage aus der Datenbank
JwtStrategy.validate las bisher alles aus dem 30-Tage-Token: ein herabgestufter
Administrator behielt seine Rechte bis zum Ablauf, ein deaktiviertes oder
geloeschtes Konto arbeitete mit seiner Sitzung weiter, und Oberflaeche (/auth/me
aus der DB) und API (Token) sahen verschiedene Rollen – die Benutzerliste
scheiterte nach einer Rollenaenderung (Befund des Nutzers auf alpha).
Jetzt ein gebundener PK-Lesezugriff je Anfrage (forTenant), 401 bei fehlendem,
deaktiviertem oder mandantenfremdem Konto. Lokal nachgewiesen: Herabstufen ->
sofort 403, Deaktivieren -> sofort 401.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:28:52 +02:00
schalli af78157536 docs(changelog): Version 1.8.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m2s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m21s
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 1m31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:48:47 +02:00
schalli 7e70fc4d32 feat(custom-modules): besuchte eigene Module offen halten (Keep-Alive, hoechstens 5)
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m37s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m12s
iframes liegen dauerhaft in einem Behaelter im Portal-Rahmen und werden per
position: fixed deckungsgleich ueber den Platzhalter der Modulseite gelegt;
beim Wegnavigieren nur versteckt (visibility), nie neu eingehaengt. Name/Adresse
je Modul im Sitzungsspeicher, Nachladen im Hintergrund, 404 verwirft. Abmelden
und Loeschen leeren. Im Browser nachgewiesen: gleiches iframe-Element und kein
neuer Seitenabruf nach Dashboard -> Modul B -> Modul A; folgt Seitenleiste
ein-/ausgeklappt und Handybreite.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:02:50 +02:00
schalli f78b422abf fix(dashboard): nie scrollen – Inhalt hoeher als die Leinwand wird eingepasst
Tessera CI/CD / Lint & Type Check (push) Successful in 1m29s
Tessera CI/CD / Tests (push) Successful in 1m43s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 22s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m45s
Die Einpassung nimmt max(Leinwandhoehe, Rasterhoehe) (fitCanvasToContent).
Anlass: Dashboard des Nutzers 940 px hoch bei Leinwand 849 px, dadurch
scrollte es ueberall. Im Bearbeitungsmodus bleibt die Hoehe vom Beginn des
Bearbeitens stehen, damit der Massstab beim Ziehen nicht wandert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:55:50 +02:00
schalli c846eb49cb style(favorites): Kachelansicht enger, lange Namen kleiner und zweizeilig
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m58s
Spaltenmindestbreite 76 -> 52 px (Abstand zwischen den Symbolen ~59 -> ~27 px
bei einer 373 px breiten Kachel). LauncherLabel misst den Titel einzeilig in
12 px; passt er nicht, 10 px, zweizeilig mit Silbentrennung und Auslassung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:33:20 +02:00
schalli af87c2c112 feat(dashboard): auf jedem Bildschirm dasselbe Bild – Leinwand je Reiter, massstaeblich skaliert
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m54s
Je Reiter wird die Flaeche des ersten Desktop-Bildschirms als __canvas im
Layout-JSON gespeichert (ohne API/DB-Aenderung, GRID_VERSION bleibt 3).
Das Raster rendert in Leinwandbreite und wird per transform: scale(min(bw/cw, bh/ch))
eingepasst, Schrift eingeschlossen, ohne Scrollen; unter 768 px wie bisher.
Ziehen/Groesse aendern unter Skalierung ueber eine eigene Positionsstrategie
(createScaledStrategy aus react-grid-layout 2.2.3 rechnet den Rasterversatz falsch).
Im Browser nachgewiesen: 1920x1080 -> 1366x768 (Faktor 0,66), Ziehen +200 px
folgt der Maus, 700 px ohne Skalierung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:19:27 +02:00
schalli bd73fa5e74 docs(proxmox): Lesezugang anlegen – API-Token fuer PVE/PBS, eigener Auditor-Benutzer fuer PMG
PMG kennt keine API-Token (Proxmox-Bugzilla 5849, offen); Schritt-fuer-Schritt
fuer Oberflaeche und Kommandozeile, Rolle an Benutzer UND Token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:12:13 +02:00
schalli 86ad95f74e docs: Windows-Test bestanden, Review-Fixes seit 26.09., Uebergabe verbraucht
Tessera CI/CD / Lint & Type Check (push) Successful in 46s
Tessera CI/CD / Tests (push) Successful in 1m19s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m30s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m12s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:23:59 +02:00
schalli 12214a948e fix(dashboard): Rasterversion schuetzen, Kalender-Mindestbreite, Breiten je Breakpoint, Hintergrund
- Layout mit neuerer Rasterversion wird nie gespeichert, Bearbeiten gesperrt mit Hinweis
- Kalender minW 11 (~260 px bei lg), Breiten je Breakpoint auf Spaltenzahl begrenzt
- optimistische Ruecksetzung nur, wenn noch der gesetzte Wert steht
- Titel-Schalter mit fester Beschriftung + aria-pressed
- Hintergrund-Dialog: Fokus rein/zurueck, Tab bleibt im Dialog
- Loeschen eines Bildes setzt eine darauf zeigende Hintergrund-Wahl zurueck
- Uebersetzungen und CHANGELOG

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli 071082983b fix(desktop,favorites): keine zweite Update-Installation, Lesegrenzen bei der Symbolsuche
- Desktop: Merker "Installation laeuft" sperrt Pruefschleife und Klick; ein angebotenes Update bleibt nach fehlgeschlagener Pruefung per Klick installierbar
- Desktop: Benachrichtigungsrecht erst nach erfolgreichem add_capability vermerken
- Favoriten: HTML nur bis MAX_HTML_CHARS und hoechstens 4 s lesen, Nicht-HTML-Antworten verwerfen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli c2e4467dd8 fix(reminders): keine Dauermeldung ohne localStorage, Mail nach Zeitaenderung, null-Pruefung
- Merkliste zusaetzlich im Arbeitsspeicher (sonst alle 10 s dieselbe Meldung)
- Aendern der Faelligkeit atomar gegen gleichzeitiges Faelligwerden, setzt Mail-Spur zurueck
- UpdateReminderDto lehnt null ab (400 statt 500)
- keine Mails an deaktivierte Benutzer
- Spaeter erinnern beschriftet heute/morgen nach dem berechneten Zeitpunkt
- Bearbeiten schickt dueAt nur bei geaenderter Zeit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli be1e0035e0 fix(custom-modules): null beim Aendern ablehnen, Ladefehler statt 404, Fehlertexte, Seitenleiste eingeklappt
- PATCH mit null fuer name/url/category ergibt 400 statt 500
- Modulansicht unterscheidet Ladefehler von "nicht gefunden"
- Formular/Loeschdialog nennen 403 und 400 eigens
- eingeklappte Seitenleiste folgt der Gruppenreihenfolge der ausgeklappten
- neue Eintraege sind mit "Eigene Module" vorbelegt
- Verwaltung zeigt bei Ladefehler nicht zusaetzlich "keine Eintraege"

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli f7213f5e45 wip: pausiert nach 1.7.0-Folgearbeiten, Windows-Test offen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:49:15 +02:00
schalli e72814abd9 docs(quick-260929-lh3): Favoriten-Symbole, Browser-Pruefung
Tessera CI/CD / Lint & Type Check (push) Successful in 52s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m21s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:48:39 +02:00
schalli 0e72ad45f8 docs(changelog): Favoriten-Symbole
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:47:23 +02:00
schalli b15c74632b fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann
- API: ausdrueckliche iconUrl wird nur auf Form (http/https, <= 2048) geprueft
  und auch gespeichert, wenn der Server sie nicht abrufen kann; keine 422 mehr
- Erkennung: Seite mit Fehlerstatus, aber HTML mit <link rel=icon>, liefert
  diesen Verweis (docuvita); og:image einer Fehlerseite zaehlt nicht
- Kachel: Proxy -> iconUrl direkt im Browser (no-referrer, nur http/https) ->
  Origin-Favicon -> Buchstabe
- Meldung iconUrlUnreachable (de/en) entfernt, Hinweis zum Vorrang angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:34:58 +02:00
schalli 7188c5b958 fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt
- eine neue, ausdruecklich eingetragene Logo-Adresse verdraengt ein frueher
  hochgeladenes Symbol (Vorrang der Datei liess die Adresse unsichtbar)
- iconVersion steigt mit, die Kachel laedt das Bild neu
- Regressionstests: Upload wird ersetzt, unveraenderte Adresse laesst Upload stehen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:32:30 +02:00
117 changed files with 9691 additions and 1213 deletions
+34
View File
@@ -0,0 +1,34 @@
---
context: default
phase: quick-auftraege-1.9.x (keine GSD-Phase)
task: 0
total_tasks: 0
status: paused
last_updated: 2026-09-30T18:00:00.000Z
---
# BLOCKING CONSTRAINTS — Read Before Anything Else
- [ ] CONSTRAINT: live NICHT pushen/taggen, bis der User es verlangt.
- [ ] CONSTRAINT: Gebündelt pushen, nicht nach jeder Kleinigkeit.
- [ ] CONSTRAINT: Browser-Prüfungen im Dunkelmodus.
- [ ] CONSTRAINT: Nie Wichtiges (Logo, Text) nur in Mailbilder packen – OWA zeigt eingebettete Bilder nicht.
<current_state>
main = live = v1.9.0 (a257bc3), CI + Release grün. alpha zuletzt 714f731 (Inhalt identisch). Arbeitsbaum sauber.
</current_state>
<completed_work>
- 30.09.: Windows-Test bestanden, Review seit 26.09. + Fixes, v1.8.0.
- Dashboard-Skalierung (nie scrollen), eigene Module Keep-Alive, Favoriten enger.
- Sicherheit: Rolle/Aktiv-Status je Anfrage aus DB; /login-Weiterleitung.
- Willkommensmail + eigene Vorlage (Platzhalter, Vorschau, Testmail, Anmeldehinweise), Spalte Letzte Anmeldung; v1.9.0.
</completed_work>
<remaining_work>
- User: live auf 1.9.0 ziehen (df -h / vorher), Willkommensmail in OWA prüfen.
</remaining_work>
<next_action>
Nachfragen, ob live gezogen und OWA ok; sonst neuen Auftrag abwarten.
</next_action>
+33
View File
@@ -0,0 +1,33 @@
{
"version": "1.0",
"timestamp": "2026-09-30T18:00:00.000Z",
"phase": null,
"phase_name": "Quick-Auftraege nach Freigabe 1.9.0 (keine GSD-Phase)",
"phase_dir": null,
"plan": null,
"task": 0,
"total_tasks": 0,
"status": "paused",
"completed_tasks": [
{"id": 1, "name": "30.09.: Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. + Fixes; Freigabe 1.8.0 (af78157)", "status": "done"},
{"id": 2, "name": "Dashboard 1:1-Skalierung (__canvas, passt Inhalt ein = nie scrollen), eigene Module Keep-Alive (max 5), Favoriten-Kachelansicht enger + lange Namen klein/zweizeilig", "status": "done"},
{"id": 3, "name": "Sicherheit: JwtStrategy liest Rolle/isActive/mustChangePassword je Anfrage aus DB; /login leitet Angemeldete aufs Dashboard", "status": "done"},
{"id": 4, "name": "Willkommensmail (Briefsymbol Benutzerliste, jederzeit an jeden) + Spalte Letzte Anmeldung + eigene Vorlage je Mandant (Admin -> Willkommensmail, Platzhalter, Vorschau, Testmail, Anmeldehinweise editierbar); Freigabe 1.9.0 (a257bc3)", "status": "done"}
],
"remaining_tasks": [],
"blockers": [],
"async_jobs": [],
"human_actions_pending": [
{"action": "Live-Server auf v1.9.0 ziehen (vorher df -h /, ggf. docker image prune -f, nie -a)", "context": "CI 11 Laeufe gruen, Release Tessera 1.9.0 mit Setup.exe + AppImage", "blocking": false},
{"action": "Willkommensmail in OWA (owa.ctl.de) pruefen: dunkler Kopf ohne weisse Luecke", "context": "OWA zeigt CID-Bilder nicht, Outlook-Programm schon", "blocking": false}
],
"decisions": [
{"decision": "Dashboard: alles mitskalieren inkl. Schrift, nie scrollen; Leinwand = Flaeche beim ersten Oeffnen je Reiter", "rationale": "AskUserQuestion 30.09.", "phase": "quick"},
{"decision": "Keine naechtliche Docker-Aufraeumung auf alpha", "rationale": "User 30.09.: passt so", "phase": "quick"},
{"decision": "Willkommensmail jederzeit an jeden Benutzer; Link Passwort festlegen 7 Tage", "rationale": "User 30.09.", "phase": "quick"},
{"decision": "PMG ohne API-Token (Proxmox kennt keine), eigener Auditor-Benutzer; Anleitung im Admin-Handbuch", "rationale": "Proxmox Bugzilla 5849 offen", "phase": "quick"}
],
"uncommitted_files": [],
"next_action": "Nichts offen von Claudes Seite. Beim Start: fragen, ob live auf 1.9.0 gezogen ist und ob die Willkommensmail in OWA passt; sonst neuen Auftrag abwarten.",
"context_notes": "main = live = v1.9.0 (a257bc3). alpha lief zuletzt auf 714f731 (= Inhalt 1.9.0). Lokaler Stack aus 714f731 gebaut. Test-Postfach MailHog nur bei Bedarf: docker run -d --rm --name mailhog --network tessera-ctl_backend-net --network-alias mailhog -p 127.0.0.1:8025:8025 mailhog/mailhog. Diagnose auf alpha ohne Passwort: JWT im api-Container mit crypto + process.env.JWT_SECRET signieren (nur lesend, kurzlebig)."
}
+6 -2
View File
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
status: verified status: verified
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren." stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
last_updated: "2026-09-23T15:30:00.000Z" last_updated: "2026-09-23T15:30:00.000Z"
last_activity: 2026-09-23 last_activity: 2026-10-01
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0 last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2 state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
progress: progress:
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden) Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6 Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live Last activity: 2026-09-30 - v1.9.0 freigegeben (Willkommensmail + Vorlage, Rollen je Anfrage aus DB, Dashboard-Skalierung, Keep-Alive eigene Module); pausiert mit HANDOFF
Progress: [██████████] 99% Progress: [██████████] 99%
@@ -482,6 +482,10 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260929-dmx | **Widget-Raster horizontal feiner + Kalender schmaler.** COLS lg 48/md 40/sm 24/xs 16/xxs 4, GRID_VERSION 3 (v2->v3 nur x/w/minW/maxW x2), alle minW/defaultW x2, Kalender minW 8 (~250 px). Browser: Anordnung pixelgleich, Kalender bis 252 px, Schritt 33 px. Auch: Hover-Anheben der Widgets entfernt (acd3c7a, Nutzerwunsch). | 2026-09-29 | 97744b5,9c9e142,46ebb4e | [260929-dmx-widget-raster-horizontal-feiner-48-spalt](./quick/260929-dmx-widget-raster-horizontal-feiner-48-spalt/) | | 260929-dmx | **Widget-Raster horizontal feiner + Kalender schmaler.** COLS lg 48/md 40/sm 24/xs 16/xxs 4, GRID_VERSION 3 (v2->v3 nur x/w/minW/maxW x2), alle minW/defaultW x2, Kalender minW 8 (~250 px). Browser: Anordnung pixelgleich, Kalender bis 252 px, Schritt 33 px. Auch: Hover-Anheben der Widgets entfernt (acd3c7a, Nutzerwunsch). | 2026-09-29 | 97744b5,9c9e142,46ebb4e | [260929-dmx-widget-raster-horizontal-feiner-48-spalt](./quick/260929-dmx-widget-raster-horizontal-feiner-48-spalt/) |
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) | | 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) | | 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
| 261001-cxo | Desktop-Client: Links mit target=_blank (Favoriten) oeffnen jetzt im System-Browser (DesktopExternalLinks -> window.open) | 2026-10-01 | 61a971c | [261001-cxo](./quick/261001-cxo-desktop-client-links-mit-target-blank-oe/) |
| 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) |
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
## Deferred Items ## Deferred Items
@@ -0,0 +1,67 @@
---
quick_id: 260929-lh3
type: quick
wave: 1
autonomous: true
---
# Quick 260929-lh3: Favoriten — eigene Symbol-Adresse wirkt nicht
## User reports (29.09.2026, alpha 8c644de)
1. Favorite with URL https://docuvita.ctl.local/server/services/web/ shows a black circle with a white "V"
instead of the page's favicon (visible in the browser tab).
2. Setting an explicit icon URL ("Symbol-Adresse", field `iconUrl`) to
https://nextcloud.com/c/uploads/2025/10/Nextcloud_01-standard-logo.png on a favorite does not change the shown icon.
## Measured facts (orchestrator, from inside the alpha api container)
- docuvita.ctl.local resolves (172.16.0.46). Server-side GET of the page returns **400** but the HTML contains
`<link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" />`.
Server-side GET of that icon returns **404 text/html** (also with a Chrome User-Agent). Root /favicon.ico → 404.
→ docuvita refuses the files to the server; the browser can load them (user sees the icon in the tab).
- Discovery (`apps/api/src/favorites/icon-discovery.service.ts` `discoverFavoriteIconUrl`) ignores non-2xx HTML
(`fetchHtml` returns null) → falls back to `{origin}/favicon.ico`; proxy fails → browser direct
`{origin}/favicon.ico` shows the "V" (a real icon served to browsers at the root).
- Explicit `iconUrl` that the server cannot fetch → API answers 422 `iconUrlUnreachable` (quick 260923-lrr),
so the user cannot set the docuvita icon URL at all.
## Task 1: Explicit icon URL change must show immediately (bug 2)
- files: apps/api/src/favorites/*, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/lib/favorites-api.ts (+ tests)
- action: Reproduce locally (admin/admin123, favorites widget): set/change `iconUrl` to a reachable PNG (e.g. the Nextcloud URL,
and a second different one). Find why the tile keeps the old image — likely the icon proxy URL
(`/favorites/:id/icon?...`) does not change when `iconUrl` changes (browser/HTTP cache, Cache-Control on the proxy
response, `iconVersion` only bumped on upload, or the server returns a cached/discovered icon instead of the explicit one).
Fix at the root: the explicit `iconUrl` wins over discovery, and any change of `iconUrl` changes the image URL
(e.g. cache-buster from `iconVersion` bumped on every iconUrl change, or a hash of iconUrl). Add regression tests
(API: PATCH iconUrl bumps version / proxy serves new bytes; web: tile src changes when iconUrl changes).
- verify: api + web tests for favorites green.
- done: commit `fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt`.
## Task 2: Accept icon URLs the server cannot fetch; browser loads them directly (bug 1)
- action:
- API: an explicit `iconUrl` that is a valid http/https URL is stored even if the server cannot fetch it
(no more 422 for "unreachable"; keep validation of scheme/length and keep rejecting non-image responses only
when the server DID get a response with a non-image content type — decide and document). Keep SSRF guard for
server-side fetches unchanged.
- Web tile: chain for an explicit `iconUrl`: proxy image → on error the browser loads `iconUrl` directly
(`<img>` with referrerPolicy="no-referrer", only http/https) → letter fallback. Existing chain for discovered icons unchanged.
- Discovery improvement (small, safe): if the page answers non-2xx but returns HTML with a `<link rel=icon>`,
still use that icon URL (so docuvita-like servers yield `/webclient/.../favicon.ico`, which the browser can then load directly).
- Remove/adjust the now-unused `iconUrlUnreachable` error text (de/en) if no longer reachable.
- verify: api + web favorites tests green; type-check/lint green; biome web ≤ 55, api ≤ 82.
- done: commit `fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann`.
## Task 3: CHANGELOG + rebuild
- CHANGELOG `## Unveröffentlicht` → `### Behoben`: two plain-German bullets (Sie-Form) for both fixes.
- `docker compose up -d --build web api`.
- Commit `docs(changelog): Favoriten-Symbole`.
## Constraints
- Commit locally only, NEVER git push. Commits end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
- Commit with explicit paths only.
- Browser check is done by the orchestrator.
@@ -0,0 +1,82 @@
---
quick_id: 260929-lh3
phase: quick
plan: 260929-lh3
subsystem: favorites
tags: [favorites, icons, icon-discovery, browser-fallback]
status: complete
commits: 3
plan_head_before: 8c644de5dad56a0394a14a00180a125919565246
plan_head_after: 0e72ad45f8833cb93ae9a8c0afa1f6d4e948e3e0
actuals:
tasks: 3
commits: 3
key-files:
modified:
- apps/api/src/favorites/favorites.service.ts
- apps/api/src/favorites/icon-discovery.service.ts
- apps/api/src/favorites/dto/create-favorite.dto.ts
- apps/api/src/favorites/dto/update-favorite.dto.ts
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
- apps/web/src/lib/favorites-api.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- CHANGELOG.md
---
# Quick 260929-lh3: Favoriten-Symbol-Adresse Summary
Explicit icon URLs are now stored even when the server cannot fetch them, the tile loads them directly in the browser, discovery uses `<link rel=icon>` from non-2xx HTML pages, and a newly entered icon URL replaces a previously uploaded icon.
## Commits
- 7188c5b `fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt` (Task 1)
- b15c746 `fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann` (Task 2)
- 0e72ad4 `docs(changelog): Favoriten-Symbole` (Task 3)
## Root cause, bug 2 ("Symbol-Adresse wirkt nicht")
Measured, not assumed:
- Local reproduction (API with curl, and the real widget in headless Chromium via CDP): changing `iconUrl` on a favorite WITHOUT an uploaded icon works. `iconVersion` is bumped, the tile is remounted, the `<img>` src changes (`?v=1` -> `?v=2`), and the bytes change (naturalWidth 626 -> 48). The proxy, `Cache-Control` and Next rewrite are not the cause.
- The alpha database (read-only psql) shows the save path works there too: favorite "Medon" holds the Nextcloud URL with `iconVersion = 1`. No favorite on alpha has `uploadedIconMime` set, and the alpha web image contains the `?v=` code.
- The real defect found in the code: `getIconBytes` always serves the uploaded file when `uploadedIconMime` is set, and `update()` never cleared it. A newly entered `iconUrl` was therefore saved but invisible for any favorite with an uploaded icon (the form even said "Ein hochgeladenes Symbol hat Vorrang"). Fixed: a new, different, non-empty `iconUrl` now clears `uploadedIconMime`, removes the file, and bumps `iconVersion`. An unchanged `iconUrl` (the form resends it on every save) leaves the upload alone.
- Caveat for the orchestrator: for the exact alpha "Medon" case (no upload) I could not reproduce a stale display; the alpha row and local browser behavior are both correct. The browser check on alpha (https, NPM, basic auth) remains the only place this can still show. If it still fails there with the new build, capture the network request of `/api-proxy/favorites/<id>/icon?v=1` in the alpha browser.
## Root cause, bug 1 (black circle with "V")
`fetchHtml` dropped every non-2xx response, so docuvita (answers the server with 400 but ships `<link rel="SHORTCUT ICON" href="/webclient/.../favicon.ico">`) fell back to `{origin}/favicon.ico`; the proxy failed and the browser showed the root favicon (the "V"). And an explicit `iconUrl` was rejected by the 422 fetch probe because docuvita returns 404 HTML to the server.
## Changes
- API: `assertIconUrlLoadable` (422) replaced by `assertIconUrlWellFormed` (http/https, <= 2048 chars, else 400); DTO `@MaxLength(2048)` on both DTOs. Decision, documented in code: even a response the server DID receive with a non-image type does not reject, because "server gets no image" does not mean "browser gets none". SSRF guard for server-side fetches is unchanged.
- Discovery: `fetchWithRedirectGuard` got `allowErrorStatus` (used only by the HTML search; `fetchIconBytes` stays strict). On a non-2xx page only `<link rel=...icon>` counts, not `og:image`.
- Web tile: proxy -> `iconUrl` direct (`referrerPolicy="no-referrer"`, http/https only) -> `{origin}/favicon.ico` (skipped when identical) -> letter. Existing chain for discovered icons behaves as before.
- Removed the `iconUrlUnreachable` reason, 422 handling and de/en texts; adjusted the upload hint (a newly entered address replaces an upload).
- CHANGELOG: two plain-German bullets under Unveröffentlicht / Behoben.
- Rebuilt `web` and `api` (`docker compose up -d --build`, healthy). Smoke test against the rebuilt API: URL the server cannot fetch is saved (proxy answers 502, tile falls back to browser), `javascript:` is rejected with 400. Test favorite deleted afterwards.
## Verification
- API favorites specs: 99 tests green. Web favorites widget, favorites-api and messages tests: 47 green. `tsc --noEmit` clean for web and api.
- Biome: web 55 warnings, api 82 warnings (at the limits, not above).
## Deviations from Plan
- [Rule 2 - Missing validation] Plan said "keep validation of scheme/length", but none existed for `iconUrl`; added form validation (service + DTO length) so the SSRF-relevant direct browser load never receives non-http(s) schemes.
- Task 1 needed no web change: the existing test "Speichern mit neuer Logo-Adresse ... ?v=1" already covers the src change; API regression tests were added.
- Commits were made on `main` as instructed (no worktree).
## Known Stubs
None.
## Self-Check: PASSED
Commits 7188c5b, b15c746, 0e72ad4 exist; SUMMARY location correct; PLAN/SUMMARY/STATE not committed.
## Browser-Pruefung (Orchestrator, 29.09.)
- Favorit „Claude“: iconUrl -> Nextcloud-PNG (PATCH 200), nach Neuladen Proxy-Bild ?v=5 mit 626 px Breite geladen.
- iconUrl -> docuvita-Brand-Icon (vom Dev-Host nicht aufloesbar): PATCH 200 (frueher 422), Kachel faellt sauber zurueck. Im Firmennetz laedt der Browser direkt.
- Zurueckgesetzt auf das urspruengliche Symbol.
@@ -0,0 +1,17 @@
---
quick_id: 261001-cxo
description: "Desktop-Client: Links mit target=_blank oeffnen"
date: 2026-10-01
---
# Desktop-Client: Links mit target=_blank oeffnen
**Befund (VM 8233, Client 1.9.0 gegen alpha):** Klick auf Favorit (`<a target="_blank">`) tut nichts; Such-Widget (`window.open`) oeffnet Edge ueber `on_new_window` (lib.rs). Der Rust-Weg funktioniert also, nur der Link-Klick erreicht ihn nicht.
## Task 1 — DesktopExternalLinks
- `apps/web/src/components/desktop/desktop-external-links.tsx`: im Desktop-Client (Cookie `tessera_desktop`) Links-/Mittelklick auf `a[href][target=_blank]` mit http/https per `window.open(href,'_blank','noopener,noreferrer')` oeffnen, `preventDefault`. Listener auf `window` (Bubble, nach React) -> von der Seite verhinderte Klicks (Favoriten im Bearbeiten-Modus) bleiben verhindert.
- In `apps/web/src/app/layout.tsx` neben `DesktopContextMenuGuard` einhaengen.
- Test `desktop-external-links.test.tsx`.
- CHANGELOG „Unveröffentlicht → Behoben“.
**Verify:** vitest gruen, tsc, biome; nach alpha-Pull auf VM 8233: Favorit oeffnet Edge.
@@ -0,0 +1,19 @@
---
quick_id: 261001-cxo
status: complete
date: 2026-10-01
commit: 61a971c
---
# Summary: Desktop-Client – Links mit target=_blank
- Nachgestellt auf VM 8233 (Client 1.9.0, alpha): Favorit-Klick ohne Wirkung, Such-Widget (`window.open`) oeffnet Edge.
- Neu `DesktopExternalLinks` (apps/web/src/components/desktop/desktop-external-links.tsx), in `app/layout.tsx` eingehaengt: im Client Links-/Mittelklick auf `a[target=_blank]` mit http/https -> `window.open(href,'_blank','noopener,noreferrer')`; verhinderte Klicks bleiben verhindert.
- 4 Tests (desktop-external-links.test.tsx), tsc + biome sauber. CHANGELOG „Unveröffentlicht → Behoben“.
- Reine Web-Aenderung: kein neuer Client noetig, wirkt nach Pull des web-Images.
- Offen: Nachweis auf VM nach alpha-Pull (User).
## Nachtrag (gleicher Tag): erste Fassung wirkte nicht
- Nach alpha-Pull weiter ohne Wirkung. Diagnose per temporaerem Klick-Protokoll (lokaler Stack, VM-Client per portproxy auf localhost:3000): `preventDefault` kam aus `<anonymous>:1:442` = Link-Skript von tauri-plugin-opener (init-iife.js, Listener auf `window`): faengt `target=_blank`-Klicks ab und ruft `plugin:opener|open_url` – von der Server-Seite nicht freigegeben, Klick verpufft. Unser Listener auf `window` lief danach und sah den Klick als verhindert.
- Fix: Listener auf `document` (Bubble) – nach React (Wurzel document), vor dem Opener-Skript. Auf VM nachgewiesen: Favorit oeffnet Edge, im Bearbeiten-Modus nichts (React-onClick verhindert).
- Commit siehe git log; Test „kommt dem Link-Skript des Clients auf window zuvor“.
@@ -0,0 +1,14 @@
---
quick_id: 261001-g68
description: "Erinnerung: Cursor springt aus Beschreibung in Titel"
date: 2026-10-01
---
# Erinnerung: Cursor springt aus Beschreibung in Titel
**Befund:** `ReminderFormModal` setzte den Fokus auf den Titel im selben Effekt wie den Escape-Listener, Abhaengigkeit `[onClose]`. `onClose` ist in der Kachel eine Inline-Funktion; die Kachel zeichnet alle 10 s neu (NOW_TICK_MS) und bei jedem Neuladen → Effekt laeuft erneut → Cursor springt in den Titel (User: beim Schreiben, und bei Loeschen-Taste in leerer Beschreibung).
## Task 1
- Fokus-Effekt nur beim Oeffnen (`[]`), Escape-Listener ueber `onCloseRef`.
- Test im Widget: Formular oeffnen, Beschreibung fokussieren, 30 s Takt → Fokus bleibt.
- CHANGELOG „Unveröffentlicht → Behoben“.
@@ -0,0 +1,10 @@
---
quick_id: 261001-g68
status: complete
date: 2026-10-01
---
# Summary
- `reminder-form-modal.tsx`: Fokus auf Titel nur einmal beim Oeffnen; Escape ueber Ref statt `[onClose]`-Abhaengigkeit.
- Neuer Test in `reminder-widget.test.tsx` – schlaegt ohne Fix fehl, mit Fix gruen; 28/28 Erinnerungs-Tests, tsc, biome sauber.
- Andere Dialoge mit `[onClose]`-Fokus (Widget-Katalog, Bilderrahmen-Lightbox) fokussieren nur den Dialog ohne Eingabefelder – nicht betroffen.
@@ -0,0 +1,14 @@
---
quick_id: 261001-hbi
description: "Favoriten: Logo fuer per JavaScript gesetzte Symbole"
date: 2026-10-01
---
# Favoriten: Logo fuer per JavaScript gesetzte Symbole
**Befund:** https://www.hosteurope.de/ liefert im HTML nur `<link rel="icon" href="data:;base64,=">`; das echte Symbol (img1.wsimg.com/.../HostEurope.png) setzt erst JavaScript. `/favicon.ico`, `/apple-touch-icon.png`, `/favicon.svg` antworten 200 mit text/html. Die serverseitige Suche faellt auf `/favicon.ico` zurueck, der Abruf scheitert (kein Bild) → Buchstabe.
## Task 1
- `IconDiscoveryService.fetchPublicServiceIconBytes(pageUrl)`: DuckDuckGo-Symboldienst (`icons.duckduckgo.com/ip3/<host>.ico`), NUR wenn die Seite oeffentlich ist (isPublicHttpUrl) — interne Hostnamen verlassen das Haus nicht; 404 fuer Unbekanntes → wirft → Buchstabe bleibt.
- `FavoritesService.getIconBytes`: scheitert das gespeicherte Symbol, einmal den Dienst fragen, sonst 502 wie bisher. Repariert auch bestehende Favoriten ohne Neuanlage.
- Tests in beiden Specs; CHANGELOG.
@@ -0,0 +1,10 @@
---
quick_id: 261001-hbi
status: complete
date: 2026-10-01
---
# Summary
- Rueckfall auf den oeffentlichen Symbol-Dienst beim Ausliefern (`getIconBytes`), nur fuer oeffentliche Seiten.
- 5 neue Tests (Dienst-URL, interne Seite fragt nicht, 404 wirft, Service nutzt Rueckfall / nicht bei Erfolg); 111/111 Favoriten-Tests, tsc, biome-Stand unveraendert.
- Lokal im Browser nachgewiesen: Favorit https://www.hosteurope.de/ zeigt das gruene H-Logo (32x32 ueber /api-proxy/favorites/<id>/icon).
+43
View File
@@ -4,17 +4,60 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht ## Unveröffentlicht
## 1.9.1 – 2026-10-01
### Behoben
- Desktop-App: Favoriten und andere Links, die sich in einem neuen Fenster öffnen (etwa „In neuem Tab öffnen“ oder Quellen im Ausschreibungs-Radar), öffnen sich jetzt in Ihrem normalen Browser. Bisher passierte beim Klick in der Desktop-App nichts.
- Erinnerungen: Beim Schreiben der Beschreibung springt der Cursor nicht mehr in die Titelzeile zurück. Bisher passierte das alle paar Sekunden, weil sich die Kachel regelmäßig neu aufbaut.
- Favoriten: Auch Seiten, die ihr Logo erst beim Laden im Browser setzen (etwa Host Europe), zeigen jetzt ihr Logo statt nur des Anfangsbuchstabens. Findet Tessera auf der Seite selbst kein Logo, fragt es bei öffentlichen Adressen einen Logo-Dienst; interne Adressen werden dabei nie weitergegeben. Das gilt auch für bereits angelegte Favoriten.
## 1.9.0 – 2026-09-30
### Neu
- Verwaltung: Eigene Vorlage für die Willkommensmail unter Administrator → Willkommensmail. Betreff, Überschrift, Einleitung, Abschluss und der Anmeldehinweis (getrennt für Verzeichnis- und lokale Konten) lassen sich anpassen, mit Platzhaltern wie {{vorname}}, {{benutzername}}, {{adresse}} oder {{firma}} (eine Tabelle auf der Seite erklärt sie). Die Seite zeigt eine Live-Vorschau der echten Mail und schickt auf Wunsch eine Testmail an Ihre Adresse; „Auf Standard zurücksetzen“ stellt die mitgelieferten Texte wieder her. Logo, Zugangsdaten und Knöpfe fügt Tessera immer selbst ein.
- Benutzerverwaltung: Willkommensmail. Über das Briefsymbol in der Benutzerliste schicken Sie einem Benutzer eine gestaltete Willkommensmail mit Tessera-Logo, Adresse, Benutzername und einem Knopf „Zu Tessera“. Konten aus dem Verzeichnis erhalten den Hinweis auf ihr Windows-Passwort, lokale Konten einen Link „Passwort festlegen“ (7 Tage gültig) – ein Passwort steht nie in der Mail. Die Liste zeigt, wann die Mail zuletzt ging.
- Benutzerverwaltung: Neue Spalte „Letzte Anmeldung“.
### Geändert
- Benutzerverwaltung: Die Aktionen je Zeile sind jetzt Symbole (Willkommensmail, Details, Bearbeiten, Löschen), damit die Liste ohne seitliches Scrollen passt.
### Behoben
- Anmeldung: Wer schon angemeldet ist und die Anmeldeseite aufruft, landet jetzt direkt auf dem Dashboard.
- Willkommensmail: Logo und Schriftzug erscheinen jetzt in jedem Mailprogramm. Bisher steckten sie in einem Bild; zeigte Outlook es nicht an, blieb nur ein großer schwarzer Kasten. Die Welle darunter ist nur noch ein schmaler Streifen; zeigt ein Mailprogramm sie nicht an (etwa Outlook im Browser), bleibt keine weiße Lücke mehr.
- Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden.
## 1.8.0 – 2026-09-30
### Neu ### Neu
- Dashboard: Neues Widget „Erinnerungen“. Sie legen eine Erinnerung mit Datum, Uhrzeit, Titel und Beschreibung an, und Tessera meldet sich genau zur gewählten Zeit: im Browser mit einer Benachrichtigung (der Browser fragt dafür einmal um Erlaubnis, und zwar beim ersten Anlegen), in der Desktop-App mit einer Windows-Benachrichtigung – auch wenn das Fenster im Infobereich liegt. Wenn Sie möchten, schickt Tessera zusätzlich eine E-Mail an Ihre Adresse, auch dann, wenn Tessera gerade nirgends geöffnet ist. Eine fällige Erinnerung bleibt im Widget hervorgehoben stehen, bis Sie „Erledigt“ wählen oder mit „Später erinnern“ verschieben – auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit; dann meldet sich Tessera (und bei Bedarf die E-Mail) noch einmal. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre. Für die Desktop-Benachrichtigungen braucht die Desktop-App ihre neue Version, die Sie über „Auf Version … aktualisieren“ im Menü des Tessera-Symbols erhalten; Widget und E-Mail funktionieren auch mit der bisherigen Version. - Dashboard: Neues Widget „Erinnerungen“. Sie legen eine Erinnerung mit Datum, Uhrzeit, Titel und Beschreibung an, und Tessera meldet sich genau zur gewählten Zeit: im Browser mit einer Benachrichtigung (der Browser fragt dafür einmal um Erlaubnis, und zwar beim ersten Anlegen), in der Desktop-App mit einer Windows-Benachrichtigung – auch wenn das Fenster im Infobereich liegt. Wenn Sie möchten, schickt Tessera zusätzlich eine E-Mail an Ihre Adresse, auch dann, wenn Tessera gerade nirgends geöffnet ist. Eine fällige Erinnerung bleibt im Widget hervorgehoben stehen, bis Sie „Erledigt“ wählen oder mit „Später erinnern“ verschieben – auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit; dann meldet sich Tessera (und bei Bedarf die E-Mail) noch einmal. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre. Für die Desktop-Benachrichtigungen braucht die Desktop-App ihre neue Version, die Sie über „Auf Version … aktualisieren“ im Menü des Tessera-Symbols erhalten; Widget und E-Mail funktionieren auch mit der bisherigen Version.
### Geändert ### Geändert
- Eigene Module: Einmal geöffnete Seiten bleiben im Hintergrund offen. Wechseln Sie zurück, ist die Seite sofort da – im selben Zustand, ohne neu zu laden. Tessera hält die fünf zuletzt benutzten offen; beim Abmelden werden sie geschlossen.
- Dashboard, Favoriten: In der Kachelansicht stehen die Symbole enger beieinander; der Abstand zwischen ihnen ist etwa halb so groß, in eine Zeile passen mehr Favoriten. Passt ein Name nicht in eine Zeile, wird er kleiner geschrieben und auf zwei Zeilen umbrochen.
- Dashboard: Es sieht jetzt auf jedem Bildschirm gleich aus. Tessera merkt sich die Fläche des Bildschirms, an dem Sie ein Dashboard zuerst öffnen, und zeigt es auf anderen Bildschirmen maßstäblich verkleinert oder vergrößert – samt Schrift und ohne Scrollen. Ist ein Dashboard länger als der Bildschirm, wird es so weit verkleinert, dass es ganz hineinpasst. Auf dem Handy bleibt es bei der bisherigen Anordnung untereinander.
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts. - Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
### Behoben ### Behoben
- Eigene Module: Lässt sich ein Eintrag nicht laden, sagt Tessera das jetzt, statt „nicht gefunden“ zu melden. Fehlende Berechtigung und ungültige Angaben werden beim Speichern und Löschen eigens genannt.
- Seitenleiste: Eingeklappt stehen eigene Module jetzt bei ihrer Kategorie, in derselben Reihenfolge wie ausgeklappt.
- Erinnerungen: Ohne Browser-Speicher (etwa im privaten Fenster) kam dieselbe Benachrichtigung alle 10 Sekunden – jetzt nur einmal.
- Erinnerungen: Nach einer Änderung von Datum oder Uhrzeit kommt die E-Mail zuverlässig zur neuen Zeit. Deaktivierte Benutzer bekommen keine Erinnerungs-E-Mails mehr.
- Erinnerungen: „Später erinnern“ zeigt „Heute um …“, wenn die Uhrzeit heute noch kommt, statt fälschlich „Morgen um …“. Speichern ohne Zeitänderung verschiebt die Fälligkeit nicht mehr um Sekunden.
- Dashboard: Wird ein Bild gelöscht, das als Hintergrund gewählt war, gilt wieder „kein Hintergrund“.
- Dashboard: Ein noch offener Tab mit älterer Tessera-Version kann die Anordnung nicht mehr verziehen; er bittet stattdessen, die Seite neu zu laden.
- Desktop-App: Während ein Update installiert wird, bietet das Menü kein zweites mehr an. Nach einer fehlgeschlagenen Update-Prüfung genügt wieder ein Klick zum Installieren.
- Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet. - Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet.
- Dashboard, Favoriten: Eine neu eingetragene Logo-Adresse wird jetzt sofort angezeigt. Bisher blieb ein früher hochgeladenes eigenes Symbol stehen und verdeckte die neue Adresse; jetzt ersetzt die neue Adresse es.
- Dashboard, Favoriten: Eine Logo-Adresse lässt sich jetzt auch speichern, wenn Tessera das Bild selbst nicht laden kann – etwa bei Seiten im internen Netz, die nur Ihrem Browser das Symbol geben. Die Kachel lädt das Bild dann direkt in Ihrem Browser. Auch die automatische Erkennung findet das Symbol solcher Seiten jetzt eher, statt auf ein Ersatzsymbol zurückzufallen.
## 1.7.0 – 2026-09-29 ## 1.7.0 – 2026-09-29
+3
View File
@@ -47,6 +47,9 @@ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
COPY --from=builder /app/packages/shared/src ./packages/shared/src COPY --from=builder /app/packages/shared/src ./packages/shared/src
COPY apps/api/scripts ./apps/api/scripts COPY apps/api/scripts ./apps/api/scripts
# Kopfbild der Willkommensmail (MailService.loadWelcomeHeaderPng liest
# apps/api/assets/mail/welcome-header.png relativ zu dist/mail/).
COPY apps/api/assets ./apps/api/assets
# Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete + # Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete +
# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur # manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur
# lesend zur Laufzeit -- kein chown noetig. # lesend zur Laufzeit -- kein chown noetig.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="80" viewBox="0 0 1200 80">
<!--
Wellenstreifen unter dem Kopf der Tessera-Systemmails (Willkommensmail).
Quelle des PNG daneben (welcome-header.png, 1200x80, angezeigt 600x40);
erzeugt mit `node apps/api/scripts/render-mail-header.mjs`.
Seit quick-260930 (Rueckmeldung des Nutzers: in Outlook "ein riesiger
schwarzer Fleck, kein Logo") steckt KEIN Logo und KEIN Text mehr im Bild:
Bildmarke und Schriftzug stehen als HTML im Mailkopf und erscheinen immer.
Dieses Bild ist nur noch Schmuck: oben die Kopffarbe, darunter die
Duenen-Wellen des Dashboard-Hintergrunds (dunkle Fassung,
apps/web/src/lib/dashboard-background.ts) mit der feinen gelben Linie,
unten laeuft es ins Weiss der Karte aus. Zeigt ein Mailprogramm das Bild
nicht (Outlook mit gesperrten Bildern), bleibt dort die dunkle Kopffarbe
der Zelle stehen — der Kopf wirkt nur etwas hoeher, keine weisse Luecke.
Seit quick-260930 (zweite Rueckmeldung) 80 statt 112 px hoch (y-Werte
x 80/112), in der Mail 40 statt 56 px.
-->
<rect width="1200" height="80" fill="#ffffff"/>
<!-- Flaechen von unten nach oben uebereinander, jede von oben bis zu ihrer
Wellenlinie: so teilen sich benachbarte Baender dieselbe Kante, ohne
Luecken dazwischen. -->
<path d="M0 0 V65.7 C220 55.7 420 72.9 600 68.6 S1000 54.3 1200 61.4 V0 Z" fill="#d9dce0"/>
<path d="M0 0 V50 C250 35.7 420 61.4 640 54.3 S1040 35.7 1200 45.7 V0 Z" fill="#2c3036"/>
<path d="M0 0 V31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6 V0 Z" fill="#1a1c20"/>
<path d="M0 31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6" fill="none" stroke="#ffed00" stroke-opacity="0.75" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,15 @@
-- Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer).
--
-- Merkt pro Benutzer, wann zuletzt eine Willkommensmail verschickt wurde,
-- damit die Liste "Willkommensmail gesendet am …" zeigen kann. Gesetzt nur
-- ueber POST /users/:id/welcome-mail nach erfolgreichem Versand; erneutes
-- Senden ueberschreibt den Wert. NULL = nie gesendet. Kein Standardwert,
-- kein Backfill.
--
-- Keine neue Regel noetig: die Spalte liegt in "User", dessen
-- tenant_isolation_policy die ganze Zeile schuetzt. Die Anmelde-Funktionen
-- auth_lookup_* liefern eine feste Spaltenliste (RETURNS TABLE) und bleiben
-- unberuehrt.
-- AlterTable
ALTER TABLE "User" ADD COLUMN "welcomeMailSentAt" TIMESTAMP(3);
@@ -0,0 +1,52 @@
-- Willkommensmail: eigene Vorlage je Mandant (Administrator → Willkommensmail).
--
-- Zweck: neue Tabelle "WelcomeMailTemplate". Ein Administrator kann Betreff,
-- Ueberschrift, Einleitung und Abschlusstext der Willkommensmail anpassen
-- (mit Platzhaltern wie {{name}}). Hoechstens EINE Zeile je Mandant
-- ("tenantId" eindeutig); "Auf Standard zuruecksetzen" loescht die Zeile, dann
-- gelten wieder die Standardtexte aus dem Code. Die festen Bausteine der Mail
-- (Kopf, Zugangsdaten, Anmeldehinweis, Knoepfe, Fusszeile) stehen NICHT in
-- der Tabelle. "updatedBy" haelt den Benutzernamen des letzten Bearbeiters
-- als reinen Anzeigetext (keine Relation).
--
-- Von Hand geschrieben (Vorbild 20260929120000_custom_module).
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
-- Vorlage ist Verwaltungsdatum des Mandanten, nicht persoenliches Datum eines
-- einzelnen Benutzers.
--
-- BEWUSST KEINE `system_read_policy`: gelesen wird nur beim Versand einer
-- Willkommensmail, und zwar gebunden an den Mandanten des Zielbenutzers
-- (`forTenant(prisma, tenantId)`); es gibt keinen Hintergrunddienst, der die
-- Vorlagen ueber alle Mandanten liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TABLE "WelcomeMailTemplate" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"heading" TEXT NOT NULL,
"intro" TEXT NOT NULL,
"closing" TEXT NOT NULL,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WelcomeMailTemplate_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "WelcomeMailTemplate_tenantId_key" ON "WelcomeMailTemplate"("tenantId");
ALTER TABLE "WelcomeMailTemplate" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "WelcomeMailTemplate" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "WelcomeMailTemplate"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,18 @@
-- Willkommensmail: Anmeldehinweis je Kontoart als Teil der eigenen Vorlage.
--
-- Zweck: zwei neue Spalten in "WelcomeMailTemplate" —
-- "loginHintDirectory" (Hinweis fuer verzeichnisgefuehrte Konten, Standard
-- "Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten
-- Windows-Passwort an.") und "loginHintLocal" (Hinweis vor dem Knopf
-- "Passwort festlegen" fuer lokale Konten).
--
-- Bewusst NULLABLE ohne Default: bestehende Vorlagen behalten NULL, und
-- WelcomeMailTemplateService setzt dafuer den Standardtext aus
-- @tessera/shared ein. So steht der Standardtext nur an EINER Stelle im
-- Code und nicht zusaetzlich in der Datenbank.
--
-- Zeilenschutz: unveraendert (tenant_isolation_policy der Tabelle gilt fuer
-- die neuen Spalten mit).
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintDirectory" TEXT;
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintLocal" TEXT;
+24
View File
@@ -50,6 +50,9 @@ model User {
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte // sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund" // Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
dashboardBackground Json? dashboardBackground Json?
// Willkommensmail aus der Benutzerverwaltung: Zeitpunkt des letzten
// Versands; null = nie gesendet
welcomeMailSentAt DateTime?
passwordResetTokens PasswordResetToken[] passwordResetTokens PasswordResetToken[]
groupMemberships GroupMembership[] groupMemberships GroupMembership[]
moduleGrants ModuleGrant[] moduleGrants ModuleGrant[]
@@ -764,3 +767,24 @@ model Reminder {
@@index([tenantId, userId, dueAt]) @@index([tenantId, userId, dueAt])
@@index([dueAt]) @@index([dueAt])
} }
// Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
// hoechstens eine je Mandant; fehlt sie, gelten die Standardtexte aus
// @tessera/shared (DEFAULT_WELCOME_MAIL_TEXTS). Nur die sechs Texte —
// Kopf, Zugangsdaten und Knoepfe bleiben fest im Code.
model WelcomeMailTemplate {
id String @id @default(uuid())
tenantId String @unique
subject String
heading String
intro String
// Anmeldehinweise je Kontoart (Migration 20260930170000); NULL in einer
// aelteren Vorlage = Standardtext aus @tessera/shared
loginHintDirectory String?
loginHintLocal String?
closing String
// Benutzername des Administrators, der zuletzt gespeichert hat (Anzeige)
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
/**
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
* (apps/api/assets/mail/welcome-header.png, 1200x80 fuer hochaufloesende
* Bildschirme, in der Mail 600x40 angezeigt) aus der daneben liegenden
* Quelle welcome-header.svg.
*
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
* Darstellung) und viele Webmailer zeigen weder SVG noch Hintergrundbilder
* zuverlaessig an. Das PNG wird als CID-Anhang eingebettet (MailService),
* die Mail laedt also nichts von aussen nach.
*
* Das PNG liegt fertig im Repo; dieses Skript ist nur noetig, wenn die SVG
* geaendert wird. Kein neues Paket: `sharp` ist ueber Next.js (apps/web)
* bereits installiert und wird von dort aufgeloest. Der Schriftzug wird
* mit den Systemschriften des erzeugenden Rechners gesetzt (fontconfig:
* Segoe UI, Inter, Noto Sans, DejaVu Sans — die erste vorhandene gewinnt).
*
* Aufruf (Repo-Wurzel): node apps/api/scripts/render-mail-header.mjs
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const assetDir = join(here, '..', 'assets', 'mail');
const webDir = join(here, '..', '..', 'web');
const require = createRequire(import.meta.url);
const nextPkg = require.resolve('next/package.json', { paths: [webDir] });
const sharp = createRequire(nextPkg)('sharp');
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
const png = await sharp(svg, { density: 72 })
.resize(1200, 80)
.png({ compressionLevel: 9, palette: false })
.toBuffer();
writeFileSync(join(assetDir, 'welcome-header.png'), png);
console.log(`welcome-header.png geschrieben (${png.length} Bytes)`);
+2 -1
View File
@@ -17,6 +17,7 @@ import { LdapService } from '../ldap/ldap.service';
import { MailService } from '../mail/mail.service'; import { MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { PASSWORD_RESET_TOKEN_TTL_MS } from './password-reset-token';
import type { JwtPayload, LoginUser } from './types/auth-user'; import type { JwtPayload, LoginUser } from './types/auth-user';
/** /**
@@ -239,7 +240,7 @@ export class AuthService {
// Generate a unique reset token // Generate a unique reset token
const token = randomUUID(); const token = randomUUID();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_TTL_MS); // 1 hour
// Create the reset token record — mandantengebunden, sobald der // Create the reset token record — mandantengebunden, sobald der
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1). // Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
@@ -1,9 +1,13 @@
import { ForbiddenException } from '@nestjs/common'; import { ForbiddenException } from '@nestjs/common';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { JwtStrategy } from '../strategies/jwt.strategy'; import { JwtStrategy } from '../strategies/jwt.strategy';
import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor'; import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor';
vi.mock('../../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
/** /**
* ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste * ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste
* und die Teilstring-Falle (260921-fi3, Aufgabe 1, Befund 1/D-01/D-02/D-03). * und die Teilstring-Falle (260921-fi3, Aufgabe 1, Befund 1/D-01/D-02/D-03).
@@ -30,7 +34,19 @@ const nextHandle = { handle: () => of('ok') } as any;
describe('ForcePasswordChangeInterceptor.intercept', () => { describe('ForcePasswordChangeInterceptor.intercept', () => {
it('Nahttest (D-03): JwtStrategy.validate() -> request.user -> GET /users wirft ForbiddenException — scheitert gegen den heutigen Quelltext, weil das Feld auf dem Weg verloren geht', async () => { it('Nahttest (D-03): JwtStrategy.validate() -> request.user -> GET /users wirft ForbiddenException — scheitert gegen den heutigen Quelltext, weil das Feld auf dem Weg verloren geht', async () => {
const strategy = new JwtStrategy({ get: () => 'test-secret' } as any); const prisma = {
user: {
findUnique: async () => ({
id: 'u1',
username: 'admin',
role: 'ADMIN',
tenantId: 't1',
isActive: true,
mustChangePassword: true,
}),
},
} as any;
const strategy = new JwtStrategy({ get: () => 'test-secret' } as any, prisma);
const user = await strategy.validate({ const user = await strategy.validate({
sub: 'u1', sub: 'u1',
username: 'admin', username: 'admin',
+18
View File
@@ -0,0 +1,18 @@
/**
* Gueltigkeit eines Kennwort-Tokens (`PasswordResetToken`, T-02-13): eine
* Stunde, einmal verwendbar. Gemeinsam genutzt vom Weg "Passwort
* vergessen" (`AuthService.requestPasswordReset`) und vom Link "Passwort
* festlegen" der Willkommensmail (`WelcomeMailService`) — beide legen
* dieselbe Art Token an und fuehren auf dieselbe Seite
* `/reset-password/<token>`, deshalb gilt dieselbe Frist.
*/
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
/**
* Gueltigkeit des Links "Passwort festlegen" in der Willkommensmail: 7 Tage.
* Neue Mitarbeiter lesen die Mail oft erst Tage spaeter; eine Stunde wie bei
* "Passwort vergessen" (dort fordert der Benutzer den Link selbst an und
* nutzt ihn sofort) waere hier fast immer abgelaufen. Einmal verwendbar
* bleibt der Link trotzdem, und jede neue Willkommensmail legt einen neuen an.
*/
export const WELCOME_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -1,9 +1,16 @@
import { describe, expect, it } from 'vitest'; import { UnauthorizedException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { forTenant } from '../../prisma/prisma-tenant.extension';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
vi.mock('../../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
/** /**
* JwtStrategy.validate — pinnt die Durchreichung von mustChangePassword * JwtStrategy.validate — seit quick-260930 kommen Rolle, Aktiv-Status und
* (260921-fi3, Aufgabe 1, Befund 1/D-01). Direkte Konstruktion ohne * Kennwort-Pflicht bei jeder Anfrage aus der Datenbank, nicht aus dem Token
* (Rollenaenderung/Deaktivierung wirkt sofort). Direkte Konstruktion ohne
* Nest-Testmodul, Muster aus `../../tenant/tenant.guard.spec.ts`. * Nest-Testmodul, Muster aus `../../tenant/tenant.guard.spec.ts`.
*/ */
@@ -11,51 +18,77 @@ function makeConfigService() {
return { get: () => 'test-secret' } as any; return { get: () => 'test-secret' } as any;
} }
describe('JwtStrategy.validate', () => { type Row = {
it('Anspruch mustChangePassword=true im Token: liefert request.user.mustChangePassword === true', async () => { id: string;
const strategy = new JwtStrategy(makeConfigService()); username: string;
role: string;
tenantId: string;
isActive: boolean;
mustChangePassword: boolean;
} | null;
const result = await strategy.validate({ function makePrisma(row: Row) {
return { user: { findUnique: vi.fn(async () => row) } } as any;
}
const payload = {
sub: 'u1', sub: 'u1',
username: 'admin', username: 'kschaller',
role: 'ADMIN', role: 'SUPER_ADMIN' as const,
tenantId: 't1', tenantId: 't1',
mustChangePassword: true,
});
expect(result.mustChangePassword).toBe(true);
});
it('Anspruch fehlt im Token (Alt-Sitzung, vor dieser Aenderung ausgestellt): liefert false statt undefined', async () => {
const strategy = new JwtStrategy(makeConfigService());
const result = await strategy.validate({
sub: 'u1',
username: 'admin',
role: 'ADMIN',
tenantId: 't1',
});
expect(result.mustChangePassword).toBe(false);
});
it('id, username, role und tenantId werden unveraendert wie bisher durchgereicht', async () => {
const strategy = new JwtStrategy(makeConfigService());
const result = await strategy.validate({
sub: 'u1',
username: 'nutzer1',
role: 'USER',
tenantId: 't2',
mustChangePassword: false, mustChangePassword: false,
}); };
const dbRow = {
id: 'u1',
username: 'kschaller',
role: 'ADMIN',
tenantId: 't1',
isActive: true,
mustChangePassword: false,
};
describe('JwtStrategy.validate', () => {
it('Rolle kommt aus der Datenbank, nicht aus dem Token (herabgestufter Super-Admin ist sofort Admin)', async () => {
const prisma = makePrisma(dbRow);
const strategy = new JwtStrategy(makeConfigService(), prisma);
const result = await strategy.validate(payload);
expect(result).toEqual({ expect(result).toEqual({
id: 'u1', id: 'u1',
username: 'nutzer1', username: 'kschaller',
role: 'USER', role: 'ADMIN',
tenantId: 't2', tenantId: 't1',
mustChangePassword: false, mustChangePassword: false,
}); });
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
expect(prisma.user.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'u1' } }),
);
});
it('deaktiviertes Konto: 401, auch mit gueltigem Token', async () => {
const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, isActive: false }));
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('geloeschtes Konto: 401', async () => {
const strategy = new JwtStrategy(makeConfigService(), makePrisma(null));
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('Konto gehoert nicht (mehr) zum Mandanten aus dem Token: 401', async () => {
const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, tenantId: 't2' }));
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('Kennwort-Pflicht kommt aus der Datenbank (vom Administrator nachtraeglich gesetzt)', async () => {
const strategy = new JwtStrategy(
makeConfigService(),
makePrisma({ ...dbRow, mustChangePassword: true }),
);
const result = await strategy.validate(payload);
expect(result.mustChangePassword).toBe(true);
}); });
}); });
+45 -10
View File
@@ -1,8 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt'; import { Strategy } from 'passport-jwt';
import { Request } from 'express'; import { Request } from 'express';
import { PrismaService } from '../../prisma/prisma.service';
import { forTenant } from '../../prisma/prisma-tenant.extension';
import type { AuthUser, JwtPayload } from '../types/auth-user'; import type { AuthUser, JwtPayload } from '../types/auth-user';
/** /**
@@ -17,7 +19,10 @@ function cookieExtractor(req: Request): string | null {
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) { constructor(
configService: ConfigService,
private readonly prisma: PrismaService,
) {
super({ super({
jwtFromRequest: cookieExtractor, jwtFromRequest: cookieExtractor,
ignoreExpiration: false, ignoreExpiration: false,
@@ -25,16 +30,46 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}); });
} }
/**
* Das Token beweist nur, WER angemeldet ist — Rolle, Aktiv-Status und
* Kennwort-Pflicht kommen bei JEDER Anfrage frisch aus der Datenbank
* (quick-260930, Befund des Nutzers): vorher galt die Rolle aus dem
* 30-Tage-Token. Ein herabgestufter Administrator behielt bis zum Ablauf
* seine alten Rechte, ein deaktiviertes oder geloeschtes Konto (etwa per
* LDAP-Abgleich beim Austritt) arbeitete mit seiner Sitzung weiter, und
* Oberflaeche (liest die Rolle ueber /auth/me aus der Datenbank) und API
* (las sie aus dem Token) sahen verschiedene Rollen — die Benutzerliste
* scheiterte dann im Client.
*
* Ein Primaerschluessel-Lesezugriff je Anfrage, gebunden an den Mandanten
* aus dem Token (`forTenant`); gehoert das Konto nicht (mehr) zu diesem
* Mandanten, fehlt es oder ist es deaktiviert, gilt die Sitzung als
* ungueltig (401) — die Web-Oberflaeche leitet dann zur Anmeldung.
*/
async validate(payload: JwtPayload): Promise<AuthUser> { async validate(payload: JwtPayload): Promise<AuthUser> {
const tenantPrisma = forTenant(this.prisma, payload.tenantId);
const user = await tenantPrisma.user.findUnique({
where: { id: payload.sub },
select: {
id: true,
username: true,
role: true,
tenantId: true,
isActive: true,
mustChangePassword: true,
},
});
if (!user || !user.isActive || user.tenantId !== payload.tenantId) {
throw new UnauthorizedException();
}
return { return {
id: payload.sub, id: user.id,
username: payload.username, username: user.username,
role: payload.role, role: user.role as AuthUser['role'],
tenantId: payload.tenantId, tenantId: user.tenantId,
// Ein vor dieser Aenderung ausgestelltes Token traegt diesen Anspruch mustChangePassword: user.mustChangePassword === true,
// nicht; der strenge Vergleich ergibt dann false, laufende Sitzungen
// verhalten sich unveraendert (260921-fi3, D-01 — keine Aussperrwelle).
mustChangePassword: payload.mustChangePassword === true,
}; };
} }
} }
@@ -64,4 +64,12 @@ describe('UpdateCustomModuleDto', () => {
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category'); expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name'); expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
}); });
it.each([
'name',
'url',
'category',
])('lehnt %s: null ab statt es durchzulassen', async (field) => {
expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field);
});
}); });
@@ -78,7 +78,11 @@ export class CreateCustomModuleDto {
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist, * `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld * aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
* dank `whitelist: true`). * dank `whitelist: true`).
* `skipNullProperties: false`: fehlende Felder bleiben unveraendert, ein
* ausdrueckliches `null` wird aber geprueft und damit abgelehnt (400) — sonst
* liefe `{"name": null}` bis in die Datenbank und endete als 500.
*/ */
export class UpdateCustomModuleDto extends PartialType( export class UpdateCustomModuleDto extends PartialType(
OmitType(CreateCustomModuleDto, ['shared'] as const), OmitType(CreateCustomModuleDto, ['shared'] as const),
{ skipNullProperties: false },
) {} ) {}
@@ -21,7 +21,11 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
}), }),
})); }));
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardImagesService } from './dashboard-images.service'; import { DashboardImagesService } from './dashboard-images.service';
@@ -73,11 +77,21 @@ interface BoundCall {
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>; type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
interface UserRow {
id: string;
dashboardBackground: unknown;
}
interface FakePrisma { interface FakePrisma {
dashboardImage: ModelMethods; dashboardImage: ModelMethods;
user: ModelMethods;
__rows: ImageRow[]; __rows: ImageRow[];
__users: UserRow[];
__boundCallLog: BoundCall[]; __boundCallLog: BoundCall[];
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods }; __makeBoundClient(
tenantId: string,
userId?: string,
): { dashboardImage: ModelMethods; user: ModelMethods };
} }
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
@@ -147,8 +161,28 @@ function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
return out; return out;
} }
function makeFakePrisma(rows: ImageRow[] = []): FakePrisma { function makeFakePrisma(rows: ImageRow[] = [], users: UserRow[] = []): FakePrisma {
const boundCallLog: BoundCall[] = []; const boundCallLog: BoundCall[] = [];
// quick-260930: Hintergrund-Wahl (`User.dashboardBackground`) — bedingtes
// updateMany ueber den JSON-Pfad `imageId`, wie Prisma es auf PostgreSQL filtert.
const user: ModelMethods = {
updateMany: vi.fn(async (raw: unknown) => {
const args = raw as {
where: { id: string; dashboardBackground: { path: string[]; equals: unknown } };
data: { dashboardBackground: unknown };
};
const [key] = args.where.dashboardBackground.path;
let count = 0;
for (const u of users) {
const bg = u.dashboardBackground as Record<string, unknown> | null;
if (u.id !== args.where.id || !bg || bg[key] !== args.where.dashboardBackground.equals)
continue;
u.dashboardBackground = args.data.dashboardBackground;
count++;
}
return { count };
}),
};
const dashboardImage: ModelMethods = { const dashboardImage: ModelMethods = {
findMany: vi.fn(async (raw: unknown) => { findMany: vi.fn(async (raw: unknown) => {
const args = raw as { const args = raw as {
@@ -168,11 +202,17 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
}), }),
count: vi.fn(async (raw: unknown) => { count: vi.fn(async (raw: unknown) => {
const args = raw as { where: { tenantId: string; userId: string } }; const args = raw as { where: { tenantId: string; userId: string } };
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length; return rows.filter(
(r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId,
).length;
}), }),
create: vi.fn(async (raw: unknown) => { create: vi.fn(async (raw: unknown) => {
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> }; const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') }); const created = makeRow({
id: `new-${rows.length + 1}`,
...args.data,
createdAt: new Date('2026-02-02'),
});
rows.push(created); rows.push(created);
return pick(created, args.select); return pick(created, args.select);
}), }),
@@ -196,20 +236,29 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
}), }),
}; };
function wrap(tenantId: string, userId?: string) { function wrapModel(model: string, methods: ModelMethods, tenantId: string, userId?: string) {
const wrapped: ModelMethods = {}; const wrapped: ModelMethods = {};
for (const method of Object.keys(dashboardImage)) { for (const method of Object.keys(methods)) {
wrapped[method] = async (...args: unknown[]) => { wrapped[method] = async (...args: unknown[]) => {
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method }); boundCallLog.push({ tenantId, userId, model, method });
return dashboardImage[method](...args); return methods[method](...args);
}; };
} }
return { dashboardImage: wrapped }; return wrapped;
}
function wrap(tenantId: string, userId?: string) {
return {
dashboardImage: wrapModel('dashboardImage', dashboardImage, tenantId, userId),
user: wrapModel('user', user, tenantId, userId),
};
} }
const fake: FakePrisma = { const fake: FakePrisma = {
dashboardImage, dashboardImage,
user,
__rows: rows, __rows: rows,
__users: users,
__boundCallLog: boundCallLog, __boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string, userId?: string) { __makeBoundClient(tenantId: string, userId?: string) {
return wrap(tenantId, userId); return wrap(tenantId, userId);
@@ -253,9 +302,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
const result = await makeService(prisma).list('user-1', 'tenant-1'); const result = await makeService(prisma).list('user-1', 'tenant-1');
expect(result.map((r) => r.id)).toEqual(['a', 'b']); expect(result.map((r) => r.id)).toEqual(['a', 'b']);
for (const r of result) { for (const r of result) {
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']); expect(Object.keys(r).sort()).toEqual([
'createdAt',
'id',
'mimeType',
'originalName',
'size',
]);
} }
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> }; const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
select: Record<string, boolean>;
};
expect(call.select.data).toBeUndefined(); expect(call.select.data).toBeUndefined();
expect(call.select.storagePath).toBeUndefined(); expect(call.select.storagePath).toBeUndefined();
}); });
@@ -274,14 +331,22 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
expect(result.mimeType).toBe('image/png'); expect(result.mimeType).toBe('image/png');
expect(result.originalName).toBe('irgendwas.txt'); expect(result.originalName).toBe('irgendwas.txt');
expect(result.size).toBe(PNG.length); expect(result.size).toBe(PNG.length);
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']); expect(Object.keys(result).sort()).toEqual([
'createdAt',
'id',
'mimeType',
'originalName',
'size',
]);
expect(prisma.__rows[0].userId).toBe('user-1'); expect(prisma.__rows[0].userId).toBe('user-1');
expect(prisma.__rows[0].tenantId).toBe('tenant-1'); expect(prisma.__rows[0].tenantId).toBe('tenant-1');
}); });
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => { it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow( await expect(
makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png')),
).rejects.toThrow(
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'), new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
); );
expect(prisma.dashboardImage.create).not.toHaveBeenCalled(); expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
@@ -304,7 +369,9 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
}); });
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => { it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' })); const foreign = Array.from({ length: 30 }, (_, i) =>
makeRow({ id: `f${i}`, userId: 'user-2' }),
);
const prisma = makeFakePrisma(foreign); const prisma = makeFakePrisma(foreign);
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({ await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
mimeType: 'image/png', mimeType: 'image/png',
@@ -321,14 +388,20 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => { it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]); const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
}); });
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => { it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]); const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
const service = makeService(prisma); const service = makeService(prisma);
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); NotFoundException,
);
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
}); });
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => { it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
@@ -347,11 +420,52 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
const service = makeService(prisma); const service = makeService(prisma);
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' }); await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']); expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); NotFoundException,
);
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
expect(prisma.__rows).toHaveLength(2); expect(prisma.__rows).toHaveLength(2);
}); });
it('Test 11b (quick-260930): war das geloeschte Bild der Dashboard-Hintergrund, steht die Wahl danach auf „kein Hintergrund“ — andere Wahlen und andere Benutzer bleiben', async () => {
const users: UserRow[] = [
{ id: 'user-1', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
{ id: 'user-2', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
];
const prisma = makeFakePrisma(
[makeStoredRow({ id: 'eigen' }), makeStoredRow({ id: 'zweites' })],
users,
);
const service = makeService(prisma);
await service.remove('eigen', 'user-1', 'tenant-1');
expect(users[0].dashboardBackground).toEqual({ kind: 'none' });
// nur die eigene Zeile
expect(users[1].dashboardBackground).toEqual({ kind: 'image', imageId: 'eigen' });
const call = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
expect(call).toEqual({
where: { id: 'user-1', dashboardBackground: { path: ['imageId'], equals: 'eigen' } },
data: { dashboardBackground: { kind: 'none' } },
});
// Ein anderes Bild loeschen laesst eine andere Wahl stehen.
users[0].dashboardBackground = { kind: 'preset', id: 'mist' };
await service.remove('zweites', 'user-1', 'tenant-1');
expect(users[0].dashboardBackground).toEqual({ kind: 'preset', id: 'mist' });
});
it('Test 11c (quick-260930): scheitert das Zuruecksetzen der Wahl, ist das Bild trotzdem geloescht (kein Fehler nach aussen)', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'eigen' })]);
vi.mocked(prisma.user.updateMany).mockRejectedValueOnce(new Error('db weg'));
await expect(makeService(prisma).remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({
id: 'eigen',
});
expect(prisma.__rows).toHaveLength(0);
});
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => { it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
const prisma = makeFakePrisma([]); const prisma = makeFakePrisma([]);
const service = makeService(prisma); const service = makeService(prisma);
@@ -370,7 +484,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich // Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n). // MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
const methods = prisma.__boundCallLog.map((c) => c.method); const methods = prisma.__boundCallLog.map((c) => c.method);
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']); // quick-260930: `remove` setzt zusaetzlich die Hintergrund-Wahl zurueck (user.updateMany).
expect(methods).toEqual([
'findMany',
'count',
'create',
'findUnique',
'findUnique',
'delete',
'updateMany',
]);
expect(prisma.__boundCallLog.at(-1)?.model).toBe('user');
expect(vi.mocked(forSystem)).not.toHaveBeenCalled(); expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
for (const c of prisma.__boundCallLog) { for (const c of prisma.__boundCallLog) {
expect(c.tenantId).toBe('tenant-1'); expect(c.tenantId).toBe('tenant-1');
@@ -387,14 +511,20 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
const onDisk = storedFile('user-1', result.id); const onDisk = storedFile('user-1', result.id);
expect(fs.existsSync(onDisk)).toBe(true); expect(fs.existsSync(onDisk)).toBe(true);
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true); expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`); expect(prisma.__rows[0].storagePath).toBe(
`user-files/dashboard-images/user-1/${result.id}.png`,
);
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2), // Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in // die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
// die Zeile. // die Zeile.
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> }; const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as {
data: Record<string, unknown>;
};
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`); expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
expect(createArgs.data.id).toBe(result.id); expect(createArgs.data.id).toBe(result.id);
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); expect(result.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(createArgs.data).not.toHaveProperty('data'); expect(createArgs.data).not.toHaveProperty('data');
expect(prisma.dashboardImage.update).not.toHaveBeenCalled(); expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
}); });
@@ -444,7 +574,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei // Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da. // dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
const prisma = makeFakePrisma([ const prisma = makeFakePrisma([
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }), makeRow({
id: 'datei-fehlt',
storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png',
}),
]); ]);
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow( await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException, NotFoundException,
@@ -456,7 +589,9 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
const onDisk = storedFile('user-1', 'weg'); const onDisk = storedFile('user-1', 'weg');
expect(fs.existsSync(onDisk)).toBe(true); expect(fs.existsSync(onDisk)).toBe(true);
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' }); await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({
id: 'weg',
});
expect(prisma.__rows).toHaveLength(0); expect(prisma.__rows).toHaveLength(0);
expect(fs.existsSync(onDisk)).toBe(false); expect(fs.existsSync(onDisk)).toBe(false);
}); });
@@ -1,3 +1,6 @@
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { import {
BadRequestException, BadRequestException,
Injectable, Injectable,
@@ -5,12 +8,9 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { import {
DASHBOARD_IMAGE_MAX_COUNT, DASHBOARD_IMAGE_MAX_COUNT,
type DashboardImageMime, type DashboardImageMime,
@@ -314,6 +314,15 @@ export class DashboardImagesService {
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. * Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird * Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
* protokolliert und geschluckt (T-HK4-04). * protokolliert und geschluckt (T-HK4-04).
*
* quick-260930: War das Bild der Dashboard-Hintergrund des Benutzers
* (`User.dashboardBackground` = `{ kind: 'image', imageId: <diese UUID> }`),
* wird die Wahl im selben Vorgang auf „kein Hintergrund“ gesetzt — sonst
* zeigte sie auf ein Bild, das es nicht mehr gibt. Bedingtes `updateMany`
* (JSON-Pfad `imageId`), damit jede andere Wahl unberuehrt bleibt; nur die
* eigene Zeile (`id: userId`). Ein Fehler dabei wird wie beim Entfernen der
* Datei protokolliert und geschluckt: das Bild ist schon weg, und das Web
* zeigt eine Wahl mit nicht ladbarem Bild ohnehin als „kein Hintergrund“.
*/ */
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> { async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -323,6 +332,19 @@ export class DashboardImagesService {
} }
await tenantPrisma.dashboardImage.delete({ where: { id } }); await tenantPrisma.dashboardImage.delete({ where: { id } });
try {
await tenantPrisma.user.updateMany({
where: { id: userId, dashboardBackground: { path: ['imageId'], equals: id } },
data: { dashboardBackground: { kind: 'none' } },
});
} catch (error) {
this.logger.warn(
`Hintergrund-Wahl zum geloeschten Bilderrahmen-Bild ${id} konnte nicht zurueckgesetzt werden: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const absolute = absoluteImagePath(row.storagePath); const absolute = absoluteImagePath(row.storagePath);
if (absolute !== null) { if (absolute !== null) {
try { try {
@@ -5,6 +5,7 @@ import {
IsString, IsString,
IsUrl, IsUrl,
IsUUID, IsUUID,
MaxLength,
} from 'class-validator'; } from 'class-validator';
/** /**
@@ -24,6 +25,7 @@ export class CreateFavoriteDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2048)
iconUrl?: string; iconUrl?: string;
@IsOptional() @IsOptional()
@@ -1,4 +1,4 @@
import { IsInt, IsOptional, IsString, IsUrl } from 'class-validator'; import { IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
/** /**
* DTO for updating an existing FavoriteLink. * DTO for updating an existing FavoriteLink.
@@ -19,6 +19,8 @@ export class UpdateFavoriteDto {
* No strict type validation so null passes through to Prisma. * No strict type validation so null passes through to Prisma.
*/ */
@IsOptional() @IsOptional()
@IsString()
@MaxLength(2048)
iconUrl?: string | null; iconUrl?: string | null;
@IsOptional() @IsOptional()
+126 -37
View File
@@ -6,7 +6,6 @@ import {
HttpException, HttpException,
NotFoundException, NotFoundException,
PayloadTooLargeException, PayloadTooLargeException,
UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FavoritesService } from './favorites.service'; import { FavoritesService } from './favorites.service';
@@ -221,7 +220,11 @@ function expectBoundCall(
} }
function makeIconDiscovery( function makeIconDiscovery(
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {}, overrides: Partial<{
discoverFavoriteIconUrl: any;
fetchIconBytes: any;
fetchPublicServiceIconBytes: any;
}> = {},
) { ) {
return { return {
discoverFavoriteIconUrl: discoverFavoriteIconUrl:
@@ -230,6 +233,11 @@ function makeIconDiscovery(
fetchIconBytes: fetchIconBytes:
overrides.fetchIconBytes ?? overrides.fetchIconBytes ??
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })), vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
fetchPublicServiceIconBytes:
overrides.fetchPublicServiceIconBytes ??
vi.fn(async () => {
throw new Error('icon service: unknown');
}),
}; };
} }
@@ -512,6 +520,35 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException); await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
}); });
it('quick-261001-hbi: gespeichertes Symbol scheitert -> Symbol-Dienst mit der Seiten-URL', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery({
fetchIconBytes: vi.fn(async () => {
throw new Error('not an image');
}),
fetchPublicServiceIconBytes: vi.fn(async () => ({
contentType: 'image/png',
body: Buffer.from('ddg'),
})),
});
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
expect(iconDiscovery.fetchPublicServiceIconBytes).toHaveBeenCalledWith(baseRow.url);
expect(result.body).toEqual(Buffer.from('ddg'));
});
it('quick-261001-hbi: gespeichertes Symbol klappt -> Symbol-Dienst wird nicht gefragt', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
await service.getIconBytes('t1', 'f1', 'user-a1');
expect(iconDiscovery.fetchPublicServiceIconBytes).not.toHaveBeenCalled();
});
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => { it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
const prisma = makeFakePrisma([baseRow]); const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery({ const iconDiscovery = makeIconDiscovery({
@@ -859,6 +896,40 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
}); });
}); });
describe('update — ausdrueckliche Logo-Adresse verdraengt ein hochgeladenes Symbol (260929-lh3)', () => {
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
it('neue, abweichende iconUrl bei vorhandenem Upload: Upload-Typ null, Datei weg, iconVersion erneut +1 — die neue Adresse wird angezeigt', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await service.uploadIcon('t1', 'f1', 'user-a1', file);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true);
const updated = await service.update('t1', 'f1', 'user-a1', {
iconUrl: 'https://neu.invalid/logo.png',
} as any);
expect(updated.iconUrl).toBe('https://neu.invalid/logo.png');
expect(updated.uploadedIconMime).toBeNull();
expect(updated.iconVersion).toBe(2);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
});
it('UNVERAENDERTE iconUrl bei vorhandenem Upload (das Formular schickt sie bei jedem Speichern mit): Upload bleibt', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await service.uploadIcon('t1', 'f1', 'user-a1', file);
const updated = await service.update('t1', 'f1', 'user-a1', {
iconUrl: baseRow.iconUrl,
} as any);
expect(updated.uploadedIconMime).toBe('image/png');
expect(updated.iconVersion).toBe(1);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true);
});
});
describe('remove() mit hochgeladenem Symbol', () => { describe('remove() mit hochgeladenem Symbol', () => {
it('Zeile und Datei weg', async () => { it('Zeile und Datei weg', async () => {
const prisma = makeFakePrisma([baseRow]); const prisma = makeFakePrisma([baseRow]);
@@ -886,26 +957,50 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
}); });
}); });
describe('create/update — Abrufprobe fuer eine explizite iconUrl (260923-lrr)', () => { describe('create/update — ausdrueckliche iconUrl: nur Formpruefung, kein Abruf (260929-lh3)', () => {
it('create mit expliziter iconUrl: Probe genau einmal; wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen', async () => { const widgets = [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }];
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]); const failingFetch = () =>
const iconDiscovery = makeIconDiscovery({ vi.fn(async () => {
fetchIconBytes: vi.fn(async () => { throw new Error('server bekommt 404/HTML');
throw new Error('blocked');
}),
}); });
it('create mit einer Adresse, die der SERVER nicht abrufen kann: wird gespeichert, KEIN Abruf, KEINE Erkennung', async () => {
const prisma = makeFakePrisma([], widgets);
const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
const service = new FavoritesService(prisma as any, iconDiscovery as any); const service = new FavoritesService(prisma as any, iconDiscovery as any);
const created = await service.create('t1', 'user-a1', {
widgetId: 'widget-a1',
title: 'Docuvita',
url: 'https://docuvita.ctl.local/server/services/web/',
iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
} as any);
expect(created.iconUrl).toBe(
'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
);
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
expect(prisma.__favorites.size).toBe(1);
});
it.each([
['kein http/https', 'ftp://x.invalid/icon.png'],
['javascript-Schema', 'javascript:alert(1)'],
['keine Adresse', 'kein url'],
['laenger als 2048 Zeichen', `https://x.invalid/${'a'.repeat(2050)}`],
])('create mit ungueltiger iconUrl (%s) -> BadRequestException, nichts geschrieben', async (_label, iconUrl) => {
const prisma = makeFakePrisma([], widgets);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect( await expect(
service.create('t1', 'user-a1', { service.create('t1', 'user-a1', {
widgetId: 'widget-a1', widgetId: 'widget-a1',
title: 'X', title: 'X',
url: 'https://x.invalid', url: 'https://x.invalid',
iconUrl: 'https://x.invalid/logo.png', iconUrl,
} as any), } as any),
).rejects.toThrow(UnprocessableEntityException); ).rejects.toThrow(BadRequestException);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png');
expect(prisma.__favorites.size).toBe(0); expect(prisma.__favorites.size).toBe(0);
}); });
@@ -922,24 +1017,31 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
iconVersion: 0, iconVersion: 0,
}; };
it('update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen', async () => { it('update mit neuer iconUrl, die der Server nicht abrufen kann: gespeichert, iconVersion +1, KEIN Abruf', async () => {
const prisma = makeFakePrisma([baseRow]); const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery({ const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
fetchIconBytes: vi.fn(async () => {
throw new Error('blocked');
}),
});
const service = new FavoritesService(prisma as any, iconDiscovery as any); const service = new FavoritesService(prisma as any, iconDiscovery as any);
const updated = await service.update('t1', 'f1', 'user-a1', {
iconUrl: 'https://neu.invalid/icon.png',
} as any);
expect(updated.iconUrl).toBe('https://neu.invalid/icon.png');
expect(updated.iconVersion).toBe(1);
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
});
it('update mit ungueltiger neuer iconUrl -> BadRequestException, Zeile unveraendert', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect( await expect(
service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any), service.update('t1', 'f1', 'user-a1', { iconUrl: 'file:///etc/passwd' } as any),
).rejects.toThrow(UnprocessableEntityException); ).rejects.toThrow(BadRequestException);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl); expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl);
}); });
it('update mit UNVERAENDERTER iconUrl: keine Probe, keine Erhoehung', async () => { it('update mit UNVERAENDERTER iconUrl: keine Pruefung, keine Erhoehung', async () => {
const prisma = makeFakePrisma([baseRow]); const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery(); const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any); const service = new FavoritesService(prisma as any, iconDiscovery as any);
@@ -958,18 +1060,5 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
expect(updated.iconVersion).toBe(0); expect(updated.iconVersion).toBe(0);
}); });
it('update mit neuer, erreichbarer iconUrl: iconVersion +1', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const updated = await service.update('t1', 'f1', 'user-a1', {
iconUrl: 'https://neu.invalid/icon.png',
} as any);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
expect(updated.iconVersion).toBe(1);
});
}); });
}); });
+74 -29
View File
@@ -9,7 +9,6 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
PayloadTooLargeException, PayloadTooLargeException,
UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { UploadedFileLike } from '../auth/types/auth-user'; import type { UploadedFileLike } from '../auth/types/auth-user';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -77,12 +76,16 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder * Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein * entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
* Dateifehler wird protokolliert und geschluckt. * Dateifehler wird protokolliert und geschluckt.
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab, * - 260929-lh3 (loest die Abrufprobe von 260923-lrr ab): eine ausdrueckliche
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer * Logo-Adresse wird nur auf Form (http/https, <= 2048 Zeichen) geprueft und
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt * auch gespeichert, wenn der Server sie nicht abrufen kann — der Browser der
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur * Kachel laedt sie dann direkt. Ein hochgeladenes Symbol wird von einer
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest. * neuen, abweichenden Adresse verdraengt (Vorrang der Datei sonst: Adresse
* gespeichert, aber unsichtbar).
*/ */
/** Hoechstlaenge einer ausdruecklichen Logo-Adresse (260929-lh3). */
const ICON_URL_MAX_LENGTH = 2048;
@Injectable() @Injectable()
export class FavoritesService { export class FavoritesService {
private readonly logger = new Logger(FavoritesService.name); private readonly logger = new Logger(FavoritesService.name);
@@ -107,19 +110,31 @@ export class FavoritesService {
} }
/** /**
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst * Prueft eine ausdruecklich eingetragene Logo-Adresse NUR auf Form (260929-lh3):
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes` * gueltige http/https-Adresse, hoechstens 2048 Zeichen. Bewusst KEIN
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler * serverseitiger Abruf mehr — Server wie docuvita liefern dem Server ein
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.) * 404/HTML, dem Browser aber das Bild; die fruehere Abrufprobe (422,
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der * 260923-lrr) machte genau diese Adressen unspeicherbar. Entscheidung: auch
* sich etwas ueber die gepruefte Adresse ablesen liesse. * eine Antwort, die der Server sieht und die kein Bild ist, weist NICHT ab —
* "Server bekommt kein Bild" heisst nicht "Browser bekommt keins", und der
* Server kann beides nicht unterscheiden. Der SSRF-Schutz bleibt unveraendert
* dort, wo der Server tatsaechlich abruft (`getIconBytes`/Erkennung); scheitert
* der Proxy, laedt die Kachel die Adresse direkt im Browser.
*/ */
private async assertIconUrlLoadable(iconUrl: string): Promise<void> { private assertIconUrlWellFormed(iconUrl: string): void {
let parsed: URL | null = null;
try { try {
await this.iconDiscovery.fetchIconBytes(iconUrl); parsed = new URL(iconUrl);
} catch { } catch {
throw new UnprocessableEntityException( parsed = null;
'Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.', }
if (
parsed === null ||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
iconUrl.length > ICON_URL_MAX_LENGTH
) {
throw new BadRequestException(
'Die Logo-Adresse muss eine gültige http- oder https-Adresse sein (höchstens 2048 Zeichen).',
); );
} }
} }
@@ -129,8 +144,8 @@ export class FavoritesService {
* Verifies the target widget belongs to the caller BEFORE any icon * Verifies the target widget belongs to the caller BEFORE any icon
* discovery network call (T-GWH-05). * discovery network call (T-GWH-05).
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection. * If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
* If iconUrl IS provided (260923-lrr), it must load successfully or the * If iconUrl IS provided it is stored as given after a form check only
* create is rejected with 422 — nothing is written on a failed probe. * (260929-lh3, see assertIconUrlWellFormed) — no server-side fetch.
*/ */
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) { async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -154,8 +169,8 @@ export class FavoritesService {
let iconUrl = dto.iconUrl ?? null; let iconUrl = dto.iconUrl ?? null;
if (iconUrl) { if (iconUrl) {
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert. // 260929-lh3: nur Formpruefung, kein serverseitiger Abruf.
await this.assertIconUrlLoadable(iconUrl); this.assertIconUrlWellFormed(iconUrl);
} else { } else {
// Server-side icon discovery (D-05) — only when caller did not supply an icon // Server-side icon discovery (D-05) — only when caller did not supply an icon
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url); iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
@@ -179,10 +194,11 @@ export class FavoritesService {
* Verifies userId ownership before applying changes (T-08-06). * Verifies userId ownership before applying changes (T-08-06).
* Accepts null as an explicit value for iconUrl (clears stored icon). * Accepts null as an explicit value for iconUrl (clears stored icon).
* *
* 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl` * 260929-lh3: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas * durchlaeuft nur die Formpruefung (`assertIconUrlWellFormed`), bevor
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede * irgendetwas geschrieben wird; sie wird auch gespeichert, wenn der Server
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`. * sie nicht abrufen kann. Jede tatsaechliche Aenderung der Symbolquelle
* erhoeht `iconVersion`.
*/ */
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) { async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -205,8 +221,8 @@ export class FavoritesService {
if ('iconUrl' in dto) { if ('iconUrl' in dto) {
if (dto.iconUrl) { if (dto.iconUrl) {
if (dto.iconUrl !== link.iconUrl) { if (dto.iconUrl !== link.iconUrl) {
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert. // 260929-lh3: nur eine NEUE, abweichende Adresse wird geprueft (Form).
await this.assertIconUrlLoadable(dto.iconUrl); this.assertIconUrlWellFormed(dto.iconUrl);
} }
// Explicit icon URL supplied — respect it as-is. // Explicit icon URL supplied — respect it as-is.
data.iconUrl = dto.iconUrl; data.iconUrl = dto.iconUrl;
@@ -219,14 +235,34 @@ export class FavoritesService {
} }
} }
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) { // 260929-lh3: eine NEUE, ausdruecklich eingetragene Logo-Adresse muss
// Vorrang vor einem frueher hochgeladenen Symbol haben. `getIconBytes`
// liefert bei gesetztem `uploadedIconMime` IMMER die Datei — ohne diesen
// Schritt blieb die neue Adresse gespeichert, aber unsichtbar (die Kachel
// zeigte weiter das alte hochgeladene Bild). Nur bei einer tatsaechlichen
// Aenderung: das Formular schickt die unveraenderte Adresse bei jedem
// Speichern mit, das darf ein hochgeladenes Symbol nicht verdraengen.
const iconUrlChanged = data.iconUrl !== undefined && data.iconUrl !== link.iconUrl;
const explicitUrlReplacesUpload =
iconUrlChanged && Boolean(dto.iconUrl) && link.uploadedIconMime !== null;
if (explicitUrlReplacesUpload) {
data.uploadedIconMime = null;
}
if (iconUrlChanged) {
data.iconVersion = { increment: 1 }; data.iconVersion = { increment: 1 };
} }
return tenantPrisma.favoriteLink.update({ const updated = await tenantPrisma.favoriteLink.update({
where: { id }, where: { id },
data, data,
}); });
if (explicitUrlReplacesUpload && link.uploadedIconMime !== null) {
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'ersetzte');
}
return updated;
} }
/** /**
@@ -461,7 +497,9 @@ export class FavoritesService {
* Throws NotFoundException (404) if the row doesn't exist, isn't owned * Throws NotFoundException (404) if the row doesn't exist, isn't owned
* by the caller, or has neither an uploaded icon nor a stored iconUrl. * by the caller, or has neither an uploaded icon nor a stored iconUrl.
* Throws a 502 HttpException if the upstream fetch fails (unreachable, * Throws a 502 HttpException if the upstream fetch fails (unreachable,
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image. * timeout, non-image, or SSRF-blocked) AND the public icon service fallback
* (quick-261001-hbi, public pages only) has no icon either -- never returns
* a placeholder image.
*/ */
async getIconBytes( async getIconBytes(
tenantId: string, tenantId: string,
@@ -499,8 +537,15 @@ export class FavoritesService {
try { try {
return await this.iconDiscovery.fetchIconBytes(link.iconUrl); return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
} catch {
// quick-261001-hbi: Seite liefert kein abrufbares Symbol (z. B. per
// JavaScript gesetzt) -- einmal beim oeffentlichen Symbol-Dienst fragen,
// nur fuer oeffentlich erreichbare Seiten.
try {
return await this.iconDiscovery.fetchPublicServiceIconBytes(link.url);
} catch { } catch {
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY); throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
} }
} }
} }
}
@@ -18,24 +18,21 @@ vi.mock('undici', () => ({
import { Agent } from 'undici'; import { Agent } from 'undici';
import { import {
discardBody,
IconDiscoveryService, IconDiscoveryService,
isPublicHttpUrl, isPublicHttpUrl,
normalizeUrl, normalizeUrl,
readTextCapped,
} from './icon-discovery.service'; } from './icon-discovery.service';
function mockResponse(options: { function mockResponse(options: { contentType?: string; body?: ArrayBuffer }): Response {
contentType?: string;
body?: ArrayBuffer;
}): Response {
const body = options.body ?? new ArrayBuffer(10); const body = options.body ?? new ArrayBuffer(10);
return { return {
ok: true, ok: true,
status: 200, status: 200,
headers: { headers: {
get: (name: string) => get: (name: string) =>
name.toLowerCase() === 'content-type' name.toLowerCase() === 'content-type' ? (options.contentType ?? 'image/png') : null,
? (options.contentType ?? 'image/png')
: null,
}, },
arrayBuffer: async () => body, arrayBuffer: async () => body,
} as unknown as Response; } as unknown as Response;
@@ -106,9 +103,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
status: 200, status: 200,
headers: { headers: {
get: (n: string) => get: (n: string) =>
n.toLowerCase() === 'content-type' n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
? 'text/html; charset=utf-8'
: null,
}, },
text: async () => html, text: async () => html,
}), }),
@@ -133,6 +128,58 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
}); });
}); });
describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatus (260929-lh3)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function htmlResponse(status: number, html: string) {
return {
ok: status >= 200 && status < 300,
status,
headers: {
get: (n: string) =>
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
},
text: async () => html,
};
}
it('Seite antwortet 400, traegt aber <link rel="SHORTCUT ICON"> (docuvita) -> dieser Verweis wird genutzt', async () => {
const html =
'<html><head><link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" /></head></html>';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(400, html)));
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl(
'http://8.8.8.8/server/services/web/',
);
expect(icon).toBe('http://8.8.8.8/webclient/docuvita/resources/brandimage/favicon.ico');
});
it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => {
const html =
'<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html)));
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
expect(icon).toBe('http://8.8.8.8/favicon.ico');
});
it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }),
);
await expect(
new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'),
).rejects.toThrow();
});
});
describe('IconDiscoveryService.fetchIconBytes', () => { describe('IconDiscoveryService.fetchIconBytes', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -155,16 +202,13 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
}); });
it('rejects when Content-Type is not an image', async () => { it('rejects when Content-Type is not an image', async () => {
vi.stubGlobal( vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
'fetch',
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
);
const service = new IconDiscoveryService(); const service = new IconDiscoveryService();
await expect( await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
service.fetchIconBytes('http://8.8.8.8/favicon.ico'), /not an image/,
).rejects.toThrow(/not an image/); );
}); });
it('rejects when the SSRF guard blocks the target', async () => { it('rejects when the SSRF guard blocks the target', async () => {
@@ -173,9 +217,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
const service = new IconDiscoveryService(); const service = new IconDiscoveryService();
await expect( await expect(service.fetchIconBytes('http://127.0.0.1/favicon.ico')).rejects.toThrow(
service.fetchIconBytes('http://127.0.0.1/favicon.ico'), /blocked or failed/,
).rejects.toThrow(/blocked or failed/); );
expect(fetchSpy).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled();
}); });
@@ -188,9 +232,60 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
const service = new IconDiscoveryService(); const service = new IconDiscoveryService();
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
/size limit/,
);
});
});
describe('IconDiscoveryService.fetchPublicServiceIconBytes (quick-261001-hbi)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('fragt fuer eine oeffentliche Seite den Symbol-Dienst mit dem Hostnamen', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png' }));
vi.stubGlobal('fetch', fetchSpy);
const service = new IconDiscoveryService();
const result = await service.fetchPublicServiceIconBytes('http://8.8.8.8/start');
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0][0]).toBe('https://icons.duckduckgo.com/ip3/8.8.8.8.ico');
expect(result.contentType).toBe('image/png');
});
it('fragt fuer eine interne Seite NICHT (Hostname verlaesst das Haus nicht)', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const service = new IconDiscoveryService();
await expect( await expect(
service.fetchIconBytes('http://8.8.8.8/favicon.ico'), service.fetchPublicServiceIconBytes('https://docuvita.ctl.local/x'),
).rejects.toThrow(/size limit/); ).rejects.toThrow(/not public/);
await expect(service.fetchPublicServiceIconBytes('http://192.168.1.5/')).rejects.toThrow(
/not public/,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('Dienst kennt kein Symbol (404) -> wirft', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
...mockResponse({ contentType: 'image/png' }),
ok: false,
status: 404,
}),
);
const service = new IconDiscoveryService();
await expect(service.fetchPublicServiceIconBytes('http://8.8.8.8/')).rejects.toThrow(
/blocked or failed/,
);
}); });
}); });
@@ -210,10 +305,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', (
}); });
it('still returns a URL string', async () => { it('still returns a URL string', async () => {
vi.stubGlobal( vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
'fetch',
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
);
const service = new IconDiscoveryService(); const service = new IconDiscoveryService();
const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page'); const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page');
@@ -287,3 +379,143 @@ describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => {
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher); expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
}); });
}); });
describe('readTextCapped / discardBody — Groessendeckel beim Lesen (T-08-09)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
/** Stream aus `chunks` Stuecken je `chunkChars` ASCII-Zeichen; zaehlt gelesene Stuecke und Abbruch. */
function countingStream(chunks: number, chunkChars: number) {
const state = { pulled: 0, cancelled: false };
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (state.pulled >= chunks) {
controller.close();
return;
}
state.pulled += 1;
controller.enqueue(encoder.encode('a'.repeat(chunkChars)));
},
cancel() {
state.cancelled = true;
},
});
return { body, state };
}
it('bricht den Stream nach der Grenze ab statt alles zu lesen', async () => {
const { body, state } = countingStream(1000, 1000);
const text = await readTextCapped({ body, text: async () => 'unbenutzt' } as never, 2500);
expect(text).toHaveLength(2500);
expect(state.pulled).toBeLessThan(10);
expect(state.cancelled).toBe(true);
});
it('gibt nach der Zeitgrenze zurueck, was bis dahin da ist (tropfender Server)', async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('<link rel="icon">'));
// danach kommt nichts mehr, der Stream bleibt offen
},
cancel() {
cancelled = true;
},
});
const text = await readTextCapped({ body, text: async () => '' } as never, 200000, 50);
expect(text).toBe('<link rel="icon">');
expect(cancelled).toBe(true);
});
it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => {
const bytes = new TextEncoder().encode('<p>Grüße</p>');
const body = new ReadableStream<Uint8Array>({
start(controller) {
// Das "ü" (2 Bytes) wird absichtlich zerteilt.
controller.enqueue(bytes.slice(0, 5));
controller.enqueue(bytes.slice(5));
controller.close();
},
});
const text = await readTextCapped({ body, text: async () => '' } as never, 200000);
expect(text).toBe('<p>Grüße</p>');
});
it('ohne Stream: Rueckfall auf text() mit Deckel', async () => {
const text = await readTextCapped(
{ body: null, text: async () => 'x'.repeat(50) } as never,
10,
);
expect(text).toBe('x'.repeat(10));
});
it('discardBody bricht einen offenen Body ab und vertraegt fehlenden Body', () => {
const { body, state } = countingStream(5, 10);
discardBody({ body } as never);
expect(state.cancelled).toBe(true);
expect(() => discardBody({ body: null } as never)).not.toThrow();
});
it('Discovery: Fehlerstatus ohne HTML-Typ -> Body wird verworfen, Rueckfall favicon.ico', async () => {
const { body, state } = countingStream(5, 10);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: {
get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null),
},
body,
}),
);
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
expect(icon).toBe('http://8.8.8.8/favicon.ico');
expect(state.cancelled).toBe(true);
});
it('Discovery: riesige HTML-Seite wird nur bis zur Grenze gelesen, Symbol am Anfang gefunden', async () => {
const head = '<html><head><link rel="icon" href="/klein.png" /></head><body>';
const encoder = new TextEncoder();
const state = { pulled: 0, cancelled: false };
const body = new ReadableStream<Uint8Array>({
pull(controller) {
state.pulled += 1;
controller.enqueue(encoder.encode(state.pulled === 1 ? head : 'a'.repeat(64 * 1024)));
},
cancel() {
state.cancelled = true;
},
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'text/html' : null) },
body,
text: async () => {
throw new Error('text() darf bei vorhandenem Stream nicht laufen');
},
}),
);
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/');
expect(icon).toBe('http://8.8.8.8/klein.png');
expect(state.cancelled).toBe(true);
// 200 000 Zeichen bei 64-KiB-Stuecken: hoechstens eine Handvoll gelesen.
expect(state.pulled).toBeLessThan(10);
});
});
+132 -36
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { lookup } from 'node:dns/promises'; import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici'; import { Injectable } from '@nestjs/common';
import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici';
/** /**
* Server-side favicon / icon discovery with SSRF protection (T-08-05). * Server-side favicon / icon discovery with SSRF protection (T-08-05).
@@ -25,6 +25,18 @@ const MAX_REDIRECTS = 2;
const MAX_HTML_CHARS = 200000; const MAX_HTML_CHARS = 200000;
const MAX_ICON_BYTES = 1_000_000; const MAX_ICON_BYTES = 1_000_000;
/**
* quick-261001-hbi — oeffentlicher Symbol-Dienst als letzter Rueckfall. Manche
* Seiten setzen ihr Symbol erst per JavaScript (hosteurope.de: im HTML nur
* `<link rel="icon" href="data:;base64,=">`, `/favicon.ico` liefert eine
* HTML-Seite) — ohne Browser findet die Suche dort nichts. DuckDuckGo kennt
* das gerenderte Symbol und antwortet fuer Unbekanntes mit 404 (dann bleibt
* der Buchstabe). Gefragt wird NUR fuer oeffentlich erreichbare Adressen,
* damit interne Hostnamen (docuvita.ctl.local, private IPs) das Haus nie
* verlassen; der Dienst erfaehrt nur den Hostnamen.
*/
const PUBLIC_ICON_SERVICE = 'https://icons.duckduckgo.com/ip3/';
/** /**
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte, * 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines * abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
@@ -49,6 +61,8 @@ const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } });
type FetchHtmlResult = { type FetchHtmlResult = {
html: string; html: string;
finalUrl: string; finalUrl: string;
/** false = die Seite antwortete mit einem Fehlerstatus (z. B. 400/404), lieferte aber HTML (260929-lh3). */
ok: boolean;
}; };
function isPrivateIpv4(address: string): boolean { function isPrivateIpv4(address: string): boolean {
@@ -56,9 +70,7 @@ function isPrivateIpv4(address: string): boolean {
if ( if (
parts.length !== 4 || parts.length !== 4 ||
parts.some( parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
(part) => !Number.isInteger(part) || part < 0 || part > 255,
)
) { ) {
return true; return true;
} }
@@ -115,12 +127,7 @@ function isPrivateIpAddress(address: string): boolean {
function isBlockedHostname(hostname: string): boolean { function isBlockedHostname(hostname: string): boolean {
const h = hostname.trim().toLowerCase(); const h = hostname.trim().toLowerCase();
return ( return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0';
h === 'localhost' ||
h.endsWith('.localhost') ||
h.endsWith('.local') ||
h === '0.0.0.0'
);
} }
export async function isPublicHttpUrl(url: URL): Promise<boolean> { export async function isPublicHttpUrl(url: URL): Promise<boolean> {
@@ -202,7 +209,7 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null {
} }
} }
function extractIconFromHtml(html: string, baseUrl: string): string | null { function extractIconFromHtml(html: string, baseUrl: string, linkTagsOnly = false): string | null {
const linkTags = html.match(/<link\b[^>]*>/gi) ?? []; const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? []; const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
@@ -214,30 +221,26 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
})) }))
.filter((c) => c.href); .filter((c) => c.href);
const appleTouchIcon = linkCandidates.find((c) => const appleTouchIcon = linkCandidates.find((c) => c.rel.includes('apple-touch-icon'))?.href;
c.rel.includes('apple-touch-icon'),
)?.href;
if (appleTouchIcon) return appleTouchIcon; if (appleTouchIcon) return appleTouchIcon;
const icon = linkCandidates.find((c) => const icon = linkCandidates.find((c) => c.rel.split(/\s+/).includes('icon'))?.href;
c.rel.split(/\s+/).includes('icon'),
)?.href;
if (icon) return icon; if (icon) return icon;
const shortcutIcon = linkCandidates.find((c) => const shortcutIcon = linkCandidates.find((c) => c.rel.includes('shortcut icon'))?.href;
c.rel.includes('shortcut icon'),
)?.href;
if (shortcutIcon) return shortcutIcon; if (shortcutIcon) return shortcutIcon;
const imageSrc = linkCandidates.find((c) => const imageSrc = linkCandidates.find((c) => c.rel.includes('image_src'))?.href;
c.rel.includes('image_src'),
)?.href;
if (imageSrc) return imageSrc; if (imageSrc) return imageSrc;
// 260929-lh3: eine Fehlerseite (Status != 2xx) traegt kein Vorschaubild der
// Seite — nur die ausdruecklichen Symbol-Verweise (<link rel=...icon>) zaehlen.
if (linkTagsOnly) return null;
const metaImage = metaTags const metaImage = metaTags
.map((tag) => parseAttributes(tag)) .map((tag) => parseAttributes(tag))
.map((a) => ({ .map((a) => ({
@@ -247,9 +250,7 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
.find( .find(
(c) => (c) =>
c.content && c.content &&
(c.property === 'og:image' || (c.property === 'og:image' || c.property === 'og:logo' || c.property === 'twitter:image'),
c.property === 'og:logo' ||
c.property === 'twitter:image'),
)?.content; )?.content;
return metaImage ?? null; return metaImage ?? null;
@@ -266,7 +267,13 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
*/ */
async function fetchWithRedirectGuard( async function fetchWithRedirectGuard(
pageUrl: URL, pageUrl: URL,
options: { accept: string; timeoutMs: number; userAgent?: string }, options: {
accept: string;
timeoutMs: number;
userAgent?: string;
/** 260929-lh3: auch eine 4xx/5xx-Antwort zurueckgeben (nur fuer die HTML-Suche). */
allowErrorStatus?: boolean;
},
): Promise<{ response: UndiciResponse; finalUrl: URL } | null> { ): Promise<{ response: UndiciResponse; finalUrl: URL } | null> {
let currentUrl = pageUrl; let currentUrl = pageUrl;
@@ -298,7 +305,7 @@ async function fetchWithRedirectGuard(
continue; continue;
} }
if (!response.ok) return null; if (!response.ok && !options.allowErrorStatus) return null;
return { response, finalUrl: currentUrl }; return { response, finalUrl: currentUrl };
} catch { } catch {
@@ -311,23 +318,98 @@ async function fetchWithRedirectGuard(
return null; return null;
} }
/** Minimaler Ausschnitt einer Antwort, den die beiden Helfer brauchen. */
type BodyResponse = Pick<UndiciResponse, 'body' | 'text'>;
/**
* Verwirft den Body einer nicht gebrauchten Antwort. Fehler (bereits
* gelesen/abgebrochen) sind egal.
*/
export function discardBody(response: Pick<UndiciResponse, 'body'>): void {
try {
response.body?.cancel().catch(() => {});
} catch {
// Body gesperrt oder schon verbraucht — nichts zu tun.
}
}
/**
* Liest den Antworttext hoechstens bis `maxChars` Zeichen und bricht den
* Stream danach ab (T-08-09). Vorher wurde der komplette Body gelesen und
* erst danach abgeschnitten — eine riesige Seite landete ganz im Speicher.
* Dekodiert wird UTF-8 wie bei `Response.text()`; da jedes Zeichen aus
* mindestens einem Byte entsteht, bleibt der Speicher bei ~maxChars plus
* einem Chunk. Ohne Stream (`body === null`) wie bisher ueber `text()`.
* `timeoutMs` begrenzt zusaetzlich die Lesedauer: die Zeitgrenze von
* `fetchWithRedirectGuard` endet mit den Kopfzeilen, ein Server, der den
* Body tropfenweise liefert, hielte die Anfrage sonst beliebig lange auf.
* Nach Ablauf zaehlt, was bis dahin gelesen ist.
*/
export async function readTextCapped(
response: BodyResponse,
maxChars: number,
timeoutMs = HTML_FETCH_TIMEOUT_MS,
): Promise<string> {
if (!response.body) {
return (await response.text()).slice(0, maxChars);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = '';
// cancel() beendet ein haengendes read() mit done: true.
const deadline = setTimeout(() => void reader.cancel().catch(() => {}), timeoutMs);
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
text += decoder.decode();
break;
}
text += decoder.decode(value, { stream: true });
if (text.length >= maxChars) {
await reader.cancel().catch(() => {});
break;
}
}
} finally {
clearTimeout(deadline);
}
return text.slice(0, maxChars);
}
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> { async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
const result = await fetchWithRedirectGuard(pageUrl, { const result = await fetchWithRedirectGuard(pageUrl, {
accept: 'text/html,application/xhtml+xml,*/*', accept: 'text/html,application/xhtml+xml,*/*',
timeoutMs: HTML_FETCH_TIMEOUT_MS, timeoutMs: HTML_FETCH_TIMEOUT_MS,
// 260929-lh3: Server wie docuvita antworten dem Server mit 400, tragen im
// HTML aber trotzdem den <link rel=icon> — den Verweis wollen wir haben.
allowErrorStatus: true,
}); });
if (!result) return null; if (!result) return null;
const contentType = result.response.headers.get('content-type') ?? ''; const contentType = result.response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('text/html')) return null; if (!contentType.toLowerCase().includes('text/html')) {
// Kein HTML (auch bei Fehlerstatus dank allowErrorStatus hier moeglich):
// Body verwerfen, sonst haelt undici die Verbindung bis zum Timeout offen.
discardBody(result.response);
return null;
}
const html = await result.response.text(); // T-08-09: HTML cap — schon beim Lesen, nicht erst nach dem kompletten Body.
const html = await readTextCapped(result.response, MAX_HTML_CHARS);
return { return {
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap html,
finalUrl: result.finalUrl.toString(), finalUrl: result.finalUrl.toString(),
ok: result.response.ok,
}; };
} }
@@ -350,12 +432,28 @@ export class IconDiscoveryService {
if (!htmlResult) return fallback; if (!htmlResult) return fallback;
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl) ?? fallback; return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? fallback;
} catch { } catch {
return fallback; return fallback;
} }
} }
/**
* quick-261001-hbi: Symbol fuer die Seite `pageUrl` beim oeffentlichen
* Symbol-Dienst holen (siehe PUBLIC_ICON_SERVICE). Wirft, wenn die Seite
* nicht oeffentlich erreichbar ist (dann wird der Dienst NICHT gefragt) oder
* der Dienst kein Symbol kennt (404) — wie `fetchIconBytes`.
*/
async fetchPublicServiceIconBytes(
pageUrl: string,
): Promise<{ contentType: string; body: Buffer }> {
const page = new URL(normalizeUrl(pageUrl));
if (!(await isPublicHttpUrl(page))) {
throw new Error('Page is not public, icon service not asked');
}
return this.fetchIconBytes(`${PUBLIC_ICON_SERVICE}${page.hostname}.ico`);
}
/** /**
* Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming * Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming
* back to the browser from Tessera's own origin (avoids Cross-Origin- * back to the browser from Tessera's own origin (avoids Cross-Origin-
@@ -365,9 +463,7 @@ export class IconDiscoveryService {
* or an oversized body. Callers must not return a placeholder image; let * or an oversized body. Callers must not return a placeholder image; let
* the caller map the failure to an HTTP error status instead. * the caller map the failure to an HTTP error status instead.
*/ */
async fetchIconBytes( async fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }> {
iconUrl: string,
): Promise<{ contentType: string; body: Buffer }> {
const url = new URL(iconUrl); const url = new URL(iconUrl);
const result = await fetchWithRedirectGuard(url, { const result = await fetchWithRedirectGuard(url, {
+50 -2
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as nodemailer from 'nodemailer'; import * as nodemailer from 'nodemailer';
import { MailService } from './mail.service'; import { MailService, WELCOME_HEADER_CID } from './mail.service';
/** /**
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich * MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
@@ -166,7 +166,11 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
const service = new MailService(settings as any, makeFakeConfig({}) as any); const service = new MailService(settings as any, makeFakeConfig({}) as any);
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1'); await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
await service.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2'); await service.sendWelcomeMail('t2', 'bob@b.example.invalid', {
subject: 'Willkommen bei Tessera',
text: 'Guten Tag bob,',
html: '<p>bob</p>',
});
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']); expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any); const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
@@ -314,3 +318,47 @@ describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)'
expect(mockClose).toHaveBeenCalledTimes(1); expect(mockClose).toHaveBeenCalledTimes(1);
}); });
}); });
describe('MailService.sendWelcomeMail / hasConfiguredTransport (Willkommensmail)', () => {
it('haengt das Kopfbild als CID-Anhang an, reicht HTML und Text durch und wirft Transportfehler nach aussen', async () => {
const service = new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
await service.sendWelcomeMail('t1', 'neu@a.example.invalid', {
subject: 'Willkommen bei Tessera',
text: 'Text',
html: `<img src="cid:${WELCOME_HEADER_CID}">`,
});
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.from).toBe('noreply@a.example.invalid');
expect(sent.html).toContain(`cid:${WELCOME_HEADER_CID}`);
expect(sent.text).toBe('Text');
expect(sent.attachments).toHaveLength(1);
expect(sent.attachments[0].cid).toBe(WELCOME_HEADER_CID);
expect(sent.attachments[0].contentType).toBe('image/png');
// PNG-Signatur: das Bild aus apps/api/assets/mail ist wirklich geladen.
expect((sent.attachments[0].content as Buffer).subarray(1, 4).toString()).toBe('PNG');
mockSendMail = vi.fn(async () => {
throw new Error('ECONNREFUSED');
});
await expect(
service.sendWelcomeMail('t1', 'neu@a.example.invalid', { subject: 'S', text: 'T', html: 'H' }),
).rejects.toThrow('ECONNREFUSED');
});
it('hasConfiguredTransport: SmtpConfig des Mandanten oder MAIL_HOST/TESSERA_SMTP_HOST — der Rueckfall localhost:1025 zaehlt nicht', async () => {
expect(
await new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
).toBe(true);
expect(
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ MAIL_HOST: 'smtp.example.invalid' }) as any).hasConfiguredTransport('t1'),
).toBe(true);
expect(
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ TESSERA_SMTP_HOST: 'legacy.example.invalid' }) as any).hasConfiguredTransport('t1'),
).toBe(true);
expect(
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
).toBe(false);
});
});
+83 -40
View File
@@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as nodemailer from 'nodemailer'; import * as nodemailer from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport'; import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import { SettingsService } from '../settings/settings.service'; import { SettingsService } from '../settings/settings.service';
@@ -45,15 +47,43 @@ import { SettingsService } from '../settings/settings.service';
* Seit quick-260914-m97 (Fehler-melden-Knopf) ist der Versandkern * Seit quick-260914-m97 (Fehler-melden-Knopf) ist der Versandkern
* `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt * `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt
* Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer * Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer
* Kennwort-Reset und Willkommensmail (T-02-12 unveraendert); `sendBugReport` * den Kennwort-Reset (T-02-12 unveraendert); `sendBugReport` und
* ruft den Kern direkt, damit der Anwender erfaehrt, ob sein Bericht ankam. * `sendWelcomeMail` rufen den Kern direkt, damit der Ausloesende erfaehrt,
* ob die Mail ging.
*/ */
/** Inhaltskennung des Kopfbilds der Willkommensmail (`<img src="cid:...">`). */
export const WELCOME_HEADER_CID = 'welcome-header@tessera';
let welcomeHeaderCache: Buffer | null | undefined;
/**
* Laedt das Kopfbild der Willkommensmail einmal je Prozess
* (apps/api/assets/mail/welcome-header.png, erzeugt von
* scripts/render-mail-header.mjs). Zur Laufzeit liegt diese Datei unter
* dist/mail/, im Test unter src/mail/ — beide Male zwei Ebenen unter
* apps/api. Fehlt das Bild, liefert die Funktion `null`; die Mail zeigt
* dann einen dunklen Textkopf statt abzubrechen.
*/
export function loadWelcomeHeaderPng(): Buffer | null {
if (welcomeHeaderCache !== undefined) return welcomeHeaderCache;
const file = path.resolve(__dirname, '..', '..', 'assets', 'mail', 'welcome-header.png');
try {
welcomeHeaderCache = fs.readFileSync(file);
} catch {
new Logger('MailService').warn(`Welcome header image missing: ${file}`);
welcomeHeaderCache = null;
}
return welcomeHeaderCache;
}
/** Anhang in der nodemailer-Form (`attachments` von `sendMail`). */ /** Anhang in der nodemailer-Form (`attachments` von `sendMail`). */
export interface OutgoingAttachment { export interface OutgoingAttachment {
filename: string; filename: string;
content: Buffer; content: Buffer;
contentType: string; contentType: string;
/** Inhaltskennung fuer eingebettete Bilder (`<img src="cid:...">`). */
cid?: string;
} }
/** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */ /** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */
@@ -186,10 +216,10 @@ export class MailService {
} }
/** /**
* Verschluckender Mantel um `deliver` fuer Kennwort-Reset und * Verschluckender Mantel um `deliver` fuer den Kennwort-Reset: Fehler
* Willkommensmail: Fehler werden protokolliert, nie geworfen — der * werden protokolliert, nie geworfen — der Anmeldeweg antwortet weiter
* Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration (T-02-12 * 200, keine E-Mail-Enumeration (T-02-12). Die Willkommensmail benutzt
* bleibt fuer genau diese beiden Wege bestehen). * ihn seit dem Versand aus der Benutzerverwaltung nicht mehr.
*/ */
private async sendViaTenantTransport( private async sendViaTenantTransport(
tenantId: string, tenantId: string,
@@ -275,46 +305,59 @@ export class MailService {
} }
/** /**
* Send a welcome email to a newly created user (optional — derzeit ohne * Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer,
* Aufrufer, gemessen 260914-eym; bleibt als Pfad ueber denselben * "Willkommensmail senden"). Inhalt und HTML baut
* Transport je Versand erhalten). * `renderWelcomeMail` (welcome-mail.template.ts), die Entscheidung ueber
* den Anmeldehinweis trifft `WelcomeMailService`. Diese Methode haengt
* nur das Kopfbild als CID-Anhang an (`WELCOME_HEADER_CID`, kein
* Nachladen von aussen) und versendet ueber den Transport des Mandanten
* des EMPFAENGERS.
* *
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport) * Fehler gehen BEWUSST nach aussen (wie `sendBugReport`): ein
* Administrator loest den Versand gezielt aus und muss erfahren, ob die
* Mail ging — es gibt hier keinen Anmeldeweg, den eine Fehlermeldung
* verraten koennte (T-02-12 betrifft nur Kennwort-Reset).
*/ */
async sendWelcomeEmail( async sendWelcomeMail(
email: string,
username: string,
tenantId: string, tenantId: string,
locale: string = 'de', to: string,
mail: { subject: string; text: string; html: string },
): Promise<void> { ): Promise<void> {
const isGerman = locale === 'de'; const header = loadWelcomeHeaderPng();
const subject = isGerman await this.deliver(
? 'Willkommen bei Tessera' tenantId,
: 'Welcome to Tessera'; {
to,
const text = isGerman ...mail,
attachments: header
? [ ? [
`Hallo ${username},`, {
'', filename: 'tessera.png',
'Ihr Tessera-Account wurde erstellt.', content: header,
'', contentType: 'image/png',
`Sie können sich unter ${this.appUrl}/login anmelden.`, cid: WELCOME_HEADER_CID,
'', },
'Mit freundlichen Grüßen,', ]
'Ihr Tessera-Team', : undefined,
].join('\n') },
: [ 'Welcome',
`Hello ${username},`, );
'', }
'Your Tessera account has been created.',
'',
`You can sign in at ${this.appUrl}/login.`,
'',
'Best regards,',
'The Tessera Team',
].join('\n');
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome'); /**
* Gibt an, ob fuer den Mandanten ein Versandweg eingerichtet ist: eine
* eigene SmtpConfig ODER ein per Umgebung gesetzter Server (MAIL_HOST /
* TESSERA_SMTP_HOST). Der letzte Rueckfall `localhost:1025` (Mailhog in
* der Entwicklung) zaehlt NICHT — sonst saehe die Oberflaeche einen
* Versandweg, der im Betrieb ins Leere geht.
*/
async hasConfiguredTransport(tenantId: string): Promise<boolean> {
const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId);
if (smtpConfig) return true;
const envHost =
this.configService.get<string>('MAIL_HOST') ??
this.configService.get<string>('TESSERA_SMTP_HOST');
return typeof envHost === 'string' && envHost.trim() !== '';
} }
/** /**
@@ -0,0 +1,213 @@
import { DEFAULT_WELCOME_MAIL_TEXTS, findUnknownWelcomeMailPlaceholders } from '@tessera/shared';
import { describe, expect, it } from 'vitest';
import {
applyWelcomeMailPlaceholders,
firstNameOf,
renderWelcomeMail,
type WelcomeMailInput,
} from './welcome-mail.template';
/**
* Willkommensmail mit eigener Vorlage: Platzhalter, Escaping, Absaetze,
* feste Bausteine.
*/
const base: WelcomeMailInput = {
name: 'Max Muster',
username: 'max.muster',
email: 'max@example.invalid',
tenantName: 'Muster & Co',
appUrl: 'https://tessera.example.invalid/',
account: { kind: 'directory' },
headerImageSrc: null,
};
/** Standard-Anmeldehinweise, damit Vorlagen im Test vollstaendig sind. */
const hints = {
loginHintDirectory: DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory,
loginHintLocal: DEFAULT_WELCOME_MAIL_TEXTS.loginHintLocal,
};
const localAccount = {
kind: 'local',
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
validHours: 168,
} as const;
const values = {
name: 'Max Muster',
vorname: 'Max',
benutzername: 'max.muster',
email: 'max@example.invalid',
adresse: 'https://t.example.invalid',
firma: 'Muster & Co',
};
describe('Platzhalter', () => {
it('ersetzt alle sechs, Gross-/Kleinschreibung und Leerraum egal; unbekannte bleiben stehen', () => {
expect(
applyWelcomeMailPlaceholders(
'{{name}}|{{vorname}}|{{benutzername}}|{{email}}|{{adresse}}|{{firma}}|{{ NAME }}|{{xyz}}',
values,
),
).toBe(
'Max Muster|Max|max.muster|max@example.invalid|https://t.example.invalid|Muster & Co|Max Muster|{{xyz}}',
);
});
it('findUnknownWelcomeMailPlaceholders nennt jeden unbekannten einmal', () => {
expect(findUnknownWelcomeMailPlaceholders('{{a}} {{name}} {{a}} {{ b }} {{}}')).toEqual([
'{{a}}',
'{{b}}',
'{{}}',
]);
expect(findUnknownWelcomeMailPlaceholders(DEFAULT_WELCOME_MAIL_TEXTS.heading)).toEqual([]);
});
it('vorname: erstes Wort des Anzeigenamens, sonst Benutzername', () => {
expect(firstNameOf(' Erika Mustermann ', 'erika')).toBe('Erika');
expect(firstNameOf(null, 'erika')).toBe('erika');
expect(firstNameOf(' ', 'erika')).toBe('erika');
});
});
describe('renderWelcomeMail mit Vorlage', () => {
it('ohne Vorlage: Standard-Betreff und -Ueberschrift', () => {
const mail = renderWelcomeMail(base);
expect(mail.subject).toBe('Willkommen bei Tessera');
expect(mail.html).toContain('Willkommen bei Tessera, Max Muster!');
expect(mail.html).toContain('Viel Erfolg mit Tessera!');
});
it('Text ist reiner Text: HTML aus Vorlage und Werten wird escaped; Firma mit & korrekt', () => {
const mail = renderWelcomeMail({
...base,
name: '<script>x</script>',
texts: {
subject: 'Hallo\r\n{{firma}}',
heading: '<h2>{{name}}</h2>',
intro: '<a href="javascript:alert(1)">klick</a>',
...hints,
closing: '',
},
});
expect(mail.subject).toBe('Hallo Muster & Co');
expect(mail.html).not.toContain('<script>x</script>');
expect(mail.html).not.toContain('<h2>');
expect(mail.html).not.toContain('href="javascript:');
expect(mail.html).toContain('&lt;h2&gt;&lt;script&gt;x&lt;/script&gt;&lt;/h2&gt;');
expect(mail.html).toContain('<title>Hallo Muster &amp; Co</title>');
});
it('Leerzeile = neuer Absatz, einfacher Umbruch = <br>; leerer Abschluss faellt weg', () => {
const mail = renderWelcomeMail({
...base,
texts: { subject: 's', heading: 'h', intro: 'A1\nA2\r\n\r\n\nB', ...hints, closing: ' ' },
});
expect(mail.html).toContain('>A1<br>A2</p>');
expect(mail.html).toContain('>B</p>');
expect(mail.html).not.toContain('Viel Erfolg');
expect(mail.text).toContain('A1\nA2\n\nB');
});
it('feste Bausteine bleiben bei jeder Vorlage: Adresse, Benutzername, Anmeldehinweis, Zu Tessera, Fusszeile', () => {
const mail = renderWelcomeMail({
...base,
account: {
kind: 'local',
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
validHours: 168,
},
texts: { subject: 'x', heading: 'y', intro: '', ...hints, closing: '' },
});
expect(mail.html).toContain('https://tessera.example.invalid/reset-password/abc');
expect(mail.html).toContain('Passwort festlegen');
expect(mail.html).toContain('7 Tage');
expect(mail.html).toContain('Zu Tessera');
expect(mail.html).toContain('max.muster');
expect(mail.html).toContain('im Auftrag Ihres Administrators');
});
it('local-example: Knopf zur Anmeldeseite mit Beispiel-Hinweis, kein reset-password', () => {
const mail = renderWelcomeMail({
...base,
account: { kind: 'local-example' },
notice: 'Testmail',
});
expect(mail.html).toContain('Beispiel-Link');
expect(mail.html).not.toContain('reset-password');
expect(mail.html).toContain('Testmail');
expect(mail.text.startsWith('[Testmail]')).toBe(true);
});
});
describe('Anmeldehinweis je Kontoart', () => {
it('Standard: Verzeichniskonto bekommt den Windows-Hinweis, lokales Konto den Hinweis vor dem Knopf', () => {
const dir = renderWelcomeMail(base);
expect(dir.html).toContain('gewohnten Windows-Passwort');
expect(dir.html).not.toContain('persönliches Passwort fest');
const local = renderWelcomeMail({ ...base, account: localAccount });
expect(local.html).toContain('legen Sie bitte Ihr persönliches Passwort fest');
expect(local.html).not.toContain('Windows-Passwort');
// Hinweis steht vor dem Knopf
expect(local.html.indexOf('persönliches Passwort fest')).toBeLessThan(
local.html.indexOf('Passwort festlegen</a>'),
);
});
it('eigener Text je Kontoart: Platzhalter ersetzt, escaped, Leerzeile = Absatz; Knopf und Gueltigkeit bleiben fest', () => {
const texts = {
...DEFAULT_WELCOME_MAIL_TEXTS,
loginHintDirectory:
'Anmeldung als {{benutzername}} mit <b>Firmenkennwort</b>.\n\nFragen? IT anrufen.',
loginHintLocal: 'Hallo {{vorname}}, bitte zuerst ein Passwort setzen.',
};
const dir = renderWelcomeMail({ ...base, texts });
expect(dir.html).toContain(
'Anmeldung als max.muster mit &lt;b&gt;Firmenkennwort&lt;/b&gt;.</p>',
);
expect(dir.html).toContain('>Fragen? IT anrufen.</p>');
expect(dir.html).not.toContain('Windows-Passwort');
expect(dir.html).not.toContain('bitte zuerst ein Passwort setzen');
expect(dir.text).toContain(
'Anmeldung als max.muster mit <b>Firmenkennwort</b>.\n\nFragen? IT anrufen.',
);
const local = renderWelcomeMail({ ...base, account: localAccount, texts });
expect(local.html).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
expect(local.html).not.toContain('Firmenkennwort');
expect(local.html).toContain('reset-password/abc');
expect(local.html).toContain('7 Tage');
expect(local.text).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
const example = renderWelcomeMail({ ...base, account: { kind: 'local-example' }, texts });
expect(example.html).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
});
it('leerer oder fehlender Hinweis (aeltere Aufrufer) → Standardtext', () => {
const { loginHintDirectory: _d, loginHintLocal: _l, ...old } = DEFAULT_WELCOME_MAIL_TEXTS;
const dir = renderWelcomeMail({ ...base, texts: old as any });
expect(dir.html).toContain('gewohnten Windows-Passwort');
const local = renderWelcomeMail({
...base,
account: localAccount,
texts: { ...DEFAULT_WELCOME_MAIL_TEXTS, loginHintLocal: ' ' },
});
expect(local.html).toContain('persönliches Passwort fest');
});
it('local-no-link behaelt den festen Hinweis', () => {
const mail = renderWelcomeMail({ ...base, account: { kind: 'local-no-link' } });
expect(mail.html).toContain('Ihr Startpasswort erhalten Sie von Ihrem Administrator.');
});
});
describe('Kopf (Outlook ohne CID-Bild)', () => {
it('Wellenstreifen 600x40 in einer Zelle mit dunkler Kopffarbe, Inhalt beginnt mit 24px Abstand', () => {
const mail = renderWelcomeMail({ ...base, headerImageSrc: 'cid:welcome-header@tessera' });
expect(mail.html).toMatch(
/<td bgcolor="#1a1c20" style="background-color:#1a1c20;line-height:0;font-size:0;">\n<img src="cid:welcome-header@tessera" width="600" height="40"/,
);
expect(mail.html).toContain('padding:24px 40px 12px;');
});
});
+419
View File
@@ -0,0 +1,419 @@
/**
* welcome-mail.template.ts — Inhalt und Gestaltung der Willkommensmail.
*
* Reine Funktion ohne Abhaengigkeiten: bekommt die fertigen Werte
* (Anzeigename, Benutzername, Adresse, Anmeldeweg) und liefert Betreff,
* Text-Alternative und HTML. Versand und Kopfbild-Anhang erledigt
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
* Anmeldehinweis" `WelcomeMailService`.
*
* Eigene Vorlage (Administrator → Willkommensmail): Betreff, Ueberschrift,
* Einleitung, die zwei Anmeldehinweise (Verzeichniskonto / lokales Konto)
* und Abschluss kommen als `texts` herein (Vorlage des Mandanten
* des ZIEL-Benutzers, sonst `DEFAULT_WELCOME_MAIL_TEXTS` aus
* `@tessera/shared`). Platzhalter (`{{name}}`, `{{vorname}}`,
* `{{benutzername}}`, `{{email}}`, `{{adresse}}`, `{{firma}}`) werden auf dem
* REINEN Text ersetzt und erst danach escaped. Leerzeile = neuer Absatz,
* einfacher Umbruch = `<br>`. Alles andere bleibt fest.
*
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
* Mail sehr unterschiedlich darstellen:
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
* - keine externen Ressourcen, keine Web-Fonts (Systemschriften);
* - Kopf: Bildmarke und Schriftzug als HTML (erscheinen immer), darunter
* die Duenen-Welle als schmaler PNG-Streifen (`headerImageSrc`, im
* Versand `cid:`), weil SVG und CSS-Hintergruende in Outlook nicht
* erscheinen; fehlt das Bild, bleibt nur ein schmaler Abschluss;
* - Knoepfe als Tabelle mit Hintergrundfarbe in der Zelle ("bulletproof"),
* Outlook ignoriert Innenabstaende und Rundungen am Link selbst.
*
* Sicherheit: jeder eingesetzte Wert laeuft durch `escapeHtml`; Links werden
* nur als http(s) uebernommen. Ein Kennwort steht NIE in der Mail — lokale
* Konten bekommen einen Link zum Festlegen (Token wie beim
* "Passwort vergessen"-Weg), verzeichnisgefuehrte den Hinweis auf das
* Windows-Passwort.
*/
import {
DEFAULT_WELCOME_MAIL_TEXTS,
isWelcomeMailPlaceholder,
WELCOME_MAIL_PLACEHOLDER_RE,
type WelcomeMailPlaceholder,
type WelcomeMailTexts,
} from '@tessera/shared';
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
const C = {
page: '#eceef1',
card: '#ffffff',
ink: '#1a1d21',
body: '#3d4450',
muted: '#6b7280',
line: '#e3e5e9',
well: '#f7f7f5',
yellow: '#ffed00',
link: '#1d5fc2',
header: '#1a1c20',
} as const;
const FONT =
"'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', Arial, sans-serif";
/** Anmeldeweg des Empfaengers — entscheidet den Hinweis in der Mail. */
export type WelcomeMailAccount =
| { kind: 'directory' }
| { kind: 'local'; setPasswordUrl: string; validHours: number }
/**
* Lokales Konto ohne Link. Wird heute nirgends erzeugt (`WelcomeMailService`
* legt fuer lokale Konten immer einen Token an); deshalb bleibt ihr Hinweis
* fest und ist nicht Teil der Vorlage.
*/
| { kind: 'local-no-link' }
/**
* Nur Testmail aus Administrator → Willkommensmail: derselbe Baustein wie
* `local`, aber OHNE Token — der Knopf fuehrt zur Anmeldeseite und ein
* Hinweis sagt, was er in der echten Mail tut.
*/
| { kind: 'local-example' };
export interface WelcomeMailInput {
/** Anzeigename, sonst Benutzername. */
name: string;
username: string;
/** E-Mail-Adresse des Empfaengers (Platzhalter `{{email}}`). */
email: string;
/** Name des Mandanten (Platzhalter `{{firma}}`). */
tenantName: string;
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
appUrl: string;
account: WelcomeMailAccount;
/** `cid:...` im Versand, `data:` in der Vorschau, `null` = Textkopf. */
headerImageSrc: string | null;
/** Eigene Vorlage des Mandanten; fehlt sie, gelten die Standardtexte. */
texts?: WelcomeMailTexts | null;
/** Hinweisleiste ganz oben (nur Testmail), reiner Text. */
notice?: string | null;
}
export interface RenderedWelcomeMail {
subject: string;
text: string;
html: string;
}
export const WELCOME_MAIL_SUBJECT = DEFAULT_WELCOME_MAIL_TEXTS.subject;
const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.';
const EXAMPLE_LINK_NOTE =
'Beispiel-Link: In der echten Willkommensmail führt dieser Knopf zu einem persönlichen Link, mit dem der neue Benutzer sein Passwort festlegt. In dieser Testmail öffnet er nur die Anmeldeseite.';
/** Werte der Platzhalter, noch NICHT escaped (Ersetzung laeuft auf reinem Text). */
export type WelcomeMailPlaceholderValues = Record<WelcomeMailPlaceholder, string>;
/** Erstes Wort des Anzeigenamens, sonst der Benutzername. */
export function firstNameOf(displayName: string | null | undefined, username: string): string {
const first = (displayName ?? '').trim().split(/\s+/)[0];
return first || username;
}
/**
* Setzt die Platzhalter in einen REINEN Text ein (Gross-/Kleinschreibung
* egal). Unbekannte bleiben stehen — gespeicherte Vorlagen enthalten keine,
* das prueft die API beim Speichern. Das Ergebnis ist weiter reiner Text und
* wird erst beim Einbau ins HTML escaped.
*/
export function applyWelcomeMailPlaceholders(
text: string,
values: WelcomeMailPlaceholderValues,
): string {
return text.replace(WELCOME_MAIL_PLACEHOLDER_RE, (whole, name: string) =>
isWelcomeMailPlaceholder(name) ? values[name.toLowerCase() as WelcomeMailPlaceholder] : whole,
);
}
/** Zeilenenden vereinheitlichen, Leerraum an den Raendern entfernen. */
function normalizeText(value: string): string {
return value.replace(/\r\n?/g, '\n').trim();
}
/** Einzeilig (Betreff, Ueberschrift): jeder Umbruch/Leerraum-Lauf wird ein Leerzeichen. */
function singleLine(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
/** Absaetze: Leerzeile trennt, einfacher Umbruch bleibt im Absatz. */
function paragraphsOf(value: string): string[] {
const text = normalizeText(value);
if (!text) return [];
return text
.split(/\n[ \t]*\n+/)
.map((part) => part.trim())
.filter(Boolean);
}
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** Nur http(s)-Adressen gelangen in ein href; alles andere wird leer. */
function safeUrl(value: string): string {
return /^https?:\/\//i.test(value) ? value : '';
}
/**
* Anmeldehinweis je Kontoart als REINER Text (Platzhalter noch nicht
* ersetzt). Verzeichnis- und lokale Konten nehmen den Text der Vorlage; ist
* er leer oder fehlt er (aeltere Aufrufer), gilt der Standardtext.
*/
function loginHintText(account: WelcomeMailAccount, texts: Partial<WelcomeMailTexts>): string {
const pick = (field: 'loginHintDirectory' | 'loginHintLocal') => {
const value = texts[field];
return typeof value === 'string' && value.trim() ? value : DEFAULT_WELCOME_MAIL_TEXTS[field];
};
switch (account.kind) {
case 'directory':
return pick('loginHintDirectory');
case 'local':
case 'local-example':
return pick('loginHintLocal');
case 'local-no-link':
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
}
}
function validityText(hours: number): string {
const span =
hours % 24 === 0 && hours >= 24
? hours === 24
? '1 Tag'
: `${hours / 24} Tage`
: hours === 1
? '1 Stunde'
: `${hours} Stunden`;
return `Der Link ist ${span} gültig und nur einmal verwendbar. Ist er abgelaufen, fordern Sie auf der Anmeldeseite über „Passwort vergessen?“ einfach einen neuen an.`;
}
/** Knopf als Tabelle: Farbe an der Zelle, damit Outlook ihn als Flaeche zeigt. */
function button(href: string, label: string, bg: string, fg: string): string {
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
<tr><td align="center" bgcolor="${bg}" style="background-color:${bg};border-radius:6px;mso-padding-alt:14px 30px;">
<a href="${escapeHtml(href)}" target="_blank" style="display:inline-block;padding:14px 30px;font-family:${FONT};font-size:16px;line-height:20px;font-weight:600;color:${fg};text-decoration:none;border-radius:6px;">${escapeHtml(label)}</a>
</td></tr></table>`;
}
/** Eine Kachel der Bildmarke: feste Zelle, Hoehe auch in Outlook exakt. */
function tile(color: string | null): string {
const bg = color ? `bgcolor="${color}" style="background-color:${color};` : 'style="';
return `<td width="9" height="9" ${bg}width:9px;height:9px;font-size:1px;line-height:9px;mso-line-height-rule:exactly;">&nbsp;</td>`;
}
/** Luecke zwischen Kacheln. */
const GAP = '<td width="3" style="width:3px;font-size:1px;line-height:1px;">&nbsp;</td>';
/**
* Bildmarke als HTML (quick-260930): das Kachel-"T" aus Tabellenzellen —
* oben drei Kacheln (die dritte gelb, im Original gedreht), darunter zwei in
* der Mitte —, auf dunkler Grundplatte mit heller Kontur wie in der App.
* Braucht kein Bild und erscheint deshalb in jedem Mailprogramm.
*/
function logoMark(): string {
const row = (cells: Array<string | null>) =>
`<tr>${cells.map((c, i) => (i > 0 ? GAP : '') + tile(c)).join('')}</tr>`;
const spacer = `<tr><td colspan="5" height="3" style="height:3px;font-size:1px;line-height:3px;mso-line-height-rule:exactly;">&nbsp;</td></tr>`;
const olive = '#9c9440';
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
<tr><td bgcolor="#111214" style="background-color:#111214;border:1px solid #3a3d44;border-radius:10px;padding:10px 10px 10px 10px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
${row([olive, olive, C.yellow])}${spacer}${row([null, olive, null])}${spacer}${row([null, olive, null])}
</table>
</td></tr></table>`;
}
/**
* Kopf der Mail (quick-260930, Rueckmeldung des Nutzers: in Outlook "ein
* riesiger schwarzer Fleck, kein Logo"). Vorher steckten Logo und
* Schriftzug in EINEM 150 px hohen Bild; zeigt ein Mailprogramm das
* eingebettete Bild nicht an, blieb nur die dunkle Flaeche. Jetzt:
* - Bildmarke (HTML-Kacheln) und Schriftzug "Tessera" (echter Text) in einer
* niedrigen dunklen Leiste — erscheinen immer;
* - darunter die Duenen-Welle als schmaler Bildstreifen (`src`, im Versand
* `cid:`, 600x40), der ins Weiss der Karte auslaeuft. Die Zelle um das
* Bild traegt die dunkle Kopffarbe (zweite Rueckmeldung des Nutzers: sein
* Outlook zeigt das CID-Bild nicht, vorher stand dort eine "riesengrosse
* weisse Luecke"): fehlt das Bild, wirkt der Kopf nur etwas hoeher.
*/
function headerRow(src: string | null): string {
const bar = `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:22px 32px 14px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr>
<td valign="middle" style="padding:0 14px 0 0;">${logoMark()}</td>
<td valign="middle" style="font-family:${FONT};font-size:26px;line-height:32px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">Tessera</td>
</tr></table>
</td></tr>`;
const wave = src
? `<tr><td bgcolor="${C.header}" style="background-color:${C.header};line-height:0;font-size:0;">
<img src="${escapeHtml(src)}" width="600" height="40" alt="" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;">
</td></tr>`
: `<tr><td bgcolor="${C.header}" height="4" style="background-color:${C.header};height:4px;font-size:1px;line-height:4px;border-bottom:3px solid ${C.yellow};">&nbsp;</td></tr>`;
return bar + wave;
}
/**
* Baut die Willkommensmail. Die sechs Texte (Betreff, Ueberschrift,
* Einleitung, Anmeldehinweis je Kontoart, Abschluss) kommen aus der eigenen
* Vorlage des Mandanten, sonst
* aus `DEFAULT_WELCOME_MAIL_TEXTS`. Sie sind REINER Text: Platzhalter werden
* auf dem Text ersetzt, erst danach wird alles escaped — HTML aus der
* Vorlage oder aus einem Benutzerwert erscheint als Text, nie als Markup.
* Kopf, Kasten Adresse/Benutzername, Knopf "Passwort festlegen" mit
* Gueltigkeitshinweis, Knopf "Zu Tessera" und Fusszeile sind feste Bausteine
* und nicht Teil der Vorlage.
*/
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
const loginUrl = `${base}/login`;
const texts = input.texts ?? DEFAULT_WELCOME_MAIL_TEXTS;
const values: WelcomeMailPlaceholderValues = {
name: input.name,
vorname: firstNameOf(input.name, input.username),
benutzername: input.username,
email: input.email,
adresse: base,
firma: input.tenantName,
};
const fill = (value: string) => applyWelcomeMailPlaceholders(value, values);
const subject = singleLine(fill(texts.subject)) || WELCOME_MAIL_SUBJECT;
const heading = singleLine(fill(texts.heading));
const introParagraphs = paragraphsOf(fill(texts.intro));
const hintParagraphs = paragraphsOf(fill(loginHintText(input.account, texts)));
const closingParagraphs = paragraphsOf(fill(texts.closing));
const notice = input.notice ? singleLine(input.notice) : '';
// ── Text-Alternative ───────────────────────────────────────────────────
const textLines: string[] = [];
if (notice) textLines.push(`[${notice}]`, '');
if (heading) textLines.push(heading, '');
for (const para of introParagraphs) textLines.push(para, '');
textLines.push('Ihre Zugangsdaten', `Adresse: ${base}`, `Benutzername: ${input.username}`);
for (const para of hintParagraphs) textLines.push('', para);
if (input.account.kind === 'local') {
textLines.push(
'',
'Passwort festlegen:',
input.account.setPasswordUrl,
validityText(input.account.validHours),
);
} else if (input.account.kind === 'local-example') {
textLines.push('', 'Passwort festlegen:', loginUrl, EXAMPLE_LINK_NOTE);
}
textLines.push('', 'Zu Tessera:', loginUrl);
for (const para of closingParagraphs) textLines.push('', para);
textLines.push('', '--', FOOTER);
const text = textLines.join('\n');
// ── HTML ───────────────────────────────────────────────────────────────
const username = escapeHtml(input.username);
const baseHtml = escapeHtml(base);
const p = (content: string, extra = '') =>
`<p style="margin:0 0 16px;font-family:${FONT};font-size:16px;line-height:25px;color:${C.body};${extra}">${content}</p>`;
/** Reiner Text -> escaped, einfache Umbrueche als <br>. */
const para = (value: string) => escapeHtml(value).replace(/\n/g, '<br>');
const label = (content: string) =>
`<div style="font-family:${FONT};font-size:12px;line-height:16px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:${C.muted};">${content}</div>`;
let accountBlock = hintParagraphs
.map((part, i) => p(para(part), i === 0 ? 'margin:24px 0 16px;' : ''))
.join('\n');
if (input.account.kind === 'local') {
const setUrl = safeUrl(input.account.setPasswordUrl);
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(validityText(input.account.validHours))}</p>`;
} else if (input.account.kind === 'local-example') {
accountBlock += `${button(loginUrl, 'Passwort festlegen', C.ink, '#ffffff')}
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(EXAMPLE_LINK_NOTE)}</p>`;
}
const noticeRow = notice
? `<tr><td style="padding:0 0 12px;"><table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td bgcolor="#fff8c2" style="background-color:#fff8c2;border:1px solid #e6d74c;border-radius:8px;padding:10px 16px;font-family:${FONT};font-size:13px;line-height:20px;color:${C.ink};">${escapeHtml(notice)}</td></tr></table></td></tr>
`
: '';
const headingHtml = heading
? `<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">${escapeHtml(heading)}</h1>
`
: '';
const introHtml = introParagraphs.map((part) => p(para(part))).join('\n');
const closingHtml = closingParagraphs.length
? `<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
${closingParagraphs
.map((part, i) =>
p(
para(part),
`font-size:14px;line-height:22px;margin:0${i < closingParagraphs.length - 1 ? ' 0 12px' : ''};`,
),
)
.join('\n')}
</td></tr></table>`
: '';
const html = `<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>${escapeHtml(subject)}</title>
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
<style>
a { color: ${C.link}; }
@media only screen and (max-width: 620px) {
.tsr-pad { padding-left: 24px !important; padding-right: 24px !important; }
}
</style>
</head>
<body style="margin:0;padding:0;background-color:${C.page};-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${C.page};">Ihr Zugang zu Tessera ist eingerichtet – hier finden Sie Adresse und Benutzername.</div>
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="${C.page}" style="background-color:${C.page};">
<tr><td align="center" style="padding:32px 12px;">
<!--[if mso]><table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;border-collapse:separate;">
${noticeRow}${headerRow(input.headerImageSrc)}
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:24px 40px 12px;">
${headingHtml}${introHtml}
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;margin:8px 0 0;">
<tr><td bgcolor="${C.well}" style="background-color:${C.well};border:1px solid ${C.line};border-left:4px solid ${C.yellow};border-radius:8px;padding:18px 22px;">
${label('Adresse')}
<div style="margin:4px 0 14px;font-family:${FONT};font-size:16px;line-height:24px;font-weight:600;"><a href="${escapeHtml(loginUrl)}" target="_blank" style="color:${C.link};text-decoration:none;">${baseHtml}</a></div>
${label('Benutzername')}
<div style="margin:4px 0 0;font-family:Consolas,'SF Mono',Menlo,'Courier New',monospace;font-size:16px;line-height:24px;font-weight:600;color:${C.ink};">${username}</div>
</td></tr></table>
${accountBlock}
</td></tr>
<tr><td class="tsr-pad" bgcolor="${C.card}" align="left" style="background-color:${C.card};padding:20px 40px 8px;">
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
</td></tr>
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:20px 40px 36px;border-radius:0 0 12px 12px;">
${closingHtml}
</td></tr>
<tr><td align="center" style="padding:20px 24px 0;font-family:${FONT};font-size:12px;line-height:18px;color:${C.muted};">${escapeHtml(FOOTER)}</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>`;
return { subject, text, html };
}
+30 -2
View File
@@ -1,4 +1,3 @@
import { PartialType } from '@nestjs/mapped-types';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
@@ -7,6 +6,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
ValidateIf,
} from 'class-validator'; } from 'class-validator';
const trimString = ({ value }: { value: unknown }) => const trimString = ({ value }: { value: unknown }) =>
@@ -44,12 +44,40 @@ export class CreateReminderDto {
emailEnabled?: boolean; emailEnabled?: boolean;
} }
/** Nur ein FEHLENDES Feld wird uebersprungen — `null` wird geprueft und damit abgelehnt. */
const whenPresent = ValidateIf((_obj: object, value: unknown) => value !== undefined);
/** /**
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen. * Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
* Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer * Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer
* gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`). * gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`).
*
* WARUM KEIN `PartialType`: das setzt `@IsOptional()`, und das laesst auch
* `null` ungeprueft durch — `{"title": null}` kaeme dann als 500 aus der
* Datenbank statt als 400 aus der Pruefung. `whenPresent` ueberspringt nur
* fehlende Felder.
*/ */
export class UpdateReminderDto extends PartialType(CreateReminderDto) {} export class UpdateReminderDto {
@whenPresent
@Transform(trimString)
@IsString()
@IsNotEmpty()
@MaxLength(200)
title?: string;
@whenPresent
@IsString()
@MaxLength(2000)
description?: string;
@whenPresent
@IsISO8601({ strict: true })
dueAt?: string;
@whenPresent
@IsBoolean()
emailEnabled?: boolean;
}
/** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */ /** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */
export class SnoozeReminderDto { export class SnoozeReminderDto {
@@ -48,7 +48,11 @@ function row(over: Partial<Row> & { id: string }): Row {
const sameTime = (a: Date | null, b: Date | null) => const sameTime = (a: Date | null, b: Date | null) =>
a === null || b === null ? a === b : a.getTime() === b.getTime(); a === null || b === null ? a === b : a.getTime() === b.getTime();
function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u1@example.invalid' }) { function makeStore(
rows: Row[],
emails: Record<string, string | null> = { u1: 'u1@example.invalid' },
inactive: string[] = [],
) {
const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => { const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => {
let list = rows.filter( let list = rows.filter(
(r) => (r) =>
@@ -58,8 +62,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
r.dueAt.getTime() <= where.dueAt.lte.getTime() && r.dueAt.getTime() <= where.dueAt.lte.getTime() &&
r.dueAt.getTime() >= where.dueAt.gte.getTime(), r.dueAt.getTime() >= where.dueAt.gte.getTime(),
); );
if (orderBy?.dueAt === 'asc') list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime()); if (orderBy?.dueAt === 'asc')
return list.slice(0, take).map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt })); list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
return list
.slice(0, take)
.map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt }));
}); });
const boundLog: string[] = []; const boundLog: string[] = [];
@@ -69,11 +76,13 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
boundLog.push(`updateMany:${tenantId}`); boundLog.push(`updateMany:${tenantId}`);
let count = 0; let count = 0;
for (const r of rows) { for (const r of rows) {
if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId) continue; if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId)
continue;
if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue; if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue;
if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue; if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue;
if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue; if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue;
if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt)) continue; if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt))
continue;
if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt; if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt;
if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment; if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment;
count++; count++;
@@ -89,7 +98,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
}), }),
}, },
user: { user: {
findFirst: vi.fn(async ({ where }: any) => ({ email: emails[where.id] ?? null })), findFirst: vi.fn(async ({ where }: any) => {
const isActive = !inactive.includes(where.id);
if ('isActive' in where && where.isActive !== isActive) return null;
return { email: emails[where.id] ?? null };
}),
}, },
}); });
@@ -100,10 +113,7 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
return { prisma, rows, systemFindMany, boundLog }; return { prisma, rows, systemFindMany, boundLog };
} }
function makeScheduler( function makeScheduler(prisma: any, opts: { smtp?: unknown; sendResult?: boolean | Error } = {}) {
prisma: any,
opts: { smtp?: unknown; sendResult?: boolean | Error } = {},
) {
const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() }; const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() };
const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) }; const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) };
const mail = { const mail = {
@@ -112,7 +122,12 @@ function makeScheduler(
return opts.sendResult ?? true; return opts.sendResult ?? true;
}), }),
}; };
const scheduler = new ReminderMailScheduler(registry as any, prisma, settings as any, mail as any); const scheduler = new ReminderMailScheduler(
registry as any,
prisma,
settings as any,
mail as any,
);
return { scheduler, registry, settings, mail }; return { scheduler, registry, settings, mail };
} }
@@ -126,7 +141,9 @@ describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => {
const one = makeScheduler(store.prisma); const one = makeScheduler(store.prisma);
const two = makeScheduler(store.prisma); const two = makeScheduler(store.prisma);
await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]); await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]);
expect(one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length).toBe(1); expect(
one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length,
).toBe(1);
expect(store.rows[0].emailSentAt).toEqual(NOW); expect(store.rows[0].emailSentAt).toEqual(NOW);
expect(store.rows[0].emailAttempts).toBe(1); expect(store.rows[0].emailAttempts).toBe(1);
}); });
@@ -223,6 +240,23 @@ describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => {
expect(mail.sendReminderEmail).not.toHaveBeenCalled(); expect(mail.sendReminderEmail).not.toHaveBeenCalled();
expect(store.rows[0].emailSentAt).toEqual(NOW); expect(store.rows[0].emailSentAt).toEqual(NOW);
}); });
it('deaktivierter Benutzer: kein Versand, Anspruch bleibt wie ohne Adresse', async () => {
const store = makeStore([row({ id: 'a' })], { u1: 'u1@example.invalid' }, ['u1']);
const { scheduler, mail } = makeScheduler(store.prisma);
const spy = vi.spyOn(store.prisma, '__tenantClient');
await scheduler.runTick(NOW);
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
expect(store.rows[0].emailSentAt).toEqual(NOW);
expect(store.rows[0].emailAttempts).toBe(1);
const client = spy.mock.results[0].value;
expect(client.user.findFirst.mock.calls[0][0].where).toEqual({
id: 'u1',
tenantId: 't1',
isActive: true,
});
});
}); });
describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => { describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => {
@@ -26,7 +26,7 @@ const BATCH = 200;
* Scheitert der Transport, gibt der Planer den Anspruch wieder frei * Scheitert der Transport, gibt der Planer den Anspruch wieder frei
* (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht — * (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht —
* hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse * hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse
* des Benutzers, bleibt der Anspruch: die Faelligkeit gilt als erledigt und * des Benutzers oder ist sein Konto deaktiviert (`isActive = false`), bleibt der Anspruch: die Faelligkeit gilt als erledigt und
* wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben * wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben
* („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`). * („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`).
* *
@@ -104,9 +104,7 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
await this.processCandidate(candidate, now); await this.processCandidate(candidate, now);
} catch (err) { } catch (err) {
// Eine kaputte Zeile darf die uebrigen nicht anhalten. // Eine kaputte Zeile darf die uebrigen nicht anhalten.
this.logger.error( this.logger.error(`Reminder email for ${candidate.id} failed: ${(err as Error).message}`);
`Reminder email for ${candidate.id} failed: ${(err as Error).message}`,
);
} }
} }
} finally { } finally {
@@ -140,14 +138,15 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt }, where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt },
select: { title: true, description: true, dueAt: true }, select: { title: true, description: true, dueAt: true },
}); });
// Nur aktive Konten: ein deaktivierter Benutzer gilt wie einer ohne Adresse.
const user = await tenantPrisma.user.findFirst({ const user = await tenantPrisma.user.findFirst({
where: { id: c.userId, tenantId: c.tenantId }, where: { id: c.userId, tenantId: c.tenantId, isActive: true },
select: { email: true }, select: { email: true },
}); });
const smtp = await this.settingsService.getSmtpConfig(c.tenantId); const smtp = await this.settingsService.getSmtpConfig(c.tenantId);
if (!row || !user?.email || smtp === null) { if (!row || !user?.email || smtp === null) {
this.logger.log( this.logger.log(
`Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user?.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`, `Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user ? 'Benutzer deaktiviert' : !user.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`,
); );
return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04) return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04)
} }
@@ -1,5 +1,5 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { ForbiddenException, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto'; import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
@@ -24,7 +24,14 @@ const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
const proto = RemindersController.prototype as any; const proto = RemindersController.prototype as any;
describe('RemindersController — Rollen', () => { describe('RemindersController — Rollen', () => {
it.each(['list', 'emailStatus', 'create', 'update', 'snooze', 'remove'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => { it.each([
'list',
'emailStatus',
'create',
'update',
'snooze',
'remove',
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
}); });
@@ -55,7 +62,9 @@ describe('RemindersController — Mandant', () => {
const controller = new RemindersController(makeService() as any); const controller = new RemindersController(makeService() as any);
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(
ForbiddenException,
);
await expect( await expect(
controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }), controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }),
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
@@ -96,6 +105,28 @@ describe('RemindersController — Pipe fuer Aendern und Verschieben', () => {
expect(out).toEqual({ title: 'b' }); expect(out).toEqual({ title: 'b' });
}); });
it.each([
'title',
'description',
'dueAt',
'emailEnabled',
])('Aendern mit %s: null ergibt 400 statt eines Datenbankfehlers', async (field) => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
await expect(
pipe.transform({ [field]: null }, { type: 'body', metatype: UpdateReminderDto }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('Aendern: ein leerer Titel wird abgelehnt, ein leeres Objekt ist erlaubt', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
await expect(
pipe.transform({ title: ' ' }, { type: 'body', metatype: UpdateReminderDto }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
pipe.transform({}, { type: 'body', metatype: UpdateReminderDto }),
).resolves.toEqual({});
});
it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => { it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true }); const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform( const out: any = await pipe.transform(
@@ -16,7 +16,13 @@ function makeFakePrisma() {
const reminder = { const reminder = {
create: vi.fn(async ({ data }: { data: any }) => { create: vi.fn(async ({ data }: { data: any }) => {
const id = `r-${++seq}`; const id = `r-${++seq}`;
const row = { id, createdAt: new Date(), updatedAt: new Date(), emailEnabled: false, ...data }; const row = {
id,
createdAt: new Date(),
updatedAt: new Date(),
emailEnabled: false,
...data,
};
rows.set(id, row); rows.set(id, row);
return row; return row;
}), }),
@@ -37,6 +43,14 @@ function makeFakePrisma() {
rows.set(where.id, row); rows.set(where.id, row);
return row; return row;
}), }),
// Wie die eine SQL-Anweisung: Bedingung pruefen und schreiben ohne `await` dazwischen.
updateMany: vi.fn(async ({ where, data }: { where: any; data: any }) => {
const r = rows.get(where.id);
if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return { count: 0 };
if (where.dueAt?.gt && !(r.dueAt.getTime() > where.dueAt.gt.getTime())) return { count: 0 };
rows.set(where.id, { ...r, ...data });
return { count: 1 };
}),
delete: vi.fn(async ({ where }: { where: any }) => { delete: vi.fn(async ({ where }: { where: any }) => {
rows.delete(where.id); rows.delete(where.id);
}), }),
@@ -106,7 +120,9 @@ describe('RemindersService — anlegen', () => {
service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }), service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }),
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(ConflictException);
// ein anderer Benutzer ist davon nicht betroffen // ein anderer Benutzer ist davon nicht betroffen
await expect(service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) })).resolves.toBeTruthy(); await expect(
service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) }),
).resolves.toBeTruthy();
}); });
}); });
@@ -153,15 +169,21 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => { ])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => {
const { prisma, service } = setup(); const { prisma, service } = setup();
seed(prisma, 'x', past(), owner); seed(prisma, 'x', past(), owner);
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(NotFoundException); await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(NotFoundException); NotFoundException,
);
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException); await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.rows.has('x')).toBe(true); expect(prisma.rows.has('x')).toBe(true);
}); });
it('eine unbekannte Kennung ergibt 404', async () => { it('eine unbekannte Kennung ergibt 404', async () => {
const { service } = setup(); const { service } = setup();
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(NotFoundException); await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(
NotFoundException,
);
}); });
it('das where jeder Abfrage traegt Mandant und Benutzer', async () => { it('das where jeder Abfrage traegt Mandant und Benutzer', async () => {
@@ -173,10 +195,11 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
tenantId: 't1', tenantId: 't1',
userId: 'u1', userId: 'u1',
}); });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.where).toEqual({ expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.where).toEqual({
id: 'x', id: 'x',
tenantId: 't1', tenantId: 't1',
userId: 'u1', userId: 'u1',
dueAt: { gt: expect.any(Date) },
}); });
}); });
}); });
@@ -185,7 +208,9 @@ describe('RemindersService — aendern', () => {
it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => { it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => {
const { prisma, service } = setup(); const { prisma, service } = setup();
seed(prisma, 'x', past()); seed(prisma, 'x', past());
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(ConflictException); await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
ConflictException,
);
}); });
it('eine vergangene neue Faelligkeit ergibt 400', async () => { it('eine vergangene neue Faelligkeit ergibt 400', async () => {
@@ -200,14 +225,61 @@ describe('RemindersService — aendern', () => {
const { prisma, service } = setup(); const { prisma, service } = setup();
seed(prisma, 'x', future()); seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'neu' }); await service.update('t1', 'u1', 'x', { title: 'neu' });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' }); expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
const newDue = inHours(9); const newDue = inHours(9);
await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' }); await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' });
expect(prisma.reminder.update.mock.calls[1]?.[0]?.data).toEqual({ expect(prisma.reminder.updateMany.mock.calls[1]?.[0]?.data).toEqual({
description: '', description: '',
dueAt: new Date(newDue), dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
}); });
}); });
it('eine neue Faelligkeit setzt emailSentAt und emailAttempts zurueck, sonst bleiben sie', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'neu' });
expect(prisma.rows.get('x').emailAttempts).toBe(2);
expect(prisma.rows.get('x').emailSentAt).not.toBeNull();
const newDue = inHours(5);
const out: any = await service.update('t1', 'u1', 'x', { dueAt: newDue });
expect(prisma.rows.get('x')).toMatchObject({
dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
});
expect(out.dueAt).toEqual(new Date(newDue));
});
it('wird die Erinnerung zwischen Pruefung und Schreiben faellig, gibt es 409 und keine Aenderung', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
// Nach dem Laden (Pruefung bestanden) ist die Zeile inzwischen faellig.
prisma.reminder.findFirst.mockImplementationOnce(async () => {
const r = prisma.rows.get('x');
const loaded = { ...r };
prisma.rows.set('x', { ...r, dueAt: past() });
return loaded;
});
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
ConflictException,
);
expect(prisma.rows.get('x').title).toBe('alt');
});
it('wird die Erinnerung zwischen Pruefung und Schreiben geloescht, gibt es 404', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
prisma.reminder.findFirst.mockImplementationOnce(async () => {
const loaded = { ...prisma.rows.get('x') };
prisma.rows.delete('x');
return loaded;
});
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
}); });
describe('RemindersService — spaeter erinnern (D-03)', () => { describe('RemindersService — spaeter erinnern (D-03)', () => {
@@ -276,7 +348,10 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
hasEmail: false, hasEmail: false,
}); });
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail'); expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail');
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({ id: 'u-ohne-mail', tenantId: 't1' }); expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({
id: 'u-ohne-mail',
tenantId: 't1',
});
}); });
it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => { it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => {
@@ -298,7 +373,11 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
const noMail = setup(); const noMail = setup();
await expect( await expect(
noMail.service.create('t1', 'u-ohne-mail', { title: 'a', dueAt: inHours(1), emailEnabled: true }), noMail.service.create('t1', 'u-ohne-mail', {
title: 'a',
dueAt: inHours(1),
emailEnabled: true,
}),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(noSmtp.prisma.rows.size).toBe(0); expect(noSmtp.prisma.rows.size).toBe(0);
expect(noMail.prisma.rows.size).toBe(0); expect(noMail.prisma.rows.size).toBe(0);
@@ -311,6 +390,6 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
BadRequestException, BadRequestException,
); );
await service.update('t1', 'u1', 'x', { emailEnabled: false }); await service.update('t1', 'u1', 'x', { emailEnabled: false });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false }); expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
}); });
}); });
+38 -6
View File
@@ -5,8 +5,8 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SettingsService } from '../settings/settings.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { SettingsService } from '../settings/settings.service';
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto'; import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */ /** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
@@ -107,26 +107,58 @@ export class RemindersService {
/** /**
* Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu * Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu
* (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03). * (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03).
*
* WARUM `updateMany` MIT `dueAt > jetzt`: Pruefung und Schreiben sind sonst
* getrennt — wird die Erinnerung dazwischen faellig (und der E-Mail-Planer
* hat sie womoeglich schon beansprucht), wuerde trotzdem geschrieben. Die
* Bedingung im selben Schreibzugriff schliesst das aus; `count === 0` heisst
* dann 409 (oder 404, falls die Zeile inzwischen geloescht ist).
*
* Eine neue Faelligkeit setzt wie beim Verschieben die Spuren des
* E-Mail-Planers zurueck, damit zur neuen Faelligkeit eine Mail geht.
*/ */
async update(tenantId: string, userId: string, id: string, dto: UpdateReminderDto) { async update(tenantId: string, userId: string, id: string, dto: UpdateReminderDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const row = await this.loadOwn(tenantPrisma, tenantId, userId, id); const row = await this.loadOwn(tenantPrisma, tenantId, userId, id);
if (row.dueAt.getTime() <= Date.now()) { const now = Date.now();
if (row.dueAt.getTime() <= now) {
throw new ConflictException('Die Erinnerung ist bereits fällig'); throw new ConflictException('Die Erinnerung ist bereits fällig');
} }
const data: { title?: string; description?: string; dueAt?: Date; emailEnabled?: boolean } = {}; const data: {
title?: string;
description?: string;
dueAt?: Date;
emailEnabled?: boolean;
emailSentAt?: null;
emailAttempts?: number;
} = {};
if (dto.title !== undefined) data.title = dto.title; if (dto.title !== undefined) data.title = dto.title;
if (dto.description !== undefined) data.description = dto.description; if (dto.description !== undefined) data.description = dto.description;
if (dto.dueAt !== undefined) data.dueAt = this.assertValidDueAt(dto.dueAt); if (dto.dueAt !== undefined) {
data.dueAt = this.assertValidDueAt(dto.dueAt);
data.emailSentAt = null;
data.emailAttempts = 0;
}
if (dto.emailEnabled !== undefined) { if (dto.emailEnabled !== undefined) {
if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId); if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId);
data.emailEnabled = dto.emailEnabled; data.emailEnabled = dto.emailEnabled;
} }
return tenantPrisma.reminder.update({ const result = await tenantPrisma.reminder.updateMany({
where: { id, tenantId, userId }, where: { id, tenantId, userId, dueAt: { gt: new Date() } },
data, data,
});
if (result.count !== 1) {
await this.loadOwn(tenantPrisma, tenantId, userId, id); // inzwischen geloescht: 404
throw new ConflictException('Die Erinnerung ist bereits fällig');
}
const updated = await tenantPrisma.reminder.findFirst({
where: { id, tenantId, userId },
select: REMINDER_SELECT, select: REMINDER_SELECT,
}); });
if (!updated) {
throw new NotFoundException('Erinnerung nicht gefunden');
}
return updated;
} }
/** /**
@@ -0,0 +1,97 @@
import {
findUnknownWelcomeMailPlaceholders,
WELCOME_MAIL_LIMITS,
type WelcomeMailTexts,
} from '@tessera/shared';
import {
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
Validate,
type ValidationArguments,
ValidatorConstraint,
type ValidatorConstraintInterface,
} from 'class-validator';
/** Feldnamen fuer die Fehlermeldung (Oberflaeche zeigt sie unveraendert). */
const FIELD_LABELS: Record<keyof WelcomeMailTexts, string> = {
subject: 'Betreff',
heading: 'Überschrift',
intro: 'Einleitungstext',
loginHintDirectory: 'Anmeldehinweis für Verzeichniskonten',
loginHintLocal: 'Anmeldehinweis für lokale Konten',
closing: 'Abschlusstext',
};
/**
* Nur bekannte Platzhalter (`{{name}}`, `{{vorname}}`, `{{benutzername}}`,
* `{{email}}`, `{{adresse}}`, `{{firma}}`). Ein unbekannter wird mit 400
* abgelehnt und in der Meldung genannt ("Unbekannter Platzhalter {{xyz}}"),
* statt spaeter woertlich in der Mail zu stehen.
*/
@ValidatorConstraint({ name: 'nurBekanntePlatzhalter', async: false })
class NurBekanntePlatzhalterConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
return typeof value !== 'string' || findUnknownWelcomeMailPlaceholders(value).length === 0;
}
defaultMessage(args: ValidationArguments): string {
const unknown =
typeof args.value === 'string' ? findUnknownWelcomeMailPlaceholders(args.value) : [];
const label = FIELD_LABELS[args.property as keyof WelcomeMailTexts] ?? args.property;
const noun = unknown.length > 1 ? 'Unbekannte Platzhalter' : 'Unbekannter Platzhalter';
return `${label}: ${noun} ${unknown.join(', ')}`;
}
}
/**
* Inhalt der eigenen Vorlage (PUT /welcome-mail-template, auch Grundlage fuer
* Vorschau und Testmail). Reiner Text: HTML wird beim Rendern escaped, nicht
* hier abgewiesen — ein "<" im Text ist erlaubt und erscheint als Zeichen.
* Betreff, Ueberschrift und beide Anmeldehinweise sind Pflicht, Einleitung
* und Abschluss duerfen leer sein (der Abschnitt faellt dann weg).
*/
export class WelcomeMailTemplateDto implements WelcomeMailTexts {
@IsString()
@Matches(/\S/, { message: 'Bitte geben Sie einen Betreff ein.' })
@MaxLength(WELCOME_MAIL_LIMITS.subject)
@Validate(NurBekanntePlatzhalterConstraint)
subject!: string;
@IsString()
@Matches(/\S/, { message: 'Bitte geben Sie eine Überschrift ein.' })
@MaxLength(WELCOME_MAIL_LIMITS.heading)
@Validate(NurBekanntePlatzhalterConstraint)
heading!: string;
@IsString()
@MaxLength(WELCOME_MAIL_LIMITS.intro)
@Validate(NurBekanntePlatzhalterConstraint)
intro!: string;
@IsString()
@Matches(/\S/, { message: 'Bitte geben Sie einen Anmeldehinweis für Verzeichniskonten ein.' })
@MaxLength(WELCOME_MAIL_LIMITS.loginHintDirectory)
@Validate(NurBekanntePlatzhalterConstraint)
loginHintDirectory!: string;
@IsString()
@Matches(/\S/, { message: 'Bitte geben Sie einen Anmeldehinweis für lokale Konten ein.' })
@MaxLength(WELCOME_MAIL_LIMITS.loginHintLocal)
@Validate(NurBekanntePlatzhalterConstraint)
loginHintLocal!: string;
@IsString()
@MaxLength(WELCOME_MAIL_LIMITS.closing)
@Validate(NurBekanntePlatzhalterConstraint)
closing!: string;
}
/** Vorschau: zusaetzlich die Kontoart, fuer die der Anmeldehinweis gezeigt wird. */
export class WelcomeMailPreviewDto extends WelcomeMailTemplateDto {
@IsOptional()
@IsIn(['directory', 'local'])
account?: 'directory' | 'local';
}
+80 -2
View File
@@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
@@ -124,11 +124,13 @@ describe('UserController', () => {
let prisma: any; let prisma: any;
let userService: any; let userService: any;
let controller: UserController; let controller: UserController;
let welcomeMail: { isAvailable: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn> };
beforeEach(() => { beforeEach(() => {
prisma = makeFakePrisma(); prisma = makeFakePrisma();
userService = makeUserServiceMock(); userService = makeUserServiceMock();
controller = new UserController(userService as any, prisma); welcomeMail = { isAvailable: vi.fn(), send: vi.fn() };
controller = new UserController(userService as any, prisma, welcomeMail as any);
}); });
describe('findAll', () => { describe('findAll', () => {
@@ -663,4 +665,80 @@ describe('UserController', () => {
expect(await storedFor('t2', 'u-other')).toBeNull(); expect(await storedFor('t2', 'u-other')).toBeNull();
}); });
}); });
describe('Willkommensmail (POST :id/welcome-mail, GET welcome-mail/status)', () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't9', id: 'super1', username: 'super1', mustChangePassword: false };
const sentAt = new Date('2026-09-30T10:00:00.000Z');
it('ADMIN: Ziel gebunden an den eigenen Mandanten aufgeloest, Dienst mit Ziel und Origin gerufen, Antwort traegt Empfaenger und Zeitpunkt', async () => {
const target = { id: 'u-x', username: 'x', tenantId: 't1', role: 'USER', lastLoginAt: null };
userService.findById.mockResolvedValue(target);
welcomeMail.send.mockResolvedValue({ to: 'x@x.invalid', welcomeMailSentAt: sentAt });
const result = await controller.sendWelcomeMail('u-x', admin, 'https://tessera.example.invalid');
expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x');
expect(welcomeMail.send).toHaveBeenCalledWith(target, 'https://tessera.example.invalid');
expect(result).toEqual({ success: true, to: 'x@x.invalid', welcomeMailSentAt: sentAt });
});
it('ADMIN: unbekannter oder fremdmandantiger Benutzer (gebundene Aufloesung null) → NotFoundException, kein Versand', async () => {
userService.findById.mockResolvedValue(null);
await expect(controller.sendWelcomeMail('u-fremd', admin)).rejects.toBeInstanceOf(NotFoundException);
expect(welcomeMail.send).not.toHaveBeenCalled();
});
it('ADMIN: Ziel eines fremden Mandanten (zweite Schicht) → ForbiddenException mit Mandanten-Meldung, kein Versand', async () => {
userService.findById.mockResolvedValue({ id: 'u-y', tenantId: 't2', role: 'SUPER_ADMIN' });
await expect(controller.sendWelcomeMail('u-y', admin)).rejects.toThrow('Cannot modify users from other tenants');
expect(welcomeMail.send).not.toHaveBeenCalled();
});
it('ADMIN: Ziel ist SUPER_ADMIN des eigenen Mandanten → ForbiddenException (Zielrollen-Riegel), kein Versand', async () => {
userService.findById.mockResolvedValue({ id: 'u-s', tenantId: 't1', role: 'SUPER_ADMIN' });
await expect(controller.sendWelcomeMail('u-s', admin)).rejects.toThrow('Cannot modify a SUPER_ADMIN user');
expect(welcomeMail.send).not.toHaveBeenCalled();
});
it('SUPER_ADMIN: Ziel ueber die uebergreifende Aufloesung, auch in fremdem Mandanten und mit oberster Rolle', async () => {
const target = { id: 'u-z', tenantId: 't2', role: 'SUPER_ADMIN', lastLoginAt: null };
userService.findByIdForPlatformAdmin.mockResolvedValue(target);
welcomeMail.send.mockResolvedValue({ to: 'z@x.invalid', welcomeMailSentAt: sentAt });
await controller.sendWelcomeMail('u-z', superAdmin);
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-z');
expect(welcomeMail.send).toHaveBeenCalledWith(target, undefined);
});
it('Fehler des Dienstes (z. B. 409 Konto deaktiviert) gehen unveraendert nach aussen', async () => {
userService.findById.mockResolvedValue({ id: 'u-x', tenantId: 't1', role: 'USER' });
welcomeMail.send.mockRejectedValue(new ConflictException('deaktiviert'));
await expect(controller.sendWelcomeMail('u-x', admin)).rejects.toBeInstanceOf(ConflictException);
});
it('GET welcome-mail/status fragt den Versandweg fuer den Mandanten des Aufrufers ab', async () => {
welcomeMail.isAvailable.mockResolvedValue(true);
await expect(controller.getWelcomeMailStatus(admin)).resolves.toEqual({ available: true });
expect(welcomeMail.isAvailable).toHaveBeenCalledWith('t1');
});
it('findAll (ADMIN) liefert welcomeMailSentAt mit', async () => {
prisma.__seedUser({
id: 'u-a',
username: 'alice',
tenantId: 't1',
email: 'alice@x.invalid',
displayName: null,
role: 'USER',
isActive: true,
createdAt: new Date(),
lastLoginAt: null,
welcomeMailSentAt: sentAt,
});
const result = await controller.findAll(admin);
expect((result as any[])[0].welcomeMailSentAt).toBe(sentAt);
});
});
}); });
+66
View File
@@ -6,6 +6,7 @@ import {
ForbiddenException, ForbiddenException,
Get, Get,
HttpCode, HttpCode,
Headers,
HttpStatus, HttpStatus,
NotFoundException, NotFoundException,
Param, Param,
@@ -39,6 +40,7 @@ import { CreateUserDto } from './dto/create-user.dto';
import { ReleaseSeenDto } from './dto/release-seen.dto'; import { ReleaseSeenDto } from './dto/release-seen.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
import { WelcomeMailService } from './welcome-mail.service';
/** Map accepted MIME types to file extensions (T-gbh-01). */ /** Map accepted MIME types to file extensions (T-gbh-01). */
const AVATAR_MIME_TO_EXT: Record<string, string> = { const AVATAR_MIME_TO_EXT: Record<string, string> = {
@@ -68,6 +70,10 @@ function resolveAvatarsDir(): string {
* quick-260928-ujj: dazu kommt ein gebundener Zugriff des * quick-260928-ujj: dazu kommt ein gebundener Zugriff des
* Selbstbedienungswegs `PATCH me/dashboard-background`, ebenfalls * Selbstbedienungswegs `PATCH me/dashboard-background`, ebenfalls
* `forTenant()` mit `where: { id: currentUser.id }`. * `forTenant()` mit `where: { id: currentUser.id }`.
*
* Willkommensmail: `POST :id/welcome-mail` loest den Zielbenutzer ueber
* `resolveTargetUser` auf; die beiden Schreibzugriffe (Token, Versandzeit)
* liegen gebunden in `WelcomeMailService`, nicht in diesem Controller.
*/ */
@Controller('users') @Controller('users')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@@ -75,6 +81,7 @@ export class UserController {
constructor( constructor(
private readonly userService: UserService, private readonly userService: UserService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly welcomeMailService: WelcomeMailService,
) {} ) {}
/** /**
@@ -126,6 +133,7 @@ export class UserController {
tenantId: true, tenantId: true,
createdAt: true, createdAt: true,
lastLoginAt: true, lastLoginAt: true,
welcomeMailSentAt: true,
}, },
orderBy: { username: 'asc' }, orderBy: { username: 'asc' },
}); });
@@ -218,6 +226,23 @@ export class UserController {
return { success: true, lastSeenReleaseVersion: version }; return { success: true, lastSeenReleaseVersion: version };
} }
/**
* GET /users/welcome-mail/status (Willkommensmail)
*
* Ob fuer den Mandanten des Aufrufers ein Versandweg eingerichtet ist —
* die Oberflaeche deaktiviert damit den Knopf "Willkommensmail senden"
* und nennt den Grund. Die wirksame Pruefung bleibt beim Versand selbst
* (`POST :id/welcome-mail`, dort je Mandant des ZIELS). Statische Route
* steht VOR der Kennungs-Route (GET :id).
*/
@Get('welcome-mail/status')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getWelcomeMailStatus(
@CurrentUser() currentUser: AuthUser,
): Promise<{ available: boolean }> {
return { available: await this.welcomeMailService.isAvailable(currentUser.tenantId) };
}
/** /**
* GET /users/:id * GET /users/:id
*/ */
@@ -330,6 +355,47 @@ export class UserController {
return result; return result;
} }
/**
* POST /users/:id/welcome-mail (Willkommensmail)
*
* Schickt einem Benutzer (egal ob schon angemeldet) eine
* Willkommensmail (Adresse, Benutzername, Anmeldehinweis je Kontoart) und
* merkt den Zeitpunkt in `welcomeMailSentAt`. Dieselben Regeln wie beim
* Bearbeiten: ADMIN nur im eigenen Mandanten, SUPER_ADMIN uebergreifend;
* ein Nicht-SUPER_ADMIN darf einem SUPER_ADMIN nichts schicken
* (Zielrollen-Riegel wie in update()). Deaktiviert -> 409, ohne
* Adresse -> 400, ohne Versandweg -> 409, Versandfehler -> 502 (alles in
* `WelcomeMailService`). Der Origin-Kopf dient nur als Rueckfall fuer die
* Adresse in der Mail, wenn `TESSERA_APP_URL` fehlt.
*/
@Post(':id/welcome-mail')
@HttpCode(HttpStatus.OK)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async sendWelcomeMail(
@Param('id') id: string,
@CurrentUser() currentUser: AuthUser,
@Headers('origin') origin?: string,
): Promise<{ success: true; to: string; welcomeMailSentAt: Date }> {
const user = await this.resolveTargetUser(currentUser, id);
if (!user) {
throw new NotFoundException('User not found');
}
if (
currentUser.role !== Role.SUPER_ADMIN &&
user.tenantId !== currentUser.tenantId
) {
throw new ForbiddenException('Cannot modify users from other tenants');
}
if (user.role === Role.SUPER_ADMIN && currentUser.role !== Role.SUPER_ADMIN) {
throw new ForbiddenException('Cannot modify a SUPER_ADMIN user');
}
const result = await this.welcomeMailService.send(user, origin);
return { success: true, ...result };
}
/** /**
* DELETE /users/:id * DELETE /users/:id
* ADMIN cannot delete self or users from other tenants. * ADMIN cannot delete self or users from other tenants.
+17 -3
View File
@@ -1,19 +1,33 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { GroupsModule } from '../groups/groups.module'; import { GroupsModule } from '../groups/groups.module';
import { MailModule } from '../mail/mail.module';
import { AdminSeedService } from './admin-seed.service'; import { AdminSeedService } from './admin-seed.service';
import { UserController } from './user.controller'; import { UserController } from './user.controller';
import { UserService } from './user.service'; import { UserService } from './user.service';
import { WelcomeMailService } from './welcome-mail.service';
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
/** /**
* Importiert GroupsModule für UserService.create's Standardgruppen- * Importiert GroupsModule für UserService.create's Standardgruppen-
* Mitgliedschaft (D-11/D-12). GroupsModule importiert seinerseits * Mitgliedschaft (D-11/D-12). GroupsModule importiert seinerseits
* UserModule nicht — GroupsService greift für Benutzerprüfungen direkt * UserModule nicht — GroupsService greift für Benutzerprüfungen direkt
* auf PrismaService zu, keine Zirkularität. * auf PrismaService zu, keine Zirkularität.
*
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
* UserModule.
*
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
* `WelcomeMailTemplateService` (Speicherung je Mandant) und
* `WelcomeMailTemplateController` (`/welcome-mail-template`) liegen hier,
* weil Vorschau und Testmail denselben Weg wie der Versand nutzen
* (`WelcomeMailService`: Adresse, Kopfbild, SMTP des Mandanten).
*/ */
@Module({ @Module({
imports: [GroupsModule], imports: [GroupsModule, MailModule],
controllers: [UserController], controllers: [UserController, WelcomeMailTemplateController],
providers: [UserService, AdminSeedService], providers: [UserService, AdminSeedService, WelcomeMailService, WelcomeMailTemplateService],
exports: [UserService], exports: [UserService],
}) })
export class UserModule {} export class UserModule {}
+1
View File
@@ -37,6 +37,7 @@ const PLATFORM_USER_SELECT = {
tenantId: true, tenantId: true,
createdAt: true, createdAt: true,
lastLoginAt: true, lastLoginAt: true,
welcomeMailSentAt: true,
} as const; } as const;
/** /**
@@ -0,0 +1,181 @@
import 'reflect-metadata';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { WelcomeMailPreviewDto, WelcomeMailTemplateDto } from './dto/welcome-mail-template.dto';
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
/**
* Administrator → Willkommensmail (eigene Vorlage).
*
* Festgenagelt: alle Routen nur ADMIN/SUPER_ADMIN; Mandant IMMER aus dem
* Token (nie aus dem Body); unbekannte Platzhalter und zu lange Texte → 400
* mit Nennung des Platzhalters; die Testmail geht an den Aufrufer selbst.
*/
function makeTemplates() {
return {
getState: vi.fn(async (..._args: unknown[]) => ({ custom: false })),
save: vi.fn(async (..._args: unknown[]) => ({ custom: true })),
reset: vi.fn(async (..._args: unknown[]) => ({ custom: false })),
};
}
function makeWelcome() {
return {
preview: vi.fn(async (..._args: unknown[]) => ({ subject: 's', html: '<html>', text: 't' })),
sendTest: vi.fn(async (..._args: unknown[]) => ({ to: 'ada@example.invalid' })),
};
}
const admin = { id: 'u-admin', username: 'ada', role: 'ADMIN', tenantId: 't1' } as any;
const proto = WelcomeMailTemplateController.prototype as any;
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const valid = { ...DEFAULT_WELCOME_MAIL_TEXTS };
async function validateBody(body: unknown, metatype: any = WelcomeMailTemplateDto) {
return pipe.transform(body, { type: 'body', metatype });
}
async function messagesOf(body: unknown): Promise<string[]> {
try {
await validateBody(body);
} catch (error) {
expect(error).toBeInstanceOf(BadRequestException);
return ((error as BadRequestException).getResponse() as { message: string[] }).message;
}
throw new Error('erwartete 400');
}
describe('WelcomeMailTemplateController — Rechte', () => {
it.each([
'get',
'save',
'reset',
'preview',
'sendTest',
])('%s nur fuer ADMIN und SUPER_ADMIN', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual(['ADMIN', 'SUPER_ADMIN']);
});
it('haengt an Pfad welcome-mail-template', () => {
expect(Reflect.getMetadata('path', WelcomeMailTemplateController)).toBe(
'welcome-mail-template',
);
});
});
describe('WelcomeMailTemplateController — Mandant aus dem Token', () => {
it('reicht currentUser.tenantId (und Benutzername/-kennung) weiter, nie Werte aus dem Body', async () => {
const templates = makeTemplates();
const welcome = makeWelcome();
const controller = new WelcomeMailTemplateController(templates as any, welcome as any);
await controller.get(admin);
await controller.save(admin, { ...valid, tenantId: 'evil' } as any);
await controller.reset(admin);
await controller.preview(admin, { ...valid, account: 'local' }, 'https://o.example.invalid');
const result = await controller.sendTest(admin, valid as any, undefined);
expect(templates.getState).toHaveBeenCalledWith('t1');
expect(templates.save).toHaveBeenCalledWith('t1', valid, 'ada');
expect(templates.reset).toHaveBeenCalledWith('t1');
expect(welcome.preview).toHaveBeenCalledWith('t1', valid, 'local', 'https://o.example.invalid');
expect(welcome.sendTest).toHaveBeenCalledWith('t1', 'u-admin', valid, undefined);
expect(result).toEqual({ success: true, to: 'ada@example.invalid' });
});
it('Vorschau ohne Kontoart zeigt die Verzeichniskonto-Variante', async () => {
const welcome = makeWelcome();
const controller = new WelcomeMailTemplateController(makeTemplates() as any, welcome as any);
await controller.preview(admin, { ...valid });
expect(welcome.preview.mock.calls[0][2]).toBe('directory');
});
});
describe('WelcomeMailTemplateDto (globale ValidationPipe)', () => {
it('Standardtexte und alle bekannten Platzhalter (auch mit Leerraum/Grossschreibung) sind gueltig', async () => {
const out: any = await validateBody({
...valid,
intro: '{{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}} {{ Name }}',
tenantId: 'evil',
});
expect(out).not.toHaveProperty('tenantId');
});
it('unbekannter Platzhalter → 400 mit Feld und Platzhalter', async () => {
const messages = await messagesOf({ ...valid, heading: 'Hallo {{xyz}} und {{abc}}' });
expect(messages).toContain('Überschrift: Unbekannte Platzhalter {{xyz}}, {{abc}}');
});
it('ein unbekannter Platzhalter im Betreff → "Unbekannter Platzhalter {{xyz}}"', async () => {
const messages = await messagesOf({ ...valid, subject: 'Hi {{xyz}}' });
expect(messages).toContain('Betreff: Unbekannter Platzhalter {{xyz}}');
});
it('Laengen: Betreff/Ueberschrift 200, Anmeldehinweise 1000, Texte 4000', async () => {
await expect(
validateBody({ ...valid, subject: 'a'.repeat(200), intro: 'b'.repeat(4000) }),
).resolves.toBeTruthy();
await expect(messagesOf({ ...valid, subject: 'a'.repeat(201) })).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, heading: 'a'.repeat(201) })).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, intro: 'a'.repeat(4001) })).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, closing: 'a'.repeat(4001) })).resolves.toHaveLength(1);
await expect(
validateBody({
...valid,
loginHintDirectory: 'a'.repeat(1000),
loginHintLocal: 'b'.repeat(1000),
}),
).resolves.toBeTruthy();
await expect(
messagesOf({ ...valid, loginHintDirectory: 'a'.repeat(1001) }),
).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, loginHintLocal: 'a'.repeat(1001) })).resolves.toHaveLength(
1,
);
});
it('leerer Betreff/leere Ueberschrift → 400; leere Einleitung/Abschluss erlaubt', async () => {
expect(await messagesOf({ ...valid, subject: ' ' })).toContain(
'Bitte geben Sie einen Betreff ein.',
);
expect(await messagesOf({ ...valid, heading: '' })).toContain(
'Bitte geben Sie eine Überschrift ein.',
);
await expect(validateBody({ ...valid, intro: '', closing: '' })).resolves.toBeTruthy();
});
it('Anmeldehinweise: Pflicht, Platzhalter geprueft, Leerzeilen erlaubt', async () => {
expect(await messagesOf({ ...valid, loginHintDirectory: ' \n ' })).toContain(
'Bitte geben Sie einen Anmeldehinweis für Verzeichniskonten ein.',
);
expect(await messagesOf({ ...valid, loginHintLocal: '' })).toContain(
'Bitte geben Sie einen Anmeldehinweis für lokale Konten ein.',
);
expect(await messagesOf({ ...valid, loginHintLocal: 'Hallo {{kennwort}}' })).toContain(
'Anmeldehinweis für lokale Konten: Unbekannter Platzhalter {{kennwort}}',
);
await expect(
validateBody({ ...valid, loginHintDirectory: 'Hallo {{ Vorname }}\n\nZweiter Absatz' }),
).resolves.toBeTruthy();
const { loginHintLocal: _l, ...rest } = valid;
await expect(messagesOf(rest)).resolves.not.toHaveLength(0);
});
it('fehlendes Feld → 400', async () => {
const { closing: _closing, ...rest } = valid;
await expect(messagesOf(rest)).resolves.not.toHaveLength(0);
});
it('Vorschau: nur directory/local als Kontoart', async () => {
await expect(
validateBody({ ...valid, account: 'local' }, WelcomeMailPreviewDto),
).resolves.toBeTruthy();
await expect(
validateBody({ ...valid, account: 'root' }, WelcomeMailPreviewDto),
).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -0,0 +1,112 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpStatus,
Post,
Put,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthUser } from '../auth/types/auth-user';
import { WelcomeMailPreviewDto, WelcomeMailTemplateDto } from './dto/welcome-mail-template.dto';
import { WelcomeMailService } from './welcome-mail.service';
import {
WelcomeMailTemplateService,
type WelcomeMailTemplateState,
} from './welcome-mail-template.service';
/** Nur die sechs Texte aus dem DTO — nie weitere Felder in die Vorlage. */
function textsOf(dto: WelcomeMailTemplateDto) {
return {
subject: dto.subject,
heading: dto.heading,
intro: dto.intro,
loginHintDirectory: dto.loginHintDirectory,
loginHintLocal: dto.loginHintLocal,
closing: dto.closing,
};
}
/**
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
*
* Alle Routen nur fuer ADMIN und SUPER_ADMIN (`@Roles`), immer fuer den
* Mandanten des angemeldeten Administrators (`currentUser.tenantId`, aus dem
* Token) — ein Mandant aus der Anfrage wird nie uebernommen. Unbekannte
* Platzhalter und zu lange Texte weist das DTO mit 400 ab.
*
* - GET /welcome-mail-template wirksame Texte + Standardtexte
* - PUT /welcome-mail-template eigene Vorlage speichern
* - DELETE /welcome-mail-template auf Standard zuruecksetzen
* - POST /welcome-mail-template/preview gerendertes HTML (Beispielwerte)
* - POST /welcome-mail-template/test Testmail an die eigene Adresse
*
* Keine Kennungs-Route (`:id`) — eine Vorlage je Mandant —, deshalb keine
* Reihenfolge-Falle.
*/
@Controller('welcome-mail-template')
export class WelcomeMailTemplateController {
constructor(
private readonly templateService: WelcomeMailTemplateService,
private readonly welcomeMailService: WelcomeMailService,
) {}
@Get()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async get(@CurrentUser() currentUser: AuthUser): Promise<WelcomeMailTemplateState> {
return this.templateService.getState(currentUser.tenantId);
}
@Put()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async save(
@CurrentUser() currentUser: AuthUser,
@Body() dto: WelcomeMailTemplateDto,
): Promise<WelcomeMailTemplateState> {
return this.templateService.save(currentUser.tenantId, textsOf(dto), currentUser.username);
}
@Delete()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async reset(@CurrentUser() currentUser: AuthUser): Promise<WelcomeMailTemplateState> {
return this.templateService.reset(currentUser.tenantId);
}
@Post('preview')
@HttpCode(HttpStatus.OK)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async preview(
@CurrentUser() currentUser: AuthUser,
@Body() dto: WelcomeMailPreviewDto,
@Headers('origin') origin?: string,
): Promise<{ subject: string; html: string; text: string }> {
return this.welcomeMailService.preview(
currentUser.tenantId,
textsOf(dto),
dto.account ?? 'directory',
origin,
);
}
@Post('test')
@HttpCode(HttpStatus.OK)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async sendTest(
@CurrentUser() currentUser: AuthUser,
@Body() dto: WelcomeMailTemplateDto,
@Headers('origin') origin?: string,
): Promise<{ success: true; to: string }> {
const result = await this.welcomeMailService.sendTest(
currentUser.tenantId,
currentUser.id,
textsOf(dto),
origin,
);
return { success: true, ...result };
}
}
@@ -0,0 +1,153 @@
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
/**
* WelcomeMailTemplateService — Speicherung der eigenen Vorlage je Mandant.
*
* Festgenagelt: jeder Zugriff gebunden an den uebergebenen Mandanten
* (`forTenant`), Standardtexte ohne Vorlage, Speichern als upsert auf
* `tenantId`, Zuruecksetzen loescht die Vorlage.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
}));
type Row = {
tenantId: string;
subject: string;
heading: string;
intro: string;
loginHintDirectory?: string | null;
loginHintLocal?: string | null;
closing: string;
updatedBy: string | null;
updatedAt: Date;
};
function makePrisma(rows: Row[] = []) {
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
return {
__log: log,
__rows: rows,
__bound(tenantId: string) {
return {
welcomeMailTemplate: {
findUnique: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'findUnique', args });
// RLS-Nachbildung: nur Zeilen des gebundenen Mandanten sichtbar
return (
rows.find((r) => r.tenantId === args.where.tenantId && r.tenantId === tenantId) ??
null
);
}),
upsert: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'upsert', args });
const existing = rows.find((r) => r.tenantId === args.where.tenantId);
if (existing) Object.assign(existing, args.update, { updatedAt: new Date() });
else rows.push({ ...args.create, updatedAt: new Date() });
return {};
}),
deleteMany: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'deleteMany', args });
const before = rows.length;
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].tenantId === args.where.tenantId) rows.splice(i, 1);
}
return { count: before - rows.length };
}),
},
tenant: {
findUnique: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'tenant', method: 'findUnique', args });
return args.where.id === 't1' ? { name: 'Beispiel GmbH' } : null;
}),
},
};
},
};
}
const texts = {
subject: 'S {{name}}',
heading: 'H',
intro: 'I',
loginHintDirectory: 'LD {{benutzername}}',
loginHintLocal: 'LL',
closing: 'C',
};
let prisma: ReturnType<typeof makePrisma>;
let service: WelcomeMailTemplateService;
beforeEach(() => {
prisma = makePrisma();
service = new WelcomeMailTemplateService(prisma as any);
});
describe('WelcomeMailTemplateService', () => {
it('ohne Vorlage: Standardtexte, custom=false, getCustomTexts=null', async () => {
const state = await service.getState('t1');
expect(state.custom).toBe(false);
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
expect(state.defaults).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
expect(await service.getCustomTexts('t1')).toBeNull();
expect(prisma.__log.every((c) => c.tenantId === 't1')).toBe(true);
});
it('speichern: upsert auf tenantId, gebunden, mit Bearbeiter; danach custom=true', async () => {
const state = await service.save('t1', texts, 'ada');
const upsert = prisma.__log.find((c) => c.method === 'upsert');
expect(upsert?.tenantId).toBe('t1');
expect(upsert?.args.where).toEqual({ tenantId: 't1' });
expect(upsert?.args.create).toEqual({ tenantId: 't1', ...texts, updatedBy: 'ada' });
expect(state.custom).toBe(true);
expect(state.texts).toEqual(texts);
expect(state.updatedBy).toBe('ada');
expect(await service.getCustomTexts('t1')).toEqual(texts);
});
it('aeltere Vorlage ohne Anmeldehinweise (NULL) → Standardtexte fuer diese Felder, Rest bleibt eigen', async () => {
prisma.__rows.push({
tenantId: 't1',
subject: 'Alt',
heading: 'H',
intro: 'I',
loginHintDirectory: null,
loginHintLocal: null,
closing: 'C',
updatedBy: null,
updatedAt: new Date(),
});
const state = await service.getState('t1');
expect(state.custom).toBe(true);
expect(state.texts.subject).toBe('Alt');
expect(state.texts.loginHintDirectory).toBe(DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory);
expect(state.texts.loginHintLocal).toBe(DEFAULT_WELCOME_MAIL_TEXTS.loginHintLocal);
});
it('Mandantenbindung: Vorlage von t1 ist fuer t2 nicht sichtbar', async () => {
await service.save('t1', texts, 'ada');
expect(await service.getCustomTexts('t2')).toBeNull();
expect(prisma.__log.filter((c) => c.method === 'findUnique').at(-1)?.tenantId).toBe('t2');
});
it('zuruecksetzen: loescht gebunden, danach wieder Standard', async () => {
await service.save('t1', texts, 'ada');
const state = await service.reset('t1');
const del = prisma.__log.find((c) => c.method === 'deleteMany');
expect(del?.tenantId).toBe('t1');
expect(del?.args.where).toEqual({ tenantId: 't1' });
expect(state.custom).toBe(false);
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
});
it('Firmenname gebunden gelesen; unbekannter Mandant → leer', async () => {
expect(await service.getTenantName('t1')).toBe('Beispiel GmbH');
expect(await service.getTenantName('tx')).toBe('');
expect(prisma.__log.filter((c) => c.model === 'tenant').map((c) => c.tenantId)).toEqual([
't1',
'tx',
]);
});
});
@@ -0,0 +1,125 @@
import { Injectable } from '@nestjs/common';
import { DEFAULT_WELCOME_MAIL_TEXTS, type WelcomeMailTexts } from '@tessera/shared';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* WelcomeMailTemplateService — eigene Vorlage der Willkommensmail
* (Administrator → Willkommensmail).
*
* Hoechstens eine Vorlage je Mandant (Tabelle "WelcomeMailTemplate",
* `tenantId` eindeutig). Gespeichert werden nur die sechs Texte; Kopf,
* Zugangsdaten, Knoepfe und Fusszeile bleiben fest in
* `welcome-mail.template.ts`. Fehlt die Vorlage, gelten
* `DEFAULT_WELCOME_MAIL_TEXTS` aus `@tessera/shared`. Die zwei
* Anmeldehinweise kamen spaeter hinzu (Spalten nullable): fehlt ein Wert in
* einer aelteren Vorlage, gilt fuer ihn der Standardtext.
*
* Mandantenbindung: jeder Zugriff laeuft ueber `forTenant(prisma, tenantId)`.
* Beim Versand ist `tenantId` der Mandant des ZIEL-Benutzers
* (`WelcomeMailService.send`), in der Verwaltung der Mandant des
* angemeldeten Administrators (`WelcomeMailTemplateController`). Die
* Platzhalter-Pruefung liegt im DTO (400 bei unbekanntem Platzhalter).
*/
/** Antwort von GET /welcome-mail-template. */
export interface WelcomeMailTemplateState {
/** `true` = eigene Vorlage gespeichert, `false` = Standardtexte. */
custom: boolean;
/** Wirksame Texte (eigene Vorlage oder Standard) — Vorbelegung im Formular. */
texts: WelcomeMailTexts;
/** Standardtexte fuer "Auf Standard zuruecksetzen". */
defaults: WelcomeMailTexts;
updatedAt: Date | null;
updatedBy: string | null;
}
const TEXT_SELECT = {
subject: true,
heading: true,
intro: true,
loginHintDirectory: true,
loginHintLocal: true,
closing: true,
updatedAt: true,
updatedBy: true,
} as const;
@Injectable()
export class WelcomeMailTemplateService {
constructor(private readonly prisma: PrismaService) {}
async getState(tenantId: string): Promise<WelcomeMailTemplateState> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.welcomeMailTemplate.findUnique({
where: { tenantId },
select: TEXT_SELECT,
});
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
if (!row) {
return { custom: false, texts: defaults, defaults, updatedAt: null, updatedBy: null };
}
return {
custom: true,
texts: {
subject: row.subject,
heading: row.heading,
intro: row.intro,
loginHintDirectory: row.loginHintDirectory?.trim()
? row.loginHintDirectory
: defaults.loginHintDirectory,
loginHintLocal: row.loginHintLocal?.trim() ? row.loginHintLocal : defaults.loginHintLocal,
closing: row.closing,
},
defaults,
updatedAt: row.updatedAt,
updatedBy: row.updatedBy,
};
}
/** Texte fuer den Versand: eigene Vorlage des Mandanten, sonst `null` (= Standard). */
async getCustomTexts(tenantId: string): Promise<WelcomeMailTexts | null> {
const state = await this.getState(tenantId);
return state.custom ? state.texts : null;
}
/** Name des Mandanten fuer `{{firma}}`; leer, wenn der Mandant fehlt. */
async getTenantName(tenantId: string): Promise<string> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenant = await tenantPrisma.tenant.findUnique({
where: { id: tenantId },
select: { name: true },
});
return tenant?.name ?? '';
}
async save(
tenantId: string,
texts: WelcomeMailTexts,
updatedBy: string,
): Promise<WelcomeMailTemplateState> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const data = {
subject: texts.subject,
heading: texts.heading,
intro: texts.intro,
loginHintDirectory: texts.loginHintDirectory,
loginHintLocal: texts.loginHintLocal,
closing: texts.closing,
updatedBy,
};
await tenantPrisma.welcomeMailTemplate.upsert({
where: { tenantId },
create: { tenantId, ...data },
update: data,
});
return this.getState(tenantId);
}
/** "Auf Standard zuruecksetzen": loescht die eigene Vorlage (idempotent). */
async reset(tenantId: string): Promise<WelcomeMailTemplateState> {
const tenantPrisma = forTenant(this.prisma, tenantId);
await tenantPrisma.welcomeMailTemplate.deleteMany({ where: { tenantId } });
return this.getState(tenantId);
}
}
@@ -0,0 +1,431 @@
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
import { DEFAULT_WELCOME_MAIL_TEXTS, type WelcomeMailTexts } from '@tessera/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
normalizeOrigin,
WelcomeMailService,
type WelcomeMailTarget,
} from './welcome-mail.service';
/**
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
*
* Festgenagelt: Versand auch an bereits angemeldete Benutzer; 409 fuer
* deaktivierte Benutzer,
* 400 ohne Adresse, 409 ohne Versandweg, 502 bei Versandfehler (und dann
* KEIN `welcomeMailSentAt`); beide Schreibzugriffe gebunden an den
* Mandanten des ZIELS; Inhalt je Kontoart (verzeichnisgefuehrt: Windows-
* Passwort, kein Token; lokal: Token-Link mit der Frist des
* "Passwort vergessen"-Wegs); Adresse aus Konfiguration bzw. Origin.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
}));
function makePrisma() {
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
return {
__log: log,
__bound(tenantId: string) {
return {
passwordResetToken: {
create: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'passwordResetToken', method: 'create', args });
return { id: 'tok-row', ...args.data };
}),
},
user: {
update: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'user', method: 'update', args });
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
}),
findFirst: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'user', method: 'findFirst', args });
return selfRow;
}),
},
};
},
};
}
function makeMail(opts: { available?: boolean; fail?: boolean } = {}) {
return {
hasConfiguredTransport: vi.fn(async () => opts.available ?? true),
sendWelcomeMail: vi.fn(async () => {
if (opts.fail) throw new Error('ECONNREFUSED');
}),
};
}
function makeConfig(values: Record<string, string | undefined>) {
return { get: vi.fn((key: string) => values[key]) };
}
const localUser: WelcomeMailTarget = {
id: 'u-local',
tenantId: 't1',
username: 'max.muster',
displayName: 'Max Muster',
email: 'max@example.invalid',
ldapDn: null,
isActive: true,
};
const ldapUser: WelcomeMailTarget = {
...localUser,
id: 'u-ldap',
username: 'erika',
displayName: null,
email: 'erika@example.invalid',
ldapDn: 'CN=Erika,OU=Users,DC=example,DC=invalid',
};
let prisma: ReturnType<typeof makePrisma>;
/** Eigenes Konto des Administrators fuer die Testmail (`user.findFirst`). */
let selfRow: {
username: string;
displayName: string | null;
email: string | null;
ldapDn: string | null;
} | null;
beforeEach(() => {
prisma = makePrisma();
selfRow = {
username: 'admin.lokal',
displayName: 'Ada Admin',
email: 'ada@example.invalid',
ldapDn: null,
};
});
/** Vorlagen-Dienst: eigene Vorlage je Mandant (Map), Firmenname je Mandant. */
function makeTemplates(custom: Record<string, WelcomeMailTexts> = {}) {
return {
getCustomTexts: vi.fn(async (tenantId: string) => custom[tenantId] ?? null),
getTenantName: vi.fn(async (tenantId: string) => `Firma ${tenantId}`),
};
}
function make(
mail = makeMail(),
config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' }),
templates = makeTemplates(),
) {
const service = new WelcomeMailService(
prisma as any,
mail as any,
config as any,
templates as any,
);
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
return { service, mail, templates };
}
describe('WelcomeMailService.send — Vorbedingungen', () => {
it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => {
const { service, mail } = make();
await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget);
await service.send({
...localUser,
lastLoginAt: new Date(),
role: 'SUPER_ADMIN',
} as WelcomeMailTarget);
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2);
expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2);
});
it('deaktiviertes Konto → ConflictException', async () => {
const { service, mail } = make();
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(
ConflictException,
);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
it('ohne E-Mail-Adresse → BadRequestException', async () => {
const { service, mail } = make();
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
it('ohne Versandweg → ConflictException mit Hinweis auf Administrator → SMTP; Pruefung im Mandanten des Ziels', async () => {
const mail = makeMail({ available: false });
const { service } = make(mail);
await expect(service.send(localUser)).rejects.toThrow('Administrator → SMTP');
expect(mail.hasConfiguredTransport).toHaveBeenCalledWith('t1');
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
expect(prisma.__log).toHaveLength(0);
});
});
describe('WelcomeMailService.send — Versand', () => {
it('lokales Konto: Token gebunden an den Mandanten des Ziels, Frist 7 Tage, Link in Text und HTML, kein Kennwort; welcomeMailSentAt gebunden gesetzt', async () => {
const { service, mail } = make();
const before = Date.now();
const result = await service.send(localUser);
const tokenCall = prisma.__log.find((c) => c.model === 'passwordResetToken');
expect(tokenCall?.tenantId).toBe('t1');
expect(tokenCall?.args.data.userId).toBe('u-local');
const ttl = (tokenCall as { args: any }).args.data.expiresAt.getTime() - before;
expect(ttl).toBeGreaterThan(7 * 24 * 60 * 60 * 1000 - 60 * 1000);
expect(ttl).toBeLessThanOrEqual(7 * 24 * 60 * 60 * 1000 + 1000);
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(1);
const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[];
expect(tenantId).toBe('t1');
expect(to).toBe('max@example.invalid');
const token = (tokenCall as { args: any }).args.data.token;
expect(rendered.text).toContain(`https://tessera.example.invalid/reset-password/${token}`);
expect(rendered.html).toContain(`https://tessera.example.invalid/reset-password/${token}`);
expect(rendered.html).toContain('Passwort festlegen');
expect(rendered.html).toContain('Willkommen bei Tessera, Max Muster!');
expect(rendered.text).toContain('Benutzername: max.muster');
expect(rendered.html).toContain('https://tessera.example.invalid/login');
expect(rendered.text).not.toContain('Windows-Passwort');
const updateCall = prisma.__log.find((c) => c.model === 'user' && c.method === 'update');
expect(updateCall?.tenantId).toBe('t1');
expect(updateCall?.args.where).toEqual({ id: 'u-local' });
expect(updateCall?.args.data.welcomeMailSentAt).toBeInstanceOf(Date);
expect(result.to).toBe('max@example.invalid');
expect(result.welcomeMailSentAt).toBe(updateCall?.args.data.welcomeMailSentAt);
});
it('verzeichnisgefuehrtes Konto: kein Token, Hinweis auf das Windows-Passwort, Anrede mit Benutzername ohne Anzeigenamen', async () => {
const { service, mail } = make();
await service.send(ldapUser);
expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.text).toContain('gewohnten Windows-Passwort');
expect(rendered.html).toContain('gewohnten Windows-Passwort');
expect(rendered.html).not.toContain('reset-password');
expect(rendered.html).not.toContain('Passwort festlegen');
expect(rendered.html).toContain('Willkommen bei Tessera, erika!');
});
it('Versandfehler → BadGatewayException, welcomeMailSentAt wird NICHT gesetzt', async () => {
const { service } = make(makeMail({ fail: true }));
await expect(service.send(ldapUser)).rejects.toBeInstanceOf(BadGatewayException);
expect(prisma.__log.some((c) => c.model === 'user')).toBe(false);
});
it('Anzeigename mit Markup wird im HTML maskiert', async () => {
const { service, mail } = make();
await service.send({ ...ldapUser, displayName: '<b>Böse</b>' });
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.html).not.toContain('<b>Böse</b>');
expect(rendered.html).toContain('&lt;b&gt;Böse&lt;/b&gt;');
});
});
describe('WelcomeMailService.resolveAppUrl', () => {
it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => {
const { service } = make(
makeMail(),
makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }),
);
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe(
'https://tessera.example.invalid',
);
});
it('ohne Konfiguration: Origin der Anfrage', () => {
const { service } = make(makeMail(), makeConfig({}));
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe(
'https://alpha.example.invalid',
);
});
it('Konfiguration zeigt nur auf localhost (Compose-Vorgabe) → Origin gewinnt; ohne Origin bleibt die Konfiguration', () => {
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'http://localhost:3000' }));
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe(
'https://alpha.example.invalid',
);
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
});
it('ungueltiger Origin wird ignoriert', () => {
expect(normalizeOrigin('javascript:alert(1)')).toBeNull();
expect(normalizeOrigin('kein origin')).toBeNull();
expect(normalizeOrigin('https://a.example.invalid/pfad')).toBe('https://a.example.invalid');
});
});
describe('WelcomeMailService.send — eigene Vorlage', () => {
const custom: WelcomeMailTexts = {
subject: 'Hallo {{vorname}} bei {{firma}}',
heading: 'Schön, dass Sie da sind, {{name}}!',
intro: 'Erste Zeile\nzweite Zeile\n\nIhr Konto: {{benutzername}} / {{email}} / {{adresse}}',
loginHintDirectory: 'Bitte mit dem Kennwort von {{firma}} anmelden, {{vorname}}.',
loginHintLocal: 'Zuerst ein eigenes Passwort vergeben, {{vorname}}.',
closing: 'Grüße vom <b>IT-Team</b>',
};
it('ohne eigene Vorlage: Standardtexte; Vorlage und Firmenname werden fuer den Mandanten des ZIELS gelesen', async () => {
const { service, mail, templates } = make();
await service.send({ ...ldapUser, tenantId: 't9' });
expect(templates.getCustomTexts).toHaveBeenCalledWith('t9');
expect(templates.getTenantName).toHaveBeenCalledWith('t9');
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.subject).toBe('Willkommen bei Tessera');
expect(rendered.html).toContain('Tessera gibt es auch als Desktop-App');
});
it('mit eigener Vorlage des Ziel-Mandanten: Platzhalter ersetzt, Absaetze/Umbrueche, Markup aus der Vorlage escaped, feste Bausteine bleiben', async () => {
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
await service.send(localUser);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.subject).toBe('Hallo Max bei Firma t1');
expect(rendered.html).toContain('Schön, dass Sie da sind, Max Muster!');
expect(rendered.html).toContain('Erste Zeile<br>zweite Zeile');
expect(rendered.html).toContain(
'Ihr Konto: max.muster / max@example.invalid / https://tessera.example.invalid',
);
expect(rendered.html).toContain('Grüße vom &lt;b&gt;IT-Team&lt;/b&gt;');
expect(rendered.html).not.toContain('<b>IT-Team</b>');
expect(rendered.html).not.toContain('Desktop-App');
// feste Bausteine
expect(rendered.html).toContain('Passwort festlegen');
expect(rendered.html).toContain('/reset-password/');
expect(rendered.html).toContain('Zu Tessera');
expect(rendered.html).toContain('Benutzername');
expect(rendered.text).toContain('Erste Zeile\nzweite Zeile');
// Anmeldehinweis fuer lokale Konten aus der Vorlage
expect(rendered.html).toContain('Zuerst ein eigenes Passwort vergeben, Max.');
expect(rendered.html).not.toContain('persönliches Passwort fest');
});
it('Verzeichniskonto mit eigener Vorlage: eigener Verzeichnis-Hinweis statt Windows-Standard', async () => {
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
await service.send(ldapUser);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.html).toContain('Bitte mit dem Kennwort von Firma t1 anmelden');
expect(rendered.html).not.toContain('gewohnten Windows-Passwort');
expect(rendered.html).not.toContain('Zuerst ein eigenes Passwort vergeben');
expect(rendered.text).toContain('Bitte mit dem Kennwort von Firma t1 anmelden');
});
it('Vorlage eines ANDEREN Mandanten wirkt nicht', async () => {
const { service, mail } = make(undefined, undefined, makeTemplates({ t2: custom }));
await service.send(localUser);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.subject).toBe('Willkommen bei Tessera');
});
it('Platzhalterwerte mit Markup werden escaped (Anzeigename in {{name}}/{{vorname}})', async () => {
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
await service.send({ ...ldapUser, displayName: '<img src=x onerror=alert(1)> Böse' });
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.html).not.toContain('<img src=x');
expect(rendered.html).toContain('&lt;img src=x onerror=alert(1)&gt; Böse');
});
});
const DEFAULT_TEXTS: WelcomeMailTexts = { ...DEFAULT_WELCOME_MAIL_TEXTS };
describe('WelcomeMailService.preview', () => {
const texts: WelcomeMailTexts = {
subject: 'Betreff {{name}}',
heading: 'Hallo {{vorname}}',
intro: 'Bei {{firma}}',
loginHintDirectory: 'Verzeichnis-Hinweis {{benutzername}}',
loginHintLocal: 'Lokal-Hinweis {{benutzername}}',
closing: '',
};
it('Beispielwerte, Firmenname des eigenen Mandanten, kein Token, kein Versand, kein cid:', async () => {
const { service, mail, templates } = make();
const rendered = await service.preview('t1', texts, 'local');
expect(templates.getTenantName).toHaveBeenCalledWith('t1');
expect(rendered.subject).toBe('Betreff Max Mustermann');
expect(rendered.html).toContain('Hallo Max');
expect(rendered.html).toContain('Bei Firma t1');
expect(rendered.html).toContain('/reset-password/beispiel');
expect(rendered.html).toContain('Lokal-Hinweis max.mustermann');
expect(rendered.html).not.toContain('Verzeichnis-Hinweis');
expect(rendered.html).not.toContain('cid:');
expect(prisma.__log).toHaveLength(0);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
it('Verzeichniskonto-Variante: Windows-Hinweis statt Passwort-Knopf', async () => {
const { service } = make();
const rendered = await service.preview('t1', DEFAULT_TEXTS, 'directory');
expect(rendered.html).toContain('gewohnten Windows-Passwort');
expect(rendered.html).not.toContain('Passwort festlegen');
const custom = await service.preview('t1', texts, 'directory');
expect(custom.html).toContain('Verzeichnis-Hinweis max.mustermann');
expect(custom.html).not.toContain('Lokal-Hinweis');
});
});
describe('WelcomeMailService.sendTest', () => {
const texts: WelcomeMailTexts = {
subject: 'Test {{name}}',
heading: 'Hallo {{vorname}}',
intro: 'Text',
loginHintDirectory: DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory,
loginHintLocal: 'Eigener Lokal-Hinweis für {{vorname}}',
closing: 'Gruß',
};
it('geht an die eigene Adresse, liest das eigene Konto gebunden, erzeugt KEIN Token und setzt KEIN welcomeMailSentAt', async () => {
const { service, mail } = make();
const result = await service.sendTest('t1', 'u-self', texts);
expect(result.to).toBe('ada@example.invalid');
const find = prisma.__log.find((c) => c.method === 'findFirst');
expect(find?.tenantId).toBe('t1');
expect(find?.args.where).toEqual({ id: 'u-self', tenantId: 't1' });
expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false);
expect(prisma.__log.some((c) => c.method === 'update')).toBe(false);
const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[];
expect(tenantId).toBe('t1');
expect(to).toBe('ada@example.invalid');
expect(rendered.subject).toBe('Test Ada Admin');
expect(rendered.html).toContain('Testmail');
// lokales Konto: Beispiel-Link zur Anmeldeseite, kein reset-password
expect(rendered.html).toContain('Passwort festlegen');
expect(rendered.html).toContain('Beispiel-Link');
expect(rendered.html).not.toContain('reset-password');
// Anmeldehinweis fuer lokale Konten aus dem Formularinhalt
expect(rendered.html).toContain('Eigener Lokal-Hinweis für Ada');
expect(rendered.text).not.toContain('reset-password');
});
it('verzeichnisgefuehrtes eigenes Konto: Windows-Hinweis', async () => {
selfRow = { ...(selfRow as NonNullable<typeof selfRow>), ldapDn: 'CN=Ada' };
const { service, mail } = make();
await service.sendTest('t1', 'u-self', texts);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.html).toContain('gewohnten Windows-Passwort');
expect(rendered.html).not.toContain('Passwort festlegen');
});
it('ohne eigene Adresse → BadRequestException, kein Versand', async () => {
selfRow = { ...(selfRow as NonNullable<typeof selfRow>), email: null };
const { service, mail } = make();
await expect(service.sendTest('t1', 'u-self', texts)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
it('ohne Versandweg → ConflictException; Versandfehler → BadGatewayException', async () => {
await expect(
make(makeMail({ available: false })).service.sendTest('t1', 'u-self', texts),
).rejects.toBeInstanceOf(ConflictException);
await expect(
make(makeMail({ fail: true })).service.sendTest('t1', 'u-self', texts),
).rejects.toBeInstanceOf(BadGatewayException);
});
});
+309
View File
@@ -0,0 +1,309 @@
import { randomUUID } from 'node:crypto';
import {
BadGatewayException,
BadRequestException,
ConflictException,
Injectable,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { User } from '@prisma/client';
import type { WelcomeMailTexts } from '@tessera/shared';
import { WELCOME_TOKEN_TTL_MS } from '../auth/password-reset-token';
import { loadWelcomeHeaderPng, MailService, WELCOME_HEADER_CID } from '../mail/mail.service';
import {
type RenderedWelcomeMail,
renderWelcomeMail,
type WelcomeMailAccount,
} from '../mail/welcome-mail.template';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
/**
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung
* (Administrator → Benutzer, "Willkommensmail senden").
*
* Kann an JEDEN Benutzer gehen, unabhaengig davon, ob und wann er sich
* schon angemeldet hat (Wunsch des Betreibers: auch zum Erneuern des
* Zugangs und zum Ausprobieren). Wer wem senden darf (nur eigener
* Mandant, ADMIN nie an SUPER_ADMIN), prueft der Controller wie beim
* Bearbeiten. Erneutes Senden ist erlaubt und ueberschreibt
* `welcomeMailSentAt`. Abgelehnt wird nur ein deaktiviertes Konto (409),
* ein Konto ohne Adresse (400) und ein Mandant ohne Versandweg (409).
*
* Anmeldehinweis je Kontoart:
* - verzeichnisgefuehrt (`ldapDn` gesetzt): Hinweis auf das gewohnte
* Windows-Passwort — Tessera kennt und setzt dieses Kennwort nicht (das
* Verzeichnis bleibt nur lesend angebunden);
* - lokal: Link "Passwort festlegen" mit einem frischen `PasswordResetToken`
* — derselbe Token, dieselbe Seite `/reset-password/<token>` und dieselbe
* Frist `WELCOME_TOKEN_TTL_MS` (7 Tage statt 1 Stunde wie beim Weg "Passwort
* vergessen"). Ein Kennwort steht NIE in der Mail.
*
* Mandantenbindung: der Aufrufer (`UserController`) hat den Zielbenutzer
* bereits rollenabhaengig aufgeloest und die Mandanten-/Rollenregeln
* geprueft; beide Zugriffe hier (`passwordResetToken.create`,
* `user.update`) binden ueber `forTenant()` an den Mandanten des ZIELS —
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
* SMTP-Weg genau dieses Mandanten (`MailService.sendWelcomeMail`).
*
* Eigene Vorlage (Administrator → Willkommensmail): `send` nimmt die Texte
* der Vorlage des Mandanten des ZIELS (`WelcomeMailTemplateService`), sonst
* die Standardtexte. `preview` rendert einen (auch ungespeicherten)
* Formularinhalt mit Beispielwerten, `sendTest` schickt ihn an die eigene
* Adresse des Administrators — OHNE Token: bei einem lokalen eigenen Konto
* zeigt die Testmail den Knopf "Passwort festlegen" als Beispiel-Link zur
* Anmeldeseite (Kontoart `local-example`), damit nie ein nutzbarer
* Passwort-Link an eine Testadresse geht.
*
* Adresse in der Mail: `TESSERA_APP_URL` (in Compose aus `APP_URL`), sonst
* der Origin der Admin-Anfrage. Zeigt die Konfiguration nur auf
* `localhost` (Compose-Vorgabe, nie gesetzt), gewinnt ein vorhandener
* Origin — sonst bekaeme der Empfaenger einen Link, der auf seinem
* Rechner ins Leere fuehrt.
*/
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
export type WelcomeMailTarget = Pick<
User,
'id' | 'tenantId' | 'username' | 'displayName' | 'email' | 'ldapDn' | 'isActive'
>;
export interface WelcomeMailResult {
to: string;
welcomeMailSentAt: Date;
}
const FALLBACK_APP_URL = 'http://localhost:3000';
/** Beispielwerte der Vorschau (Administrator → Willkommensmail). */
export const PREVIEW_SAMPLE = {
name: 'Max Mustermann',
username: 'max.mustermann',
email: 'max.mustermann@example.com',
} as const;
/** `http(s)://host[:port]` aus einem Origin-Kopf, sonst `null`. */
export function normalizeOrigin(value: string | undefined | null): string | null {
if (!value) return null;
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return url.origin;
} catch {
return null;
}
}
function isLocalhostUrl(value: string): boolean {
try {
const host = new URL(value).hostname;
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
} catch {
return false;
}
}
@Injectable()
export class WelcomeMailService {
private readonly logger = new Logger(WelcomeMailService.name);
constructor(
private readonly prisma: PrismaService,
private readonly mailService: MailService,
private readonly configService: ConfigService,
private readonly templateService: WelcomeMailTemplateService,
) {}
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
async isAvailable(tenantId: string): Promise<boolean> {
return this.mailService.hasConfiguredTransport(tenantId);
}
/**
* Oeffentliche Adresse fuer Links in der Mail: Konfiguration, sonst Origin
* der Anfrage, sonst die Entwicklungsvorgabe (siehe Kopfkommentar).
*/
resolveAppUrl(requestOrigin?: string | null): string {
const raw = (this.configService.get<string>('TESSERA_APP_URL') ?? '').trim();
const configured = /^https?:\/\//i.test(raw) ? raw.replace(/\/+$/, '') : '';
const origin = normalizeOrigin(requestOrigin);
if (configured && !(isLocalhostUrl(configured) && origin)) {
return configured;
}
return origin ?? (configured || FALLBACK_APP_URL);
}
async send(target: WelcomeMailTarget, requestOrigin?: string | null): Promise<WelcomeMailResult> {
if (!target.isActive) {
throw new ConflictException(
'Dieses Konto ist deaktiviert. Aktivieren Sie es zuerst, bevor Sie eine Willkommensmail senden.',
);
}
const to = target.email?.trim();
if (!to) {
throw new BadRequestException('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.');
}
if (!(await this.mailService.hasConfiguredTransport(target.tenantId))) {
throw new ConflictException(
'Für den E-Mail-Versand ist noch kein SMTP-Server eingerichtet. Ein Administrator legt ihn unter Administrator → SMTP fest.',
);
}
const appUrl = this.resolveAppUrl(requestOrigin);
const tenantPrisma = forTenant(this.prisma, target.tenantId);
let account: WelcomeMailAccount;
if (target.ldapDn) {
account = { kind: 'directory' };
} else {
// Derselbe Token wie beim Weg "Passwort vergessen" (T-02-13): zufaellig,
// einmal verwendbar, gleiche Frist.
const token = randomUUID();
await tenantPrisma.passwordResetToken.create({
data: {
token,
userId: target.id,
expiresAt: new Date(Date.now() + WELCOME_TOKEN_TTL_MS),
},
});
account = {
kind: 'local',
setPasswordUrl: `${appUrl}/reset-password/${token}`,
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
};
}
const [texts, tenantName] = await Promise.all([
this.templateService.getCustomTexts(target.tenantId),
this.templateService.getTenantName(target.tenantId),
]);
const mail = renderWelcomeMail({
name: target.displayName?.trim() || target.username,
username: target.username,
email: to,
tenantName,
appUrl,
account,
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
texts,
});
try {
await this.mailService.sendWelcomeMail(target.tenantId, to, mail);
} catch (error) {
this.logger.error(
`Welcome mail to user ${target.id} failed`,
error instanceof Error ? error.stack : String(error),
);
throw new BadGatewayException(
'Die Willkommensmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
);
}
const updated = await tenantPrisma.user.update({
where: { id: target.id },
data: { welcomeMailSentAt: new Date() },
select: { welcomeMailSentAt: true },
});
const welcomeMailSentAt = updated.welcomeMailSentAt ?? new Date();
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
return { to, welcomeMailSentAt };
}
/**
* Vorschau fuer Administrator → Willkommensmail: rendert `texts` (auch
* ungespeichert) mit festen Beispielwerten und dem Firmennamen des eigenen
* Mandanten. Das Kopfbild steht als `data:`-Adresse im HTML statt als
* `cid:`, damit die Vorschau im Rahmen der Seite der echten Mail gleicht.
* Der Link "Passwort festlegen" ist ein Beispiel ohne Token.
*/
async preview(
tenantId: string,
texts: WelcomeMailTexts,
accountKind: 'directory' | 'local',
requestOrigin?: string | null,
): Promise<RenderedWelcomeMail> {
const appUrl = this.resolveAppUrl(requestOrigin);
const header = loadWelcomeHeaderPng();
const account: WelcomeMailAccount =
accountKind === 'directory'
? { kind: 'directory' }
: {
kind: 'local',
setPasswordUrl: `${appUrl}/reset-password/beispiel`,
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
};
return renderWelcomeMail({
name: PREVIEW_SAMPLE.name,
username: PREVIEW_SAMPLE.username,
email: PREVIEW_SAMPLE.email,
tenantName: await this.templateService.getTenantName(tenantId),
appUrl,
account,
headerImageSrc: header ? `data:image/png;base64,${header.toString('base64')}` : null,
texts,
});
}
/**
* Testmail an den angemeldeten Administrator selbst, mit dem aktuellen
* (auch ungespeicherten) Formularinhalt und dem eigenen Konto als
* Beispiel. Es entsteht KEIN Token: ein verzeichnisgefuehrtes eigenes
* Konto bekommt den Windows-Hinweis, ein lokales den Beispiel-Link zur
* Anmeldeseite (`local-example`). `welcomeMailSentAt` bleibt unberuehrt.
* Ohne eigene Adresse 400, ohne Versandweg 409, Versandfehler 502.
*/
async sendTest(
tenantId: string,
userId: string,
texts: WelcomeMailTexts,
requestOrigin?: string | null,
): Promise<{ to: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const self = await tenantPrisma.user.findFirst({
where: { id: userId, tenantId },
select: { username: true, displayName: true, email: true, ldapDn: true },
});
const to = self?.email?.trim();
if (!self || !to) {
throw new BadRequestException(
'Für Ihr eigenes Konto ist keine E-Mail-Adresse hinterlegt. Hinterlegen Sie zuerst eine Adresse (Administrator → Benutzer), dann kann die Testmail an Sie gehen.',
);
}
if (!(await this.mailService.hasConfiguredTransport(tenantId))) {
throw new ConflictException(
'Für den E-Mail-Versand ist noch kein SMTP-Server eingerichtet. Ein Administrator legt ihn unter Administrator → SMTP fest.',
);
}
const mail = renderWelcomeMail({
name: self.displayName?.trim() || self.username,
username: self.username,
email: to,
tenantName: await this.templateService.getTenantName(tenantId),
appUrl: this.resolveAppUrl(requestOrigin),
account: self.ldapDn ? { kind: 'directory' } : { kind: 'local-example' },
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
texts,
notice:
'Testmail aus Administrator → Willkommensmail – so sieht die Willkommensmail mit Ihrer Vorlage aus.',
});
try {
await this.mailService.sendWelcomeMail(tenantId, to, mail);
} catch (error) {
this.logger.error(
`Welcome mail test to user ${userId} failed`,
error instanceof Error ? error.stack : String(error),
);
throw new BadGatewayException(
'Die Testmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
);
}
this.logger.log(`Welcome mail test sent to user ${userId} (tenant ${tenantId})`);
return { to };
}
}
+197 -16
View File
@@ -1,4 +1,5 @@
use semver::Version; use semver::Version;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
use tauri::{ use tauri::{
@@ -48,6 +49,59 @@ struct VersionResponse {
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das. /// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
struct PendingUpdate(Mutex<Option<(Update, String)>>); struct PendingUpdate(Mutex<Option<(Update, String)>>);
/// Ablauf-Merker des Updaters neben `PendingUpdate`.
///
/// `installing`: von `spawn_update_install` bis zum Fehlerfall gesetzt (im
/// Erfolgsfall startet die App neu bzw. beendet sich, der Merker bleibt).
/// Solange er gesetzt ist, tun Tray-Klick, `spawn_version_check` und die
/// 4-h-Schleife nichts -- vorher konnte eine Pruefung waehrend des Downloads
/// ein neues Update ablegen und den Eintrag wieder aktiv schalten, ein
/// zweiter Klick startete dann einen parallelen Download samt Installer.
///
/// `offered`: eine Pruefung hat ein Update angeboten ("Auf Version …
/// aktualisieren"). Der Klick darauf prueft frisch (`install_after`); schlug
/// diese Pruefung fehl, war der abgelegte Stand weg und der naechste Klick
/// bot nur wieder an -- zwei weitere Klicks bis zur Installation. Der Merker
/// ueberlebt die fehlgeschlagene Pruefung, der naechste erfolgreiche Klick
/// installiert direkt. Geleert, wenn eine Pruefung "kein Update" ergibt oder
/// der Server wechselt.
#[derive(Default)]
struct UpdateFlow {
installing: AtomicBool,
offered: AtomicBool,
}
impl UpdateFlow {
/// Beansprucht die Installation. `false`, wenn bereits eine laeuft --
/// dann darf der Aufrufer nichts starten.
fn try_begin_install(&self) -> bool {
self.installing
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
/// Gibt die Installation nach einem Fehler wieder frei.
fn install_failed(&self) {
self.installing.store(false, Ordering::SeqCst);
}
fn is_installing(&self) -> bool {
self.installing.load(Ordering::SeqCst)
}
fn set_offered(&self, offered: bool) {
self.offered.store(offered, Ordering::SeqCst);
}
/// Soll der Tray-Klick nach der frischen Pruefung installieren? Ja, wenn
/// ein Stand abgelegt war ODER vorher schon ein Update angeboten wurde
/// (Klick auf "Update-Prüfung fehlgeschlagen … – erneut prüfen" nach
/// einem Angebot).
fn install_on_click(&self, had_pending: bool) -> bool {
had_pending || self.offered.load(Ordering::SeqCst)
}
}
/// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen /// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen
/// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut; /// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut;
/// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden /// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden
@@ -429,28 +483,44 @@ fn grant_server_notifications(app: &AppHandle, url: &str) {
); );
return; return;
}; };
{ let result = grant_origin_once(&GRANTED_SERVER_ORIGINS, &pattern, |pattern| {
let mut granted = match GRANTED_SERVER_ORIGINS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if granted.contains(&pattern) {
return;
}
granted.push(pattern.clone());
}
let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications") let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications")
.remote(pattern) .remote(pattern.to_string())
.local(false) .local(false)
.window("main"); .window("main");
for permission in SERVER_NOTIFICATION_PERMISSIONS { for permission in SERVER_NOTIFICATION_PERMISSIONS {
capability = capability.permission(permission); capability = capability.permission(permission);
} }
if let Err(e) = app.add_capability(capability) { app.add_capability(capability)
});
if let Err(e) = result {
eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e); eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e);
} }
} }
/// Fuehrt `grant` fuer `pattern` hoechstens einmal erfolgreich aus. Die Sperre
/// von `granted` bleibt ueber den Aufruf gehalten, damit zwei gleichzeitige
/// Aufrufe nicht doppelt berechtigen; vermerkt wird der Ursprung erst NACH
/// dem Erfolg. Frueher stand er schon vorher in der Liste -- schlug
/// `add_capability` fehl, gab es bis zum Neustart keinen neuen Versuch.
/// Bereits vermerkt: `Ok(())` ohne Aufruf.
fn grant_origin_once<E>(
granted: &Mutex<Vec<String>>,
pattern: &str,
grant: impl FnOnce(&str) -> Result<(), E>,
) -> Result<(), E> {
let mut granted = match granted.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if granted.iter().any(|p| p == pattern) {
return Ok(());
}
grant(pattern)?;
granted.push(pattern.to_string());
Ok(())
}
/// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide /// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in /// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
/// `update_labels`. /// `update_labels`.
@@ -596,7 +666,13 @@ fn clear_check_notice(app: &AppHandle) {
/// `/desktop/download/<os>` liefert immer den AKTUELLEN Installer; ein /// `/desktop/download/<os>` liefert immer den AKTUELLEN Installer; ein
/// Stunden alter Stand trug noch die Signatur der Vorversion, nach dem /// Stunden alter Stand trug noch die Signatur der Vorversion, nach dem
/// Server-Update passten Datei und Signatur nicht mehr zusammen. /// Server-Update passten Datei und Signatur nicht mehr zusammen.
///
/// Waehrend einer Installation (`UpdateFlow::installing`) tut die Funktion
/// nichts: kein Menuetext, kein Leeren, keine Ablage.
fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool) { fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool) {
if app.state::<UpdateFlow>().is_installing() {
return;
}
let update_item = app.state::<TrayItems>().update.clone(); let update_item = app.state::<TrayItems>().update.clone();
let _ = update_item.set_text(UPDATE_ITEM_CHECKING); let _ = update_item.set_text(UPDATE_ITEM_CHECKING);
let _ = update_item.set_enabled(false); let _ = update_item.set_enabled(false);
@@ -615,7 +691,14 @@ fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool)
.expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein"); .expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein");
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
match check_for_update(&app, endpoint).await { let result = check_for_update(&app, endpoint).await;
// Eine parallel gestartete Pruefung (Klick waehrend der 4-h-Schleife)
// kann inzwischen eine Installation ausgeloest haben: dann weder
// Menuetext noch Ablage anfassen.
if app.state::<UpdateFlow>().is_installing() {
return;
}
match result {
Ok(Some(mut update)) => { Ok(Some(mut update)) => {
let (menu_text, body) = Version::parse(&update.version) let (menu_text, body) = Version::parse(&update.version)
.map(|release| release_labels(&current, &release)) .map(|release| release_labels(&current, &release))
@@ -640,10 +723,12 @@ fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool)
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() { if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text)); *pending = Some((update, menu_text));
} }
app.state::<UpdateFlow>().set_offered(true);
} }
Ok(None) => { Ok(None) => {
let _ = update_item.set_text(UPDATE_ITEM_NONE); let _ = update_item.set_text(UPDATE_ITEM_NONE);
let _ = update_item.set_enabled(true); let _ = update_item.set_enabled(true);
app.state::<UpdateFlow>().set_offered(false);
clear_check_notice(&app); clear_check_notice(&app);
} }
// Der Release-Bau verweigert http-Endpunkte (config.rs // Der Release-Bau verweigert http-Endpunkte (config.rs
@@ -692,7 +777,13 @@ fn open_download_page(app: &AppHandle) {
/// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder /// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder
/// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als /// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als
/// Rueckfall. /// Rueckfall.
///
/// Beansprucht `UpdateFlow::installing`; laeuft bereits eine Installation,
/// passiert nichts. Freigegeben wird der Merker nur im Fehlerfall.
fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) { fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
if !app.state::<UpdateFlow>().try_begin_install() {
return;
}
let item = app.state::<TrayItems>().update.clone(); let item = app.state::<TrayItems>().update.clone();
let _ = item.set_enabled(false); let _ = item.set_enabled(false);
let _ = item.set_text("Update wird heruntergeladen…"); let _ = item.set_text("Update wird heruntergeladen…");
@@ -738,6 +829,7 @@ fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() { if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text.clone())); *pending = Some((update, menu_text.clone()));
} }
app.state::<UpdateFlow>().install_failed();
let _ = item.set_enabled(true); let _ = item.set_enabled(true);
let _ = app let _ = app
.notification() .notification()
@@ -796,6 +888,8 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
// Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen. // Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen.
grant_server_notifications(&app, &normalized); grant_server_notifications(&app, &normalized);
apply_server(&app, Some(&normalized)); apply_server(&app, Some(&normalized));
// Ein Angebot des alten Servers gilt nicht fuer den neuen.
app.state::<UpdateFlow>().set_offered(false);
spawn_version_check(app.clone(), normalized.clone(), false); spawn_version_check(app.clone(), normalized.clone(), false);
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
@@ -993,6 +1087,7 @@ pub fn run() {
update: update.clone(), update: update.clone(),
}); });
app.manage(PendingUpdate(Mutex::new(None))); app.manage(PendingUpdate(Mutex::new(None)));
app.manage(UpdateFlow::default());
app.manage(LastCheckNotice(Mutex::new(String::new()))); app.manage(LastCheckNotice(Mutex::new(String::new())));
let autostart_for_menu = autostart.clone(); let autostart_for_menu = autostart.clone();
@@ -1021,6 +1116,14 @@ pub fn run() {
// nur erneut. Der Browser-Weg (`open_download_page`) // nur erneut. Der Browser-Weg (`open_download_page`)
// bleibt Rueckfall einer fehlgeschlagenen Installation. // bleibt Rueckfall einer fehlgeschlagenen Installation.
// Ohne gespeicherte Adresse gibt es nichts zu pruefen. // Ohne gespeicherte Adresse gibt es nichts zu pruefen.
// Waehrend einer Installation tut der Klick nichts
// (`UpdateFlow`). War vorher schon ein Update
// angeboten (Stand nach fehlgeschlagener Pruefung
// weg), installiert der Klick trotzdem direkt.
let flow = app.state::<UpdateFlow>();
if flow.is_installing() {
return;
}
let had_pending = app let had_pending = app
.state::<PendingUpdate>() .state::<PendingUpdate>()
.0 .0
@@ -1029,7 +1132,8 @@ pub fn run() {
.and_then(|mut guard| guard.take()) .and_then(|mut guard| guard.take())
.is_some(); .is_some();
if let Some(url) = stored_server_url(app) { if let Some(url) = stored_server_url(app) {
spawn_version_check(app.clone(), url, had_pending); let install_after = flow.install_on_click(had_pending);
spawn_version_check(app.clone(), url, install_after);
} }
} }
"autostart" => { "autostart" => {
@@ -1081,11 +1185,16 @@ pub fn run() {
// ein Update, wird der Durchlauf uebersprungen: die Pruefung // ein Update, wird der Durchlauf uebersprungen: die Pruefung
// wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden. // wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden.
// Waehrend einer Installation ist der Stand per `take()` leer; // Waehrend einer Installation ist der Stand per `take()` leer;
// faellt die 4-h-Marke genau in den Download, wird nur der // darum prueft die Schleife zusaetzlich `UpdateFlow::installing`
// Fortschrittstext ueberschrieben (T-FRG-04, akzeptiert). // und ueberspringt den Durchlauf (vorher: Pruefung mitten im
// Download legte ein neues Update ab und schaltete den Eintrag
// wieder aktiv -- zweiter paralleler Download moeglich).
let handle = app.handle().clone(); let handle = app.handle().clone();
std::thread::spawn(move || loop { std::thread::spawn(move || loop {
std::thread::sleep(UPDATE_CHECK_INTERVAL); std::thread::sleep(UPDATE_CHECK_INTERVAL);
if handle.state::<UpdateFlow>().is_installing() {
continue;
}
let pending = handle let pending = handle
.state::<PendingUpdate>() .state::<PendingUpdate>()
.0 .0
@@ -1493,6 +1602,78 @@ mod tests {
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich"); assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600)); assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
} }
// --- UpdateFlow ---
#[test]
fn update_flow_nur_eine_installation_gleichzeitig() {
let flow = UpdateFlow::default();
assert!(!flow.is_installing());
assert!(flow.try_begin_install());
assert!(flow.is_installing());
assert!(
!flow.try_begin_install(),
"zweite Installation muss abgewiesen werden"
);
}
#[test]
fn update_flow_fehler_gibt_installation_frei() {
let flow = UpdateFlow::default();
assert!(flow.try_begin_install());
flow.install_failed();
assert!(!flow.is_installing());
assert!(flow.try_begin_install());
}
#[test]
fn update_flow_klick_installiert_bei_abgelegtem_stand() {
let flow = UpdateFlow::default();
assert!(flow.install_on_click(true));
assert!(!flow.install_on_click(false));
}
#[test]
fn update_flow_klick_nach_angebot_und_fehlgeschlagener_pruefung_installiert() {
let flow = UpdateFlow::default();
flow.set_offered(true);
// Stand wurde vom ersten Klick entnommen, frische Pruefung schlug fehl.
assert!(flow.install_on_click(false));
// "Kein Update" oder Serverwechsel leert das Angebot.
flow.set_offered(false);
assert!(!flow.install_on_click(false));
}
// --- grant_origin_once ---
#[test]
fn grant_origin_once_vermerkt_erst_nach_erfolg() {
let granted = Mutex::new(Vec::new());
let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |_| Err("kaputt"));
assert_eq!(r, Err("kaputt"));
assert!(granted.lock().unwrap().is_empty());
let mut calls = 0;
let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |p| {
calls += 1;
assert_eq!(p, "https://a");
Ok(())
});
assert_eq!(r, Ok(()));
assert_eq!(calls, 1, "nach einem Fehler muss ein neuer Versuch laufen");
assert_eq!(*granted.lock().unwrap(), vec!["https://a".to_string()]);
}
#[test]
fn grant_origin_once_ruft_bei_vermerktem_ursprung_nicht_erneut() {
let granted = Mutex::new(vec!["https://a".to_string()]);
let r: Result<(), &str> =
grant_origin_once(&granted, "https://a", |_| panic!("darf nicht laufen"));
assert_eq!(r, Ok(()));
let r: Result<(), &str> = grant_origin_once(&granted, "https://b", |_| Ok(()));
assert_eq!(r, Ok(()));
assert_eq!(granted.lock().unwrap().len(), 2);
}
// --- server_origin_* (quick-260929-if2, E-01) --- // --- server_origin_* (quick-260929-if2, E-01) ---
fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern { fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern {
@@ -28,6 +28,8 @@ const messages: Record<string, Record<string, unknown>> = {
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.', urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
saveError: 'Das Modul konnte nicht gespeichert werden.', saveError: 'Das Modul konnte nicht gespeichert werden.',
deleteError: 'Das Modul konnte nicht gelöscht werden.', deleteError: 'Das Modul konnte nicht gelöscht werden.',
forbidden: 'Sie haben keine Berechtigung für diese Aktion.',
invalidInput: 'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
deleteConfirm: { deleteConfirm: {
title: 'Eigenes Modul löschen', title: 'Eigenes Modul löschen',
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.', bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
@@ -58,8 +60,18 @@ vi.mock('next-intl', () => ({
})); }));
vi.mock('next/link', () => ({ vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => ( default: ({
<a href={href} className={className}>{children}</a> children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
), ),
})); }));
@@ -68,13 +80,7 @@ vi.mock('@/lib/use-category-label', () => ({
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug, ({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
})); }));
const { const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({
mockList,
mockCreate,
mockUpdate,
mockDelete,
mockBump,
} = vi.hoisted(() => ({
mockList: vi.fn(), mockList: vi.fn(),
mockCreate: vi.fn(), mockCreate: vi.fn(),
mockUpdate: vi.fn(), mockUpdate: vi.fn(),
@@ -106,6 +112,7 @@ vi.mock('@/lib/stores/auth-store', () => ({
selector({ user: { role: mockRole } }), selector({ user: { role: mockRole } }),
})); }));
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
import AdminCustomModulesPage from './page'; import AdminCustomModulesPage from './page';
const wiki = { const wiki = {
@@ -136,7 +143,9 @@ describe('AdminCustomModulesPage', () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument(); expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument(); expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length).toBeGreaterThan(0); expect(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length,
).toBeGreaterThan(0);
}); });
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => { it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
@@ -154,13 +163,17 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => { it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.')).toBeInTheDocument(); expect(
await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.'),
).toBeInTheDocument();
expect(mockCreate).not.toHaveBeenCalled(); expect(mockCreate).not.toHaveBeenCalled();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
@@ -168,7 +181,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => { it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
@@ -183,7 +198,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: leerer Name zeigt die Meldung', async () => { it('Anlegen: leerer Name zeigt die Meldung', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), ' '); await userEvent.type(screen.getByLabelText('Name'), ' ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
@@ -196,7 +213,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => { it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite '); await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
@@ -217,11 +236,62 @@ describe('AdminCustomModulesPage', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
}); });
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => { it('Anlegen: die Kategorie ist vorbelegt mit „Eigene Module“', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure'); screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
expect(screen.getByLabelText('Kategorie')).toHaveValue('custom-modules');
});
it('Anlegen ohne Kategoriewahl sendet die Kategorie „Eigene Module“', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ category: 'custom-modules' }),
);
});
});
it('Speichern mit 403 zeigt „keine Berechtigung“, nicht den allgemeinen Fehler', async () => {
mockCreate.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Sie haben keine Berechtigung für diese Aktion.',
);
expect(screen.queryByText('Das Modul konnte nicht gespeichert werden.')).toBeNull();
});
it('Speichern mit 400 zeigt die Meldung des Servers', async () => {
mockList.mockResolvedValue([wiki]);
mockUpdate.mockRejectedValue(
new CustomModuleRequestError(400, 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.'),
);
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
const dialog = screen.getByRole('dialog');
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
);
}); });
it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => { it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => {
@@ -254,13 +324,17 @@ describe('AdminCustomModulesPage', () => {
mockCreate.mockRejectedValue(new Error('500')); mockCreate.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument(); expect(
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
@@ -271,7 +345,9 @@ describe('AdminCustomModulesPage', () => {
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
const dialog = screen.getByRole('dialog'); const dialog = screen.getByRole('dialog');
expect(within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.')).toBeInTheDocument(); expect(
within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.'),
).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' })); await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1')); await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
@@ -283,7 +359,9 @@ describe('AdminCustomModulesPage', () => {
mockList.mockResolvedValue([wiki]); mockList.mockResolvedValue([wiki]);
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' })); await userEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }),
);
expect(mockDelete).not.toHaveBeenCalled(); expect(mockDelete).not.toHaveBeenCalled();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
@@ -295,13 +373,38 @@ describe('AdminCustomModulesPage', () => {
mockDelete.mockRejectedValue(new Error('500')); mockDelete.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' })); await userEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
);
expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument(); expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
it('Löschen mit 403 zeigt „keine Berechtigung“', async () => {
mockList.mockResolvedValue([wiki]);
mockDelete.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Sie haben keine Berechtigung für diese Aktion.',
);
expect(screen.queryByText('Das Modul konnte nicht gelöscht werden.')).toBeNull();
});
it('Ladefehler zeigt nur die Fehlermeldung, nicht zusätzlich den Leer-Zustand', async () => {
mockList.mockRejectedValue(new CustomModuleRequestError(500, 'Internal server error'));
render(<AdminCustomModulesPage />);
expect(await screen.findByText('Laden fehlgeschlagen.')).toBeInTheDocument();
expect(screen.queryByText('Noch keine gemeinsamen Einträge')).toBeNull();
});
it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => { it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
mockList.mockResolvedValue([ mockList.mockResolvedValue([
wiki, wiki,
+262 -58
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
import { UserAccessModal } from './components/UserAccessModal'; import { UserAccessModal } from './components/UserAccessModal';
@@ -19,6 +19,10 @@ interface User {
isActive: boolean; isActive: boolean;
tenantId: string; tenantId: string;
createdAt: string; createdAt: string;
/** `null` = hat sich noch nie angemeldet (Spalte "Letzte Anmeldung": "Noch nie"). */
lastLoginAt?: string | null;
/** Zeitpunkt der letzten Willkommensmail, `null` = nie gesendet. */
welcomeMailSentAt?: string | null;
} }
interface UserFormData { interface UserFormData {
@@ -57,6 +61,8 @@ export default function AdminUsersPage() {
const t = useTranslations('admin.users'); const t = useTranslations('admin.users');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tHeader = useTranslations('header'); const tHeader = useTranslations('header');
const tWelcome = useTranslations('admin.users.welcomeMail');
const locale = useLocale();
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
@@ -68,6 +74,13 @@ export default function AdminUsersPage() {
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [detailsUser, setDetailsUser] = useState<User | null>(null); const [detailsUser, setDetailsUser] = useState<User | null>(null);
// Willkommensmail: Versandweg vorhanden? `null` = noch unbekannt (Knopf
// bleibt bedienbar, die API meldet einen fehlenden Versandweg selbst).
const [welcomeAvailable, setWelcomeAvailable] = useState<boolean | null>(null);
const [welcomeUser, setWelcomeUser] = useState<User | null>(null);
const [welcomeSending, setWelcomeSending] = useState(false);
const [welcomeError, setWelcomeError] = useState<string | null>(null);
const [welcomeSuccess, setWelcomeSuccess] = useState<string | null>(null);
const [formData, setFormData] = useState<UserFormData>({ const [formData, setFormData] = useState<UserFormData>({
username: '', username: '',
email: '', email: '',
@@ -77,8 +90,7 @@ export default function AdminUsersPage() {
}); });
// Access check: only ADMIN and SUPER_ADMIN // Access check: only ADMIN and SUPER_ADMIN
const hasAccess = const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const fetchUsers = useCallback(async () => { const fetchUsers = useCallback(async () => {
setLoadError(null); setLoadError(null);
@@ -106,6 +118,73 @@ export default function AdminUsersPage() {
} }
}, [hasAccess, fetchUsers]); }, [hasAccess, fetchUsers]);
useEffect(() => {
if (!hasAccess) return;
let cancelled = false;
fetch(`${API_URL}/users/welcome-mail/status`, { credentials: 'include' })
.then(async (res) => {
if (!res.ok) return;
const body = (await res.json()) as { available?: unknown };
if (!cancelled && typeof body?.available === 'boolean') {
setWelcomeAvailable(body.available);
}
})
.catch(() => {
/* unbekannt lassen — die API prueft beim Versand selbst */
});
return () => {
cancelled = true;
};
}, [hasAccess]);
const formatDateTime = (iso: string) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d);
};
/**
* Grund, warum der Knopf "Willkommensmail senden" nicht bedienbar ist,
* oder `null`. Rein ergonomisch — die API prueft dasselbe (400/409).
*/
const welcomeBlockedReason = (user: User): string | null => {
if (!user.email) return tWelcome('noEmail');
if (!user.isActive) return tWelcome('inactive');
if (welcomeAvailable === false) return tWelcome('noSmtp');
return null;
};
const openWelcome = (user: User) => {
setWelcomeUser(user);
setWelcomeError(null);
setWelcomeSuccess(null);
};
const handleSendWelcome = async () => {
if (!welcomeUser) return;
setWelcomeSending(true);
setWelcomeError(null);
try {
const res = await fetch(`${API_URL}/users/${welcomeUser.id}/welcome-mail`, {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
const email = welcomeUser.email ?? '';
setWelcomeUser(null);
setWelcomeSuccess(tWelcome('success', { email }));
fetchUsers();
} else {
const detail = await readApiMessage(res);
setWelcomeError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
}
} catch {
setWelcomeError(t('errors.network'));
} finally {
setWelcomeSending(false);
}
};
const openCreate = () => { const openCreate = () => {
setEditingUser(null); setEditingUser(null);
setFormData({ setFormData({
@@ -136,9 +215,7 @@ export default function AdminUsersPage() {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
const url = editingUser const url = editingUser ? `${API_URL}/users/${editingUser.id}` : `${API_URL}/users`;
? `${API_URL}/users/${editingUser.id}`
: `${API_URL}/users`;
const method = editingUser ? 'PATCH' : 'POST'; const method = editingUser ? 'PATCH' : 'POST';
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
@@ -166,9 +243,7 @@ export default function AdminUsersPage() {
fetchUsers(); fetchUsers();
} else { } else {
const detail = await readApiMessage(res); const detail = await readApiMessage(res);
setFormError( setFormError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
);
} }
} catch { } catch {
setFormError(t('errors.network')); setFormError(t('errors.network'));
@@ -187,9 +262,7 @@ export default function AdminUsersPage() {
fetchUsers(); fetchUsers();
} else { } else {
const detail = await readApiMessage(res); const detail = await readApiMessage(res);
setDeleteError( setDeleteError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
);
} }
} catch { } catch {
setDeleteError(t('errors.network')); setDeleteError(t('errors.network'));
@@ -212,6 +285,13 @@ export default function AdminUsersPage() {
const canManageRow = (user: User) => const canManageRow = (user: User) =>
!(user.role === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN'); !(user.role === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN');
/**
* Willkommensmail: an jeden Benutzer, dessen Zeile die angemeldete Person
* verwalten darf (Zielrollen-Riegel wie beim Bearbeiten). Spiegelt die
* API, die allein entscheidet.
*/
const canSendWelcome = (user: User) => canManageRow(user);
const roleBadgeClass = (role: string) => { const roleBadgeClass = (role: string) => {
switch (role) { switch (role) {
case 'SUPER_ADMIN': case 'SUPER_ADMIN':
@@ -228,11 +308,7 @@ export default function AdminUsersPage() {
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1> <h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button <button type="button" onClick={openCreate} className="btn btn-primary">
type="button"
onClick={openCreate}
className="btn btn-primary"
>
{t('create')} {t('create')}
</button> </button>
</div> </div>
@@ -246,6 +322,23 @@ export default function AdminUsersPage() {
</div> </div>
)} )}
{welcomeSuccess && (
<div
role="status"
className="flex items-start justify-between gap-3 rounded-md border border-status-ok/40 bg-status-ok/12 p-3 text-sm text-status-ok-fg"
>
<span>{welcomeSuccess}</span>
<button
type="button"
onClick={() => setWelcomeSuccess(null)}
aria-label={tCommon('close')}
className="rounded px-1 text-xs hover:bg-muted"
>
×
</button>
</div>
)}
{/* Users table */} {/* Users table */}
{loading ? ( {loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p> <p className="text-muted-foreground">{tCommon('loading')}</p>
@@ -271,6 +364,9 @@ export default function AdminUsersPage() {
<th className="px-4 py-3 text-left font-medium text-muted-foreground"> <th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('status')} {t('status')}
</th> </th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('lastLogin')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"> <th className="px-4 py-3 text-right font-medium text-muted-foreground">
{t('actions')} {t('actions')}
</th> </th>
@@ -279,15 +375,9 @@ export default function AdminUsersPage() {
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{users.map((user) => ( {users.map((user) => (
<tr key={user.id} className="hover:bg-muted/30 transition-colors"> <tr key={user.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium text-foreground"> <td className="px-4 py-3 font-medium text-foreground">{user.username}</td>
{user.username} <td className="px-4 py-3 text-muted-foreground">{user.email ?? '–'}</td>
</td> <td className="px-4 py-3 text-muted-foreground">{user.displayName ?? '-'}</td>
<td className="px-4 py-3 text-muted-foreground">
{user.email ?? '–'}
</td>
<td className="px-4 py-3 text-muted-foreground">
{user.displayName ?? '-'}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span <span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`} className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`}
@@ -305,23 +395,52 @@ export default function AdminUsersPage() {
> >
{user.isActive ? tCommon('active') : tCommon('inactive')} {user.isActive ? tCommon('active') : tCommon('inactive')}
</span> </span>
{user.welcomeMailSentAt && (
<div className="mt-1 text-xs text-muted-foreground">
{tWelcome('sentAt', { date: formatDateTime(user.welcomeMailSentAt) })}
</div>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t('neverLoggedIn')}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-0.5">
{canSendWelcome(user) && (
<button
type="button"
onClick={() => openWelcome(user)}
disabled={welcomeBlockedReason(user) !== null}
title={
welcomeBlockedReason(user) ??
(user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button'))
}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<ActionIcon kind="mail" />
<span className="sr-only">
{user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button')}
</span>
</button>
)}
<button <button
type="button" type="button"
onClick={() => setDetailsUser(user)} onClick={() => setDetailsUser(user)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors" title={t('grants.detailsButton')}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
> >
{t('grants.detailsButton')} <ActionIcon kind="details" />
<span className="sr-only">{t('grants.detailsButton')}</span>
</button> </button>
{canManageRow(user) && ( {canManageRow(user) && (
<button <button
type="button" type="button"
onClick={() => openEdit(user)} onClick={() => openEdit(user)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors" title={tCommon('edit')}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
> >
{tCommon('edit')} <ActionIcon kind="edit" />
<span className="sr-only">{tCommon('edit')}</span>
</button> </button>
)} )}
{canManageRow(user) && ( {canManageRow(user) && (
@@ -332,9 +451,11 @@ export default function AdminUsersPage() {
setDeleteError(null); setDeleteError(null);
}} }}
disabled={user.id === currentUser?.id} disabled={user.id === currentUser?.id}
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none" title={tCommon('delete')}
className="rounded p-1.5 text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
> >
{tCommon('delete')} <ActionIcon kind="delete" />
<span className="sr-only">{tCommon('delete')}</span>
</button> </button>
)} )}
</div> </div>
@@ -363,9 +484,7 @@ export default function AdminUsersPage() {
type="text" type="text"
required required
value={formData.username} value={formData.username}
onChange={(e) => onChange={(e) => setFormData({ ...formData, username: e.target.value })}
setFormData({ ...formData, username: e.target.value })
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/> />
</div> </div>
@@ -378,9 +497,7 @@ export default function AdminUsersPage() {
type="email" type="email"
required required
value={formData.email} value={formData.email}
onChange={(e) => onChange={(e) => setFormData({ ...formData, email: e.target.value })}
setFormData({ ...formData, email: e.target.value })
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/> />
</div> </div>
@@ -388,9 +505,7 @@ export default function AdminUsersPage() {
<label htmlFor="user-password" className="text-sm font-medium text-foreground"> <label htmlFor="user-password" className="text-sm font-medium text-foreground">
{t('password')} {t('password')}
{editingUser && ( {editingUser && (
<span className="ml-1 text-xs text-muted-foreground"> <span className="ml-1 text-xs text-muted-foreground">(optional)</span>
(optional)
</span>
)} )}
</label> </label>
<input <input
@@ -399,9 +514,7 @@ export default function AdminUsersPage() {
required={!editingUser} required={!editingUser}
minLength={8} minLength={8}
value={formData.password} value={formData.password}
onChange={(e) => onChange={(e) => setFormData({ ...formData, password: e.target.value })}
setFormData({ ...formData, password: e.target.value })
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/> />
</div> </div>
@@ -413,9 +526,7 @@ export default function AdminUsersPage() {
id="user-display-name" id="user-display-name"
type="text" type="text"
value={formData.displayName} value={formData.displayName}
onChange={(e) => onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
setFormData({ ...formData, displayName: e.target.value })
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/> />
</div> </div>
@@ -437,9 +548,7 @@ export default function AdminUsersPage() {
<option value="USER">{tHeader('role.USER')}</option> <option value="USER">{tHeader('role.USER')}</option>
<option value="ADMIN">{tHeader('role.ADMIN')}</option> <option value="ADMIN">{tHeader('role.ADMIN')}</option>
{currentUser?.role === 'SUPER_ADMIN' && ( {currentUser?.role === 'SUPER_ADMIN' && (
<option value="SUPER_ADMIN"> <option value="SUPER_ADMIN">{tHeader('role.SUPER_ADMIN')}</option>
{tHeader('role.SUPER_ADMIN')}
</option>
)} )}
</select> </select>
</div> </div>
@@ -459,10 +568,7 @@ export default function AdminUsersPage() {
> >
{tCommon('cancel')} {tCommon('cancel')}
</button> </button>
<button <button type="submit" className="btn btn-primary">
type="submit"
className="btn btn-primary"
>
{tCommon('save')} {tCommon('save')}
</button> </button>
</div> </div>
@@ -475,9 +581,7 @@ export default function AdminUsersPage() {
{deleteConfirm && ( {deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg"> <div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<p className="text-sm text-foreground mb-4"> <p className="text-sm text-foreground mb-4">{t('deleteConfirm')}</p>
{t('deleteConfirm')}
</p>
{deleteError && ( {deleteError && (
<div <div
role="alert" role="alert"
@@ -506,6 +610,57 @@ export default function AdminUsersPage() {
</div> </div>
)} )}
{/* Willkommensmail: Bestaetigung mit Empfaengeradresse */}
{welcomeUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-labelledby="welcome-mail-title"
className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg"
>
<h2 id="welcome-mail-title" className="text-lg font-bold text-foreground mb-3">
{tWelcome('dialogTitle')}
</h2>
<p className="text-sm text-foreground">{tWelcome('dialogText')}</p>
<p className="mt-2 mb-4 rounded-md bg-muted px-3 py-2 text-sm font-medium text-foreground break-all">
{welcomeUser.displayName ?? welcomeUser.username} &lt;{welcomeUser.email}&gt;
</p>
{welcomeUser.welcomeMailSentAt && (
<p className="mb-4 text-sm text-muted-foreground">
{tWelcome('dialogResend', { date: formatDateTime(welcomeUser.welcomeMailSentAt) })}
</p>
)}
{welcomeError && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive mb-4"
>
{welcomeError}
</div>
)}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setWelcomeUser(null)}
disabled={welcomeSending}
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
<button
type="button"
onClick={handleSendWelcome}
disabled={welcomeSending}
className="btn btn-primary"
>
{welcomeSending ? tWelcome('sending') : tWelcome('send')}
</button>
</div>
</div>
</div>
)}
{/* User-Detail-Zugriff (D-16): geerbte + direkte Modulfreigaben */} {/* User-Detail-Zugriff (D-16): geerbte + direkte Modulfreigaben */}
{detailsUser && ( {detailsUser && (
<UserAccessModal <UserAccessModal
@@ -517,3 +672,52 @@ export default function AdminUsersPage() {
</div> </div>
); );
} }
/**
* Symbole der Zeilenaktionen (quick-260930): die Tabelle wurde mit der
* Spalte "Letzte Anmeldung" und der Willkommensmail zu breit fuer Text-
* Knoepfe. Beschriftung steht an jedem Knopf als unsichtbarer Text (sr-only)
* und als title (Tooltip).
*/
function ActionIcon({ kind }: { kind: 'mail' | 'details' | 'edit' | 'delete' }) {
return (
<svg
aria-hidden="true"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{kind === 'mail' && (
<>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</>
)}
{kind === 'details' && (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5" />
<path d="M12 8h.01" />
</>
)}
{kind === 'edit' && (
<>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</>
)}
{kind === 'delete' && (
<>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
</>
)}
</svg>
);
}
@@ -18,6 +18,8 @@ const messages: Record<string, Record<string, unknown>> = {
displayName: 'Anzeigename', displayName: 'Anzeigename',
role: 'Rolle', role: 'Rolle',
status: 'Status', status: 'Status',
lastLogin: 'Letzte Anmeldung',
neverLoggedIn: 'Noch nie',
actions: 'Aktionen', actions: 'Aktionen',
password: 'Passwort', password: 'Passwort',
noUsers: 'Keine Benutzer gefunden', noUsers: 'Keine Benutzer gefunden',
@@ -31,6 +33,20 @@ const messages: Record<string, Record<string, unknown>> = {
loadFailed: 'Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu.', loadFailed: 'Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
}, },
}, },
'admin.users.welcomeMail': {
button: 'Willkommensmail senden',
resend: 'Erneut senden',
sentAt: 'Willkommensmail gesendet am {date}',
noEmail: 'Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.',
noSmtp: 'Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).',
inactive: 'Das Konto ist deaktiviert.',
dialogTitle: 'Willkommensmail senden',
dialogText: 'Tessera schickt eine Willkommensmail an:',
dialogResend: 'Eine Willkommensmail wurde bereits am {date} gesendet. Sie wird erneut verschickt.',
send: 'Senden',
sending: 'Wird gesendet …',
success: 'Die Willkommensmail an {email} wurde gesendet.',
},
header: { header: {
role: { role: {
SUPER_ADMIN: 'Super-Admin', SUPER_ADMIN: 'Super-Admin',
@@ -47,6 +63,7 @@ const messages: Record<string, Record<string, unknown>> = {
accessDenied: 'Zugriff verweigert', accessDenied: 'Zugriff verweigert',
active: 'Aktiv', active: 'Aktiv',
inactive: 'Inaktiv', inactive: 'Inaktiv',
close: 'Schließen',
}, },
}; };
@@ -69,6 +86,7 @@ function resolve(ns: string, key: string, params?: Record<string, unknown>): str
vi.mock('next-intl', () => ({ vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) =>
resolve(ns, key, params), resolve(ns, key, params),
useLocale: () => 'de',
})); }));
const mockAuthStore = vi.fn(); const mockAuthStore = vi.fn();
@@ -93,6 +111,8 @@ interface User {
isActive: boolean; isActive: boolean;
tenantId: string; tenantId: string;
createdAt: string; createdAt: string;
lastLoginAt?: string | null;
welcomeMailSentAt?: string | null;
} }
const mockUsers: User[] = [ const mockUsers: User[] = [
@@ -524,13 +544,13 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
}); });
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement; const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
expect(within(superAdminRow).getByText('Details')).toBeInTheDocument(); expect(within(superAdminRow).getByRole('button', { name: 'Details' })).toBeInTheDocument();
expect(within(superAdminRow).queryByText('Bearbeiten')).not.toBeInTheDocument(); expect(within(superAdminRow).queryByText('Bearbeiten')).not.toBeInTheDocument();
expect(within(superAdminRow).queryByText('Löschen')).not.toBeInTheDocument(); expect(within(superAdminRow).queryByText('Löschen')).not.toBeInTheDocument();
const normalRow = screen.getByText('user.zwei').closest('tr') as HTMLElement; const normalRow = screen.getByText('user.zwei').closest('tr') as HTMLElement;
expect(within(normalRow).getByText('Bearbeiten')).toBeInTheDocument(); expect(within(normalRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
expect(within(normalRow).getByText('Löschen')).toBeInTheDocument(); expect(within(normalRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
}); });
it('bietet einem SUPER_ADMIN in der SUPER_ADMIN-Zeile beide Aktionsknoepfe an', async () => { it('bietet einem SUPER_ADMIN in der SUPER_ADMIN-Zeile beide Aktionsknoepfe an', async () => {
@@ -544,8 +564,8 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
}); });
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement; const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
expect(within(superAdminRow).getByText('Bearbeiten')).toBeInTheDocument(); expect(within(superAdminRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
expect(within(superAdminRow).getByText('Löschen')).toBeInTheDocument(); expect(within(superAdminRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
}); });
it('haelt die bestehende Sperre gegen Selbstloeschung unveraendert (ADMIN u1)', async () => { it('haelt die bestehende Sperre gegen Selbstloeschung unveraendert (ADMIN u1)', async () => {
@@ -559,8 +579,165 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
}); });
const ownRow = screen.getByText('admin.eins').closest('tr') as HTMLElement; const ownRow = screen.getByText('admin.eins').closest('tr') as HTMLElement;
const ownDeleteButton = within(ownRow).getByText('Löschen'); const ownDeleteButton = within(ownRow).getByRole('button', { name: 'Löschen' });
expect(ownDeleteButton).toBeInTheDocument(); expect(ownDeleteButton).toBeInTheDocument();
expect(ownDeleteButton).toBeDisabled(); expect(ownDeleteButton).toBeDisabled();
}); });
}); });
describe('AdminUsersPage — Willkommensmail', () => {
const base = {
role: 'USER' as const,
isActive: true,
tenantId: 't1',
createdAt: '2026-01-01T00:00:00.000Z',
welcomeMailSentAt: null,
};
const welcomeUsers: User[] = [
{ ...base, id: 'u1', username: 'admin.eins', email: 'admin.eins@ctl.de', displayName: 'Admin Eins', role: 'ADMIN', lastLoginAt: '2026-09-01T08:00:00.000Z' },
{ ...base, id: 'u2', username: 'neu.zwei', email: 'neu.zwei@ctl.de', displayName: 'Neu Zwei', lastLoginAt: null },
{ ...base, id: 'u3', username: 'ohne.adresse', email: null, displayName: null, lastLoginAt: null },
{ ...base, id: 'u4', username: 'schon.da', email: 'schon.da@ctl.de', displayName: null, lastLoginAt: '2026-09-02T08:00:00.000Z' },
{ ...base, id: 'u5', username: 'super.fuenf', email: 'super@ctl.de', displayName: null, role: 'SUPER_ADMIN', lastLoginAt: '2026-09-02T08:00:00.000Z' },
];
function stubFetch(opts: { available?: boolean; post?: () => Promise<unknown> } = {}) {
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
if (url.endsWith('/users/welcome-mail/status')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ available: opts.available ?? true }) });
}
if (url.endsWith('/users') && (!init || init.method === undefined)) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(welcomeUsers) });
}
if (url.endsWith('/welcome-mail') && init?.method === 'POST') {
return (opts.post ?? (() => Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })))();
}
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
it('Knopf bei jedem verwaltbaren Benutzer, auch nach einer Anmeldung; SUPER_ADMIN-Konto fuer einen ADMIN nie; ohne Adresse deaktiviert', async () => {
stubAdmin();
stubFetch();
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
const rowAdmin = screen.getByText('admin.eins').closest('tr') as HTMLElement;
expect(within(rowAdmin).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
const rowDa = screen.getByText('schon.da').closest('tr') as HTMLElement;
expect(within(rowDa).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
// SUPER_ADMIN-Konto: fuer einen ADMIN gesperrt wie beim Bearbeiten
const rowSuper = screen.getByText('super.fuenf').closest('tr') as HTMLElement;
expect(within(rowSuper).queryByText('Willkommensmail senden')).not.toBeInTheDocument();
const rowOhne = screen.getByText('ohne.adresse').closest('tr') as HTMLElement;
const btn = within(rowOhne).getByRole('button', { name: 'Willkommensmail senden' });
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute('title', 'Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.');
});
it('Spalte "Letzte Anmeldung": Datum/Uhrzeit lokal formatiert, sonst "Noch nie"', async () => {
stubAdmin();
stubFetch();
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
expect(screen.getByText('Letzte Anmeldung')).toBeInTheDocument();
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
expect(within(rowNeu).getByText('Noch nie')).toBeInTheDocument();
const expected = new Intl.DateTimeFormat('de', { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date('2026-09-01T08:00:00.000Z'),
);
const rowAdmin = screen.getByText('admin.eins').closest('tr') as HTMLElement;
expect(within(rowAdmin).getByText(expected)).toBeInTheDocument();
});
it('ohne eingerichtetes SMTP ist der Knopf deaktiviert und nennt den Grund', async () => {
stubAdmin();
stubFetch({ available: false });
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
await waitFor(() => expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toBeDisabled());
expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toHaveAttribute(
'title',
'Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).',
);
});
it('Dialog nennt den Empfaenger; Senden ruft POST /users/:id/welcome-mail und zeigt die Erfolgsmeldung', async () => {
stubAdmin();
const fetchMock = stubFetch();
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
await userEvent.click(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText(/neu\.zwei@ctl\.de/)).toBeInTheDocument();
await userEvent.click(within(dialog).getByText('Senden'));
await waitFor(() => {
expect(screen.getByText('Die Willkommensmail an neu.zwei@ctl.de wurde gesendet.')).toBeInTheDocument();
});
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'POST');
expect(post?.[0]).toMatch(/\/users\/u2\/welcome-mail$/);
});
it('bei einer Ablehnung (409) bleibt der Dialog offen und zeigt den Servertext', async () => {
stubAdmin();
stubFetch({
post: () =>
Promise.resolve({
ok: false,
status: 409,
json: () => Promise.resolve({ message: 'Dieser Benutzer hat sich bereits angemeldet.' }),
}),
});
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
await userEvent.click(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' }));
const dialog = await screen.findByRole('dialog');
await userEvent.click(within(dialog).getByText('Senden'));
await waitFor(() => {
expect(
screen.getByText('Der Server hat die Aktion abgelehnt: Dieser Benutzer hat sich bereits angemeldet.'),
).toBeInTheDocument();
});
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('nach einem Versand heisst der Knopf "Erneut senden" und die Zeile nennt das Datum', async () => {
stubAdmin();
welcomeUsers[1] = { ...welcomeUsers[1], welcomeMailSentAt: '2026-09-29T10:15:00.000Z' };
stubFetch();
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
expect(within(rowNeu).getByRole('button', { name: 'Erneut senden' })).toBeInTheDocument();
expect(within(rowNeu).getByText(/Willkommensmail gesendet am/)).toBeInTheDocument();
welcomeUsers[1] = { ...welcomeUsers[1], welcomeMailSentAt: null };
});
it('SUPER_ADMIN sieht den Knopf auch bei einem angemeldeten SUPER_ADMIN-Konto', async () => {
stubAdmin('u9', 'SUPER_ADMIN');
stubFetch();
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('super.fuenf')).toBeInTheDocument());
const rowSuper = screen.getByText('super.fuenf').closest('tr') as HTMLElement;
expect(within(rowSuper).getByRole('button', { name: 'Willkommensmail senden' })).toBeInTheDocument();
});
});
@@ -0,0 +1,27 @@
'use client';
import { useTranslations } from 'next-intl';
import { WelcomeMailTemplateEditor } from '@/components/admin/welcome-mail-template-editor';
import { useAuthStore } from '@/lib/stores/auth-store';
/**
* Administrator → Willkommensmail: eigene Vorlage je Mandant (Betreff,
* Ueberschrift, Einleitung, Abschluss mit Platzhaltern). ADMIN und
* SUPER_ADMIN; die Rollenpruefung hier ist nur Anzeige — durchgesetzt wird
* sie serverseitig (`@Roles` an `/welcome-mail-template`).
*/
export default function AdminWelcomeMailPage() {
const tCommon = useTranslations('common');
const currentUser = useAuthStore((s) => s.user);
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
if (!hasAccess) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
</div>
);
}
return <WelcomeMailTemplateEditor />;
}
+18 -10
View File
@@ -1,28 +1,32 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl'; import {
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot'; BackgroundPicker,
DashboardBackdrop,
useDashboardBackground,
} from '@/components/dashboard/dashboard-background';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs'; import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
import type { WidgetType } from '@/components/dashboard/widget-registry';
import { registerWidget } from '@/components/dashboard/widget-registry'; import { registerWidget } from '@/components/dashboard/widget-registry';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget'; import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget'; import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget'; import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget'; import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget'; import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget';
import { ReminderWidget } from '@/components/dashboard/widgets/reminder-widget'; import { ReminderWidget } from '@/components/dashboard/widgets/reminder-widget';
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
import { useDashboardStore } from '@/lib/stores/dashboard-store'; import { useDashboardStore } from '@/lib/stores/dashboard-store';
import type { WidgetType } from '@/components/dashboard/widget-registry';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -71,6 +75,7 @@ export default function DashboardPage() {
activeDashboardId, activeDashboardId,
isSwitchingDashboard, isSwitchingDashboard,
layouts, layouts,
canvas,
widgets, widgets,
isEditMode, isEditMode,
isLoading, isLoading,
@@ -85,6 +90,7 @@ export default function DashboardPage() {
renameDashboard, renameDashboard,
deleteDashboard, deleteDashboard,
reorderDashboards, reorderDashboards,
captureCanvas,
} = useDashboardStore(); } = useDashboardStore();
// Load dashboard data on mount // Load dashboard data on mount
@@ -200,6 +206,8 @@ export default function DashboardPage() {
onRemoveWidget={removeWidget} onRemoveWidget={removeWidget}
onOpenCatalog={() => setCatalogOpen(true)} onOpenCatalog={() => setCatalogOpen(true)}
onQuickAdd={(type) => addWidget(type)} onQuickAdd={(type) => addWidget(type)}
canvas={canvas}
onCaptureCanvas={captureCanvas}
/> />
)} )}
@@ -129,9 +129,9 @@ describe('CustomModulesSettingsPage', () => {
render(<CustomModulesSettingsPage />); render(<CustomModulesSettingsPage />);
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument(); expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument(); expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument();
expect( expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan(
screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length, 0,
).toBeGreaterThan(0); );
}); });
it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => { it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => {
@@ -158,7 +158,7 @@ describe('CustomModulesSettingsPage', () => {
expect(mockCreate).toHaveBeenCalledWith({ expect(mockCreate).toHaveBeenCalledWith({
name: 'Mein Wiki', name: 'Mein Wiki',
url: 'https://example.com', url: 'https://example.com',
category: 'infrastructure', category: 'custom-modules',
}); });
}); });
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared'); expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared');
@@ -228,7 +228,9 @@ describe('CustomModulesSettingsPage', () => {
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument(); expect(
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
+3 -5
View File
@@ -3,6 +3,7 @@ import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server'; import { getLocale, getMessages } from 'next-intl/server';
import { ThemeProvider } from 'next-themes'; import { ThemeProvider } from 'next-themes';
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard'; import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
import { DesktopExternalLinks } from '@/components/desktop/desktop-external-links';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -10,11 +11,7 @@ export const metadata: Metadata = {
description: 'Modulare Workflow-Plattform', description: 'Modulare Workflow-Plattform',
}; };
export default async function RootLayout({ export default async function RootLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
const locale = await getLocale(); const locale = await getLocale();
const messages = await getMessages(); const messages = await getMessages();
@@ -29,6 +26,7 @@ export default async function RootLayout({
> >
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
<DesktopContextMenuGuard /> <DesktopContextMenuGuard />
<DesktopExternalLinks />
{children} {children}
</NextIntlClientProvider> </NextIntlClientProvider>
</ThemeProvider> </ThemeProvider>
@@ -83,6 +83,18 @@ export function AdminSidebar() {
</svg> </svg>
), ),
}, },
{
label: t('admin.welcomeMail'),
href: '/admin/welcome-mail',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
<path d="m16 19 2 2 4-4" />
</svg>
),
},
{ {
label: t('admin.groups'), label: t('admin.groups'),
href: '/admin/groups', href: '/admin/groups',
@@ -0,0 +1,298 @@
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
// Namensraumfaehiger next-intl-Mock gegen die echten deutschen Texte:
// Punktschluessel werden aufgeloest, {param} wird eingesetzt.
vi.mock('next-intl', () => ({
useLocale: () => 'de',
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
let val: unknown = de;
for (const part of `${ns}.${key}`.split('.')) {
val = (val as Record<string, unknown> | undefined)?.[part];
}
if (typeof val !== 'string') return `${ns}.${key}`;
return params
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
: val;
},
}));
const api = vi.hoisted(() => ({
fetchWelcomeMailTemplate: vi.fn(),
saveWelcomeMailTemplate: vi.fn(),
resetWelcomeMailTemplate: vi.fn(),
previewWelcomeMail: vi.fn(),
sendWelcomeMailTest: vi.fn(),
}));
vi.mock('@/lib/welcome-mail-template-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/welcome-mail-template-api')>();
return { ...actual, ...api };
});
import { WelcomeMailTemplateRequestError } from '@/lib/welcome-mail-template-api';
import { WelcomeMailTemplateEditor } from './welcome-mail-template-editor';
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
const custom = {
subject: 'Hallo {{vorname}}',
heading: 'Willkommen, {{name}}!',
intro: 'Eigene Einleitung',
loginHintDirectory: 'Eigener Verzeichnis-Hinweis',
loginHintLocal: 'Eigener Lokal-Hinweis',
closing: 'Ihr IT-Team',
};
function state(isCustom: boolean) {
return {
custom: isCustom,
texts: isCustom ? { ...custom } : { ...defaults },
defaults: { ...defaults },
updatedAt: isCustom ? '2026-09-30T10:00:00.000Z' : null,
updatedBy: isCustom ? 'ada' : null,
};
}
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
api.fetchWelcomeMailTemplate.mockResolvedValue(state(false));
api.previewWelcomeMail.mockImplementation(async (texts: typeof defaults, account: string) => ({
subject: texts.subject,
html: `<html><body>Vorschau ${account}</body></html>`,
text: '',
}));
api.saveWelcomeMailTemplate.mockImplementation(async (texts: typeof defaults) => ({
...state(true),
texts: { ...texts },
}));
api.resetWelcomeMailTemplate.mockResolvedValue(state(false));
api.sendWelcomeMailTest.mockResolvedValue({ success: true, to: 'ada@example.invalid' });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
async function renderLoaded() {
render(<WelcomeMailTemplateEditor />);
return (await screen.findByLabelText('Betreff')) as HTMLInputElement;
}
describe('WelcomeMailTemplateEditor', () => {
it('laedt die Standardtexte ins Formular und zeigt Hinweis auf die festen Bausteine', async () => {
const subject = await renderLoaded();
expect(subject.value).toBe(defaults.subject);
expect((screen.getByLabelText('Überschrift') as HTMLInputElement).value).toBe(defaults.heading);
expect((screen.getByLabelText('Einleitungstext') as HTMLTextAreaElement).value).toBe(
defaults.intro,
);
expect((screen.getByLabelText('Abschlusstext') as HTMLTextAreaElement).value).toBe(
defaults.closing,
);
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
'Zurzeit gelten die Standardtexte.',
);
expect(
screen.getByText(/So kann eine eigene Vorlage die Anmeldung nicht kaputt machen/),
).toBeTruthy();
});
it('Hinweisbox Platzhalter: Tabelle mit allen sechs Platzhaltern, Bedeutung und Beispiel', async () => {
await renderLoaded();
const box = screen.getByRole('region', { name: 'Platzhalter' });
const rows = within(box).getAllByRole('row');
expect(rows).toHaveLength(7); // Kopf + 6
for (const token of [
'{{name}}',
'{{vorname}}',
'{{benutzername}}',
'{{email}}',
'{{adresse}}',
'{{firma}}',
]) {
expect(within(box).getByRole('button', { name: `${token} einfügen` })).toBeTruthy();
}
expect(within(box).getByText('Name Ihres Mandanten')).toBeTruthy();
expect(within(box).getByText('max.mustermann@example.com')).toBeTruthy();
});
it('Klick auf einen Platzhalter fuegt ihn an der Cursorposition des zuletzt fokussierten Feldes ein', async () => {
const subject = await renderLoaded();
fireEvent.change(subject, { target: { value: 'Hallo bei uns' } });
subject.focus();
subject.setSelectionRange(6, 6);
fireEvent.click(screen.getByRole('button', { name: '{{vorname}} einfügen' }));
expect(subject.value).toBe('Hallo {{vorname}} bei uns');
});
it('unbekannter Platzhalter wird benannt, Speichern und Testmail sind gesperrt', async () => {
const user = userEvent.setup();
await renderLoaded();
const heading = screen.getByLabelText('Überschrift');
await user.clear(heading);
fireEvent.change(heading, { target: { value: 'Hallo {{xyz}}' } });
expect(await screen.findByText('Unbekannter Platzhalter {{xyz}}')).toBeTruthy();
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
true,
);
expect(
(screen.getByRole('button', { name: 'Testmail an mich senden' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it('Anmeldehinweise: beide Felder nach dem Einleitungstext, mit Standardtext bzw. eigener Vorlage vorbelegt', async () => {
await renderLoaded();
const dir = screen.getByLabelText(
'Anmeldehinweis für Verzeichniskonten',
) as HTMLTextAreaElement;
const local = screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement;
expect(dir.value).toBe(
'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.',
);
expect(local.value).toBe(
'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.',
);
// Reihenfolge: Einleitung → Verzeichnis → lokal → Abschluss
const intro = screen.getByLabelText('Einleitungstext');
const closing = screen.getByLabelText('Abschlusstext');
const follows = (a: Element, b: Element) =>
Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
expect(follows(intro, dir)).toBe(true);
expect(follows(dir, local)).toBe(true);
expect(follows(local, closing)).toBe(true);
expect(screen.getByText(/direkt vor dem Knopf „Passwort festlegen“/)).toBeTruthy();
cleanup();
api.fetchWelcomeMailTemplate.mockResolvedValue(state(true));
await renderLoaded();
expect(
(screen.getByLabelText('Anmeldehinweis für Verzeichniskonten') as HTMLTextAreaElement).value,
).toBe(custom.loginHintDirectory);
expect(
(screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement).value,
).toBe(custom.loginHintLocal);
});
it('Anmeldehinweis: Pflichtfeld, Platzhalter-Klick fuegt ein, Fokus schaltet die Vorschau auf die Kontoart', async () => {
await renderLoaded();
const local = screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement;
fireEvent.change(local, { target: { value: 'Hallo ' } });
local.focus();
local.setSelectionRange(6, 6);
fireEvent.click(screen.getByRole('button', { name: '{{vorname}} einfügen' }));
expect(local.value).toBe('Hallo {{vorname}}');
expect(screen.getByRole('button', { name: 'Lokales Konto' }).getAttribute('aria-pressed')).toBe(
'true',
);
fireEvent.change(local, { target: { value: ' ' } });
expect(await screen.findByText('Bitte füllen Sie dieses Feld aus.')).toBeTruthy();
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
true,
);
});
it('Speichern schickt alle sechs Texte inkl. Anmeldehinweise', async () => {
const user = userEvent.setup();
await renderLoaded();
fireEvent.change(screen.getByLabelText('Anmeldehinweis für Verzeichniskonten'), {
target: { value: 'Mit dem Firmenkennwort anmelden, {{vorname}}.' },
});
fireEvent.change(screen.getByLabelText('Anmeldehinweis für lokale Konten'), {
target: { value: 'Erst Passwort setzen.' },
});
await user.click(screen.getByRole('button', { name: 'Speichern' }));
expect(api.saveWelcomeMailTemplate).toHaveBeenCalledWith({
...defaults,
loginHintDirectory: 'Mit dem Firmenkennwort anmelden, {{vorname}}.',
loginHintLocal: 'Erst Passwort setzen.',
});
});
it('Speichern schickt die Texte und meldet Erfolg; Status zeigt die eigene Vorlage', async () => {
const user = userEvent.setup();
const subject = await renderLoaded();
fireEvent.change(subject, { target: { value: 'Neu für {{firma}}' } });
await user.click(screen.getByRole('button', { name: 'Speichern' }));
expect(api.saveWelcomeMailTemplate).toHaveBeenCalledWith({
...defaults,
subject: 'Neu für {{firma}}',
});
expect((await screen.findByRole('status')).textContent).toContain(
'Die Vorlage wurde gespeichert.',
);
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
'Eigene Vorlage aktiv',
);
expect(screen.getByTestId('welcome-template-status').textContent).toContain('von ada');
});
it('Fehler der API beim Speichern wird angezeigt (400 mit Meldung)', async () => {
const user = userEvent.setup();
api.saveWelcomeMailTemplate.mockRejectedValueOnce(
new WelcomeMailTemplateRequestError(400, ['Betreff: Unbekannter Platzhalter {{abc}}']),
);
await renderLoaded();
await user.click(screen.getByRole('button', { name: 'Speichern' }));
expect((await screen.findByRole('alert')).textContent).toBe(
'Betreff: Unbekannter Platzhalter {{abc}}',
);
});
it('Auf Standard zuruecksetzen: Rueckfrage, DELETE, danach Standardtexte im Formular', async () => {
const user = userEvent.setup();
api.fetchWelcomeMailTemplate.mockResolvedValue(state(true));
const subject = await renderLoaded();
expect(subject.value).toBe(custom.subject);
await user.click(screen.getByRole('button', { name: 'Auf Standard zurücksetzen' }));
const dialog = screen.getByRole('alertdialog');
expect(api.resetWelcomeMailTemplate).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole('button', { name: 'Zurücksetzen' }));
expect(api.resetWelcomeMailTemplate).toHaveBeenCalledTimes(1);
await waitFor(() => expect(subject.value).toBe(defaults.subject));
expect(screen.getByTestId('welcome-template-status').textContent).toContain('Standardtexte');
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('Vorschau: iframe mit dem von der API gerenderten HTML, Umschalter lokales Konto', async () => {
const user = userEvent.setup();
await renderLoaded();
const frame = screen.getByTitle('Vorschau der Willkommensmail') as HTMLIFrameElement;
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau directory'));
expect(frame.getAttribute('sandbox')).toBe('');
await user.click(screen.getByRole('button', { name: 'Lokales Konto' }));
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau local'));
expect(api.previewWelcomeMail).toHaveBeenLastCalledWith(defaults, 'local', expect.anything());
expect(screen.getByTestId('welcome-preview-subject').textContent).toBe(defaults.subject);
});
it('Testmail an mich: sendet den aktuellen (ungespeicherten) Inhalt und nennt die Adresse', async () => {
const user = userEvent.setup();
const subject = await renderLoaded();
fireEvent.change(subject, { target: { value: 'Ungespeichert' } });
await user.click(screen.getByRole('button', { name: 'Testmail an mich senden' }));
expect(api.sendWelcomeMailTest).toHaveBeenCalledWith({ ...defaults, subject: 'Ungespeichert' });
expect(api.saveWelcomeMailTemplate).not.toHaveBeenCalled();
expect((await screen.findByRole('status')).textContent).toBe(
'Die Testmail wurde an ada@example.invalid gesendet.',
);
});
it('Ladefehler wird angezeigt', async () => {
api.fetchWelcomeMailTemplate.mockRejectedValueOnce(new Error('down'));
render(<WelcomeMailTemplateEditor />);
expect((await screen.findByRole('alert')).textContent).toContain('konnte nicht geladen werden');
});
});
@@ -0,0 +1,513 @@
'use client';
import {
findUnknownWelcomeMailPlaceholders,
WELCOME_MAIL_LIMITS,
WELCOME_MAIL_PLACEHOLDERS,
type WelcomeMailPlaceholder,
type WelcomeMailTexts,
} from '@tessera/shared';
import { useLocale, useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import {
fetchWelcomeMailTemplate,
previewWelcomeMail,
resetWelcomeMailTemplate,
saveWelcomeMailTemplate,
sendWelcomeMailTest,
type WelcomeMailPreview,
WelcomeMailTemplateRequestError,
type WelcomeMailTemplateState,
} from '@/lib/welcome-mail-template-api';
type Field = keyof WelcomeMailTexts;
type PreviewAccount = 'directory' | 'local';
type Feedback = { type: 'success' | 'error'; message: string } | null;
const FIELDS: Field[] = [
'subject',
'heading',
'intro',
'loginHintDirectory',
'loginHintLocal',
'closing',
];
const MULTILINE: Record<Field, boolean> = {
subject: false,
heading: false,
intro: true,
loginHintDirectory: true,
loginHintLocal: true,
closing: true,
};
/** Pflichtfelder (dieselben wie im DTO der API). */
const REQUIRED: ReadonlySet<Field> = new Set([
'subject',
'heading',
'loginHintDirectory',
'loginHintLocal',
]);
/** Zeilen der mehrzeiligen Felder. */
const ROWS: Partial<Record<Field, number>> = {
intro: 6,
loginHintDirectory: 3,
loginHintLocal: 3,
closing: 5,
};
/** Welche Kontoart die Vorschau zeigt, wenn ein Anmeldehinweis bearbeitet wird. */
const HINT_ACCOUNT: Partial<Record<Field, PreviewAccount>> = {
loginHintDirectory: 'directory',
loginHintLocal: 'local',
};
/** Verzoegerung der Live-Vorschau nach der letzten Eingabe. */
const PREVIEW_DELAY_MS = 400;
/**
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
*
* Sechs Textfelder (Betreff, Ueberschrift, Einleitung, Anmeldehinweis fuer
* Verzeichniskonten, Anmeldehinweis fuer lokale Konten, Abschluss), eine
* Hinweisbox mit den Platzhaltern (Klick fuegt an der Cursorposition des
* zuletzt benutzten Feldes ein), eine Live-Vorschau (von der API gerendert,
* also dieselbe Mail wie im Versand) mit Umschalter Verzeichniskonto/lokales
* Konto (springt beim Bearbeiten eines Anmeldehinweises auf die passende
* Kontoart), "Testmail an mich senden" und "Auf Standard zuruecksetzen".
*
* Unbekannte Platzhalter benennt das Formular schon beim Tippen; die API
* lehnt sie beim Speichern ohnehin mit 400 ab (Meldung wird angezeigt).
*/
export function WelcomeMailTemplateEditor() {
const t = useTranslations('admin.welcomeMail');
const locale = useLocale();
const [state, setState] = useState<WelcomeMailTemplateState | null>(null);
const [form, setForm] = useState<WelcomeMailTexts | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [resetting, setResetting] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [feedback, setFeedback] = useState<Feedback>(null);
const [account, setAccount] = useState<PreviewAccount>('directory');
const [preview, setPreview] = useState<WelcomeMailPreview | null>(null);
const [previewFailed, setPreviewFailed] = useState(false);
const fieldRefs = useRef<Partial<Record<Field, HTMLInputElement | HTMLTextAreaElement | null>>>(
{},
);
const lastFocused = useRef<Field>('intro');
useEffect(() => {
fetchWelcomeMailTemplate()
.then((loaded) => {
setState(loaded);
setForm({ ...loaded.texts });
})
.catch(() => setLoadFailed(true));
}, []);
// ── Pruefung im Formular (dieselben Regeln wie die API) ───────────────
const fieldErrors = (field: Field, value: string): string[] => {
const errors: string[] = [];
if (REQUIRED.has(field) && !value.trim()) {
errors.push(t('errors.required'));
}
if (value.length > WELCOME_MAIL_LIMITS[field]) {
errors.push(t('errors.tooLong', { max: WELCOME_MAIL_LIMITS[field] }));
}
const unknown = findUnknownWelcomeMailPlaceholders(value);
if (unknown.length === 1) errors.push(t('errors.unknownPlaceholder', { token: unknown[0] }));
if (unknown.length > 1) {
errors.push(t('errors.unknownPlaceholders', { tokens: unknown.join(', ') }));
}
return errors;
};
const errorsByField = form
? (Object.fromEntries(FIELDS.map((f) => [f, fieldErrors(f, form[f])])) as Record<
Field,
string[]
>)
: null;
const hasErrors = errorsByField ? FIELDS.some((f) => errorsByField[f].length > 0) : false;
const dirty = !!form && !!state && FIELDS.some((f) => form[f] !== state.texts[f]);
// ── Live-Vorschau ─────────────────────────────────────────────────────
useEffect(() => {
if (!form || hasErrors) return;
const controller = new AbortController();
const timer = setTimeout(() => {
previewWelcomeMail(form, account, controller.signal)
.then((result) => {
setPreview(result);
setPreviewFailed(false);
})
.catch((error: unknown) => {
if ((error as { name?: string })?.name === 'AbortError') return;
setPreviewFailed(true);
});
}, PREVIEW_DELAY_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [form, account, hasErrors]);
// ── Aktionen ──────────────────────────────────────────────────────────
const serverMessage = (error: unknown, fallback: string): string => {
if (error instanceof WelcomeMailTemplateRequestError && error.messages.length > 0) {
return error.messages.join(' ');
}
return fallback;
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!form || hasErrors) return;
setSaving(true);
setFeedback(null);
try {
const saved = await saveWelcomeMailTemplate(form);
setState(saved);
setForm({ ...saved.texts });
setFeedback({ type: 'success', message: t('saved') });
} catch (error) {
setFeedback({ type: 'error', message: serverMessage(error, t('errors.save')) });
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!state) return;
setResetting(true);
setFeedback(null);
try {
const next = state.custom ? await resetWelcomeMailTemplate() : state;
setState(next);
setForm({ ...next.defaults });
setConfirmReset(false);
setFeedback({ type: 'success', message: t('resetDone') });
} catch (error) {
setFeedback({ type: 'error', message: serverMessage(error, t('errors.reset')) });
} finally {
setResetting(false);
}
};
const handleTest = async () => {
if (!form || hasErrors) return;
setTesting(true);
setFeedback(null);
try {
const result = await sendWelcomeMailTest(form);
setFeedback({ type: 'success', message: t('testSent', { email: result.to }) });
} catch (error) {
setFeedback({ type: 'error', message: serverMessage(error, t('errors.test')) });
} finally {
setTesting(false);
}
};
/** Fuegt `{{name}}` an der Cursorposition des zuletzt benutzten Feldes ein. */
const insertPlaceholder = (placeholder: WelcomeMailPlaceholder) => {
if (!form) return;
const field = lastFocused.current;
const token = `{{${placeholder}}}`;
const el = fieldRefs.current[field];
const value = form[field];
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
setForm({ ...form, [field]: value.slice(0, start) + token + value.slice(end) });
const caret = start + token.length;
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(caret, caret);
});
};
// ── Darstellung ───────────────────────────────────────────────────────
if (loadFailed) {
return (
<p role="alert" className="text-sm text-destructive">
{t('errors.load')}
</p>
);
}
if (!form || !state || !errorsByField) {
return <p className="text-sm text-muted-foreground">{t('loading')}</p>;
}
const inputClass =
'w-full rounded border border-border bg-background px-3 text-sm text-foreground';
const statusText = state.custom
? state.updatedAt
? t(state.updatedBy ? 'statusCustomBy' : 'statusCustom', {
date: new Date(state.updatedAt).toLocaleString(locale === 'en' ? 'en-GB' : 'de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
}),
user: state.updatedBy ?? '',
})
: t('statusCustom', { date: '' })
: t('statusDefault');
return (
<div className="space-y-6">
<div>
<h1 className="mb-2 text-lg font-semibold text-foreground">{t('title')}</h1>
<p className="max-w-3xl text-sm text-muted-foreground">{t('description')}</p>
</div>
{/* Feste Bausteine */}
<section
aria-labelledby="welcome-fixed-title"
className="max-w-3xl rounded-lg border border-border bg-muted/40 p-4"
>
<h2 id="welcome-fixed-title" className="mb-1 text-sm font-semibold text-foreground">
{t('fixed.title')}
</h2>
<p className="text-sm text-muted-foreground">{t('fixed.body')}</p>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
{/* Formular */}
<form onSubmit={handleSave} className="min-w-0 space-y-4" noValidate>
<p
className={`text-sm ${state.custom ? 'text-foreground' : 'text-muted-foreground'}`}
data-testid="welcome-template-status"
>
{statusText}
{dirty && <span className="ml-2 font-medium">· {t('unsaved')}</span>}
</p>
{FIELDS.map((field) => {
const id = `welcome-${field}`;
const errors = errorsByField[field];
const describedBy = [`${id}-help`, errors.length ? `${id}-error` : '']
.filter(Boolean)
.join(' ');
const common = {
id,
value: form[field],
'aria-invalid': errors.length > 0 || undefined,
'aria-describedby': describedBy,
onFocus: () => {
lastFocused.current = field;
const hintAccount = HINT_ACCOUNT[field];
if (hintAccount) setAccount(hintAccount);
},
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
},
};
return (
<div key={field}>
<label htmlFor={id} className="mb-1 block text-sm text-foreground">
{t(`fields.${field}`)}
</label>
{MULTILINE[field] ? (
<textarea
{...common}
ref={(el) => {
fieldRefs.current[field] = el;
}}
rows={ROWS[field] ?? 5}
className={`${inputClass} py-2`}
/>
) : (
<input
{...common}
ref={(el) => {
fieldRefs.current[field] = el;
}}
type="text"
className={`${inputClass} h-9`}
/>
)}
<div className="mt-1 flex justify-between gap-3 text-xs text-muted-foreground">
<span id={`${id}-help`}>{t(`fields.${field}Help`)}</span>
<span className="shrink-0 tabular-nums">
{t('counter', { count: form[field].length, max: WELCOME_MAIL_LIMITS[field] })}
</span>
</div>
{errors.length > 0 && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive">
{errors.join(' ')}
</p>
)}
</div>
);
})}
<div className="flex flex-wrap gap-3 pt-2">
<button type="submit" disabled={saving || hasErrors} className="btn btn-primary">
{saving ? t('saving') : t('save')}
</button>
<button
type="button"
disabled={testing || hasErrors}
onClick={handleTest}
className="btn btn-secondary"
>
{testing ? t('testSending') : t('test')}
</button>
<button
type="button"
disabled={resetting}
onClick={() => setConfirmReset(true)}
className="btn btn-secondary"
>
{t('reset')}
</button>
</div>
<p className="text-xs text-muted-foreground">{t('testHelp')}</p>
{confirmReset && (
<div
role="alertdialog"
aria-labelledby="welcome-reset-title"
aria-describedby="welcome-reset-body"
className="max-w-xl rounded-lg border border-border bg-background p-4"
>
<h2 id="welcome-reset-title" className="mb-1 text-sm font-semibold text-foreground">
{t('resetConfirm.title')}
</h2>
<p id="welcome-reset-body" className="mb-3 text-sm text-muted-foreground">
{t('resetConfirm.body')}
</p>
<div className="flex gap-3">
<button
type="button"
disabled={resetting}
onClick={handleReset}
className="btn btn-primary"
>
{t('resetConfirm.confirm')}
</button>
<button
type="button"
onClick={() => setConfirmReset(false)}
className="btn btn-secondary"
>
{t('resetConfirm.cancel')}
</button>
</div>
</div>
)}
{feedback && (
<p
role={feedback.type === 'error' ? 'alert' : 'status'}
className={`text-sm ${feedback.type === 'error' ? 'text-destructive' : ''}`}
style={feedback.type === 'success' ? { color: 'oklch(0.40 0.15 148)' } : undefined}
>
{feedback.message}
</p>
)}
{/* Platzhalter */}
<section
aria-labelledby="welcome-placeholders-title"
className="rounded-lg border border-border p-4"
>
<h2
id="welcome-placeholders-title"
className="mb-1 text-sm font-semibold text-foreground"
>
{t('placeholders.title')}
</h2>
<p className="mb-3 text-sm text-muted-foreground">{t('placeholders.intro')}</p>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th scope="col" className="py-2 pr-3 font-medium">
{t('placeholders.colPlaceholder')}
</th>
<th scope="col" className="py-2 pr-3 font-medium">
{t('placeholders.colMeaning')}
</th>
<th scope="col" className="py-2 font-medium">
{t('placeholders.colExample')}
</th>
</tr>
</thead>
<tbody>
{WELCOME_MAIL_PLACEHOLDERS.map((name) => (
<tr key={name} className="border-b border-border last:border-0 align-top">
<td className="py-2 pr-3">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertPlaceholder(name)}
title={t('placeholders.insertHint')}
aria-label={t('placeholders.insert', { token: `{{${name}}}` })}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground hover:bg-sidebar-hover"
>
{`{{${name}}}`}
</button>
</td>
<td className="py-2 pr-3 text-foreground">
{t(`placeholders.items.${name}.meaning`)}
</td>
<td className="py-2 text-muted-foreground">
{t(`placeholders.items.${name}.example`)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-muted-foreground">{t('placeholders.rules')}</p>
</section>
</form>
{/* Vorschau */}
<section aria-labelledby="welcome-preview-title" className="min-w-0">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h2 id="welcome-preview-title" className="text-sm font-semibold text-foreground">
{t('preview.title')}
</h2>
<fieldset className="flex gap-1">
<legend className="sr-only">{t('preview.accountLabel')}</legend>
{(['directory', 'local'] as const).map((kind) => (
<button
key={kind}
type="button"
aria-pressed={account === kind}
onClick={() => setAccount(kind)}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
account === kind
? 'border-foreground bg-foreground text-background'
: 'border-border text-foreground hover:bg-sidebar-hover'
}`}
>
{t(`preview.${kind}`)}
</button>
))}
</fieldset>
</div>
<p className="mb-2 text-sm text-muted-foreground">
{t('preview.subject')}{' '}
<span className="font-medium text-foreground" data-testid="welcome-preview-subject">
{preview?.subject ?? ''}
</span>
</p>
{hasErrors && <p className="mb-2 text-sm text-destructive">{t('preview.blocked')}</p>}
{previewFailed && <p className="mb-2 text-sm text-destructive">{t('preview.error')}</p>}
<iframe
title={t('preview.frameTitle')}
sandbox=""
srcDoc={preview?.html ?? ''}
className="h-[760px] w-full rounded-lg border border-border bg-white"
/>
<p className="mt-2 text-xs text-muted-foreground">{t('preview.sampleHint')}</p>
</section>
</div>
</div>
);
}
@@ -1,14 +1,16 @@
'use client'; 'use client';
import { useState } from 'react'; import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared'; import { useState } from 'react';
import { import {
type CustomModule, type CustomModule,
CustomModuleRequestError,
checkCustomModuleUrl, checkCustomModuleUrl,
createCustomModule, createCustomModule,
updateCustomModule, updateCustomModule,
} from '@/lib/custom-modules-api'; } from '@/lib/custom-modules-api';
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
import { useCategoryLabel } from '@/lib/use-category-label'; import { useCategoryLabel } from '@/lib/use-category-label';
interface CustomModuleFormModalProps { interface CustomModuleFormModalProps {
@@ -27,6 +29,23 @@ interface CustomModuleFormModalProps {
const NAME_MAX = 100; const NAME_MAX = 100;
const URL_MAX = 2048; const URL_MAX = 2048;
/**
* Fehlertext fuer Speichern und Loeschen: 403 = keine Berechtigung, 400 = die
* API hat die Angaben abgelehnt (mit ihrer Meldung), alles andere (500,
* Netzwerk) = der allgemeine „bitte erneut versuchen“-Text `fallbackKey`.
*/
export function customModuleErrorText(
err: unknown,
t: (key: string, values?: Record<string, string>) => string,
fallbackKey: 'saveError' | 'deleteError',
): string {
if (err instanceof CustomModuleRequestError) {
if (err.status === 403) return t('forbidden');
if (err.status === 400) return t('invalidInput');
}
return t(fallbackKey);
}
/** /**
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`; * Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`;
* quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame, * quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame,
@@ -47,7 +66,9 @@ export function CustomModuleFormModal({
const [name, setName] = useState(mod?.name ?? ''); const [name, setName] = useState(mod?.name ?? '');
const [url, setUrl] = useState(mod?.url ?? ''); const [url, setUrl] = useState(mod?.url ?? '');
const [category, setCategory] = useState<string>(mod?.category ?? 'infrastructure'); // Neue Eintraege landen vorbelegt in der Gruppe „Eigene Module“; beim
// Bearbeiten bleibt die gespeicherte Kategorie.
const [category, setCategory] = useState<string>(mod?.category ?? CUSTOM_MODULE_CATEGORY);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -76,13 +97,17 @@ export function CustomModuleFormModal({
const input = { name: trimmedName, url: trimmedUrl, category }; const input = { name: trimmedName, url: trimmedUrl, category };
if (mod) { if (mod) {
await updateCustomModule(mod.id, input); await updateCustomModule(mod.id, input);
// Sitzungsspeicher der offen gehaltenen Module nachziehen — nur wenn
// das Modul dort schon liegt; eine neue Adresse laedt dessen <iframe> um.
const cache = useCustomModuleCacheStore.getState();
if (cache.modules[mod.id]) cache.remember({ id: mod.id, name: input.name, url: input.url });
} else { } else {
await createCustomModule(shared ? { ...input, shared: true } : input); await createCustomModule(shared ? { ...input, shared: true } : input);
} }
onSaved(); onSaved();
onClose(); onClose();
} catch { } catch (err) {
setError(t('saveError')); setError(customModuleErrorText(err, t, 'saveError'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api'; import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useCategoryLabel } from '@/lib/use-category-label'; import { useCategoryLabel } from '@/lib/use-category-label';
@@ -86,9 +86,7 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 <h1 className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}>
className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}
>
{texts.title} {texts.title}
</h1> </h1>
<button type="button" onClick={openCreate} className="btn btn-primary"> <button type="button" onClick={openCreate} className="btn btn-primary">
@@ -104,9 +102,11 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
</div> </div>
)} )}
{/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da —
„keine Eintraege“ waere dann falsch. */}
{loading ? ( {loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p> <p className="text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 ? ( ) : modules.length === 0 && loadFailed ? null : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center"> <div className="flex flex-col items-center justify-center py-16 text-center">
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2> <h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p> <p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
@@ -119,9 +119,15 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
<tr> <tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('name')}</th> <th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('url')}</th> {t('name')}
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('category')}</th> </th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('url')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('category')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"> <th className="px-4 py-3 text-right font-medium text-muted-foreground">
{tManage('actions')} {tManage('actions')}
</th> </th>
@@ -1,8 +1,10 @@
'use client'; 'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api'; import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
import { customModuleErrorText } from './custom-module-form-modal';
interface DeleteCustomModuleDialogProps { interface DeleteCustomModuleDialogProps {
mod: CustomModule; mod: CustomModule;
@@ -35,10 +37,12 @@ export function DeleteCustomModuleDialog({
setError(null); setError(null);
try { try {
await deleteCustomModule(mod.id); await deleteCustomModule(mod.id);
// Offen gehaltenen <iframe> und Sitzungsspeicher des Moduls verwerfen.
useCustomModuleCacheStore.getState().forget(mod.id);
onDeleted(); onDeleted();
onClose(); onClose();
} catch { } catch (err) {
setError(t('deleteError')); setError(customModuleErrorText(err, t, 'deleteError'));
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
@@ -55,7 +59,11 @@ export function DeleteCustomModuleDialog({
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground"> <h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
{t('deleteConfirm.title')} {t('deleteConfirm.title')}
</h2> </h2>
<p className="mb-4 text-sm text-foreground">{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', { name: mod.name })}</p> <p className="mb-4 text-sm text-foreground">
{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', {
name: mod.name,
})}
</p>
{error && ( {error && (
<div <div
@@ -1,4 +1,4 @@
import { act, renderHook, waitFor } from '@testing-library/react'; import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store'; import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store';
@@ -13,6 +13,23 @@ vi.mock('@/lib/auth-actions', () => ({
updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args), updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args),
})); }));
// quick-260930: Auswahlfenster (Fokus, geloeschtes Bild) braucht Uebersetzung,
// Erscheinungsbild und die Bilderliste.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock('next-themes', () => ({
useTheme: () => ({ resolvedTheme: 'light' }),
}));
const fetchDashboardImages = vi.fn();
vi.mock('@/lib/dashboard-images-api', () => ({
fetchDashboardImages: (...args: unknown[]) => fetchDashboardImages(...args),
uploadDashboardImage: vi.fn(),
dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${id}`,
}));
const IMAGE_ID = '11111111-2222-3333-4444-555555555555';
const LEGACY_KEY = 'tessera.dashboardBackground.u1'; const LEGACY_KEY = 'tessera.dashboardBackground.u1';
function makeUser(overrides: Partial<AuthUser> = {}): AuthUser { function makeUser(overrides: Partial<AuthUser> = {}): AuthUser {
@@ -141,3 +158,147 @@ describe('useDashboardBackground (quick-260928-ujj)', () => {
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled(); expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
}); });
}); });
describe('useDashboardBackground — Rueckfall nur fuer den eigenen Wert (quick-260930)', () => {
it('scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => {
let rejectFirst: (e: Error) => void = () => {};
updateDashboardBackgroundAction
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectFirst = reject;
}),
)
.mockResolvedValueOnce({ success: true });
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) });
const { result } = await renderBackgroundHook();
act(() => result.current.choose({ kind: 'preset', id: 'mist' }));
act(() => result.current.choose({ kind: 'preset', id: 'dunes' }));
await act(async () => {
rejectFirst(new Error('offline'));
await Promise.resolve();
});
expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' });
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({
kind: 'preset',
id: 'dunes',
});
});
});
describe('useDashboardBackground — Bild nicht mehr vorhanden (quick-260930)', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('laesst sich das gewaehlte Bild nicht laden, gilt die Wahl als „kein Hintergrund“ (ohne zu speichern)', async () => {
class FailingImage {
onerror: (() => void) | null = null;
set src(_value: string) {
setTimeout(() => this.onerror?.(), 0);
}
}
vi.stubGlobal('Image', FailingImage);
useAuthStore.setState({
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
});
const { result } = await renderBackgroundHook();
await waitFor(() => expect(result.current.background).toEqual({ kind: 'none' }));
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
});
it('ein ladbares Bild bleibt gewaehlt', async () => {
class LoadingImage {
onerror: (() => void) | null = null;
onload: (() => void) | null = null;
set src(_value: string) {
setTimeout(() => this.onload?.(), 0);
}
}
vi.stubGlobal('Image', LoadingImage);
useAuthStore.setState({
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
});
const { result } = await renderBackgroundHook();
await act(async () => {
await new Promise((r) => setTimeout(r, 5));
});
expect(result.current.background).toEqual({ kind: 'image', imageId: IMAGE_ID });
});
});
describe('BackgroundPicker (quick-260930)', () => {
async function renderPicker(value: import('@/lib/dashboard-background').DashboardBackground) {
const { BackgroundPicker } = await import('./dashboard-background');
const onChange = vi.fn();
render(<BackgroundPicker value={value} onChange={onChange} />);
return { onChange, trigger: screen.getByRole('button', { name: 'button' }) };
}
beforeEach(() => {
fetchDashboardImages.mockReset();
fetchDashboardImages.mockResolvedValue([]);
});
it('Oeffnen setzt den Fokus auf die erste Auswahl, Escape schliesst und gibt ihn an den Knopf zurueck', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog');
const first = screen.getByRole('button', { name: 'none' });
expect(dialog).toContainElement(first);
expect(first).toHaveFocus();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it('Klick nach draussen schliesst und gibt den Fokus an den Knopf zurueck', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
fireEvent.click(trigger);
await screen.findByRole('dialog');
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('dialog')).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it('Tab bleibt im Fenster: vom letzten Knopf zum ersten, Umschalt+Tab vom ersten zum letzten', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog');
const buttons = Array.from(dialog.querySelectorAll('button'));
const first = buttons[0];
const last = buttons[buttons.length - 1];
last.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(first).toHaveFocus();
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
expect(last).toHaveFocus();
});
it('gewaehltes Bild fehlt in der Bilderliste -> „Keiner“ ist markiert', async () => {
fetchDashboardImages.mockResolvedValue([
{ id: 'anderes', originalName: 'urlaub.jpg', mimeType: 'image/jpeg', size: 1, createdAt: '' },
]);
const { trigger } = await renderPicker({ kind: 'image', imageId: IMAGE_ID });
fireEvent.click(trigger);
await screen.findByRole('button', { name: 'urlaub.jpg' });
expect(screen.getByRole('button', { name: 'none' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'urlaub.jpg' })).toHaveAttribute(
'aria-pressed',
'false',
);
});
});
@@ -26,6 +26,23 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
useAuthStore.getState().setUser({ ...current, dashboardBackground: value }); useAuthStore.getState().setUser({ ...current, dashboardBackground: value });
} }
/**
* Nimmt eine fehlgeschlagene Wahl zurueck — aber nur, wenn im Store noch
* GENAU dieser optimistisch gesetzte Wert steht (quick-260930). Hat der
* Benutzer inzwischen etwas anderes gewaehlt, wuerde der Fehler des ersten
* Klicks sonst die zweite Wahl ueberschreiben.
*/
function revertStoredBackground(
userId: string,
optimistic: DashboardBackground,
previous: DashboardBackground | null,
) {
const current = useAuthStore.getState().user;
if (!current || current.id !== userId) return;
if (current.dashboardBackground !== optimistic) return;
setStoredBackground(userId, previous);
}
/** /**
* Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj). * Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj).
* *
@@ -34,18 +51,43 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
* die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck. * die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck.
* Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor, * Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor,
* wird sie genau einmal je Benutzerkennung uebernommen. * wird sie genau einmal je Benutzerkennung uebernommen.
*
* quick-260930: Zeigt die Wahl auf ein eigenes Bild, das nicht (mehr) zu
* laden ist — etwa weil es im Bilderrahmen geloescht wurde (die API setzt die
* gespeicherte Wahl dabei auf „kein Hintergrund“, eine schon geladene Sitzung
* kennt aber noch den alten Wert) —, gilt sie als „kein Hintergrund“. Das
* ist nur Anzeige; gespeichert wird dabei nichts.
*/ */
export function useDashboardBackground() { export function useDashboardBackground() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const userId = user?.id ?? null; const userId = user?.id ?? null;
const stored = user?.dashboardBackground ?? null; const stored = user?.dashboardBackground ?? null;
const background = stored ?? NO_BACKGROUND; const imageId = stored?.kind === 'image' ? stored.imageId : null;
const [brokenImageId, setBrokenImageId] = useState<string | null>(null);
const background =
stored === null || (imageId !== null && imageId === brokenImageId) ? NO_BACKGROUND : stored;
const migratedFor = useRef<string | null>(null); const migratedFor = useRef<string | null>(null);
// Laedt das gewaehlte Bild einmal zur Probe; schlaegt das fehl, wird die
// Wahl als „kein Hintergrund“ angezeigt.
useEffect(() => {
if (imageId === null || typeof Image === 'undefined') return;
let active = true;
const probe = new Image();
probe.onerror = () => {
if (active) setBrokenImageId(imageId);
};
probe.src = dashboardImageSrc(imageId);
return () => {
active = false;
probe.onerror = null;
};
}, [imageId]);
const save = useCallback( const save = useCallback(
(id: string, value: DashboardBackground, previous: DashboardBackground | null) => { (id: string, value: DashboardBackground, previous: DashboardBackground | null) => {
setStoredBackground(id, value); setStoredBackground(id, value);
const revert = () => setStoredBackground(id, previous); const revert = () => revertStoredBackground(id, value, previous);
updateDashboardBackgroundAction(value) updateDashboardBackgroundAction(value)
.then((result) => { .then((result) => {
if (!result.success) revert(); if (!result.success) revert();
@@ -141,23 +183,88 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
const [images, setImages] = useState<DashboardImageMeta[] | null>(null); const [images, setImages] = useState<DashboardImageMeta[] | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Liste erfolgreich geladen? Nur dann laesst sich sagen, dass ein gewaehltes
// Bild fehlt (nach einem Abruffehler ist die Liste nur ersatzweise leer).
const [imagesListed, setImagesListed] = useState(false);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const wasOpen = useRef(false);
// Gewaehltes eigenes Bild, das es nicht mehr gibt -> „Keiner“ markieren
// (quick-260930).
const shown: DashboardBackground =
value.kind === 'image' &&
imagesListed &&
images !== null &&
!images.some((image) => image.id === value.imageId)
? NO_BACKGROUND
: value;
useEffect(() => { useEffect(() => {
if (!open || images !== null) return; if (!open || images !== null) return;
fetchDashboardImages() fetchDashboardImages()
.then(setImages) .then((list) => {
setImages(list);
setImagesListed(true);
})
.catch(() => setImages([])); .catch(() => setImages([]));
}, [open, images]); }, [open, images]);
// Fokus (quick-260930): beim Oeffnen auf die erste Auswahl („Keiner“),
// nach dem Schliessen zurueck auf den Knopf „Hintergrund“ — ausser der
// Benutzer hat zum Schliessen etwas anderes Fokussierbares angeklickt.
useEffect(() => {
if (open) {
wasOpen.current = true;
dialogRef.current?.querySelector<HTMLButtonElement>('button:not([disabled])')?.focus();
return;
}
if (!wasOpen.current) return;
wasOpen.current = false;
// Einen Takt spaeter pruefen: beim Klick nach draussen setzt der Browser
// den Fokus erst NACH dem mousedown-Horcher (auf das Ziel oder den body).
const timer = window.setTimeout(() => {
const active = document.activeElement;
if (!active || active === document.body || rootRef.current?.contains(active)) {
triggerRef.current?.focus();
}
}, 0);
return () => window.clearTimeout(timer);
}, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function onPointer(e: MouseEvent) { function onPointer(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
} }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false); if (e.key === 'Escape') {
setOpen(false);
return;
}
// Tab bleibt im Fenster (Muster ActivationDialog im Marktplatz):
// vom letzten Knopf zum ersten und mit Umschalt+Tab umgekehrt.
if (e.key === 'Tab' && dialogRef.current) {
const items = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled])'),
);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (!active || !dialogRef.current.contains(active)) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
} }
document.addEventListener('mousedown', onPointer); document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
@@ -190,7 +297,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
preview: CSSProperties, preview: CSSProperties,
content?: React.ReactNode, content?: React.ReactNode,
) => { ) => {
const selected = sameChoice(value, choice); const selected = sameChoice(shown, choice);
return ( return (
<li key={key}> <li key={key}>
<button <button
@@ -223,6 +330,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
return ( return (
<div ref={rootRef} className="relative"> <div ref={rootRef} className="relative">
<button <button
ref={triggerRef}
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-expanded={open} aria-expanded={open}
@@ -253,6 +361,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
{open && ( {open && (
<div <div
ref={dialogRef}
role="dialog" role="dialog"
aria-label={t('title')} aria-label={t('title')}
className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border" className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border"
@@ -1,8 +1,15 @@
import { Children, isValidElement } from 'react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { Children, isValidElement } from 'react';
import { createScaledStrategy, defaultPositionStrategy } from 'react-grid-layout/core';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer'; import { stubResizeObserver } from '@/test/fake-resize-observer';
import { RESIZE_AXIS_FALLBACK } from './dashboard-grid'; import {
computeCanvasScale,
fitCanvasToContent,
DASHBOARD_BOTTOM_GAP_PX,
RESIZE_AXIS_FALLBACK,
scaledPositionStrategy,
} from './dashboard-grid';
// Mock CSS imports that vitest cannot resolve // Mock CSS imports that vitest cannot resolve
vi.mock('react-grid-layout/css/styles.css', () => ({})); vi.mock('react-grid-layout/css/styles.css', () => ({}));
@@ -137,9 +144,7 @@ describe('DashboardGrid', () => {
}); });
it('renders widget instance when widgets are provided', async () => { it('renders widget instance when widgets are provided', async () => {
const widgets = [ const widgets = [{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } }];
{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } },
];
const layouts = { const layouts = {
lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }], lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }],
md: [], md: [],
@@ -167,9 +172,7 @@ describe('DashboardGrid', () => {
}); });
it('shows edit affordances (delete button) when isEditMode is true', async () => { it('shows edit affordances (delete button) when isEditMode is true', async () => {
const widgets = [ const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
{ id: 'inst-2', widgetType: 'clock', config: {} },
];
const layouts = { const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [], md: [],
@@ -312,7 +315,7 @@ describe('DashboardGrid', () => {
expect(layouts.lg[0].minH).toBe(8); expect(layouts.lg[0].minH).toBe(8);
}); });
it('quick-260929-dmx Test 9c: bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 8 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 8 angehoben', async () => { it('quick-260929-dmx Test 9c (quick-260930: Kalender-minW 11): bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 11 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 11 angehoben', async () => {
captured.props = null; captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid'); const { DashboardGrid } = await import('./dashboard-grid');
// So sieht ein aus v2 (minW 6 x2) migrierter Kalender im Zustand aus. // So sieht ein aus v2 (minW 6 x2) migrierter Kalender im Zustand aus.
@@ -341,25 +344,108 @@ describe('DashboardGrid', () => {
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>; const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
// Breite bleibt (Bildschirmgroesse unveraendert), nur das Minimum sinkt. // Breite bleibt (Bildschirmgroesse unveraendert), nur das Minimum sinkt.
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 8, minH: 8 }); expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 8, minH: 8 }); expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 8, h: 16, minW: 8, minH: 8 }); expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 11, h: 16, minW: 11, minH: 8 });
// Auch der data-grid-Wert der Kinder traegt das neue Minimum. // Auch der data-grid-Wert der Kinder traegt das neue Minimum.
const children = Children.toArray(captured.props?.children as React.ReactNode); const children = Children.toArray(captured.props?.children as React.ReactNode);
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props['data-grid'] as Record<string, unknown>; const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props[
expect(grid.minW).toBe(8); 'data-grid'
] as Record<string, unknown>;
expect(grid.minW).toBe(11);
}); });
const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag'; it('quick-260930 Test 9d: ein schmaler Kalender mit direktem Nachbarn wird nur bis zum Nachbarn angehoben (keine neue Ueberlappung), am Rasterrand nur bis zum Rand', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
lg: [
// Kalender w 8 (vor quick-260930 erlaubt), Uhr direkt rechts ab x 18.
{ i: 'cal-1', x: 10, y: 0, w: 8, h: 16 },
{ i: 'clk-1', x: 18, y: 4, w: 8, h: 4 },
// Kalender am rechten Rand: x 40 + 11 > 48.
{ i: 'cal-2', x: 40, y: 20, w: 8, h: 16 },
// Nachbar liegt tiefer (keine Zeilenueberschneidung) -> kein Hindernis.
{ i: 'cal-3', x: 0, y: 40, w: 8, h: 8 },
{ i: 'clk-2', x: 8, y: 48, w: 8, h: 4 },
],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'cal-1', widgetType: 'calendar', config: {} },
{ id: 'clk-1', widgetType: 'clock', config: {} },
{ id: 'cal-2', widgetType: 'calendar', config: {} },
{ id: 'cal-3', widgetType: 'calendar', config: {} },
{ id: 'clk-2', widgetType: 'clock', config: {} },
]}
isEditMode
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.lg[0]).toMatchObject({ i: 'cal-1', x: 10, w: 8, minW: 11 });
expect(passed.lg[1]).toMatchObject({ i: 'clk-1', x: 18, w: 8 });
expect(passed.lg[2]).toMatchObject({ i: 'cal-2', x: 40, w: 8, minW: 11 });
expect(passed.lg[3]).toMatchObject({ i: 'cal-3', x: 0, w: 11, minW: 11 });
});
it('quick-260930 Test 9e: minW und w werden auf die Spaltenzahl des Breakpoints begrenzt (xxs 4, xs 16)', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
xs: [{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4 }],
xxs: [
{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4, minW: 12 },
{ i: 'cal-1', x: 0, y: 4, w: 2, h: 16 },
],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'srch-1', widgetType: 'search', config: {} },
{ id: 'cal-1', widgetType: 'calendar', config: {} },
]}
isEditMode
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.xs[0]).toMatchObject({ w: 16, minW: 12 });
expect(passed.xxs[0]).toMatchObject({ w: 4, minW: 4 });
// Kalender (minW 11) am xxs-Breakpoint: Minimum 4, Breite auf 4 angehoben.
expect(passed.xxs[1]).toMatchObject({ w: 4, minW: 4 });
});
const CANCEL =
'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => { it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => {
const { DashboardGrid } = await import('./dashboard-grid'); const { DashboardGrid } = await import('./dashboard-grid');
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }]; const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }; const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
};
captured.props = null; captured.props = null;
render( render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />, <DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
); );
expect(captured.props?.dragConfig).toEqual({ expect(captured.props?.dragConfig).toEqual({
enabled: true, enabled: true,
@@ -372,7 +458,13 @@ describe('DashboardGrid', () => {
captured.props = null; captured.props = null;
render( render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />, <DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
); );
expect(captured.props?.dragConfig).toEqual({ expect(captured.props?.dragConfig).toEqual({
enabled: false, enabled: false,
@@ -400,7 +492,10 @@ describe('DashboardGrid', () => {
type: unknown; type: unknown;
allowOverlap: boolean; allowOverlap: boolean;
preventCollision?: boolean; preventCollision?: boolean;
compact: (layout: Array<Record<string, unknown>>, cols: number) => Array<Record<string, unknown>>; compact: (
layout: Array<Record<string, unknown>>,
cols: number,
) => Array<Record<string, unknown>>;
}; };
expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true }); expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true });
expect(typeof compactor.compact).toBe('function'); expect(typeof compactor.compact).toBe('function');
@@ -423,10 +518,22 @@ describe('DashboardGrid', () => {
expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL); expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL);
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }]; const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }; const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
};
render( render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />, <DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
); );
const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement; const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement;
@@ -455,7 +562,13 @@ describe('DashboardGrid', () => {
cleanup(); cleanup();
render( render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />, <DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
); );
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull(); expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
expect(document.querySelector('.widget-drag-handle')).toBeNull(); expect(document.querySelector('.widget-drag-handle')).toBeNull();
@@ -512,7 +625,9 @@ describe('DashboardGrid', () => {
expect(captured.props?.width).toBe(1000); expect(captured.props?.width).toBe(1000);
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 1600, 800)); vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
new DOMRect(0, 0, 1600, 800),
);
act(() => { act(() => {
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
}); });
@@ -528,14 +643,182 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
{ i: 'fav', x: 0, y: 0, w: 6, h: 10 }, { i: 'fav', x: 0, y: 0, w: 6, h: 10 },
{ i: 'below', x: 0, y: 10, w: 6, h: 8 }, { i: 'below', x: 0, y: 10, w: 6, h: 8 },
]; ];
const context = { layout } as unknown as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[4]; const context = { layout } as unknown as Parameters<
NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>
>[4];
const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0]; const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0];
it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => { it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({ w: 2, h: 10 }); expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({
w: 2,
h: 10,
});
}); });
it('laesst eine freie Groesse unveraendert', () => { it('laesst eine freie Groesse unveraendert', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 }); expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 });
}); });
}); });
describe('Leinwand: Dashboard wie ein Bild mitskalieren', () => {
const ONE = {
layouts: { lg: [{ i: 'inst-1', x: 0, y: 0, w: 8, h: 4 }], md: [], sm: [], xs: [], xxs: [] },
widgets: [{ id: 'inst-1', widgetType: 'clock', config: {} }],
};
// jsdom: innerHeight 768, getBoundingClientRect -> 0 -> verfuegbare Hoehe 748.
const JSDOM_AVAILABLE_H = 768 - DASHBOARD_BOTTOM_GAP_PX;
function setInnerHeight(value: number) {
Object.defineProperty(window, 'innerHeight', { value, configurable: true, writable: true });
}
afterEach(() => setInnerHeight(768));
async function renderGrid(props: {
canvas?: { w: number; h: number } | null;
onCaptureCanvas?: (area: { w: number; h: number }) => void;
}) {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const view = render(
<DashboardGrid
{...ONE}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
{...props}
/>,
);
const outer = view.container.firstElementChild as HTMLElement;
const inner = outer.firstElementChild as HTMLElement;
return { outer, inner };
}
describe('fitCanvasToContent', () => {
it('Inhalt passt in die Leinwand: Leinwand bleibt (1:1 am Original-PC)', () => {
expect(fitCanvasToContent({ w: 1641, h: 849 }, 700)).toEqual({ w: 1641, h: 849 });
});
it('Inhalt hoeher als die Leinwand: Inhaltshoehe zaehlt, damit nie gescrollt wird', () => {
expect(fitCanvasToContent({ w: 1641, h: 849 }, 940)).toEqual({ w: 1641, h: 940 });
// am Original-PC selbst: 849 / 940 -> alles passt hinein
expect(
computeCanvasScale({ w: 1641, h: 849 }, fitCanvasToContent({ w: 1641, h: 849 }, 940)),
).toBeCloseTo(849 / 940);
});
it('unbrauchbare Hoehe: Leinwand bleibt', () => {
expect(fitCanvasToContent({ w: 1600, h: 800 }, Number.NaN)).toEqual({ w: 1600, h: 800 });
});
});
describe('computeCanvasScale', () => {
it('breitenbegrenzt: freier Platz unten', () => {
expect(computeCanvasScale({ w: 1280, h: 900 }, { w: 1600, h: 800 })).toBe(0.8);
});
it('hoehenbegrenzt: freier Platz rechts', () => {
expect(computeCanvasScale({ w: 1920, h: 600 }, { w: 1600, h: 800 })).toBe(0.75);
});
it('Vergroesserung bei groesserem Bildschirm (s > 1)', () => {
expect(computeCanvasScale({ w: 2400, h: 1300 }, { w: 1600, h: 800 })).toBe(1.5);
});
it('gleiche Flaeche -> 1', () => {
expect(computeCanvasScale({ w: 1600, h: 800 }, { w: 1600, h: 800 })).toBe(1);
});
it('unbrauchbare Hoehe begrenzt nicht, dann zaehlt nur die Breite', () => {
expect(computeCanvasScale({ w: 800, h: 0 }, { w: 1600, h: 800 })).toBe(0.5);
expect(computeCanvasScale({ w: 800, h: Number.NaN }, { w: 1600, h: 800 })).toBe(0.5);
});
});
describe('scaledPositionStrategy (Ziehen/Groesse aendern unter transform)', () => {
it('s = 1 ist die Standardstrategie', () => {
expect(scaledPositionStrategy(1)).toBe(defaultPositionStrategy);
});
it('s != 1: transform-Strategie mit scale, OHNE calcDragPosition (RGL rechnet dann relativ zum Raster)', () => {
const strategy = scaledPositionStrategy(0.5);
expect(strategy.type).toBe('transform');
expect(strategy.scale).toBe(0.5);
expect(strategy.calcDragPosition).toBeUndefined();
expect(strategy.calcStyle({ left: 10, top: 20, width: 30, height: 40 })).toEqual(
defaultPositionStrategy.calcStyle({ left: 10, top: 20, width: 30, height: 40 }),
);
});
it('Grund: createScaledStrategy rechnet den Ziehstart in Fensterkoordinaten (ohne Raster-Versatz)', () => {
// Kachel sichtbar bei clientX 300 (Raster beginnt bei 250 neben der
// Seitenleiste, Kachel also 50 px im Raster), Maus 10 px in der Kachel.
const pos = createScaledStrategy(0.5).calcDragPosition?.(310, 110, 10, 10);
// Richtig waere (300 - 250) / 0.5 = 100; geliefert wird 300 / 0.5 = 600.
expect(pos?.left).toBe(600);
});
});
it('skalierter Modus: virtuelle Breite canvas.w, transform scale(s) links oben, sichtbare Hoehe = Rasterhoehe x s', async () => {
stubResizeObserver({ width: 1000, height: 800 });
const { outer, inner } = await renderGrid({ canvas: { w: 2000, h: 800 } });
// s = min(1000 / 2000, 748 / 800) = 0.5
expect(captured.props?.width).toBe(2000);
expect(inner.style.width).toBe('2000px');
expect(inner.style.transform).toBe('scale(0.5)');
expect(inner.style.transformOrigin).toBe('top left');
expect(outer.style.height).toBe('400px');
expect(outer.style.overflow).toBe('hidden');
const strategy = captured.props?.positionStrategy as { scale: number };
expect(strategy.scale).toBe(0.5);
});
it('skalierter Modus hoehenbegrenzt und vergroessernd', async () => {
stubResizeObserver({ width: 1000, height: 800 });
setInnerHeight(420); // verfuegbar 400
const first = await renderGrid({ canvas: { w: 1000, h: 800 } });
expect(first.inner.style.transform).toBe('scale(0.5)');
cleanup();
setInnerHeight(768);
// Rasterinhalt niedriger als die Leinwand (300 < 374) — sonst zaehlte die
// Inhaltshoehe (fitCanvasToContent).
stubResizeObserver({ width: 1000, height: 300 });
const second = await renderGrid({ canvas: { w: 800, h: 374 } });
// min(1000 / 800, 748 / 374) = 1.25
expect(second.inner.style.transform).toBe('scale(1.25)');
expect(captured.props?.width).toBe(800);
});
it('Inhalt hoeher als die Leinwand: eingepasst, damit nie gescrollt wird', async () => {
stubResizeObserver({ width: 1000, height: 1600 });
setInnerHeight(820); // verfuegbar 800
const { inner } = await renderGrid({ canvas: { w: 1000, h: 800 } });
// min(1000 / 1000, 800 / 1600) = 0.5
expect(inner.style.transform).toBe('scale(0.5)');
});
it('schmal (< 768): wie bisher — Containerbreite, kein transform, keine Erfassung', async () => {
stubResizeObserver({ width: 600, height: 800 });
const onCaptureCanvas = vi.fn();
const { outer, inner } = await renderGrid({ canvas: { w: 1600, h: 800 }, onCaptureCanvas });
expect(captured.props?.width).toBe(600);
expect(inner.style.transform).toBe('');
expect(outer.style.height).toBe('');
expect(captured.props?.positionStrategy).toBe(defaultPositionStrategy);
cleanup();
await renderGrid({ canvas: null, onCaptureCanvas });
expect(onCaptureCanvas).not.toHaveBeenCalled();
});
it('ohne Leinwand am Desktop: unskaliert und die verfuegbare Flaeche wird zum Erfassen gemeldet', async () => {
stubResizeObserver({ width: 1000, height: 800 });
const onCaptureCanvas = vi.fn();
const { inner } = await renderGrid({ canvas: null, onCaptureCanvas });
expect(captured.props?.width).toBe(1000);
expect(inner.style.transform).toBe('');
expect(onCaptureCanvas).toHaveBeenCalledWith({ w: 1000, h: JSDOM_AVAILABLE_H });
});
it('mit Leinwand wird nicht erneut erfasst', async () => {
stubResizeObserver({ width: 1000, height: 800 });
const onCaptureCanvas = vi.fn();
await renderGrid({ canvas: { w: 1000, h: 748 }, onCaptureCanvas });
expect(onCaptureCanvas).not.toHaveBeenCalled();
});
});
@@ -1,13 +1,26 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout'; import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core'; import { noCompactor, Responsive } from 'react-grid-layout';
import {
defaultConstraints,
defaultPositionStrategy,
type LayoutConstraint,
type PositionStrategy,
transformStrategy,
} from 'react-grid-layout/core';
import 'react-grid-layout/css/styles.css'; import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css'; import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { WIDGET_CONSTRAINTS, WIDGET_REGISTRY, type WidgetType } from './widget-registry'; import { CANVAS_MIN_HEIGHT, CANVAS_MIN_WIDTH, type GridCanvas } from '@/lib/grid-layout-migration';
import {
clampWidthToCols,
GRID_COLS,
WIDGET_CONSTRAINTS,
WIDGET_REGISTRY,
type WidgetType,
} from './widget-registry';
import { WidgetIcon } from './widgets/widget-icon'; import { WidgetIcon } from './widgets/widget-icon';
import { WidgetWrapper } from './widgets/widget-wrapper'; import { WidgetWrapper } from './widgets/widget-wrapper';
@@ -23,7 +36,9 @@ import { WidgetWrapper } from './widgets/widget-wrapper';
// damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration // damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration
// einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position. // einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position.
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }; const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }; // Spaltenzahlen stehen seit quick-260930 in widget-registry.tsx (GRID_COLS),
// weil auch der Store sie fuer neue Kacheln braucht.
const COLS = GRID_COLS;
// quick-260916-dyv: Ziehen zuverlaessig. // quick-260916-dyv: Ziehen zuverlaessig.
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx). // - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
@@ -93,6 +108,76 @@ const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS
const GRID_MARGIN = 12; const GRID_MARGIN = 12;
/**
* Dashboard wie ein Bild mitskalieren (Leinwand).
*
* Wunsch: an PC 1 eingerichtet, an PC 2 mit anderer Aufloesung GENAU gleich
* aussehen — alles (auch die Schrift) gleichmaessig kleiner oder groesser,
* nie scrollen; passt die Bildschirmform nicht, bleibt rechts oder unten
* Platz frei.
*
* Umsetzung: Jeder Reiter hat eine Leinwand `{ w, h }` (gespeichert als
* `__canvas`, siehe grid-layout-migration.ts) — die verfuegbare Flaeche, auf
* der er eingerichtet wurde (erfasst beim ersten Oeffnen am Desktop, danach
* fest). Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus. Auf
* jedem Desktop-Bildschirm zeichnet react-grid-layout das Raster mit der
* VIRTUELLEN Breite `canvas.w` — Breakpoint und Spaltenbreite sind damit
* ueberall identisch — und ein umgebendes Element verkleinert/vergroessert
* das Ergebnis per `transform: scale(s)` mit
* `s = min(Breite / canvas.w, Hoehe / canvas.h)` (computeCanvasScale),
* links oben ausgerichtet (nicht zentriert, bewusst entfernt in bc4c011).
*
* Unter CANVAS_MIN_WIDTH (Handy/schmal) bleibt alles wie zuvor: Breite =
* gemessene Containerbreite, kein transform, keine Erfassung.
*/
/** Abstand unter dem Raster bis zur Fensterkante: main p-3 (12) + Wrapper p-2 (8). */
export const DASHBOARD_BOTTOM_GAP_PX = 20;
/**
* Skalierungsfaktor der Leinwand fuer die verfuegbare Flaeche: das kleinere
* der beiden Verhaeltnisse, damit alles hineinpasst (freier Platz rechts
* ODER unten). Eine unbrauchbare Hoehe (<= 0, nicht endlich) begrenzt nicht —
* dann entscheidet nur die Breite.
*/
export function computeCanvasScale(available: GridCanvas, canvas: GridCanvas): number {
const byWidth = available.w / canvas.w;
const byHeight = available.h / canvas.h;
const heightUsable = Number.isFinite(byHeight) && byHeight > 0;
const s = heightUsable ? Math.min(byWidth, byHeight) : byWidth;
return Number.isFinite(s) && s > 0 ? s : 1;
}
/**
* Leinwand fuer die Einpassung: ist der Inhalt (unskalierte Rasterhoehe)
* hoeher als die Leinwand, gilt die Inhaltshoehe, sonst die Leinwand selbst —
* so bleibt es am Original-PC bei 1:1, solange alles draufpasst.
*/
export function fitCanvasToContent(canvas: GridCanvas, contentHeight: number): GridCanvas {
if (!Number.isFinite(contentHeight) || contentHeight <= canvas.h) return canvas;
return { w: canvas.w, h: contentHeight };
}
/**
* Positionsstrategie fuer ein per `transform: scale(s)` skaliertes Raster.
*
* Bewusst NICHT `createScaledStrategy(s)` aus react-grid-layout 2.2.3: deren
* `calcDragPosition` rechnet `(clientX - offsetX) / scale` =
* `clientRect.left / scale` (chunk-KDANGDDL.mjs:315-320) — eine FENSTER-
* Koordinate ohne Abzug der Raster-Oberkante/-Linkskante. Im Portal liegt das
* Raster rechts neben der Seitenleiste; die Kachel spraenge beim Ziehstart um
* (Seitenleiste + Abstand) / s Pixel nach rechts unten. Ohne
* `calcDragPosition` nimmt react-grid-layout den eigenen Weg
* (chunk-WGL5FSZH.mjs:146-163): `(clientRect.left - parentRect.left) /
* transformScale` — relativ zum Raster und korrekt entskaliert. Die
* Mausbewegungen teilen `DraggableCore` (Prop `scale`) bzw. `Resizable`
* (`deltaX / transformScale`) selbst durch `scale`; beide lesen ihn aus
* `positionStrategy.scale` (chunk-WGL5FSZH.mjs:675, 512, 527).
*/
export function scaledPositionStrategy(scale: number): PositionStrategy {
if (scale === 1) return defaultPositionStrategy;
return { type: 'transform', scale, calcStyle: transformStrategy.calcStyle };
}
interface LayoutItemShape { interface LayoutItemShape {
i: string; i: string;
x: number; x: number;
@@ -113,6 +198,10 @@ interface DashboardGridProps {
onOpenCatalog?: () => void; onOpenCatalog?: () => void;
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */ /** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
onQuickAdd?: (type: WidgetType) => void; onQuickAdd?: (type: WidgetType) => void;
/** Leinwand des aktiven Reiters; null = noch nicht erfasst (dann unskaliert). */
canvas?: GridCanvas | null;
/** Wird mit der verfuegbaren Flaeche gerufen, solange `canvas` fehlt (nur Desktop). */
onCaptureCanvas?: (area: GridCanvas) => void;
} }
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */ /** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
@@ -138,6 +227,17 @@ const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note'];
* chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich — * chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich —
* der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten. * der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten.
* Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert. * Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert.
*
* quick-260930: minW je Breakpoint auf dessen Spaltenzahl begrenzt (Suche
* minW 12 am xxs-Breakpoint mit 4 Spalten), w ebenso. Die BREITE wird nur so
* weit angehoben, wie rechts Platz ist — innerhalb des Rasters und ohne eine
* NEUE Ueberlappung mit einem Nachbarn (freie Platzierung ohne Kompaktierung
* wuerde sie sonst stehen lassen; Anlass: Kalender-minW 8 -> 11, schmal
* gezogene Kalender mit direktem Nachbarn). Bleibt die Kachel dadurch unter
* dem Minimum, ist das harmlos: react-grid-layout zeichnet sie woertlich und
* klemmt erst beim naechsten Groessenziehen; stoesst das an den Nachbarn,
* greift RESIZE_AXIS_FALLBACK (nur die Hoehe aendert sich), bis der Nachbar
* verschoben ist.
*/ */
function applyConstraintMinima( function applyConstraintMinima(
layouts: Record<string, Array<LayoutItemShape>>, layouts: Record<string, Array<LayoutItemShape>>,
@@ -150,18 +250,42 @@ function applyConstraintMinima(
for (const key of Object.keys(layouts)) { for (const key of Object.keys(layouts)) {
const entries = layouts[key]; const entries = layouts[key];
if (!Array.isArray(entries)) continue; if (!Array.isArray(entries)) continue;
result[key] = entries.map((entry) => { const cols = GRID_COLS[key] ?? Number.POSITIVE_INFINITY;
// Zielbreite je Eintrag merken; angehoben wird im zweiten Durchgang,
// wenn alle Hoehen feststehen (die Kollisionspruefung braucht sie).
const targetW = new Map<string, number>();
const next = entries.map((entry) => {
const type = typeById.get(entry.i); const type = typeById.get(entry.i);
const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined; const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined;
if (!constraints) return { ...entry }; if (!constraints) return { ...entry };
const minW = clampWidthToCols(constraints.minW, key);
const w = Math.min(entry.w, cols);
if (w < minW) targetW.set(entry.i, minW);
return { return {
...entry, ...entry,
w: Math.max(entry.w, constraints.minW), w,
h: Math.max(entry.h, constraints.minH), h: Math.max(entry.h, constraints.minH),
minW: constraints.minW, minW,
minH: constraints.minH, minH: constraints.minH,
}; };
}); });
for (const item of next) {
const target = targetW.get(item.i);
if (target === undefined) continue;
const others = next.filter((o) => o !== item);
// Schon bestehende Ueberlappungen zaehlen nicht als Hindernis — sie
// entstehen nicht durch das Anheben.
const blockers = others.filter((o) => !overlaps(item, o));
while (
item.w < target &&
item.x + item.w + 1 <= cols &&
!blockers.some((o) => overlaps({ ...item, w: item.w + 1 }, o))
) {
item.w += 1;
}
}
result[key] = next;
} }
return result; return result;
} }
@@ -181,9 +305,15 @@ export function DashboardGrid({
onRemoveWidget, onRemoveWidget,
onOpenCatalog, onOpenCatalog,
onQuickAdd, onQuickAdd,
canvas = null,
onCaptureCanvas,
}: DashboardGridProps) { }: DashboardGridProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [width, setWidth] = useState(1200); const [width, setWidth] = useState(1200);
// Verfuegbare Hoehe bis zur unteren Fensterkante (Leinwand). 0 = unbekannt.
const [availableHeight, setAvailableHeight] = useState(0);
// Unskalierte Hoehe des Rasters (inneres Element) fuer die sichtbare Hoehe.
const [innerHeight, setInnerHeight] = useState(0);
// quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf), // quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste. // nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
@@ -222,6 +352,16 @@ export function DashboardGrid({
} }
}, []); }, []);
// Leinwand: Hoehe = Fensterhoehe minus Oberkante des Rasters im DOKUMENT
// (rect.top + scrollY, also unabhaengig vom Scrollstand) minus Abstand
// unten. Das aeussere Element ist selbst nicht transformiert, seine
// Oberkante ist also die echte.
const applyHeight = useCallback((node: HTMLElement) => {
const top = node.getBoundingClientRect().top + window.scrollY;
const next = window.innerHeight - top - DASHBOARD_BOTTOM_GAP_PX;
setAvailableHeight(Number.isFinite(next) && next > 0 ? Math.floor(next) : 0);
}, []);
const measureRef = useCallback( const measureRef = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
// Ein eventuell laufender Beobachter zuerst trennen — auch der // Ein eventuell laufender Beobachter zuerst trennen — auch der
@@ -234,32 +374,84 @@ export function DashboardGrid({
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen. // Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
applyWidth(node.getBoundingClientRect().width); applyWidth(node.getBoundingClientRect().width);
applyHeight(node);
const observer = new ResizeObserver((entries) => { const observer = new ResizeObserver((entries) => {
applyWidth(entries[0].contentRect.width); applyWidth(entries[0].contentRect.width);
applyHeight(node);
}); });
observer.observe(node); observer.observe(node);
observerRef.current = observer; observerRef.current = observer;
// Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den // Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
// Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf. // Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
}, },
[applyWidth], [applyWidth, applyHeight],
); );
useEffect(() => { useEffect(() => {
const onResize = () => { const onResize = () => {
if (nodeRef.current) { if (nodeRef.current) {
applyWidth(nodeRef.current.getBoundingClientRect().width); applyWidth(nodeRef.current.getBoundingClientRect().width);
applyHeight(nodeRef.current);
} }
}; };
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize);
}, [applyWidth]); }, [applyWidth, applyHeight]);
// Unskalierte Hoehe des inneren Elements (react-grid-layout setzt die
// Rasterhoehe selbst). `contentRect`/`offsetHeight` sind Layout-Masse und
// vom transform unberuehrt — genau das Gewuenschte.
const innerObserverRef = useRef<ResizeObserver | null>(null);
const innerRef = useCallback((node: HTMLDivElement | null) => {
innerObserverRef.current?.disconnect();
innerObserverRef.current = null;
if (!node) return;
if (node.offsetHeight > 0) setInnerHeight(node.offsetHeight);
const observer = new ResizeObserver((entries) => {
const h = entries[0].contentRect.height;
if (Number.isFinite(h) && h > 0) setInnerHeight(h);
});
observer.observe(node);
innerObserverRef.current = observer;
}, []);
// Desktop = verfuegbare Breite ab CANVAS_MIN_WIDTH; nur dort Leinwand.
const isDesktop = width >= CANVAS_MIN_WIDTH;
const scaled = isDesktop && canvas !== null;
// "Nie scrollen" (Nutzerentscheidung): ist der Inhalt hoeher als die
// Leinwand (schon am Original-PC laenger als der Bildschirm), zaehlt die
// Inhaltshoehe — dann passt auch dort alles hinein, und jeder andere
// Bildschirm zeigt dasselbe Bild. Im Bearbeitungsmodus bleibt die Hoehe vom
// Beginn des Bearbeitens stehen, sonst wuerde das Raster beim Ziehen nach
// unten laufend kleiner und die Kachel liefe dem Mauszeiger davon.
const editHeightRef = useRef<number | null>(null);
if (isEditMode && editHeightRef.current === null) editHeightRef.current = innerHeight;
if (!isEditMode) editHeightRef.current = null;
const contentHeight = isEditMode ? (editHeightRef.current ?? innerHeight) : innerHeight;
const scale = scaled
? computeCanvasScale({ w: width, h: availableHeight }, fitCanvasToContent(canvas, contentHeight))
: 1;
const positionStrategy = useMemo(() => scaledPositionStrategy(scale), [scale]);
// Leinwand erfassen: nur mit eingehaengtem Raster (also mit Kacheln), am
// Desktop und mit brauchbarer Hoehe. Der Store uebernimmt sie einmal und
// ignoriert weitere Aufrufe; danach ist `canvas` gesetzt und dieser
// Effekt tut nichts mehr.
const hasWidgets = widgets.length > 0;
useEffect(() => {
if (!onCaptureCanvas || canvas !== null || !hasWidgets) return;
if (!isDesktop || availableHeight < CANVAS_MIN_HEIGHT) return;
onCaptureCanvas({ w: width, h: availableHeight });
}, [onCaptureCanvas, canvas, hasWidgets, isDesktop, width, availableHeight]);
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS — // quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge // siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
// stabil bleibt. // stabil bleibt.
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]); const effectiveLayouts = useMemo(
() => applyConstraintMinima(layouts, widgets),
[layouts, widgets],
);
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne // Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten // Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
@@ -269,7 +461,18 @@ export function DashboardGrid({
<div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center"> <div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
<div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8"> <div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground"> <span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="9" rx="1.5" /> <rect x="3" y="3" width="7" height="9" rx="1.5" />
<rect x="14" y="3" width="7" height="5" rx="1.5" /> <rect x="14" y="3" width="7" height="5" rx="1.5" />
<rect x="14" y="12" width="7" height="9" rx="1.5" /> <rect x="14" y="12" width="7" height="9" rx="1.5" />
@@ -296,7 +499,19 @@ export function DashboardGrid({
)} )}
{onOpenCatalog && ( {onOpenCatalog && (
<button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5"> <button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
{t('addWidget')} {t('addWidget')}
</button> </button>
)} )}
@@ -305,10 +520,25 @@ export function DashboardGrid({
); );
} }
// Gleiche DOM-Struktur in beiden Modi, damit react-grid-layout beim
// Wechsel (etwa direkt nach dem Erfassen, s = 1) nicht neu einhaengt.
// Aeusseres Element: volle Breite (Messung), im skalierten Modus die
// sichtbare Hoehe = unskalierte Rasterhoehe x s und `overflow: hidden` —
// der transform aendert die Layout-Box nicht, ohne beides bliebe toter
// Scrollraum (s < 1) bzw. ragte die 1:1-Breite seitlich hinaus.
const outerStyle: React.CSSProperties | undefined = scaled
? { overflow: 'hidden', ...(innerHeight > 0 ? { height: innerHeight * scale } : {}) }
: undefined;
const innerStyle: React.CSSProperties | undefined = scaled
? { width: canvas.w, transform: `scale(${scale})`, transformOrigin: 'top left' }
: undefined;
return ( return (
<div ref={measureRef}> <div ref={measureRef} style={outerStyle} data-canvas-scale={scaled ? scale : undefined}>
<div ref={innerRef} style={innerStyle}>
<Responsive <Responsive
width={width} width={scaled ? canvas.w : width}
positionStrategy={positionStrategy}
breakpoints={BREAKPOINTS} breakpoints={BREAKPOINTS}
cols={COLS} cols={COLS}
layouts={effectiveLayouts as ResponsiveLayouts} layouts={effectiveLayouts as ResponsiveLayouts}
@@ -356,5 +586,6 @@ export function DashboardGrid({
})} })}
</Responsive> </Responsive>
</div> </div>
</div>
); );
} }
@@ -0,0 +1,42 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// quick-260930: Stammt die Anordnung von einem neueren Programmstand, ist der
// Stift gesperrt und bittet ums Neuladen.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
const storeState = vi.hoisted(() => ({ layoutFromNewerVersion: false }));
vi.mock('@/lib/stores/dashboard-store', () => ({
useDashboardStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
import { EditModeToggle } from './edit-mode-toggle';
beforeEach(() => {
storeState.layoutFromNewerVersion = false;
});
describe('EditModeToggle (quick-260930)', () => {
it('normal: Stift ist bedienbar', () => {
const onToggle = vi.fn();
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
const button = screen.getByRole('button', { name: 'editShort' });
expect(button).toBeEnabled();
fireEvent.click(button);
expect(onToggle).toHaveBeenCalledTimes(1);
});
it('Anordnung eines neueren Programmstands: gesperrt, Hinweis „neu laden“ als Beschreibung und Tooltip', () => {
storeState.layoutFromNewerVersion = true;
const onToggle = vi.fn();
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
const button = screen.getByRole('button', { name: 'editShort' });
expect(button).toBeDisabled();
expect(button).toHaveAttribute('title', 'reloadRequired');
expect(button).toHaveAccessibleDescription('reloadRequired');
fireEvent.click(button);
expect(onToggle).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useId } from 'react';
import { useDashboardStore } from '@/lib/stores/dashboard-store';
interface EditModeToggleProps { interface EditModeToggleProps {
isEditMode: boolean; isEditMode: boolean;
@@ -13,18 +15,29 @@ interface EditModeToggleProps {
* Sitzt seit quick-260928-vxe rechts in der dunklen App-Leiste: in der * Sitzt seit quick-260928-vxe rechts in der dunklen App-Leiste: in der
* Ansicht nur der Stift (Name per aria-label/Tooltip), im Bearbeitungsmodus * Ansicht nur der Stift (Name per aria-label/Tooltip), im Bearbeitungsmodus
* der gelbe Knopf „Fertig“. * der gelbe Knopf „Fertig“.
*
* quick-260930: Stammt die geladene Anordnung von einem neueren
* Programmstand (`layoutFromNewerVersion` im Store), ist der Stift gesperrt
* und nennt als Beschreibung den Grund („Bitte laden Sie die Seite neu“) —
* Speichern wuerde die Anordnung sonst mit der alten Rastermarke
* ueberschreiben.
*/ */
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) { export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const locked = useDashboardStore((state) => state.layoutFromNewerVersion) && !isEditMode;
const hintId = useId();
return ( return (
<>
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'}`} disabled={locked}
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'} ${locked ? 'cursor-not-allowed opacity-60' : ''}`}
aria-pressed={isEditMode} aria-pressed={isEditMode}
aria-label={isEditMode ? undefined : t('editShort')} aria-label={isEditMode ? undefined : t('editShort')}
title={isEditMode ? t('saveChanges') : t('editMode')} aria-describedby={locked ? hintId : undefined}
title={locked ? t('reloadRequired') : isEditMode ? t('saveChanges') : t('editMode')}
> >
{isEditMode ? ( {isEditMode ? (
// Checkmark icon // Checkmark icon
@@ -63,5 +76,11 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
)} )}
{isEditMode ? t('done') : null} {isEditMode ? t('done') : null}
</button> </button>
{locked && (
<span id={hintId} className="sr-only">
{t('reloadRequired')}
</span>
)}
</>
); );
} }
@@ -1,13 +1,15 @@
import { describe, expect, it, vi } from 'vitest';
import { WIDGET_TYPES } from '@tessera/shared'; import { WIDGET_TYPES } from '@tessera/shared';
import { describe, expect, it, vi } from 'vitest';
import { import {
clampWidthToCols,
GRID_COLS,
registerWidget,
visibleWidgetTypes,
WIDGET_CONSTRAINTS, WIDGET_CONSTRAINTS,
WIDGET_REGISTRY, WIDGET_REGISTRY,
type WidgetDefinition, type WidgetDefinition,
type WidgetProps, type WidgetProps,
type WidgetType, type WidgetType,
registerWidget,
visibleWidgetTypes,
} from './widget-registry'; } from './widget-registry';
/** /**
@@ -38,16 +40,15 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
describe('WIDGET_CONSTRAINTS (DASH-11)', () => { describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
it('contains entries for all expected widget types', () => { it('contains entries for all expected widget types', () => {
for (const type of ALL_WIDGET_TYPES) { for (const type of ALL_WIDGET_TYPES) {
expect( expect(WIDGET_CONSTRAINTS, `WIDGET_CONSTRAINTS must contain key "${type}"`).toHaveProperty(
WIDGET_CONSTRAINTS, type,
`WIDGET_CONSTRAINTS must contain key "${type}"`, );
).toHaveProperty(type);
} }
}); });
it.each(ALL_WIDGET_TYPES)( it.each(
'WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH', ALL_WIDGET_TYPES,
(type) => { )('WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH', (type) => {
const constraints = WIDGET_CONSTRAINTS[type]; const constraints = WIDGET_CONSTRAINTS[type];
expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number'); expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number');
@@ -57,10 +58,13 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1); expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1); expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(constraints.minW); expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(constraints.minH); constraints.minW,
},
); );
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(
constraints.minH,
);
});
it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => { it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator'); expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator');
@@ -71,12 +75,13 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => { it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => {
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle // Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche // Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8 // Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW
// (rund 250 px, gemessene kleinste benutzbare Breite; vorher 6 von 24). // (quick-260930: 11 = rund 260 px an der schmalsten lg-Breite; das
// vorige 8 ergab nur rund 186 px; vorher 6 von 24).
expect(WIDGET_CONSTRAINTS).toEqual({ expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 }, clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 },
search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 }, search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 },
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 }, calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 }, note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 }, calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 },
favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 }, favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 },
@@ -98,6 +103,38 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
}); });
}); });
describe('Kalender-minW (quick-260930)', () => {
it('ergibt an der schmalsten lg-Breite (1200 px, 48 Spalten, 12 px Abstand) mindestens 250 px', () => {
const margin = 12;
const cols = GRID_COLS.lg;
const colWidth = (1200 - 2 * margin - (cols - 1) * margin) / cols;
const px = (w: number) => w * colWidth + (w - 1) * margin;
const { minW } = WIDGET_CONSTRAINTS.calendar;
expect(px(minW)).toBeGreaterThanOrEqual(250);
// und nicht unnoetig breit: eine Spalte weniger laege unter 250 px
expect(px(minW - 1)).toBeLessThan(250);
});
});
describe('clampWidthToCols (quick-260930)', () => {
it('begrenzt Breiten auf die Spaltenzahl des Breakpoints', () => {
expect(clampWidthToCols(24, 'xxs')).toBe(4);
expect(clampWidthToCols(24, 'xs')).toBe(16);
expect(clampWidthToCols(24, 'lg')).toBe(24);
expect(clampWidthToCols(3, 'xxs')).toBe(3);
expect(clampWidthToCols(99, 'unbekannt')).toBe(99);
});
it('kein Minimum und keine Vorgabe ist nach der Begrenzung groesser als die Spaltenzahl', () => {
for (const bp of Object.keys(GRID_COLS)) {
for (const c of Object.values(WIDGET_CONSTRAINTS)) {
expect(clampWidthToCols(c.minW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
expect(clampWidthToCols(c.defaultW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
}
}
});
});
/** /**
* quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau * quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau
* EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die * EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react';
import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared'; import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
import type { ComponentType } from 'react';
/** /**
* Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in * Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in
@@ -57,11 +57,17 @@ export const WIDGET_CONSTRAINTS: Record<
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block. // fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
// Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter // Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
// den Monat nur ein Termin. // den Monat nur ein Termin.
// quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar — minW 8 von // quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar. Ziel sind
// 48 Spalten = rund 250 px am lg-Breakpoint (gemessen: Monatsraster, // rund 250 px (Monatsraster, Ueberschrift und Terminliste noch benutzbar;
// Ueberschrift und Terminliste sind dort noch benutzbar; bei rund 185 px // bei rund 185 px wird die Ueberschrift abgeschnitten). quick-260930: das
// wird die Ueberschrift abgeschnitten). defaultW 16 = unveraenderte Breite. // damalige minW 8 ergab am lg-Breakpoint nur rund 186 px — gerechnet an
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 }, // der schmalsten lg-Breite (1200 px Raster, 48 Spalten, 12 px Abstand und
// Rand): Spalte = (1200 - 2*12 - 47*12) / 48 = 12,75 px, Kachel = w*12,75 +
// (w-1)*12. minW 11 = rund 260 px (10 waeren 235 px), auf breiteren
// Bildschirmen entsprechend mehr. defaultW 16 = unveraenderte Breite.
// Bestehende schmalere Kalender hebt dashboard-grid.tsx beim Anzeigen an,
// soweit rechts Platz ist (applyConstraintMinima).
calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 }, note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
// Phase 8 new widgets (D-01) // Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat // minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
@@ -98,6 +104,24 @@ export const WIDGET_CONSTRAINTS: Record<
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }, reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
}; };
/**
* Spalten je Breakpoint (quick-260929-dmx: 48stel am lg-Breakpoint). Steht
* hier und nicht in dashboard-grid.tsx, weil auch der Store (neue Kacheln)
* die Spaltenzahl braucht und die Registry ohnehin von beiden importiert wird.
*/
export const GRID_COLS: Record<string, number> = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
/**
* Begrenzt eine Breite (minW, defaultW) auf die Spaltenzahl des Breakpoints
* (quick-260930). Sonst waere z. B. die Suche (minW 12, defaultW 24) am
* xxs-Breakpoint mit 4 Spalten breiter als das ganze Raster. Unbekannte
* Breakpoints bleiben unbegrenzt.
*/
export function clampWidthToCols(width: number, breakpoint: string): number {
const cols = GRID_COLS[breakpoint];
return cols === undefined ? width : Math.min(width, cols);
}
/** /**
* Definition for a widget type in the catalog. * Definition for a widget type in the catalog.
*/ */
@@ -37,20 +37,20 @@ vi.mock('@/lib/dashboard-api', () => ({
updateWidgetConfig: vi.fn().mockResolvedValue(undefined), updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
})); }));
import { updateWidgetConfig } from '@/lib/dashboard-api';
import { import {
createFavorite,
deleteFavorite,
FavoriteRequestError, FavoriteRequestError,
fetchFavorites, fetchFavorites,
createFavorite,
updateFavorite,
deleteFavorite,
reorderFavorites,
uploadFavoriteIcon,
removeFavoriteIcon, removeFavoriteIcon,
reorderFavorites,
updateFavorite,
uploadFavoriteIcon,
} from '@/lib/favorites-api'; } from '@/lib/favorites-api';
import { updateWidgetConfig } from '@/lib/dashboard-api';
// Must import AFTER mocks // Must import AFTER mocks
import { FavoritesWidget } from './favorites-widget'; import { FavoritesWidget, LauncherLabel } from './favorites-widget';
// Typed mock helpers // Typed mock helpers
const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>; const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
@@ -106,13 +106,7 @@ describe('FavoritesWidget', () => {
}); });
it('calls fetchFavorites with instanceId on mount and renders link titles', async () => { it('calls fetchFavorites with instanceId on mount and renders link titles', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3) // fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
expect(mockFetch).toHaveBeenCalledWith('fav-1'); expect(mockFetch).toHaveBeenCalledWith('fav-1');
@@ -127,13 +121,7 @@ describe('FavoritesWidget', () => {
it('shows empty message when fetchFavorites resolves to empty array', async () => { it('shows empty message when fetchFavorites resolves to empty array', async () => {
mockFetch.mockResolvedValue([]); mockFetch.mockResolvedValue([]);
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('favorites.empty')).toBeInTheDocument(); expect(screen.getByText('favorites.empty')).toBeInTheDocument();
@@ -143,13 +131,7 @@ describe('FavoritesWidget', () => {
it('add: submit form in edit mode calls createFavorite with widgetId, title, url', async () => { it('add: submit form in edit mode calls createFavorite with widgetId, title, url', async () => {
mockFetch.mockResolvedValue([]); mockFetch.mockResolvedValue([]);
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('favorites.empty')).toBeInTheDocument(); expect(screen.getByText('favorites.empty')).toBeInTheDocument();
@@ -189,13 +171,7 @@ describe('FavoritesWidget', () => {
}); });
it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => { it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -239,13 +215,7 @@ describe('FavoritesWidget', () => {
}); });
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => { it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -271,13 +241,7 @@ describe('FavoritesWidget', () => {
}); });
it('view toggle: switching to grid in edit mode renders grid container', async () => { it('view toggle: switching to grid in edit mode renders grid container', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{ viewMode: 'list' }} isEditMode={true} />);
<FavoritesWidget
instanceId="fav-1"
config={{ viewMode: 'list' }}
isEditMode={true}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -299,13 +263,7 @@ describe('FavoritesWidget', () => {
}); });
it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => { it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Notion')).toBeInTheDocument(); expect(screen.getByText('Notion')).toBeInTheDocument();
@@ -318,24 +276,16 @@ describe('FavoritesWidget', () => {
// quick-260916-iex: optionaler Titel // quick-260916-iex: optionaler Titel
describe('optionaler Titel (quick-260916-iex)', () => { describe('optionaler Titel (quick-260916-iex)', () => {
it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => { it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => {
const r1 = render( const r1 = render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
}); });
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect( expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
).not.toBeInTheDocument();
r1.unmount(); r1.unmount();
const r2 = render( const r2 = render(
<FavoritesWidget <FavoritesWidget instanceId="fav-1" config={{ title: ' ' }} isEditMode={false} />,
instanceId="fav-1"
config={{ title: ' ' }}
isEditMode={false}
/>,
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -343,13 +293,7 @@ describe('FavoritesWidget', () => {
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.queryByRole('heading')).not.toBeInTheDocument();
r2.unmount(); r2.unmount();
render( render(<FavoritesWidget instanceId="fav-1" config={{ title: 42 }} isEditMode={false} />);
<FavoritesWidget
instanceId="fav-1"
config={{ title: 42 }}
isEditMode={false}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
}); });
@@ -358,11 +302,7 @@ describe('FavoritesWidget', () => {
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => { it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
render( render(
<FavoritesWidget <FavoritesWidget instanceId="fav-1" config={{ title: 'Werkzeuge' }} isEditMode={false} />,
instanceId="fav-1"
config={{ title: 'Werkzeuge' }}
isEditMode={false}
/>,
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -371,15 +311,11 @@ describe('FavoritesWidget', () => {
expect(heading.tagName).toBe('H2'); expect(heading.tagName).toBe('H2');
expect(heading.className).toContain('text-sm'); expect(heading.className).toContain('text-sm');
expect(heading.className).toContain('font-semibold'); expect(heading.className).toContain('font-semibold');
expect( expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
).not.toBeInTheDocument();
}); });
it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => { it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
}); });
@@ -391,25 +327,17 @@ describe('FavoritesWidget', () => {
expect(titleField.className).toContain('widgetNoDrag'); expect(titleField.className).toContain('widgetNoDrag');
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect( expect(screen.getByRole('button', { name: /favorites\.listView/i })).toBeInTheDocument();
screen.getByRole('button', { name: /favorites\.listView/i }), expect(screen.getByRole('button', { name: /favorites\.gridView/i })).toBeInTheDocument();
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /favorites\.gridView/i }),
).toBeInTheDocument();
}); });
it('entprellt das Speichern des Titels um 1500 ms', async () => { it('entprellt das Speichern des Titels um 1500 ms', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try { try {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
await act(async () => {}); await act(async () => {});
const titleField = screen.getByPlaceholderText( const titleField = screen.getByPlaceholderText('favorites.titlePlaceholder');
'favorites.titlePlaceholder',
);
for (const value of ['W', 'We', 'Werkzeuge']) { for (const value of ['W', 'We', 'Werkzeuge']) {
await act(async () => { await act(async () => {
@@ -444,9 +372,7 @@ describe('FavoritesWidget', () => {
// Sortierpfeile im Bearbeitungsmodus. // Sortierpfeile im Bearbeitungsmodus.
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => { describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => { it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Notion')).toBeInTheDocument(); expect(screen.getByText('Notion')).toBeInTheDocument();
@@ -460,9 +386,7 @@ describe('FavoritesWidget', () => {
}); });
it('Kette Proxy -> direkt -> Buchstabe', async () => { it('Kette Proxy -> direkt -> Buchstabe', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -490,14 +414,88 @@ describe('FavoritesWidget', () => {
expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G'); expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G');
}); });
it('kein Direktbild bei Nicht-http-URL', async () => { it('ausdrueckliche Symbol-Adresse (260929-lh3): Proxy -> Browser laedt iconUrl direkt (no-referrer) -> Origin-Favicon -> Buchstabe', async () => {
mockFetch.mockResolvedValue([ mockFetch.mockResolvedValue([
{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 }, {
id: 'fav-id-9',
widgetId: 'fav-1',
title: 'Docuvita',
url: 'https://docuvita.ctl.local/server/services/web/',
iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
position: 0,
},
]); ]);
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
await waitFor(() => {
expect(screen.getByText('Docuvita')).toBeInTheDocument();
});
act(() => {
fireEvent.error(screen.getByTestId('icon-proxy-fav-id-9'));
});
const direct1 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement;
expect(direct1.src).toBe(
'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
); );
expect(direct1.getAttribute('referrerpolicy')).toBe('no-referrer');
act(() => {
fireEvent.error(direct1);
});
const direct2 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement;
expect(direct2.src).toBe('https://docuvita.ctl.local/favicon.ico');
act(() => {
fireEvent.error(direct2);
});
expect(screen.queryByTestId('icon-direct-fav-id-9')).not.toBeInTheDocument();
expect(screen.getByTestId('letter-fallback-fav-id-9')).toHaveTextContent('D');
});
it('iconUrl mit Nicht-http-Schema wird NIE direkt geladen (javascript:/data:)', async () => {
mockFetch.mockResolvedValue([
{
id: 'fav-id-8',
widgetId: 'fav-1',
title: 'Boese',
url: 'https://boese.example',
iconUrl: 'javascript:alert(1)',
position: 0,
},
]);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('Boese')).toBeInTheDocument();
});
act(() => {
fireEvent.error(screen.getByTestId('icon-proxy-fav-id-8'));
});
const direct = screen.getByTestId('icon-direct-fav-id-8') as HTMLImageElement;
expect(direct.src).toBe('https://boese.example/favicon.ico');
});
it('kein Direktbild bei Nicht-http-URL', async () => {
mockFetch.mockResolvedValue([
{
id: 'fav-id-3',
widgetId: 'fav-1',
title: 'Ablage',
url: 'ftp://files.example',
iconUrl: null,
position: 0,
},
]);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Ablage')).toBeInTheDocument(); expect(screen.getByText('Ablage')).toBeInTheDocument();
@@ -514,9 +512,7 @@ describe('FavoritesWidget', () => {
{ ...BASE_FAVORITES[0], position: 1 }, { ...BASE_FAVORITES[0], position: 1 },
]); ]);
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -549,9 +545,7 @@ describe('FavoritesWidget', () => {
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => { it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
mockReorder.mockRejectedValue(new Error('boom')); mockReorder.mockRejectedValue(new Error('boom'));
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -587,9 +581,7 @@ describe('FavoritesWidget', () => {
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
rerender( rerender(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
}); });
@@ -692,7 +684,7 @@ describe('FavoritesWidget', () => {
}); });
}); });
it('Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon(\'fav-id-1\', Datei); Formular schliesst', async () => { it("Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon('fav-id-1', Datei); Formular schliesst", async () => {
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 }); mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 });
mockUploadIcon.mockResolvedValue({ mockUploadIcon.mockResolvedValue({
...BASE_FAVORITES[0], ...BASE_FAVORITES[0],
@@ -720,7 +712,10 @@ describe('FavoritesWidget', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' })); expect(mockUpdate).toHaveBeenCalledWith(
'fav-id-1',
expect.objectContaining({ title: 'GitHub' }),
);
}); });
await waitFor(() => { await waitFor(() => {
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file); expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
@@ -730,8 +725,8 @@ describe('FavoritesWidget', () => {
}); });
}); });
it('updateFavorite wirft FavoriteRequestError(iconUrlUnreachable) -> Meldung im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => { it('updateFavorite wirft (z. B. 400 ungueltige Adresse) -> allgemeine Meldung favorites.error im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => {
mockUpdate.mockRejectedValue(new FavoriteRequestError('iconUrlUnreachable')); mockUpdate.mockRejectedValue(new Error('Failed to update favorite'));
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />); render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
@@ -753,7 +748,7 @@ describe('FavoritesWidget', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconUrlUnreachable'); expect(screen.getByRole('alert')).toHaveTextContent('favorites.error');
}); });
expect(mockUploadIcon).not.toHaveBeenCalled(); expect(mockUploadIcon).not.toHaveBeenCalled();
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
@@ -788,12 +783,16 @@ describe('FavoritesWidget', () => {
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
}); });
it('Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon(\'fav-id-1\'), danach verschwindet der Knopf, Formular bleibt offen', async () => { it("Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon('fav-id-1'), danach verschwindet der Knopf, Formular bleibt offen", async () => {
mockFetch.mockResolvedValue([ mockFetch.mockResolvedValue([
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 }, { ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 },
BASE_FAVORITES[1], BASE_FAVORITES[1],
]); ]);
mockRemoveIcon.mockResolvedValue({ ...BASE_FAVORITES[0], uploadedIconMime: null, iconVersion: 2 }); mockRemoveIcon.mockResolvedValue({
...BASE_FAVORITES[0],
uploadedIconMime: null,
iconVersion: 2,
});
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />); render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
@@ -812,7 +811,9 @@ describe('FavoritesWidget', () => {
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1'); expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument(); expect(
screen.queryByRole('button', { name: 'favorites.iconRemoveButton' }),
).not.toBeInTheDocument();
}); });
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
}); });
@@ -826,7 +827,9 @@ describe('FavoritesWidget', () => {
openEditForGitHub(); openEditForGitHub();
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument(); expect(
screen.queryByRole('button', { name: 'favorites.iconRemoveButton' }),
).not.toBeInTheDocument();
}); });
it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => { it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => {
@@ -866,7 +869,11 @@ describe('FavoritesWidget', () => {
await waitFor(() => { await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith( expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ widgetId: 'fav-1', title: 'New Link', url: 'https://example.com' }), expect.objectContaining({
widgetId: 'fav-1',
title: 'New Link',
url: 'https://example.com',
}),
); );
}); });
await waitFor(() => { await waitFor(() => {
@@ -878,3 +885,31 @@ describe('FavoritesWidget', () => {
}); });
}); });
}); });
describe('LauncherLabel — Beschriftung in der Kachelansicht', () => {
afterEach(() => {
vi.restoreAllMocks();
});
function mockWidths(scroll: number, client: number) {
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(scroll);
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(client);
}
it('bleibt in normaler Groesse, wenn der Titel in eine Zeile passt', () => {
mockWidths(40, 60);
render(<LauncherLabel text="Wiki" />);
const label = screen.getByText('Wiki');
expect(label).not.toHaveAttribute('data-small');
expect(label.className).toContain('text-xs');
});
it('wird kleiner und zweizeilig, wenn der Titel nicht in eine Zeile passt', () => {
mockWidths(120, 60);
render(<LauncherLabel text="Reisekostenabrechnung" />);
const label = screen.getByText('Reisekostenabrechnung');
expect(label).toHaveAttribute('data-small', 'true');
expect(label.className).toContain('text-[10px]');
expect(label.className).toContain('line-clamp-2');
});
});
@@ -1,21 +1,21 @@
'use client'; 'use client';
import { WidgetIcon } from './widget-icon';
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { type FormEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { WidgetProps } from '@/components/dashboard/widget-registry';
import { updateWidgetConfig } from '@/lib/dashboard-api'; import { updateWidgetConfig } from '@/lib/dashboard-api';
import { import {
FavoriteRequestError,
createFavorite, createFavorite,
deleteFavorite, deleteFavorite,
type FavoriteLink,
FavoriteRequestError,
fetchFavorites, fetchFavorites,
removeFavoriteIcon, removeFavoriteIcon,
reorderFavorites, reorderFavorites,
updateFavorite, updateFavorite,
uploadFavoriteIcon, uploadFavoriteIcon,
type FavoriteLink,
} from '@/lib/favorites-api'; } from '@/lib/favorites-api';
import type { WidgetProps } from '@/components/dashboard/widget-registry'; import { WidgetIcon } from './widget-icon';
// quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget // quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget
const TITLE_DEBOUNCE_MS = 1500; const TITLE_DEBOUNCE_MS = 1500;
@@ -23,7 +23,7 @@ const TITLE_DEBOUNCE_MS = 1500;
/** /**
* Bildet einen Fehler aus dem Favoriten-Klienten auf einen Uebersetzungs- * Bildet einen Fehler aus dem Favoriten-Klienten auf einen Uebersetzungs-
* schluessel ab (260923-lrr): `FavoriteRequestError` traegt den passenden * schluessel ab (260923-lrr): `FavoriteRequestError` traegt den passenden
* Grund (`iconUrlUnreachable`/`iconTooLarge`/`iconInvalidType`/ * Grund (`iconTooLarge`/`iconInvalidType`/
* `iconUploadFailed`) bereits als `reason`, jeder andere Fehler faellt auf * `iconUploadFailed`) bereits als `reason`, jeder andere Fehler faellt auf
* die bisherige allgemeine Meldung zurueck. * die bisherige allgemeine Meldung zurueck.
*/ */
@@ -51,11 +51,7 @@ function favoriteErrorKey(err: unknown): string {
* - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch, * - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch,
* persistiert ueber PUT /favorites/order * persistiert ueber PUT /favorites/order
*/ */
export function FavoritesWidget({ export function FavoritesWidget({ instanceId, config, isEditMode }: WidgetProps) {
instanceId,
config,
isEditMode,
}: WidgetProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [favorites, setFavorites] = useState<FavoriteLink[]>([]); const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
@@ -68,12 +64,8 @@ export function FavoritesWidget({
); );
// Optionaler Titel (quick-260916-iex) // Optionaler Titel (quick-260916-iex)
const [title, setTitle] = useState<string>( const [title, setTitle] = useState<string>(typeof config.title === 'string' ? config.title : '');
typeof config.title === 'string' ? config.title : '', const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
);
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
useEffect(() => { useEffect(() => {
return () => clearTimeout(titleTimerRef.current); return () => clearTimeout(titleTimerRef.current);
@@ -335,15 +327,15 @@ export function FavoritesWidget({
/> />
) : ( ) : (
hasTitle && ( hasTitle && (
<h2 className="truncate text-sm font-semibold text-foreground"> <h2 className="truncate text-sm font-semibold text-foreground">{title.trim()}</h2>
{title.trim()}
</h2>
) )
)} )}
</div> </div>
)} )}
<div className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}> <div
className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}
>
{/* View mode toggle (edit mode only) */} {/* View mode toggle (edit mode only) */}
{isEditMode && ( {isEditMode && (
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten") // role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
@@ -382,23 +374,20 @@ export function FavoritesWidget({
)} )}
{/* Status messages */} {/* Status messages */}
{loading && ( {loading && <p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>}
<p className="text-xs text-muted-foreground">{t('favorites.loading')}</p> {error && <p className="text-xs text-destructive">{error}</p>}
)}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{/* Favorites list / grid */} {/* Favorites list / grid */}
{!loading && sortedFavorites.length === 0 && ( {!loading && sortedFavorites.length === 0 && (
<p className="text-xs text-muted-foreground">{t('favorites.empty')}</p> <p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
)} )}
{!loading && sortedFavorites.length > 0 && ( {!loading &&
viewMode === 'grid' ? ( sortedFavorites.length > 0 &&
(viewMode === 'grid' ? (
<div <div
data-testid="favorites-grid" data-testid="favorites-grid"
className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-1" className="grid grid-cols-[repeat(auto-fill,minmax(52px,1fr))] gap-1"
> >
{sortedFavorites.map((fav, index) => ( {sortedFavorites.map((fav, index) => (
<FavoriteTile <FavoriteTile
@@ -430,10 +419,7 @@ export function FavoritesWidget({
))} ))}
</div> </div>
) : ( ) : (
<div <div data-testid="favorites-list" className="flex flex-col gap-0.5">
data-testid="favorites-list"
className="flex flex-col gap-0.5"
>
{sortedFavorites.map((fav, index) => ( {sortedFavorites.map((fav, index) => (
<FavoriteTile <FavoriteTile
key={fav.id} key={fav.id}
@@ -462,8 +448,7 @@ export function FavoritesWidget({
/> />
))} ))}
</div> </div>
) ))}
)}
{/* Add form (edit mode only, D-04) */} {/* Add form (edit mode only, D-04) */}
{isEditMode && ( {isEditMode && (
@@ -527,13 +512,37 @@ function getDirectFaviconSrc(url: string): string | null {
} }
/** /**
* FavoriteIcon — dreistufiger Symbol-Ersatzweg (260917-jdd): * Direkt-ladbare Adressen fuer Stufe 2 des Ersatzwegs (260929-lh3): erst die
* gespeicherte `iconUrl`, dann das Origin-Favicon der Favoriten-URL; nur
* http:/https: (kein javascript:/data:, T-JDD-04), ohne Doppelte.
*/
function getDirectCandidates(iconUrl: string | null, url: string): string[] {
const candidates: string[] = [];
if (iconUrl) {
try {
const u = new URL(iconUrl);
if (u.protocol === 'http:' || u.protocol === 'https:') candidates.push(u.toString());
} catch {
// ungueltige Adresse: uebersprungen
}
}
const origin = getDirectFaviconSrc(url);
if (origin && !candidates.includes(origin)) candidates.push(origin);
return candidates;
}
/**
* FavoriteIcon — Symbol-Ersatzweg (260917-jdd, erweitert 260929-lh3):
* *
* 1. `proxy` — Server-Proxy (GET /favorites/:id/icon), der seit diesem Plan * 1. `proxy` — Server-Proxy (GET /favorites/:id/icon), der seit diesem Plan
* auch bei Zertifikatsfehlern des Zielhosts liefert (undici-Dispatcher). * auch bei Zertifikatsfehlern des Zielhosts liefert (undici-Dispatcher).
* 2. `direct` — Direktbild aus dem Browser des Nutzers * 2. `direct` — Direktbilder aus dem Browser des Nutzers
* (`referrerPolicy="no-referrer"`, Origin nur aus http/https); erreicht * (`referrerPolicy="no-referrer"`, nur http/https), nacheinander:
* interne Hosts, die der SSRF-Schutz des Servers absichtlich ablehnt. * zuerst die gespeicherte `iconUrl` (ausdrueckliche Symbol-Adresse oder
* erkannter Verweis — Server wie docuvita geben dem Server 404/HTML, dem
* Browser aber das Bild), dann `{origin}/favicon.ico` (entfaellt, wenn
* identisch). Erreicht auch interne Hosts, die der SSRF-Schutz des
* Servers absichtlich ablehnt.
* 3. `none` — der Buchstaben-Platzhalter liegt IMMER darunter. * 3. `none` — der Buchstaben-Platzhalter liegt IMMER darunter.
* *
* Bewusst KEIN Drittanbieter-Favicon-Dienst: der wuerde Hostnamen nach * Bewusst KEIN Drittanbieter-Favicon-Dienst: der wuerde Hostnamen nach
@@ -575,10 +584,12 @@ function FavoriteIcon({
const proxySrc = hasServerIcon const proxySrc = hasServerIcon
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}` ? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
: null; : null;
const directSrc = getDirectFaviconSrc(fav.url); // 260929-lh3: Direkt-Kandidaten in Reihenfolge — gespeicherte Adresse zuerst,
const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>( // dann das Origin-Favicon; nur http/https, ohne Doppelte.
proxySrc ? 'proxy' : 'direct', const directSrcs = getDirectCandidates(fav.iconUrl, fav.url);
); // 'proxy' | Index in directSrcs | Ende der Kette (Buchstabe).
const [stage, setStage] = useState<'proxy' | number>(proxySrc ? 'proxy' : 0);
const directSrc = typeof stage === 'number' ? (directSrcs[stage] ?? null) : null;
return ( return (
<div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}> <div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}>
@@ -598,10 +609,10 @@ function FavoriteIcon({
height={px} height={px}
loading="lazy" loading="lazy"
className={`relative rounded-sm ${img}`} className={`relative rounded-sm ${img}`}
onError={() => setStage('direct')} onError={() => setStage(0)}
/> />
)} )}
{stage === 'direct' && directSrc && ( {directSrc && (
<img <img
data-testid={`icon-direct-${fav.id}`} data-testid={`icon-direct-${fav.id}`}
src={directSrc} src={directSrc}
@@ -612,7 +623,7 @@ function FavoriteIcon({
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
className={`relative rounded-sm ${img}`} className={`relative rounded-sm ${img}`}
onError={() => setStage('none')} onError={() => setStage((prev) => (typeof prev === 'number' ? prev + 1 : 0))}
/> />
)} )}
</div> </div>
@@ -651,6 +662,60 @@ type FavoriteTileProps = {
launcher?: boolean; launcher?: boolean;
}; };
/**
* Beschriftung einer Favoriten-Kachel in der Kachelansicht. Passt der Titel
* in normaler Groesse (12 px) nicht in eine Zeile, wuerde er an die
* Nachbarkachel stossen bzw. mitten im Wort umbrechen — dann wird die Schrift
* kleiner (10 px) und darf zweizeilig umbrechen (Silbentrennung, danach "…").
* Gemessen wird immer in normaler Groesse und einzeilig, damit das Ergebnis
* nicht von der eigenen Umschaltung abhaengt (keine Rueckkopplung). Der
* ResizeObserver misst neu, wenn die Kachel breiter oder schmaler wird;
* scrollWidth/clientWidth sind Layoutmasse und von der Dashboard-Skalierung
* (transform) unberuehrt.
*/
export function LauncherLabel({ text }: { text: string }) {
const ref = useRef<HTMLSpanElement>(null);
const [small, setSmall] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: neuer Titel = neu messen, der Wert selbst steht im DOM
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const prev = {
whiteSpace: el.style.whiteSpace,
fontSize: el.style.fontSize,
display: el.style.display,
};
el.style.whiteSpace = 'nowrap';
el.style.fontSize = '12px';
el.style.display = 'block';
const overflow = el.scrollWidth > el.clientWidth + 1;
el.style.whiteSpace = prev.whiteSpace;
el.style.fontSize = prev.fontSize;
el.style.display = prev.display;
setSmall(overflow);
};
measure();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [text]);
return (
<span
ref={ref}
data-small={small ? 'true' : undefined}
className={`line-clamp-2 w-full break-words hyphens-auto ${
small ? 'text-[10px] leading-3' : 'text-xs leading-4'
}`}
>
{text}
</span>
);
}
function FavoriteTile({ function FavoriteTile({
fav, fav,
isEditMode, isEditMode,
@@ -702,9 +767,11 @@ function FavoriteTile({
large={launcher} large={launcher}
/> />
<span className={launcher ? 'line-clamp-2 w-full break-words text-xs leading-4' : 'truncate text-sm'}> {launcher ? (
{fav.title} <LauncherLabel text={fav.title} />
</span> ) : (
<span className="truncate text-sm">{fav.title}</span>
)}
</a> </a>
{/* Action buttons (edit mode only, D-04) */} {/* Action buttons (edit mode only, D-04) */}
@@ -6,10 +6,10 @@ import { createPortal } from 'react-dom';
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify'; import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time'; import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
import { import {
createReminder,
type Reminder, type Reminder,
type ReminderEmailStatus, type ReminderEmailStatus,
ReminderRequestError, ReminderRequestError,
createReminder,
updateReminder, updateReminder,
} from '@/lib/reminders-api'; } from '@/lib/reminders-api';
@@ -65,14 +65,23 @@ export function ReminderFormModal({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const titleRef = useRef<HTMLInputElement>(null); const titleRef = useRef<HTMLInputElement>(null);
// Fokus nur EINMAL beim Oeffnen (quick-261001-g68): `onClose` kommt aus der
// Kachel als neue Funktion bei jedem Neuzeichnen — die Kachel zeichnet alle
// 10 s neu (NOW_TICK_MS). Hing der Fokus mit am `onClose`-Effekt, sprang der
// Cursor beim Schreiben der Beschreibung immer wieder in den Titel.
useEffect(() => { useEffect(() => {
titleRef.current?.focus(); titleRef.current?.focus();
}, []);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') onCloseRef.current();
}; };
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey);
}, [onClose]); }, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -85,9 +94,15 @@ export function ReminderFormModal({
setError(t('reminder.titleRequired')); setError(t('reminder.titleRequired'));
return; return;
} }
const dueAt = localInputsToIso(date, time); // Beim Bearbeiten nur senden, wenn Datum oder Uhrzeit wirklich geaendert
// wurden: das Formular kennt keine Sekunden, ein unveraendertes Feld wuerde
// die Faelligkeit sonst um bis zu 59 s verschieben — und kurz vor der
// Faelligkeit faelschlich „in der Vergangenheit“ melden. Ist sie inzwischen
// faellig, antwortet der Server ohnehin mit 409 (`alreadyDue`).
const dueChanged = !reminder || date !== initial.date || time !== initial.time;
const dueAt = dueChanged ? localInputsToIso(date, time) : undefined;
// Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen. // Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen.
if (dueAt === null || new Date(dueAt).getTime() <= Date.now()) { if (dueChanged && (!dueAt || new Date(dueAt).getTime() <= Date.now())) {
setError(t('reminder.pastError')); setError(t('reminder.pastError'));
return; return;
} }
@@ -98,16 +113,15 @@ export function ReminderFormModal({
// ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400 // ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400
// ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen. // ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen.
const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled; const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled;
const input = { const common = {
title: trimmedTitle, title: trimmedTitle,
description: description.trim(), description: description.trim(),
dueAt,
...(emailChanged ? { emailEnabled } : {}), ...(emailChanged ? { emailEnabled } : {}),
}; };
if (reminder) { if (reminder) {
await updateReminder(reminder.id, input); await updateReminder(reminder.id, { ...common, ...(dueAt ? { dueAt } : {}) });
} else { } else if (dueAt) {
await createReminder(input); await createReminder({ ...common, dueAt });
} }
onSaved(); onSaved();
onClose(); onClose();
@@ -21,11 +21,11 @@ vi.mock('@/lib/reminders-api', async () => {
}); });
import { import {
ReminderRequestError,
createReminder, createReminder,
deleteReminder, deleteReminder,
getReminderEmailStatus, getReminderEmailStatus,
listReminders, listReminders,
ReminderRequestError,
snoozeReminder, snoozeReminder,
updateReminder, updateReminder,
} from '@/lib/reminders-api'; } from '@/lib/reminders-api';
@@ -79,7 +79,9 @@ async function openForm() {
function fillAndSubmit(title: string) { function fillAndSubmit(title: string) {
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } }); fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2099-03-04' } }); fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
target: { value: '2099-03-04' },
});
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } }); fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } });
fireEvent.click(screen.getByText('reminder.save')); fireEvent.click(screen.getByText('reminder.save'));
} }
@@ -146,7 +148,9 @@ describe('ReminderWidget', () => {
render(<ReminderWidget {...props} />); render(<ReminderWidget {...props} />);
await openForm(); await openForm();
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } }); fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2001-01-01' } }); fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
target: { value: '2001-01-01' },
});
fireEvent.click(screen.getByText('reminder.save')); fireEvent.click(screen.getByText('reminder.save'));
expect(await screen.findByText('reminder.pastError')).toBeTruthy(); expect(await screen.findByText('reminder.pastError')).toBeTruthy();
expect(mockCreate).not.toHaveBeenCalled(); expect(mockCreate).not.toHaveBeenCalled();
@@ -260,6 +264,38 @@ describe('ReminderWidget — faellig und Aktionen (D-03)', () => {
expect(requestPermission).not.toHaveBeenCalled(); expect(requestPermission).not.toHaveBeenCalled();
}); });
it('Bearbeiten ohne Aenderung von Datum/Uhrzeit sendet kein dueAt (keine Sekunden-Verschiebung)', async () => {
// Faellig in 30 s, mit Sekunden: auf Minuten gekuerzt laege sie in der Vergangenheit.
const due = new Date(Date.now() + 30_000);
due.setSeconds(due.getSeconds(), 500);
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'Neu' } });
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(screen.queryByText('reminder.pastError')).toBeNull();
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('dueAt');
expect(mockUpdate.mock.calls[0][1].title).toBe('Neu');
});
it('Bearbeiten mit geaenderter Uhrzeit sendet das neue dueAt', async () => {
const due = new Date(Date.now() + 2 * 3600_000);
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
const next = new Date(Date.now() + 26 * 3600_000);
const pad = (n: number) => String(n).padStart(2, '0');
const nextDate = `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`;
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: nextDate } });
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '09:15' } });
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(mockUpdate.mock.calls[0][1].dueAt).toBe(new Date(`${nextDate}T09:15`).toISOString());
});
it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => { it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => {
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]); mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
mockSnooze.mockRejectedValue(new ReminderRequestError(409)); mockSnooze.mockRejectedValue(new ReminderRequestError(409));
@@ -313,6 +349,53 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
}); });
}); });
describe('ReminderWidget — Formular behaelt den Fokus (quick-261001-g68)', () => {
afterEach(() => {
vi.useRealTimers();
});
it('der 10-s-Takt der Kachel holt den Cursor nicht aus der Beschreibung zurueck in den Titel', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-29T12:00:00.000Z'));
mockList.mockResolvedValue([]);
render(<ReminderWidget {...props} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
fireEvent.click(screen.getByText('reminder.add'));
expect(document.activeElement).toBe(screen.getByLabelText('reminder.titleLabel'));
const description = screen.getByLabelText('reminder.descriptionLabel');
description.focus();
fireEvent.change(description, { target: { value: 'Unterlagen mitnehmen' } });
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(document.activeElement).toBe(description);
});
});
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
afterEach(() => {
vi.useRealTimers();
});
it('faellig gestern 23:50, jetzt 00:10: die Option heisst „Heute“ und verschiebt auf heute 23:50', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date(2026, 8, 30, 0, 10));
mockList.mockResolvedValue([
reminder('a', 'Spaet', new Date(2026, 8, 29, 23, 50).toISOString()),
]);
mockSnooze.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByText('reminder.snooze'));
expect(screen.queryByText('reminder.snoozeTomorrow')).toBeNull();
fireEvent.click(await screen.findByText('reminder.snoozeToday'));
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
expect(mockSnooze.mock.calls[0][1]).toBe(new Date(2026, 8, 30, 23, 50).toISOString());
});
});
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => { describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
async function openFormAndWaitForStatus() { async function openFormAndWaitForStatus() {
await openForm(); await openForm();
@@ -383,7 +466,10 @@ describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => { it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => {
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true }); mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
mockList.mockResolvedValue([ mockList.mockResolvedValue([
{ ...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()), emailEnabled: true }, {
...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()),
emailEnabled: true,
},
]); ]);
mockUpdate.mockResolvedValue({}); mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />); render(<ReminderWidget {...props} />);
@@ -3,15 +3,15 @@
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { browserPermissionState } from '@/lib/reminder-notify'; import { browserPermissionState } from '@/lib/reminder-notify';
import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time'; import { isSameLocalDay, type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
import { import {
deleteReminder,
getReminderEmailStatus,
listReminders,
REMINDERS_CHANGED_EVENT, REMINDERS_CHANGED_EVENT,
type Reminder, type Reminder,
type ReminderEmailStatus, type ReminderEmailStatus,
ReminderRequestError, ReminderRequestError,
deleteReminder,
getReminderEmailStatus,
listReminders,
snoozeReminder, snoozeReminder,
} from '@/lib/reminders-api'; } from '@/lib/reminders-api';
import type { WidgetProps } from '../widget-registry'; import type { WidgetProps } from '../widget-registry';
@@ -107,7 +107,9 @@ export function ReminderWidget(_props: WidgetProps) {
/** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */ /** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */
const handleFailure = (err: unknown, conflictKey: string) => { const handleFailure = (err: unknown, conflictKey: string) => {
setNotice( setNotice(
err instanceof ReminderRequestError && err.status === 409 ? t(conflictKey) : t('reminder.saveError'), err instanceof ReminderRequestError && err.status === 409
? t(conflictKey)
: t('reminder.saveError'),
); );
void refetch(); void refetch();
}; };
@@ -134,6 +136,16 @@ export function ReminderWidget(_props: WidgetProps) {
} }
}; };
// „Heute“ oder „Morgen“ aus dem tatsaechlich berechneten Zeitpunkt: kurz nach
// Mitternacht liegt das naechste Vorkommen der Uhrzeit noch heute.
const tomorrowLabel = (r: Reminder) => {
const target = snoozeTarget('tomorrow', new Date(r.dueAt), new Date(now));
const time = timeOnly.format(target);
return isSameLocalDay(target, new Date(now))
? t('reminder.snoozeToday', { time })
: t('reminder.snoozeTomorrow', { time });
};
const remove = async (id: string) => { const remove = async (id: string) => {
setNotice(null); setNotice(null);
setConfirmDeleteId(null); setConfirmDeleteId(null);
@@ -177,7 +189,9 @@ export function ReminderWidget(_props: WidgetProps) {
} }
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{r.title}</p> <p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{r.title}
</p>
{r.emailEnabled && ( {r.emailEnabled && (
<span <span
className="shrink-0 text-muted-foreground" className="shrink-0 text-muted-foreground"
@@ -244,8 +258,7 @@ export function ReminderWidget(_props: WidgetProps) {
> >
{preset === '10m' && t('reminder.snooze10m')} {preset === '10m' && t('reminder.snooze10m')}
{preset === '1h' && t('reminder.snooze1h')} {preset === '1h' && t('reminder.snooze1h')}
{preset === 'tomorrow' && {preset === 'tomorrow' && tomorrowLabel(r)}
t('reminder.snoozeTomorrow', { time: timeOnly.format(new Date(r.dueAt)) })}
</button> </button>
))} ))}
</div> </div>
@@ -71,7 +71,9 @@ describe('WidgetWrapper', () => {
onRemove={vi.fn()} onRemove={vi.fn()}
/>, />,
); );
fireEvent.click(screen.getByRole('button', { name: 'hideTitle' })); const toggle = screen.getByRole('button', { name: 'hideTitle' });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(toggle);
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true }); expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true });
// im Bearbeitungsmodus bleibt der Titel sichtbar // im Bearbeitungsmodus bleibt der Titel sichtbar
expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title'); expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title');
@@ -86,7 +88,11 @@ describe('WidgetWrapper', () => {
onRemove={vi.fn()} onRemove={vi.fn()}
/>, />,
); );
fireEvent.click(screen.getByRole('button', { name: 'showTitle' })); // quick-260930: gleiche Beschriftung, der Zustand steckt in aria-pressed.
const toggle = screen.getByRole('button', { name: 'hideTitle' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(screen.queryByRole('button', { name: 'showTitle' })).toBeNull();
fireEvent.click(toggle);
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false }); expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false });
rerender( rerender(
@@ -48,7 +48,12 @@ interface WidgetWrapperProps {
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx. * .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
* Renders the actual widget component via WIDGET_REGISTRY lookup. * Renders the actual widget component via WIDGET_REGISTRY lookup.
*/ */
export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }: WidgetWrapperProps) { export function WidgetWrapper({
widget,
isEditMode,
onRemove,
enterIndex = 0,
}: WidgetWrapperProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType]; const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component; const WidgetComponent = definition?.component;
@@ -103,8 +108,11 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
void setWidgetConfig(widget.id, { hideTitle: !titleHidden }); void setWidgetConfig(widget.id, { hideTitle: !titleHidden });
}} }}
className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`} className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
aria-label={titleHidden ? t('showTitle') : t('hideTitle')} // quick-260930: feste Beschriftung plus aria-pressed — ein
title={titleHidden ? t('showTitle') : t('hideTitle')} // Umschalter, dessen Name mit dem Zustand wechselt, liest sich
// doppelt („Titel einblenden, gedrueckt“).
aria-label={t('hideTitle')}
title={t('hideTitle')}
aria-pressed={titleHidden} aria-pressed={titleHidden}
data-no-drag="" data-no-drag=""
> >
@@ -186,9 +194,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher
stand hier der rohe Typname, der dem Anwender nichts sagte. */ stand hier der rohe Typname, der dem Anwender nichts sagte. */
<div className="flex h-full items-center justify-center p-3"> <div className="flex h-full items-center justify-center p-3">
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">{t('unavailable')}</p>
{t('unavailable')}
</p>
</div> </div>
)} )}
</div> </div>
@@ -0,0 +1,110 @@
import { act, cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DESKTOP_COOKIE_NAME } from '@/lib/desktop-client';
import { DesktopExternalLinks } from './desktop-external-links';
/**
* desktop-external-links.test (quick-261001-cxo) — im Desktop-Client gehen
* Klicks auf Links mit `target="_blank"` ueber `window.open`; ohne Cookie,
* bei verhinderten Klicks, ohne `_blank` und bei Nicht-http(s) bleibt alles
* unberuehrt. `fire` gibt den `dispatchEvent`-Rueckgabewert zurueck:
* `false` bedeutet, `preventDefault()` wurde aufgerufen.
*/
function setCookie() {
document.cookie = `${DESKTOP_COOKIE_NAME}=1; path=/`;
}
function clearCookie() {
document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
function link(href: string, target?: string) {
const a = document.createElement('a');
a.href = href;
if (target) a.target = target;
const span = document.createElement('span');
a.appendChild(span);
document.body.appendChild(a);
return span;
}
function fire(el: Element, type: 'click' | 'auxclick' = 'click', button = 0) {
return el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, button }));
}
let openSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
});
afterEach(() => {
cleanup();
clearCookie();
openSpy.mockRestore();
document.body.innerHTML = '';
});
describe('DesktopExternalLinks', () => {
it('mit Cookie: Klick auf _blank-Link oeffnet per window.open', async () => {
setCookie();
render(<DesktopExternalLinks />);
await act(async () => {});
const inner = link('https://chatgpt.com/', '_blank');
expect(fire(inner)).toBe(false);
expect(openSpy).toHaveBeenCalledWith('https://chatgpt.com/', '_blank', 'noopener,noreferrer');
});
it('mit Cookie: kommt dem Link-Skript des Clients auf window zuvor', async () => {
setCookie();
render(<DesktopExternalLinks />);
await act(async () => {});
// Nachbau von tauri-plugin-opener (init-iife.js): bricht ab, wenn der
// Klick schon verhindert ist, sonst preventDefault + IPC-Aufruf.
const openerSaw: boolean[] = [];
const opener = (e: MouseEvent) => openerSaw.push(e.defaultPrevented);
window.addEventListener('click', opener);
try {
fire(link('https://chatgpt.com/', '_blank'));
} finally {
window.removeEventListener('click', opener);
}
expect(openSpy).toHaveBeenCalledTimes(1);
expect(openerSaw).toEqual([true]);
});
it('mit Cookie: Mittelklick auf _blank-Link oeffnet ebenfalls', async () => {
setCookie();
render(<DesktopExternalLinks />);
await act(async () => {});
const inner = link('http://intranet.local/', '_blank');
expect(fire(inner, 'auxclick', 1)).toBe(false);
expect(openSpy).toHaveBeenCalledWith('http://intranet.local/', '_blank', 'noopener,noreferrer');
});
it('mit Cookie: verhinderter Klick, Link ohne _blank und mailto bleiben unberuehrt', async () => {
setCookie();
render(<DesktopExternalLinks />);
await act(async () => {});
const prevented = link('https://chatgpt.com/', '_blank');
prevented.addEventListener('click', (e) => e.preventDefault());
fire(prevented);
expect(fire(link('https://example.com/'))).toBe(true);
expect(fire(link('mailto:a@example.com', '_blank'))).toBe(true);
expect(fire(link('https://example.com/', '_blank'), 'click', 2)).toBe(true);
expect(openSpy).not.toHaveBeenCalled();
});
it('ohne Cookie: keine Umleitung', async () => {
render(<DesktopExternalLinks />);
await act(async () => {});
expect(fire(link('https://chatgpt.com/', '_blank'))).toBe(true);
expect(openSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,61 @@
'use client';
import { useEffect } from 'react';
import { useIsDesktopClient } from '@/lib/desktop-client';
/**
* DesktopExternalLinks (quick-261001-cxo) — Links mit `target="_blank"`
* (Favoriten, „In neuem Tab öffnen“ bei XFrame/eigenen Modulen, Quellen im
* Ausschreibungs-Radar) taten im Desktop-Client beim Klick nichts, waehrend
* `window.open` (Such-Widget) ueber `on_new_window` im System-Browser landet.
*
* Ursache (VM 8233, 01.10.2026, gemessen): tauri-plugin-opener schleust ein
* Skript ein, das auf `window` solche Klicks abfaengt, `preventDefault()`
* ruft und `plugin:opener|open_url` aufruft -- von der Server-Seite aus ist
* dieser Aufruf nicht freigegeben, der Klick verpufft. Dieser Helfer kommt
* ihm zuvor und oeffnet per `window.open`; das Opener-Skript sieht den Klick
* dann als verhindert und tut nichts.
*
* Lauscht deshalb auf `document` in der Bubble-Phase: NACH Reacts Handlern
* (Wurzel `document`, frueher registriert) -- ein Klick, den die Seite selbst
* verhindert (Favoriten im Bearbeiten-Modus), bleibt verhindert --, aber VOR
* dem Opener-Skript auf `window`. Nur http/https.
*/
function externalTarget(event: MouseEvent): HTMLAnchorElement | null {
if (event.defaultPrevented) return null;
// Linksklick und Mittelklick oeffnen beide ein neues Fenster.
if (event.button !== 0 && event.button !== 1) return null;
const target = event.target;
if (!(target instanceof Element)) return null;
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return null;
if (anchor.target !== '_blank') return null;
if (anchor.protocol !== 'http:' && anchor.protocol !== 'https:') return null;
return anchor;
}
export function DesktopExternalLinks() {
const isDesktop = useIsDesktopClient();
useEffect(() => {
if (!isDesktop) return;
const handler = (event: MouseEvent) => {
if (event.type === 'auxclick' && event.button !== 1) return;
if (event.type === 'click' && event.button !== 0) return;
const anchor = externalTarget(event);
if (!anchor) return;
event.preventDefault();
window.open(anchor.href, '_blank', 'noopener,noreferrer');
};
document.addEventListener('click', handler);
document.addEventListener('auxclick', handler);
return () => {
document.removeEventListener('click', handler);
document.removeEventListener('auxclick', handler);
};
}, [isDesktop]);
return null;
}
+11 -6
View File
@@ -1,12 +1,13 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { installErrorBuffer } from '@/lib/error-buffer';
import { Header } from '@/components/layout/header'; import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar'; import { Sidebar } from '@/components/layout/sidebar';
import { CustomModuleFrameHost } from '@/components/modules/custom-module-frame-host';
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host'; import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
import { ReminderNotifier } from '@/components/reminders/reminder-notifier'; import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
import { installErrorBuffer } from '@/lib/error-buffer';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
export function AppShell({ children }: { children: React.ReactNode }) { export function AppShell({ children }: { children: React.ReactNode }) {
const isCollapsed = useSidebarStore((s) => s.isCollapsed); const isCollapsed = useSidebarStore((s) => s.isCollapsed);
@@ -24,9 +25,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
// Desktop: sidebar pushes content via margin-left // Desktop: sidebar pushes content via margin-left
// Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css) // Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css)
const sidebarWidth = isCollapsed const sidebarWidth = isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)';
? 'var(--sidebar-width-collapsed)'
: 'var(--sidebar-width)';
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@@ -35,7 +34,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{/* Seitenrahmen seit quick-260916-bwo halbiert: p-3 (12 px) statt vorher 24 px (Stufe 6), gilt fuer alle Seiten. */} {/* Seitenrahmen seit quick-260916-bwo halbiert: p-3 (12 px) statt vorher 24 px (Stufe 6), gilt fuer alle Seiten. */}
<main <main
className="app-shell-main min-h-[calc(100vh-var(--header-height))] transition-all duration-200 p-3" className="app-shell-main min-h-[calc(100vh-var(--header-height))] transition-all duration-200 p-3"
style={mounted ? { '--current-sidebar-width': sidebarWidth } as React.CSSProperties : undefined} style={
mounted ? ({ '--current-sidebar-width': sidebarWidth } as React.CSSProperties) : undefined
}
> >
{children} {children}
</main> </main>
@@ -46,6 +47,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die
Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */} Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */}
<ReminderNotifier /> <ReminderNotifier />
{/* Eigene Module offen halten (30.09.2026): die <iframe>s leben hier im
Rahmen und ueberstehen so das Wegnavigieren; die Modulseite zeigt nur
einen Platzhalter, ueber den der Behaelter das aktive Modul legt. */}
<CustomModuleFrameHost />
</div> </div>
); );
} }
+28 -27
View File
@@ -1,18 +1,19 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import { TesseraLogo } from '@/components/brand/tessera-logo'; import { TesseraLogo } from '@/components/brand/tessera-logo';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useAuthStore } from '@/lib/stores/auth-store';
import { fetchSessionState, logout } from '@/lib/auth-actions';
import { buildNextParam } from '@/lib/safe-next';
import { ThemeToggle } from '@/components/theme-toggle';
import { BugReportButton } from '@/components/bug-report/bug-report-button'; import { BugReportButton } from '@/components/bug-report/bug-report-button';
import { HEADER_ACTIONS_SLOT_ID, HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot'; import { HEADER_ACTIONS_SLOT_ID, HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
import { usePathname } from 'next/navigation'; import { ThemeToggle } from '@/components/theme-toggle';
import { fetchSessionState, logout } from '@/lib/auth-actions';
import { buildNextParam } from '@/lib/safe-next';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store'; import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
export function Header() { export function Header() {
const t = useTranslations(); const t = useTranslations();
@@ -60,13 +61,8 @@ export function Header() {
return; return;
} }
redirectedRef.current = true; redirectedRef.current = true;
const next = buildNextParam( const next = buildNextParam(window.location.pathname, window.location.search);
window.location.pathname, window.location.href = next ? `/login?next=${encodeURIComponent(next)}` : '/login';
window.location.search,
);
window.location.href = next
? `/login?next=${encodeURIComponent(next)}`
: '/login';
} }
// 'unavailable' (T-gyd-02): kein Redirect, bisheriges stilles // 'unavailable' (T-gyd-02): kein Redirect, bisheriges stilles
// Verhalten -- ein API-Ausfall darf keine Abmelde-Schleife ausloesen. // Verhalten -- ein API-Ausfall darf keine Abmelde-Schleife ausloesen.
@@ -76,10 +72,7 @@ export function Header() {
// Close dropdown on outside click // Close dropdown on outside click
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if ( if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false); setDropdownOpen(false);
} }
} }
@@ -91,9 +84,7 @@ export function Header() {
const [avatarError, setAvatarError] = useState(false); const [avatarError, setAvatarError] = useState(false);
const userInitial = user const userInitial = user ? (user.displayName ?? user.username).charAt(0).toUpperCase() : '?';
? (user.displayName ?? user.username).charAt(0).toUpperCase()
: '?';
const roleLabel = user ? tHeader(`role.${user.role}`) : ''; const roleLabel = user ? tHeader(`role.${user.role}`) : '';
@@ -101,11 +92,7 @@ export function Header() {
// der Name der aktuellen Seite bzw. des Moduls. // der Name der aktuellen Seite bzw. des Moduls.
const navModules = useNavStore((s) => s.modules); const navModules = useNavStore((s) => s.modules);
const pageTitle = resolvePageTitle(pathname, navModules); const pageTitle = resolvePageTitle(pathname, navModules);
const pageTitleText = pageTitle const pageTitleText = pageTitle ? ('key' in pageTitle ? t(pageTitle.key) : pageTitle.text) : null;
? 'key' in pageTitle
? t(pageTitle.key)
: pageTitle.text
: null;
return ( return (
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center bg-appbar px-2 text-appbar-foreground sm:px-3 dark:shadow-[inset_0_-1px_0_oklch(1_0_0/0.07)]"> <header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center bg-appbar px-2 text-appbar-foreground sm:px-3 dark:shadow-[inset_0_-1px_0_oklch(1_0_0/0.07)]">
@@ -258,7 +245,18 @@ export function Header() {
: 'text-foreground hover:bg-muted' : 'text-foreground hover:bg-muted'
}`} }`}
> >
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /> <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg> </svg>
{tHeader('admin.label')} {tHeader('admin.label')}
@@ -273,6 +271,9 @@ export function Header() {
type="button" type="button"
onClick={() => { onClick={() => {
setDropdownOpen(false); setDropdownOpen(false);
// Offen gehaltene eigene Module und ihr Sitzungsspeicher
// gehoeren zur Sitzung — beim Abmelden alles verwerfen.
useCustomModuleCacheStore.getState().clear();
logout(); logout();
}} }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors"
+54 -10
View File
@@ -8,8 +8,18 @@ vi.mock('next/navigation', () => ({
})); }));
vi.mock('next/link', () => ({ vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => ( default: ({
<a href={href} className={className}>{children}</a> children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
), ),
})); }));
@@ -100,9 +110,7 @@ const mockActiveModules = [
function stubFetch() { function stubFetch() {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn(() => vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) })),
Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }),
),
); );
} }
@@ -316,7 +324,9 @@ describe('Sidebar', () => {
expect(screen.getByText('Mein Wiki')).toBeInTheDocument(); expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
}); });
const groups = Array.from( const groups = Array.from(
screen.getByRole('group', { name: 'Kategorien' }).querySelectorAll(':scope > ul > li > button span.truncate'), screen
.getByRole('group', { name: 'Kategorien' })
.querySelectorAll(':scope > ul > li > button span.truncate'),
).map((n) => n.textContent); ).map((n) => n.textContent);
expect(groups.at(-1)).toBe('custom-modules'); expect(groups.at(-1)).toBe('custom-modules');
expect(groups).toContain('fleet'); expect(groups).toContain('fleet');
@@ -340,12 +350,40 @@ describe('Sidebar', () => {
render(<Sidebar />); render(<Sidebar />);
const hrefs = () => const hrefs = () =>
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href')); Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
a.getAttribute('href'),
);
await waitFor(() => { await waitFor(() => {
expect(hrefs()).toContain('/modules/custom/cm-1'); expect(hrefs()).toContain('/modules/custom/cm-1');
}); });
expect(hrefs().at(-1)).toBe('/modules/custom/cm-1'); expect(hrefs().at(-1)).toBe('/modules/custom/cm-1');
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(hrefs().indexOf('/modules/custom/cm-1')); expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(
hrefs().indexOf('/modules/custom/cm-1'),
);
});
it('eingeklappt dieselbe Gruppenfolge wie ausgeklappt (eigenes Modul bei seiner Kategorie)', async () => {
mockCustomModules = [
customModule({ id: 'cm-1', category: 'Domain-Tools', name: 'Mein Tool' }),
customModule({ id: 'cm-2', category: 'custom-modules', name: 'Mein Wiki' }),
];
mockSidebarCollapsed(true);
const Sidebar = await importSidebar();
render(<Sidebar />);
const hrefs = () =>
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
a.getAttribute('href'),
);
await waitFor(() => {
expect(hrefs()).toContain('/modules/custom/cm-2');
});
expect(hrefs()).toEqual([
'/modules/Domain-Tools/domaincheck',
'/modules/custom/cm-1',
'/modules/Utilities/converter',
'/modules/custom/cm-2',
]);
}); });
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => { it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
@@ -360,7 +398,10 @@ describe('Sidebar', () => {
.getByText('Domain-Tools') .getByText('Domain-Tools')
.closest('li') .closest('li')
?.querySelectorAll('a span.truncate'); ?.querySelectorAll('a span.truncate');
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']); expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([
'Domaincheck',
'Aaa Eigenes',
]);
}); });
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => { it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
@@ -379,7 +420,10 @@ describe('Sidebar', () => {
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'), screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull(); ).toBeNull();
expect( expect(
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'), screen
.getByText('Domaincheck')
.closest('a')
?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull(); ).toBeNull();
}); });
+120 -45
View File
@@ -1,20 +1,20 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { TesseraLogo } from '@/components/brand/tessera-logo'; import { TesseraLogo } from '@/components/brand/tessera-logo';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
import { useNavStore } from '@/lib/stores/nav-store';
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting'; import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { ModuleTile } from '@/components/modules/module-tile';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api'; import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useNavStore } from '@/lib/stores/nav-store';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useCategoryLabel } from '@/lib/use-category-label';
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */ /** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
function SelectionPill() { function SelectionPill() {
@@ -109,13 +109,17 @@ export function Sidebar() {
const linkClass = (href: string) => itemClass(isActive(href)); const linkClass = (href: string) => itemClass(isActive(href));
const isEntryActive = (entry: SidebarEntry) => const isEntryActive = (entry: SidebarEntry) =>
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref)); isActive(entry.href) ||
(entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die // Moduleintraege unter einer Kategorie eine Stufe kleiner als die
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift; // Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
// die 20-px-Modulkachel passt weiter hinein. // die 20-px-Modulkachel passt weiter hinein.
const moduleItemClass = (active: boolean) => const moduleItemClass = (active: boolean) =>
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]'); itemClass(active)
.replace('h-9', 'h-8')
.replace('gap-3', 'gap-2.5')
.replace('text-sm', 'text-[13px]');
// Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt // Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch // 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch
@@ -186,28 +190,29 @@ export function Sidebar() {
}); });
}; };
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — „Eigene Module“ // Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
// also auch hier immer zuletzt. // die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
const orderedCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>();
for (const entry of entries) {
const list = categories.get(entry.category);
if (list) list.push(entry);
else categories.set(entry.category, [entry]);
}
return Array.from(categories.entries()).sort(
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
);
}, [entries]);
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
const collapsedEntries = useMemo( const collapsedEntries = useMemo(
() => () => orderedCategories.flatMap(([, categoryEntries]) => categoryEntries),
[...entries].sort( [orderedCategories],
(a, b) =>
Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY),
),
[entries],
); );
const filteredCategories = useMemo(() => { const filteredCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>(); const ordered = orderedCategories;
for (const entry of entries) {
if (!categories.has(entry.category)) categories.set(entry.category, []);
categories.get(entry.category)!.push(entry);
}
// „Eigene Module“ steht immer zuletzt, die uebrigen in Fundreihenfolge.
const ordered = Array.from(categories.entries()).sort(
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
);
if (!searchQuery) return ordered; if (!searchQuery) return ordered;
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
@@ -221,10 +226,22 @@ export function Sidebar() {
} }
} }
return result; return result;
}, [entries, searchQuery, categoryLabel]); }, [orderedCategories, searchQuery, categoryLabel]);
const navIcon = (children: React.ReactNode) => ( const navIcon = (children: React.ReactNode) => (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width={isCollapsed ? 22 : 20} height={isCollapsed ? 22 : 20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0"> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width={isCollapsed ? 22 : 20}
height={isCollapsed ? 22 : 20}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
{children} {children}
</svg> </svg>
); );
@@ -234,7 +251,12 @@ export function Sidebar() {
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}> <nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}> <ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
<li> <li>
<Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}> <Link
href="/"
className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')}
aria-label={t('dashboard')}
title={isCollapsed ? t('dashboard') : undefined}
>
{isActive('/') && <SelectionPill />} {isActive('/') && <SelectionPill />}
{navIcon( {navIcon(
<> <>
@@ -248,7 +270,16 @@ export function Sidebar() {
</Link> </Link>
</li> </li>
<li> <li>
<Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}> <Link
href="/marketplace"
className={
isCollapsed
? collapsedItemClass(isActive('/marketplace'))
: linkClass('/marketplace')
}
aria-label={t('marketplace')}
title={isCollapsed ? t('marketplace') : undefined}
>
{isActive('/marketplace') && <SelectionPill />} {isActive('/marketplace') && <SelectionPill />}
{navIcon( {navIcon(
<> <>
@@ -268,9 +299,18 @@ export function Sidebar() {
const active = isEntryActive(entry); const active = isEntryActive(entry);
return ( return (
<li key={entry.key}> <li key={entry.key}>
<Link href={entry.href} className={collapsedItemClass(active)} aria-label={entry.name} title={entry.name}> <Link
href={entry.href}
className={collapsedItemClass(active)}
aria-label={entry.name}
title={entry.name}
>
{active && <SelectionPill />} {active && <SelectionPill />}
<ModuleTile slug={entry.tileSlug} size={24} tone={active ? 'accent' : 'neutral'} /> <ModuleTile
slug={entry.tileSlug}
size={24}
tone={active ? 'accent' : 'neutral'}
/>
</Link> </Link>
</li> </li>
); );
@@ -290,9 +330,7 @@ export function Sidebar() {
{t('categories.empty')} {t('categories.empty')}
</div> </div>
) : filteredCategories.length === 0 ? ( ) : filteredCategories.length === 0 ? (
<div className="px-2.5 py-2 text-sm text-muted-foreground"> <div className="px-2.5 py-2 text-sm text-muted-foreground">{t('noResults')}</div>
{t('noResults')}
</div>
) : ( ) : (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{filteredCategories.map(([category, categoryEntries]) => { {filteredCategories.map(([category, categoryEntries]) => {
@@ -307,7 +345,19 @@ export function Sidebar() {
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
> >
<span className="truncate">{categoryLabel(category)}</span> <span className="truncate">{categoryLabel(category)}</span>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}
>
<polyline points="6 9 12 15 18 9" /> <polyline points="6 9 12 15 18 9" />
</svg> </svg>
</button> </button>
@@ -319,7 +369,11 @@ export function Sidebar() {
<li key={entry.key}> <li key={entry.key}>
<Link href={entry.href} className={moduleItemClass(active)}> <Link href={entry.href} className={moduleItemClass(active)}>
{active && <SelectionPill />} {active && <SelectionPill />}
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} /> <ModuleTile
slug={entry.tileSlug}
size={20}
tone={active ? 'accent' : 'neutral'}
/>
<span className="truncate">{entry.name}</span> <span className="truncate">{entry.name}</span>
</Link> </Link>
</li> </li>
@@ -353,7 +407,19 @@ export function Sidebar() {
aria-label={isCollapsed ? t('expand') : t('collapse')} aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : undefined} title={isCollapsed ? t('expand') : undefined}
> >
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
>
<rect x="3" y="3" width="18" height="18" rx="2" /> <rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" /> <path d="M9 3v18" />
<path d="m16 15-3-3 3-3" /> <path d="m16 15-3-3 3-3" />
@@ -377,9 +443,7 @@ export function Sidebar() {
<aside <aside
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block" className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
style={{ style={{
width: isCollapsed width: isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)',
? 'var(--sidebar-width-collapsed)'
: 'var(--sidebar-width)',
}} }}
> >
{sidebarContent} {sidebarContent}
@@ -408,7 +472,18 @@ export function Sidebar() {
aria-label={tCommon('close')} aria-label={tCommon('close')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground" className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
> >
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
@@ -0,0 +1,134 @@
'use client';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { checkCustomModuleUrl } from '@/lib/custom-modules-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
interface FrameRect {
top: number;
left: number;
width: number;
height: number;
}
/**
* Behaelter fuer die offen gehaltenen eigenen Module (30.09.2026). Dauerhaft
* im Portal-Rahmen (AppShell) eingehaengt, damit ein <iframe> das Wegnavigieren
* ueberlebt: zurueck auf dem Modul ist die Fremdseite sofort da, mit
* Scrollstand, Anmeldung und Eingaben.
*
* Positionierung — warum Ueberlagern statt Einhaengen in die Seite: Ein
* <iframe>, das im DOM verschoben wird (auch per Portal in einen anderen
* Elternknoten), laedt in allen Browsern neu. Deshalb bleibt jedes <iframe>
* immer im selben Elternknoten hier, und die Modulseite rendert nur einen
* leeren Platzhalter (custom-module-view.tsx). Das aktive <iframe> wird per
* `position: fixed` deckungsgleich ueber den Platzhalter gelegt; gemessen wird
* mit `getBoundingClientRect` bei jeder Groessenaenderung des Platzhalters
* (ResizeObserver — faengt auch das Ein-/Ausklappen der Seitenleiste ab, deren
* Uebergang die Breite des Inhaltsbereichs Bild fuer Bild aendert), bei
* Fenstergroesse, beim Scrollen und am Ende von CSS-Uebergaengen. Eine reine
* CSS-Nachbildung der Rahmenmasse (Kopfzeile, Seitenleiste, Innenabstand)
* waere zerbrechlicher: jede Layoutaenderung dort muesste hier nachgezogen
* werden.
*
* Versteckt wird mit `visibility: hidden` + `pointer-events: none` an der
* zuletzt gemessenen Stelle — NICHT mit `display: none`: dort haette die
* Fremdseite die Groesse 0, manche Seiten rechnen dann ihr Layout neu und
* verlieren den Scrollstand. `visibility: hidden` nimmt das <iframe> zudem aus
* Tab-Reihenfolge und Vorlesebaum.
*
* Ebene: `z-index: 10` — ueber dem Inhaltsbereich (Platzhalter), aber unter
* Seitenleiste (30), Kopfzeile und Dialogen (50) und der Handy-Seitenleiste
* (60), die das Modul also weiterhin ueberdecken.
*
* Sandbox und Attribute wie bisher in der Modulansicht bzw. im XFrame-Widget
* (`XFRAME_SANDBOX`, `allow=""`, kein Referrer); ein <iframe> entsteht nur bei
* einer https-Adresse ohne Zugangsdaten (T-9WC-03).
*/
export function CustomModuleFrameHost() {
const modules = useCustomModuleCacheStore((s) => s.modules);
const openIds = useCustomModuleCacheStore((s) => s.openIds);
const active = useCustomModuleCacheStore((s) => s.active);
const clear = useCustomModuleCacheStore((s) => s.clear);
const [rect, setRect] = useState<FrameRect | null>(null);
// Benutzerwechsel ohne Neuladen der Seite: nichts vom vorigen Benutzer
// weiterzeigen. (Das Abmelden selbst leert den Speicher in der Kopfzeile.)
const userId = useAuthStore((s) => s.user?.id ?? null);
const lastUserId = useRef<string | null>(null);
useEffect(() => {
if (userId && lastUserId.current && lastUserId.current !== userId) clear();
if (userId) lastUserId.current = userId;
}, [userId, clear]);
const slot = active?.slot ?? null;
useLayoutEffect(() => {
if (!slot) return;
let frame = 0;
const measure = () => {
frame = 0;
const r = slot.getBoundingClientRect();
setRect((prev) =>
prev &&
prev.top === r.top &&
prev.left === r.left &&
prev.width === r.width &&
prev.height === r.height
? prev
: { top: r.top, left: r.left, width: r.width, height: r.height },
);
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(measure);
};
measure();
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null;
observer?.observe(slot);
window.addEventListener('resize', schedule);
window.addEventListener('scroll', schedule, true);
document.addEventListener('transitionend', schedule, true);
return () => {
if (frame) cancelAnimationFrame(frame);
observer?.disconnect();
window.removeEventListener('resize', schedule);
window.removeEventListener('scroll', schedule, true);
document.removeEventListener('transitionend', schedule, true);
};
}, [slot]);
// Immer derselbe Elternknoten, auch ohne offene Module — sonst haengte React
// die <iframe>s beim Wechsel zwischen leer und nicht leer neu ein.
return (
<div data-testid="custom-module-frame-host">
{openIds.map((id) => {
const mod = modules[id];
if (!mod || checkCustomModuleUrl(mod.url) !== 'ok') return null;
const visible = active?.id === id && rect !== null;
return (
<iframe
key={id}
src={mod.url}
title={mod.name}
sandbox={XFRAME_SANDBOX}
allow=""
referrerPolicy="no-referrer"
className="fixed z-10 rounded-lg border-0 bg-background"
style={{
top: rect?.top ?? 0,
left: rect?.left ?? 0,
width: rect?.width ?? 0,
height: rect?.height ?? 0,
visibility: visible ? 'visible' : 'hidden',
pointerEvents: visible ? 'auto' : 'none',
}}
aria-hidden={visible ? undefined : true}
data-custom-module-id={id}
data-testid="custom-module-frame"
/>
);
})}
</div>
);
}
@@ -1,12 +1,14 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config'; import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
vi.mock('next-intl', () => ({ vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => { useTranslations: () => (key: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
openInNewTab: 'In neuem Tab öffnen', openInNewTab: 'In neuem Tab öffnen',
notFound: 'Dieses Modul gibt es nicht mehr.', notFound: 'Dieses Modul gibt es nicht mehr.',
loadError: 'Dieses Modul konnte nicht geladen werden.',
invalidUrl: 'Ungültige Adresse', invalidUrl: 'Ungültige Adresse',
loading: 'Wird geladen', loading: 'Wird geladen',
}; };
@@ -22,12 +24,33 @@ vi.mock('@/lib/custom-modules-api', async () => {
return { ...actual, getCustomModule: mockGetCustomModule }; return { ...actual, getCustomModule: mockGetCustomModule };
}); });
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
import { CustomModuleFrameHost } from './custom-module-frame-host';
import { CustomModuleView } from './custom-module-view'; import { CustomModuleView } from './custom-module-view';
function mod(url: string) { /**
* Der <iframe> lebt seit dem Offenhalten (30.09.2026) im Behaelter des
* Portal-Rahmens; die Modulseite zeigt nur den Platzhalter. Die Tests rendern
* deshalb beide nebeneinander wie in AppShell. `route` bildet den
* Seitenwechsel nach: nur die Modulseite wechselt, der Behaelter bleibt.
*/
function Portal({ route }: { route: string | null }) {
return (
<>
<main>{route ? <CustomModuleView id={route} /> : <p>Dashboard</p>}</main>
<CustomModuleFrameHost />
</>
);
}
function renderModule(id: string) {
return render(<Portal route={id} />);
}
function mod(url: string, id = 'abc', name = 'Wiki') {
return { return {
id: 'abc', id,
name: 'Wiki', name,
url, url,
category: 'infrastructure', category: 'infrastructure',
createdAt: '2026-09-29T00:00:00.000Z', createdAt: '2026-09-29T00:00:00.000Z',
@@ -37,6 +60,7 @@ function mod(url: string) {
beforeEach(() => { beforeEach(() => {
mockGetCustomModule.mockReset(); mockGetCustomModule.mockReset();
useCustomModuleCacheStore.getState().clear();
}); });
afterEach(() => { afterEach(() => {
@@ -46,7 +70,7 @@ afterEach(() => {
describe('CustomModuleView', () => { describe('CustomModuleView', () => {
it('rendert den Rahmen mit Sandbox, ohne Referrer und ohne allow', async () => { it('rendert den Rahmen mit Sandbox, ohne Referrer und ohne allow', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki')); mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />); renderModule('abc');
const frame = await screen.findByTitle('Wiki'); const frame = await screen.findByTitle('Wiki');
expect(frame.tagName).toBe('IFRAME'); expect(frame.tagName).toBe('IFRAME');
@@ -60,7 +84,7 @@ describe('CustomModuleView', () => {
it('zeigt immer den Link „In neuem Tab öffnen“, aber keine Name- oder Hinweiszeile', async () => { it('zeigt immer den Link „In neuem Tab öffnen“, aber keine Name- oder Hinweiszeile', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki')); mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />); renderModule('abc');
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' }); const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
expect(link).toHaveAttribute('href', 'https://example.com/wiki'); expect(link).toHaveAttribute('href', 'https://example.com/wiki');
@@ -75,7 +99,7 @@ describe('CustomModuleView', () => {
document.body.appendChild(slot); document.body.appendChild(slot);
try { try {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki')); mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />); renderModule('abc');
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' }); const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
expect(slot.contains(link)).toBe(true); expect(slot.contains(link)).toBe(true);
} finally { } finally {
@@ -90,7 +114,7 @@ describe('CustomModuleView', () => {
'https://user:pw@example.com', 'https://user:pw@example.com',
])('rendert bei der Adresse %s weder Rahmen noch Link', async (url) => { ])('rendert bei der Adresse %s weder Rahmen noch Link', async (url) => {
mockGetCustomModule.mockResolvedValue(mod(url)); mockGetCustomModule.mockResolvedValue(mod(url));
const { container } = render(<CustomModuleView id="abc" />); const { container } = renderModule('abc');
await screen.findByText('Ungültige Adresse'); await screen.findByText('Ungültige Adresse');
expect(container.querySelector('iframe')).toBeNull(); expect(container.querySelector('iframe')).toBeNull();
@@ -99,11 +123,122 @@ describe('CustomModuleView', () => {
it('zeigt bei 404 den Nicht-gefunden-Text', async () => { it('zeigt bei 404 den Nicht-gefunden-Text', async () => {
mockGetCustomModule.mockResolvedValue(null); mockGetCustomModule.mockResolvedValue(null);
const { container } = render(<CustomModuleView id="weg" />); const { container } = renderModule('weg');
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument(); expect(screen.getByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument();
}); });
expect(container.querySelector('iframe')).toBeNull(); expect(container.querySelector('iframe')).toBeNull();
}); });
it.each([
['500', new CustomModuleRequestError(500, 'Internal server error')],
['403', new CustomModuleRequestError(403, 'Forbidden')],
['Netzwerkfehler', new TypeError('Failed to fetch')],
])('zeigt bei %s „konnte nicht geladen werden“ statt „nicht gefunden“', async (_, err) => {
mockGetCustomModule.mockRejectedValue(err);
const { container } = renderModule('abc');
expect(await screen.findByRole('alert')).toHaveTextContent(
'Dieses Modul konnte nicht geladen werden.',
);
expect(screen.queryByText('Dieses Modul gibt es nicht mehr.')).toBeNull();
expect(container.querySelector('iframe')).toBeNull();
});
});
describe('Eigene Module offen halten', () => {
it('haelt den Rahmen beim Weg- und Zuruecknavigieren als dasselbe Element im DOM', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
const { rerender } = render(<Portal route="abc" />);
const frame = await screen.findByTitle('Wiki');
expect(frame).toHaveStyle({ visibility: 'visible' });
rerender(<Portal route={null} />);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
const hidden = document.querySelector('iframe');
expect(hidden).toBe(frame);
expect(frame.isConnected).toBe(true);
expect(frame).toHaveStyle({ visibility: 'hidden', pointerEvents: 'none' });
rerender(<Portal route="abc" />);
await waitFor(() => expect(frame).toHaveStyle({ visibility: 'visible' }));
expect(document.querySelector('iframe')).toBe(frame);
});
it('zeigt ein schon geladenes Modul sofort und laedt im Hintergrund nach', async () => {
useCustomModuleCacheStore.getState().remember(mod('https://example.com/alt'));
let resolve: (value: ReturnType<typeof mod>) => void = () => {};
mockGetCustomModule.mockReturnValue(
new Promise((r) => {
resolve = r;
}),
);
renderModule('abc');
// Ohne auf die API zu warten: kein Ladehinweis, Rahmen schon da.
expect(screen.queryByText('Wird geladen')).toBeNull();
const frame = screen.getByTitle('Wiki');
expect(frame).toHaveAttribute('src', 'https://example.com/alt');
expect(mockGetCustomModule).toHaveBeenCalledWith('abc');
// Nachgeladene neue Adresse landet im selben Rahmen.
await act(async () => {
resolve(mod('https://example.com/neu'));
});
expect(screen.getByTitle('Wiki')).toBe(frame);
expect(frame).toHaveAttribute('src', 'https://example.com/neu');
});
it('bleibt bei gespeichertem Stand sichtbar, wenn nur das Nachladen scheitert', async () => {
useCustomModuleCacheStore.getState().remember(mod('https://example.com/wiki'));
mockGetCustomModule.mockRejectedValue(new TypeError('Failed to fetch'));
renderModule('abc');
await waitFor(() => expect(mockGetCustomModule).toHaveBeenCalled());
expect(screen.queryByRole('alert')).toBeNull();
expect(screen.getByTitle('Wiki').tagName).toBe('IFRAME');
});
it('verwirft bei 404 Speicherstand und offenen Rahmen und zeigt „nicht gefunden“', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
const { rerender } = render(<Portal route="abc" />);
await screen.findByTitle('Wiki');
rerender(<Portal route={null} />);
mockGetCustomModule.mockResolvedValue(null);
rerender(<Portal route="abc" />);
expect(await screen.findByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument();
expect(document.querySelector('iframe')).toBeNull();
const state = useCustomModuleCacheStore.getState();
expect(state.modules.abc).toBeUndefined();
expect(state.openIds).toEqual([]);
});
it('haelt hoechstens fuenf Module offen und verwirft das am laengsten unbenutzte', async () => {
mockGetCustomModule.mockImplementation(async (id: string) =>
mod(`https://example.com/${id}`, id, `Modul ${id}`),
);
const { rerender } = render(<Portal route="m1" />);
await screen.findByTitle('Modul m1');
for (const id of ['m2', 'm3', 'm4', 'm5', 'm6']) {
rerender(<Portal route={id} />);
await screen.findByTitle(`Modul ${id}`);
}
expect(document.querySelectorAll('iframe')).toHaveLength(5);
expect(screen.queryByTitle('Modul m1')).toBeNull();
expect(screen.getByTitle('Modul m2')).toBeInTheDocument();
});
it('verwirft beim Leeren des Speichers (Abmelden) alle Rahmen', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
const { rerender } = render(<Portal route="abc" />);
await screen.findByTitle('Wiki');
rerender(<Portal route={null} />);
act(() => useCustomModuleCacheStore.getState().clear());
expect(document.querySelector('iframe')).toBeNull();
});
}); });
@@ -1,13 +1,13 @@
'use client'; 'use client';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot'; import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config'; import { checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api'; import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'ready'; mod: CustomModule }; type LoadStatus = 'loading' | 'missing' | 'failed' | 'ready';
/** /**
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen * Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen
@@ -19,40 +19,85 @@ type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'read
* Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne * Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne
* Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit * Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03). * `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
* Nur ein 404 heisst „gibt es nicht mehr“; 403, 500 oder ein Netzwerkfehler
* zeigen „konnte nicht geladen werden“ — der Eintrag existiert vielleicht noch.
*
* Offen halten (30.09.2026): Der <iframe> selbst lebt NICHT mehr hier, sondern
* im Behaelter `CustomModuleFrameHost` im Portal-Rahmen; diese Seite rendert
* nur den Platzhalter, ueber den der Behaelter ihn legt (Begruendung dort).
* Name und Adresse kommen aus dem Sitzungsspeicher
* (`custom-module-cache-store.ts`): ein schon einmal geladenes Modul erscheint
* sofort, `getCustomModule` laeuft trotzdem im Hintergrund. Eine geaenderte
* Adresse landet so im Speicher und laedt den <iframe> um; ein 404 verwirft den
* Eintrag samt <iframe> und zeigt „nicht gefunden“. Scheitert NUR das
* Nachladen (403, 500, Netz) bei vorhandenem Speicherstand, bleibt das Modul
* sichtbar — ohne Speicherstand gilt wie bisher „konnte nicht geladen werden“.
*/ */
export function CustomModuleView({ id }: { id: string }) { export function CustomModuleView({ id }: { id: string }) {
const t = useTranslations('customModules'); const t = useTranslations('customModules');
const [state, setState] = useState<LoadState>({ status: 'loading' }); const mod = useCustomModuleCacheStore((s) => s.modules[id]);
const remember = useCustomModuleCacheStore((s) => s.remember);
const forget = useCustomModuleCacheStore((s) => s.forget);
const activate = useCustomModuleCacheStore((s) => s.activate);
const deactivate = useCustomModuleCacheStore((s) => s.deactivate);
const [status, setStatus] = useState<LoadStatus>(() =>
useCustomModuleCacheStore.getState().modules[id] ? 'ready' : 'loading',
);
const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null); const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null);
const slotRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
setActionsSlot(document.getElementById(HEADER_ACTIONS_SLOT_ID)); setActionsSlot(document.getElementById(HEADER_ACTIONS_SLOT_ID));
}, []); }, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setState({ status: 'loading' }); const hasCached = () => Boolean(useCustomModuleCacheStore.getState().modules[id]);
setStatus(hasCached() ? 'ready' : 'loading');
getCustomModule(id) getCustomModule(id)
.then((mod) => { .then((loaded) => {
if (cancelled) return; if (cancelled) return;
setState(mod ? { status: 'ready', mod } : { status: 'missing' }); if (loaded) {
remember(loaded);
setStatus('ready');
} else {
forget(id);
setStatus('missing');
}
}) })
.catch(() => { .catch(() => {
if (!cancelled) setState({ status: 'missing' }); if (!cancelled) setStatus(hasCached() ? 'ready' : 'failed');
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [id]); }, [id, remember, forget]);
if (state.status === 'loading') { const urlOk = mod ? checkCustomModuleUrl(mod.url) === 'ok' : false;
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>; const showFrame = status === 'ready' && urlOk;
}
if (state.status === 'missing') { // Platzhalter beim Behaelter anmelden; beim Verlassen wird der <iframe> nur
// versteckt. Wechselt die Adresse auf ungueltig, verschwindet der
// Platzhalter und damit auch die Anmeldung (`showFrame`).
useEffect(() => {
const slot = slotRef.current;
if (!showFrame || !slot) return;
activate(id, slot);
return () => deactivate(slot);
}, [showFrame, id, activate, deactivate]);
if (status === 'missing') {
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>; return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
} }
if (status === 'failed') {
const { mod } = state; return (
const urlOk = checkCustomModuleUrl(mod.url) === 'ok'; <div role="alert" className="p-4 text-sm text-destructive">
{t('loadError')}
</div>
);
}
if (status === 'loading' || !mod) {
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>;
}
const openLink = urlOk ? ( const openLink = urlOk ? (
<a <a
@@ -85,14 +130,10 @@ export function CustomModuleView({ id }: { id: string }) {
<div className="flex h-[calc(100vh-var(--header-height)-1.5rem)] min-h-[320px] flex-col gap-2"> <div className="flex h-[calc(100vh-var(--header-height)-1.5rem)] min-h-[320px] flex-col gap-2">
{openLink && (actionsSlot ? createPortal(openLink, actionsSlot) : openLink)} {openLink && (actionsSlot ? createPortal(openLink, actionsSlot) : openLink)}
{urlOk ? ( {urlOk ? (
<iframe <div
src={mod.url} ref={slotRef}
title={mod.name} className="w-full flex-1 rounded-lg bg-background"
sandbox={XFRAME_SANDBOX} data-testid="custom-module-slot"
allow=""
referrerPolicy="no-referrer"
className="w-full flex-1 rounded-lg border-0 bg-background"
data-testid="custom-module-frame"
/> />
) : ( ) : (
<div className="rounded-md border border-border p-4 text-sm text-muted-foreground"> <div className="rounded-md border border-border p-4 text-sm text-muted-foreground">
@@ -17,12 +17,13 @@ vi.mock('@/lib/reminders-api', async () => {
}); });
vi.mock('@/lib/reminder-notify', async () => { vi.mock('@/lib/reminder-notify', async () => {
const actual = await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify'); const actual =
await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
return { ...actual, showReminderNotification: vi.fn(async () => undefined) }; return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
}); });
import { showReminderNotification } from '@/lib/reminder-notify'; import { resetNotifiedMemoryForTests, showReminderNotification } from '@/lib/reminder-notify';
import { ReminderRequestError, listReminders } from '@/lib/reminders-api'; import { listReminders, ReminderRequestError } from '@/lib/reminders-api';
import { ReminderNotifier } from './reminder-notifier'; import { ReminderNotifier } from './reminder-notifier';
const mockList = listReminders as ReturnType<typeof vi.fn>; const mockList = listReminders as ReturnType<typeof vi.fn>;
@@ -53,6 +54,7 @@ beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(NOW); vi.setSystemTime(NOW);
window.localStorage.clear(); window.localStorage.clear();
resetNotifiedMemoryForTests();
mockList.mockReset(); mockList.mockReset();
mockShow.mockClear(); mockShow.mockClear();
}); });
@@ -77,7 +79,9 @@ describe('ReminderNotifier', () => {
}); });
it('meldet nichts, solange nichts faellig ist', async () => { it('meldet nichts, solange nichts faellig ist', async () => {
mockList.mockResolvedValue([reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() })]); mockList.mockResolvedValue([
reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() }),
]);
render(<ReminderNotifier />); render(<ReminderNotifier />);
await advance(30_000); await advance(30_000);
expect(mockShow).not.toHaveBeenCalled(); expect(mockShow).not.toHaveBeenCalled();
@@ -85,7 +89,10 @@ describe('ReminderNotifier', () => {
it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => { it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => {
mockList.mockResolvedValue([ mockList.mockResolvedValue([
reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString(), description: 'x'.repeat(300) }), reminder({
dueAt: new Date(NOW.getTime() - 1000).toISOString(),
description: 'x'.repeat(300),
}),
]); ]);
render(<ReminderNotifier />); render(<ReminderNotifier />);
await advance(0); await advance(0);
+13 -15
View File
@@ -14,9 +14,9 @@ import {
* Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld * Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld
* `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst -> * `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst ->
* `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie * `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie
* `createFavorite`/`updateFavorite` (422 -> `FavoriteRequestError` mit * `createFavorite`/`updateFavorite` (jeder Fehler bleibt eine generische
* `reason: 'iconUrlUnreachable'`, jeder andere Fehler bleibt eine generische * `Error`; die 422-Abrufprobe ist seit 260929-lh3 entfallen). Muster
* `Error` wie bisher). Muster `dashboard-images-api.test.ts`. * `dashboard-images-api.test.ts`.
*/ */
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() })); const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
@@ -112,9 +112,9 @@ describe('favorites-api (quick-260923-lrr)', () => {
expect(init.credentials).toBe('include'); expect(init.credentials).toBe('include');
}); });
describe('createFavorite / updateFavorite — Abrufprobe (260923-lrr)', () => { describe('createFavorite / updateFavorite — Fehler bleiben generisch (260929-lh3)', () => {
it('createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => { it('createFavorite: 400 (ungueltige Logo-Adresse) -> generische Error, KEIN FavoriteRequestError', async () => {
mockFetch.mockResolvedValue(new Response('{}', { status: 422 })); mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
const err = await createFavorite({ const err = await createFavorite({
widgetId: 'w1', widgetId: 'w1',
@@ -123,8 +123,8 @@ describe('favorites-api (quick-260923-lrr)', () => {
iconUrl: 'https://x.invalid/logo.png', iconUrl: 'https://x.invalid/logo.png',
}).catch((e) => e); }).catch((e) => e);
expect(err).toBeInstanceOf(FavoriteRequestError); expect(err).toBeInstanceOf(Error);
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable'); expect(err).not.toBeInstanceOf(FavoriteRequestError);
}); });
it('createFavorite: anderer Fehler -> wie bisher eine generische Error', async () => { it('createFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
@@ -138,15 +138,13 @@ describe('favorites-api (quick-260923-lrr)', () => {
expect(err).not.toBeInstanceOf(FavoriteRequestError); expect(err).not.toBeInstanceOf(FavoriteRequestError);
}); });
it('updateFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => { it('updateFavorite: 400 (ungueltige Logo-Adresse) -> generische Error, KEIN FavoriteRequestError', async () => {
mockFetch.mockResolvedValue(new Response('{}', { status: 422 })); mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch( const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch((e) => e);
(e) => e,
);
expect(err).toBeInstanceOf(FavoriteRequestError); expect(err).toBeInstanceOf(Error);
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable'); expect(err).not.toBeInstanceOf(FavoriteRequestError);
}); });
it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => { it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
+7 -11
View File
@@ -7,10 +7,9 @@
* 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung * 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
* erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion` * erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion`
* (optional, weil Testdaten und aeltere Antworten sie nicht tragen). * (optional, weil Testdaten und aeltere Antworten sie nicht tragen).
* `createFavorite`/`updateFavorite` uebersetzen ein 422 (Abrufprobe der API * `FavoriteRequestError` traegt den Grund einer abgewiesenen Symbol-Datei
* fuer eine nicht abrufbare Logo-Adresse) in `FavoriteRequestError`, damit * (Widget bildet ihn auf eine sprachrichtige Meldung ab). 260929-lh3: die
* das Widget eine sprachrichtige Meldung zeigen kann, statt der bisherigen * 422-Abrufprobe fuer Logo-Adressen ist entfallen. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
* generischen `Error`. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
* Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts). * Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts).
*/ */
@@ -34,7 +33,6 @@ export interface FavoriteLink {
/** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */ /** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */
export type FavoriteErrorReason = export type FavoriteErrorReason =
| 'iconUrlUnreachable'
| 'iconTooLarge' | 'iconTooLarge'
| 'iconInvalidType' | 'iconInvalidType'
| 'iconUploadFailed'; | 'iconUploadFailed';
@@ -72,8 +70,9 @@ export async function fetchFavorites(widgetId: string): Promise<FavoriteLink[]>
/** /**
* Create a new favorite link. * Create a new favorite link.
* Server-side icon discovery runs automatically if iconUrl is not provided. * Server-side icon discovery runs automatically if iconUrl is not provided.
* 260923-lrr: eine explizite `iconUrl`, die sich serverseitig nicht laden * 260929-lh3: eine ausdrueckliche `iconUrl` wird auch gespeichert, wenn der
* laesst, ergibt 422 -> `FavoriteRequestError('iconUrlUnreachable')`. * Server sie nicht abrufen kann (die Kachel laedt sie dann im Browser); die
* frueheren 422-Antworten gibt es nicht mehr.
*/ */
export async function createFavorite(payload: { export async function createFavorite(payload: {
widgetId: string; widgetId: string;
@@ -87,7 +86,6 @@ export async function createFavorite(payload: {
credentials: 'include', credentials: 'include',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
if (!res.ok) throw new Error('Failed to create favorite'); if (!res.ok) throw new Error('Failed to create favorite');
return res.json(); return res.json();
@@ -96,8 +94,7 @@ export async function createFavorite(payload: {
/** /**
* Update an existing favorite link. * Update an existing favorite link.
* Pass iconUrl: null to clear a stored icon. * Pass iconUrl: null to clear a stored icon.
* 260923-lrr: eine neue, nicht abrufbare `iconUrl` ergibt ebenso 422 -> * 260929-lh3: wie `createFavorite` — keine 422-Abweisung wegen Abrufbarkeit mehr.
* `FavoriteRequestError('iconUrlUnreachable')`.
*/ */
export async function updateFavorite( export async function updateFavorite(
id: string, id: string,
@@ -109,7 +106,6 @@ export async function updateFavorite(
credentials: 'include', credentials: 'include',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
if (!res.ok) throw new Error('Failed to update favorite'); if (!res.ok) throw new Error('Failed to update favorite');
return res.json(); return res.json();
+135 -9
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
GRID_CANVAS_KEY,
GRID_SCALE_FACTOR, GRID_SCALE_FACTOR,
GRID_VERSION, GRID_VERSION,
GRID_VERSION_KEY, GRID_VERSION_KEY,
@@ -36,7 +37,15 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const { layouts, migrated } = migrateGridLayouts(altLayouts()); const { layouts, migrated } = migrateGridLayouts(altLayouts());
expect(layouts.lg[0]).toEqual({ expect(layouts.lg[0]).toEqual({
i: 'a', x: 4, y: 4, w: 8, h: 6, minW: 8, minH: 4, moved: false, static: false, i: 'a',
x: 4,
y: 4,
w: 8,
h: 6,
minW: 8,
minH: 4,
moved: false,
static: false,
}); });
expect(layouts.lg[1]).toEqual({ i: 'b', x: 8, y: 0, w: 24, h: 4, maxW: 48, maxH: 16 }); expect(layouts.lg[1]).toEqual({ i: 'b', x: 8, y: 0, w: 24, h: 4, maxW: 48, maxH: 16 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 8, h: 4 }); expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 8, h: 4 });
@@ -53,7 +62,15 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 }); const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 });
expect(layouts.lg[0]).toEqual({ expect(layouts.lg[0]).toEqual({
i: 'a', x: 2, y: 2, w: 4, h: 3, minW: 4, minH: 2, moved: false, static: false, i: 'a',
x: 2,
y: 2,
w: 4,
h: 3,
minW: 4,
minH: 2,
moved: false,
static: false,
}); });
expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 2, maxW: 24, maxH: 8 }); expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 2, maxW: 24, maxH: 8 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 2 }); expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 2 });
@@ -131,6 +148,7 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const future = migrateGridLayouts({ ...alt, __gridVersion: 4 }); const future = migrateGridLayouts({ ...alt, __gridVersion: 4 });
expect(future.layouts).toEqual(alt); expect(future.layouts).toEqual(alt);
expect(future.migrated).toBe(false); expect(future.migrated).toBe(false);
expect(future.newer).toBe(true);
const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' }); const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' });
expect(stringMarker.migrated).toBe(true); expect(stringMarker.migrated).toBe(true);
@@ -138,24 +156,132 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY); expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY);
const odd = migrateGridLayouts({ const odd = migrateGridLayouts({
lg: [{ i: 'c', x: 'kaputt', y: 1, w: 2, h: 2, resizeHandles: ['se'], moved: true, static: false }], lg: [
{
i: 'c',
x: 'kaputt',
y: 1,
w: 2,
h: 2,
resizeHandles: ['se'],
moved: true,
static: false,
},
],
}); });
expect(odd.layouts.lg[0]).toEqual({ expect(odd.layouts.lg[0]).toEqual({
i: 'c', x: 'kaputt', y: 2, w: 8, h: 4, resizeHandles: ['se'], moved: true, static: false, i: 'c',
x: 'kaputt',
y: 2,
w: 8,
h: 4,
resizeHandles: ['se'],
moved: true,
static: false,
}); });
expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false); expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false);
expect(odd.migrated).toBe(true); expect(odd.migrated).toBe(true);
}); });
it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => { it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => {
const r = migrateGridLayouts({ lg: 'kaputt', md: null, sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }] }); const r = migrateGridLayouts({
lg: 'kaputt',
md: null,
sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }],
});
expect(Object.keys(r.layouts)).toEqual(['sm']); expect(Object.keys(r.layouts)).toEqual(['sm']);
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 }); expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 });
expect(r.migrated).toBe(true); expect(r.migrated).toBe(true);
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false }); expect(migrateGridLayouts(null)).toEqual({
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false }); layouts: {},
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false }); migrated: false,
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false }); newer: false,
canvas: null,
});
expect(migrateGridLayouts(undefined)).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
expect(migrateGridLayouts(42)).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
expect(migrateGridLayouts([1, 2])).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
});
it('Test 8 (quick-260930): nur eine HOEHERE Marke meldet newer — v1/v2/v3 nicht', () => {
const alt = altLayouts();
expect(migrateGridLayouts(alt).newer).toBe(false);
expect(migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: 2 }).newer).toBe(false);
expect(migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: GRID_VERSION }).newer).toBe(false);
const future = migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: GRID_VERSION + 1 });
expect(future.newer).toBe(true);
// Unveraendert uebernommen, nichts skaliert.
expect(future.layouts).toEqual(alt);
});
});
describe('grid-layout-migration: Leinwand `__canvas` (Dashboard wie ein Bild mitskalieren)', () => {
const v3 = () => ({ lg: [{ i: 'a', x: 0, y: 0, w: 8, h: 4 }], [GRID_VERSION_KEY]: GRID_VERSION });
it('liest eine gueltige Leinwand und laesst sie aus `layouts` heraus', () => {
const r = migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: { w: 1600, h: 820 } });
expect(r.canvas).toEqual({ w: 1600, h: 820 });
expect(Object.keys(r.layouts)).toEqual(['lg']);
expect(r.migrated).toBe(false);
});
it('fehlende oder ungueltige Werte -> null', () => {
const bad: unknown[] = [
undefined,
null,
'x',
[1600, 800],
{ w: 1600 },
{ w: '1600', h: 800 },
{ w: Number.NaN, h: 800 },
{ w: Number.POSITIVE_INFINITY, h: 800 },
{ w: 767, h: 800 },
{ w: 1600, h: 199 },
];
for (const value of bad) {
expect(migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: value }).canvas).toBeNull();
}
// Grenzwerte sind gueltig.
expect(migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: { w: 768, h: 200 } }).canvas).toEqual({
w: 768,
h: 200,
});
});
it('withGridVersion schreibt die Leinwand nur, wenn eine da ist', () => {
const { layouts } = migrateGridLayouts(v3());
expect(withGridVersion(layouts)).not.toHaveProperty(GRID_CANVAS_KEY);
expect(withGridVersion(layouts, null)).not.toHaveProperty(GRID_CANVAS_KEY);
expect(withGridVersion(layouts, { w: 1920, h: 950 })).toEqual({
...layouts,
[GRID_VERSION_KEY]: GRID_VERSION,
[GRID_CANVAS_KEY]: { w: 1920, h: 950 },
});
});
it('Idempotenz mit Leinwand: Laden -> Speichern -> Laden liefert dasselbe, nichts skaliert', () => {
const once = migrateGridLayouts({ ...altLayouts(), [GRID_CANVAS_KEY]: { w: 1400, h: 700 } });
expect(once.migrated).toBe(true);
expect(once.canvas).toEqual({ w: 1400, h: 700 });
const twice = migrateGridLayouts(withGridVersion(once.layouts, once.canvas));
expect(twice.migrated).toBe(false);
expect(twice.layouts).toEqual(once.layouts);
expect(twice.canvas).toEqual(once.canvas);
}); });
}); });
+65 -7
View File
@@ -34,6 +34,26 @@
* Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))` * Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))`
* liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`. * liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`.
* *
* Neuere Marke (quick-260930): Traegt das JSON eine HOEHERE Marke als
* `GRID_VERSION`, hat es ein neuerer Programmstand geschrieben (etwa ein
* frisch geladener Tab neben einem alten). Umgerechnet wird dann nichts, aber
* `newer: true` gemeldet — der Store darf diese Anordnung NIE speichern: er
* wuerde sie mit der alten Marke zurueckschreiben, und der neue Stand wuerde
* sie beim naechsten Laden ein zweites Mal skalieren.
*
* Leinwand (Bezugsgroesse, "wie ein Bild mitskalieren"): Zusaetzlich kann das
* JSON den Schluessel `__canvas: { w, h }` tragen — die verfuegbare Flaeche
* (Pixel), auf der die Anordnung eingerichtet wurde. Das Raster zeichnet
* sich auf JEDEM Desktop-Bildschirm mit genau dieser Breite und skaliert das
* Ergebnis per `transform: scale` in die dortige Flaeche (siehe
* dashboard-grid.tsx). Wie der Marker lebt `__canvas` nur im JSON, nicht in
* `layouts`: `migrateGridLayouts` liefert ihn getrennt als `canvas`,
* `withGridVersion(layouts, canvas)` schreibt ihn zurueck — JEDER
* Speicherpfad muss ihn mitgeben, sonst geht er verloren. Kein neuer
* `GRID_VERSION`: aeltere Programmstaende ueberspringen den Schluessel, weil
* sein Wert kein Array ist (`!Array.isArray(value)` unten) — und schreiben
* ihn beim Speichern nicht zurueck; dann wird er einfach neu erfasst.
*
* Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API * Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API
* das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem * das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem
* GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne * GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne
@@ -43,6 +63,17 @@
export const GRID_VERSION = 3; export const GRID_VERSION = 3;
export const GRID_VERSION_KEY = '__gridVersion'; export const GRID_VERSION_KEY = '__gridVersion';
export const GRID_SCALE_FACTOR = 2; export const GRID_SCALE_FACTOR = 2;
export const GRID_CANVAS_KEY = '__canvas';
/** Unter dieser Breite gilt die Handy-/Schmalansicht, dort wird nicht skaliert. */
export const CANVAS_MIN_WIDTH = 768;
/** Kleinere Hoehen sind Messfehler (Fenster kurz zusammengeklappt o. ae.). */
export const CANVAS_MIN_HEIGHT = 200;
/** Bezugsflaeche in Pixeln, auf der die Anordnung eingerichtet wurde. */
export interface GridCanvas {
w: number;
h: number;
}
export interface GridLayoutItem { export interface GridLayoutItem {
i: string; i: string;
@@ -67,19 +98,40 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
/**
* Prueft eine Leinwand: endliche Zahlen, w >= CANVAS_MIN_WIDTH,
* h >= CANVAS_MIN_HEIGHT. Alles andere -> null (wird dann neu erfasst).
*/
export function parseGridCanvas(value: unknown): GridCanvas | null {
if (!isPlainObject(value)) return null;
const { w, h } = value;
if (typeof w !== 'number' || typeof h !== 'number') return null;
if (!Number.isFinite(w) || !Number.isFinite(h)) return null;
if (w < CANVAS_MIN_WIDTH || h < CANVAS_MIN_HEIGHT) return null;
return { w, h };
}
/** /**
* Rechnet eine rohe (aus der API geladene) Anordnung in die aktuellen * Rechnet eine rohe (aus der API geladene) Anordnung in die aktuellen
* Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob * Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob
* etwas verdoppelt wurde (dann muss der Aufrufer sofort mit Marker speichern). * etwas verdoppelt wurde (dann muss der Aufrufer sofort mit Marker speichern).
*/ */
export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrated: boolean } { export function migrateGridLayouts(raw: unknown): {
layouts: GridLayouts;
migrated: boolean;
/** Marke hoeher als `GRID_VERSION` — Anordnung stammt von einem neueren Programmstand. */
newer: boolean;
/** Gueltige Leinwand aus `__canvas`, sonst null. */
canvas: GridCanvas | null;
} {
if (!isPlainObject(raw)) { if (!isPlainObject(raw)) {
return { layouts: {}, migrated: false }; return { layouts: {}, migrated: false, newer: false, canvas: null };
} }
const markerValue = raw[GRID_VERSION_KEY]; const markerValue = raw[GRID_VERSION_KEY];
// Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt. // Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt.
const version = typeof markerValue === 'number' ? markerValue : 1; const version = typeof markerValue === 'number' ? markerValue : 1;
const newer = version > GRID_VERSION;
const steps = MIGRATION_STEPS.filter((step) => version <= step.from); const steps = MIGRATION_STEPS.filter((step) => version <= step.from);
const needsScaling = steps.length > 0; const needsScaling = steps.length > 0;
@@ -87,7 +139,7 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat
let migrated = false; let migrated = false;
for (const key of Object.keys(raw)) { for (const key of Object.keys(raw)) {
if (key === GRID_VERSION_KEY) continue; if (key === GRID_VERSION_KEY || key === GRID_CANVAS_KEY) continue;
const value = raw[key]; const value = raw[key];
if (!Array.isArray(value)) continue; if (!Array.isArray(value)) continue;
@@ -108,12 +160,18 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat
}); });
} }
return { layouts, migrated }; return { layouts, migrated, newer, canvas: parseGridCanvas(raw[GRID_CANVAS_KEY]) };
} }
/** /**
* Haengt den Marker fuer das Speichern an, ohne die Eingabe zu veraendern. * Haengt den Marker (und, falls vorhanden, die Leinwand) fuer das Speichern
* an, ohne die Eingabe zu veraendern.
*/ */
export function withGridVersion(layouts: GridLayouts): Record<string, unknown> { export function withGridVersion(
return { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION }; layouts: GridLayouts,
canvas?: GridCanvas | null,
): Record<string, unknown> {
const result: Record<string, unknown> = { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
if (canvas) result[GRID_CANVAS_KEY] = { w: canvas.w, h: canvas.h };
return result;
} }
+27 -1
View File
@@ -5,6 +5,7 @@ import {
isTauriWebview, isTauriWebview,
remindersToNotify, remindersToNotify,
requestBrowserPermissionOnce, requestBrowserPermissionOnce,
resetNotifiedMemoryForTests,
showReminderNotification, showReminderNotification,
withNotifyLock, withNotifyLock,
} from './reminder-notify'; } from './reminder-notify';
@@ -34,6 +35,7 @@ function installNotification(permission: NotificationPermission) {
beforeEach(() => { beforeEach(() => {
window.localStorage.clear(); window.localStorage.clear();
resetNotifiedMemoryForTests();
}); });
afterEach(() => { afterEach(() => {
@@ -65,6 +67,28 @@ describe('claimNotification', () => {
const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}'); const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}');
expect(Object.keys(stored)).toEqual(['neu']); expect(Object.keys(stored)).toEqual(['neu']);
}); });
it('ohne nutzbaren localStorage (wirft beim Lesen und Schreiben) meldet es trotzdem nur einmal', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('SecurityError');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 10_000)).toBe(false);
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 3600_000)).toBe(false);
// ein neuer Schluessel (verschoben) wird weiterhin gemeldet
expect(claimNotification('r1|2026-09-29T12:10:00.000Z', NOW)).toBe(true);
});
it('ohne window.localStorage (Zugriff wirft) meldet es trotzdem nur einmal', () => {
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
throw new Error('SecurityError');
});
expect(claimNotification('k', NOW)).toBe(true);
expect(claimNotification('k', NOW + 10_000)).toBe(false);
});
}); });
describe('remindersToNotify', () => { describe('remindersToNotify', () => {
@@ -93,7 +117,9 @@ describe('showReminderNotification', () => {
const invoke = installTauri(); const invoke = installTauri();
invoke.mockRejectedValue(new Error('nope')); invoke.mockRejectedValue(new Error('nope'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(showReminderNotification({ title: 'T', body: 'B', tag: 'x' })).resolves.toBeUndefined(); await expect(
showReminderNotification({ title: 'T', body: 'B', tag: 'x' }),
).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledTimes(1);
}); });

Some files were not shown because too many files have changed in this diff Show More