20 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
81 changed files with 6718 additions and 650 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)."
}
+5 -2
View File
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
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."
last_updated: "2026-09-23T15:30:00.000Z"
last_activity: 2026-09-30
last_activity: 2026-10-01
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
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)
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
Last activity: 2026-09-30 - Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. mit 4 Fix-Commits (be1e003, c2e4467, 0710829, 12214a9)
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%
@@ -483,6 +483,9 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 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-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
@@ -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).
+31
View File
@@ -4,12 +4,43 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## 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
- 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
- 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.
+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/packages/shared/src ./packages/shared/src
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 +
# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur
# 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
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
dashboardBackground Json?
// Willkommensmail aus der Benutzerverwaltung: Zeitpunkt des letzten
// Versands; null = nie gesendet
welcomeMailSentAt DateTime?
passwordResetTokens PasswordResetToken[]
groupMemberships GroupMembership[]
moduleGrants ModuleGrant[]
@@ -764,3 +767,24 @@ model Reminder {
@@index([tenantId, userId, 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 { PrismaService } from '../prisma/prisma.service';
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';
/**
@@ -239,7 +240,7 @@ export class AuthService {
// Generate a unique reset token
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
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
@@ -1,9 +1,13 @@
import { ForbiddenException } from '@nestjs/common';
import { of } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { JwtStrategy } from '../strategies/jwt.strategy';
import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor';
vi.mock('../../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
/**
* ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste
* 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', () => {
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({
sub: 'u1',
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';
vi.mock('../../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
/**
* JwtStrategy.validate — pinnt die Durchreichung von mustChangePassword
* (260921-fi3, Aufgabe 1, Befund 1/D-01). Direkte Konstruktion ohne
* JwtStrategy.validate — seit quick-260930 kommen Rolle, Aktiv-Status und
* 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`.
*/
@@ -11,51 +18,77 @@ function makeConfigService() {
return { get: () => 'test-secret' } as any;
}
describe('JwtStrategy.validate', () => {
it('Anspruch mustChangePassword=true im Token: liefert request.user.mustChangePassword === true', async () => {
const strategy = new JwtStrategy(makeConfigService());
type Row = {
id: string;
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',
username: 'admin',
role: 'ADMIN',
username: 'kschaller',
role: 'SUPER_ADMIN' as const,
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,
});
};
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({
id: 'u1',
username: 'nutzer1',
role: 'USER',
tenantId: 't2',
username: 'kschaller',
role: 'ADMIN',
tenantId: 't1',
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 { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
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';
/**
@@ -17,7 +19,10 @@ function cookieExtractor(req: Request): string | null {
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
constructor(
configService: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: cookieExtractor,
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> {
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 {
id: payload.sub,
username: payload.username,
role: payload.role,
tenantId: payload.tenantId,
// Ein vor dieser Aenderung ausgestelltes Token traegt diesen Anspruch
// nicht; der strenge Vergleich ergibt dann false, laufende Sitzungen
// verhalten sich unveraendert (260921-fi3, D-01 — keine Aussperrwelle).
mustChangePassword: payload.mustChangePassword === true,
id: user.id,
username: user.username,
role: user.role as AuthUser['role'],
tenantId: user.tenantId,
mustChangePassword: user.mustChangePassword === true,
};
}
}
@@ -220,7 +220,11 @@ function expectBoundCall(
}
function makeIconDiscovery(
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {},
overrides: Partial<{
discoverFavoriteIconUrl: any;
fetchIconBytes: any;
fetchPublicServiceIconBytes: any;
}> = {},
) {
return {
discoverFavoriteIconUrl:
@@ -229,6 +233,11 @@ function makeIconDiscovery(
fetchIconBytes:
overrides.fetchIconBytes ??
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
fetchPublicServiceIconBytes:
overrides.fetchPublicServiceIconBytes ??
vi.fn(async () => {
throw new Error('icon service: unknown');
}),
};
}
@@ -511,6 +520,35 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
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 () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery({
+10 -1
View File
@@ -497,7 +497,9 @@ export class FavoritesService {
* 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.
* 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(
tenantId: string,
@@ -535,8 +537,15 @@ export class FavoritesService {
try {
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 {
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
}
}
}
}
@@ -238,6 +238,57 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
});
});
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(
service.fetchPublicServiceIconBytes('https://docuvita.ctl.local/x'),
).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/,
);
});
});
describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -25,6 +25,18 @@ const MAX_REDIRECTS = 2;
const MAX_HTML_CHARS = 200000;
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,
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
@@ -426,6 +438,22 @@ export class IconDiscoveryService {
}
}
/**
* 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
* back to the browser from Tessera's own origin (avoids Cross-Origin-
+50 -2
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
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
@@ -166,7 +166,11 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
const service = new MailService(settings as any, makeFakeConfig({}) as any);
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']);
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);
});
});
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 { ConfigService } from '@nestjs/config';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as nodemailer from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
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
* `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt
* Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer
* Kennwort-Reset und Willkommensmail (T-02-12 unveraendert); `sendBugReport`
* ruft den Kern direkt, damit der Anwender erfaehrt, ob sein Bericht ankam.
* den Kennwort-Reset (T-02-12 unveraendert); `sendBugReport` und
* `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`). */
export interface OutgoingAttachment {
filename: string;
content: Buffer;
contentType: string;
/** Inhaltskennung fuer eingebettete Bilder (`<img src="cid:...">`). */
cid?: string;
}
/** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */
@@ -186,10 +216,10 @@ export class MailService {
}
/**
* Verschluckender Mantel um `deliver` fuer Kennwort-Reset und
* Willkommensmail: Fehler werden protokolliert, nie geworfen — der
* Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration (T-02-12
* bleibt fuer genau diese beiden Wege bestehen).
* Verschluckender Mantel um `deliver` fuer den Kennwort-Reset: Fehler
* werden protokolliert, nie geworfen — der Anmeldeweg antwortet weiter
* 200, keine E-Mail-Enumeration (T-02-12). Die Willkommensmail benutzt
* ihn seit dem Versand aus der Benutzerverwaltung nicht mehr.
*/
private async sendViaTenantTransport(
tenantId: string,
@@ -275,46 +305,59 @@ export class MailService {
}
/**
* Send a welcome email to a newly created user (optional — derzeit ohne
* Aufrufer, gemessen 260914-eym; bleibt als Pfad ueber denselben
* Transport je Versand erhalten).
* Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer,
* "Willkommensmail senden"). Inhalt und HTML baut
* `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(
email: string,
username: string,
async sendWelcomeMail(
tenantId: string,
locale: string = 'de',
to: string,
mail: { subject: string; text: string; html: string },
): Promise<void> {
const isGerman = locale === 'de';
const subject = isGerman
? 'Willkommen bei Tessera'
: 'Welcome to Tessera';
const text = isGerman
const header = loadWelcomeHeaderPng();
await this.deliver(
tenantId,
{
to,
...mail,
attachments: header
? [
`Hallo ${username},`,
'',
'Ihr Tessera-Account wurde erstellt.',
'',
`Sie können sich unter ${this.appUrl}/login anmelden.`,
'',
'Mit freundlichen Grüßen,',
'Ihr Tessera-Team',
].join('\n')
: [
`Hello ${username},`,
'',
'Your Tessera account has been created.',
'',
`You can sign in at ${this.appUrl}/login.`,
'',
'Best regards,',
'The Tessera Team',
].join('\n');
{
filename: 'tessera.png',
content: header,
contentType: 'image/png',
cid: WELCOME_HEADER_CID,
},
]
: undefined,
},
'Welcome',
);
}
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 };
}
@@ -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 { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
@@ -124,11 +124,13 @@ describe('UserController', () => {
let prisma: any;
let userService: any;
let controller: UserController;
let welcomeMail: { isAvailable: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn> };
beforeEach(() => {
prisma = makeFakePrisma();
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', () => {
@@ -663,4 +665,80 @@ describe('UserController', () => {
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,
Get,
HttpCode,
Headers,
HttpStatus,
NotFoundException,
Param,
@@ -39,6 +40,7 @@ import { CreateUserDto } from './dto/create-user.dto';
import { ReleaseSeenDto } from './dto/release-seen.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserService } from './user.service';
import { WelcomeMailService } from './welcome-mail.service';
/** Map accepted MIME types to file extensions (T-gbh-01). */
const AVATAR_MIME_TO_EXT: Record<string, string> = {
@@ -68,6 +70,10 @@ function resolveAvatarsDir(): string {
* quick-260928-ujj: dazu kommt ein gebundener Zugriff des
* Selbstbedienungswegs `PATCH me/dashboard-background`, ebenfalls
* `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')
@UseGuards(RolesGuard)
@@ -75,6 +81,7 @@ export class UserController {
constructor(
private readonly userService: UserService,
private readonly prisma: PrismaService,
private readonly welcomeMailService: WelcomeMailService,
) {}
/**
@@ -126,6 +133,7 @@ export class UserController {
tenantId: true,
createdAt: true,
lastLoginAt: true,
welcomeMailSentAt: true,
},
orderBy: { username: 'asc' },
});
@@ -218,6 +226,23 @@ export class UserController {
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
*/
@@ -330,6 +355,47 @@ export class UserController {
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
* ADMIN cannot delete self or users from other tenants.
+17 -3
View File
@@ -1,19 +1,33 @@
import { Module } from '@nestjs/common';
import { GroupsModule } from '../groups/groups.module';
import { MailModule } from '../mail/mail.module';
import { AdminSeedService } from './admin-seed.service';
import { UserController } from './user.controller';
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-
* Mitgliedschaft (D-11/D-12). GroupsModule importiert seinerseits
* UserModule nicht — GroupsService greift für Benutzerprüfungen direkt
* 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({
imports: [GroupsModule],
controllers: [UserController],
providers: [UserService, AdminSeedService],
imports: [GroupsModule, MailModule],
controllers: [UserController, WelcomeMailTemplateController],
providers: [UserService, AdminSeedService, WelcomeMailService, WelcomeMailTemplateService],
exports: [UserService],
})
export class UserModule {}
+1
View File
@@ -37,6 +37,7 @@ const PLATFORM_USER_SELECT = {
tenantId: true,
createdAt: true,
lastLoginAt: true,
welcomeMailSentAt: true,
} 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 };
}
}
+262 -58
View File
@@ -1,7 +1,7 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
import { UserAccessModal } from './components/UserAccessModal';
@@ -19,6 +19,10 @@ interface User {
isActive: boolean;
tenantId: 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 {
@@ -57,6 +61,8 @@ export default function AdminUsersPage() {
const t = useTranslations('admin.users');
const tCommon = useTranslations('common');
const tHeader = useTranslations('header');
const tWelcome = useTranslations('admin.users.welcomeMail');
const locale = useLocale();
const currentUser = useAuthStore((s) => s.user);
const [users, setUsers] = useState<User[]>([]);
@@ -68,6 +74,13 @@ export default function AdminUsersPage() {
const [formError, setFormError] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | 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>({
username: '',
email: '',
@@ -77,8 +90,7 @@ export default function AdminUsersPage() {
});
// Access check: only ADMIN and SUPER_ADMIN
const hasAccess =
currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const fetchUsers = useCallback(async () => {
setLoadError(null);
@@ -106,6 +118,73 @@ export default function AdminUsersPage() {
}
}, [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 = () => {
setEditingUser(null);
setFormData({
@@ -136,9 +215,7 @@ export default function AdminUsersPage() {
e.preventDefault();
setFormError(null);
const url = editingUser
? `${API_URL}/users/${editingUser.id}`
: `${API_URL}/users`;
const url = editingUser ? `${API_URL}/users/${editingUser.id}` : `${API_URL}/users`;
const method = editingUser ? 'PATCH' : 'POST';
const body: Record<string, unknown> = {
@@ -166,9 +243,7 @@ export default function AdminUsersPage() {
fetchUsers();
} else {
const detail = await readApiMessage(res);
setFormError(
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
);
setFormError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
}
} catch {
setFormError(t('errors.network'));
@@ -187,9 +262,7 @@ export default function AdminUsersPage() {
fetchUsers();
} else {
const detail = await readApiMessage(res);
setDeleteError(
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
);
setDeleteError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
}
} catch {
setDeleteError(t('errors.network'));
@@ -212,6 +285,13 @@ export default function AdminUsersPage() {
const canManageRow = (user: User) =>
!(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) => {
switch (role) {
case 'SUPER_ADMIN':
@@ -228,11 +308,7 @@ export default function AdminUsersPage() {
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="btn btn-primary"
>
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
</div>
@@ -246,6 +322,23 @@ export default function AdminUsersPage() {
</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 */}
{loading ? (
<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">
{t('status')}
</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">
{t('actions')}
</th>
@@ -279,15 +375,9 @@ export default function AdminUsersPage() {
<tbody className="divide-y divide-border">
{users.map((user) => (
<tr key={user.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium text-foreground">
{user.username}
</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 font-medium text-foreground">{user.username}</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">
<span
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')}
</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 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
type="button"
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>
{canManageRow(user) && (
<button
type="button"
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>
)}
{canManageRow(user) && (
@@ -332,9 +451,11 @@ export default function AdminUsersPage() {
setDeleteError(null);
}}
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>
)}
</div>
@@ -363,9 +484,7 @@ export default function AdminUsersPage() {
type="text"
required
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
onChange={(e) => 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"
/>
</div>
@@ -378,9 +497,7 @@ export default function AdminUsersPage() {
type="email"
required
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
onChange={(e) => 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"
/>
</div>
@@ -388,9 +505,7 @@ export default function AdminUsersPage() {
<label htmlFor="user-password" className="text-sm font-medium text-foreground">
{t('password')}
{editingUser && (
<span className="ml-1 text-xs text-muted-foreground">
(optional)
</span>
<span className="ml-1 text-xs text-muted-foreground">(optional)</span>
)}
</label>
<input
@@ -399,9 +514,7 @@ export default function AdminUsersPage() {
required={!editingUser}
minLength={8}
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
onChange={(e) => 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"
/>
</div>
@@ -413,9 +526,7 @@ export default function AdminUsersPage() {
id="user-display-name"
type="text"
value={formData.displayName}
onChange={(e) =>
setFormData({ ...formData, displayName: e.target.value })
}
onChange={(e) => 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"
/>
</div>
@@ -437,9 +548,7 @@ export default function AdminUsersPage() {
<option value="USER">{tHeader('role.USER')}</option>
<option value="ADMIN">{tHeader('role.ADMIN')}</option>
{currentUser?.role === 'SUPER_ADMIN' && (
<option value="SUPER_ADMIN">
{tHeader('role.SUPER_ADMIN')}
</option>
<option value="SUPER_ADMIN">{tHeader('role.SUPER_ADMIN')}</option>
)}
</select>
</div>
@@ -459,10 +568,7 @@ export default function AdminUsersPage() {
>
{tCommon('cancel')}
</button>
<button
type="submit"
className="btn btn-primary"
>
<button type="submit" className="btn btn-primary">
{tCommon('save')}
</button>
</div>
@@ -475,9 +581,7 @@ export default function AdminUsersPage() {
{deleteConfirm && (
<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">
<p className="text-sm text-foreground mb-4">
{t('deleteConfirm')}
</p>
<p className="text-sm text-foreground mb-4">{t('deleteConfirm')}</p>
{deleteError && (
<div
role="alert"
@@ -506,6 +610,57 @@ export default function AdminUsersPage() {
</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 */}
{detailsUser && (
<UserAccessModal
@@ -517,3 +672,52 @@ export default function AdminUsersPage() {
</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',
role: 'Rolle',
status: 'Status',
lastLogin: 'Letzte Anmeldung',
neverLoggedIn: 'Noch nie',
actions: 'Aktionen',
password: 'Passwort',
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.',
},
},
'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: {
role: {
SUPER_ADMIN: 'Super-Admin',
@@ -47,6 +63,7 @@ const messages: Record<string, Record<string, unknown>> = {
accessDenied: 'Zugriff verweigert',
active: 'Aktiv',
inactive: 'Inaktiv',
close: 'Schließen',
},
};
@@ -69,6 +86,7 @@ function resolve(ns: string, key: string, params?: Record<string, unknown>): str
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) =>
resolve(ns, key, params),
useLocale: () => 'de',
}));
const mockAuthStore = vi.fn();
@@ -93,6 +111,8 @@ interface User {
isActive: boolean;
tenantId: string;
createdAt: string;
lastLoginAt?: string | null;
welcomeMailSentAt?: string | null;
}
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;
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('Löschen')).not.toBeInTheDocument();
const normalRow = screen.getByText('user.zwei').closest('tr') as HTMLElement;
expect(within(normalRow).getByText('Bearbeiten')).toBeInTheDocument();
expect(within(normalRow).getByText('Löschen')).toBeInTheDocument();
expect(within(normalRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
expect(within(normalRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
});
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;
expect(within(superAdminRow).getByText('Bearbeiten')).toBeInTheDocument();
expect(within(superAdminRow).getByText('Löschen')).toBeInTheDocument();
expect(within(superAdminRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
expect(within(superAdminRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
});
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 ownDeleteButton = within(ownRow).getByText('Löschen');
const ownDeleteButton = within(ownRow).getByRole('button', { name: 'Löschen' });
expect(ownDeleteButton).toBeInTheDocument();
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';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
import {
BackgroundPicker,
DashboardBackdrop,
useDashboardBackground,
} from '@/components/dashboard/dashboard-background';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
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 type { WidgetType } 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 { 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 { NoteWidget } from '@/components/dashboard/widgets/note-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 { 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 type { WidgetType } from '@/components/dashboard/widget-registry';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -71,6 +75,7 @@ export default function DashboardPage() {
activeDashboardId,
isSwitchingDashboard,
layouts,
canvas,
widgets,
isEditMode,
isLoading,
@@ -85,6 +90,7 @@ export default function DashboardPage() {
renameDashboard,
deleteDashboard,
reorderDashboards,
captureCanvas,
} = useDashboardStore();
// Load dashboard data on mount
@@ -200,6 +206,8 @@ export default function DashboardPage() {
onRemoveWidget={removeWidget}
onOpenCatalog={() => setCatalogOpen(true)}
onQuickAdd={(type) => addWidget(type)}
canvas={canvas}
onCaptureCanvas={captureCanvas}
/>
)}
+3 -5
View File
@@ -3,6 +3,7 @@ import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import { ThemeProvider } from 'next-themes';
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
import { DesktopExternalLinks } from '@/components/desktop/desktop-external-links';
import './globals.css';
export const metadata: Metadata = {
@@ -10,11 +11,7 @@ export const metadata: Metadata = {
description: 'Modulare Workflow-Plattform',
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getLocale();
const messages = await getMessages();
@@ -29,6 +26,7 @@ export default async function RootLayout({
>
<NextIntlClientProvider messages={messages}>
<DesktopContextMenuGuard />
<DesktopExternalLinks />
{children}
</NextIntlClientProvider>
</ThemeProvider>
@@ -83,6 +83,18 @@ export function AdminSidebar() {
</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'),
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>
);
}
@@ -10,6 +10,7 @@ import {
createCustomModule,
updateCustomModule,
} from '@/lib/custom-modules-api';
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
import { useCategoryLabel } from '@/lib/use-category-label';
interface CustomModuleFormModalProps {
@@ -96,6 +97,10 @@ export function CustomModuleFormModal({
const input = { name: trimmedName, url: trimmedUrl, category };
if (mod) {
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 {
await createCustomModule(shared ? { ...input, shared: true } : input);
}
@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl';
import { useState } from 'react';
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 {
@@ -36,6 +37,8 @@ export function DeleteCustomModuleDialog({
setError(null);
try {
await deleteCustomModule(mod.id);
// Offen gehaltenen <iframe> und Sitzungsspeicher des Moduls verwerfen.
useCustomModuleCacheStore.getState().forget(mod.id);
onDeleted();
onClose();
} catch (err) {
@@ -1,8 +1,15 @@
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 { 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
vi.mock('react-grid-layout/css/styles.css', () => ({}));
@@ -652,3 +659,166 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
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();
});
});
@@ -3,10 +3,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import { noCompactor, Responsive } from 'react-grid-layout';
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core';
import {
defaultConstraints,
defaultPositionStrategy,
type LayoutConstraint,
type PositionStrategy,
transformStrategy,
} from 'react-grid-layout/core';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl';
import { CANVAS_MIN_HEIGHT, CANVAS_MIN_WIDTH, type GridCanvas } from '@/lib/grid-layout-migration';
import {
clampWidthToCols,
GRID_COLS,
@@ -101,6 +108,76 @@ const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS
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 {
i: string;
x: number;
@@ -121,6 +198,10 @@ interface DashboardGridProps {
onOpenCatalog?: () => void;
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
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. */
@@ -224,9 +305,15 @@ export function DashboardGrid({
onRemoveWidget,
onOpenCatalog,
onQuickAdd,
canvas = null,
onCaptureCanvas,
}: DashboardGridProps) {
const t = useTranslations('widgets');
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),
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
@@ -265,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(
(node: HTMLDivElement | null) => {
// Ein eventuell laufender Beobachter zuerst trennen — auch der
@@ -277,27 +374,76 @@ export function DashboardGrid({
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
applyWidth(node.getBoundingClientRect().width);
applyHeight(node);
const observer = new ResizeObserver((entries) => {
applyWidth(entries[0].contentRect.width);
applyHeight(node);
});
observer.observe(node);
observerRef.current = observer;
// Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
// Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
},
[applyWidth],
[applyWidth, applyHeight],
);
useEffect(() => {
const onResize = () => {
if (nodeRef.current) {
applyWidth(nodeRef.current.getBoundingClientRect().width);
applyHeight(nodeRef.current);
}
};
window.addEventListener('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 —
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
@@ -374,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 (
<div ref={measureRef}>
<div ref={measureRef} style={outerStyle} data-canvas-scale={scaled ? scale : undefined}>
<div ref={innerRef} style={innerStyle}>
<Responsive
width={width}
width={scaled ? canvas.w : width}
positionStrategy={positionStrategy}
breakpoints={BREAKPOINTS}
cols={COLS}
layouts={effectiveLayouts as ResponsiveLayouts}
@@ -425,5 +586,6 @@ export function DashboardGrid({
})}
</Responsive>
</div>
</div>
);
}
@@ -37,20 +37,20 @@ vi.mock('@/lib/dashboard-api', () => ({
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
}));
import { updateWidgetConfig } from '@/lib/dashboard-api';
import {
createFavorite,
deleteFavorite,
FavoriteRequestError,
fetchFavorites,
createFavorite,
updateFavorite,
deleteFavorite,
reorderFavorites,
uploadFavoriteIcon,
removeFavoriteIcon,
reorderFavorites,
updateFavorite,
uploadFavoriteIcon,
} from '@/lib/favorites-api';
import { updateWidgetConfig } from '@/lib/dashboard-api';
// Must import AFTER mocks
import { FavoritesWidget } from './favorites-widget';
import { FavoritesWidget, LauncherLabel } from './favorites-widget';
// Typed mock helpers
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 () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
expect(mockFetch).toHaveBeenCalledWith('fav-1');
@@ -127,13 +121,7 @@ describe('FavoritesWidget', () => {
it('shows empty message when fetchFavorites resolves to empty array', async () => {
mockFetch.mockResolvedValue([]);
render(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
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 () => {
mockFetch.mockResolvedValue([]);
render(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
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 () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -239,13 +215,7 @@ describe('FavoritesWidget', () => {
});
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -271,13 +241,7 @@ describe('FavoritesWidget', () => {
});
it('view toggle: switching to grid in edit mode renders grid container', async () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{ viewMode: 'list' }}
isEditMode={true}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{ viewMode: 'list' }} isEditMode={true} />);
await waitFor(() => {
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 () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('Notion')).toBeInTheDocument();
@@ -318,24 +276,16 @@ describe('FavoritesWidget', () => {
// quick-260916-iex: optionaler Titel
describe('optionaler Titel (quick-260916-iex)', () => {
it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => {
const r1 = render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
const r1 = render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect(
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
r1.unmount();
const r2 = render(
<FavoritesWidget
instanceId="fav-1"
config={{ title: ' ' }}
isEditMode={false}
/>,
<FavoritesWidget instanceId="fav-1" config={{ title: ' ' }} isEditMode={false} />,
);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -343,13 +293,7 @@ describe('FavoritesWidget', () => {
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
r2.unmount();
render(
<FavoritesWidget
instanceId="fav-1"
config={{ title: 42 }}
isEditMode={false}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{ title: 42 }} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
@@ -358,11 +302,7 @@ describe('FavoritesWidget', () => {
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{ title: 'Werkzeuge' }}
isEditMode={false}
/>,
<FavoritesWidget instanceId="fav-1" config={{ title: 'Werkzeuge' }} isEditMode={false} />,
);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -371,15 +311,11 @@ describe('FavoritesWidget', () => {
expect(heading.tagName).toBe('H2');
expect(heading.className).toContain('text-sm');
expect(heading.className).toContain('font-semibold');
expect(
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
});
it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => {
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
@@ -391,25 +327,17 @@ describe('FavoritesWidget', () => {
expect(titleField.className).toContain('widgetNoDrag');
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: /favorites\.listView/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /favorites\.gridView/i }),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /favorites\.listView/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /favorites\.gridView/i })).toBeInTheDocument();
});
it('entprellt das Speichern des Titels um 1500 ms', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try {
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await act(async () => {});
const titleField = screen.getByPlaceholderText(
'favorites.titlePlaceholder',
);
const titleField = screen.getByPlaceholderText('favorites.titlePlaceholder');
for (const value of ['W', 'We', 'Werkzeuge']) {
await act(async () => {
@@ -444,9 +372,7 @@ describe('FavoritesWidget', () => {
// Sortierpfeile im Bearbeitungsmodus.
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('Notion')).toBeInTheDocument();
@@ -460,9 +386,7 @@ describe('FavoritesWidget', () => {
});
it('Kette Proxy -> direkt -> Buchstabe', async () => {
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -561,12 +485,17 @@ describe('FavoritesWidget', () => {
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 },
{
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} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('Ablage')).toBeInTheDocument();
@@ -583,9 +512,7 @@ describe('FavoritesWidget', () => {
{ ...BASE_FAVORITES[0], position: 1 },
]);
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -618,9 +545,7 @@ describe('FavoritesWidget', () => {
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
mockReorder.mockRejectedValue(new Error('boom'));
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -656,9 +581,7 @@ describe('FavoritesWidget', () => {
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
rerender(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
rerender(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
});
@@ -761,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 });
mockUploadIcon.mockResolvedValue({
...BASE_FAVORITES[0],
@@ -789,7 +712,10 @@ describe('FavoritesWidget', () => {
});
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' }));
expect(mockUpdate).toHaveBeenCalledWith(
'fav-id-1',
expect.objectContaining({ title: 'GitHub' }),
);
});
await waitFor(() => {
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
@@ -857,12 +783,16 @@ describe('FavoritesWidget', () => {
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([
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 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} />);
@@ -881,7 +811,9 @@ describe('FavoritesWidget', () => {
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
});
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();
});
@@ -895,7 +827,9 @@ describe('FavoritesWidget', () => {
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 () => {
@@ -935,7 +869,11 @@ describe('FavoritesWidget', () => {
await waitFor(() => {
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(() => {
@@ -947,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';
import { WidgetIcon } from './widget-icon';
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
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 {
FavoriteRequestError,
createFavorite,
deleteFavorite,
type FavoriteLink,
FavoriteRequestError,
fetchFavorites,
removeFavoriteIcon,
reorderFavorites,
updateFavorite,
uploadFavoriteIcon,
type FavoriteLink,
} 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
const TITLE_DEBOUNCE_MS = 1500;
@@ -51,11 +51,7 @@ function favoriteErrorKey(err: unknown): string {
* - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch,
* persistiert ueber PUT /favorites/order
*/
export function FavoritesWidget({
instanceId,
config,
isEditMode,
}: WidgetProps) {
export function FavoritesWidget({ instanceId, config, isEditMode }: WidgetProps) {
const t = useTranslations('widgets');
const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
@@ -68,12 +64,8 @@ export function FavoritesWidget({
);
// Optionaler Titel (quick-260916-iex)
const [title, setTitle] = useState<string>(
typeof config.title === 'string' ? config.title : '',
);
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const [title, setTitle] = useState<string>(typeof config.title === 'string' ? config.title : '');
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
return () => clearTimeout(titleTimerRef.current);
@@ -335,15 +327,15 @@ export function FavoritesWidget({
/>
) : (
hasTitle && (
<h2 className="truncate text-sm font-semibold text-foreground">
{title.trim()}
</h2>
<h2 className="truncate text-sm font-semibold text-foreground">{title.trim()}</h2>
)
)}
</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) */}
{isEditMode && (
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
@@ -382,23 +374,20 @@ export function FavoritesWidget({
)}
{/* Status messages */}
{loading && (
<p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>
)}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{loading && <p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
{/* Favorites list / grid */}
{!loading && sortedFavorites.length === 0 && (
<p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
)}
{!loading && sortedFavorites.length > 0 && (
viewMode === 'grid' ? (
{!loading &&
sortedFavorites.length > 0 &&
(viewMode === 'grid' ? (
<div
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) => (
<FavoriteTile
@@ -430,10 +419,7 @@ export function FavoritesWidget({
))}
</div>
) : (
<div
data-testid="favorites-list"
className="flex flex-col gap-0.5"
>
<div data-testid="favorites-list" className="flex flex-col gap-0.5">
{sortedFavorites.map((fav, index) => (
<FavoriteTile
key={fav.id}
@@ -462,8 +448,7 @@ export function FavoritesWidget({
/>
))}
</div>
)
)}
))}
{/* Add form (edit mode only, D-04) */}
{isEditMode && (
@@ -677,6 +662,60 @@ type FavoriteTileProps = {
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({
fav,
isEditMode,
@@ -728,9 +767,11 @@ function FavoriteTile({
large={launcher}
/>
<span className={launcher ? 'line-clamp-2 w-full break-words text-xs leading-4' : 'truncate text-sm'}>
{fav.title}
</span>
{launcher ? (
<LauncherLabel text={fav.title} />
) : (
<span className="truncate text-sm">{fav.title}</span>
)}
</a>
{/* Action buttons (edit mode only, D-04) */}
@@ -65,14 +65,23 @@ export function ReminderFormModal({
const [error, setError] = useState<string | null>(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(() => {
titleRef.current?.focus();
}, []);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Escape') onCloseRef.current();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -349,6 +349,32 @@ 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();
@@ -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';
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 { Sidebar } from '@/components/layout/sidebar';
import { CustomModuleFrameHost } from '@/components/modules/custom-module-frame-host';
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
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 }) {
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
@@ -24,9 +25,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
// Desktop: sidebar pushes content via margin-left
// Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css)
const sidebarWidth = isCollapsed
? 'var(--sidebar-width-collapsed)'
: 'var(--sidebar-width)';
const sidebarWidth = isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)';
return (
<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. */}
<main
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}
</main>
@@ -46,6 +47,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die
Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */}
<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>
);
}
+28 -27
View File
@@ -1,18 +1,19 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
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 { 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 { useSidebarStore } from '@/lib/stores/sidebar-store';
export function Header() {
const t = useTranslations();
@@ -60,13 +61,8 @@ export function Header() {
return;
}
redirectedRef.current = true;
const next = buildNextParam(
window.location.pathname,
window.location.search,
);
window.location.href = next
? `/login?next=${encodeURIComponent(next)}`
: '/login';
const next = buildNextParam(window.location.pathname, window.location.search);
window.location.href = next ? `/login?next=${encodeURIComponent(next)}` : '/login';
}
// 'unavailable' (T-gyd-02): kein Redirect, bisheriges stilles
// Verhalten -- ein API-Ausfall darf keine Abmelde-Schleife ausloesen.
@@ -76,10 +72,7 @@ export function Header() {
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
}
@@ -91,9 +84,7 @@ export function Header() {
const [avatarError, setAvatarError] = useState(false);
const userInitial = user
? (user.displayName ?? user.username).charAt(0).toUpperCase()
: '?';
const userInitial = user ? (user.displayName ?? user.username).charAt(0).toUpperCase() : '?';
const roleLabel = user ? tHeader(`role.${user.role}`) : '';
@@ -101,11 +92,7 @@ export function Header() {
// der Name der aktuellen Seite bzw. des Moduls.
const navModules = useNavStore((s) => s.modules);
const pageTitle = resolvePageTitle(pathname, navModules);
const pageTitleText = pageTitle
? 'key' in pageTitle
? t(pageTitle.key)
: pageTitle.text
: null;
const pageTitleText = pageTitle ? ('key' in pageTitle ? t(pageTitle.key) : pageTitle.text) : null;
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)]">
@@ -258,7 +245,18 @@ export function Header() {
: '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" />
</svg>
{tHeader('admin.label')}
@@ -273,6 +271,9 @@ export function Header() {
type="button"
onClick={() => {
setDropdownOpen(false);
// Offen gehaltene eigene Module und ihr Sitzungsspeicher
// gehoeren zur Sitzung — beim Abmelden alles verwerfen.
useCustomModuleCacheStore.getState().clear();
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"
@@ -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,4 +1,4 @@
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 { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
@@ -24,12 +24,33 @@ vi.mock('@/lib/custom-modules-api', async () => {
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';
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 {
id: 'abc',
name: 'Wiki',
id,
name,
url,
category: 'infrastructure',
createdAt: '2026-09-29T00:00:00.000Z',
@@ -39,6 +60,7 @@ function mod(url: string) {
beforeEach(() => {
mockGetCustomModule.mockReset();
useCustomModuleCacheStore.getState().clear();
});
afterEach(() => {
@@ -48,7 +70,7 @@ afterEach(() => {
describe('CustomModuleView', () => {
it('rendert den Rahmen mit Sandbox, ohne Referrer und ohne allow', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />);
renderModule('abc');
const frame = await screen.findByTitle('Wiki');
expect(frame.tagName).toBe('IFRAME');
@@ -62,7 +84,7 @@ describe('CustomModuleView', () => {
it('zeigt immer den Link „In neuem Tab öffnen“, aber keine Name- oder Hinweiszeile', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />);
renderModule('abc');
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
expect(link).toHaveAttribute('href', 'https://example.com/wiki');
@@ -77,7 +99,7 @@ describe('CustomModuleView', () => {
document.body.appendChild(slot);
try {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />);
renderModule('abc');
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
expect(slot.contains(link)).toBe(true);
} finally {
@@ -92,7 +114,7 @@ describe('CustomModuleView', () => {
'https://user:pw@example.com',
])('rendert bei der Adresse %s weder Rahmen noch Link', async (url) => {
mockGetCustomModule.mockResolvedValue(mod(url));
const { container } = render(<CustomModuleView id="abc" />);
const { container } = renderModule('abc');
await screen.findByText('Ungültige Adresse');
expect(container.querySelector('iframe')).toBeNull();
@@ -101,7 +123,7 @@ describe('CustomModuleView', () => {
it('zeigt bei 404 den Nicht-gefunden-Text', async () => {
mockGetCustomModule.mockResolvedValue(null);
const { container } = render(<CustomModuleView id="weg" />);
const { container } = renderModule('weg');
await waitFor(() => {
expect(screen.getByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument();
@@ -115,7 +137,7 @@ describe('CustomModuleView', () => {
['Netzwerkfehler', new TypeError('Failed to fetch')],
])('zeigt bei %s „konnte nicht geladen werden“ statt „nicht gefunden“', async (_, err) => {
mockGetCustomModule.mockRejectedValue(err);
const { container } = render(<CustomModuleView id="abc" />);
const { container } = renderModule('abc');
expect(await screen.findByRole('alert')).toHaveTextContent(
'Dieses Modul konnte nicht geladen werden.',
@@ -124,3 +146,99 @@ describe('CustomModuleView', () => {
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,17 +1,13 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
import { checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
type LoadState =
| { status: 'loading' }
| { status: 'missing' }
| { status: 'failed' }
| { status: 'ready'; mod: CustomModule };
type LoadStatus = 'loading' | 'missing' | 'failed' | 'ready';
/**
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen
@@ -25,47 +21,83 @@ type LoadState =
* `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 }) {
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 slotRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setActionsSlot(document.getElementById(HEADER_ACTIONS_SLOT_ID));
}, []);
useEffect(() => {
let cancelled = false;
setState({ status: 'loading' });
const hasCached = () => Boolean(useCustomModuleCacheStore.getState().modules[id]);
setStatus(hasCached() ? 'ready' : 'loading');
getCustomModule(id)
.then((mod) => {
.then((loaded) => {
if (cancelled) return;
setState(mod ? { status: 'ready', mod } : { status: 'missing' });
if (loaded) {
remember(loaded);
setStatus('ready');
} else {
forget(id);
setStatus('missing');
}
})
.catch(() => {
if (!cancelled) setState({ status: 'failed' });
if (!cancelled) setStatus(hasCached() ? 'ready' : 'failed');
});
return () => {
cancelled = true;
};
}, [id]);
}, [id, remember, forget]);
if (state.status === 'loading') {
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>;
}
if (state.status === 'missing') {
const urlOk = mod ? checkCustomModuleUrl(mod.url) === 'ok' : false;
const showFrame = status === 'ready' && urlOk;
// 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>;
}
if (state.status === 'failed') {
if (status === 'failed') {
return (
<div role="alert" className="p-4 text-sm text-destructive">
{t('loadError')}
</div>
);
}
const { mod } = state;
const urlOk = checkCustomModuleUrl(mod.url) === 'ok';
if (status === 'loading' || !mod) {
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>;
}
const openLink = urlOk ? (
<a
@@ -98,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">
{openLink && (actionsSlot ? createPortal(openLink, actionsSlot) : openLink)}
{urlOk ? (
<iframe
src={mod.url}
title={mod.name}
sandbox={XFRAME_SANDBOX}
allow=""
referrerPolicy="no-referrer"
className="w-full flex-1 rounded-lg border-0 bg-background"
data-testid="custom-module-frame"
<div
ref={slotRef}
className="w-full flex-1 rounded-lg bg-background"
data-testid="custom-module-slot"
/>
) : (
<div className="rounded-md border border-border p-4 text-sm text-muted-foreground">
+80 -4
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
GRID_CANVAS_KEY,
GRID_SCALE_FACTOR,
GRID_VERSION,
GRID_VERSION_KEY,
@@ -192,10 +193,30 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 });
expect(r.migrated).toBe(true);
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts(null)).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', () => {
@@ -209,3 +230,58 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
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);
});
});
+51 -6
View File
@@ -41,6 +41,19 @@
* 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
* das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem
* GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne
@@ -50,6 +63,17 @@
export const GRID_VERSION = 3;
export const GRID_VERSION_KEY = '__gridVersion';
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 {
i: string;
@@ -74,6 +98,19 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
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
* Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob
@@ -84,9 +121,11 @@ export function migrateGridLayouts(raw: unknown): {
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)) {
return { layouts: {}, migrated: false, newer: false };
return { layouts: {}, migrated: false, newer: false, canvas: null };
}
const markerValue = raw[GRID_VERSION_KEY];
@@ -100,7 +139,7 @@ export function migrateGridLayouts(raw: unknown): {
let migrated = false;
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];
if (!Array.isArray(value)) continue;
@@ -121,12 +160,18 @@ export function migrateGridLayouts(raw: unknown): {
});
}
return { layouts, migrated, newer };
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> {
return { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
export function withGridVersion(
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;
}
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
CUSTOM_MODULE_KEEP_ALIVE_MAX,
touchLru,
useCustomModuleCacheStore,
} from './custom-module-cache-store';
describe('touchLru', () => {
it('setzt ein neues Modul nach vorn ohne zu verwerfen', () => {
expect(touchLru(['a', 'b'], 'c', 5)).toEqual({ recent: ['c', 'a', 'b'], evicted: [] });
});
it('holt ein vorhandenes Modul nach vorn, ohne es zu verdoppeln', () => {
expect(touchLru(['a', 'b', 'c'], 'c', 5)).toEqual({ recent: ['c', 'a', 'b'], evicted: [] });
});
it('verwirft ueber der Grenze das am laengsten unbenutzte', () => {
expect(touchLru(['e', 'd', 'c', 'b', 'a'], 'f', 5)).toEqual({
recent: ['f', 'e', 'd', 'c', 'b'],
evicted: ['a'],
});
});
it('haelt das gerade benutzte Modul auch bei unsinniger Grenze', () => {
expect(touchLru(['a'], 'b', 0)).toEqual({ recent: ['b'], evicted: ['a'] });
});
});
describe('useCustomModuleCacheStore', () => {
const slot = () => document.createElement('div');
const remember = (id: string) =>
useCustomModuleCacheStore.getState().remember({ id, name: id, url: `https://x.test/${id}` });
beforeEach(() => {
useCustomModuleCacheStore.getState().clear();
});
it('haelt die Einhaengereihenfolge stabil und verwirft nach LRU', () => {
const ids = Array.from({ length: CUSTOM_MODULE_KEEP_ALIVE_MAX + 1 }, (_, i) => `m${i}`);
for (const id of ids) remember(id);
const { activate } = useCustomModuleCacheStore.getState();
for (const id of ids.slice(0, CUSTOM_MODULE_KEEP_ALIVE_MAX)) activate(id, slot());
// m0 erneut benutzen -> m1 ist jetzt am laengsten unbenutzt.
activate('m0', slot());
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['m0', 'm1', 'm2', 'm3', 'm4']);
activate('m5', slot());
const state = useCustomModuleCacheStore.getState();
expect(state.openIds).toEqual(['m0', 'm2', 'm3', 'm4', 'm5']);
expect(state.recent[0]).toBe('m5');
// Metadaten bleiben — nur der offene Rahmen wird verworfen.
expect(state.modules.m1).toBeDefined();
});
it('aktiviert kein Modul ohne Metadaten', () => {
useCustomModuleCacheStore.getState().activate('unbekannt', slot());
expect(useCustomModuleCacheStore.getState().openIds).toEqual([]);
expect(useCustomModuleCacheStore.getState().active).toBeNull();
});
it('deaktiviert nur den eigenen Platzhalter', () => {
remember('a');
const first = slot();
const { activate, deactivate } = useCustomModuleCacheStore.getState();
activate('a', first);
deactivate(slot());
expect(useCustomModuleCacheStore.getState().active?.slot).toBe(first);
deactivate(first);
expect(useCustomModuleCacheStore.getState().active).toBeNull();
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['a']);
});
it('vergisst ein Modul samt offenem Rahmen', () => {
remember('a');
useCustomModuleCacheStore.getState().activate('a', slot());
useCustomModuleCacheStore.getState().forget('a');
const state = useCustomModuleCacheStore.getState();
expect(state.modules).toEqual({});
expect(state.openIds).toEqual([]);
expect(state.recent).toEqual([]);
expect(state.active).toBeNull();
});
});
@@ -0,0 +1,102 @@
import { create } from 'zustand';
import type { CustomModule } from '@/lib/custom-modules-api';
/**
* Eigene Module offen halten (30.09.2026, Nutzerwunsch „der
* Aufruf dauert jedes Mal lange“). Zwei Dinge liegen hier im Speicher der
* Sitzung (bewusst OHNE persist — nach dem Neuladen der Seite ist alles leer):
*
* 1. `modules`: Name und Adresse je bereits geladenem Modul. Ein erneuter
* Aufruf zeigt sofort diesen Stand und laedt im Hintergrund nach
* (custom-module-view.tsx), statt erst auf `getCustomModule` zu warten.
* 2. `openIds`/`recent`: welche Module ihren <iframe> behalten
* (custom-module-frame-host.tsx). `openIds` ist die Einhaengereihenfolge
* und bleibt STABIL — neue Module kommen hinten dazu, verworfene fallen
* heraus. Wuerde die Liste bei jedem Aufruf umsortiert, verschoebe React
* die <iframe>-Knoten im DOM, und ein verschobenes <iframe> laedt neu.
* Die Nutzungsreihenfolge fuer die Begrenzung steht deshalb getrennt in
* `recent` (zuletzt benutzt vorn).
*/
/**
* Hoechstens so viele eigene Module halten ihren <iframe> offen. Jede offene
* Fremdseite belegt weiter Speicher und laeuft im Hintergrund mit (Skripte,
* Abfragen); fuenf deckt den ueblichen Wechsel zwischen wenigen Werkzeugen ab,
* ohne den Browser bei vielen Eintraegen in der Seitenleiste zu belasten.
*/
export const CUSTOM_MODULE_KEEP_ALIVE_MAX = 5;
export type CachedCustomModule = Pick<CustomModule, 'id' | 'name' | 'url'>;
export interface LruResult {
/** Neue Nutzungsreihenfolge, zuletzt benutzt vorn. */
recent: string[];
/** Ids, die dabei ueber die Grenze gefallen sind (aelteste zuerst verworfen). */
evicted: string[];
}
/** Reine LRU-Regel: `id` nach vorn, alles jenseits von `max` wird verworfen. */
export function touchLru(recent: readonly string[], id: string, max: number): LruResult {
const next = [id, ...recent.filter((other) => other !== id)];
const limit = Math.max(1, max);
return { recent: next.slice(0, limit), evicted: next.slice(limit) };
}
export interface ActiveCustomModuleSlot {
id: string;
/** Platzhalter der Modulseite, ueber den der Behaelter den <iframe> legt. */
slot: HTMLElement;
}
interface CustomModuleCacheState {
modules: Record<string, CachedCustomModule>;
openIds: string[];
recent: string[];
active: ActiveCustomModuleSlot | null;
/** Metadaten merken oder aktualisieren (Name/Adresse nach dem Laden oder Speichern). */
remember: (mod: CachedCustomModule) => void;
/** Modul vergessen (404 oder geloescht): Metadaten UND offener <iframe> weg. */
forget: (id: string) => void;
/** Die Modulseite zeigt `id` im Platzhalter `slot`: offen halten und nach vorn. */
activate: (id: string, slot: HTMLElement) => void;
/** Die Modulseite verschwindet: <iframe> nur verstecken, nicht verwerfen. */
deactivate: (slot: HTMLElement) => void;
/** Abmelden/Benutzerwechsel: alles verwerfen. */
clear: () => void;
}
export const useCustomModuleCacheStore = create<CustomModuleCacheState>()((set) => ({
modules: {},
openIds: [],
recent: [],
active: null,
remember: (mod) =>
set((s) => {
const prev = s.modules[mod.id];
if (prev && prev.name === mod.name && prev.url === mod.url) return s;
return { modules: { ...s.modules, [mod.id]: { id: mod.id, name: mod.name, url: mod.url } } };
}),
forget: (id) =>
set((s) => {
const { [id]: _removed, ...modules } = s.modules;
return {
modules,
openIds: s.openIds.filter((other) => other !== id),
recent: s.recent.filter((other) => other !== id),
active: s.active?.id === id ? null : s.active,
};
}),
activate: (id, slot) =>
set((s) => {
if (!s.modules[id]) return s;
const { recent, evicted } = touchLru(s.recent, id, CUSTOM_MODULE_KEEP_ALIVE_MAX);
const kept = s.openIds.filter((other) => !evicted.includes(other));
return {
recent,
openIds: kept.includes(id) ? kept : [...kept, id],
active: { id, slot },
};
}),
deactivate: (slot) => set((s) => (s.active?.slot === slot ? { active: null } : s)),
clear: () => set({ modules: {}, openIds: [], recent: [], active: null }),
}));
@@ -54,6 +54,7 @@ beforeEach(() => {
isLoading: false,
error: null,
layoutFromNewerVersion: false,
canvas: null,
});
vi.clearAllMocks();
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]);
@@ -454,3 +455,106 @@ describe('dashboard-store — optimistische Kachel-Konfiguration (quick-260930)'
expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false });
});
});
describe('dashboard-store — Leinwand (Dashboard wie ein Bild mitskalieren)', () => {
const V3 = { lg: [{ i: 'a', x: 0, y: 0, w: 8, h: 4 }], __gridVersion: 3 };
it('Test 24: Reiter ohne Leinwand -> captureCanvas setzt sie und speichert SOFORT mit __canvas und Marker', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().canvas).toBeNull();
expect(api.saveLayout).not.toHaveBeenCalled();
await useDashboardStore.getState().captureCanvas({ w: 1600.4, h: 820.2 });
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 820 });
expect(api.saveLayout).toHaveBeenCalledTimes(1);
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', {
lg: V3.lg,
__gridVersion: 3,
__canvas: { w: 1600, h: 820 },
});
// Danach aendert sie sich nicht mehr automatisch.
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 820 });
expect(api.saveLayout).toHaveBeenCalledTimes(1);
});
it('Test 25: geladene Leinwand steht im Zustand, wird nicht neu erfasst, und saveLayout schreibt sie mit', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3, __canvas: { w: 1920, h: 950 } });
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
expect(api.saveLayout).not.toHaveBeenCalled();
await useDashboardStore.getState().saveLayout();
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', {
lg: V3.lg,
__gridVersion: 3,
__canvas: { w: 1920, h: 950 },
});
});
it('Test 26: Sofort-Speichern nach der Umrechnung behaelt die Leinwand', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
__canvas: { w: 1400, h: 700 },
});
await useDashboardStore.getState().loadDashboard();
expect(api.saveLayout).toHaveBeenCalledWith(
'dash-1',
expect.objectContaining({ __gridVersion: 3, __canvas: { w: 1400, h: 700 } }),
);
});
it('Test 27: Reiterwechsel setzt die Leinwand des neuen Reiters (auch null)', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
vi.mocked(api.fetchLayout).mockImplementation(async (id: string) =>
id === 'dash-1' ? { ...V3, __canvas: { w: 1920, h: 950 } } : { ...V3 },
);
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
await useDashboardStore.getState().selectDashboard('dash-2');
expect(useDashboardStore.getState().canvas).toBeNull();
// Erfassen gilt nun fuer dash-2.
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
expect(api.saveLayout).toHaveBeenLastCalledWith(
'dash-2',
expect.objectContaining({ __canvas: { w: 1280, h: 700 } }),
);
await useDashboardStore.getState().selectDashboard('dash-1');
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
});
it('Test 28: nicht erfassen bei Anordnung eines neueren Programmstands oder bei zu kleiner Flaeche', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3, __gridVersion: 99 });
await useDashboardStore.getState().loadDashboard();
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 800 });
expect(useDashboardStore.getState().canvas).toBeNull();
expect(api.saveLayout).not.toHaveBeenCalled();
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
useDashboardStore.setState({ activeDashboardId: null });
await useDashboardStore.getState().loadDashboard();
await useDashboardStore.getState().captureCanvas({ w: 767, h: 800 });
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 150 });
expect(useDashboardStore.getState().canvas).toBeNull();
expect(api.saveLayout).not.toHaveBeenCalled();
});
it('Test 29: scheitert das Speichern beim Erfassen, bleibt die Leinwand im Zustand, console.error einmal', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.saveLayout).mockRejectedValueOnce(new Error('offline'));
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 800 });
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 800 });
expect(useDashboardStore.getState().error).toBeNull();
expect(errorSpy).toHaveBeenCalledTimes(1);
});
});
+62 -6
View File
@@ -3,7 +3,14 @@ import type { WidgetType } from '@/components/dashboard/widget-registry';
import { clampWidthToCols, WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
import type { DashboardTab } from '@/lib/dashboard-api';
import * as api from '@/lib/dashboard-api';
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
import {
CANVAS_MIN_HEIGHT,
CANVAS_MIN_WIDTH,
type GridCanvas,
migrateGridLayouts,
parseGridCanvas,
withGridVersion,
} from '@/lib/grid-layout-migration';
export interface WidgetInstance {
id: string;
@@ -28,6 +35,12 @@ interface DashboardState {
* Bearbeitungsmodus bleibt gesperrt; die Oberflaeche bittet ums Neuladen.
*/
layoutFromNewerVersion: boolean;
/**
* Leinwand des aktiven Reiters (Bezugsflaeche in Pixeln), siehe
* `captureCanvas`. Gehoert wie `layouts` zum aktiven Reiter; null =
* noch nicht erfasst.
*/
canvas: GridCanvas | null;
setEditMode: (mode: boolean) => void;
updateLayouts: (layouts: Record<string, unknown>) => void;
@@ -38,6 +51,8 @@ interface DashboardState {
loadDashboard: () => Promise<void>;
saveLayout: () => Promise<void>;
selectDashboard: (id: string) => Promise<void>;
/** Erfasst einmalig die Leinwand des aktiven Reiters und speichert sie sofort. */
captureCanvas: (area: GridCanvas) => Promise<void>;
createDashboard: () => Promise<void>;
renameDashboard: (id: string, name: string) => Promise<void>;
deleteDashboard: (id: string) => Promise<void>;
@@ -92,6 +107,14 @@ function loadDashboardsOnce(): Promise<DashboardTab[]> {
* ist alt), setzt der Store `layoutFromNewerVersion`: `saveLayout` speichert
* dann nie, `setEditMode(true)` wird ignoriert, die Oberflaeche bittet ums
* Neuladen. Der Merker gilt je geladenem Reiter.
*
* Leinwand (Dashboard wie ein Bild mitskalieren): `canvas` ist die Flaeche,
* auf der der Reiter eingerichtet wurde. Sie lebt im gespeicherten JSON als
* `__canvas` und wird — wie der Marker — an JEDER Speicherstelle mit
* `withGridVersion(layouts, canvas)` zurueckgeschrieben (Sofort-Speichern nach
* der Umrechnung, `saveLayout`, `captureCanvas`); fehlt sie dort, ist sie
* verloren und wird beim naechsten Oeffnen am dann benutzten Bildschirm neu
* erfasst.
*/
export const useDashboardStore = create<DashboardState>()((set, get) => ({
dashboards: [],
@@ -104,6 +127,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
isLoading: false,
error: null,
layoutFromNewerVersion: false,
canvas: null,
setEditMode: (mode: boolean) => {
// quick-260930: Anordnung eines neueren Programmstands — nicht bearbeiten.
@@ -214,11 +238,12 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
api.fetchLayout(first.id),
api.fetchWidgets(first.id),
]);
const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts);
const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
set({
dashboards,
activeDashboardId: first.id,
layouts: migratedLayouts,
canvas,
widgets,
isLoading: false,
layoutFromNewerVersion: newer,
@@ -229,7 +254,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
if (migrated) {
try {
await api.saveLayout(first.id, withGridVersion(migratedLayouts));
await api.saveLayout(first.id, withGridVersion(migratedLayouts, canvas));
} catch (err) {
console.error('Failed to persist migrated layout:', err);
}
@@ -249,7 +274,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
// sie ginge mit der alten Marke zurueck und wuerde doppelt skaliert.
if (get().layoutFromNewerVersion) return;
try {
await api.saveLayout(dashboardId, withGridVersion(get().layouts));
await api.saveLayout(dashboardId, withGridVersion(get().layouts, get().canvas));
set({ isDirty: false });
} catch (err) {
console.error('Failed to save layout:', err);
@@ -275,10 +300,11 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
set({ isSwitchingDashboard: true, error: null });
try {
const [rawLayouts, widgets] = await Promise.all([api.fetchLayout(id), api.fetchWidgets(id)]);
const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts);
const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
set({
activeDashboardId: id,
layouts: migratedLayouts,
canvas,
widgets,
isDirty: false,
isSwitchingDashboard: false,
@@ -287,7 +313,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
});
if (migrated) {
try {
await api.saveLayout(id, withGridVersion(migratedLayouts));
await api.saveLayout(id, withGridVersion(migratedLayouts, canvas));
} catch (err) {
console.error('Failed to persist migrated layout:', err);
}
@@ -298,6 +324,36 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
}
},
/**
* Leinwand erfassen (Dashboard wie ein Bild mitskalieren). Das Raster ruft
* das auf, solange der aktive Reiter keine Leinwand hat und die verfuegbare
* Flaeche Desktop-Breite hat. Die Flaeche wird EINMAL uebernommen und
* sofort gespeichert; danach aendert sie sich nicht mehr automatisch.
* Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus — auf jedem
* anderen Bildschirm wird es gleichmaessig (auch die Schrift) auf dessen
* Flaeche verkleinert oder vergroessert, ohne Scrollen.
*
* Nichts geschieht bei vorhandener Leinwand, ungueltiger Flaeche (Handy,
* Messfehler) oder einer Anordnung eines neueren Programmstands (die wird
* nie gespeichert). Eigener try/catch wie beim Sofort-Speichern nach der
* Umrechnung: ein Speicherfehler laesst die Leinwand im Zustand stehen, ein
* spaeteres `saveLayout` schreibt sie dann mit.
*/
captureCanvas: async (area: GridCanvas) => {
const state = get();
const dashboardId = state.activeDashboardId;
if (!dashboardId || state.canvas || state.layoutFromNewerVersion) return;
if (area.w < CANVAS_MIN_WIDTH || area.h < CANVAS_MIN_HEIGHT) return;
const canvas = parseGridCanvas({ w: Math.round(area.w), h: Math.round(area.h) });
if (!canvas) return;
set({ canvas });
try {
await api.saveLayout(dashboardId, withGridVersion(state.layouts, canvas));
} catch (err) {
console.error('Failed to persist dashboard canvas:', err);
}
},
/** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */
createDashboard: async () => {
try {
@@ -0,0 +1,101 @@
/**
* Willkommensmail-Vorlage — API-Client (Administrator → Willkommensmail).
* Konsumiert `/welcome-mail-template`. Muster `custom-modules-api.ts`:
* `credentials: 'include'` fuer Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
*/
import type { WelcomeMailTexts } from '@tessera/shared';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/** Antwort von GET/PUT/DELETE /welcome-mail-template. */
export interface WelcomeMailTemplateState {
/** `true` = eigene Vorlage gespeichert, `false` = Standardtexte. */
custom: boolean;
texts: WelcomeMailTexts;
defaults: WelcomeMailTexts;
updatedAt: string | null;
updatedBy: string | null;
}
export interface WelcomeMailPreview {
subject: string;
html: string;
text: string;
}
/** Fehler mit HTTP-Status und den Servermeldungen (400: je Feld eine). */
export class WelcomeMailTemplateRequestError extends Error {
constructor(
readonly status: number,
readonly messages: string[],
) {
super(messages.join(' ') || `HTTP ${status}`);
this.name = 'WelcomeMailTemplateRequestError';
}
}
async function readMessages(res: Response): Promise<string[]> {
try {
const body = (await res.json()) as { message?: unknown };
if (Array.isArray(body.message)) return body.message.map(String);
if (typeof body.message === 'string' && body.message) return [body.message];
} catch {
// kein JSON — Status allein
}
return [];
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_URL}/welcome-mail-template${path}`, {
credentials: 'include',
...init,
headers: init.body ? { 'Content-Type': 'application/json' } : undefined,
});
if (!res.ok) throw new WelcomeMailTemplateRequestError(res.status, await readMessages(res));
return (await res.json()) as T;
}
/** Nur die sechs Texte senden — nie weitere Felder aus dem Formularzustand. */
function textsOf(texts: WelcomeMailTexts): WelcomeMailTexts {
return {
subject: texts.subject,
heading: texts.heading,
intro: texts.intro,
loginHintDirectory: texts.loginHintDirectory,
loginHintLocal: texts.loginHintLocal,
closing: texts.closing,
};
}
export function fetchWelcomeMailTemplate(): Promise<WelcomeMailTemplateState> {
return request('');
}
export function saveWelcomeMailTemplate(
texts: WelcomeMailTexts,
): Promise<WelcomeMailTemplateState> {
return request('', { method: 'PUT', body: JSON.stringify(textsOf(texts)) });
}
export function resetWelcomeMailTemplate(): Promise<WelcomeMailTemplateState> {
return request('', { method: 'DELETE' });
}
export function previewWelcomeMail(
texts: WelcomeMailTexts,
account: 'directory' | 'local',
signal?: AbortSignal,
): Promise<WelcomeMailPreview> {
return request('/preview', {
method: 'POST',
body: JSON.stringify({ ...textsOf(texts), account }),
signal,
});
}
export function sendWelcomeMailTest(
texts: WelcomeMailTexts,
): Promise<{ success: true; to: string }> {
return request('/test', { method: 'POST', body: JSON.stringify(textsOf(texts)) });
}
+118 -1
View File
@@ -88,7 +88,8 @@
"customModules": "Eigene Module",
"ldap": "LDAP",
"smtp": "SMTP",
"groups": "Gruppen"
"groups": "Gruppen",
"welcomeMail": "Willkommensmail"
},
"role": {
"SUPER_ADMIN": "Super-Admin",
@@ -535,6 +536,8 @@
"displayName": "Anzeigename",
"role": "Rolle",
"status": "Status",
"lastLogin": "Letzte Anmeldung",
"neverLoggedIn": "Noch nie",
"actions": "Aktionen",
"password": "Passwort",
"noUsers": "Keine Benutzer gefunden",
@@ -557,6 +560,20 @@
"generic": "Die Aktion konnte nicht durchgeführt werden. Bitte erneut versuchen.",
"network": "Der Server ist nicht erreichbar. Bitte erneut versuchen.",
"loadFailed": "Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu."
},
"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 mit Adresse, Benutzername und Hinweisen zur ersten Anmeldung 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."
}
},
"tenants": {
@@ -722,6 +739,106 @@
"create": "Gemeinsamen Eintrag anlegen",
"noModules": "Noch keine gemeinsamen Einträge",
"noModulesBody": "Legen Sie Ihren ersten gemeinsamen Eintrag an, um eine Webseite für alle Benutzer in der Seitenleiste zu zeigen."
},
"welcomeMail": {
"title": "Willkommensmail",
"description": "Hier legen Sie fest, mit welchen Worten Tessera neue Benutzer begrüßt. Die Vorlage gilt für Ihren Mandanten und wird bei jeder Willkommensmail aus der Benutzerverwaltung verwendet.",
"loading": "Vorlage wird geladen …",
"fixed": {
"title": "Was Sie anpassen können – und was immer drin bleibt",
"body": "Sie bestimmen Betreff, Überschrift, Einleitung, Abschlusstext und den Anmeldehinweis – getrennt für Verzeichniskonten und für lokale Konten. Alles, was für die Anmeldung technisch nötig ist, fügt Tessera immer automatisch ein und lässt sich nicht verändern: der Kopf mit Logo, der Kasten mit Adresse und Benutzername, der Knopf „Passwort festlegen“ samt Link und Gültigkeitshinweis bei lokalen Konten, der Knopf „Zu Tessera“ und die Fußzeile. So kann eine eigene Vorlage die Anmeldung nicht kaputt machen."
},
"statusDefault": "Zurzeit gelten die Standardtexte.",
"statusCustom": "Eigene Vorlage aktiv – zuletzt geändert am {date}.",
"statusCustomBy": "Eigene Vorlage aktiv – zuletzt geändert am {date} von {user}.",
"unsaved": "Ungespeicherte Änderungen",
"fields": {
"subject": "Betreff",
"subjectHelp": "Eine Zeile, Pflichtfeld.",
"heading": "Überschrift",
"headingHelp": "Große Zeile oben in der Mail, Pflichtfeld.",
"intro": "Einleitungstext",
"introHelp": "Steht unter der Überschrift. Eine Leerzeile beginnt einen neuen Absatz.",
"loginHintDirectory": "Anmeldehinweis für Verzeichniskonten",
"loginHintDirectoryHelp": "Erscheint unter dem Kasten mit den Zugangsdaten, wenn der Benutzer aus dem Verzeichnis (Active Directory/LDAP) kommt und sich mit seinem gewohnten Windows-Passwort anmeldet. Pflichtfeld.",
"loginHintLocal": "Anmeldehinweis für lokale Konten",
"loginHintLocalHelp": "Erscheint bei lokalen Konten direkt vor dem Knopf „Passwort festlegen“. Knopf und Gültigkeitshinweis darunter fügt Tessera immer selbst ein. Pflichtfeld.",
"closing": "Abschlusstext",
"closingHelp": "Steht am Ende der Mail, z. B. Tipp und Grußzeile. Darf leer bleiben."
},
"counter": "{count} / {max}",
"save": "Speichern",
"saving": "Wird gespeichert …",
"saved": "Die Vorlage wurde gespeichert. Ab jetzt verwenden neue Willkommensmails diese Texte.",
"test": "Testmail an mich senden",
"testSending": "Wird gesendet …",
"testSent": "Die Testmail wurde an {email} gesendet.",
"testHelp": "Die Testmail geht an Ihre eigene E-Mail-Adresse und zeigt den aktuellen Inhalt des Formulars, auch wenn er noch nicht gespeichert ist. Sie enthält keinen echten Link zum Festlegen eines Passworts.",
"reset": "Auf Standard zurücksetzen",
"resetDone": "Die Standardtexte sind wiederhergestellt.",
"resetConfirm": {
"title": "Auf Standard zurücksetzen?",
"body": "Ihre eigene Vorlage wird gelöscht und die Standardtexte gelten wieder. Das lässt sich nicht rückgängig machen.",
"confirm": "Zurücksetzen",
"cancel": "Abbrechen"
},
"errors": {
"load": "Die Vorlage konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
"save": "Die Vorlage konnte nicht gespeichert werden.",
"reset": "Die Vorlage konnte nicht zurückgesetzt werden.",
"test": "Die Testmail konnte nicht gesendet werden.",
"required": "Bitte füllen Sie dieses Feld aus.",
"tooLong": "Höchstens {max} Zeichen.",
"unknownPlaceholder": "Unbekannter Platzhalter {token}",
"unknownPlaceholders": "Unbekannte Platzhalter {tokens}"
},
"placeholders": {
"title": "Platzhalter",
"intro": "Platzhalter setzt Tessera beim Versand durch die Angaben des jeweiligen Benutzers. Sie funktionieren in allen vier Feldern. Ein Klick auf einen Platzhalter fügt ihn an der Cursorposition des zuletzt benutzten Feldes ein.",
"colPlaceholder": "Platzhalter",
"colMeaning": "Bedeutung",
"colExample": "Beispiel",
"insert": "{token} einfügen",
"insertHint": "Klicken zum Einfügen",
"rules": "Andere Platzhalter kennt Tessera nicht – sie werden beim Speichern abgelehnt. Die Texte sind reiner Text: Formatierungen oder HTML werden nicht übernommen, sondern so angezeigt, wie Sie sie eingeben.",
"items": {
"name": {
"meaning": "Anzeigename des Benutzers, sonst sein Benutzername",
"example": "Max Mustermann"
},
"vorname": {
"meaning": "Erstes Wort des Anzeigenamens, sonst der Benutzername",
"example": "Max"
},
"benutzername": {
"meaning": "Benutzername für die Anmeldung",
"example": "max.mustermann"
},
"email": {
"meaning": "E-Mail-Adresse des Benutzers",
"example": "max.mustermann@example.com"
},
"adresse": {
"meaning": "Adresse von Tessera",
"example": "https://tessera.example.com"
},
"firma": {
"meaning": "Name Ihres Mandanten",
"example": "Beispiel GmbH"
}
}
},
"preview": {
"title": "Vorschau",
"accountLabel": "Vorschau für Kontoart",
"directory": "Verzeichniskonto",
"local": "Lokales Konto",
"subject": "Betreff:",
"frameTitle": "Vorschau der Willkommensmail",
"error": "Die Vorschau konnte nicht erstellt werden.",
"blocked": "Die Vorschau wird aktualisiert, sobald alle Felder in Ordnung sind.",
"sampleHint": "Die Vorschau verwendet Beispielwerte (Max Mustermann) und den Namen Ihres Mandanten. Der Link „Passwort festlegen“ ist in der Vorschau nur ein Beispiel."
}
}
},
"adminModules": {
+118 -1
View File
@@ -88,7 +88,8 @@
"customModules": "Custom modules",
"ldap": "LDAP",
"smtp": "SMTP",
"groups": "Groups"
"groups": "Groups",
"welcomeMail": "Welcome email"
},
"role": {
"SUPER_ADMIN": "Super Admin",
@@ -535,6 +536,8 @@
"displayName": "Display Name",
"role": "Role",
"status": "Status",
"lastLogin": "Last sign-in",
"neverLoggedIn": "Never",
"actions": "Actions",
"password": "Password",
"noUsers": "No users found",
@@ -557,6 +560,20 @@
"generic": "The action could not be completed. Please try again.",
"network": "The server is not reachable. Please try again.",
"loadFailed": "The user list could not be loaded. Please reload the page."
},
"welcomeMail": {
"button": "Send welcome email",
"resend": "Send again",
"sentAt": "Welcome email sent on {date}",
"noEmail": "No email address on file – please add one first.",
"noSmtp": "No SMTP server has been set up yet (Administrator → SMTP).",
"inactive": "The account is deactivated.",
"dialogTitle": "Send welcome email",
"dialogText": "Tessera will send a welcome email with the address, username and first sign-in instructions to:",
"dialogResend": "A welcome email was already sent on {date}. It will be sent again.",
"send": "Send",
"sending": "Sending …",
"success": "The welcome email to {email} has been sent."
}
},
"tenants": {
@@ -722,6 +739,106 @@
"create": "Add shared entry",
"noModules": "No shared entries yet",
"noModulesBody": "Add your first shared entry to show a website in the sidebar for all users."
},
"welcomeMail": {
"title": "Welcome email",
"description": "Choose the words Tessera uses to greet new users. The template applies to your tenant and is used for every welcome email sent from user management.",
"loading": "Loading template …",
"fixed": {
"title": "What you can change – and what always stays",
"body": "You decide the subject, heading, introduction, closing text and the sign-in hint – separately for directory accounts and for local accounts. Everything technically needed to sign in is always added automatically and cannot be changed: the header with logo, the box with address and username, the “Set password” button with its link and validity note for local accounts, the “Go to Tessera” button and the footer. This way a custom template cannot break sign-in."
},
"statusDefault": "The default texts are currently in use.",
"statusCustom": "Custom template active – last changed on {date}.",
"statusCustomBy": "Custom template active – last changed on {date} by {user}.",
"unsaved": "Unsaved changes",
"fields": {
"subject": "Subject",
"subjectHelp": "One line, required.",
"heading": "Heading",
"headingHelp": "Large line at the top of the email, required.",
"intro": "Introduction",
"introHelp": "Shown below the heading. An empty line starts a new paragraph.",
"loginHintDirectory": "Sign-in hint for directory accounts",
"loginHintDirectoryHelp": "Shown below the box with the sign-in details when the user comes from the directory (Active Directory/LDAP) and signs in with their usual Windows password. Required.",
"loginHintLocal": "Sign-in hint for local accounts",
"loginHintLocalHelp": "Shown for local accounts right before the “Set password” button. Tessera always adds the button and the validity note below it. Required.",
"closing": "Closing text",
"closingHelp": "Shown at the end of the email, e.g. a tip and a sign-off. May be left empty."
},
"counter": "{count} / {max}",
"save": "Save",
"saving": "Saving …",
"saved": "The template has been saved. New welcome emails will use these texts from now on.",
"test": "Send test email to me",
"testSending": "Sending …",
"testSent": "The test email was sent to {email}.",
"testHelp": "The test email goes to your own email address and shows the current form content, even if it has not been saved yet. It does not contain a real link for setting a password.",
"reset": "Reset to default",
"resetDone": "The default texts have been restored.",
"resetConfirm": {
"title": "Reset to default?",
"body": "Your custom template will be deleted and the default texts apply again. This cannot be undone.",
"confirm": "Reset",
"cancel": "Cancel"
},
"errors": {
"load": "The template could not be loaded. Please reload the page.",
"save": "The template could not be saved.",
"reset": "The template could not be reset.",
"test": "The test email could not be sent.",
"required": "Please fill in this field.",
"tooLong": "At most {max} characters.",
"unknownPlaceholder": "Unknown placeholder {token}",
"unknownPlaceholders": "Unknown placeholders {tokens}"
},
"placeholders": {
"title": "Placeholders",
"intro": "Tessera replaces placeholders with the details of each user when sending. They work in all four fields. Clicking a placeholder inserts it at the cursor position of the field you used last.",
"colPlaceholder": "Placeholder",
"colMeaning": "Meaning",
"colExample": "Example",
"insert": "Insert {token}",
"insertHint": "Click to insert",
"rules": "Tessera does not know any other placeholders – they are rejected when saving. The texts are plain text: formatting or HTML is not applied but shown exactly as you type it.",
"items": {
"name": {
"meaning": "Display name of the user, otherwise the username",
"example": "Max Mustermann"
},
"vorname": {
"meaning": "First word of the display name, otherwise the username",
"example": "Max"
},
"benutzername": {
"meaning": "Username for signing in",
"example": "max.mustermann"
},
"email": {
"meaning": "Email address of the user",
"example": "max.mustermann@example.com"
},
"adresse": {
"meaning": "Address of Tessera",
"example": "https://tessera.example.com"
},
"firma": {
"meaning": "Name of your tenant",
"example": "Example Ltd"
}
}
},
"preview": {
"title": "Preview",
"accountLabel": "Preview for account type",
"directory": "Directory account",
"local": "Local account",
"subject": "Subject:",
"frameTitle": "Welcome email preview",
"error": "The preview could not be created.",
"blocked": "The preview updates as soon as all fields are valid.",
"sampleHint": "The preview uses sample values (Max Mustermann) and the name of your tenant. The “Set password” link is only an example in the preview."
}
}
},
"adminModules": {
@@ -197,4 +197,10 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'Arbeitsspeicher',
// quick-260925-bow: „Was ist neu“-Fenster, Gruppe „Verbessert“ — korrektes Deutsch mit „ss“
'Verbessert',
// Administrator → Willkommensmail (eigene Vorlage) — korrektes Deutsch mit „ss“/„ue“/„ae“
'anpassen',
'Abschlusstext',
'aktuellen',
'Passworts',
'tessera',
];
+79 -6
View File
@@ -1,6 +1,7 @@
// @vitest-environment node
import { NextRequest } from 'next/server';
import { SignJWT } from 'jose';
import { NextRequest } from 'next/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { middleware } from './middleware';
@@ -67,7 +68,9 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
});
it('Test 6 (quick-260918-gza): /login mit dv/dc/dos setzt zusaetzlich tessera_desktop_client', async () => {
const req = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows');
const req = new NextRequest(
'http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows',
);
const res = await middleware(req);
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|windows');
@@ -98,15 +101,23 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
);
expect((await middleware(spaceInOs)).cookies.get('tessera_desktop_client')).toBeUndefined();
const missingDv = new NextRequest('http://localhost:3000/login?desktop=1&dc=a6d1a64&dos=windows');
const missingDv = new NextRequest(
'http://localhost:3000/login?desktop=1&dc=a6d1a64&dos=windows',
);
expect((await middleware(missingDv)).cookies.get('tessera_desktop_client')).toBeUndefined();
const emptyCommit = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=&dos=linux');
expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe('1.2.0||linux');
const emptyCommit = new NextRequest(
'http://localhost:3000/login?desktop=1&dv=1.2.0&dc=&dos=linux',
);
expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe(
'1.2.0||linux',
);
});
it('Test 9 (quick-260918-gza, Redirect-Pfad): /dashboard ohne Session setzt beide Cookies auf dem 307', async () => {
const req = new NextRequest('http://localhost:3000/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux');
const req = new NextRequest(
'http://localhost:3000/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux',
);
const res = await middleware(req);
expect(res.status).toBe(307);
expect(res.headers.get('location')).toContain('/login');
@@ -114,3 +125,65 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|linux');
});
});
describe('middleware — /login bei bestehender Anmeldung (quick-260930)', () => {
beforeEach(() => {
vi.stubEnv('JWT_SECRET', 'test-secret');
});
afterEach(() => {
vi.unstubAllEnvs();
});
async function token(claims: Record<string, unknown> = {}) {
return new SignJWT({ sub: 'u1', ...claims })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('5m')
.sign(new TextEncoder().encode('test-secret'));
}
function loginReq(url: string, session?: string) {
return new NextRequest(url, session ? { headers: { cookie: `session=${session}` } } : {});
}
it('angemeldet: /login leitet aufs Dashboard um', async () => {
const res = await middleware(loginReq('http://localhost:3000/login', await token()));
expect(res.status).toBe(307);
expect(new URL(res.headers.get('location') as string).pathname).toBe('/');
});
it('angemeldet mit sicherem next: dorthin', async () => {
const res = await middleware(
loginReq('http://localhost:3000/login?next=%2Fadmin%2Fusers', await token()),
);
expect(new URL(res.headers.get('location') as string).pathname).toBe('/admin/users');
});
it('angemeldet mit fremdem oder zirkulaerem next: Dashboard', async () => {
for (const next of ['https%3A%2F%2Fboese.example', '%2F%2Fboese.example', '%2Flogin']) {
const res = await middleware(
loginReq(`http://localhost:3000/login?next=${next}`, await token()),
);
const loc = new URL(res.headers.get('location') as string);
expect(loc.host).toBe('localhost:3000');
expect(loc.pathname).toBe('/');
}
});
it('ohne oder mit ungueltigem Cookie: Anmeldeseite wie bisher', async () => {
const ohne = await middleware(loginReq('http://localhost:3000/login'));
expect(ohne.headers.get('x-middleware-next')).toBe('1');
const kaputt = await middleware(
loginReq('http://localhost:3000/login', 'kein.gueltiges.token'),
);
expect(kaputt.headers.get('x-middleware-next')).toBe('1');
});
it('Kennwortwechsel ausstehend: keine Umleitung von /login', async () => {
const res = await middleware(
loginReq('http://localhost:3000/login', await token({ mustChangePassword: true })),
);
expect(res.headers.get('x-middleware-next')).toBe('1');
});
});
+25 -6
View File
@@ -1,6 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
import { buildNextParam } from '@/lib/safe-next';
import { type NextRequest, NextResponse } from 'next/server';
import { buildNextParam, sanitizeNextPath } from '@/lib/safe-next';
/**
* Next.js middleware for frontend route protection (Pattern 4).
@@ -113,6 +113,28 @@ function getSecret() {
export async function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
// Bereits angemeldet und /login aufgerufen (quick-260930, Wunsch des
// Nutzers): statt der Anmeldeseite direkt zum Ziel — `next`, sofern ein
// sicherer relativer Pfad, sonst das Dashboard. Nur bei gueltiger
// Signatur; ist das Konto inzwischen gesperrt, lehnt die API die Sitzung
// ab, die Oberflaeche loescht das Cookie serverseitig und schickt zur
// Anmeldung zurueck — dann ohne Cookie, also keine Schleife.
if (path === '/login' || path.startsWith('/login/')) {
const existing = req.cookies.get('session')?.value;
if (existing) {
try {
const { payload } = await jwtVerify(existing, getSecret(), { algorithms: ['HS256'] });
if (payload.mustChangePassword !== true) {
const next = sanitizeNextPath(req.nextUrl.searchParams.get('next'));
const target = next.startsWith('/login') ? '/' : next;
return withDesktopCookie(req, NextResponse.redirect(new URL(target, req.nextUrl)));
}
} catch {
// ungueltiges Cookie: Anmeldeseite wie gewohnt zeigen
}
}
}
// Allow public routes without authentication
if (publicRoutes.some((route) => path.startsWith(route))) {
return withDesktopCookie(req, NextResponse.next());
@@ -141,10 +163,7 @@ export async function middleware(req: NextRequest) {
});
// D-06: Force password change redirect
if (
payload.mustChangePassword === true &&
!path.startsWith('/change-password')
) {
if (payload.mustChangePassword === true && !path.startsWith('/change-password')) {
return withDesktopCookie(
req,
NextResponse.redirect(new URL('/change-password', req.nextUrl)),
+107 -1
View File
@@ -35,7 +35,7 @@ Wichtige Details, die im Code tatsächlich so umgesetzt sind:
## 2. Benutzerverwaltung
Der Bereich **Administrator → Benutzer** zeigt eine Tabelle mit Benutzername, E-Mail, Anzeigename, Rolle und Status. Ein ADMIN sieht dabei ausschließlich die Benutzer des eigenen Mandanten, ein SUPER_ADMIN sieht alle.
Der Bereich **Administrator → Benutzer** zeigt eine Tabelle mit Benutzername, E-Mail, Anzeigename, Rolle, Status und **Letzte Anmeldung** (Datum und Uhrzeit der letzten Anmeldung, bei neuen Konten „Noch nie“). Ein ADMIN sieht dabei ausschließlich die Benutzer des eigenen Mandanten, ein SUPER_ADMIN sieht alle.
### Benutzer anlegen
@@ -53,6 +53,43 @@ Ein Konto, das über die AD-Anbindung importiert oder synchronisiert wurde, hat
Seit Kurzem ist die E-Mail-Adresse eines Benutzers **optional**: Wenn beim Import eine Adresse bereits einem anderen Konto gehört, wird das Konto trotzdem angelegt bzw. aktualisiert – nur eben ohne diese Adresse. Anmeldung und Zugriff funktionieren für ein solches Konto normal, lediglich Benachrichtigungen per E-Mail (z. B. Passwort-Reset) erreichen es nicht. In der Tabelle wird eine fehlende Adresse als „–“ angezeigt. Bei der manuellen Anlage über das Formular ist eine E-Mail-Adresse weiterhin Pflicht.
### Willkommensmail
Bei jedem Benutzer steht in der Tabelle der Button „Willkommensmail senden“ – gedacht vor allem für neue Konten (Spalte „Letzte Anmeldung“: „Noch nie“), er lässt sich aber auch bei Benutzern nutzen, die sich schon angemeldet haben, etwa um die Zugangsdaten erneut zuzuschicken oder die Mail auszuprobieren. Es gelten dieselben Rechte wie beim Bearbeiten: ein ADMIN kann nur Benutzern des eigenen Mandanten und keinem SUPER_ADMIN eine Willkommensmail schicken. Nach einer Rückfrage mit der Empfängeradresse verschickt Tessera eine gestaltete E-Mail mit der Adresse von Tessera, dem Benutzernamen und einem Hinweis zur ersten Anmeldung:
- **verzeichnisgeführtes Konto (AD/LDAP):** Hinweis, sich mit dem gewohnten Windows-Passwort anzumelden;
- **lokales Konto:** ein Button „Passwort festlegen“, über den der Benutzer sein eigenes Passwort setzt. Der Link funktioniert wie „Passwort vergessen?“ – er ist 7 Tage gültig und nur einmal verwendbar; danach kann der Benutzer auf der Anmeldeseite jederzeit einen neuen anfordern.
Ein Passwort steht nie in der Mail. Bei einem lokalen Konto, das bereits ein Passwort hat, ersetzt „Passwort festlegen“ dieses Passwort erst, wenn der Benutzer den Link tatsächlich benutzt. Nach dem Versand zeigt die Zeile „Willkommensmail gesendet am …“, und der Button heißt „Erneut senden“. Der Button ist ausgegraut, wenn beim Benutzer keine E-Mail-Adresse hinterlegt ist, das Konto deaktiviert ist oder noch kein Mailserver eingerichtet wurde (siehe Kapitel 6, SMTP); der Grund steht im Hinweistext des Buttons. Die Adresse in der Mail stammt aus der Einstellung `APP_URL` des Servers (siehe `docs/anleitung-betrieb.md`); fehlt sie, nimmt Tessera die Adresse, unter der Sie die Benutzerverwaltung gerade geöffnet haben.
#### Eigene Vorlage für die Willkommensmail
Unter **Administrator → Willkommensmail** legen Sie fest, mit welchen Worten Tessera neue Benutzer begrüßt. Die Vorlage gilt für Ihren Mandanten; ADMIN und SUPER_ADMIN können sie bearbeiten. Anpassen lassen sich sechs Texte:
- **Betreff** (Pflicht, höchstens 200 Zeichen),
- **Überschrift** (Pflicht, höchstens 200 Zeichen),
- **Einleitungstext** (höchstens 4000 Zeichen; eine Leerzeile beginnt einen neuen Absatz),
- **Anmeldehinweis für Verzeichniskonten** (Pflicht, höchstens 1000 Zeichen) – erscheint unter dem Kasten mit den Zugangsdaten, wenn der Benutzer aus dem Verzeichnis (AD/LDAP) kommt; Standard: „Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.“,
- **Anmeldehinweis für lokale Konten** (Pflicht, höchstens 1000 Zeichen) – erscheint bei lokalen Konten direkt vor dem Button „Passwort festlegen“; Standard: „Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.“,
- **Abschlusstext** (höchstens 4000 Zeichen, darf leer bleiben – etwa ein Tipp und die Grußzeile).
Alles, was für die Anmeldung technisch nötig ist, fügt Tessera immer automatisch ein und lässt sich nicht verändern: der Kopf mit Logo, der Kasten mit Adresse und Benutzername, der Button „Passwort festlegen“ samt persönlichem Link und Gültigkeitshinweis bei lokalen Konten, der Button „Zu Tessera“ und die Fußzeile. Beide Anmeldehinweise dürfen Leerzeilen (neuer Absatz) enthalten. Vorlagen, die vor Einführung der Anmeldehinweise gespeichert wurden, verwenden dafür automatisch die Standardtexte. So kann eine eigene Vorlage die Anmeldung nicht kaputt machen. Die Texte sind reiner Text – Formatierungen oder HTML werden nicht übernommen, sondern so angezeigt, wie sie eingegeben wurden.
**Platzhalter** funktionieren in allen sechs Feldern und werden beim Versand durch die Angaben des jeweiligen Benutzers ersetzt. Ein Klick auf einen Platzhalter in der Hinweisbox fügt ihn an der Cursorposition des zuletzt benutzten Feldes ein.
| Platzhalter | Bedeutung | Beispiel |
|---|---|---|
| `{{name}}` | Anzeigename, sonst Benutzername | Max Mustermann |
| `{{vorname}}` | erstes Wort des Anzeigenamens, sonst Benutzername | Max |
| `{{benutzername}}` | Benutzername für die Anmeldung | max.mustermann |
| `{{email}}` | E-Mail-Adresse des Benutzers | max.mustermann@example.com |
| `{{adresse}}` | Adresse von Tessera | https://tessera.example.com |
| `{{firma}}` | Name des Mandanten | Beispiel GmbH |
Andere Platzhalter kennt Tessera nicht: die Seite nennt sie schon beim Tippen („Unbekannter Platzhalter {{xyz}}“), und das Speichern wird abgelehnt.
Rechts zeigt eine **Vorschau** die Mail genau so, wie sie verschickt wird – mit Beispielwerten und umschaltbar zwischen „Verzeichniskonto“ und „Lokales Konto“; die Vorschau zeigt dabei den jeweiligen Anmeldehinweis und springt beim Bearbeiten eines Anmeldehinweises von selbst auf die passende Kontoart. **Testmail an mich senden** schickt den aktuellen Inhalt des Formulars (auch ungespeichert) an Ihre eigene E-Mail-Adresse, mit Ihrem Konto als Beispiel. Die Testmail enthält nie einen echten Link zum Festlegen eines Passworts: bei einem lokalen Konto führt der Button nur zur Anmeldeseite und ist als Beispiel gekennzeichnet. **Auf Standard zurücksetzen** löscht die eigene Vorlage nach einer Rückfrage; danach gelten wieder die mitgelieferten Standardtexte. Ohne eigene Vorlage verwendet Tessera immer die Standardtexte.
### Deaktivieren und Löschen
Die Benutzerliste zeigt einen Status „Aktiv“/„Inaktiv“ an, dieser lässt sich aber **nicht** über einen Schalter im Formular umschalten – im Bearbeiten-Dialog gibt es dafür kein Feld. Ein Konto wird auf zwei Wegen inaktiv:
@@ -205,6 +242,75 @@ Unter „Verwaltung → Eigene Module“ legen Sie **gemeinsame** Seitenleisten-
Daneben kann **jeder Benutzer** – auch Sie selbst – unter „Einstellungen → Eigene Module“ persönliche Einträge anlegen. Diese sieht ausschließlich der Besitzer; Administratoren sehen und ändern sie nicht. Wird ein Benutzer gelöscht, verschwinden seine persönlichen Einträge mit. Die Verwaltungsseite zeigt deshalb nur die gemeinsamen Einträge.
### Proxmox-Server anbinden: Zugang nur zum Lesen einrichten
Tessera schaut bei Proxmox nur zu und ändert dort nichts. Legen Sie deshalb auf jedem Proxmox-Server einen **eigenen Zugang nur für Tessera** an, der ausschließlich lesen darf – nie Ihr persönliches Konto und nie `root`. Die Server tragen Sie danach im Proxmox-Modul unter „Zu den Einstellungen“ ein.
| Produkt | Zugangsart in Tessera | Rolle am Proxmox-Server |
|---|---|---|
| PVE (Virtualisierung) | API-Token (empfohlen) | PVEAuditor |
| PBS (Backup) | API-Token (empfohlen) | Audit |
| PMG (Mail-Gateway) | Benutzer/Passwort | Auditor |
**Warum bei PMG kein Token?** Das Mail-Gateway kennt keine API-Token – Proxmox hat sie dort bisher nicht eingebaut (Stand September 2026, offene Funktionsanfrage bei Proxmox). Tessera bietet bei PMG deshalb nur Benutzername und Passwort an. Mit einem eigenen Benutzer, der nur die Rolle „Auditor“ hat, ist das genauso sicher: dieses Konto kann nichts verändern.
Ein Token hat bei PVE und PBS zwei Vorteile gegenüber einem Passwort: Er lässt sich einzeln widerrufen, ohne das Konto anzufassen, und er kann sich nicht an der Proxmox-Oberfläche anmelden. Wichtig bei beiden: Ein Token darf nie mehr als sein Benutzer. Die Leserolle muss deshalb **dem Benutzer und dem Token** zugewiesen werden, sonst meldet Tessera beim Verbindungstest „keine Berechtigung“.
#### PVE: Benutzer und API-Token anlegen
In der Proxmox-VE-Oberfläche (Ansicht „Rechenzentrum“ / „Datacenter“):
1. **Berechtigungen → Benutzer → Hinzufügen:** Benutzername `tessera`, Realm „Proxmox VE authentication server“ (`pve`), ein langes, zufälliges Passwort (es wird nirgends gebraucht), Kommentar z. B. „Tessera, nur lesen“.
2. **Berechtigungen → API-Token → Hinzufügen:** Benutzer `tessera@pve`, Token-ID `tessera`, Haken bei „Privilegien-Trennung“ gesetzt lassen. Nach dem Speichern zeigt Proxmox die **Token-ID** (`tessera@pve!tessera`) und das **Geheimnis** – das Geheimnis erscheint **nur dieses eine Mal**, kopieren Sie es sofort.
3. **Berechtigungen → Hinzufügen → Benutzer-Berechtigung:** Pfad `/`, Benutzer `tessera@pve`, Rolle `PVEAuditor`, „Weitergeben“ angehakt.
4. **Berechtigungen → Hinzufügen → API-Token-Berechtigung:** Pfad `/`, API-Token `tessera@pve!tessera`, Rolle `PVEAuditor`, „Weitergeben“ angehakt.
Dasselbe auf der Kommandozeile eines PVE-Knotens (als root):
```
pveum user add tessera@pve --comment "Tessera, nur lesen"
pveum user token add tessera@pve tessera --privsep 1
pveum acl modify / --users tessera@pve --roles PVEAuditor
pveum acl modify / --tokens 'tessera@pve!tessera' --roles PVEAuditor
```
Der zweite Befehl gibt das Geheimnis (`value`) einmalig aus. In einem Cluster genügt das auf einem Knoten – Benutzer und Rechte gelten für den ganzen Cluster.
In Tessera: Typ **PVE**, Adresse z. B. `https://pve01.intern:8006`, Zugangsart **API-Token**, Token-Kennung `tessera@pve!tessera`, Token-Geheimnis = das kopierte Geheimnis.
#### PBS: Benutzer und API-Token anlegen
In der Proxmox-Backup-Server-Oberfläche:
1. **Konfiguration → Zugriffssteuerung → Benutzerverwaltung → Hinzufügen:** Benutzername `tessera`, Realm „Proxmox Backup authentication server“ (`pbs`), ein langes, zufälliges Passwort.
2. **Reiter „API-Token“ → Hinzufügen:** Benutzer `tessera@pbs`, Token-Name `tessera`. Proxmox zeigt danach **Token-ID** (`tessera@pbs!tessera`) und **Geheimnis** – das Geheimnis nur dieses eine Mal, sofort kopieren.
3. **Reiter „Berechtigungen“ → Hinzufügen → Benutzer-Berechtigung:** Pfad `/`, Benutzer `tessera@pbs`, Rolle `Audit`, „Weitergeben“ angehakt.
4. **Reiter „Berechtigungen“ → Hinzufügen → API-Token-Berechtigung:** Pfad `/`, API-Token `tessera@pbs!tessera`, Rolle `Audit`, „Weitergeben“ angehakt.
Kommandozeile des Backup-Servers (als root):
```
proxmox-backup-manager user create tessera@pbs --comment "Tessera, nur lesen"
proxmox-backup-manager user generate-token tessera@pbs tessera
proxmox-backup-manager acl update / Audit --auth-id tessera@pbs
proxmox-backup-manager acl update / Audit --auth-id 'tessera@pbs!tessera'
```
In Tessera: Typ **PBS**, Adresse z. B. `https://pbs01.intern:8007`, Zugangsart **API-Token**, Token-Kennung `tessera@pbs!tessera`, Token-Geheimnis = das kopierte Geheimnis.
#### PMG: Benutzer mit Leserolle anlegen
In der Proxmox-Mail-Gateway-Oberfläche:
1. **Konfiguration → Benutzerverwaltung → Hinzufügen:** Benutzername `tessera`, Realm „Proxmox Mail Gateway authentication server“ (`pmg`), Rolle **Auditor**, ein langes, zufälliges Passwort, Haken bei „Aktiviert“.
2. Das Passwort notieren – Tessera braucht es.
In Tessera: Typ **PMG**, Adresse z. B. `https://pmg01.intern:8006`, Zugangsart **Benutzer/Passwort** (bei PMG die einzige Auswahl), Benutzername `tessera@pmg`, Passwort = das vergebene Passwort.
#### Prüfen und später widerrufen
Nach dem Speichern prüft **„Verbindung testen“** den Zugang sofort. „Keine Berechtigung“ heißt fast immer: Die Rolle fehlt am Benutzer oder am Token, oder „Weitergeben“ ist nicht angehakt. Soll Tessera einen Server nicht mehr sehen, löschen Sie bei PVE/PBS einfach den Token, bei PMG den Benutzer `tessera` – andere Konten sind davon nicht betroffen.
## 6. SMTP
Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde.
+3 -1
View File
@@ -60,6 +60,8 @@ Unten in der Seitenleiste begrüßt Tessera Sie je nach Tageszeit mit Ihrem Name
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, die genau dort stehen, wo Sie sie im Bearbeitungsmodus platziert haben. Jedes Widget trägt oben ein gelbes Symbol-Feld und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln.
**Gleiches Bild auf jedem Bildschirm:** Tessera merkt sich je Dashboard die Größe der Fläche auf dem Bildschirm, an dem Sie es zum ersten Mal geöffnet haben. Auf jedem anderen Bildschirm – etwa am Laptop statt am großen Monitor – zeigt es dasselbe Dashboard maßstäblich verkleinert oder vergrößert, Kacheln und Schrift gleichermaßen, sodass es vollständig hineinpasst. Hat der Bildschirm eine andere Form, bleibt rechts oder unten etwas Platz frei. Bearbeiten können Sie das Dashboard auf jedem Bildschirm. Auf dem Handy (schmaler als 768 Pixel) ordnet Tessera die Kacheln weiterhin untereinander an.
**Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
- Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung).
@@ -160,7 +162,7 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
**Einen Server anlegen** (nur für Administratoren, über den Link „Zu den Einstellungen"): Name, Typ (PVE/PBS/PMG), Adresse (z. B. `https://pve.intern:8006`) und Zugang. Beim Zugang wählen Sie zwischen einem API-Token oder Benutzername/Passwort — bei PMG bietet Tessera von vornherein nur Benutzername/Passwort an, weil dieses Proxmox-Produkt keine API-Token kennt. Ein einmal gespeichertes Geheimnis (Token oder Passwort) wird nie wieder im Klartext angezeigt; lassen Sie das Feld beim Bearbeiten leer, um es unverändert zu lassen, oder tragen Sie ein neues ein, um es zu ersetzen.
**Zugangsrechte am Proxmox-Server:** Legen Sie dort für den Zugang, den Sie hier eintragen, ausschließlich eine NUR-LESE-Rolle an — Tessera braucht nie mehr. Bei PVE ist das die Rolle **PVEAuditor**, bei PBS **Audit** (beziehungsweise feiner **DatastoreAudit**), bei PMG **Auditor**.
**Zugangsrechte am Proxmox-Server:** Legen Sie dort für den Zugang, den Sie hier eintragen, ausschließlich eine NUR-LESE-Rolle an — Tessera braucht nie mehr. Bei PVE ist das die Rolle **PVEAuditor**, bei PBS **Audit** (beziehungsweise feiner **DatastoreAudit**), bei PMG **Auditor**. Wie Sie diesen Zugang Schritt für Schritt anlegen (bei PVE und PBS als API-Token, bei PMG als eigener Benutzer), steht im Administrationshandbuch unter [Proxmox-Server anbinden](anleitung-administration.md#proxmox-server-anbinden-zugang-nur-zum-lesen-einrichten).
**Zertifikat prüfen:** Dieser Schalter steht standardmäßig auf „prüfen" (an). Nutzen Sie für einen Server ein selbstsigniertes oder sonst nicht vertrauenswürdiges Zertifikat, können Sie die Prüfung für **genau diesen einen Server** ausschalten — die Ausnahme gilt nie für einen anderen Server.
File diff suppressed because one or more lines are too long
+88
View File
@@ -288,3 +288,91 @@ export const CUSTOM_MODULE_CATEGORY = "custom-modules" as const;
export const CUSTOM_MODULE_CATEGORIES = [...MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY] as const;
export type CustomModuleCategory = (typeof CUSTOM_MODULE_CATEGORIES)[number];
/**
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail).
*
* Eine Stelle fuer API und Oberflaeche: welche Platzhalter es gibt, wie lang
* die sechs Textfelder sein duerfen, wie die Standardtexte lauten (Vorbelegung
* im Formular, "Auf Standard zuruecksetzen", Versand ohne eigene Vorlage) und
* wie unbekannte Platzhalter erkannt werden. Die API lehnt eine Vorlage mit
* unbekanntem Platzhalter mit 400 ab; das Formular benennt ihn schon vorher.
*
* Die festen Bausteine der Mail (Kopf, Kasten Adresse/Benutzername, Knopf
* "Passwort festlegen" samt Gueltigkeitshinweis, Knopf "Zu Tessera",
* Fusszeile) sind KEIN Teil der Vorlage — so kann eine eigene Vorlage die
* Anmeldung nicht kaputt machen. Anpassbar ist nur der Anmeldehinweis-TEXT
* davor, getrennt fuer Verzeichniskonten und lokale Konten.
*/
export const WELCOME_MAIL_PLACEHOLDERS = [
'name',
'vorname',
'benutzername',
'email',
'adresse',
'firma',
] as const;
export type WelcomeMailPlaceholder = (typeof WELCOME_MAIL_PLACEHOLDERS)[number];
/** Die sechs bearbeitbaren Texte der Willkommensmail. */
export interface WelcomeMailTexts {
subject: string;
heading: string;
intro: string;
/** Anmeldehinweis fuer verzeichnisgefuehrte Konten (Windows-Passwort). */
loginHintDirectory: string;
/** Anmeldehinweis fuer lokale Konten, steht vor dem Knopf "Passwort festlegen". */
loginHintLocal: string;
closing: string;
}
/** Hoechstlaengen je Feld (Zeichen). */
export const WELCOME_MAIL_LIMITS: Readonly<Record<keyof WelcomeMailTexts, number>> = {
subject: 200,
heading: 200,
intro: 4000,
loginHintDirectory: 1000,
loginHintLocal: 1000,
closing: 4000,
};
/**
* Standardtexte — entsprechen der Willkommensmail vor der eigenen Vorlage.
* Leerzeile = neuer Absatz, einfacher Zeilenumbruch = Umbruch im Absatz.
*/
export const DEFAULT_WELCOME_MAIL_TEXTS: Readonly<WelcomeMailTexts> = {
subject: 'Willkommen bei Tessera',
heading: 'Willkommen bei Tessera, {{name}}!',
intro:
'Für Sie wurde ein Zugang zu Tessera eingerichtet – Ihrer zentralen Plattform für Werkzeuge und Abläufe im Unternehmen. Alles, was Sie für Ihre tägliche Arbeit brauchen, finden Sie dort an einem Ort.',
loginHintDirectory:
'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.',
loginHintLocal:
'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.',
closing:
'Tipp: Tessera gibt es auch als Desktop-App – den Download finden Sie auf der Anmeldeseite.\n\nViel Erfolg mit Tessera!',
};
/** Findet `{{ … }}`; der Name ohne Leerraum steht in Gruppe 1. */
export const WELCOME_MAIL_PLACEHOLDER_RE = /\{\{\s*([^{}]*?)\s*\}\}/g;
/** Ob `name` (Gross-/Kleinschreibung egal) ein bekannter Platzhalter ist. */
export function isWelcomeMailPlaceholder(name: string): name is WelcomeMailPlaceholder {
return (WELCOME_MAIL_PLACEHOLDERS as readonly string[]).includes(name.toLowerCase());
}
/**
* Alle unbekannten Platzhalter eines Textes in der Form `{{xyz}}`, ohne
* Doppelte, in Reihenfolge des ersten Auftretens.
*/
export function findUnknownWelcomeMailPlaceholders(text: string): string[] {
const unknown: string[] = [];
for (const match of text.matchAll(WELCOME_MAIL_PLACEHOLDER_RE)) {
const token = `{{${match[1]}}}`;
if (!isWelcomeMailPlaceholder(match[1]) && !unknown.includes(token)) {
unknown.push(token);
}
}
return unknown;
}