Compare commits
10 Commits
31d514b7ca
..
v1.9.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 645c5e5887 | |||
| 2dd11b439d | |||
| 59b8cd43fc | |||
| 5919a55cbf | |||
| 7edaf8c00b | |||
| 61a971ccc0 | |||
| 471cfbf98b | |||
| a257bc3f86 | |||
| 714f731ac9 | |||
| e10da76259 |
@@ -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>
|
||||||
@@ -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
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
|
|||||||
status: verified
|
status: verified
|
||||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||||
last_updated: "2026-09-23T15:30:00.000Z"
|
last_updated: "2026-09-23T15:30:00.000Z"
|
||||||
last_activity: 2026-09-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
|
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
|
||||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||||
progress:
|
progress:
|
||||||
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
|||||||
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||||
Plan: 6 of 6
|
Plan: 6 of 6
|
||||||
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
||||||
Last activity: 2026-09-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%
|
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-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
|
||||||
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
|
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
|
||||||
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
|
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
|
||||||
|
| 261001-cxo | Desktop-Client: Links mit target=_blank (Favoriten) oeffnen jetzt im System-Browser (DesktopExternalLinks -> window.open) | 2026-10-01 | 61a971c | [261001-cxo](./quick/261001-cxo-desktop-client-links-mit-target-blank-oe/) |
|
||||||
|
| 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) |
|
||||||
|
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
|
||||||
|
|
||||||
## Deferred Items
|
## Deferred Items
|
||||||
|
|
||||||
|
|||||||
+17
@@ -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.
|
||||||
+19
@@ -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“.
|
||||||
+14
@@ -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“.
|
||||||
+10
@@ -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.
|
||||||
+14
@@ -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.
|
||||||
+10
@@ -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).
|
||||||
+12
-1
@@ -4,8 +4,19 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
## Unveröffentlicht
|
## Unveröffentlicht
|
||||||
|
|
||||||
|
## 1.9.1 – 2026-10-01
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Desktop-App: Favoriten und andere Links, die sich in einem neuen Fenster öffnen (etwa „In neuem Tab öffnen“ oder Quellen im Ausschreibungs-Radar), öffnen sich jetzt in Ihrem normalen Browser. Bisher passierte beim Klick in der Desktop-App nichts.
|
||||||
|
- Erinnerungen: Beim Schreiben der Beschreibung springt der Cursor nicht mehr in die Titelzeile zurück. Bisher passierte das alle paar Sekunden, weil sich die Kachel regelmäßig neu aufbaut.
|
||||||
|
- Favoriten: Auch Seiten, die ihr Logo erst beim Laden im Browser setzen (etwa Host Europe), zeigen jetzt ihr Logo statt nur des Anfangsbuchstabens. Findet Tessera auf der Seite selbst kein Logo, fragt es bei öffentlichen Adressen einen Logo-Dienst; interne Adressen werden dabei nie weitergegeben. Das gilt auch für bereits angelegte Favoriten.
|
||||||
|
|
||||||
|
## 1.9.0 – 2026-09-30
|
||||||
|
|
||||||
### Neu
|
### 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: 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“.
|
- Benutzerverwaltung: Neue Spalte „Letzte Anmeldung“.
|
||||||
|
|
||||||
@@ -16,7 +27,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
### Behoben
|
### Behoben
|
||||||
|
|
||||||
- Anmeldung: Wer schon angemeldet ist und die Anmeldeseite aufruft, landet jetzt direkt auf dem Dashboard.
|
- 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.
|
- 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.
|
- 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
|
## 1.8.0 – 2026-09-30
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -1,7 +1,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="112" viewBox="0 0 1200 112">
|
<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).
|
Wellenstreifen unter dem Kopf der Tessera-Systemmails (Willkommensmail).
|
||||||
Quelle des PNG daneben (welcome-header.png, 1200x112, angezeigt 600x56);
|
Quelle des PNG daneben (welcome-header.png, 1200x80, angezeigt 600x40);
|
||||||
erzeugt mit `node apps/api/scripts/render-mail-header.mjs`.
|
erzeugt mit `node apps/api/scripts/render-mail-header.mjs`.
|
||||||
|
|
||||||
Seit quick-260930 (Rueckmeldung des Nutzers: in Outlook "ein riesiger
|
Seit quick-260930 (Rueckmeldung des Nutzers: in Outlook "ein riesiger
|
||||||
@@ -11,14 +11,17 @@
|
|||||||
Duenen-Wellen des Dashboard-Hintergrunds (dunkle Fassung,
|
Duenen-Wellen des Dashboard-Hintergrunds (dunkle Fassung,
|
||||||
apps/web/src/lib/dashboard-background.ts) mit der feinen gelben Linie,
|
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
|
unten laeuft es ins Weiss der Karte aus. Zeigt ein Mailprogramm das Bild
|
||||||
nicht, bleibt dort nur weisser Abstand.
|
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="112" fill="#ffffff"/>
|
<rect width="1200" height="80" fill="#ffffff"/>
|
||||||
<!-- Flaechen von unten nach oben uebereinander, jede von oben bis zu ihrer
|
<!-- Flaechen von unten nach oben uebereinander, jede von oben bis zu ihrer
|
||||||
Wellenlinie: so teilen sich benachbarte Baender dieselbe Kante, ohne
|
Wellenlinie: so teilen sich benachbarte Baender dieselbe Kante, ohne
|
||||||
Luecken dazwischen. -->
|
Luecken dazwischen. -->
|
||||||
<path d="M0 0 V92 C220 78 420 102 600 96 S1000 76 1200 86 V0 Z" fill="#d9dce0"/>
|
<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 V70 C250 50 420 86 640 76 S1040 50 1200 64 V0 Z" fill="#2c3036"/>
|
<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 V44 C330 18 520 62 700 52 S1060 22 1200 40 V0 Z" fill="#1a1c20"/>
|
<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 44 C330 18 520 62 700 52 S1060 22 1200 40" fill="none" stroke="#ffed00" stroke-opacity="0.75" stroke-width="3"/>
|
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -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;
|
||||||
@@ -767,3 +767,24 @@ model Reminder {
|
|||||||
@@index([tenantId, userId, dueAt])
|
@@index([tenantId, userId, dueAt])
|
||||||
@@index([dueAt])
|
@@index([dueAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
|
||||||
|
// hoechstens eine je Mandant; fehlt sie, gelten die Standardtexte aus
|
||||||
|
// @tessera/shared (DEFAULT_WELCOME_MAIL_TEXTS). Nur die sechs Texte —
|
||||||
|
// Kopf, Zugangsdaten und Knoepfe bleiben fest im Code.
|
||||||
|
model WelcomeMailTemplate {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @unique
|
||||||
|
subject String
|
||||||
|
heading String
|
||||||
|
intro String
|
||||||
|
// Anmeldehinweise je Kontoart (Migration 20260930170000); NULL in einer
|
||||||
|
// aelteren Vorlage = Standardtext aus @tessera/shared
|
||||||
|
loginHintDirectory String?
|
||||||
|
loginHintLocal String?
|
||||||
|
closing String
|
||||||
|
// Benutzername des Administrators, der zuletzt gespeichert hat (Anzeige)
|
||||||
|
updatedBy String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
||||||
* (apps/api/assets/mail/welcome-header.png, 1200x112 fuer hochaufloesende
|
* (apps/api/assets/mail/welcome-header.png, 1200x80 fuer hochaufloesende
|
||||||
* Bildschirme, in der Mail 600x56 angezeigt) aus der daneben liegenden
|
* Bildschirme, in der Mail 600x40 angezeigt) aus der daneben liegenden
|
||||||
* Quelle welcome-header.svg.
|
* Quelle welcome-header.svg.
|
||||||
*
|
*
|
||||||
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
||||||
@@ -33,7 +33,7 @@ const sharp = createRequire(nextPkg)('sharp');
|
|||||||
|
|
||||||
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
||||||
const png = await sharp(svg, { density: 72 })
|
const png = await sharp(svg, { density: 72 })
|
||||||
.resize(1200, 112)
|
.resize(1200, 80)
|
||||||
.png({ compressionLevel: 9, palette: false })
|
.png({ compressionLevel: 9, palette: false })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
||||||
|
|||||||
@@ -220,7 +220,11 @@ function expectBoundCall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeIconDiscovery(
|
function makeIconDiscovery(
|
||||||
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {},
|
overrides: Partial<{
|
||||||
|
discoverFavoriteIconUrl: any;
|
||||||
|
fetchIconBytes: any;
|
||||||
|
fetchPublicServiceIconBytes: any;
|
||||||
|
}> = {},
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
discoverFavoriteIconUrl:
|
discoverFavoriteIconUrl:
|
||||||
@@ -229,6 +233,11 @@ function makeIconDiscovery(
|
|||||||
fetchIconBytes:
|
fetchIconBytes:
|
||||||
overrides.fetchIconBytes ??
|
overrides.fetchIconBytes ??
|
||||||
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
|
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
|
||||||
|
fetchPublicServiceIconBytes:
|
||||||
|
overrides.fetchPublicServiceIconBytes ??
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw new Error('icon service: unknown');
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +520,35 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('quick-261001-hbi: gespeichertes Symbol scheitert -> Symbol-Dienst mit der Seiten-URL', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery({
|
||||||
|
fetchIconBytes: vi.fn(async () => {
|
||||||
|
throw new Error('not an image');
|
||||||
|
}),
|
||||||
|
fetchPublicServiceIconBytes: vi.fn(async () => ({
|
||||||
|
contentType: 'image/png',
|
||||||
|
body: Buffer.from('ddg'),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(iconDiscovery.fetchPublicServiceIconBytes).toHaveBeenCalledWith(baseRow.url);
|
||||||
|
expect(result.body).toEqual(Buffer.from('ddg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quick-261001-hbi: gespeichertes Symbol klappt -> Symbol-Dienst wird nicht gefragt', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(iconDiscovery.fetchPublicServiceIconBytes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
|
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
|
||||||
const prisma = makeFakePrisma([baseRow]);
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
const iconDiscovery = makeIconDiscovery({
|
const iconDiscovery = makeIconDiscovery({
|
||||||
|
|||||||
@@ -497,7 +497,9 @@ export class FavoritesService {
|
|||||||
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
||||||
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
||||||
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
||||||
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
|
* timeout, non-image, or SSRF-blocked) AND the public icon service fallback
|
||||||
|
* (quick-261001-hbi, public pages only) has no icon either -- never returns
|
||||||
|
* a placeholder image.
|
||||||
*/
|
*/
|
||||||
async getIconBytes(
|
async getIconBytes(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -536,7 +538,14 @@ export class FavoritesService {
|
|||||||
try {
|
try {
|
||||||
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
|
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
// 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)', () => {
|
describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ const MAX_REDIRECTS = 2;
|
|||||||
const MAX_HTML_CHARS = 200000;
|
const MAX_HTML_CHARS = 200000;
|
||||||
const MAX_ICON_BYTES = 1_000_000;
|
const MAX_ICON_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-261001-hbi — oeffentlicher Symbol-Dienst als letzter Rueckfall. Manche
|
||||||
|
* Seiten setzen ihr Symbol erst per JavaScript (hosteurope.de: im HTML nur
|
||||||
|
* `<link rel="icon" href="data:;base64,=">`, `/favicon.ico` liefert eine
|
||||||
|
* HTML-Seite) — ohne Browser findet die Suche dort nichts. DuckDuckGo kennt
|
||||||
|
* das gerenderte Symbol und antwortet fuer Unbekanntes mit 404 (dann bleibt
|
||||||
|
* der Buchstabe). Gefragt wird NUR fuer oeffentlich erreichbare Adressen,
|
||||||
|
* damit interne Hostnamen (docuvita.ctl.local, private IPs) das Haus nie
|
||||||
|
* verlassen; der Dienst erfaehrt nur den Hostnamen.
|
||||||
|
*/
|
||||||
|
const PUBLIC_ICON_SERVICE = 'https://icons.duckduckgo.com/ip3/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
|
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
|
||||||
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
|
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
|
||||||
@@ -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
|
* Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming
|
||||||
* back to the browser from Tessera's own origin (avoids Cross-Origin-
|
* back to the browser from Tessera's own origin (avoids Cross-Origin-
|
||||||
|
|||||||
@@ -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('<h2><script>x</script></h2>');
|
||||||
|
expect(mail.html).toContain('<title>Hallo Muster & 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 <b>Firmenkennwort</b>.</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;');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,15 @@
|
|||||||
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
|
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
|
||||||
* Anmeldehinweis" `WelcomeMailService`.
|
* 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
|
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
|
||||||
* Mail sehr unterschiedlich darstellen:
|
* Mail sehr unterschiedlich darstellen:
|
||||||
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
|
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
|
||||||
@@ -25,6 +34,14 @@
|
|||||||
* Windows-Passwort.
|
* 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). */
|
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
|
||||||
const C = {
|
const C = {
|
||||||
page: '#eceef1',
|
page: '#eceef1',
|
||||||
@@ -46,17 +63,36 @@ const FONT =
|
|||||||
export type WelcomeMailAccount =
|
export type WelcomeMailAccount =
|
||||||
| { kind: 'directory' }
|
| { kind: 'directory' }
|
||||||
| { kind: 'local'; setPasswordUrl: string; validHours: number }
|
| { kind: 'local'; setPasswordUrl: string; validHours: number }
|
||||||
| { kind: 'local-no-link' };
|
/**
|
||||||
|
* 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 {
|
export interface WelcomeMailInput {
|
||||||
/** Anzeigename, sonst Benutzername. */
|
/** Anzeigename, sonst Benutzername. */
|
||||||
name: string;
|
name: string;
|
||||||
username: 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. */
|
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
|
||||||
appUrl: string;
|
appUrl: string;
|
||||||
account: WelcomeMailAccount;
|
account: WelcomeMailAccount;
|
||||||
/** `cid:...` im Versand, relativer Pfad in der Vorschau, `null` = Textkopf. */
|
/** `cid:...` im Versand, `data:` in der Vorschau, `null` = Textkopf. */
|
||||||
headerImageSrc: string | null;
|
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 {
|
export interface RenderedWelcomeMail {
|
||||||
@@ -65,13 +101,55 @@ export interface RenderedWelcomeMail {
|
|||||||
html: string;
|
html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WELCOME_MAIL_SUBJECT = 'Willkommen bei Tessera';
|
export const WELCOME_MAIL_SUBJECT = DEFAULT_WELCOME_MAIL_TEXTS.subject;
|
||||||
|
|
||||||
const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.';
|
const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.';
|
||||||
const INTRO =
|
const EXAMPLE_LINK_NOTE =
|
||||||
'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.';
|
'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.';
|
||||||
const DESKTOP_HINT =
|
|
||||||
'Tessera gibt es auch als Desktop-App – den Download finden Sie auf der 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 {
|
export function escapeHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
@@ -87,12 +165,22 @@ function safeUrl(value: string): string {
|
|||||||
return /^https?:\/\//i.test(value) ? value : '';
|
return /^https?:\/\//i.test(value) ? value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function loginHintText(account: WelcomeMailAccount): string {
|
/**
|
||||||
|
* 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) {
|
switch (account.kind) {
|
||||||
case 'directory':
|
case 'directory':
|
||||||
return 'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.';
|
return pick('loginHintDirectory');
|
||||||
case 'local':
|
case 'local':
|
||||||
return 'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.';
|
case 'local-example':
|
||||||
|
return pick('loginHintLocal');
|
||||||
case 'local-no-link':
|
case 'local-no-link':
|
||||||
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
|
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
|
||||||
}
|
}
|
||||||
@@ -154,8 +242,10 @@ ${row([olive, olive, C.yellow])}${spacer}${row([null, olive, null])}${spacer}${r
|
|||||||
* - Bildmarke (HTML-Kacheln) und Schriftzug "Tessera" (echter Text) in einer
|
* - Bildmarke (HTML-Kacheln) und Schriftzug "Tessera" (echter Text) in einer
|
||||||
* niedrigen dunklen Leiste — erscheinen immer;
|
* niedrigen dunklen Leiste — erscheinen immer;
|
||||||
* - darunter die Duenen-Welle als schmaler Bildstreifen (`src`, im Versand
|
* - darunter die Duenen-Welle als schmaler Bildstreifen (`src`, im Versand
|
||||||
* `cid:`), der ins Weiss der Karte auslaeuft. Fehlt er, bleibt nur weisser
|
* `cid:`, 600x40), der ins Weiss der Karte auslaeuft. Die Zelle um das
|
||||||
* Abstand, kein dunkler Block.
|
* 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 {
|
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;">
|
const bar = `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:22px 32px 14px;">
|
||||||
@@ -165,30 +255,53 @@ function headerRow(src: string | null): string {
|
|||||||
</tr></table>
|
</tr></table>
|
||||||
</td></tr>`;
|
</td></tr>`;
|
||||||
const wave = src
|
const wave = src
|
||||||
? `<tr><td bgcolor="${C.card}" style="background-color:${C.card};line-height:0;font-size:0;">
|
? `<tr><td bgcolor="${C.header}" style="background-color:${C.header};line-height:0;font-size:0;">
|
||||||
<img src="${escapeHtml(src)}" width="600" height="56" alt="" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;">
|
<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>`
|
</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};"> </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};"> </td></tr>`;
|
||||||
return bar + wave;
|
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 {
|
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
|
||||||
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
|
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
|
||||||
const loginUrl = `${base}/login`;
|
const loginUrl = `${base}/login`;
|
||||||
const hint = loginHintText(input.account);
|
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 ───────────────────────────────────────────────────
|
// ── Text-Alternative ───────────────────────────────────────────────────
|
||||||
const textLines = [
|
const textLines: string[] = [];
|
||||||
`Guten Tag ${input.name},`,
|
if (notice) textLines.push(`[${notice}]`, '');
|
||||||
'',
|
if (heading) textLines.push(heading, '');
|
||||||
INTRO,
|
for (const para of introParagraphs) textLines.push(para, '');
|
||||||
'',
|
textLines.push('Ihre Zugangsdaten', `Adresse: ${base}`, `Benutzername: ${input.username}`);
|
||||||
'Ihre Zugangsdaten',
|
for (const para of hintParagraphs) textLines.push('', para);
|
||||||
`Adresse: ${base}`,
|
|
||||||
`Benutzername: ${input.username}`,
|
|
||||||
'',
|
|
||||||
hint,
|
|
||||||
];
|
|
||||||
if (input.account.kind === 'local') {
|
if (input.account.kind === 'local') {
|
||||||
textLines.push(
|
textLines.push(
|
||||||
'',
|
'',
|
||||||
@@ -196,39 +309,63 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
|||||||
input.account.setPasswordUrl,
|
input.account.setPasswordUrl,
|
||||||
validityText(input.account.validHours),
|
validityText(input.account.validHours),
|
||||||
);
|
);
|
||||||
|
} else if (input.account.kind === 'local-example') {
|
||||||
|
textLines.push('', 'Passwort festlegen:', loginUrl, EXAMPLE_LINK_NOTE);
|
||||||
}
|
}
|
||||||
textLines.push(
|
textLines.push('', 'Zu Tessera:', loginUrl);
|
||||||
'',
|
for (const para of closingParagraphs) textLines.push('', para);
|
||||||
'Zu Tessera:',
|
textLines.push('', '--', FOOTER);
|
||||||
loginUrl,
|
|
||||||
'',
|
|
||||||
DESKTOP_HINT,
|
|
||||||
'',
|
|
||||||
'Viel Erfolg mit Tessera!',
|
|
||||||
'',
|
|
||||||
'--',
|
|
||||||
FOOTER,
|
|
||||||
);
|
|
||||||
const text = textLines.join('\n');
|
const text = textLines.join('\n');
|
||||||
|
|
||||||
// ── HTML ───────────────────────────────────────────────────────────────
|
// ── HTML ───────────────────────────────────────────────────────────────
|
||||||
const name = escapeHtml(input.name);
|
|
||||||
const username = escapeHtml(input.username);
|
const username = escapeHtml(input.username);
|
||||||
const baseHtml = escapeHtml(base);
|
const baseHtml = escapeHtml(base);
|
||||||
|
|
||||||
const p = (content: string, extra = '') =>
|
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>`;
|
`<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) =>
|
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>`;
|
`<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 = p(escapeHtml(hint), 'margin:24px 0 16px;');
|
let accountBlock = hintParagraphs
|
||||||
|
.map((part, i) => p(para(part), i === 0 ? 'margin:24px 0 16px;' : ''))
|
||||||
|
.join('\n');
|
||||||
if (input.account.kind === 'local') {
|
if (input.account.kind === 'local') {
|
||||||
const setUrl = safeUrl(input.account.setPasswordUrl);
|
const setUrl = safeUrl(input.account.setPasswordUrl);
|
||||||
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
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>`;
|
<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>
|
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">
|
<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>
|
<head>
|
||||||
@@ -237,7 +374,7 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
|||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<meta name="supported-color-schemes" content="light">
|
<meta name="supported-color-schemes" content="light">
|
||||||
<title>${escapeHtml(WELCOME_MAIL_SUBJECT)}</title>
|
<title>${escapeHtml(subject)}</title>
|
||||||
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
||||||
<style>
|
<style>
|
||||||
a { color: ${C.link}; }
|
a { color: ${C.link}; }
|
||||||
@@ -252,10 +389,9 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
|||||||
<tr><td align="center" style="padding:32px 12px;">
|
<tr><td align="center" style="padding:32px 12px;">
|
||||||
<!--[if mso]><table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
|
<!--[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;">
|
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;border-collapse:separate;">
|
||||||
${headerRow(input.headerImageSrc)}
|
${noticeRow}${headerRow(input.headerImageSrc)}
|
||||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:36px 40px 12px;">
|
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:24px 40px 12px;">
|
||||||
<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">Willkommen bei Tessera, ${name}!</h1>
|
${headingHtml}${introHtml}
|
||||||
${p(escapeHtml(INTRO.charAt(0).toUpperCase() + INTRO.slice(1)))}
|
|
||||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;margin:8px 0 0;">
|
<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;">
|
<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')}
|
${label('Adresse')}
|
||||||
@@ -269,10 +405,7 @@ ${accountBlock}
|
|||||||
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
|
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
|
||||||
</td></tr>
|
</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;">
|
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:20px 40px 36px;border-radius:0 0 12px 12px;">
|
||||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
|
${closingHtml}
|
||||||
${p(`<strong style="color:${C.ink};">Tipp:</strong> ${escapeHtml(DESKTOP_HINT)}`, `font-size:14px;line-height:22px;margin:0 0 12px;`)}
|
|
||||||
${p('Viel Erfolg mit Tessera!', 'font-size:14px;line-height:22px;margin:0;')}
|
|
||||||
</td></tr></table>
|
|
||||||
</td></tr>
|
</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>
|
<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>
|
</table>
|
||||||
@@ -282,5 +415,5 @@ ${p('Viel Erfolg mit Tessera!', 'font-size:14px;line-height:22px;margin:0;')}
|
|||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
return { subject: WELCOME_MAIL_SUBJECT, text, 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';
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { AdminSeedService } from './admin-seed.service';
|
|||||||
import { UserController } from './user.controller';
|
import { UserController } from './user.controller';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { WelcomeMailService } from './welcome-mail.service';
|
import { WelcomeMailService } from './welcome-mail.service';
|
||||||
|
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
|
||||||
|
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Importiert GroupsModule für UserService.create's Standardgruppen-
|
* Importiert GroupsModule für UserService.create's Standardgruppen-
|
||||||
@@ -15,11 +17,17 @@ import { WelcomeMailService } from './welcome-mail.service';
|
|||||||
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
|
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
|
||||||
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
|
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
|
||||||
* UserModule.
|
* UserModule.
|
||||||
|
*
|
||||||
|
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
|
||||||
|
* `WelcomeMailTemplateService` (Speicherung je Mandant) und
|
||||||
|
* `WelcomeMailTemplateController` (`/welcome-mail-template`) liegen hier,
|
||||||
|
* weil Vorschau und Testmail denselben Weg wie der Versand nutzen
|
||||||
|
* (`WelcomeMailService`: Adresse, Kopfbild, SMTP des Mandanten).
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [GroupsModule, MailModule],
|
imports: [GroupsModule, MailModule],
|
||||||
controllers: [UserController],
|
controllers: [UserController, WelcomeMailTemplateController],
|
||||||
providers: [UserService, AdminSeedService, WelcomeMailService],
|
providers: [UserService, AdminSeedService, WelcomeMailService, WelcomeMailTemplateService],
|
||||||
exports: [UserService],
|
exports: [UserService],
|
||||||
})
|
})
|
||||||
export class UserModule {}
|
export class UserModule {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { normalizeOrigin, type WelcomeMailTarget, WelcomeMailService } from './welcome-mail.service';
|
import {
|
||||||
|
normalizeOrigin,
|
||||||
|
WelcomeMailService,
|
||||||
|
type WelcomeMailTarget,
|
||||||
|
} from './welcome-mail.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
|
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
|
||||||
@@ -34,6 +39,10 @@ function makePrisma() {
|
|||||||
log.push({ tenantId, model: 'user', method: 'update', args });
|
log.push({ tenantId, model: 'user', method: 'update', args });
|
||||||
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
|
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
|
||||||
}),
|
}),
|
||||||
|
findFirst: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'user', method: 'findFirst', args });
|
||||||
|
return selfRow;
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -73,36 +82,74 @@ const ldapUser: WelcomeMailTarget = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let prisma: ReturnType<typeof makePrisma>;
|
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(() => {
|
beforeEach(() => {
|
||||||
prisma = makePrisma();
|
prisma = makePrisma();
|
||||||
|
selfRow = {
|
||||||
|
username: 'admin.lokal',
|
||||||
|
displayName: 'Ada Admin',
|
||||||
|
email: 'ada@example.invalid',
|
||||||
|
ldapDn: null,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function make(mail = makeMail(), config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' })) {
|
/** Vorlagen-Dienst: eigene Vorlage je Mandant (Map), Firmenname je Mandant. */
|
||||||
const service = new WelcomeMailService(prisma as any, mail as any, config as any);
|
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, 'log').mockImplementation(() => undefined);
|
||||||
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
|
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
|
||||||
return { service, mail };
|
return { service, mail, templates };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('WelcomeMailService.send — Vorbedingungen', () => {
|
describe('WelcomeMailService.send — Vorbedingungen', () => {
|
||||||
it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => {
|
it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => {
|
||||||
const { service, mail } = make();
|
const { service, mail } = make();
|
||||||
await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget);
|
await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget);
|
||||||
await service.send({ ...localUser, lastLoginAt: new Date(), role: 'SUPER_ADMIN' } as WelcomeMailTarget);
|
await service.send({
|
||||||
|
...localUser,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
} as WelcomeMailTarget);
|
||||||
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2);
|
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2);
|
||||||
expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2);
|
expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deaktiviertes Konto → ConflictException', async () => {
|
it('deaktiviertes Konto → ConflictException', async () => {
|
||||||
const { service, mail } = make();
|
const { service, mail } = make();
|
||||||
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(ConflictException);
|
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ohne E-Mail-Adresse → BadRequestException', async () => {
|
it('ohne E-Mail-Adresse → BadRequestException', async () => {
|
||||||
const { service, mail } = make();
|
const { service, mail } = make();
|
||||||
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(BadRequestException);
|
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,18 +229,27 @@ describe('WelcomeMailService.send — Versand', () => {
|
|||||||
|
|
||||||
describe('WelcomeMailService.resolveAppUrl', () => {
|
describe('WelcomeMailService.resolveAppUrl', () => {
|
||||||
it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => {
|
it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => {
|
||||||
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }));
|
const { service } = make(
|
||||||
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe('https://tessera.example.invalid');
|
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', () => {
|
it('ohne Konfiguration: Origin der Anfrage', () => {
|
||||||
const { service } = make(makeMail(), makeConfig({}));
|
const { service } = make(makeMail(), makeConfig({}));
|
||||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
|
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', () => {
|
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' }));
|
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('https://alpha.example.invalid')).toBe(
|
||||||
|
'https://alpha.example.invalid',
|
||||||
|
);
|
||||||
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
|
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,3 +259,173 @@ describe('WelcomeMailService.resolveAppUrl', () => {
|
|||||||
expect(normalizeOrigin('https://a.example.invalid/pfad')).toBe('https://a.example.invalid');
|
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 <b>IT-Team</b>');
|
||||||
|
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('<img src=x onerror=alert(1)> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
BadGatewayException,
|
BadGatewayException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -7,12 +8,17 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
import { randomUUID } from 'node:crypto';
|
import type { WelcomeMailTexts } from '@tessera/shared';
|
||||||
import { WELCOME_TOKEN_TTL_MS } from '../auth/password-reset-token';
|
import { WELCOME_TOKEN_TTL_MS } from '../auth/password-reset-token';
|
||||||
import { loadWelcomeHeaderPng, MailService, WELCOME_HEADER_CID } from '../mail/mail.service';
|
import { loadWelcomeHeaderPng, MailService, WELCOME_HEADER_CID } from '../mail/mail.service';
|
||||||
import { renderWelcomeMail, type WelcomeMailAccount } from '../mail/welcome-mail.template';
|
import {
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
type RenderedWelcomeMail,
|
||||||
|
renderWelcomeMail,
|
||||||
|
type WelcomeMailAccount,
|
||||||
|
} from '../mail/welcome-mail.template';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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
|
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung
|
||||||
@@ -42,6 +48,15 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
|
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
|
||||||
* SMTP-Weg genau dieses Mandanten (`MailService.sendWelcomeMail`).
|
* 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
|
* Adresse in der Mail: `TESSERA_APP_URL` (in Compose aus `APP_URL`), sonst
|
||||||
* der Origin der Admin-Anfrage. Zeigt die Konfiguration nur auf
|
* der Origin der Admin-Anfrage. Zeigt die Konfiguration nur auf
|
||||||
* `localhost` (Compose-Vorgabe, nie gesetzt), gewinnt ein vorhandener
|
* `localhost` (Compose-Vorgabe, nie gesetzt), gewinnt ein vorhandener
|
||||||
@@ -52,13 +67,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
|
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
|
||||||
export type WelcomeMailTarget = Pick<
|
export type WelcomeMailTarget = Pick<
|
||||||
User,
|
User,
|
||||||
| 'id'
|
'id' | 'tenantId' | 'username' | 'displayName' | 'email' | 'ldapDn' | 'isActive'
|
||||||
| 'tenantId'
|
|
||||||
| 'username'
|
|
||||||
| 'displayName'
|
|
||||||
| 'email'
|
|
||||||
| 'ldapDn'
|
|
||||||
| 'isActive'
|
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface WelcomeMailResult {
|
export interface WelcomeMailResult {
|
||||||
@@ -68,6 +77,13 @@ export interface WelcomeMailResult {
|
|||||||
|
|
||||||
const FALLBACK_APP_URL = 'http://localhost:3000';
|
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`. */
|
/** `http(s)://host[:port]` aus einem Origin-Kopf, sonst `null`. */
|
||||||
export function normalizeOrigin(value: string | undefined | null): string | null {
|
export function normalizeOrigin(value: string | undefined | null): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
@@ -97,6 +113,7 @@ export class WelcomeMailService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly mailService: MailService,
|
private readonly mailService: MailService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
|
private readonly templateService: WelcomeMailTemplateService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
|
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
|
||||||
@@ -158,12 +175,19 @@ export class WelcomeMailService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [texts, tenantName] = await Promise.all([
|
||||||
|
this.templateService.getCustomTexts(target.tenantId),
|
||||||
|
this.templateService.getTenantName(target.tenantId),
|
||||||
|
]);
|
||||||
const mail = renderWelcomeMail({
|
const mail = renderWelcomeMail({
|
||||||
name: target.displayName?.trim() || target.username,
|
name: target.displayName?.trim() || target.username,
|
||||||
username: target.username,
|
username: target.username,
|
||||||
|
email: to,
|
||||||
|
tenantName,
|
||||||
appUrl,
|
appUrl,
|
||||||
account,
|
account,
|
||||||
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
||||||
|
texts,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -188,4 +212,98 @@ export class WelcomeMailService {
|
|||||||
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
|
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
|
||||||
return { to, welcomeMailSentAt };
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
|||||||
import { getLocale, getMessages } from 'next-intl/server';
|
import { getLocale, getMessages } from 'next-intl/server';
|
||||||
import { ThemeProvider } from 'next-themes';
|
import { ThemeProvider } from 'next-themes';
|
||||||
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
|
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
|
||||||
|
import { DesktopExternalLinks } from '@/components/desktop/desktop-external-links';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -10,11 +11,7 @@ export const metadata: Metadata = {
|
|||||||
description: 'Modulare Workflow-Plattform',
|
description: 'Modulare Workflow-Plattform',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
@@ -29,6 +26,7 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<DesktopContextMenuGuard />
|
<DesktopContextMenuGuard />
|
||||||
|
<DesktopExternalLinks />
|
||||||
{children}
|
{children}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ export function AdminSidebar() {
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t('admin.welcomeMail'),
|
||||||
|
href: '/admin/welcome-mail',
|
||||||
|
show: true,
|
||||||
|
icon: (
|
||||||
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8" />
|
||||||
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||||
|
<path d="m16 19 2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t('admin.groups'),
|
label: t('admin.groups'),
|
||||||
href: '/admin/groups',
|
href: '/admin/groups',
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import de from '@/messages/de.json';
|
||||||
|
|
||||||
|
// Namensraumfaehiger next-intl-Mock gegen die echten deutschen Texte:
|
||||||
|
// Punktschluessel werden aufgeloest, {param} wird eingesetzt.
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useLocale: () => 'de',
|
||||||
|
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
|
||||||
|
let val: unknown = de;
|
||||||
|
for (const part of `${ns}.${key}`.split('.')) {
|
||||||
|
val = (val as Record<string, unknown> | undefined)?.[part];
|
||||||
|
}
|
||||||
|
if (typeof val !== 'string') return `${ns}.${key}`;
|
||||||
|
return params
|
||||||
|
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
|
||||||
|
: val;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
fetchWelcomeMailTemplate: vi.fn(),
|
||||||
|
saveWelcomeMailTemplate: vi.fn(),
|
||||||
|
resetWelcomeMailTemplate: vi.fn(),
|
||||||
|
previewWelcomeMail: vi.fn(),
|
||||||
|
sendWelcomeMailTest: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/welcome-mail-template-api', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/lib/welcome-mail-template-api')>();
|
||||||
|
return { ...actual, ...api };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { WelcomeMailTemplateRequestError } from '@/lib/welcome-mail-template-api';
|
||||||
|
import { WelcomeMailTemplateEditor } from './welcome-mail-template-editor';
|
||||||
|
|
||||||
|
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
|
||||||
|
const custom = {
|
||||||
|
subject: 'Hallo {{vorname}}',
|
||||||
|
heading: 'Willkommen, {{name}}!',
|
||||||
|
intro: 'Eigene Einleitung',
|
||||||
|
loginHintDirectory: 'Eigener Verzeichnis-Hinweis',
|
||||||
|
loginHintLocal: 'Eigener Lokal-Hinweis',
|
||||||
|
closing: 'Ihr IT-Team',
|
||||||
|
};
|
||||||
|
|
||||||
|
function state(isCustom: boolean) {
|
||||||
|
return {
|
||||||
|
custom: isCustom,
|
||||||
|
texts: isCustom ? { ...custom } : { ...defaults },
|
||||||
|
defaults: { ...defaults },
|
||||||
|
updatedAt: isCustom ? '2026-09-30T10:00:00.000Z' : null,
|
||||||
|
updatedBy: isCustom ? 'ada' : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||||
|
cb(0);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
api.fetchWelcomeMailTemplate.mockResolvedValue(state(false));
|
||||||
|
api.previewWelcomeMail.mockImplementation(async (texts: typeof defaults, account: string) => ({
|
||||||
|
subject: texts.subject,
|
||||||
|
html: `<html><body>Vorschau ${account}</body></html>`,
|
||||||
|
text: '',
|
||||||
|
}));
|
||||||
|
api.saveWelcomeMailTemplate.mockImplementation(async (texts: typeof defaults) => ({
|
||||||
|
...state(true),
|
||||||
|
texts: { ...texts },
|
||||||
|
}));
|
||||||
|
api.resetWelcomeMailTemplate.mockResolvedValue(state(false));
|
||||||
|
api.sendWelcomeMailTest.mockResolvedValue({ success: true, to: 'ada@example.invalid' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderLoaded() {
|
||||||
|
render(<WelcomeMailTemplateEditor />);
|
||||||
|
return (await screen.findByLabelText('Betreff')) as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WelcomeMailTemplateEditor', () => {
|
||||||
|
it('laedt die Standardtexte ins Formular und zeigt Hinweis auf die festen Bausteine', async () => {
|
||||||
|
const subject = await renderLoaded();
|
||||||
|
expect(subject.value).toBe(defaults.subject);
|
||||||
|
expect((screen.getByLabelText('Überschrift') as HTMLInputElement).value).toBe(defaults.heading);
|
||||||
|
expect((screen.getByLabelText('Einleitungstext') as HTMLTextAreaElement).value).toBe(
|
||||||
|
defaults.intro,
|
||||||
|
);
|
||||||
|
expect((screen.getByLabelText('Abschlusstext') as HTMLTextAreaElement).value).toBe(
|
||||||
|
defaults.closing,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
|
||||||
|
'Zurzeit gelten die Standardtexte.',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/So kann eine eigene Vorlage die Anmeldung nicht kaputt machen/),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Hinweisbox Platzhalter: Tabelle mit allen sechs Platzhaltern, Bedeutung und Beispiel', async () => {
|
||||||
|
await renderLoaded();
|
||||||
|
const box = screen.getByRole('region', { name: 'Platzhalter' });
|
||||||
|
const rows = within(box).getAllByRole('row');
|
||||||
|
expect(rows).toHaveLength(7); // Kopf + 6
|
||||||
|
for (const token of [
|
||||||
|
'{{name}}',
|
||||||
|
'{{vorname}}',
|
||||||
|
'{{benutzername}}',
|
||||||
|
'{{email}}',
|
||||||
|
'{{adresse}}',
|
||||||
|
'{{firma}}',
|
||||||
|
]) {
|
||||||
|
expect(within(box).getByRole('button', { name: `${token} einfügen` })).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(within(box).getByText('Name Ihres Mandanten')).toBeTruthy();
|
||||||
|
expect(within(box).getByText('max.mustermann@example.com')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Klick auf einen Platzhalter fuegt ihn an der Cursorposition des zuletzt fokussierten Feldes ein', async () => {
|
||||||
|
const subject = await renderLoaded();
|
||||||
|
fireEvent.change(subject, { target: { value: 'Hallo bei uns' } });
|
||||||
|
subject.focus();
|
||||||
|
subject.setSelectionRange(6, 6);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '{{vorname}} einfügen' }));
|
||||||
|
expect(subject.value).toBe('Hallo {{vorname}} bei uns');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unbekannter Platzhalter wird benannt, Speichern und Testmail sind gesperrt', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderLoaded();
|
||||||
|
const heading = screen.getByLabelText('Überschrift');
|
||||||
|
await user.clear(heading);
|
||||||
|
fireEvent.change(heading, { target: { value: 'Hallo {{xyz}}' } });
|
||||||
|
expect(await screen.findByText('Unbekannter Platzhalter {{xyz}}')).toBeTruthy();
|
||||||
|
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole('button', { name: 'Testmail an mich senden' }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anmeldehinweise: beide Felder nach dem Einleitungstext, mit Standardtext bzw. eigener Vorlage vorbelegt', async () => {
|
||||||
|
await renderLoaded();
|
||||||
|
const dir = screen.getByLabelText(
|
||||||
|
'Anmeldehinweis für Verzeichniskonten',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
const local = screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement;
|
||||||
|
expect(dir.value).toBe(
|
||||||
|
'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.',
|
||||||
|
);
|
||||||
|
expect(local.value).toBe(
|
||||||
|
'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.',
|
||||||
|
);
|
||||||
|
// Reihenfolge: Einleitung → Verzeichnis → lokal → Abschluss
|
||||||
|
const intro = screen.getByLabelText('Einleitungstext');
|
||||||
|
const closing = screen.getByLabelText('Abschlusstext');
|
||||||
|
const follows = (a: Element, b: Element) =>
|
||||||
|
Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||||
|
expect(follows(intro, dir)).toBe(true);
|
||||||
|
expect(follows(dir, local)).toBe(true);
|
||||||
|
expect(follows(local, closing)).toBe(true);
|
||||||
|
expect(screen.getByText(/direkt vor dem Knopf „Passwort festlegen“/)).toBeTruthy();
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
api.fetchWelcomeMailTemplate.mockResolvedValue(state(true));
|
||||||
|
await renderLoaded();
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText('Anmeldehinweis für Verzeichniskonten') as HTMLTextAreaElement).value,
|
||||||
|
).toBe(custom.loginHintDirectory);
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement).value,
|
||||||
|
).toBe(custom.loginHintLocal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anmeldehinweis: Pflichtfeld, Platzhalter-Klick fuegt ein, Fokus schaltet die Vorschau auf die Kontoart', async () => {
|
||||||
|
await renderLoaded();
|
||||||
|
const local = screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(local, { target: { value: 'Hallo ' } });
|
||||||
|
local.focus();
|
||||||
|
local.setSelectionRange(6, 6);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '{{vorname}} einfügen' }));
|
||||||
|
expect(local.value).toBe('Hallo {{vorname}}');
|
||||||
|
expect(screen.getByRole('button', { name: 'Lokales Konto' }).getAttribute('aria-pressed')).toBe(
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(local, { target: { value: ' ' } });
|
||||||
|
expect(await screen.findByText('Bitte füllen Sie dieses Feld aus.')).toBeTruthy();
|
||||||
|
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Speichern schickt alle sechs Texte inkl. Anmeldehinweise', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderLoaded();
|
||||||
|
fireEvent.change(screen.getByLabelText('Anmeldehinweis für Verzeichniskonten'), {
|
||||||
|
target: { value: 'Mit dem Firmenkennwort anmelden, {{vorname}}.' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('Anmeldehinweis für lokale Konten'), {
|
||||||
|
target: { value: 'Erst Passwort setzen.' },
|
||||||
|
});
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
expect(api.saveWelcomeMailTemplate).toHaveBeenCalledWith({
|
||||||
|
...defaults,
|
||||||
|
loginHintDirectory: 'Mit dem Firmenkennwort anmelden, {{vorname}}.',
|
||||||
|
loginHintLocal: 'Erst Passwort setzen.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Speichern schickt die Texte und meldet Erfolg; Status zeigt die eigene Vorlage', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const subject = await renderLoaded();
|
||||||
|
fireEvent.change(subject, { target: { value: 'Neu für {{firma}}' } });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
expect(api.saveWelcomeMailTemplate).toHaveBeenCalledWith({
|
||||||
|
...defaults,
|
||||||
|
subject: 'Neu für {{firma}}',
|
||||||
|
});
|
||||||
|
expect((await screen.findByRole('status')).textContent).toContain(
|
||||||
|
'Die Vorlage wurde gespeichert.',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
|
||||||
|
'Eigene Vorlage aktiv',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('welcome-template-status').textContent).toContain('von ada');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler der API beim Speichern wird angezeigt (400 mit Meldung)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
api.saveWelcomeMailTemplate.mockRejectedValueOnce(
|
||||||
|
new WelcomeMailTemplateRequestError(400, ['Betreff: Unbekannter Platzhalter {{abc}}']),
|
||||||
|
);
|
||||||
|
await renderLoaded();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||||
|
'Betreff: Unbekannter Platzhalter {{abc}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Auf Standard zuruecksetzen: Rueckfrage, DELETE, danach Standardtexte im Formular', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
api.fetchWelcomeMailTemplate.mockResolvedValue(state(true));
|
||||||
|
const subject = await renderLoaded();
|
||||||
|
expect(subject.value).toBe(custom.subject);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Auf Standard zurücksetzen' }));
|
||||||
|
const dialog = screen.getByRole('alertdialog');
|
||||||
|
expect(api.resetWelcomeMailTemplate).not.toHaveBeenCalled();
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Zurücksetzen' }));
|
||||||
|
|
||||||
|
expect(api.resetWelcomeMailTemplate).toHaveBeenCalledTimes(1);
|
||||||
|
await waitFor(() => expect(subject.value).toBe(defaults.subject));
|
||||||
|
expect(screen.getByTestId('welcome-template-status').textContent).toContain('Standardtexte');
|
||||||
|
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Vorschau: iframe mit dem von der API gerenderten HTML, Umschalter lokales Konto', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderLoaded();
|
||||||
|
const frame = screen.getByTitle('Vorschau der Willkommensmail') as HTMLIFrameElement;
|
||||||
|
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau directory'));
|
||||||
|
expect(frame.getAttribute('sandbox')).toBe('');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Lokales Konto' }));
|
||||||
|
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau local'));
|
||||||
|
expect(api.previewWelcomeMail).toHaveBeenLastCalledWith(defaults, 'local', expect.anything());
|
||||||
|
expect(screen.getByTestId('welcome-preview-subject').textContent).toBe(defaults.subject);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Testmail an mich: sendet den aktuellen (ungespeicherten) Inhalt und nennt die Adresse', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const subject = await renderLoaded();
|
||||||
|
fireEvent.change(subject, { target: { value: 'Ungespeichert' } });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Testmail an mich senden' }));
|
||||||
|
expect(api.sendWelcomeMailTest).toHaveBeenCalledWith({ ...defaults, subject: 'Ungespeichert' });
|
||||||
|
expect(api.saveWelcomeMailTemplate).not.toHaveBeenCalled();
|
||||||
|
expect((await screen.findByRole('status')).textContent).toBe(
|
||||||
|
'Die Testmail wurde an ada@example.invalid gesendet.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ladefehler wird angezeigt', async () => {
|
||||||
|
api.fetchWelcomeMailTemplate.mockRejectedValueOnce(new Error('down'));
|
||||||
|
render(<WelcomeMailTemplateEditor />);
|
||||||
|
expect((await screen.findByRole('alert')).textContent).toContain('konnte nicht geladen werden');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
findUnknownWelcomeMailPlaceholders,
|
||||||
|
WELCOME_MAIL_LIMITS,
|
||||||
|
WELCOME_MAIL_PLACEHOLDERS,
|
||||||
|
type WelcomeMailPlaceholder,
|
||||||
|
type WelcomeMailTexts,
|
||||||
|
} from '@tessera/shared';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
fetchWelcomeMailTemplate,
|
||||||
|
previewWelcomeMail,
|
||||||
|
resetWelcomeMailTemplate,
|
||||||
|
saveWelcomeMailTemplate,
|
||||||
|
sendWelcomeMailTest,
|
||||||
|
type WelcomeMailPreview,
|
||||||
|
WelcomeMailTemplateRequestError,
|
||||||
|
type WelcomeMailTemplateState,
|
||||||
|
} from '@/lib/welcome-mail-template-api';
|
||||||
|
|
||||||
|
type Field = keyof WelcomeMailTexts;
|
||||||
|
type PreviewAccount = 'directory' | 'local';
|
||||||
|
type Feedback = { type: 'success' | 'error'; message: string } | null;
|
||||||
|
|
||||||
|
const FIELDS: Field[] = [
|
||||||
|
'subject',
|
||||||
|
'heading',
|
||||||
|
'intro',
|
||||||
|
'loginHintDirectory',
|
||||||
|
'loginHintLocal',
|
||||||
|
'closing',
|
||||||
|
];
|
||||||
|
const MULTILINE: Record<Field, boolean> = {
|
||||||
|
subject: false,
|
||||||
|
heading: false,
|
||||||
|
intro: true,
|
||||||
|
loginHintDirectory: true,
|
||||||
|
loginHintLocal: true,
|
||||||
|
closing: true,
|
||||||
|
};
|
||||||
|
/** Pflichtfelder (dieselben wie im DTO der API). */
|
||||||
|
const REQUIRED: ReadonlySet<Field> = new Set([
|
||||||
|
'subject',
|
||||||
|
'heading',
|
||||||
|
'loginHintDirectory',
|
||||||
|
'loginHintLocal',
|
||||||
|
]);
|
||||||
|
/** Zeilen der mehrzeiligen Felder. */
|
||||||
|
const ROWS: Partial<Record<Field, number>> = {
|
||||||
|
intro: 6,
|
||||||
|
loginHintDirectory: 3,
|
||||||
|
loginHintLocal: 3,
|
||||||
|
closing: 5,
|
||||||
|
};
|
||||||
|
/** Welche Kontoart die Vorschau zeigt, wenn ein Anmeldehinweis bearbeitet wird. */
|
||||||
|
const HINT_ACCOUNT: Partial<Record<Field, PreviewAccount>> = {
|
||||||
|
loginHintDirectory: 'directory',
|
||||||
|
loginHintLocal: 'local',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Verzoegerung der Live-Vorschau nach der letzten Eingabe. */
|
||||||
|
const PREVIEW_DELAY_MS = 400;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
|
||||||
|
*
|
||||||
|
* Sechs Textfelder (Betreff, Ueberschrift, Einleitung, Anmeldehinweis fuer
|
||||||
|
* Verzeichniskonten, Anmeldehinweis fuer lokale Konten, Abschluss), eine
|
||||||
|
* Hinweisbox mit den Platzhaltern (Klick fuegt an der Cursorposition des
|
||||||
|
* zuletzt benutzten Feldes ein), eine Live-Vorschau (von der API gerendert,
|
||||||
|
* also dieselbe Mail wie im Versand) mit Umschalter Verzeichniskonto/lokales
|
||||||
|
* Konto (springt beim Bearbeiten eines Anmeldehinweises auf die passende
|
||||||
|
* Kontoart), "Testmail an mich senden" und "Auf Standard zuruecksetzen".
|
||||||
|
*
|
||||||
|
* Unbekannte Platzhalter benennt das Formular schon beim Tippen; die API
|
||||||
|
* lehnt sie beim Speichern ohnehin mit 400 ab (Meldung wird angezeigt).
|
||||||
|
*/
|
||||||
|
export function WelcomeMailTemplateEditor() {
|
||||||
|
const t = useTranslations('admin.welcomeMail');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const [state, setState] = useState<WelcomeMailTemplateState | null>(null);
|
||||||
|
const [form, setForm] = useState<WelcomeMailTexts | null>(null);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
|
const [feedback, setFeedback] = useState<Feedback>(null);
|
||||||
|
|
||||||
|
const [account, setAccount] = useState<PreviewAccount>('directory');
|
||||||
|
const [preview, setPreview] = useState<WelcomeMailPreview | null>(null);
|
||||||
|
const [previewFailed, setPreviewFailed] = useState(false);
|
||||||
|
|
||||||
|
const fieldRefs = useRef<Partial<Record<Field, HTMLInputElement | HTMLTextAreaElement | null>>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const lastFocused = useRef<Field>('intro');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchWelcomeMailTemplate()
|
||||||
|
.then((loaded) => {
|
||||||
|
setState(loaded);
|
||||||
|
setForm({ ...loaded.texts });
|
||||||
|
})
|
||||||
|
.catch(() => setLoadFailed(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Pruefung im Formular (dieselben Regeln wie die API) ───────────────
|
||||||
|
const fieldErrors = (field: Field, value: string): string[] => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (REQUIRED.has(field) && !value.trim()) {
|
||||||
|
errors.push(t('errors.required'));
|
||||||
|
}
|
||||||
|
if (value.length > WELCOME_MAIL_LIMITS[field]) {
|
||||||
|
errors.push(t('errors.tooLong', { max: WELCOME_MAIL_LIMITS[field] }));
|
||||||
|
}
|
||||||
|
const unknown = findUnknownWelcomeMailPlaceholders(value);
|
||||||
|
if (unknown.length === 1) errors.push(t('errors.unknownPlaceholder', { token: unknown[0] }));
|
||||||
|
if (unknown.length > 1) {
|
||||||
|
errors.push(t('errors.unknownPlaceholders', { tokens: unknown.join(', ') }));
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorsByField = form
|
||||||
|
? (Object.fromEntries(FIELDS.map((f) => [f, fieldErrors(f, form[f])])) as Record<
|
||||||
|
Field,
|
||||||
|
string[]
|
||||||
|
>)
|
||||||
|
: null;
|
||||||
|
const hasErrors = errorsByField ? FIELDS.some((f) => errorsByField[f].length > 0) : false;
|
||||||
|
const dirty = !!form && !!state && FIELDS.some((f) => form[f] !== state.texts[f]);
|
||||||
|
|
||||||
|
// ── Live-Vorschau ─────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!form || hasErrors) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
previewWelcomeMail(form, account, controller.signal)
|
||||||
|
.then((result) => {
|
||||||
|
setPreview(result);
|
||||||
|
setPreviewFailed(false);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if ((error as { name?: string })?.name === 'AbortError') return;
|
||||||
|
setPreviewFailed(true);
|
||||||
|
});
|
||||||
|
}, PREVIEW_DELAY_MS);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [form, account, hasErrors]);
|
||||||
|
|
||||||
|
// ── Aktionen ──────────────────────────────────────────────────────────
|
||||||
|
const serverMessage = (error: unknown, fallback: string): string => {
|
||||||
|
if (error instanceof WelcomeMailTemplateRequestError && error.messages.length > 0) {
|
||||||
|
return error.messages.join(' ');
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form || hasErrors) return;
|
||||||
|
setSaving(true);
|
||||||
|
setFeedback(null);
|
||||||
|
try {
|
||||||
|
const saved = await saveWelcomeMailTemplate(form);
|
||||||
|
setState(saved);
|
||||||
|
setForm({ ...saved.texts });
|
||||||
|
setFeedback({ type: 'success', message: t('saved') });
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({ type: 'error', message: serverMessage(error, t('errors.save')) });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
if (!state) return;
|
||||||
|
setResetting(true);
|
||||||
|
setFeedback(null);
|
||||||
|
try {
|
||||||
|
const next = state.custom ? await resetWelcomeMailTemplate() : state;
|
||||||
|
setState(next);
|
||||||
|
setForm({ ...next.defaults });
|
||||||
|
setConfirmReset(false);
|
||||||
|
setFeedback({ type: 'success', message: t('resetDone') });
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({ type: 'error', message: serverMessage(error, t('errors.reset')) });
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
if (!form || hasErrors) return;
|
||||||
|
setTesting(true);
|
||||||
|
setFeedback(null);
|
||||||
|
try {
|
||||||
|
const result = await sendWelcomeMailTest(form);
|
||||||
|
setFeedback({ type: 'success', message: t('testSent', { email: result.to }) });
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({ type: 'error', message: serverMessage(error, t('errors.test')) });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fuegt `{{name}}` an der Cursorposition des zuletzt benutzten Feldes ein. */
|
||||||
|
const insertPlaceholder = (placeholder: WelcomeMailPlaceholder) => {
|
||||||
|
if (!form) return;
|
||||||
|
const field = lastFocused.current;
|
||||||
|
const token = `{{${placeholder}}}`;
|
||||||
|
const el = fieldRefs.current[field];
|
||||||
|
const value = form[field];
|
||||||
|
const start = el?.selectionStart ?? value.length;
|
||||||
|
const end = el?.selectionEnd ?? value.length;
|
||||||
|
setForm({ ...form, [field]: value.slice(0, start) + token + value.slice(end) });
|
||||||
|
const caret = start + token.length;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el?.focus();
|
||||||
|
el?.setSelectionRange(caret, caret);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Darstellung ───────────────────────────────────────────────────────
|
||||||
|
if (loadFailed) {
|
||||||
|
return (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{t('errors.load')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!form || !state || !errorsByField) {
|
||||||
|
return <p className="text-sm text-muted-foreground">{t('loading')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full rounded border border-border bg-background px-3 text-sm text-foreground';
|
||||||
|
const statusText = state.custom
|
||||||
|
? state.updatedAt
|
||||||
|
? t(state.updatedBy ? 'statusCustomBy' : 'statusCustom', {
|
||||||
|
date: new Date(state.updatedAt).toLocaleString(locale === 'en' ? 'en-GB' : 'de-DE', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}),
|
||||||
|
user: state.updatedBy ?? '',
|
||||||
|
})
|
||||||
|
: t('statusCustom', { date: '' })
|
||||||
|
: t('statusDefault');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-2 text-lg font-semibold text-foreground">{t('title')}</h1>
|
||||||
|
<p className="max-w-3xl text-sm text-muted-foreground">{t('description')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feste Bausteine */}
|
||||||
|
<section
|
||||||
|
aria-labelledby="welcome-fixed-title"
|
||||||
|
className="max-w-3xl rounded-lg border border-border bg-muted/40 p-4"
|
||||||
|
>
|
||||||
|
<h2 id="welcome-fixed-title" className="mb-1 text-sm font-semibold text-foreground">
|
||||||
|
{t('fixed.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('fixed.body')}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
|
{/* Formular */}
|
||||||
|
<form onSubmit={handleSave} className="min-w-0 space-y-4" noValidate>
|
||||||
|
<p
|
||||||
|
className={`text-sm ${state.custom ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||||
|
data-testid="welcome-template-status"
|
||||||
|
>
|
||||||
|
{statusText}
|
||||||
|
{dirty && <span className="ml-2 font-medium">· {t('unsaved')}</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{FIELDS.map((field) => {
|
||||||
|
const id = `welcome-${field}`;
|
||||||
|
const errors = errorsByField[field];
|
||||||
|
const describedBy = [`${id}-help`, errors.length ? `${id}-error` : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
const common = {
|
||||||
|
id,
|
||||||
|
value: form[field],
|
||||||
|
'aria-invalid': errors.length > 0 || undefined,
|
||||||
|
'aria-describedby': describedBy,
|
||||||
|
onFocus: () => {
|
||||||
|
lastFocused.current = field;
|
||||||
|
const hintAccount = HINT_ACCOUNT[field];
|
||||||
|
if (hintAccount) setAccount(hintAccount);
|
||||||
|
},
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div key={field}>
|
||||||
|
<label htmlFor={id} className="mb-1 block text-sm text-foreground">
|
||||||
|
{t(`fields.${field}`)}
|
||||||
|
</label>
|
||||||
|
{MULTILINE[field] ? (
|
||||||
|
<textarea
|
||||||
|
{...common}
|
||||||
|
ref={(el) => {
|
||||||
|
fieldRefs.current[field] = el;
|
||||||
|
}}
|
||||||
|
rows={ROWS[field] ?? 5}
|
||||||
|
className={`${inputClass} py-2`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
{...common}
|
||||||
|
ref={(el) => {
|
||||||
|
fieldRefs.current[field] = el;
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
className={`${inputClass} h-9`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<span id={`${id}-help`}>{t(`fields.${field}Help`)}</span>
|
||||||
|
<span className="shrink-0 tabular-nums">
|
||||||
|
{t('counter', { count: form[field].length, max: WELCOME_MAIL_LIMITS[field] })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<p id={`${id}-error`} className="mt-1 text-sm text-destructive">
|
||||||
|
{errors.join(' ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
|
<button type="submit" disabled={saving || hasErrors} className="btn btn-primary">
|
||||||
|
{saving ? t('saving') : t('save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={testing || hasErrors}
|
||||||
|
onClick={handleTest}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{testing ? t('testSending') : t('test')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={resetting}
|
||||||
|
onClick={() => setConfirmReset(true)}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{t('reset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('testHelp')}</p>
|
||||||
|
|
||||||
|
{confirmReset && (
|
||||||
|
<div
|
||||||
|
role="alertdialog"
|
||||||
|
aria-labelledby="welcome-reset-title"
|
||||||
|
aria-describedby="welcome-reset-body"
|
||||||
|
className="max-w-xl rounded-lg border border-border bg-background p-4"
|
||||||
|
>
|
||||||
|
<h2 id="welcome-reset-title" className="mb-1 text-sm font-semibold text-foreground">
|
||||||
|
{t('resetConfirm.title')}
|
||||||
|
</h2>
|
||||||
|
<p id="welcome-reset-body" className="mb-3 text-sm text-muted-foreground">
|
||||||
|
{t('resetConfirm.body')}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={resetting}
|
||||||
|
onClick={handleReset}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{t('resetConfirm.confirm')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmReset(false)}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{t('resetConfirm.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feedback && (
|
||||||
|
<p
|
||||||
|
role={feedback.type === 'error' ? 'alert' : 'status'}
|
||||||
|
className={`text-sm ${feedback.type === 'error' ? 'text-destructive' : ''}`}
|
||||||
|
style={feedback.type === 'success' ? { color: 'oklch(0.40 0.15 148)' } : undefined}
|
||||||
|
>
|
||||||
|
{feedback.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Platzhalter */}
|
||||||
|
<section
|
||||||
|
aria-labelledby="welcome-placeholders-title"
|
||||||
|
className="rounded-lg border border-border p-4"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="welcome-placeholders-title"
|
||||||
|
className="mb-1 text-sm font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
{t('placeholders.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-3 text-sm text-muted-foreground">{t('placeholders.intro')}</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||||
|
<th scope="col" className="py-2 pr-3 font-medium">
|
||||||
|
{t('placeholders.colPlaceholder')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="py-2 pr-3 font-medium">
|
||||||
|
{t('placeholders.colMeaning')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="py-2 font-medium">
|
||||||
|
{t('placeholders.colExample')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{WELCOME_MAIL_PLACEHOLDERS.map((name) => (
|
||||||
|
<tr key={name} className="border-b border-border last:border-0 align-top">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => insertPlaceholder(name)}
|
||||||
|
title={t('placeholders.insertHint')}
|
||||||
|
aria-label={t('placeholders.insert', { token: `{{${name}}}` })}
|
||||||
|
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground hover:bg-sidebar-hover"
|
||||||
|
>
|
||||||
|
{`{{${name}}}`}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-foreground">
|
||||||
|
{t(`placeholders.items.${name}.meaning`)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-muted-foreground">
|
||||||
|
{t(`placeholders.items.${name}.example`)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">{t('placeholders.rules')}</p>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Vorschau */}
|
||||||
|
<section aria-labelledby="welcome-preview-title" className="min-w-0">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 id="welcome-preview-title" className="text-sm font-semibold text-foreground">
|
||||||
|
{t('preview.title')}
|
||||||
|
</h2>
|
||||||
|
<fieldset className="flex gap-1">
|
||||||
|
<legend className="sr-only">{t('preview.accountLabel')}</legend>
|
||||||
|
{(['directory', 'local'] as const).map((kind) => (
|
||||||
|
<button
|
||||||
|
key={kind}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={account === kind}
|
||||||
|
onClick={() => setAccount(kind)}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
account === kind
|
||||||
|
? 'border-foreground bg-foreground text-background'
|
||||||
|
: 'border-border text-foreground hover:bg-sidebar-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`preview.${kind}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-muted-foreground">
|
||||||
|
{t('preview.subject')}{' '}
|
||||||
|
<span className="font-medium text-foreground" data-testid="welcome-preview-subject">
|
||||||
|
{preview?.subject ?? ''}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{hasErrors && <p className="mb-2 text-sm text-destructive">{t('preview.blocked')}</p>}
|
||||||
|
{previewFailed && <p className="mb-2 text-sm text-destructive">{t('preview.error')}</p>}
|
||||||
|
<iframe
|
||||||
|
title={t('preview.frameTitle')}
|
||||||
|
sandbox=""
|
||||||
|
srcDoc={preview?.html ?? ''}
|
||||||
|
className="h-[760px] w-full rounded-lg border border-border bg-white"
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">{t('preview.sampleHint')}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,14 +65,23 @@ export function ReminderFormModal({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const titleRef = useRef<HTMLInputElement>(null);
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Fokus nur EINMAL beim Oeffnen (quick-261001-g68): `onClose` kommt aus der
|
||||||
|
// Kachel als neue Funktion bei jedem Neuzeichnen — die Kachel zeichnet alle
|
||||||
|
// 10 s neu (NOW_TICK_MS). Hing der Fokus mit am `onClose`-Effekt, sprang der
|
||||||
|
// Cursor beim Schreiben der Beschreibung immer wieder in den Titel.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
titleRef.current?.focus();
|
titleRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') onCloseRef.current();
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
return () => document.removeEventListener('keydown', onKey);
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
}, [onClose]);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -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', () => {
|
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
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;
|
||||||
|
}
|
||||||
@@ -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)) });
|
||||||
|
}
|
||||||
@@ -88,7 +88,8 @@
|
|||||||
"customModules": "Eigene Module",
|
"customModules": "Eigene Module",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"smtp": "SMTP",
|
"smtp": "SMTP",
|
||||||
"groups": "Gruppen"
|
"groups": "Gruppen",
|
||||||
|
"welcomeMail": "Willkommensmail"
|
||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"SUPER_ADMIN": "Super-Admin",
|
"SUPER_ADMIN": "Super-Admin",
|
||||||
@@ -738,6 +739,106 @@
|
|||||||
"create": "Gemeinsamen Eintrag anlegen",
|
"create": "Gemeinsamen Eintrag anlegen",
|
||||||
"noModules": "Noch keine gemeinsamen Einträge",
|
"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."
|
"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": {
|
"adminModules": {
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"customModules": "Custom modules",
|
"customModules": "Custom modules",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"smtp": "SMTP",
|
"smtp": "SMTP",
|
||||||
"groups": "Groups"
|
"groups": "Groups",
|
||||||
|
"welcomeMail": "Welcome email"
|
||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"SUPER_ADMIN": "Super Admin",
|
"SUPER_ADMIN": "Super Admin",
|
||||||
@@ -738,6 +739,106 @@
|
|||||||
"create": "Add shared entry",
|
"create": "Add shared entry",
|
||||||
"noModules": "No shared entries yet",
|
"noModules": "No shared entries yet",
|
||||||
"noModulesBody": "Add your first shared entry to show a website in the sidebar for all users."
|
"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": {
|
"adminModules": {
|
||||||
|
|||||||
@@ -197,4 +197,10 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
|||||||
'Arbeitsspeicher',
|
'Arbeitsspeicher',
|
||||||
// quick-260925-bow: „Was ist neu“-Fenster, Gruppe „Verbessert“ — korrektes Deutsch mit „ss“
|
// quick-260925-bow: „Was ist neu“-Fenster, Gruppe „Verbessert“ — korrektes Deutsch mit „ss“
|
||||||
'Verbessert',
|
'Verbessert',
|
||||||
|
// Administrator → Willkommensmail (eigene Vorlage) — korrektes Deutsch mit „ss“/„ue“/„ae“
|
||||||
|
'anpassen',
|
||||||
|
'Abschlusstext',
|
||||||
|
'aktuellen',
|
||||||
|
'Passworts',
|
||||||
|
'tessera',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -58,10 +58,38 @@ Seit Kurzem ist die E-Mail-Adresse eines Benutzers **optional**: Wenn beim Impor
|
|||||||
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:
|
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;
|
- **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 1 Stunde gültig und nur einmal verwendbar; danach kann der Benutzer auf der Anmeldeseite jederzeit einen neuen anfordern.
|
- **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.
|
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
|
### 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:
|
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:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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 const CUSTOM_MODULE_CATEGORIES = [...MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY] as const;
|
||||||
|
|
||||||
export type CustomModuleCategory = (typeof CUSTOM_MODULE_CATEGORIES)[number];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user