Compare commits
11 Commits
v1.8.0
...
5919a55cbf
| Author | SHA1 | Date | |
|---|---|---|---|
| 5919a55cbf | |||
| 7edaf8c00b | |||
| 61a971ccc0 | |||
| 471cfbf98b | |||
| a257bc3f86 | |||
| 714f731ac9 | |||
| e10da76259 | |||
| 31d514b7ca | |||
| 52f538c432 | |||
| 32441d77c7 | |||
| 0b34e82b21 |
@@ -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)."
|
||||
}
|
||||
+3
-2
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
|
||||
status: verified
|
||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||
last_updated: "2026-09-23T15:30:00.000Z"
|
||||
last_activity: 2026-09-30
|
||||
last_activity: 2026-10-01
|
||||
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
|
||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||
progress:
|
||||
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
||||
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||
Plan: 6 of 6
|
||||
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
||||
Last activity: 2026-09-30 - Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. mit 4 Fix-Commits (be1e003, c2e4467, 0710829, 12214a9)
|
||||
Last activity: 2026-09-30 - v1.9.0 freigegeben (Willkommensmail + Vorlage, Rollen je Anfrage aus DB, Dashboard-Skalierung, Keep-Alive eigene Module); pausiert mit HANDOFF
|
||||
|
||||
Progress: [██████████] 99%
|
||||
|
||||
@@ -483,6 +483,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
|
||||
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
|
||||
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
|
||||
| 261001-cxo | Desktop-Client: Links mit target=_blank (Favoriten) oeffnen jetzt im System-Browser (DesktopExternalLinks -> window.open) | 2026-10-01 | 61a971c | [261001-cxo](./quick/261001-cxo-desktop-client-links-mit-target-blank-oe/) |
|
||||
|
||||
## 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“.
|
||||
@@ -4,6 +4,28 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
### 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.
|
||||
|
||||
## 1.9.0 – 2026-09-30
|
||||
|
||||
### Neu
|
||||
|
||||
- Verwaltung: Eigene Vorlage für die Willkommensmail unter Administrator → Willkommensmail. Betreff, Überschrift, Einleitung, Abschluss und der Anmeldehinweis (getrennt für Verzeichnis- und lokale Konten) lassen sich anpassen, mit Platzhaltern wie {{vorname}}, {{benutzername}}, {{adresse}} oder {{firma}} (eine Tabelle auf der Seite erklärt sie). Die Seite zeigt eine Live-Vorschau der echten Mail und schickt auf Wunsch eine Testmail an Ihre Adresse; „Auf Standard zurücksetzen“ stellt die mitgelieferten Texte wieder her. Logo, Zugangsdaten und Knöpfe fügt Tessera immer selbst ein.
|
||||
- Benutzerverwaltung: Willkommensmail. Über das Briefsymbol in der Benutzerliste schicken Sie einem Benutzer eine gestaltete Willkommensmail mit Tessera-Logo, Adresse, Benutzername und einem Knopf „Zu Tessera“. Konten aus dem Verzeichnis erhalten den Hinweis auf ihr Windows-Passwort, lokale Konten einen Link „Passwort festlegen“ (7 Tage gültig) – ein Passwort steht nie in der Mail. Die Liste zeigt, wann die Mail zuletzt ging.
|
||||
- Benutzerverwaltung: Neue Spalte „Letzte Anmeldung“.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Benutzerverwaltung: Die Aktionen je Zeile sind jetzt Symbole (Willkommensmail, Details, Bearbeiten, Löschen), damit die Liste ohne seitliches Scrollen passt.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Anmeldung: Wer schon angemeldet ist und die Anmeldeseite aufruft, landet jetzt direkt auf dem Dashboard.
|
||||
- Willkommensmail: Logo und Schriftzug erscheinen jetzt in jedem Mailprogramm. Bisher steckten sie in einem Bild; zeigte Outlook es nicht an, blieb nur ein großer schwarzer Kasten. Die Welle darunter ist nur noch ein schmaler Streifen; zeigt ein Mailprogramm sie nicht an (etwa Outlook im Browser), bleibt keine weiße Lücke mehr.
|
||||
- Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden.
|
||||
|
||||
## 1.8.0 – 2026-09-30
|
||||
|
||||
### Neu
|
||||
|
||||
@@ -47,6 +47,9 @@ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_
|
||||
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
|
||||
COPY --from=builder /app/packages/shared/src ./packages/shared/src
|
||||
COPY apps/api/scripts ./apps/api/scripts
|
||||
# Kopfbild der Willkommensmail (MailService.loadWelcomeHeaderPng liest
|
||||
# apps/api/assets/mail/welcome-header.png relativ zu dist/mail/).
|
||||
COPY apps/api/assets ./apps/api/assets
|
||||
# Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete +
|
||||
# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur
|
||||
# lesend zur Laufzeit -- kein chown noetig.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="80" viewBox="0 0 1200 80">
|
||||
<!--
|
||||
Wellenstreifen unter dem Kopf der Tessera-Systemmails (Willkommensmail).
|
||||
Quelle des PNG daneben (welcome-header.png, 1200x80, angezeigt 600x40);
|
||||
erzeugt mit `node apps/api/scripts/render-mail-header.mjs`.
|
||||
|
||||
Seit quick-260930 (Rueckmeldung des Nutzers: in Outlook "ein riesiger
|
||||
schwarzer Fleck, kein Logo") steckt KEIN Logo und KEIN Text mehr im Bild:
|
||||
Bildmarke und Schriftzug stehen als HTML im Mailkopf und erscheinen immer.
|
||||
Dieses Bild ist nur noch Schmuck: oben die Kopffarbe, darunter die
|
||||
Duenen-Wellen des Dashboard-Hintergrunds (dunkle Fassung,
|
||||
apps/web/src/lib/dashboard-background.ts) mit der feinen gelben Linie,
|
||||
unten laeuft es ins Weiss der Karte aus. Zeigt ein Mailprogramm das Bild
|
||||
nicht (Outlook mit gesperrten Bildern), bleibt dort die dunkle Kopffarbe
|
||||
der Zelle stehen — der Kopf wirkt nur etwas hoeher, keine weisse Luecke.
|
||||
Seit quick-260930 (zweite Rueckmeldung) 80 statt 112 px hoch (y-Werte
|
||||
x 80/112), in der Mail 40 statt 56 px.
|
||||
-->
|
||||
<rect width="1200" height="80" fill="#ffffff"/>
|
||||
<!-- Flaechen von unten nach oben uebereinander, jede von oben bis zu ihrer
|
||||
Wellenlinie: so teilen sich benachbarte Baender dieselbe Kante, ohne
|
||||
Luecken dazwischen. -->
|
||||
<path d="M0 0 V65.7 C220 55.7 420 72.9 600 68.6 S1000 54.3 1200 61.4 V0 Z" fill="#d9dce0"/>
|
||||
<path d="M0 0 V50 C250 35.7 420 61.4 640 54.3 S1040 35.7 1200 45.7 V0 Z" fill="#2c3036"/>
|
||||
<path d="M0 0 V31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6 V0 Z" fill="#1a1c20"/>
|
||||
<path d="M0 31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6" fill="none" stroke="#ffed00" stroke-opacity="0.75" stroke-width="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,15 @@
|
||||
-- Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer).
|
||||
--
|
||||
-- Merkt pro Benutzer, wann zuletzt eine Willkommensmail verschickt wurde,
|
||||
-- damit die Liste "Willkommensmail gesendet am …" zeigen kann. Gesetzt nur
|
||||
-- ueber POST /users/:id/welcome-mail nach erfolgreichem Versand; erneutes
|
||||
-- Senden ueberschreibt den Wert. NULL = nie gesendet. Kein Standardwert,
|
||||
-- kein Backfill.
|
||||
--
|
||||
-- Keine neue Regel noetig: die Spalte liegt in "User", dessen
|
||||
-- tenant_isolation_policy die ganze Zeile schuetzt. Die Anmelde-Funktionen
|
||||
-- auth_lookup_* liefern eine feste Spaltenliste (RETURNS TABLE) und bleiben
|
||||
-- unberuehrt.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "welcomeMailSentAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Willkommensmail: eigene Vorlage je Mandant (Administrator → Willkommensmail).
|
||||
--
|
||||
-- Zweck: neue Tabelle "WelcomeMailTemplate". Ein Administrator kann Betreff,
|
||||
-- Ueberschrift, Einleitung und Abschlusstext der Willkommensmail anpassen
|
||||
-- (mit Platzhaltern wie {{name}}). Hoechstens EINE Zeile je Mandant
|
||||
-- ("tenantId" eindeutig); "Auf Standard zuruecksetzen" loescht die Zeile, dann
|
||||
-- gelten wieder die Standardtexte aus dem Code. Die festen Bausteine der Mail
|
||||
-- (Kopf, Zugangsdaten, Anmeldehinweis, Knoepfe, Fusszeile) stehen NICHT in
|
||||
-- der Tabelle. "updatedBy" haelt den Benutzernamen des letzten Bearbeiters
|
||||
-- als reinen Anzeigetext (keine Relation).
|
||||
--
|
||||
-- Von Hand geschrieben (Vorbild 20260929120000_custom_module).
|
||||
--
|
||||
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
|
||||
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
|
||||
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
|
||||
-- Vorlage ist Verwaltungsdatum des Mandanten, nicht persoenliches Datum eines
|
||||
-- einzelnen Benutzers.
|
||||
--
|
||||
-- BEWUSST KEINE `system_read_policy`: gelesen wird nur beim Versand einer
|
||||
-- Willkommensmail, und zwar gebunden an den Mandanten des Zielbenutzers
|
||||
-- (`forTenant(prisma, tenantId)`); es gibt keinen Hintergrunddienst, der die
|
||||
-- Vorlagen ueber alle Mandanten liest.
|
||||
--
|
||||
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||
-- tun.
|
||||
--
|
||||
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||
|
||||
CREATE TABLE "WelcomeMailTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"heading" TEXT NOT NULL,
|
||||
"intro" TEXT NOT NULL,
|
||||
"closing" TEXT NOT NULL,
|
||||
"updatedBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WelcomeMailTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "WelcomeMailTemplate_tenantId_key" ON "WelcomeMailTemplate"("tenantId");
|
||||
|
||||
ALTER TABLE "WelcomeMailTemplate" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "WelcomeMailTemplate" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "WelcomeMailTemplate"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Willkommensmail: Anmeldehinweis je Kontoart als Teil der eigenen Vorlage.
|
||||
--
|
||||
-- Zweck: zwei neue Spalten in "WelcomeMailTemplate" —
|
||||
-- "loginHintDirectory" (Hinweis fuer verzeichnisgefuehrte Konten, Standard
|
||||
-- "Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten
|
||||
-- Windows-Passwort an.") und "loginHintLocal" (Hinweis vor dem Knopf
|
||||
-- "Passwort festlegen" fuer lokale Konten).
|
||||
--
|
||||
-- Bewusst NULLABLE ohne Default: bestehende Vorlagen behalten NULL, und
|
||||
-- WelcomeMailTemplateService setzt dafuer den Standardtext aus
|
||||
-- @tessera/shared ein. So steht der Standardtext nur an EINER Stelle im
|
||||
-- Code und nicht zusaetzlich in der Datenbank.
|
||||
--
|
||||
-- Zeilenschutz: unveraendert (tenant_isolation_policy der Tabelle gilt fuer
|
||||
-- die neuen Spalten mit).
|
||||
|
||||
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintDirectory" TEXT;
|
||||
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintLocal" TEXT;
|
||||
@@ -50,6 +50,9 @@ model User {
|
||||
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
|
||||
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
|
||||
dashboardBackground Json?
|
||||
// Willkommensmail aus der Benutzerverwaltung: Zeitpunkt des letzten
|
||||
// Versands; null = nie gesendet
|
||||
welcomeMailSentAt DateTime?
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
groupMemberships GroupMembership[]
|
||||
moduleGrants ModuleGrant[]
|
||||
@@ -764,3 +767,24 @@ model Reminder {
|
||||
@@index([tenantId, userId, dueAt])
|
||||
@@index([dueAt])
|
||||
}
|
||||
|
||||
// Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
|
||||
// hoechstens eine je Mandant; fehlt sie, gelten die Standardtexte aus
|
||||
// @tessera/shared (DEFAULT_WELCOME_MAIL_TEXTS). Nur die sechs Texte —
|
||||
// Kopf, Zugangsdaten und Knoepfe bleiben fest im Code.
|
||||
model WelcomeMailTemplate {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique
|
||||
subject String
|
||||
heading String
|
||||
intro String
|
||||
// Anmeldehinweise je Kontoart (Migration 20260930170000); NULL in einer
|
||||
// aelteren Vorlage = Standardtext aus @tessera/shared
|
||||
loginHintDirectory String?
|
||||
loginHintLocal String?
|
||||
closing String
|
||||
// Benutzername des Administrators, der zuletzt gespeichert hat (Anzeige)
|
||||
updatedBy String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
||||
* (apps/api/assets/mail/welcome-header.png, 1200x80 fuer hochaufloesende
|
||||
* Bildschirme, in der Mail 600x40 angezeigt) aus der daneben liegenden
|
||||
* Quelle welcome-header.svg.
|
||||
*
|
||||
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
||||
* Darstellung) und viele Webmailer zeigen weder SVG noch Hintergrundbilder
|
||||
* zuverlaessig an. Das PNG wird als CID-Anhang eingebettet (MailService),
|
||||
* die Mail laedt also nichts von aussen nach.
|
||||
*
|
||||
* Das PNG liegt fertig im Repo; dieses Skript ist nur noetig, wenn die SVG
|
||||
* geaendert wird. Kein neues Paket: `sharp` ist ueber Next.js (apps/web)
|
||||
* bereits installiert und wird von dort aufgeloest. Der Schriftzug wird
|
||||
* mit den Systemschriften des erzeugenden Rechners gesetzt (fontconfig:
|
||||
* Segoe UI, Inter, Noto Sans, DejaVu Sans — die erste vorhandene gewinnt).
|
||||
*
|
||||
* Aufruf (Repo-Wurzel): node apps/api/scripts/render-mail-header.mjs
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const assetDir = join(here, '..', 'assets', 'mail');
|
||||
const webDir = join(here, '..', '..', 'web');
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const nextPkg = require.resolve('next/package.json', { paths: [webDir] });
|
||||
const sharp = createRequire(nextPkg)('sharp');
|
||||
|
||||
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
||||
const png = await sharp(svg, { density: 72 })
|
||||
.resize(1200, 80)
|
||||
.png({ compressionLevel: 9, palette: false })
|
||||
.toBuffer();
|
||||
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
||||
console.log(`welcome-header.png geschrieben (${png.length} Bytes)`);
|
||||
@@ -17,6 +17,7 @@ import { LdapService } from '../ldap/ldap.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PASSWORD_RESET_TOKEN_TTL_MS } from './password-reset-token';
|
||||
import type { JwtPayload, LoginUser } from './types/auth-user';
|
||||
|
||||
/**
|
||||
@@ -239,7 +240,7 @@ export class AuthService {
|
||||
|
||||
// Generate a unique reset token
|
||||
const token = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_TTL_MS); // 1 hour
|
||||
|
||||
// Create the reset token record — mandantengebunden, sobald der
|
||||
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { of } from 'rxjs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { JwtStrategy } from '../strategies/jwt.strategy';
|
||||
import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor';
|
||||
|
||||
vi.mock('../../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
|
||||
/**
|
||||
* ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste
|
||||
* und die Teilstring-Falle (260921-fi3, Aufgabe 1, Befund 1/D-01/D-02/D-03).
|
||||
@@ -30,7 +34,19 @@ const nextHandle = { handle: () => of('ok') } as any;
|
||||
|
||||
describe('ForcePasswordChangeInterceptor.intercept', () => {
|
||||
it('Nahttest (D-03): JwtStrategy.validate() -> request.user -> GET /users wirft ForbiddenException — scheitert gegen den heutigen Quelltext, weil das Feld auf dem Weg verloren geht', async () => {
|
||||
const strategy = new JwtStrategy({ get: () => 'test-secret' } as any);
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: async () => ({
|
||||
id: 'u1',
|
||||
username: 'admin',
|
||||
role: 'ADMIN',
|
||||
tenantId: 't1',
|
||||
isActive: true,
|
||||
mustChangePassword: true,
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
const strategy = new JwtStrategy({ get: () => 'test-secret' } as any, prisma);
|
||||
const user = await strategy.validate({
|
||||
sub: 'u1',
|
||||
username: 'admin',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Gueltigkeit eines Kennwort-Tokens (`PasswordResetToken`, T-02-13): eine
|
||||
* Stunde, einmal verwendbar. Gemeinsam genutzt vom Weg "Passwort
|
||||
* vergessen" (`AuthService.requestPasswordReset`) und vom Link "Passwort
|
||||
* festlegen" der Willkommensmail (`WelcomeMailService`) — beide legen
|
||||
* dieselbe Art Token an und fuehren auf dieselbe Seite
|
||||
* `/reset-password/<token>`, deshalb gilt dieselbe Frist.
|
||||
*/
|
||||
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Gueltigkeit des Links "Passwort festlegen" in der Willkommensmail: 7 Tage.
|
||||
* Neue Mitarbeiter lesen die Mail oft erst Tage spaeter; eine Stunde wie bei
|
||||
* "Passwort vergessen" (dort fordert der Benutzer den Link selbst an und
|
||||
* nutzt ihn sofort) waere hier fast immer abgelaufen. Einmal verwendbar
|
||||
* bleibt der Link trotzdem, und jede neue Willkommensmail legt einen neuen an.
|
||||
*/
|
||||
export const WELCOME_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { forTenant } from '../../prisma/prisma-tenant.extension';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
vi.mock('../../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
|
||||
/**
|
||||
* JwtStrategy.validate — pinnt die Durchreichung von mustChangePassword
|
||||
* (260921-fi3, Aufgabe 1, Befund 1/D-01). Direkte Konstruktion ohne
|
||||
* JwtStrategy.validate — seit quick-260930 kommen Rolle, Aktiv-Status und
|
||||
* Kennwort-Pflicht bei jeder Anfrage aus der Datenbank, nicht aus dem Token
|
||||
* (Rollenaenderung/Deaktivierung wirkt sofort). Direkte Konstruktion ohne
|
||||
* Nest-Testmodul, Muster aus `../../tenant/tenant.guard.spec.ts`.
|
||||
*/
|
||||
|
||||
@@ -11,51 +18,77 @@ function makeConfigService() {
|
||||
return { get: () => 'test-secret' } as any;
|
||||
}
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
tenantId: string;
|
||||
isActive: boolean;
|
||||
mustChangePassword: boolean;
|
||||
} | null;
|
||||
|
||||
function makePrisma(row: Row) {
|
||||
return { user: { findUnique: vi.fn(async () => row) } } as any;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sub: 'u1',
|
||||
username: 'kschaller',
|
||||
role: 'SUPER_ADMIN' as const,
|
||||
tenantId: 't1',
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
const dbRow = {
|
||||
id: 'u1',
|
||||
username: 'kschaller',
|
||||
role: 'ADMIN',
|
||||
tenantId: 't1',
|
||||
isActive: true,
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
describe('JwtStrategy.validate', () => {
|
||||
it('Anspruch mustChangePassword=true im Token: liefert request.user.mustChangePassword === true', async () => {
|
||||
const strategy = new JwtStrategy(makeConfigService());
|
||||
it('Rolle kommt aus der Datenbank, nicht aus dem Token (herabgestufter Super-Admin ist sofort Admin)', async () => {
|
||||
const prisma = makePrisma(dbRow);
|
||||
const strategy = new JwtStrategy(makeConfigService(), prisma);
|
||||
|
||||
const result = await strategy.validate({
|
||||
sub: 'u1',
|
||||
username: 'admin',
|
||||
role: 'ADMIN',
|
||||
tenantId: 't1',
|
||||
mustChangePassword: true,
|
||||
});
|
||||
|
||||
expect(result.mustChangePassword).toBe(true);
|
||||
});
|
||||
|
||||
it('Anspruch fehlt im Token (Alt-Sitzung, vor dieser Aenderung ausgestellt): liefert false statt undefined', async () => {
|
||||
const strategy = new JwtStrategy(makeConfigService());
|
||||
|
||||
const result = await strategy.validate({
|
||||
sub: 'u1',
|
||||
username: 'admin',
|
||||
role: 'ADMIN',
|
||||
tenantId: 't1',
|
||||
});
|
||||
|
||||
expect(result.mustChangePassword).toBe(false);
|
||||
});
|
||||
|
||||
it('id, username, role und tenantId werden unveraendert wie bisher durchgereicht', async () => {
|
||||
const strategy = new JwtStrategy(makeConfigService());
|
||||
|
||||
const result = await strategy.validate({
|
||||
sub: 'u1',
|
||||
username: 'nutzer1',
|
||||
role: 'USER',
|
||||
tenantId: 't2',
|
||||
mustChangePassword: false,
|
||||
});
|
||||
const result = await strategy.validate(payload);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'u1',
|
||||
username: 'nutzer1',
|
||||
role: 'USER',
|
||||
tenantId: 't2',
|
||||
username: 'kschaller',
|
||||
role: 'ADMIN',
|
||||
tenantId: 't1',
|
||||
mustChangePassword: false,
|
||||
});
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'u1' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('deaktiviertes Konto: 401, auch mit gueltigem Token', async () => {
|
||||
const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, isActive: false }));
|
||||
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('geloeschtes Konto: 401', async () => {
|
||||
const strategy = new JwtStrategy(makeConfigService(), makePrisma(null));
|
||||
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('Konto gehoert nicht (mehr) zum Mandanten aus dem Token: 401', async () => {
|
||||
const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, tenantId: 't2' }));
|
||||
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('Kennwort-Pflicht kommt aus der Datenbank (vom Administrator nachtraeglich gesetzt)', async () => {
|
||||
const strategy = new JwtStrategy(
|
||||
makeConfigService(),
|
||||
makePrisma({ ...dbRow, mustChangePassword: true }),
|
||||
);
|
||||
const result = await strategy.validate(payload);
|
||||
expect(result.mustChangePassword).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { Request } from 'express';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { forTenant } from '../../prisma/prisma-tenant.extension';
|
||||
import type { AuthUser, JwtPayload } from '../types/auth-user';
|
||||
|
||||
/**
|
||||
@@ -17,7 +19,10 @@ function cookieExtractor(req: Request): string | null {
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: cookieExtractor,
|
||||
ignoreExpiration: false,
|
||||
@@ -25,16 +30,46 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Token beweist nur, WER angemeldet ist — Rolle, Aktiv-Status und
|
||||
* Kennwort-Pflicht kommen bei JEDER Anfrage frisch aus der Datenbank
|
||||
* (quick-260930, Befund des Nutzers): vorher galt die Rolle aus dem
|
||||
* 30-Tage-Token. Ein herabgestufter Administrator behielt bis zum Ablauf
|
||||
* seine alten Rechte, ein deaktiviertes oder geloeschtes Konto (etwa per
|
||||
* LDAP-Abgleich beim Austritt) arbeitete mit seiner Sitzung weiter, und
|
||||
* Oberflaeche (liest die Rolle ueber /auth/me aus der Datenbank) und API
|
||||
* (las sie aus dem Token) sahen verschiedene Rollen — die Benutzerliste
|
||||
* scheiterte dann im Client.
|
||||
*
|
||||
* Ein Primaerschluessel-Lesezugriff je Anfrage, gebunden an den Mandanten
|
||||
* aus dem Token (`forTenant`); gehoert das Konto nicht (mehr) zu diesem
|
||||
* Mandanten, fehlt es oder ist es deaktiviert, gilt die Sitzung als
|
||||
* ungueltig (401) — die Web-Oberflaeche leitet dann zur Anmeldung.
|
||||
*/
|
||||
async validate(payload: JwtPayload): Promise<AuthUser> {
|
||||
const tenantPrisma = forTenant(this.prisma, payload.tenantId);
|
||||
const user = await tenantPrisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
role: true,
|
||||
tenantId: true,
|
||||
isActive: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive || user.tenantId !== payload.tenantId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
username: payload.username,
|
||||
role: payload.role,
|
||||
tenantId: payload.tenantId,
|
||||
// Ein vor dieser Aenderung ausgestelltes Token traegt diesen Anspruch
|
||||
// nicht; der strenge Vergleich ergibt dann false, laufende Sitzungen
|
||||
// verhalten sich unveraendert (260921-fi3, D-01 — keine Aussperrwelle).
|
||||
mustChangePassword: payload.mustChangePassword === true,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role as AuthUser['role'],
|
||||
tenantId: user.tenantId,
|
||||
mustChangePassword: user.mustChangePassword === true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { MailService } from './mail.service';
|
||||
import { MailService, WELCOME_HEADER_CID } from './mail.service';
|
||||
|
||||
/**
|
||||
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
|
||||
@@ -166,7 +166,11 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
|
||||
const service = new MailService(settings as any, makeFakeConfig({}) as any);
|
||||
|
||||
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
|
||||
await service.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2');
|
||||
await service.sendWelcomeMail('t2', 'bob@b.example.invalid', {
|
||||
subject: 'Willkommen bei Tessera',
|
||||
text: 'Guten Tag bob,',
|
||||
html: '<p>bob</p>',
|
||||
});
|
||||
|
||||
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
|
||||
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
|
||||
@@ -314,3 +318,47 @@ describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)'
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MailService.sendWelcomeMail / hasConfiguredTransport (Willkommensmail)', () => {
|
||||
it('haengt das Kopfbild als CID-Anhang an, reicht HTML und Text durch und wirft Transportfehler nach aussen', async () => {
|
||||
const service = new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
|
||||
|
||||
await service.sendWelcomeMail('t1', 'neu@a.example.invalid', {
|
||||
subject: 'Willkommen bei Tessera',
|
||||
text: 'Text',
|
||||
html: `<img src="cid:${WELCOME_HEADER_CID}">`,
|
||||
});
|
||||
|
||||
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||
expect(sent.from).toBe('noreply@a.example.invalid');
|
||||
expect(sent.html).toContain(`cid:${WELCOME_HEADER_CID}`);
|
||||
expect(sent.text).toBe('Text');
|
||||
expect(sent.attachments).toHaveLength(1);
|
||||
expect(sent.attachments[0].cid).toBe(WELCOME_HEADER_CID);
|
||||
expect(sent.attachments[0].contentType).toBe('image/png');
|
||||
// PNG-Signatur: das Bild aus apps/api/assets/mail ist wirklich geladen.
|
||||
expect((sent.attachments[0].content as Buffer).subarray(1, 4).toString()).toBe('PNG');
|
||||
|
||||
mockSendMail = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
await expect(
|
||||
service.sendWelcomeMail('t1', 'neu@a.example.invalid', { subject: 'S', text: 'T', html: 'H' }),
|
||||
).rejects.toThrow('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('hasConfiguredTransport: SmtpConfig des Mandanten oder MAIL_HOST/TESSERA_SMTP_HOST — der Rueckfall localhost:1025 zaehlt nicht', async () => {
|
||||
expect(
|
||||
await new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ MAIL_HOST: 'smtp.example.invalid' }) as any).hasConfiguredTransport('t1'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ TESSERA_SMTP_HOST: 'legacy.example.invalid' }) as any).hasConfiguredTransport('t1'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
@@ -45,15 +47,43 @@ import { SettingsService } from '../settings/settings.service';
|
||||
* Seit quick-260914-m97 (Fehler-melden-Knopf) ist der Versandkern
|
||||
* `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt
|
||||
* Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer
|
||||
* Kennwort-Reset und Willkommensmail (T-02-12 unveraendert); `sendBugReport`
|
||||
* ruft den Kern direkt, damit der Anwender erfaehrt, ob sein Bericht ankam.
|
||||
* den Kennwort-Reset (T-02-12 unveraendert); `sendBugReport` und
|
||||
* `sendWelcomeMail` rufen den Kern direkt, damit der Ausloesende erfaehrt,
|
||||
* ob die Mail ging.
|
||||
*/
|
||||
|
||||
/** Inhaltskennung des Kopfbilds der Willkommensmail (`<img src="cid:...">`). */
|
||||
export const WELCOME_HEADER_CID = 'welcome-header@tessera';
|
||||
|
||||
let welcomeHeaderCache: Buffer | null | undefined;
|
||||
|
||||
/**
|
||||
* Laedt das Kopfbild der Willkommensmail einmal je Prozess
|
||||
* (apps/api/assets/mail/welcome-header.png, erzeugt von
|
||||
* scripts/render-mail-header.mjs). Zur Laufzeit liegt diese Datei unter
|
||||
* dist/mail/, im Test unter src/mail/ — beide Male zwei Ebenen unter
|
||||
* apps/api. Fehlt das Bild, liefert die Funktion `null`; die Mail zeigt
|
||||
* dann einen dunklen Textkopf statt abzubrechen.
|
||||
*/
|
||||
export function loadWelcomeHeaderPng(): Buffer | null {
|
||||
if (welcomeHeaderCache !== undefined) return welcomeHeaderCache;
|
||||
const file = path.resolve(__dirname, '..', '..', 'assets', 'mail', 'welcome-header.png');
|
||||
try {
|
||||
welcomeHeaderCache = fs.readFileSync(file);
|
||||
} catch {
|
||||
new Logger('MailService').warn(`Welcome header image missing: ${file}`);
|
||||
welcomeHeaderCache = null;
|
||||
}
|
||||
return welcomeHeaderCache;
|
||||
}
|
||||
|
||||
/** Anhang in der nodemailer-Form (`attachments` von `sendMail`). */
|
||||
export interface OutgoingAttachment {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
/** Inhaltskennung fuer eingebettete Bilder (`<img src="cid:...">`). */
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
/** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */
|
||||
@@ -186,10 +216,10 @@ export class MailService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschluckender Mantel um `deliver` fuer Kennwort-Reset und
|
||||
* Willkommensmail: Fehler werden protokolliert, nie geworfen — der
|
||||
* Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration (T-02-12
|
||||
* bleibt fuer genau diese beiden Wege bestehen).
|
||||
* Verschluckender Mantel um `deliver` fuer den Kennwort-Reset: Fehler
|
||||
* werden protokolliert, nie geworfen — der Anmeldeweg antwortet weiter
|
||||
* 200, keine E-Mail-Enumeration (T-02-12). Die Willkommensmail benutzt
|
||||
* ihn seit dem Versand aus der Benutzerverwaltung nicht mehr.
|
||||
*/
|
||||
private async sendViaTenantTransport(
|
||||
tenantId: string,
|
||||
@@ -275,46 +305,59 @@ export class MailService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a welcome email to a newly created user (optional — derzeit ohne
|
||||
* Aufrufer, gemessen 260914-eym; bleibt als Pfad ueber denselben
|
||||
* Transport je Versand erhalten).
|
||||
* Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer,
|
||||
* "Willkommensmail senden"). Inhalt und HTML baut
|
||||
* `renderWelcomeMail` (welcome-mail.template.ts), die Entscheidung ueber
|
||||
* den Anmeldehinweis trifft `WelcomeMailService`. Diese Methode haengt
|
||||
* nur das Kopfbild als CID-Anhang an (`WELCOME_HEADER_CID`, kein
|
||||
* Nachladen von aussen) und versendet ueber den Transport des Mandanten
|
||||
* des EMPFAENGERS.
|
||||
*
|
||||
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
|
||||
* Fehler gehen BEWUSST nach aussen (wie `sendBugReport`): ein
|
||||
* Administrator loest den Versand gezielt aus und muss erfahren, ob die
|
||||
* Mail ging — es gibt hier keinen Anmeldeweg, den eine Fehlermeldung
|
||||
* verraten koennte (T-02-12 betrifft nur Kennwort-Reset).
|
||||
*/
|
||||
async sendWelcomeEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
async sendWelcomeMail(
|
||||
tenantId: string,
|
||||
locale: string = 'de',
|
||||
to: string,
|
||||
mail: { subject: string; text: string; html: string },
|
||||
): Promise<void> {
|
||||
const isGerman = locale === 'de';
|
||||
const subject = isGerman
|
||||
? 'Willkommen bei Tessera'
|
||||
: 'Welcome to Tessera';
|
||||
const header = loadWelcomeHeaderPng();
|
||||
await this.deliver(
|
||||
tenantId,
|
||||
{
|
||||
to,
|
||||
...mail,
|
||||
attachments: header
|
||||
? [
|
||||
{
|
||||
filename: 'tessera.png',
|
||||
content: header,
|
||||
contentType: 'image/png',
|
||||
cid: WELCOME_HEADER_CID,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
'Welcome',
|
||||
);
|
||||
}
|
||||
|
||||
const text = isGerman
|
||||
? [
|
||||
`Hallo ${username},`,
|
||||
'',
|
||||
'Ihr Tessera-Account wurde erstellt.',
|
||||
'',
|
||||
`Sie können sich unter ${this.appUrl}/login anmelden.`,
|
||||
'',
|
||||
'Mit freundlichen Grüßen,',
|
||||
'Ihr Tessera-Team',
|
||||
].join('\n')
|
||||
: [
|
||||
`Hello ${username},`,
|
||||
'',
|
||||
'Your Tessera account has been created.',
|
||||
'',
|
||||
`You can sign in at ${this.appUrl}/login.`,
|
||||
'',
|
||||
'Best regards,',
|
||||
'The Tessera Team',
|
||||
].join('\n');
|
||||
|
||||
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome');
|
||||
/**
|
||||
* Gibt an, ob fuer den Mandanten ein Versandweg eingerichtet ist: eine
|
||||
* eigene SmtpConfig ODER ein per Umgebung gesetzter Server (MAIL_HOST /
|
||||
* TESSERA_SMTP_HOST). Der letzte Rueckfall `localhost:1025` (Mailhog in
|
||||
* der Entwicklung) zaehlt NICHT — sonst saehe die Oberflaeche einen
|
||||
* Versandweg, der im Betrieb ins Leere geht.
|
||||
*/
|
||||
async hasConfiguredTransport(tenantId: string): Promise<boolean> {
|
||||
const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId);
|
||||
if (smtpConfig) return true;
|
||||
const envHost =
|
||||
this.configService.get<string>('MAIL_HOST') ??
|
||||
this.configService.get<string>('TESSERA_SMTP_HOST');
|
||||
return typeof envHost === 'string' && envHost.trim() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { DEFAULT_WELCOME_MAIL_TEXTS, findUnknownWelcomeMailPlaceholders } from '@tessera/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyWelcomeMailPlaceholders,
|
||||
firstNameOf,
|
||||
renderWelcomeMail,
|
||||
type WelcomeMailInput,
|
||||
} from './welcome-mail.template';
|
||||
|
||||
/**
|
||||
* Willkommensmail mit eigener Vorlage: Platzhalter, Escaping, Absaetze,
|
||||
* feste Bausteine.
|
||||
*/
|
||||
|
||||
const base: WelcomeMailInput = {
|
||||
name: 'Max Muster',
|
||||
username: 'max.muster',
|
||||
email: 'max@example.invalid',
|
||||
tenantName: 'Muster & Co',
|
||||
appUrl: 'https://tessera.example.invalid/',
|
||||
account: { kind: 'directory' },
|
||||
headerImageSrc: null,
|
||||
};
|
||||
|
||||
/** Standard-Anmeldehinweise, damit Vorlagen im Test vollstaendig sind. */
|
||||
const hints = {
|
||||
loginHintDirectory: DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory,
|
||||
loginHintLocal: DEFAULT_WELCOME_MAIL_TEXTS.loginHintLocal,
|
||||
};
|
||||
|
||||
const localAccount = {
|
||||
kind: 'local',
|
||||
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
|
||||
validHours: 168,
|
||||
} as const;
|
||||
|
||||
const values = {
|
||||
name: 'Max Muster',
|
||||
vorname: 'Max',
|
||||
benutzername: 'max.muster',
|
||||
email: 'max@example.invalid',
|
||||
adresse: 'https://t.example.invalid',
|
||||
firma: 'Muster & Co',
|
||||
};
|
||||
|
||||
describe('Platzhalter', () => {
|
||||
it('ersetzt alle sechs, Gross-/Kleinschreibung und Leerraum egal; unbekannte bleiben stehen', () => {
|
||||
expect(
|
||||
applyWelcomeMailPlaceholders(
|
||||
'{{name}}|{{vorname}}|{{benutzername}}|{{email}}|{{adresse}}|{{firma}}|{{ NAME }}|{{xyz}}',
|
||||
values,
|
||||
),
|
||||
).toBe(
|
||||
'Max Muster|Max|max.muster|max@example.invalid|https://t.example.invalid|Muster & Co|Max Muster|{{xyz}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('findUnknownWelcomeMailPlaceholders nennt jeden unbekannten einmal', () => {
|
||||
expect(findUnknownWelcomeMailPlaceholders('{{a}} {{name}} {{a}} {{ b }} {{}}')).toEqual([
|
||||
'{{a}}',
|
||||
'{{b}}',
|
||||
'{{}}',
|
||||
]);
|
||||
expect(findUnknownWelcomeMailPlaceholders(DEFAULT_WELCOME_MAIL_TEXTS.heading)).toEqual([]);
|
||||
});
|
||||
|
||||
it('vorname: erstes Wort des Anzeigenamens, sonst Benutzername', () => {
|
||||
expect(firstNameOf(' Erika Mustermann ', 'erika')).toBe('Erika');
|
||||
expect(firstNameOf(null, 'erika')).toBe('erika');
|
||||
expect(firstNameOf(' ', 'erika')).toBe('erika');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderWelcomeMail mit Vorlage', () => {
|
||||
it('ohne Vorlage: Standard-Betreff und -Ueberschrift', () => {
|
||||
const mail = renderWelcomeMail(base);
|
||||
expect(mail.subject).toBe('Willkommen bei Tessera');
|
||||
expect(mail.html).toContain('Willkommen bei Tessera, Max Muster!');
|
||||
expect(mail.html).toContain('Viel Erfolg mit Tessera!');
|
||||
});
|
||||
|
||||
it('Text ist reiner Text: HTML aus Vorlage und Werten wird escaped; Firma mit & korrekt', () => {
|
||||
const mail = renderWelcomeMail({
|
||||
...base,
|
||||
name: '<script>x</script>',
|
||||
texts: {
|
||||
subject: 'Hallo\r\n{{firma}}',
|
||||
heading: '<h2>{{name}}</h2>',
|
||||
intro: '<a href="javascript:alert(1)">klick</a>',
|
||||
...hints,
|
||||
closing: '',
|
||||
},
|
||||
});
|
||||
expect(mail.subject).toBe('Hallo Muster & Co');
|
||||
expect(mail.html).not.toContain('<script>x</script>');
|
||||
expect(mail.html).not.toContain('<h2>');
|
||||
expect(mail.html).not.toContain('href="javascript:');
|
||||
expect(mail.html).toContain('<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;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* welcome-mail.template.ts — Inhalt und Gestaltung der Willkommensmail.
|
||||
*
|
||||
* Reine Funktion ohne Abhaengigkeiten: bekommt die fertigen Werte
|
||||
* (Anzeigename, Benutzername, Adresse, Anmeldeweg) und liefert Betreff,
|
||||
* Text-Alternative und HTML. Versand und Kopfbild-Anhang erledigt
|
||||
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
|
||||
* Anmeldehinweis" `WelcomeMailService`.
|
||||
*
|
||||
* Eigene Vorlage (Administrator → Willkommensmail): Betreff, Ueberschrift,
|
||||
* Einleitung, die zwei Anmeldehinweise (Verzeichniskonto / lokales Konto)
|
||||
* und Abschluss kommen als `texts` herein (Vorlage des Mandanten
|
||||
* des ZIEL-Benutzers, sonst `DEFAULT_WELCOME_MAIL_TEXTS` aus
|
||||
* `@tessera/shared`). Platzhalter (`{{name}}`, `{{vorname}}`,
|
||||
* `{{benutzername}}`, `{{email}}`, `{{adresse}}`, `{{firma}}`) werden auf dem
|
||||
* REINEN Text ersetzt und erst danach escaped. Leerzeile = neuer Absatz,
|
||||
* einfacher Umbruch = `<br>`. Alles andere bleibt fest.
|
||||
*
|
||||
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
|
||||
* Mail sehr unterschiedlich darstellen:
|
||||
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
|
||||
* - keine externen Ressourcen, keine Web-Fonts (Systemschriften);
|
||||
* - Kopf: Bildmarke und Schriftzug als HTML (erscheinen immer), darunter
|
||||
* die Duenen-Welle als schmaler PNG-Streifen (`headerImageSrc`, im
|
||||
* Versand `cid:`), weil SVG und CSS-Hintergruende in Outlook nicht
|
||||
* erscheinen; fehlt das Bild, bleibt nur ein schmaler Abschluss;
|
||||
* - Knoepfe als Tabelle mit Hintergrundfarbe in der Zelle ("bulletproof"),
|
||||
* Outlook ignoriert Innenabstaende und Rundungen am Link selbst.
|
||||
*
|
||||
* Sicherheit: jeder eingesetzte Wert laeuft durch `escapeHtml`; Links werden
|
||||
* nur als http(s) uebernommen. Ein Kennwort steht NIE in der Mail — lokale
|
||||
* Konten bekommen einen Link zum Festlegen (Token wie beim
|
||||
* "Passwort vergessen"-Weg), verzeichnisgefuehrte den Hinweis auf das
|
||||
* Windows-Passwort.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_WELCOME_MAIL_TEXTS,
|
||||
isWelcomeMailPlaceholder,
|
||||
WELCOME_MAIL_PLACEHOLDER_RE,
|
||||
type WelcomeMailPlaceholder,
|
||||
type WelcomeMailTexts,
|
||||
} from '@tessera/shared';
|
||||
|
||||
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
|
||||
const C = {
|
||||
page: '#eceef1',
|
||||
card: '#ffffff',
|
||||
ink: '#1a1d21',
|
||||
body: '#3d4450',
|
||||
muted: '#6b7280',
|
||||
line: '#e3e5e9',
|
||||
well: '#f7f7f5',
|
||||
yellow: '#ffed00',
|
||||
link: '#1d5fc2',
|
||||
header: '#1a1c20',
|
||||
} as const;
|
||||
|
||||
const FONT =
|
||||
"'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', Arial, sans-serif";
|
||||
|
||||
/** Anmeldeweg des Empfaengers — entscheidet den Hinweis in der Mail. */
|
||||
export type WelcomeMailAccount =
|
||||
| { kind: 'directory' }
|
||||
| { kind: 'local'; setPasswordUrl: string; validHours: number }
|
||||
/**
|
||||
* Lokales Konto ohne Link. Wird heute nirgends erzeugt (`WelcomeMailService`
|
||||
* legt fuer lokale Konten immer einen Token an); deshalb bleibt ihr Hinweis
|
||||
* fest und ist nicht Teil der Vorlage.
|
||||
*/
|
||||
| { kind: 'local-no-link' }
|
||||
/**
|
||||
* Nur Testmail aus Administrator → Willkommensmail: derselbe Baustein wie
|
||||
* `local`, aber OHNE Token — der Knopf fuehrt zur Anmeldeseite und ein
|
||||
* Hinweis sagt, was er in der echten Mail tut.
|
||||
*/
|
||||
| { kind: 'local-example' };
|
||||
|
||||
export interface WelcomeMailInput {
|
||||
/** Anzeigename, sonst Benutzername. */
|
||||
name: string;
|
||||
username: string;
|
||||
/** E-Mail-Adresse des Empfaengers (Platzhalter `{{email}}`). */
|
||||
email: string;
|
||||
/** Name des Mandanten (Platzhalter `{{firma}}`). */
|
||||
tenantName: string;
|
||||
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
|
||||
appUrl: string;
|
||||
account: WelcomeMailAccount;
|
||||
/** `cid:...` im Versand, `data:` in der Vorschau, `null` = Textkopf. */
|
||||
headerImageSrc: string | null;
|
||||
/** Eigene Vorlage des Mandanten; fehlt sie, gelten die Standardtexte. */
|
||||
texts?: WelcomeMailTexts | null;
|
||||
/** Hinweisleiste ganz oben (nur Testmail), reiner Text. */
|
||||
notice?: string | null;
|
||||
}
|
||||
|
||||
export interface RenderedWelcomeMail {
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export const WELCOME_MAIL_SUBJECT = DEFAULT_WELCOME_MAIL_TEXTS.subject;
|
||||
|
||||
const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.';
|
||||
const EXAMPLE_LINK_NOTE =
|
||||
'Beispiel-Link: In der echten Willkommensmail führt dieser Knopf zu einem persönlichen Link, mit dem der neue Benutzer sein Passwort festlegt. In dieser Testmail öffnet er nur die Anmeldeseite.';
|
||||
|
||||
/** Werte der Platzhalter, noch NICHT escaped (Ersetzung laeuft auf reinem Text). */
|
||||
export type WelcomeMailPlaceholderValues = Record<WelcomeMailPlaceholder, string>;
|
||||
|
||||
/** Erstes Wort des Anzeigenamens, sonst der Benutzername. */
|
||||
export function firstNameOf(displayName: string | null | undefined, username: string): string {
|
||||
const first = (displayName ?? '').trim().split(/\s+/)[0];
|
||||
return first || username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Platzhalter in einen REINEN Text ein (Gross-/Kleinschreibung
|
||||
* egal). Unbekannte bleiben stehen — gespeicherte Vorlagen enthalten keine,
|
||||
* das prueft die API beim Speichern. Das Ergebnis ist weiter reiner Text und
|
||||
* wird erst beim Einbau ins HTML escaped.
|
||||
*/
|
||||
export function applyWelcomeMailPlaceholders(
|
||||
text: string,
|
||||
values: WelcomeMailPlaceholderValues,
|
||||
): string {
|
||||
return text.replace(WELCOME_MAIL_PLACEHOLDER_RE, (whole, name: string) =>
|
||||
isWelcomeMailPlaceholder(name) ? values[name.toLowerCase() as WelcomeMailPlaceholder] : whole,
|
||||
);
|
||||
}
|
||||
|
||||
/** Zeilenenden vereinheitlichen, Leerraum an den Raendern entfernen. */
|
||||
function normalizeText(value: string): string {
|
||||
return value.replace(/\r\n?/g, '\n').trim();
|
||||
}
|
||||
|
||||
/** Einzeilig (Betreff, Ueberschrift): jeder Umbruch/Leerraum-Lauf wird ein Leerzeichen. */
|
||||
function singleLine(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Absaetze: Leerzeile trennt, einfacher Umbruch bleibt im Absatz. */
|
||||
function paragraphsOf(value: string): string[] {
|
||||
const text = normalizeText(value);
|
||||
if (!text) return [];
|
||||
return text
|
||||
.split(/\n[ \t]*\n+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Nur http(s)-Adressen gelangen in ein href; alles andere wird leer. */
|
||||
function safeUrl(value: string): string {
|
||||
return /^https?:\/\//i.test(value) ? value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Anmeldehinweis je Kontoart als REINER Text (Platzhalter noch nicht
|
||||
* ersetzt). Verzeichnis- und lokale Konten nehmen den Text der Vorlage; ist
|
||||
* er leer oder fehlt er (aeltere Aufrufer), gilt der Standardtext.
|
||||
*/
|
||||
function loginHintText(account: WelcomeMailAccount, texts: Partial<WelcomeMailTexts>): string {
|
||||
const pick = (field: 'loginHintDirectory' | 'loginHintLocal') => {
|
||||
const value = texts[field];
|
||||
return typeof value === 'string' && value.trim() ? value : DEFAULT_WELCOME_MAIL_TEXTS[field];
|
||||
};
|
||||
switch (account.kind) {
|
||||
case 'directory':
|
||||
return pick('loginHintDirectory');
|
||||
case 'local':
|
||||
case 'local-example':
|
||||
return pick('loginHintLocal');
|
||||
case 'local-no-link':
|
||||
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
|
||||
}
|
||||
}
|
||||
|
||||
function validityText(hours: number): string {
|
||||
const span =
|
||||
hours % 24 === 0 && hours >= 24
|
||||
? hours === 24
|
||||
? '1 Tag'
|
||||
: `${hours / 24} Tage`
|
||||
: hours === 1
|
||||
? '1 Stunde'
|
||||
: `${hours} Stunden`;
|
||||
return `Der Link ist ${span} gültig und nur einmal verwendbar. Ist er abgelaufen, fordern Sie auf der Anmeldeseite über „Passwort vergessen?“ einfach einen neuen an.`;
|
||||
}
|
||||
|
||||
/** Knopf als Tabelle: Farbe an der Zelle, damit Outlook ihn als Flaeche zeigt. */
|
||||
function button(href: string, label: string, bg: string, fg: string): string {
|
||||
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
|
||||
<tr><td align="center" bgcolor="${bg}" style="background-color:${bg};border-radius:6px;mso-padding-alt:14px 30px;">
|
||||
<a href="${escapeHtml(href)}" target="_blank" style="display:inline-block;padding:14px 30px;font-family:${FONT};font-size:16px;line-height:20px;font-weight:600;color:${fg};text-decoration:none;border-radius:6px;">${escapeHtml(label)}</a>
|
||||
</td></tr></table>`;
|
||||
}
|
||||
|
||||
/** Eine Kachel der Bildmarke: feste Zelle, Hoehe auch in Outlook exakt. */
|
||||
function tile(color: string | null): string {
|
||||
const bg = color ? `bgcolor="${color}" style="background-color:${color};` : 'style="';
|
||||
return `<td width="9" height="9" ${bg}width:9px;height:9px;font-size:1px;line-height:9px;mso-line-height-rule:exactly;"> </td>`;
|
||||
}
|
||||
|
||||
/** Luecke zwischen Kacheln. */
|
||||
const GAP = '<td width="3" style="width:3px;font-size:1px;line-height:1px;"> </td>';
|
||||
|
||||
/**
|
||||
* Bildmarke als HTML (quick-260930): das Kachel-"T" aus Tabellenzellen —
|
||||
* oben drei Kacheln (die dritte gelb, im Original gedreht), darunter zwei in
|
||||
* der Mitte —, auf dunkler Grundplatte mit heller Kontur wie in der App.
|
||||
* Braucht kein Bild und erscheint deshalb in jedem Mailprogramm.
|
||||
*/
|
||||
function logoMark(): string {
|
||||
const row = (cells: Array<string | null>) =>
|
||||
`<tr>${cells.map((c, i) => (i > 0 ? GAP : '') + tile(c)).join('')}</tr>`;
|
||||
const spacer = `<tr><td colspan="5" height="3" style="height:3px;font-size:1px;line-height:3px;mso-line-height-rule:exactly;"> </td></tr>`;
|
||||
const olive = '#9c9440';
|
||||
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
|
||||
<tr><td bgcolor="#111214" style="background-color:#111214;border:1px solid #3a3d44;border-radius:10px;padding:10px 10px 10px 10px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||
${row([olive, olive, C.yellow])}${spacer}${row([null, olive, null])}${spacer}${row([null, olive, null])}
|
||||
</table>
|
||||
</td></tr></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopf der Mail (quick-260930, Rueckmeldung des Nutzers: in Outlook "ein
|
||||
* riesiger schwarzer Fleck, kein Logo"). Vorher steckten Logo und
|
||||
* Schriftzug in EINEM 150 px hohen Bild; zeigt ein Mailprogramm das
|
||||
* eingebettete Bild nicht an, blieb nur die dunkle Flaeche. Jetzt:
|
||||
* - Bildmarke (HTML-Kacheln) und Schriftzug "Tessera" (echter Text) in einer
|
||||
* niedrigen dunklen Leiste — erscheinen immer;
|
||||
* - darunter die Duenen-Welle als schmaler Bildstreifen (`src`, im Versand
|
||||
* `cid:`, 600x40), der ins Weiss der Karte auslaeuft. Die Zelle um das
|
||||
* Bild traegt die dunkle Kopffarbe (zweite Rueckmeldung des Nutzers: sein
|
||||
* Outlook zeigt das CID-Bild nicht, vorher stand dort eine "riesengrosse
|
||||
* weisse Luecke"): fehlt das Bild, wirkt der Kopf nur etwas hoeher.
|
||||
*/
|
||||
function headerRow(src: string | null): string {
|
||||
const bar = `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:22px 32px 14px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr>
|
||||
<td valign="middle" style="padding:0 14px 0 0;">${logoMark()}</td>
|
||||
<td valign="middle" style="font-family:${FONT};font-size:26px;line-height:32px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">Tessera</td>
|
||||
</tr></table>
|
||||
</td></tr>`;
|
||||
const wave = src
|
||||
? `<tr><td bgcolor="${C.header}" style="background-color:${C.header};line-height:0;font-size:0;">
|
||||
<img src="${escapeHtml(src)}" width="600" height="40" alt="" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;">
|
||||
</td></tr>`
|
||||
: `<tr><td bgcolor="${C.header}" height="4" style="background-color:${C.header};height:4px;font-size:1px;line-height:4px;border-bottom:3px solid ${C.yellow};"> </td></tr>`;
|
||||
return bar + wave;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Willkommensmail. Die sechs Texte (Betreff, Ueberschrift,
|
||||
* Einleitung, Anmeldehinweis je Kontoart, Abschluss) kommen aus der eigenen
|
||||
* Vorlage des Mandanten, sonst
|
||||
* aus `DEFAULT_WELCOME_MAIL_TEXTS`. Sie sind REINER Text: Platzhalter werden
|
||||
* auf dem Text ersetzt, erst danach wird alles escaped — HTML aus der
|
||||
* Vorlage oder aus einem Benutzerwert erscheint als Text, nie als Markup.
|
||||
* Kopf, Kasten Adresse/Benutzername, Knopf "Passwort festlegen" mit
|
||||
* Gueltigkeitshinweis, Knopf "Zu Tessera" und Fusszeile sind feste Bausteine
|
||||
* und nicht Teil der Vorlage.
|
||||
*/
|
||||
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
|
||||
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
|
||||
const loginUrl = `${base}/login`;
|
||||
const texts = input.texts ?? DEFAULT_WELCOME_MAIL_TEXTS;
|
||||
|
||||
const values: WelcomeMailPlaceholderValues = {
|
||||
name: input.name,
|
||||
vorname: firstNameOf(input.name, input.username),
|
||||
benutzername: input.username,
|
||||
email: input.email,
|
||||
adresse: base,
|
||||
firma: input.tenantName,
|
||||
};
|
||||
const fill = (value: string) => applyWelcomeMailPlaceholders(value, values);
|
||||
|
||||
const subject = singleLine(fill(texts.subject)) || WELCOME_MAIL_SUBJECT;
|
||||
const heading = singleLine(fill(texts.heading));
|
||||
const introParagraphs = paragraphsOf(fill(texts.intro));
|
||||
const hintParagraphs = paragraphsOf(fill(loginHintText(input.account, texts)));
|
||||
const closingParagraphs = paragraphsOf(fill(texts.closing));
|
||||
const notice = input.notice ? singleLine(input.notice) : '';
|
||||
|
||||
// ── Text-Alternative ───────────────────────────────────────────────────
|
||||
const textLines: string[] = [];
|
||||
if (notice) textLines.push(`[${notice}]`, '');
|
||||
if (heading) textLines.push(heading, '');
|
||||
for (const para of introParagraphs) textLines.push(para, '');
|
||||
textLines.push('Ihre Zugangsdaten', `Adresse: ${base}`, `Benutzername: ${input.username}`);
|
||||
for (const para of hintParagraphs) textLines.push('', para);
|
||||
if (input.account.kind === 'local') {
|
||||
textLines.push(
|
||||
'',
|
||||
'Passwort festlegen:',
|
||||
input.account.setPasswordUrl,
|
||||
validityText(input.account.validHours),
|
||||
);
|
||||
} else if (input.account.kind === 'local-example') {
|
||||
textLines.push('', 'Passwort festlegen:', loginUrl, EXAMPLE_LINK_NOTE);
|
||||
}
|
||||
textLines.push('', 'Zu Tessera:', loginUrl);
|
||||
for (const para of closingParagraphs) textLines.push('', para);
|
||||
textLines.push('', '--', FOOTER);
|
||||
const text = textLines.join('\n');
|
||||
|
||||
// ── HTML ───────────────────────────────────────────────────────────────
|
||||
const username = escapeHtml(input.username);
|
||||
const baseHtml = escapeHtml(base);
|
||||
|
||||
const p = (content: string, extra = '') =>
|
||||
`<p style="margin:0 0 16px;font-family:${FONT};font-size:16px;line-height:25px;color:${C.body};${extra}">${content}</p>`;
|
||||
|
||||
/** Reiner Text -> escaped, einfache Umbrueche als <br>. */
|
||||
const para = (value: string) => escapeHtml(value).replace(/\n/g, '<br>');
|
||||
|
||||
const label = (content: string) =>
|
||||
`<div style="font-family:${FONT};font-size:12px;line-height:16px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:${C.muted};">${content}</div>`;
|
||||
|
||||
let accountBlock = hintParagraphs
|
||||
.map((part, i) => p(para(part), i === 0 ? 'margin:24px 0 16px;' : ''))
|
||||
.join('\n');
|
||||
if (input.account.kind === 'local') {
|
||||
const setUrl = safeUrl(input.account.setPasswordUrl);
|
||||
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
||||
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(validityText(input.account.validHours))}</p>`;
|
||||
} else if (input.account.kind === 'local-example') {
|
||||
accountBlock += `${button(loginUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
||||
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(EXAMPLE_LINK_NOTE)}</p>`;
|
||||
}
|
||||
|
||||
const noticeRow = notice
|
||||
? `<tr><td style="padding:0 0 12px;"><table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td bgcolor="#fff8c2" style="background-color:#fff8c2;border:1px solid #e6d74c;border-radius:8px;padding:10px 16px;font-family:${FONT};font-size:13px;line-height:20px;color:${C.ink};">${escapeHtml(notice)}</td></tr></table></td></tr>
|
||||
`
|
||||
: '';
|
||||
|
||||
const headingHtml = heading
|
||||
? `<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">${escapeHtml(heading)}</h1>
|
||||
`
|
||||
: '';
|
||||
const introHtml = introParagraphs.map((part) => p(para(part))).join('\n');
|
||||
|
||||
const closingHtml = closingParagraphs.length
|
||||
? `<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
|
||||
${closingParagraphs
|
||||
.map((part, i) =>
|
||||
p(
|
||||
para(part),
|
||||
`font-size:14px;line-height:22px;margin:0${i < closingParagraphs.length - 1 ? ' 0 12px' : ''};`,
|
||||
),
|
||||
)
|
||||
.join('\n')}
|
||||
</td></tr></table>`
|
||||
: '';
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>${escapeHtml(subject)}</title>
|
||||
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
||||
<style>
|
||||
a { color: ${C.link}; }
|
||||
@media only screen and (max-width: 620px) {
|
||||
.tsr-pad { padding-left: 24px !important; padding-right: 24px !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:${C.page};-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${C.page};">Ihr Zugang zu Tessera ist eingerichtet – hier finden Sie Adresse und Benutzername.</div>
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="${C.page}" style="background-color:${C.page};">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<!--[if mso]><table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;border-collapse:separate;">
|
||||
${noticeRow}${headerRow(input.headerImageSrc)}
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:24px 40px 12px;">
|
||||
${headingHtml}${introHtml}
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;margin:8px 0 0;">
|
||||
<tr><td bgcolor="${C.well}" style="background-color:${C.well};border:1px solid ${C.line};border-left:4px solid ${C.yellow};border-radius:8px;padding:18px 22px;">
|
||||
${label('Adresse')}
|
||||
<div style="margin:4px 0 14px;font-family:${FONT};font-size:16px;line-height:24px;font-weight:600;"><a href="${escapeHtml(loginUrl)}" target="_blank" style="color:${C.link};text-decoration:none;">${baseHtml}</a></div>
|
||||
${label('Benutzername')}
|
||||
<div style="margin:4px 0 0;font-family:Consolas,'SF Mono',Menlo,'Courier New',monospace;font-size:16px;line-height:24px;font-weight:600;color:${C.ink};">${username}</div>
|
||||
</td></tr></table>
|
||||
${accountBlock}
|
||||
</td></tr>
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" align="left" style="background-color:${C.card};padding:20px 40px 8px;">
|
||||
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
|
||||
</td></tr>
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:20px 40px 36px;border-radius:0 0 12px 12px;">
|
||||
${closingHtml}
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding:20px 24px 0;font-family:${FONT};font-size:12px;line-height:18px;color:${C.muted};">${escapeHtml(FOOTER)}</td></tr>
|
||||
</table>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject, text, html };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
findUnknownWelcomeMailPlaceholders,
|
||||
WELCOME_MAIL_LIMITS,
|
||||
type WelcomeMailTexts,
|
||||
} from '@tessera/shared';
|
||||
import {
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Validate,
|
||||
type ValidationArguments,
|
||||
ValidatorConstraint,
|
||||
type ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
|
||||
/** Feldnamen fuer die Fehlermeldung (Oberflaeche zeigt sie unveraendert). */
|
||||
const FIELD_LABELS: Record<keyof WelcomeMailTexts, string> = {
|
||||
subject: 'Betreff',
|
||||
heading: 'Überschrift',
|
||||
intro: 'Einleitungstext',
|
||||
loginHintDirectory: 'Anmeldehinweis für Verzeichniskonten',
|
||||
loginHintLocal: 'Anmeldehinweis für lokale Konten',
|
||||
closing: 'Abschlusstext',
|
||||
};
|
||||
|
||||
/**
|
||||
* Nur bekannte Platzhalter (`{{name}}`, `{{vorname}}`, `{{benutzername}}`,
|
||||
* `{{email}}`, `{{adresse}}`, `{{firma}}`). Ein unbekannter wird mit 400
|
||||
* abgelehnt und in der Meldung genannt ("Unbekannter Platzhalter {{xyz}}"),
|
||||
* statt spaeter woertlich in der Mail zu stehen.
|
||||
*/
|
||||
@ValidatorConstraint({ name: 'nurBekanntePlatzhalter', async: false })
|
||||
class NurBekanntePlatzhalterConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown): boolean {
|
||||
return typeof value !== 'string' || findUnknownWelcomeMailPlaceholders(value).length === 0;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const unknown =
|
||||
typeof args.value === 'string' ? findUnknownWelcomeMailPlaceholders(args.value) : [];
|
||||
const label = FIELD_LABELS[args.property as keyof WelcomeMailTexts] ?? args.property;
|
||||
const noun = unknown.length > 1 ? 'Unbekannte Platzhalter' : 'Unbekannter Platzhalter';
|
||||
return `${label}: ${noun} ${unknown.join(', ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inhalt der eigenen Vorlage (PUT /welcome-mail-template, auch Grundlage fuer
|
||||
* Vorschau und Testmail). Reiner Text: HTML wird beim Rendern escaped, nicht
|
||||
* hier abgewiesen — ein "<" im Text ist erlaubt und erscheint als Zeichen.
|
||||
* Betreff, Ueberschrift und beide Anmeldehinweise sind Pflicht, Einleitung
|
||||
* und Abschluss duerfen leer sein (der Abschnitt faellt dann weg).
|
||||
*/
|
||||
export class WelcomeMailTemplateDto implements WelcomeMailTexts {
|
||||
@IsString()
|
||||
@Matches(/\S/, { message: 'Bitte geben Sie einen Betreff ein.' })
|
||||
@MaxLength(WELCOME_MAIL_LIMITS.subject)
|
||||
@Validate(NurBekanntePlatzhalterConstraint)
|
||||
subject!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/\S/, { message: 'Bitte geben Sie eine Überschrift ein.' })
|
||||
@MaxLength(WELCOME_MAIL_LIMITS.heading)
|
||||
@Validate(NurBekanntePlatzhalterConstraint)
|
||||
heading!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(WELCOME_MAIL_LIMITS.intro)
|
||||
@Validate(NurBekanntePlatzhalterConstraint)
|
||||
intro!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/\S/, { message: 'Bitte geben Sie einen Anmeldehinweis für Verzeichniskonten ein.' })
|
||||
@MaxLength(WELCOME_MAIL_LIMITS.loginHintDirectory)
|
||||
@Validate(NurBekanntePlatzhalterConstraint)
|
||||
loginHintDirectory!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/\S/, { message: 'Bitte geben Sie einen Anmeldehinweis für lokale Konten ein.' })
|
||||
@MaxLength(WELCOME_MAIL_LIMITS.loginHintLocal)
|
||||
@Validate(NurBekanntePlatzhalterConstraint)
|
||||
loginHintLocal!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(WELCOME_MAIL_LIMITS.closing)
|
||||
@Validate(NurBekanntePlatzhalterConstraint)
|
||||
closing!: string;
|
||||
}
|
||||
|
||||
/** Vorschau: zusaetzlich die Kontoart, fuer die der Anmeldehinweis gezeigt wird. */
|
||||
export class WelcomeMailPreviewDto extends WelcomeMailTemplateDto {
|
||||
@IsOptional()
|
||||
@IsIn(['directory', 'local'])
|
||||
account?: 'directory' | 'local';
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
@@ -124,11 +124,13 @@ describe('UserController', () => {
|
||||
let prisma: any;
|
||||
let userService: any;
|
||||
let controller: UserController;
|
||||
let welcomeMail: { isAvailable: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = makeFakePrisma();
|
||||
userService = makeUserServiceMock();
|
||||
controller = new UserController(userService as any, prisma);
|
||||
welcomeMail = { isAvailable: vi.fn(), send: vi.fn() };
|
||||
controller = new UserController(userService as any, prisma, welcomeMail as any);
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
@@ -663,4 +665,80 @@ describe('UserController', () => {
|
||||
expect(await storedFor('t2', 'u-other')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Willkommensmail (POST :id/welcome-mail, GET welcome-mail/status)', () => {
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't9', id: 'super1', username: 'super1', mustChangePassword: false };
|
||||
const sentAt = new Date('2026-09-30T10:00:00.000Z');
|
||||
|
||||
it('ADMIN: Ziel gebunden an den eigenen Mandanten aufgeloest, Dienst mit Ziel und Origin gerufen, Antwort traegt Empfaenger und Zeitpunkt', async () => {
|
||||
const target = { id: 'u-x', username: 'x', tenantId: 't1', role: 'USER', lastLoginAt: null };
|
||||
userService.findById.mockResolvedValue(target);
|
||||
welcomeMail.send.mockResolvedValue({ to: 'x@x.invalid', welcomeMailSentAt: sentAt });
|
||||
|
||||
const result = await controller.sendWelcomeMail('u-x', admin, 'https://tessera.example.invalid');
|
||||
|
||||
expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x');
|
||||
expect(welcomeMail.send).toHaveBeenCalledWith(target, 'https://tessera.example.invalid');
|
||||
expect(result).toEqual({ success: true, to: 'x@x.invalid', welcomeMailSentAt: sentAt });
|
||||
});
|
||||
|
||||
it('ADMIN: unbekannter oder fremdmandantiger Benutzer (gebundene Aufloesung null) → NotFoundException, kein Versand', async () => {
|
||||
userService.findById.mockResolvedValue(null);
|
||||
await expect(controller.sendWelcomeMail('u-fremd', admin)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(welcomeMail.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ADMIN: Ziel eines fremden Mandanten (zweite Schicht) → ForbiddenException mit Mandanten-Meldung, kein Versand', async () => {
|
||||
userService.findById.mockResolvedValue({ id: 'u-y', tenantId: 't2', role: 'SUPER_ADMIN' });
|
||||
await expect(controller.sendWelcomeMail('u-y', admin)).rejects.toThrow('Cannot modify users from other tenants');
|
||||
expect(welcomeMail.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ADMIN: Ziel ist SUPER_ADMIN des eigenen Mandanten → ForbiddenException (Zielrollen-Riegel), kein Versand', async () => {
|
||||
userService.findById.mockResolvedValue({ id: 'u-s', tenantId: 't1', role: 'SUPER_ADMIN' });
|
||||
await expect(controller.sendWelcomeMail('u-s', admin)).rejects.toThrow('Cannot modify a SUPER_ADMIN user');
|
||||
expect(welcomeMail.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN: Ziel ueber die uebergreifende Aufloesung, auch in fremdem Mandanten und mit oberster Rolle', async () => {
|
||||
const target = { id: 'u-z', tenantId: 't2', role: 'SUPER_ADMIN', lastLoginAt: null };
|
||||
userService.findByIdForPlatformAdmin.mockResolvedValue(target);
|
||||
welcomeMail.send.mockResolvedValue({ to: 'z@x.invalid', welcomeMailSentAt: sentAt });
|
||||
|
||||
await controller.sendWelcomeMail('u-z', superAdmin);
|
||||
|
||||
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-z');
|
||||
expect(welcomeMail.send).toHaveBeenCalledWith(target, undefined);
|
||||
});
|
||||
|
||||
it('Fehler des Dienstes (z. B. 409 Konto deaktiviert) gehen unveraendert nach aussen', async () => {
|
||||
userService.findById.mockResolvedValue({ id: 'u-x', tenantId: 't1', role: 'USER' });
|
||||
welcomeMail.send.mockRejectedValue(new ConflictException('deaktiviert'));
|
||||
await expect(controller.sendWelcomeMail('u-x', admin)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('GET welcome-mail/status fragt den Versandweg fuer den Mandanten des Aufrufers ab', async () => {
|
||||
welcomeMail.isAvailable.mockResolvedValue(true);
|
||||
await expect(controller.getWelcomeMailStatus(admin)).resolves.toEqual({ available: true });
|
||||
expect(welcomeMail.isAvailable).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
|
||||
it('findAll (ADMIN) liefert welcomeMailSentAt mit', async () => {
|
||||
prisma.__seedUser({
|
||||
id: 'u-a',
|
||||
username: 'alice',
|
||||
tenantId: 't1',
|
||||
email: 'alice@x.invalid',
|
||||
displayName: null,
|
||||
role: 'USER',
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
lastLoginAt: null,
|
||||
welcomeMailSentAt: sentAt,
|
||||
});
|
||||
const result = await controller.findAll(admin);
|
||||
expect((result as any[])[0].welcomeMailSentAt).toBe(sentAt);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Headers,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
@@ -39,6 +40,7 @@ import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { ReleaseSeenDto } from './dto/release-seen.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UserService } from './user.service';
|
||||
import { WelcomeMailService } from './welcome-mail.service';
|
||||
|
||||
/** Map accepted MIME types to file extensions (T-gbh-01). */
|
||||
const AVATAR_MIME_TO_EXT: Record<string, string> = {
|
||||
@@ -68,6 +70,10 @@ function resolveAvatarsDir(): string {
|
||||
* quick-260928-ujj: dazu kommt ein gebundener Zugriff des
|
||||
* Selbstbedienungswegs `PATCH me/dashboard-background`, ebenfalls
|
||||
* `forTenant()` mit `where: { id: currentUser.id }`.
|
||||
*
|
||||
* Willkommensmail: `POST :id/welcome-mail` loest den Zielbenutzer ueber
|
||||
* `resolveTargetUser` auf; die beiden Schreibzugriffe (Token, Versandzeit)
|
||||
* liegen gebunden in `WelcomeMailService`, nicht in diesem Controller.
|
||||
*/
|
||||
@Controller('users')
|
||||
@UseGuards(RolesGuard)
|
||||
@@ -75,6 +81,7 @@ export class UserController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly welcomeMailService: WelcomeMailService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -126,6 +133,7 @@ export class UserController {
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
welcomeMailSentAt: true,
|
||||
},
|
||||
orderBy: { username: 'asc' },
|
||||
});
|
||||
@@ -218,6 +226,23 @@ export class UserController {
|
||||
return { success: true, lastSeenReleaseVersion: version };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/welcome-mail/status (Willkommensmail)
|
||||
*
|
||||
* Ob fuer den Mandanten des Aufrufers ein Versandweg eingerichtet ist —
|
||||
* die Oberflaeche deaktiviert damit den Knopf "Willkommensmail senden"
|
||||
* und nennt den Grund. Die wirksame Pruefung bleibt beim Versand selbst
|
||||
* (`POST :id/welcome-mail`, dort je Mandant des ZIELS). Statische Route
|
||||
* steht VOR der Kennungs-Route (GET :id).
|
||||
*/
|
||||
@Get('welcome-mail/status')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async getWelcomeMailStatus(
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
): Promise<{ available: boolean }> {
|
||||
return { available: await this.welcomeMailService.isAvailable(currentUser.tenantId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/:id
|
||||
*/
|
||||
@@ -330,6 +355,47 @@ export class UserController {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /users/:id/welcome-mail (Willkommensmail)
|
||||
*
|
||||
* Schickt einem Benutzer (egal ob schon angemeldet) eine
|
||||
* Willkommensmail (Adresse, Benutzername, Anmeldehinweis je Kontoart) und
|
||||
* merkt den Zeitpunkt in `welcomeMailSentAt`. Dieselben Regeln wie beim
|
||||
* Bearbeiten: ADMIN nur im eigenen Mandanten, SUPER_ADMIN uebergreifend;
|
||||
* ein Nicht-SUPER_ADMIN darf einem SUPER_ADMIN nichts schicken
|
||||
* (Zielrollen-Riegel wie in update()). Deaktiviert -> 409, ohne
|
||||
* Adresse -> 400, ohne Versandweg -> 409, Versandfehler -> 502 (alles in
|
||||
* `WelcomeMailService`). Der Origin-Kopf dient nur als Rueckfall fuer die
|
||||
* Adresse in der Mail, wenn `TESSERA_APP_URL` fehlt.
|
||||
*/
|
||||
@Post(':id/welcome-mail')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async sendWelcomeMail(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
@Headers('origin') origin?: string,
|
||||
): Promise<{ success: true; to: string; welcomeMailSentAt: Date }> {
|
||||
const user = await this.resolveTargetUser(currentUser, id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (
|
||||
currentUser.role !== Role.SUPER_ADMIN &&
|
||||
user.tenantId !== currentUser.tenantId
|
||||
) {
|
||||
throw new ForbiddenException('Cannot modify users from other tenants');
|
||||
}
|
||||
|
||||
if (user.role === Role.SUPER_ADMIN && currentUser.role !== Role.SUPER_ADMIN) {
|
||||
throw new ForbiddenException('Cannot modify a SUPER_ADMIN user');
|
||||
}
|
||||
|
||||
const result = await this.welcomeMailService.send(user, origin);
|
||||
return { success: true, ...result };
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /users/:id
|
||||
* ADMIN cannot delete self or users from other tenants.
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GroupsModule } from '../groups/groups.module';
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { AdminSeedService } from './admin-seed.service';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
import { WelcomeMailService } from './welcome-mail.service';
|
||||
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
|
||||
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
|
||||
|
||||
/**
|
||||
* Importiert GroupsModule für UserService.create's Standardgruppen-
|
||||
* Mitgliedschaft (D-11/D-12). GroupsModule importiert seinerseits
|
||||
* UserModule nicht — GroupsService greift für Benutzerprüfungen direkt
|
||||
* auf PrismaService zu, keine Zirkularität.
|
||||
*
|
||||
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
|
||||
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
|
||||
* UserModule.
|
||||
*
|
||||
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
|
||||
* `WelcomeMailTemplateService` (Speicherung je Mandant) und
|
||||
* `WelcomeMailTemplateController` (`/welcome-mail-template`) liegen hier,
|
||||
* weil Vorschau und Testmail denselben Weg wie der Versand nutzen
|
||||
* (`WelcomeMailService`: Adresse, Kopfbild, SMTP des Mandanten).
|
||||
*/
|
||||
@Module({
|
||||
imports: [GroupsModule],
|
||||
controllers: [UserController],
|
||||
providers: [UserService, AdminSeedService],
|
||||
imports: [GroupsModule, MailModule],
|
||||
controllers: [UserController, WelcomeMailTemplateController],
|
||||
providers: [UserService, AdminSeedService, WelcomeMailService, WelcomeMailTemplateService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@@ -37,6 +37,7 @@ const PLATFORM_USER_SELECT = {
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
welcomeMailSentAt: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'reflect-metadata';
|
||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { WelcomeMailPreviewDto, WelcomeMailTemplateDto } from './dto/welcome-mail-template.dto';
|
||||
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
|
||||
|
||||
/**
|
||||
* Administrator → Willkommensmail (eigene Vorlage).
|
||||
*
|
||||
* Festgenagelt: alle Routen nur ADMIN/SUPER_ADMIN; Mandant IMMER aus dem
|
||||
* Token (nie aus dem Body); unbekannte Platzhalter und zu lange Texte → 400
|
||||
* mit Nennung des Platzhalters; die Testmail geht an den Aufrufer selbst.
|
||||
*/
|
||||
|
||||
function makeTemplates() {
|
||||
return {
|
||||
getState: vi.fn(async (..._args: unknown[]) => ({ custom: false })),
|
||||
save: vi.fn(async (..._args: unknown[]) => ({ custom: true })),
|
||||
reset: vi.fn(async (..._args: unknown[]) => ({ custom: false })),
|
||||
};
|
||||
}
|
||||
|
||||
function makeWelcome() {
|
||||
return {
|
||||
preview: vi.fn(async (..._args: unknown[]) => ({ subject: 's', html: '<html>', text: 't' })),
|
||||
sendTest: vi.fn(async (..._args: unknown[]) => ({ to: 'ada@example.invalid' })),
|
||||
};
|
||||
}
|
||||
|
||||
const admin = { id: 'u-admin', username: 'ada', role: 'ADMIN', tenantId: 't1' } as any;
|
||||
const proto = WelcomeMailTemplateController.prototype as any;
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
|
||||
const valid = { ...DEFAULT_WELCOME_MAIL_TEXTS };
|
||||
|
||||
async function validateBody(body: unknown, metatype: any = WelcomeMailTemplateDto) {
|
||||
return pipe.transform(body, { type: 'body', metatype });
|
||||
}
|
||||
|
||||
async function messagesOf(body: unknown): Promise<string[]> {
|
||||
try {
|
||||
await validateBody(body);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(BadRequestException);
|
||||
return ((error as BadRequestException).getResponse() as { message: string[] }).message;
|
||||
}
|
||||
throw new Error('erwartete 400');
|
||||
}
|
||||
|
||||
describe('WelcomeMailTemplateController — Rechte', () => {
|
||||
it.each([
|
||||
'get',
|
||||
'save',
|
||||
'reset',
|
||||
'preview',
|
||||
'sendTest',
|
||||
])('%s nur fuer ADMIN und SUPER_ADMIN', (name) => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual(['ADMIN', 'SUPER_ADMIN']);
|
||||
});
|
||||
|
||||
it('haengt an Pfad welcome-mail-template', () => {
|
||||
expect(Reflect.getMetadata('path', WelcomeMailTemplateController)).toBe(
|
||||
'welcome-mail-template',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailTemplateController — Mandant aus dem Token', () => {
|
||||
it('reicht currentUser.tenantId (und Benutzername/-kennung) weiter, nie Werte aus dem Body', async () => {
|
||||
const templates = makeTemplates();
|
||||
const welcome = makeWelcome();
|
||||
const controller = new WelcomeMailTemplateController(templates as any, welcome as any);
|
||||
|
||||
await controller.get(admin);
|
||||
await controller.save(admin, { ...valid, tenantId: 'evil' } as any);
|
||||
await controller.reset(admin);
|
||||
await controller.preview(admin, { ...valid, account: 'local' }, 'https://o.example.invalid');
|
||||
const result = await controller.sendTest(admin, valid as any, undefined);
|
||||
|
||||
expect(templates.getState).toHaveBeenCalledWith('t1');
|
||||
expect(templates.save).toHaveBeenCalledWith('t1', valid, 'ada');
|
||||
expect(templates.reset).toHaveBeenCalledWith('t1');
|
||||
expect(welcome.preview).toHaveBeenCalledWith('t1', valid, 'local', 'https://o.example.invalid');
|
||||
expect(welcome.sendTest).toHaveBeenCalledWith('t1', 'u-admin', valid, undefined);
|
||||
expect(result).toEqual({ success: true, to: 'ada@example.invalid' });
|
||||
});
|
||||
|
||||
it('Vorschau ohne Kontoart zeigt die Verzeichniskonto-Variante', async () => {
|
||||
const welcome = makeWelcome();
|
||||
const controller = new WelcomeMailTemplateController(makeTemplates() as any, welcome as any);
|
||||
await controller.preview(admin, { ...valid });
|
||||
expect(welcome.preview.mock.calls[0][2]).toBe('directory');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailTemplateDto (globale ValidationPipe)', () => {
|
||||
it('Standardtexte und alle bekannten Platzhalter (auch mit Leerraum/Grossschreibung) sind gueltig', async () => {
|
||||
const out: any = await validateBody({
|
||||
...valid,
|
||||
intro: '{{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}} {{ Name }}',
|
||||
tenantId: 'evil',
|
||||
});
|
||||
expect(out).not.toHaveProperty('tenantId');
|
||||
});
|
||||
|
||||
it('unbekannter Platzhalter → 400 mit Feld und Platzhalter', async () => {
|
||||
const messages = await messagesOf({ ...valid, heading: 'Hallo {{xyz}} und {{abc}}' });
|
||||
expect(messages).toContain('Überschrift: Unbekannte Platzhalter {{xyz}}, {{abc}}');
|
||||
});
|
||||
|
||||
it('ein unbekannter Platzhalter im Betreff → "Unbekannter Platzhalter {{xyz}}"', async () => {
|
||||
const messages = await messagesOf({ ...valid, subject: 'Hi {{xyz}}' });
|
||||
expect(messages).toContain('Betreff: Unbekannter Platzhalter {{xyz}}');
|
||||
});
|
||||
|
||||
it('Laengen: Betreff/Ueberschrift 200, Anmeldehinweise 1000, Texte 4000', async () => {
|
||||
await expect(
|
||||
validateBody({ ...valid, subject: 'a'.repeat(200), intro: 'b'.repeat(4000) }),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(messagesOf({ ...valid, subject: 'a'.repeat(201) })).resolves.toHaveLength(1);
|
||||
await expect(messagesOf({ ...valid, heading: 'a'.repeat(201) })).resolves.toHaveLength(1);
|
||||
await expect(messagesOf({ ...valid, intro: 'a'.repeat(4001) })).resolves.toHaveLength(1);
|
||||
await expect(messagesOf({ ...valid, closing: 'a'.repeat(4001) })).resolves.toHaveLength(1);
|
||||
await expect(
|
||||
validateBody({
|
||||
...valid,
|
||||
loginHintDirectory: 'a'.repeat(1000),
|
||||
loginHintLocal: 'b'.repeat(1000),
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
messagesOf({ ...valid, loginHintDirectory: 'a'.repeat(1001) }),
|
||||
).resolves.toHaveLength(1);
|
||||
await expect(messagesOf({ ...valid, loginHintLocal: 'a'.repeat(1001) })).resolves.toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('leerer Betreff/leere Ueberschrift → 400; leere Einleitung/Abschluss erlaubt', async () => {
|
||||
expect(await messagesOf({ ...valid, subject: ' ' })).toContain(
|
||||
'Bitte geben Sie einen Betreff ein.',
|
||||
);
|
||||
expect(await messagesOf({ ...valid, heading: '' })).toContain(
|
||||
'Bitte geben Sie eine Überschrift ein.',
|
||||
);
|
||||
await expect(validateBody({ ...valid, intro: '', closing: '' })).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('Anmeldehinweise: Pflicht, Platzhalter geprueft, Leerzeilen erlaubt', async () => {
|
||||
expect(await messagesOf({ ...valid, loginHintDirectory: ' \n ' })).toContain(
|
||||
'Bitte geben Sie einen Anmeldehinweis für Verzeichniskonten ein.',
|
||||
);
|
||||
expect(await messagesOf({ ...valid, loginHintLocal: '' })).toContain(
|
||||
'Bitte geben Sie einen Anmeldehinweis für lokale Konten ein.',
|
||||
);
|
||||
expect(await messagesOf({ ...valid, loginHintLocal: 'Hallo {{kennwort}}' })).toContain(
|
||||
'Anmeldehinweis für lokale Konten: Unbekannter Platzhalter {{kennwort}}',
|
||||
);
|
||||
await expect(
|
||||
validateBody({ ...valid, loginHintDirectory: 'Hallo {{ Vorname }}\n\nZweiter Absatz' }),
|
||||
).resolves.toBeTruthy();
|
||||
const { loginHintLocal: _l, ...rest } = valid;
|
||||
await expect(messagesOf(rest)).resolves.not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fehlendes Feld → 400', async () => {
|
||||
const { closing: _closing, ...rest } = valid;
|
||||
await expect(messagesOf(rest)).resolves.not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Vorschau: nur directory/local als Kontoart', async () => {
|
||||
await expect(
|
||||
validateBody({ ...valid, account: 'local' }, WelcomeMailPreviewDto),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
validateBody({ ...valid, account: 'root' }, WelcomeMailPreviewDto),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Put,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthUser } from '../auth/types/auth-user';
|
||||
import { WelcomeMailPreviewDto, WelcomeMailTemplateDto } from './dto/welcome-mail-template.dto';
|
||||
import { WelcomeMailService } from './welcome-mail.service';
|
||||
import {
|
||||
WelcomeMailTemplateService,
|
||||
type WelcomeMailTemplateState,
|
||||
} from './welcome-mail-template.service';
|
||||
|
||||
/** Nur die sechs Texte aus dem DTO — nie weitere Felder in die Vorlage. */
|
||||
function textsOf(dto: WelcomeMailTemplateDto) {
|
||||
return {
|
||||
subject: dto.subject,
|
||||
heading: dto.heading,
|
||||
intro: dto.intro,
|
||||
loginHintDirectory: dto.loginHintDirectory,
|
||||
loginHintLocal: dto.loginHintLocal,
|
||||
closing: dto.closing,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
|
||||
*
|
||||
* Alle Routen nur fuer ADMIN und SUPER_ADMIN (`@Roles`), immer fuer den
|
||||
* Mandanten des angemeldeten Administrators (`currentUser.tenantId`, aus dem
|
||||
* Token) — ein Mandant aus der Anfrage wird nie uebernommen. Unbekannte
|
||||
* Platzhalter und zu lange Texte weist das DTO mit 400 ab.
|
||||
*
|
||||
* - GET /welcome-mail-template wirksame Texte + Standardtexte
|
||||
* - PUT /welcome-mail-template eigene Vorlage speichern
|
||||
* - DELETE /welcome-mail-template auf Standard zuruecksetzen
|
||||
* - POST /welcome-mail-template/preview gerendertes HTML (Beispielwerte)
|
||||
* - POST /welcome-mail-template/test Testmail an die eigene Adresse
|
||||
*
|
||||
* Keine Kennungs-Route (`:id`) — eine Vorlage je Mandant —, deshalb keine
|
||||
* Reihenfolge-Falle.
|
||||
*/
|
||||
@Controller('welcome-mail-template')
|
||||
export class WelcomeMailTemplateController {
|
||||
constructor(
|
||||
private readonly templateService: WelcomeMailTemplateService,
|
||||
private readonly welcomeMailService: WelcomeMailService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async get(@CurrentUser() currentUser: AuthUser): Promise<WelcomeMailTemplateState> {
|
||||
return this.templateService.getState(currentUser.tenantId);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async save(
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
@Body() dto: WelcomeMailTemplateDto,
|
||||
): Promise<WelcomeMailTemplateState> {
|
||||
return this.templateService.save(currentUser.tenantId, textsOf(dto), currentUser.username);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async reset(@CurrentUser() currentUser: AuthUser): Promise<WelcomeMailTemplateState> {
|
||||
return this.templateService.reset(currentUser.tenantId);
|
||||
}
|
||||
|
||||
@Post('preview')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async preview(
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
@Body() dto: WelcomeMailPreviewDto,
|
||||
@Headers('origin') origin?: string,
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
return this.welcomeMailService.preview(
|
||||
currentUser.tenantId,
|
||||
textsOf(dto),
|
||||
dto.account ?? 'directory',
|
||||
origin,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async sendTest(
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
@Body() dto: WelcomeMailTemplateDto,
|
||||
@Headers('origin') origin?: string,
|
||||
): Promise<{ success: true; to: string }> {
|
||||
const result = await this.welcomeMailService.sendTest(
|
||||
currentUser.tenantId,
|
||||
currentUser.id,
|
||||
textsOf(dto),
|
||||
origin,
|
||||
);
|
||||
return { success: true, ...result };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
|
||||
|
||||
/**
|
||||
* WelcomeMailTemplateService — Speicherung der eigenen Vorlage je Mandant.
|
||||
*
|
||||
* Festgenagelt: jeder Zugriff gebunden an den uebergebenen Mandanten
|
||||
* (`forTenant`), Standardtexte ohne Vorlage, Speichern als upsert auf
|
||||
* `tenantId`, Zuruecksetzen loescht die Vorlage.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
|
||||
}));
|
||||
|
||||
type Row = {
|
||||
tenantId: string;
|
||||
subject: string;
|
||||
heading: string;
|
||||
intro: string;
|
||||
loginHintDirectory?: string | null;
|
||||
loginHintLocal?: string | null;
|
||||
closing: string;
|
||||
updatedBy: string | null;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function makePrisma(rows: Row[] = []) {
|
||||
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
|
||||
return {
|
||||
__log: log,
|
||||
__rows: rows,
|
||||
__bound(tenantId: string) {
|
||||
return {
|
||||
welcomeMailTemplate: {
|
||||
findUnique: vi.fn(async (args: any) => {
|
||||
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'findUnique', args });
|
||||
// RLS-Nachbildung: nur Zeilen des gebundenen Mandanten sichtbar
|
||||
return (
|
||||
rows.find((r) => r.tenantId === args.where.tenantId && r.tenantId === tenantId) ??
|
||||
null
|
||||
);
|
||||
}),
|
||||
upsert: vi.fn(async (args: any) => {
|
||||
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'upsert', args });
|
||||
const existing = rows.find((r) => r.tenantId === args.where.tenantId);
|
||||
if (existing) Object.assign(existing, args.update, { updatedAt: new Date() });
|
||||
else rows.push({ ...args.create, updatedAt: new Date() });
|
||||
return {};
|
||||
}),
|
||||
deleteMany: vi.fn(async (args: any) => {
|
||||
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'deleteMany', args });
|
||||
const before = rows.length;
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
if (rows[i].tenantId === args.where.tenantId) rows.splice(i, 1);
|
||||
}
|
||||
return { count: before - rows.length };
|
||||
}),
|
||||
},
|
||||
tenant: {
|
||||
findUnique: vi.fn(async (args: any) => {
|
||||
log.push({ tenantId, model: 'tenant', method: 'findUnique', args });
|
||||
return args.where.id === 't1' ? { name: 'Beispiel GmbH' } : null;
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const texts = {
|
||||
subject: 'S {{name}}',
|
||||
heading: 'H',
|
||||
intro: 'I',
|
||||
loginHintDirectory: 'LD {{benutzername}}',
|
||||
loginHintLocal: 'LL',
|
||||
closing: 'C',
|
||||
};
|
||||
|
||||
let prisma: ReturnType<typeof makePrisma>;
|
||||
let service: WelcomeMailTemplateService;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = makePrisma();
|
||||
service = new WelcomeMailTemplateService(prisma as any);
|
||||
});
|
||||
|
||||
describe('WelcomeMailTemplateService', () => {
|
||||
it('ohne Vorlage: Standardtexte, custom=false, getCustomTexts=null', async () => {
|
||||
const state = await service.getState('t1');
|
||||
expect(state.custom).toBe(false);
|
||||
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
|
||||
expect(state.defaults).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
|
||||
expect(await service.getCustomTexts('t1')).toBeNull();
|
||||
expect(prisma.__log.every((c) => c.tenantId === 't1')).toBe(true);
|
||||
});
|
||||
|
||||
it('speichern: upsert auf tenantId, gebunden, mit Bearbeiter; danach custom=true', async () => {
|
||||
const state = await service.save('t1', texts, 'ada');
|
||||
const upsert = prisma.__log.find((c) => c.method === 'upsert');
|
||||
expect(upsert?.tenantId).toBe('t1');
|
||||
expect(upsert?.args.where).toEqual({ tenantId: 't1' });
|
||||
expect(upsert?.args.create).toEqual({ tenantId: 't1', ...texts, updatedBy: 'ada' });
|
||||
expect(state.custom).toBe(true);
|
||||
expect(state.texts).toEqual(texts);
|
||||
expect(state.updatedBy).toBe('ada');
|
||||
expect(await service.getCustomTexts('t1')).toEqual(texts);
|
||||
});
|
||||
|
||||
it('aeltere Vorlage ohne Anmeldehinweise (NULL) → Standardtexte fuer diese Felder, Rest bleibt eigen', async () => {
|
||||
prisma.__rows.push({
|
||||
tenantId: 't1',
|
||||
subject: 'Alt',
|
||||
heading: 'H',
|
||||
intro: 'I',
|
||||
loginHintDirectory: null,
|
||||
loginHintLocal: null,
|
||||
closing: 'C',
|
||||
updatedBy: null,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const state = await service.getState('t1');
|
||||
expect(state.custom).toBe(true);
|
||||
expect(state.texts.subject).toBe('Alt');
|
||||
expect(state.texts.loginHintDirectory).toBe(DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory);
|
||||
expect(state.texts.loginHintLocal).toBe(DEFAULT_WELCOME_MAIL_TEXTS.loginHintLocal);
|
||||
});
|
||||
|
||||
it('Mandantenbindung: Vorlage von t1 ist fuer t2 nicht sichtbar', async () => {
|
||||
await service.save('t1', texts, 'ada');
|
||||
expect(await service.getCustomTexts('t2')).toBeNull();
|
||||
expect(prisma.__log.filter((c) => c.method === 'findUnique').at(-1)?.tenantId).toBe('t2');
|
||||
});
|
||||
|
||||
it('zuruecksetzen: loescht gebunden, danach wieder Standard', async () => {
|
||||
await service.save('t1', texts, 'ada');
|
||||
const state = await service.reset('t1');
|
||||
const del = prisma.__log.find((c) => c.method === 'deleteMany');
|
||||
expect(del?.tenantId).toBe('t1');
|
||||
expect(del?.args.where).toEqual({ tenantId: 't1' });
|
||||
expect(state.custom).toBe(false);
|
||||
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
|
||||
});
|
||||
|
||||
it('Firmenname gebunden gelesen; unbekannter Mandant → leer', async () => {
|
||||
expect(await service.getTenantName('t1')).toBe('Beispiel GmbH');
|
||||
expect(await service.getTenantName('tx')).toBe('');
|
||||
expect(prisma.__log.filter((c) => c.model === 'tenant').map((c) => c.tenantId)).toEqual([
|
||||
't1',
|
||||
'tx',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DEFAULT_WELCOME_MAIL_TEXTS, type WelcomeMailTexts } from '@tessera/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* WelcomeMailTemplateService — eigene Vorlage der Willkommensmail
|
||||
* (Administrator → Willkommensmail).
|
||||
*
|
||||
* Hoechstens eine Vorlage je Mandant (Tabelle "WelcomeMailTemplate",
|
||||
* `tenantId` eindeutig). Gespeichert werden nur die sechs Texte; Kopf,
|
||||
* Zugangsdaten, Knoepfe und Fusszeile bleiben fest in
|
||||
* `welcome-mail.template.ts`. Fehlt die Vorlage, gelten
|
||||
* `DEFAULT_WELCOME_MAIL_TEXTS` aus `@tessera/shared`. Die zwei
|
||||
* Anmeldehinweise kamen spaeter hinzu (Spalten nullable): fehlt ein Wert in
|
||||
* einer aelteren Vorlage, gilt fuer ihn der Standardtext.
|
||||
*
|
||||
* Mandantenbindung: jeder Zugriff laeuft ueber `forTenant(prisma, tenantId)`.
|
||||
* Beim Versand ist `tenantId` der Mandant des ZIEL-Benutzers
|
||||
* (`WelcomeMailService.send`), in der Verwaltung der Mandant des
|
||||
* angemeldeten Administrators (`WelcomeMailTemplateController`). Die
|
||||
* Platzhalter-Pruefung liegt im DTO (400 bei unbekanntem Platzhalter).
|
||||
*/
|
||||
|
||||
/** Antwort von GET /welcome-mail-template. */
|
||||
export interface WelcomeMailTemplateState {
|
||||
/** `true` = eigene Vorlage gespeichert, `false` = Standardtexte. */
|
||||
custom: boolean;
|
||||
/** Wirksame Texte (eigene Vorlage oder Standard) — Vorbelegung im Formular. */
|
||||
texts: WelcomeMailTexts;
|
||||
/** Standardtexte fuer "Auf Standard zuruecksetzen". */
|
||||
defaults: WelcomeMailTexts;
|
||||
updatedAt: Date | null;
|
||||
updatedBy: string | null;
|
||||
}
|
||||
|
||||
const TEXT_SELECT = {
|
||||
subject: true,
|
||||
heading: true,
|
||||
intro: true,
|
||||
loginHintDirectory: true,
|
||||
loginHintLocal: true,
|
||||
closing: true,
|
||||
updatedAt: true,
|
||||
updatedBy: true,
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class WelcomeMailTemplateService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getState(tenantId: string): Promise<WelcomeMailTemplateState> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const row = await tenantPrisma.welcomeMailTemplate.findUnique({
|
||||
where: { tenantId },
|
||||
select: TEXT_SELECT,
|
||||
});
|
||||
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
|
||||
if (!row) {
|
||||
return { custom: false, texts: defaults, defaults, updatedAt: null, updatedBy: null };
|
||||
}
|
||||
return {
|
||||
custom: true,
|
||||
texts: {
|
||||
subject: row.subject,
|
||||
heading: row.heading,
|
||||
intro: row.intro,
|
||||
loginHintDirectory: row.loginHintDirectory?.trim()
|
||||
? row.loginHintDirectory
|
||||
: defaults.loginHintDirectory,
|
||||
loginHintLocal: row.loginHintLocal?.trim() ? row.loginHintLocal : defaults.loginHintLocal,
|
||||
closing: row.closing,
|
||||
},
|
||||
defaults,
|
||||
updatedAt: row.updatedAt,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
/** Texte fuer den Versand: eigene Vorlage des Mandanten, sonst `null` (= Standard). */
|
||||
async getCustomTexts(tenantId: string): Promise<WelcomeMailTexts | null> {
|
||||
const state = await this.getState(tenantId);
|
||||
return state.custom ? state.texts : null;
|
||||
}
|
||||
|
||||
/** Name des Mandanten fuer `{{firma}}`; leer, wenn der Mandant fehlt. */
|
||||
async getTenantName(tenantId: string): Promise<string> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const tenant = await tenantPrisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { name: true },
|
||||
});
|
||||
return tenant?.name ?? '';
|
||||
}
|
||||
|
||||
async save(
|
||||
tenantId: string,
|
||||
texts: WelcomeMailTexts,
|
||||
updatedBy: string,
|
||||
): Promise<WelcomeMailTemplateState> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const data = {
|
||||
subject: texts.subject,
|
||||
heading: texts.heading,
|
||||
intro: texts.intro,
|
||||
loginHintDirectory: texts.loginHintDirectory,
|
||||
loginHintLocal: texts.loginHintLocal,
|
||||
closing: texts.closing,
|
||||
updatedBy,
|
||||
};
|
||||
await tenantPrisma.welcomeMailTemplate.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, ...data },
|
||||
update: data,
|
||||
});
|
||||
return this.getState(tenantId);
|
||||
}
|
||||
|
||||
/** "Auf Standard zuruecksetzen": loescht die eigene Vorlage (idempotent). */
|
||||
async reset(tenantId: string): Promise<WelcomeMailTemplateState> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
await tenantPrisma.welcomeMailTemplate.deleteMany({ where: { tenantId } });
|
||||
return this.getState(tenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { DEFAULT_WELCOME_MAIL_TEXTS, type WelcomeMailTexts } from '@tessera/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
normalizeOrigin,
|
||||
WelcomeMailService,
|
||||
type WelcomeMailTarget,
|
||||
} from './welcome-mail.service';
|
||||
|
||||
/**
|
||||
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
|
||||
*
|
||||
* Festgenagelt: Versand auch an bereits angemeldete Benutzer; 409 fuer
|
||||
* deaktivierte Benutzer,
|
||||
* 400 ohne Adresse, 409 ohne Versandweg, 502 bei Versandfehler (und dann
|
||||
* KEIN `welcomeMailSentAt`); beide Schreibzugriffe gebunden an den
|
||||
* Mandanten des ZIELS; Inhalt je Kontoart (verzeichnisgefuehrt: Windows-
|
||||
* Passwort, kein Token; lokal: Token-Link mit der Frist des
|
||||
* "Passwort vergessen"-Wegs); Adresse aus Konfiguration bzw. Origin.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
|
||||
}));
|
||||
|
||||
function makePrisma() {
|
||||
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
|
||||
return {
|
||||
__log: log,
|
||||
__bound(tenantId: string) {
|
||||
return {
|
||||
passwordResetToken: {
|
||||
create: vi.fn(async (args: any) => {
|
||||
log.push({ tenantId, model: 'passwordResetToken', method: 'create', args });
|
||||
return { id: 'tok-row', ...args.data };
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
update: vi.fn(async (args: any) => {
|
||||
log.push({ tenantId, model: 'user', method: 'update', args });
|
||||
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
|
||||
}),
|
||||
findFirst: vi.fn(async (args: any) => {
|
||||
log.push({ tenantId, model: 'user', method: 'findFirst', args });
|
||||
return selfRow;
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMail(opts: { available?: boolean; fail?: boolean } = {}) {
|
||||
return {
|
||||
hasConfiguredTransport: vi.fn(async () => opts.available ?? true),
|
||||
sendWelcomeMail: vi.fn(async () => {
|
||||
if (opts.fail) throw new Error('ECONNREFUSED');
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeConfig(values: Record<string, string | undefined>) {
|
||||
return { get: vi.fn((key: string) => values[key]) };
|
||||
}
|
||||
|
||||
const localUser: WelcomeMailTarget = {
|
||||
id: 'u-local',
|
||||
tenantId: 't1',
|
||||
username: 'max.muster',
|
||||
displayName: 'Max Muster',
|
||||
email: 'max@example.invalid',
|
||||
ldapDn: null,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const ldapUser: WelcomeMailTarget = {
|
||||
...localUser,
|
||||
id: 'u-ldap',
|
||||
username: 'erika',
|
||||
displayName: null,
|
||||
email: 'erika@example.invalid',
|
||||
ldapDn: 'CN=Erika,OU=Users,DC=example,DC=invalid',
|
||||
};
|
||||
|
||||
let prisma: ReturnType<typeof makePrisma>;
|
||||
/** Eigenes Konto des Administrators fuer die Testmail (`user.findFirst`). */
|
||||
let selfRow: {
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
ldapDn: string | null;
|
||||
} | null;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = makePrisma();
|
||||
selfRow = {
|
||||
username: 'admin.lokal',
|
||||
displayName: 'Ada Admin',
|
||||
email: 'ada@example.invalid',
|
||||
ldapDn: null,
|
||||
};
|
||||
});
|
||||
|
||||
/** Vorlagen-Dienst: eigene Vorlage je Mandant (Map), Firmenname je Mandant. */
|
||||
function makeTemplates(custom: Record<string, WelcomeMailTexts> = {}) {
|
||||
return {
|
||||
getCustomTexts: vi.fn(async (tenantId: string) => custom[tenantId] ?? null),
|
||||
getTenantName: vi.fn(async (tenantId: string) => `Firma ${tenantId}`),
|
||||
};
|
||||
}
|
||||
|
||||
function make(
|
||||
mail = makeMail(),
|
||||
config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' }),
|
||||
templates = makeTemplates(),
|
||||
) {
|
||||
const service = new WelcomeMailService(
|
||||
prisma as any,
|
||||
mail as any,
|
||||
config as any,
|
||||
templates as any,
|
||||
);
|
||||
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
|
||||
return { service, mail, templates };
|
||||
}
|
||||
|
||||
describe('WelcomeMailService.send — Vorbedingungen', () => {
|
||||
it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => {
|
||||
const { service, mail } = make();
|
||||
await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget);
|
||||
await service.send({
|
||||
...localUser,
|
||||
lastLoginAt: new Date(),
|
||||
role: 'SUPER_ADMIN',
|
||||
} as WelcomeMailTarget);
|
||||
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('deaktiviertes Konto → ConflictException', async () => {
|
||||
const { service, mail } = make();
|
||||
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne E-Mail-Adresse → BadRequestException', async () => {
|
||||
const { service, mail } = make();
|
||||
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne Versandweg → ConflictException mit Hinweis auf Administrator → SMTP; Pruefung im Mandanten des Ziels', async () => {
|
||||
const mail = makeMail({ available: false });
|
||||
const { service } = make(mail);
|
||||
await expect(service.send(localUser)).rejects.toThrow('Administrator → SMTP');
|
||||
expect(mail.hasConfiguredTransport).toHaveBeenCalledWith('t1');
|
||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||
expect(prisma.__log).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailService.send — Versand', () => {
|
||||
it('lokales Konto: Token gebunden an den Mandanten des Ziels, Frist 7 Tage, Link in Text und HTML, kein Kennwort; welcomeMailSentAt gebunden gesetzt', async () => {
|
||||
const { service, mail } = make();
|
||||
const before = Date.now();
|
||||
|
||||
const result = await service.send(localUser);
|
||||
|
||||
const tokenCall = prisma.__log.find((c) => c.model === 'passwordResetToken');
|
||||
expect(tokenCall?.tenantId).toBe('t1');
|
||||
expect(tokenCall?.args.data.userId).toBe('u-local');
|
||||
const ttl = (tokenCall as { args: any }).args.data.expiresAt.getTime() - before;
|
||||
expect(ttl).toBeGreaterThan(7 * 24 * 60 * 60 * 1000 - 60 * 1000);
|
||||
expect(ttl).toBeLessThanOrEqual(7 * 24 * 60 * 60 * 1000 + 1000);
|
||||
|
||||
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(1);
|
||||
const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[];
|
||||
expect(tenantId).toBe('t1');
|
||||
expect(to).toBe('max@example.invalid');
|
||||
const token = (tokenCall as { args: any }).args.data.token;
|
||||
expect(rendered.text).toContain(`https://tessera.example.invalid/reset-password/${token}`);
|
||||
expect(rendered.html).toContain(`https://tessera.example.invalid/reset-password/${token}`);
|
||||
expect(rendered.html).toContain('Passwort festlegen');
|
||||
expect(rendered.html).toContain('Willkommen bei Tessera, Max Muster!');
|
||||
expect(rendered.text).toContain('Benutzername: max.muster');
|
||||
expect(rendered.html).toContain('https://tessera.example.invalid/login');
|
||||
expect(rendered.text).not.toContain('Windows-Passwort');
|
||||
|
||||
const updateCall = prisma.__log.find((c) => c.model === 'user' && c.method === 'update');
|
||||
expect(updateCall?.tenantId).toBe('t1');
|
||||
expect(updateCall?.args.where).toEqual({ id: 'u-local' });
|
||||
expect(updateCall?.args.data.welcomeMailSentAt).toBeInstanceOf(Date);
|
||||
expect(result.to).toBe('max@example.invalid');
|
||||
expect(result.welcomeMailSentAt).toBe(updateCall?.args.data.welcomeMailSentAt);
|
||||
});
|
||||
|
||||
it('verzeichnisgefuehrtes Konto: kein Token, Hinweis auf das Windows-Passwort, Anrede mit Benutzername ohne Anzeigenamen', async () => {
|
||||
const { service, mail } = make();
|
||||
|
||||
await service.send(ldapUser);
|
||||
|
||||
expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false);
|
||||
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||
expect(rendered.text).toContain('gewohnten Windows-Passwort');
|
||||
expect(rendered.html).toContain('gewohnten Windows-Passwort');
|
||||
expect(rendered.html).not.toContain('reset-password');
|
||||
expect(rendered.html).not.toContain('Passwort festlegen');
|
||||
expect(rendered.html).toContain('Willkommen bei Tessera, erika!');
|
||||
});
|
||||
|
||||
it('Versandfehler → BadGatewayException, welcomeMailSentAt wird NICHT gesetzt', async () => {
|
||||
const { service } = make(makeMail({ fail: true }));
|
||||
await expect(service.send(ldapUser)).rejects.toBeInstanceOf(BadGatewayException);
|
||||
expect(prisma.__log.some((c) => c.model === 'user')).toBe(false);
|
||||
});
|
||||
|
||||
it('Anzeigename mit Markup wird im HTML maskiert', async () => {
|
||||
const { service, mail } = make();
|
||||
await service.send({ ...ldapUser, displayName: '<b>Böse</b>' });
|
||||
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||
expect(rendered.html).not.toContain('<b>Böse</b>');
|
||||
expect(rendered.html).toContain('<b>Böse</b>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailService.resolveAppUrl', () => {
|
||||
it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => {
|
||||
const { service } = make(
|
||||
makeMail(),
|
||||
makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }),
|
||||
);
|
||||
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe(
|
||||
'https://tessera.example.invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('ohne Konfiguration: Origin der Anfrage', () => {
|
||||
const { service } = make(makeMail(), makeConfig({}));
|
||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe(
|
||||
'https://alpha.example.invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('Konfiguration zeigt nur auf localhost (Compose-Vorgabe) → Origin gewinnt; ohne Origin bleibt die Konfiguration', () => {
|
||||
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'http://localhost:3000' }));
|
||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe(
|
||||
'https://alpha.example.invalid',
|
||||
);
|
||||
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('ungueltiger Origin wird ignoriert', () => {
|
||||
expect(normalizeOrigin('javascript:alert(1)')).toBeNull();
|
||||
expect(normalizeOrigin('kein origin')).toBeNull();
|
||||
expect(normalizeOrigin('https://a.example.invalid/pfad')).toBe('https://a.example.invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailService.send — eigene Vorlage', () => {
|
||||
const custom: WelcomeMailTexts = {
|
||||
subject: 'Hallo {{vorname}} bei {{firma}}',
|
||||
heading: 'Schön, dass Sie da sind, {{name}}!',
|
||||
intro: 'Erste Zeile\nzweite Zeile\n\nIhr Konto: {{benutzername}} / {{email}} / {{adresse}}',
|
||||
loginHintDirectory: 'Bitte mit dem Kennwort von {{firma}} anmelden, {{vorname}}.',
|
||||
loginHintLocal: 'Zuerst ein eigenes Passwort vergeben, {{vorname}}.',
|
||||
closing: 'Grüße vom <b>IT-Team</b>',
|
||||
};
|
||||
|
||||
it('ohne eigene Vorlage: Standardtexte; Vorlage und Firmenname werden fuer den Mandanten des ZIELS gelesen', async () => {
|
||||
const { service, mail, templates } = make();
|
||||
await service.send({ ...ldapUser, tenantId: 't9' });
|
||||
expect(templates.getCustomTexts).toHaveBeenCalledWith('t9');
|
||||
expect(templates.getTenantName).toHaveBeenCalledWith('t9');
|
||||
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||
expect(rendered.subject).toBe('Willkommen bei Tessera');
|
||||
expect(rendered.html).toContain('Tessera gibt es auch als Desktop-App');
|
||||
});
|
||||
|
||||
it('mit eigener Vorlage des Ziel-Mandanten: Platzhalter ersetzt, Absaetze/Umbrueche, Markup aus der Vorlage escaped, feste Bausteine bleiben', async () => {
|
||||
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
|
||||
await service.send(localUser);
|
||||
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||
expect(rendered.subject).toBe('Hallo Max bei Firma t1');
|
||||
expect(rendered.html).toContain('Schön, dass Sie da sind, Max Muster!');
|
||||
expect(rendered.html).toContain('Erste Zeile<br>zweite Zeile');
|
||||
expect(rendered.html).toContain(
|
||||
'Ihr Konto: max.muster / max@example.invalid / https://tessera.example.invalid',
|
||||
);
|
||||
expect(rendered.html).toContain('Grüße vom <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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { User } from '@prisma/client';
|
||||
import type { WelcomeMailTexts } from '@tessera/shared';
|
||||
import { WELCOME_TOKEN_TTL_MS } from '../auth/password-reset-token';
|
||||
import { loadWelcomeHeaderPng, MailService, WELCOME_HEADER_CID } from '../mail/mail.service';
|
||||
import {
|
||||
type RenderedWelcomeMail,
|
||||
renderWelcomeMail,
|
||||
type WelcomeMailAccount,
|
||||
} from '../mail/welcome-mail.template';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
|
||||
|
||||
/**
|
||||
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung
|
||||
* (Administrator → Benutzer, "Willkommensmail senden").
|
||||
*
|
||||
* Kann an JEDEN Benutzer gehen, unabhaengig davon, ob und wann er sich
|
||||
* schon angemeldet hat (Wunsch des Betreibers: auch zum Erneuern des
|
||||
* Zugangs und zum Ausprobieren). Wer wem senden darf (nur eigener
|
||||
* Mandant, ADMIN nie an SUPER_ADMIN), prueft der Controller wie beim
|
||||
* Bearbeiten. Erneutes Senden ist erlaubt und ueberschreibt
|
||||
* `welcomeMailSentAt`. Abgelehnt wird nur ein deaktiviertes Konto (409),
|
||||
* ein Konto ohne Adresse (400) und ein Mandant ohne Versandweg (409).
|
||||
*
|
||||
* Anmeldehinweis je Kontoart:
|
||||
* - verzeichnisgefuehrt (`ldapDn` gesetzt): Hinweis auf das gewohnte
|
||||
* Windows-Passwort — Tessera kennt und setzt dieses Kennwort nicht (das
|
||||
* Verzeichnis bleibt nur lesend angebunden);
|
||||
* - lokal: Link "Passwort festlegen" mit einem frischen `PasswordResetToken`
|
||||
* — derselbe Token, dieselbe Seite `/reset-password/<token>` und dieselbe
|
||||
* Frist `WELCOME_TOKEN_TTL_MS` (7 Tage statt 1 Stunde wie beim Weg "Passwort
|
||||
* vergessen"). Ein Kennwort steht NIE in der Mail.
|
||||
*
|
||||
* Mandantenbindung: der Aufrufer (`UserController`) hat den Zielbenutzer
|
||||
* bereits rollenabhaengig aufgeloest und die Mandanten-/Rollenregeln
|
||||
* geprueft; beide Zugriffe hier (`passwordResetToken.create`,
|
||||
* `user.update`) binden ueber `forTenant()` an den Mandanten des ZIELS —
|
||||
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
|
||||
* SMTP-Weg genau dieses Mandanten (`MailService.sendWelcomeMail`).
|
||||
*
|
||||
* Eigene Vorlage (Administrator → Willkommensmail): `send` nimmt die Texte
|
||||
* der Vorlage des Mandanten des ZIELS (`WelcomeMailTemplateService`), sonst
|
||||
* die Standardtexte. `preview` rendert einen (auch ungespeicherten)
|
||||
* Formularinhalt mit Beispielwerten, `sendTest` schickt ihn an die eigene
|
||||
* Adresse des Administrators — OHNE Token: bei einem lokalen eigenen Konto
|
||||
* zeigt die Testmail den Knopf "Passwort festlegen" als Beispiel-Link zur
|
||||
* Anmeldeseite (Kontoart `local-example`), damit nie ein nutzbarer
|
||||
* Passwort-Link an eine Testadresse geht.
|
||||
*
|
||||
* Adresse in der Mail: `TESSERA_APP_URL` (in Compose aus `APP_URL`), sonst
|
||||
* der Origin der Admin-Anfrage. Zeigt die Konfiguration nur auf
|
||||
* `localhost` (Compose-Vorgabe, nie gesetzt), gewinnt ein vorhandener
|
||||
* Origin — sonst bekaeme der Empfaenger einen Link, der auf seinem
|
||||
* Rechner ins Leere fuehrt.
|
||||
*/
|
||||
|
||||
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
|
||||
export type WelcomeMailTarget = Pick<
|
||||
User,
|
||||
'id' | 'tenantId' | 'username' | 'displayName' | 'email' | 'ldapDn' | 'isActive'
|
||||
>;
|
||||
|
||||
export interface WelcomeMailResult {
|
||||
to: string;
|
||||
welcomeMailSentAt: Date;
|
||||
}
|
||||
|
||||
const FALLBACK_APP_URL = 'http://localhost:3000';
|
||||
|
||||
/** Beispielwerte der Vorschau (Administrator → Willkommensmail). */
|
||||
export const PREVIEW_SAMPLE = {
|
||||
name: 'Max Mustermann',
|
||||
username: 'max.mustermann',
|
||||
email: 'max.mustermann@example.com',
|
||||
} as const;
|
||||
|
||||
/** `http(s)://host[:port]` aus einem Origin-Kopf, sonst `null`. */
|
||||
export function normalizeOrigin(value: string | undefined | null): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalhostUrl(value: string): boolean {
|
||||
try {
|
||||
const host = new URL(value).hostname;
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WelcomeMailService {
|
||||
private readonly logger = new Logger(WelcomeMailService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly templateService: WelcomeMailTemplateService,
|
||||
) {}
|
||||
|
||||
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
|
||||
async isAvailable(tenantId: string): Promise<boolean> {
|
||||
return this.mailService.hasConfiguredTransport(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffentliche Adresse fuer Links in der Mail: Konfiguration, sonst Origin
|
||||
* der Anfrage, sonst die Entwicklungsvorgabe (siehe Kopfkommentar).
|
||||
*/
|
||||
resolveAppUrl(requestOrigin?: string | null): string {
|
||||
const raw = (this.configService.get<string>('TESSERA_APP_URL') ?? '').trim();
|
||||
const configured = /^https?:\/\//i.test(raw) ? raw.replace(/\/+$/, '') : '';
|
||||
const origin = normalizeOrigin(requestOrigin);
|
||||
if (configured && !(isLocalhostUrl(configured) && origin)) {
|
||||
return configured;
|
||||
}
|
||||
return origin ?? (configured || FALLBACK_APP_URL);
|
||||
}
|
||||
|
||||
async send(target: WelcomeMailTarget, requestOrigin?: string | null): Promise<WelcomeMailResult> {
|
||||
if (!target.isActive) {
|
||||
throw new ConflictException(
|
||||
'Dieses Konto ist deaktiviert. Aktivieren Sie es zuerst, bevor Sie eine Willkommensmail senden.',
|
||||
);
|
||||
}
|
||||
const to = target.email?.trim();
|
||||
if (!to) {
|
||||
throw new BadRequestException('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.');
|
||||
}
|
||||
if (!(await this.mailService.hasConfiguredTransport(target.tenantId))) {
|
||||
throw new ConflictException(
|
||||
'Für den E-Mail-Versand ist noch kein SMTP-Server eingerichtet. Ein Administrator legt ihn unter Administrator → SMTP fest.',
|
||||
);
|
||||
}
|
||||
|
||||
const appUrl = this.resolveAppUrl(requestOrigin);
|
||||
const tenantPrisma = forTenant(this.prisma, target.tenantId);
|
||||
|
||||
let account: WelcomeMailAccount;
|
||||
if (target.ldapDn) {
|
||||
account = { kind: 'directory' };
|
||||
} else {
|
||||
// Derselbe Token wie beim Weg "Passwort vergessen" (T-02-13): zufaellig,
|
||||
// einmal verwendbar, gleiche Frist.
|
||||
const token = randomUUID();
|
||||
await tenantPrisma.passwordResetToken.create({
|
||||
data: {
|
||||
token,
|
||||
userId: target.id,
|
||||
expiresAt: new Date(Date.now() + WELCOME_TOKEN_TTL_MS),
|
||||
},
|
||||
});
|
||||
account = {
|
||||
kind: 'local',
|
||||
setPasswordUrl: `${appUrl}/reset-password/${token}`,
|
||||
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
const [texts, tenantName] = await Promise.all([
|
||||
this.templateService.getCustomTexts(target.tenantId),
|
||||
this.templateService.getTenantName(target.tenantId),
|
||||
]);
|
||||
const mail = renderWelcomeMail({
|
||||
name: target.displayName?.trim() || target.username,
|
||||
username: target.username,
|
||||
email: to,
|
||||
tenantName,
|
||||
appUrl,
|
||||
account,
|
||||
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
||||
texts,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.mailService.sendWelcomeMail(target.tenantId, to, mail);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Welcome mail to user ${target.id} failed`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
'Die Willkommensmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await tenantPrisma.user.update({
|
||||
where: { id: target.id },
|
||||
data: { welcomeMailSentAt: new Date() },
|
||||
select: { welcomeMailSentAt: true },
|
||||
});
|
||||
const welcomeMailSentAt = updated.welcomeMailSentAt ?? new Date();
|
||||
|
||||
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
|
||||
return { to, welcomeMailSentAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschau fuer Administrator → Willkommensmail: rendert `texts` (auch
|
||||
* ungespeichert) mit festen Beispielwerten und dem Firmennamen des eigenen
|
||||
* Mandanten. Das Kopfbild steht als `data:`-Adresse im HTML statt als
|
||||
* `cid:`, damit die Vorschau im Rahmen der Seite der echten Mail gleicht.
|
||||
* Der Link "Passwort festlegen" ist ein Beispiel ohne Token.
|
||||
*/
|
||||
async preview(
|
||||
tenantId: string,
|
||||
texts: WelcomeMailTexts,
|
||||
accountKind: 'directory' | 'local',
|
||||
requestOrigin?: string | null,
|
||||
): Promise<RenderedWelcomeMail> {
|
||||
const appUrl = this.resolveAppUrl(requestOrigin);
|
||||
const header = loadWelcomeHeaderPng();
|
||||
const account: WelcomeMailAccount =
|
||||
accountKind === 'directory'
|
||||
? { kind: 'directory' }
|
||||
: {
|
||||
kind: 'local',
|
||||
setPasswordUrl: `${appUrl}/reset-password/beispiel`,
|
||||
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
|
||||
};
|
||||
return renderWelcomeMail({
|
||||
name: PREVIEW_SAMPLE.name,
|
||||
username: PREVIEW_SAMPLE.username,
|
||||
email: PREVIEW_SAMPLE.email,
|
||||
tenantName: await this.templateService.getTenantName(tenantId),
|
||||
appUrl,
|
||||
account,
|
||||
headerImageSrc: header ? `data:image/png;base64,${header.toString('base64')}` : null,
|
||||
texts,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Testmail an den angemeldeten Administrator selbst, mit dem aktuellen
|
||||
* (auch ungespeicherten) Formularinhalt und dem eigenen Konto als
|
||||
* Beispiel. Es entsteht KEIN Token: ein verzeichnisgefuehrtes eigenes
|
||||
* Konto bekommt den Windows-Hinweis, ein lokales den Beispiel-Link zur
|
||||
* Anmeldeseite (`local-example`). `welcomeMailSentAt` bleibt unberuehrt.
|
||||
* Ohne eigene Adresse 400, ohne Versandweg 409, Versandfehler 502.
|
||||
*/
|
||||
async sendTest(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
texts: WelcomeMailTexts,
|
||||
requestOrigin?: string | null,
|
||||
): Promise<{ to: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const self = await tenantPrisma.user.findFirst({
|
||||
where: { id: userId, tenantId },
|
||||
select: { username: true, displayName: true, email: true, ldapDn: true },
|
||||
});
|
||||
const to = self?.email?.trim();
|
||||
if (!self || !to) {
|
||||
throw new BadRequestException(
|
||||
'Für Ihr eigenes Konto ist keine E-Mail-Adresse hinterlegt. Hinterlegen Sie zuerst eine Adresse (Administrator → Benutzer), dann kann die Testmail an Sie gehen.',
|
||||
);
|
||||
}
|
||||
if (!(await this.mailService.hasConfiguredTransport(tenantId))) {
|
||||
throw new ConflictException(
|
||||
'Für den E-Mail-Versand ist noch kein SMTP-Server eingerichtet. Ein Administrator legt ihn unter Administrator → SMTP fest.',
|
||||
);
|
||||
}
|
||||
|
||||
const mail = renderWelcomeMail({
|
||||
name: self.displayName?.trim() || self.username,
|
||||
username: self.username,
|
||||
email: to,
|
||||
tenantName: await this.templateService.getTenantName(tenantId),
|
||||
appUrl: this.resolveAppUrl(requestOrigin),
|
||||
account: self.ldapDn ? { kind: 'directory' } : { kind: 'local-example' },
|
||||
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
||||
texts,
|
||||
notice:
|
||||
'Testmail aus Administrator → Willkommensmail – so sieht die Willkommensmail mit Ihrer Vorlage aus.',
|
||||
});
|
||||
|
||||
try {
|
||||
await this.mailService.sendWelcomeMail(tenantId, to, mail);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Welcome mail test to user ${userId} failed`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
'Die Testmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
|
||||
);
|
||||
}
|
||||
this.logger.log(`Welcome mail test sent to user ${userId} (tenant ${tenantId})`);
|
||||
return { to };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { UserAccessModal } from './components/UserAccessModal';
|
||||
|
||||
@@ -19,6 +19,10 @@ interface User {
|
||||
isActive: boolean;
|
||||
tenantId: string;
|
||||
createdAt: string;
|
||||
/** `null` = hat sich noch nie angemeldet (Spalte "Letzte Anmeldung": "Noch nie"). */
|
||||
lastLoginAt?: string | null;
|
||||
/** Zeitpunkt der letzten Willkommensmail, `null` = nie gesendet. */
|
||||
welcomeMailSentAt?: string | null;
|
||||
}
|
||||
|
||||
interface UserFormData {
|
||||
@@ -57,6 +61,8 @@ export default function AdminUsersPage() {
|
||||
const t = useTranslations('admin.users');
|
||||
const tCommon = useTranslations('common');
|
||||
const tHeader = useTranslations('header');
|
||||
const tWelcome = useTranslations('admin.users.welcomeMail');
|
||||
const locale = useLocale();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
@@ -68,6 +74,13 @@ export default function AdminUsersPage() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [detailsUser, setDetailsUser] = useState<User | null>(null);
|
||||
// Willkommensmail: Versandweg vorhanden? `null` = noch unbekannt (Knopf
|
||||
// bleibt bedienbar, die API meldet einen fehlenden Versandweg selbst).
|
||||
const [welcomeAvailable, setWelcomeAvailable] = useState<boolean | null>(null);
|
||||
const [welcomeUser, setWelcomeUser] = useState<User | null>(null);
|
||||
const [welcomeSending, setWelcomeSending] = useState(false);
|
||||
const [welcomeError, setWelcomeError] = useState<string | null>(null);
|
||||
const [welcomeSuccess, setWelcomeSuccess] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<UserFormData>({
|
||||
username: '',
|
||||
email: '',
|
||||
@@ -77,8 +90,7 @@ export default function AdminUsersPage() {
|
||||
});
|
||||
|
||||
// Access check: only ADMIN and SUPER_ADMIN
|
||||
const hasAccess =
|
||||
currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoadError(null);
|
||||
@@ -106,6 +118,73 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
}, [hasAccess, fetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) return;
|
||||
let cancelled = false;
|
||||
fetch(`${API_URL}/users/welcome-mail/status`, { credentials: 'include' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { available?: unknown };
|
||||
if (!cancelled && typeof body?.available === 'boolean') {
|
||||
setWelcomeAvailable(body.available);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* unbekannt lassen — die API prueft beim Versand selbst */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasAccess]);
|
||||
|
||||
const formatDateTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d);
|
||||
};
|
||||
|
||||
/**
|
||||
* Grund, warum der Knopf "Willkommensmail senden" nicht bedienbar ist,
|
||||
* oder `null`. Rein ergonomisch — die API prueft dasselbe (400/409).
|
||||
*/
|
||||
const welcomeBlockedReason = (user: User): string | null => {
|
||||
if (!user.email) return tWelcome('noEmail');
|
||||
if (!user.isActive) return tWelcome('inactive');
|
||||
if (welcomeAvailable === false) return tWelcome('noSmtp');
|
||||
return null;
|
||||
};
|
||||
|
||||
const openWelcome = (user: User) => {
|
||||
setWelcomeUser(user);
|
||||
setWelcomeError(null);
|
||||
setWelcomeSuccess(null);
|
||||
};
|
||||
|
||||
const handleSendWelcome = async () => {
|
||||
if (!welcomeUser) return;
|
||||
setWelcomeSending(true);
|
||||
setWelcomeError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/users/${welcomeUser.id}/welcome-mail`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
const email = welcomeUser.email ?? '';
|
||||
setWelcomeUser(null);
|
||||
setWelcomeSuccess(tWelcome('success', { email }));
|
||||
fetchUsers();
|
||||
} else {
|
||||
const detail = await readApiMessage(res);
|
||||
setWelcomeError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
|
||||
}
|
||||
} catch {
|
||||
setWelcomeError(t('errors.network'));
|
||||
} finally {
|
||||
setWelcomeSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingUser(null);
|
||||
setFormData({
|
||||
@@ -136,9 +215,7 @@ export default function AdminUsersPage() {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
const url = editingUser
|
||||
? `${API_URL}/users/${editingUser.id}`
|
||||
: `${API_URL}/users`;
|
||||
const url = editingUser ? `${API_URL}/users/${editingUser.id}` : `${API_URL}/users`;
|
||||
const method = editingUser ? 'PATCH' : 'POST';
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
@@ -166,9 +243,7 @@ export default function AdminUsersPage() {
|
||||
fetchUsers();
|
||||
} else {
|
||||
const detail = await readApiMessage(res);
|
||||
setFormError(
|
||||
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
|
||||
);
|
||||
setFormError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
|
||||
}
|
||||
} catch {
|
||||
setFormError(t('errors.network'));
|
||||
@@ -187,9 +262,7 @@ export default function AdminUsersPage() {
|
||||
fetchUsers();
|
||||
} else {
|
||||
const detail = await readApiMessage(res);
|
||||
setDeleteError(
|
||||
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
|
||||
);
|
||||
setDeleteError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
|
||||
}
|
||||
} catch {
|
||||
setDeleteError(t('errors.network'));
|
||||
@@ -212,6 +285,13 @@ export default function AdminUsersPage() {
|
||||
const canManageRow = (user: User) =>
|
||||
!(user.role === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN');
|
||||
|
||||
/**
|
||||
* Willkommensmail: an jeden Benutzer, dessen Zeile die angemeldete Person
|
||||
* verwalten darf (Zielrollen-Riegel wie beim Bearbeiten). Spiegelt die
|
||||
* API, die allein entscheidet.
|
||||
*/
|
||||
const canSendWelcome = (user: User) => canManageRow(user);
|
||||
|
||||
const roleBadgeClass = (role: string) => {
|
||||
switch (role) {
|
||||
case 'SUPER_ADMIN':
|
||||
@@ -228,11 +308,7 @@ export default function AdminUsersPage() {
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||
{t('create')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -246,6 +322,23 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{welcomeSuccess && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start justify-between gap-3 rounded-md border border-status-ok/40 bg-status-ok/12 p-3 text-sm text-status-ok-fg"
|
||||
>
|
||||
<span>{welcomeSuccess}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWelcomeSuccess(null)}
|
||||
aria-label={tCommon('close')}
|
||||
className="rounded px-1 text-xs hover:bg-muted"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users table */}
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||
@@ -271,6 +364,9 @@ export default function AdminUsersPage() {
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('status')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('lastLogin')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||
{t('actions')}
|
||||
</th>
|
||||
@@ -279,15 +375,9 @@ export default function AdminUsersPage() {
|
||||
<tbody className="divide-y divide-border">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{user.username}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{user.email ?? '–'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{user.displayName ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-foreground">{user.username}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{user.email ?? '–'}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{user.displayName ?? '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`}
|
||||
@@ -305,23 +395,52 @@ export default function AdminUsersPage() {
|
||||
>
|
||||
{user.isActive ? tCommon('active') : tCommon('inactive')}
|
||||
</span>
|
||||
{user.welcomeMailSentAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{tWelcome('sentAt', { date: formatDateTime(user.welcomeMailSentAt) })}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
|
||||
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t('neverLoggedIn')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{canSendWelcome(user) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openWelcome(user)}
|
||||
disabled={welcomeBlockedReason(user) !== null}
|
||||
title={
|
||||
welcomeBlockedReason(user) ??
|
||||
(user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button'))
|
||||
}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ActionIcon kind="mail" />
|
||||
<span className="sr-only">
|
||||
{user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsUser(user)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||
title={t('grants.detailsButton')}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('grants.detailsButton')}
|
||||
<ActionIcon kind="details" />
|
||||
<span className="sr-only">{t('grants.detailsButton')}</span>
|
||||
</button>
|
||||
{canManageRow(user) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(user)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||
title={tCommon('edit')}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{tCommon('edit')}
|
||||
<ActionIcon kind="edit" />
|
||||
<span className="sr-only">{tCommon('edit')}</span>
|
||||
</button>
|
||||
)}
|
||||
{canManageRow(user) && (
|
||||
@@ -332,9 +451,11 @@ export default function AdminUsersPage() {
|
||||
setDeleteError(null);
|
||||
}}
|
||||
disabled={user.id === currentUser?.id}
|
||||
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
|
||||
title={tCommon('delete')}
|
||||
className="rounded p-1.5 text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
|
||||
>
|
||||
{tCommon('delete')}
|
||||
<ActionIcon kind="delete" />
|
||||
<span className="sr-only">{tCommon('delete')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -363,9 +484,7 @@ export default function AdminUsersPage() {
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -378,9 +497,7 @@ export default function AdminUsersPage() {
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -388,9 +505,7 @@ export default function AdminUsersPage() {
|
||||
<label htmlFor="user-password" className="text-sm font-medium text-foreground">
|
||||
{t('password')}
|
||||
{editingUser && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
(optional)
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">(optional)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
@@ -399,9 +514,7 @@ export default function AdminUsersPage() {
|
||||
required={!editingUser}
|
||||
minLength={8}
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -413,9 +526,7 @@ export default function AdminUsersPage() {
|
||||
id="user-display-name"
|
||||
type="text"
|
||||
value={formData.displayName}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, displayName: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -437,9 +548,7 @@ export default function AdminUsersPage() {
|
||||
<option value="USER">{tHeader('role.USER')}</option>
|
||||
<option value="ADMIN">{tHeader('role.ADMIN')}</option>
|
||||
{currentUser?.role === 'SUPER_ADMIN' && (
|
||||
<option value="SUPER_ADMIN">
|
||||
{tHeader('role.SUPER_ADMIN')}
|
||||
</option>
|
||||
<option value="SUPER_ADMIN">{tHeader('role.SUPER_ADMIN')}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -459,10 +568,7 @@ export default function AdminUsersPage() {
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{tCommon('save')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -475,9 +581,7 @@ export default function AdminUsersPage() {
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<p className="text-sm text-foreground mb-4">
|
||||
{t('deleteConfirm')}
|
||||
</p>
|
||||
<p className="text-sm text-foreground mb-4">{t('deleteConfirm')}</p>
|
||||
{deleteError && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -506,6 +610,57 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Willkommensmail: Bestaetigung mit Empfaengeradresse */}
|
||||
{welcomeUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="welcome-mail-title"
|
||||
className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg"
|
||||
>
|
||||
<h2 id="welcome-mail-title" className="text-lg font-bold text-foreground mb-3">
|
||||
{tWelcome('dialogTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-foreground">{tWelcome('dialogText')}</p>
|
||||
<p className="mt-2 mb-4 rounded-md bg-muted px-3 py-2 text-sm font-medium text-foreground break-all">
|
||||
{welcomeUser.displayName ?? welcomeUser.username} <{welcomeUser.email}>
|
||||
</p>
|
||||
{welcomeUser.welcomeMailSentAt && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{tWelcome('dialogResend', { date: formatDateTime(welcomeUser.welcomeMailSentAt) })}
|
||||
</p>
|
||||
)}
|
||||
{welcomeError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive mb-4"
|
||||
>
|
||||
{welcomeError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWelcomeUser(null)}
|
||||
disabled={welcomeSending}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendWelcome}
|
||||
disabled={welcomeSending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{welcomeSending ? tWelcome('sending') : tWelcome('send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User-Detail-Zugriff (D-16): geerbte + direkte Modulfreigaben */}
|
||||
{detailsUser && (
|
||||
<UserAccessModal
|
||||
@@ -517,3 +672,52 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbole der Zeilenaktionen (quick-260930): die Tabelle wurde mit der
|
||||
* Spalte "Letzte Anmeldung" und der Willkommensmail zu breit fuer Text-
|
||||
* Knoepfe. Beschriftung steht an jedem Knopf als unsichtbarer Text (sr-only)
|
||||
* und als title (Tooltip).
|
||||
*/
|
||||
function ActionIcon({ kind }: { kind: 'mail' | 'details' | 'edit' | 'delete' }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{kind === 'mail' && (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</>
|
||||
)}
|
||||
{kind === 'details' && (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5" />
|
||||
<path d="M12 8h.01" />
|
||||
</>
|
||||
)}
|
||||
{kind === 'edit' && (
|
||||
<>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</>
|
||||
)}
|
||||
{kind === 'delete' && (
|
||||
<>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ const messages: Record<string, Record<string, unknown>> = {
|
||||
displayName: 'Anzeigename',
|
||||
role: 'Rolle',
|
||||
status: 'Status',
|
||||
lastLogin: 'Letzte Anmeldung',
|
||||
neverLoggedIn: 'Noch nie',
|
||||
actions: 'Aktionen',
|
||||
password: 'Passwort',
|
||||
noUsers: 'Keine Benutzer gefunden',
|
||||
@@ -31,6 +33,20 @@ const messages: Record<string, Record<string, unknown>> = {
|
||||
loadFailed: 'Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
|
||||
},
|
||||
},
|
||||
'admin.users.welcomeMail': {
|
||||
button: 'Willkommensmail senden',
|
||||
resend: 'Erneut senden',
|
||||
sentAt: 'Willkommensmail gesendet am {date}',
|
||||
noEmail: 'Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.',
|
||||
noSmtp: 'Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).',
|
||||
inactive: 'Das Konto ist deaktiviert.',
|
||||
dialogTitle: 'Willkommensmail senden',
|
||||
dialogText: 'Tessera schickt eine Willkommensmail an:',
|
||||
dialogResend: 'Eine Willkommensmail wurde bereits am {date} gesendet. Sie wird erneut verschickt.',
|
||||
send: 'Senden',
|
||||
sending: 'Wird gesendet …',
|
||||
success: 'Die Willkommensmail an {email} wurde gesendet.',
|
||||
},
|
||||
header: {
|
||||
role: {
|
||||
SUPER_ADMIN: 'Super-Admin',
|
||||
@@ -47,6 +63,7 @@ const messages: Record<string, Record<string, unknown>> = {
|
||||
accessDenied: 'Zugriff verweigert',
|
||||
active: 'Aktiv',
|
||||
inactive: 'Inaktiv',
|
||||
close: 'Schließen',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,6 +86,7 @@ function resolve(ns: string, key: string, params?: Record<string, unknown>): str
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) =>
|
||||
resolve(ns, key, params),
|
||||
useLocale: () => 'de',
|
||||
}));
|
||||
|
||||
const mockAuthStore = vi.fn();
|
||||
@@ -93,6 +111,8 @@ interface User {
|
||||
isActive: boolean;
|
||||
tenantId: string;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string | null;
|
||||
welcomeMailSentAt?: string | null;
|
||||
}
|
||||
|
||||
const mockUsers: User[] = [
|
||||
@@ -524,13 +544,13 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
|
||||
});
|
||||
|
||||
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
|
||||
expect(within(superAdminRow).getByText('Details')).toBeInTheDocument();
|
||||
expect(within(superAdminRow).getByRole('button', { name: 'Details' })).toBeInTheDocument();
|
||||
expect(within(superAdminRow).queryByText('Bearbeiten')).not.toBeInTheDocument();
|
||||
expect(within(superAdminRow).queryByText('Löschen')).not.toBeInTheDocument();
|
||||
|
||||
const normalRow = screen.getByText('user.zwei').closest('tr') as HTMLElement;
|
||||
expect(within(normalRow).getByText('Bearbeiten')).toBeInTheDocument();
|
||||
expect(within(normalRow).getByText('Löschen')).toBeInTheDocument();
|
||||
expect(within(normalRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
|
||||
expect(within(normalRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('bietet einem SUPER_ADMIN in der SUPER_ADMIN-Zeile beide Aktionsknoepfe an', async () => {
|
||||
@@ -544,8 +564,8 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
|
||||
});
|
||||
|
||||
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
|
||||
expect(within(superAdminRow).getByText('Bearbeiten')).toBeInTheDocument();
|
||||
expect(within(superAdminRow).getByText('Löschen')).toBeInTheDocument();
|
||||
expect(within(superAdminRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
|
||||
expect(within(superAdminRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('haelt die bestehende Sperre gegen Selbstloeschung unveraendert (ADMIN u1)', async () => {
|
||||
@@ -559,8 +579,165 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
|
||||
});
|
||||
|
||||
const ownRow = screen.getByText('admin.eins').closest('tr') as HTMLElement;
|
||||
const ownDeleteButton = within(ownRow).getByText('Löschen');
|
||||
const ownDeleteButton = within(ownRow).getByRole('button', { name: 'Löschen' });
|
||||
expect(ownDeleteButton).toBeInTheDocument();
|
||||
expect(ownDeleteButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminUsersPage — Willkommensmail', () => {
|
||||
const base = {
|
||||
role: 'USER' as const,
|
||||
isActive: true,
|
||||
tenantId: 't1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
welcomeMailSentAt: null,
|
||||
};
|
||||
const welcomeUsers: User[] = [
|
||||
{ ...base, id: 'u1', username: 'admin.eins', email: 'admin.eins@ctl.de', displayName: 'Admin Eins', role: 'ADMIN', lastLoginAt: '2026-09-01T08:00:00.000Z' },
|
||||
{ ...base, id: 'u2', username: 'neu.zwei', email: 'neu.zwei@ctl.de', displayName: 'Neu Zwei', lastLoginAt: null },
|
||||
{ ...base, id: 'u3', username: 'ohne.adresse', email: null, displayName: null, lastLoginAt: null },
|
||||
{ ...base, id: 'u4', username: 'schon.da', email: 'schon.da@ctl.de', displayName: null, lastLoginAt: '2026-09-02T08:00:00.000Z' },
|
||||
{ ...base, id: 'u5', username: 'super.fuenf', email: 'super@ctl.de', displayName: null, role: 'SUPER_ADMIN', lastLoginAt: '2026-09-02T08:00:00.000Z' },
|
||||
];
|
||||
|
||||
function stubFetch(opts: { available?: boolean; post?: () => Promise<unknown> } = {}) {
|
||||
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||
if (url.endsWith('/users/welcome-mail/status')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ available: opts.available ?? true }) });
|
||||
}
|
||||
if (url.endsWith('/users') && (!init || init.method === undefined)) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(welcomeUsers) });
|
||||
}
|
||||
if (url.endsWith('/welcome-mail') && init?.method === 'POST') {
|
||||
return (opts.post ?? (() => Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })))();
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
it('Knopf bei jedem verwaltbaren Benutzer, auch nach einer Anmeldung; SUPER_ADMIN-Konto fuer einen ADMIN nie; ohne Adresse deaktiviert', async () => {
|
||||
stubAdmin();
|
||||
stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
|
||||
const rowAdmin = screen.getByText('admin.eins').closest('tr') as HTMLElement;
|
||||
expect(within(rowAdmin).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
|
||||
|
||||
const rowDa = screen.getByText('schon.da').closest('tr') as HTMLElement;
|
||||
expect(within(rowDa).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
|
||||
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
|
||||
|
||||
// SUPER_ADMIN-Konto: fuer einen ADMIN gesperrt wie beim Bearbeiten
|
||||
const rowSuper = screen.getByText('super.fuenf').closest('tr') as HTMLElement;
|
||||
expect(within(rowSuper).queryByText('Willkommensmail senden')).not.toBeInTheDocument();
|
||||
|
||||
const rowOhne = screen.getByText('ohne.adresse').closest('tr') as HTMLElement;
|
||||
const btn = within(rowOhne).getByRole('button', { name: 'Willkommensmail senden' });
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveAttribute('title', 'Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.');
|
||||
});
|
||||
|
||||
it('Spalte "Letzte Anmeldung": Datum/Uhrzeit lokal formatiert, sonst "Noch nie"', async () => {
|
||||
stubAdmin();
|
||||
stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
expect(screen.getByText('Letzte Anmeldung')).toBeInTheDocument();
|
||||
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
expect(within(rowNeu).getByText('Noch nie')).toBeInTheDocument();
|
||||
|
||||
const expected = new Intl.DateTimeFormat('de', { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||||
new Date('2026-09-01T08:00:00.000Z'),
|
||||
);
|
||||
const rowAdmin = screen.getByText('admin.eins').closest('tr') as HTMLElement;
|
||||
expect(within(rowAdmin).getByText(expected)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ohne eingerichtetes SMTP ist der Knopf deaktiviert und nennt den Grund', async () => {
|
||||
stubAdmin();
|
||||
stubFetch({ available: false });
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
await waitFor(() => expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toBeDisabled());
|
||||
expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toHaveAttribute(
|
||||
'title',
|
||||
'Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).',
|
||||
);
|
||||
});
|
||||
|
||||
it('Dialog nennt den Empfaenger; Senden ruft POST /users/:id/welcome-mail und zeigt die Erfolgsmeldung', async () => {
|
||||
stubAdmin();
|
||||
const fetchMock = stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
await userEvent.click(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText(/neu\.zwei@ctl\.de/)).toBeInTheDocument();
|
||||
await userEvent.click(within(dialog).getByText('Senden'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Die Willkommensmail an neu.zwei@ctl.de wurde gesendet.')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'POST');
|
||||
expect(post?.[0]).toMatch(/\/users\/u2\/welcome-mail$/);
|
||||
});
|
||||
|
||||
it('bei einer Ablehnung (409) bleibt der Dialog offen und zeigt den Servertext', async () => {
|
||||
stubAdmin();
|
||||
stubFetch({
|
||||
post: () =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: () => Promise.resolve({ message: 'Dieser Benutzer hat sich bereits angemeldet.' }),
|
||||
}),
|
||||
});
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
await userEvent.click(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
await userEvent.click(within(dialog).getByText('Senden'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Der Server hat die Aktion abgelehnt: Dieser Benutzer hat sich bereits angemeldet.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('nach einem Versand heisst der Knopf "Erneut senden" und die Zeile nennt das Datum', async () => {
|
||||
stubAdmin();
|
||||
welcomeUsers[1] = { ...welcomeUsers[1], welcomeMailSentAt: '2026-09-29T10:15:00.000Z' };
|
||||
stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
expect(within(rowNeu).getByRole('button', { name: 'Erneut senden' })).toBeInTheDocument();
|
||||
expect(within(rowNeu).getByText(/Willkommensmail gesendet am/)).toBeInTheDocument();
|
||||
welcomeUsers[1] = { ...welcomeUsers[1], welcomeMailSentAt: null };
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN sieht den Knopf auch bei einem angemeldeten SUPER_ADMIN-Konto', async () => {
|
||||
stubAdmin('u9', 'SUPER_ADMIN');
|
||||
stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('super.fuenf')).toBeInTheDocument());
|
||||
const rowSuper = screen.getByText('super.fuenf').closest('tr') as HTMLElement;
|
||||
expect(within(rowSuper).getByRole('button', { name: 'Willkommensmail senden' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WelcomeMailTemplateEditor } from '@/components/admin/welcome-mail-template-editor';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
/**
|
||||
* Administrator → Willkommensmail: eigene Vorlage je Mandant (Betreff,
|
||||
* Ueberschrift, Einleitung, Abschluss mit Platzhaltern). ADMIN und
|
||||
* SUPER_ADMIN; die Rollenpruefung hier ist nur Anzeige — durchgesetzt wird
|
||||
* sie serverseitig (`@Roles` an `/welcome-mail-template`).
|
||||
*/
|
||||
export default function AdminWelcomeMailPage() {
|
||||
const tCommon = useTranslations('common');
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <WelcomeMailTemplateEditor />;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages } from 'next-intl/server';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
|
||||
import { DesktopExternalLinks } from '@/components/desktop/desktop-external-links';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -10,11 +11,7 @@ export const metadata: Metadata = {
|
||||
description: 'Modulare Workflow-Plattform',
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
|
||||
@@ -29,6 +26,7 @@ export default async function RootLayout({
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<DesktopContextMenuGuard />
|
||||
<DesktopExternalLinks />
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -83,6 +83,18 @@ export function AdminSidebar() {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('admin.welcomeMail'),
|
||||
href: '/admin/welcome-mail',
|
||||
show: true,
|
||||
icon: (
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
<path d="m16 19 2 2 4-4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('admin.groups'),
|
||||
href: '/admin/groups',
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
|
||||
// Namensraumfaehiger next-intl-Mock gegen die echten deutschen Texte:
|
||||
// Punktschluessel werden aufgeloest, {param} wird eingesetzt.
|
||||
vi.mock('next-intl', () => ({
|
||||
useLocale: () => 'de',
|
||||
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
|
||||
let val: unknown = de;
|
||||
for (const part of `${ns}.${key}`.split('.')) {
|
||||
val = (val as Record<string, unknown> | undefined)?.[part];
|
||||
}
|
||||
if (typeof val !== 'string') return `${ns}.${key}`;
|
||||
return params
|
||||
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
|
||||
: val;
|
||||
},
|
||||
}));
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
fetchWelcomeMailTemplate: vi.fn(),
|
||||
saveWelcomeMailTemplate: vi.fn(),
|
||||
resetWelcomeMailTemplate: vi.fn(),
|
||||
previewWelcomeMail: vi.fn(),
|
||||
sendWelcomeMailTest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/welcome-mail-template-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/welcome-mail-template-api')>();
|
||||
return { ...actual, ...api };
|
||||
});
|
||||
|
||||
import { WelcomeMailTemplateRequestError } from '@/lib/welcome-mail-template-api';
|
||||
import { WelcomeMailTemplateEditor } from './welcome-mail-template-editor';
|
||||
|
||||
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
|
||||
const custom = {
|
||||
subject: 'Hallo {{vorname}}',
|
||||
heading: 'Willkommen, {{name}}!',
|
||||
intro: 'Eigene Einleitung',
|
||||
loginHintDirectory: 'Eigener Verzeichnis-Hinweis',
|
||||
loginHintLocal: 'Eigener Lokal-Hinweis',
|
||||
closing: 'Ihr IT-Team',
|
||||
};
|
||||
|
||||
function state(isCustom: boolean) {
|
||||
return {
|
||||
custom: isCustom,
|
||||
texts: isCustom ? { ...custom } : { ...defaults },
|
||||
defaults: { ...defaults },
|
||||
updatedAt: isCustom ? '2026-09-30T10:00:00.000Z' : null,
|
||||
updatedBy: isCustom ? 'ada' : null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
api.fetchWelcomeMailTemplate.mockResolvedValue(state(false));
|
||||
api.previewWelcomeMail.mockImplementation(async (texts: typeof defaults, account: string) => ({
|
||||
subject: texts.subject,
|
||||
html: `<html><body>Vorschau ${account}</body></html>`,
|
||||
text: '',
|
||||
}));
|
||||
api.saveWelcomeMailTemplate.mockImplementation(async (texts: typeof defaults) => ({
|
||||
...state(true),
|
||||
texts: { ...texts },
|
||||
}));
|
||||
api.resetWelcomeMailTemplate.mockResolvedValue(state(false));
|
||||
api.sendWelcomeMailTest.mockResolvedValue({ success: true, to: 'ada@example.invalid' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderLoaded() {
|
||||
render(<WelcomeMailTemplateEditor />);
|
||||
return (await screen.findByLabelText('Betreff')) as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe('WelcomeMailTemplateEditor', () => {
|
||||
it('laedt die Standardtexte ins Formular und zeigt Hinweis auf die festen Bausteine', async () => {
|
||||
const subject = await renderLoaded();
|
||||
expect(subject.value).toBe(defaults.subject);
|
||||
expect((screen.getByLabelText('Überschrift') as HTMLInputElement).value).toBe(defaults.heading);
|
||||
expect((screen.getByLabelText('Einleitungstext') as HTMLTextAreaElement).value).toBe(
|
||||
defaults.intro,
|
||||
);
|
||||
expect((screen.getByLabelText('Abschlusstext') as HTMLTextAreaElement).value).toBe(
|
||||
defaults.closing,
|
||||
);
|
||||
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
|
||||
'Zurzeit gelten die Standardtexte.',
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/So kann eine eigene Vorlage die Anmeldung nicht kaputt machen/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Hinweisbox Platzhalter: Tabelle mit allen sechs Platzhaltern, Bedeutung und Beispiel', async () => {
|
||||
await renderLoaded();
|
||||
const box = screen.getByRole('region', { name: 'Platzhalter' });
|
||||
const rows = within(box).getAllByRole('row');
|
||||
expect(rows).toHaveLength(7); // Kopf + 6
|
||||
for (const token of [
|
||||
'{{name}}',
|
||||
'{{vorname}}',
|
||||
'{{benutzername}}',
|
||||
'{{email}}',
|
||||
'{{adresse}}',
|
||||
'{{firma}}',
|
||||
]) {
|
||||
expect(within(box).getByRole('button', { name: `${token} einfügen` })).toBeTruthy();
|
||||
}
|
||||
expect(within(box).getByText('Name Ihres Mandanten')).toBeTruthy();
|
||||
expect(within(box).getByText('max.mustermann@example.com')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Klick auf einen Platzhalter fuegt ihn an der Cursorposition des zuletzt fokussierten Feldes ein', async () => {
|
||||
const subject = await renderLoaded();
|
||||
fireEvent.change(subject, { target: { value: 'Hallo bei uns' } });
|
||||
subject.focus();
|
||||
subject.setSelectionRange(6, 6);
|
||||
fireEvent.click(screen.getByRole('button', { name: '{{vorname}} einfügen' }));
|
||||
expect(subject.value).toBe('Hallo {{vorname}} bei uns');
|
||||
});
|
||||
|
||||
it('unbekannter Platzhalter wird benannt, Speichern und Testmail sind gesperrt', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderLoaded();
|
||||
const heading = screen.getByLabelText('Überschrift');
|
||||
await user.clear(heading);
|
||||
fireEvent.change(heading, { target: { value: 'Hallo {{xyz}}' } });
|
||||
expect(await screen.findByText('Unbekannter Platzhalter {{xyz}}')).toBeTruthy();
|
||||
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
(screen.getByRole('button', { name: 'Testmail an mich senden' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('Anmeldehinweise: beide Felder nach dem Einleitungstext, mit Standardtext bzw. eigener Vorlage vorbelegt', async () => {
|
||||
await renderLoaded();
|
||||
const dir = screen.getByLabelText(
|
||||
'Anmeldehinweis für Verzeichniskonten',
|
||||
) as HTMLTextAreaElement;
|
||||
const local = screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement;
|
||||
expect(dir.value).toBe(
|
||||
'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.',
|
||||
);
|
||||
expect(local.value).toBe(
|
||||
'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.',
|
||||
);
|
||||
// Reihenfolge: Einleitung → Verzeichnis → lokal → Abschluss
|
||||
const intro = screen.getByLabelText('Einleitungstext');
|
||||
const closing = screen.getByLabelText('Abschlusstext');
|
||||
const follows = (a: Element, b: Element) =>
|
||||
Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(follows(intro, dir)).toBe(true);
|
||||
expect(follows(dir, local)).toBe(true);
|
||||
expect(follows(local, closing)).toBe(true);
|
||||
expect(screen.getByText(/direkt vor dem Knopf „Passwort festlegen“/)).toBeTruthy();
|
||||
|
||||
cleanup();
|
||||
api.fetchWelcomeMailTemplate.mockResolvedValue(state(true));
|
||||
await renderLoaded();
|
||||
expect(
|
||||
(screen.getByLabelText('Anmeldehinweis für Verzeichniskonten') as HTMLTextAreaElement).value,
|
||||
).toBe(custom.loginHintDirectory);
|
||||
expect(
|
||||
(screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement).value,
|
||||
).toBe(custom.loginHintLocal);
|
||||
});
|
||||
|
||||
it('Anmeldehinweis: Pflichtfeld, Platzhalter-Klick fuegt ein, Fokus schaltet die Vorschau auf die Kontoart', async () => {
|
||||
await renderLoaded();
|
||||
const local = screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement;
|
||||
fireEvent.change(local, { target: { value: 'Hallo ' } });
|
||||
local.focus();
|
||||
local.setSelectionRange(6, 6);
|
||||
fireEvent.click(screen.getByRole('button', { name: '{{vorname}} einfügen' }));
|
||||
expect(local.value).toBe('Hallo {{vorname}}');
|
||||
expect(screen.getByRole('button', { name: 'Lokales Konto' }).getAttribute('aria-pressed')).toBe(
|
||||
'true',
|
||||
);
|
||||
|
||||
fireEvent.change(local, { target: { value: ' ' } });
|
||||
expect(await screen.findByText('Bitte füllen Sie dieses Feld aus.')).toBeTruthy();
|
||||
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('Speichern schickt alle sechs Texte inkl. Anmeldehinweise', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderLoaded();
|
||||
fireEvent.change(screen.getByLabelText('Anmeldehinweis für Verzeichniskonten'), {
|
||||
target: { value: 'Mit dem Firmenkennwort anmelden, {{vorname}}.' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Anmeldehinweis für lokale Konten'), {
|
||||
target: { value: 'Erst Passwort setzen.' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(api.saveWelcomeMailTemplate).toHaveBeenCalledWith({
|
||||
...defaults,
|
||||
loginHintDirectory: 'Mit dem Firmenkennwort anmelden, {{vorname}}.',
|
||||
loginHintLocal: 'Erst Passwort setzen.',
|
||||
});
|
||||
});
|
||||
|
||||
it('Speichern schickt die Texte und meldet Erfolg; Status zeigt die eigene Vorlage', async () => {
|
||||
const user = userEvent.setup();
|
||||
const subject = await renderLoaded();
|
||||
fireEvent.change(subject, { target: { value: 'Neu für {{firma}}' } });
|
||||
await user.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(api.saveWelcomeMailTemplate).toHaveBeenCalledWith({
|
||||
...defaults,
|
||||
subject: 'Neu für {{firma}}',
|
||||
});
|
||||
expect((await screen.findByRole('status')).textContent).toContain(
|
||||
'Die Vorlage wurde gespeichert.',
|
||||
);
|
||||
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
|
||||
'Eigene Vorlage aktiv',
|
||||
);
|
||||
expect(screen.getByTestId('welcome-template-status').textContent).toContain('von ada');
|
||||
});
|
||||
|
||||
it('Fehler der API beim Speichern wird angezeigt (400 mit Meldung)', async () => {
|
||||
const user = userEvent.setup();
|
||||
api.saveWelcomeMailTemplate.mockRejectedValueOnce(
|
||||
new WelcomeMailTemplateRequestError(400, ['Betreff: Unbekannter Platzhalter {{abc}}']),
|
||||
);
|
||||
await renderLoaded();
|
||||
await user.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
'Betreff: Unbekannter Platzhalter {{abc}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('Auf Standard zuruecksetzen: Rueckfrage, DELETE, danach Standardtexte im Formular', async () => {
|
||||
const user = userEvent.setup();
|
||||
api.fetchWelcomeMailTemplate.mockResolvedValue(state(true));
|
||||
const subject = await renderLoaded();
|
||||
expect(subject.value).toBe(custom.subject);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Auf Standard zurücksetzen' }));
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
expect(api.resetWelcomeMailTemplate).not.toHaveBeenCalled();
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Zurücksetzen' }));
|
||||
|
||||
expect(api.resetWelcomeMailTemplate).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(subject.value).toBe(defaults.subject));
|
||||
expect(screen.getByTestId('welcome-template-status').textContent).toContain('Standardtexte');
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('Vorschau: iframe mit dem von der API gerenderten HTML, Umschalter lokales Konto', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderLoaded();
|
||||
const frame = screen.getByTitle('Vorschau der Willkommensmail') as HTMLIFrameElement;
|
||||
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau directory'));
|
||||
expect(frame.getAttribute('sandbox')).toBe('');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Lokales Konto' }));
|
||||
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau local'));
|
||||
expect(api.previewWelcomeMail).toHaveBeenLastCalledWith(defaults, 'local', expect.anything());
|
||||
expect(screen.getByTestId('welcome-preview-subject').textContent).toBe(defaults.subject);
|
||||
});
|
||||
|
||||
it('Testmail an mich: sendet den aktuellen (ungespeicherten) Inhalt und nennt die Adresse', async () => {
|
||||
const user = userEvent.setup();
|
||||
const subject = await renderLoaded();
|
||||
fireEvent.change(subject, { target: { value: 'Ungespeichert' } });
|
||||
await user.click(screen.getByRole('button', { name: 'Testmail an mich senden' }));
|
||||
expect(api.sendWelcomeMailTest).toHaveBeenCalledWith({ ...defaults, subject: 'Ungespeichert' });
|
||||
expect(api.saveWelcomeMailTemplate).not.toHaveBeenCalled();
|
||||
expect((await screen.findByRole('status')).textContent).toBe(
|
||||
'Die Testmail wurde an ada@example.invalid gesendet.',
|
||||
);
|
||||
});
|
||||
|
||||
it('Ladefehler wird angezeigt', async () => {
|
||||
api.fetchWelcomeMailTemplate.mockRejectedValueOnce(new Error('down'));
|
||||
render(<WelcomeMailTemplateEditor />);
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('konnte nicht geladen werden');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
findUnknownWelcomeMailPlaceholders,
|
||||
WELCOME_MAIL_LIMITS,
|
||||
WELCOME_MAIL_PLACEHOLDERS,
|
||||
type WelcomeMailPlaceholder,
|
||||
type WelcomeMailTexts,
|
||||
} from '@tessera/shared';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
fetchWelcomeMailTemplate,
|
||||
previewWelcomeMail,
|
||||
resetWelcomeMailTemplate,
|
||||
saveWelcomeMailTemplate,
|
||||
sendWelcomeMailTest,
|
||||
type WelcomeMailPreview,
|
||||
WelcomeMailTemplateRequestError,
|
||||
type WelcomeMailTemplateState,
|
||||
} from '@/lib/welcome-mail-template-api';
|
||||
|
||||
type Field = keyof WelcomeMailTexts;
|
||||
type PreviewAccount = 'directory' | 'local';
|
||||
type Feedback = { type: 'success' | 'error'; message: string } | null;
|
||||
|
||||
const FIELDS: Field[] = [
|
||||
'subject',
|
||||
'heading',
|
||||
'intro',
|
||||
'loginHintDirectory',
|
||||
'loginHintLocal',
|
||||
'closing',
|
||||
];
|
||||
const MULTILINE: Record<Field, boolean> = {
|
||||
subject: false,
|
||||
heading: false,
|
||||
intro: true,
|
||||
loginHintDirectory: true,
|
||||
loginHintLocal: true,
|
||||
closing: true,
|
||||
};
|
||||
/** Pflichtfelder (dieselben wie im DTO der API). */
|
||||
const REQUIRED: ReadonlySet<Field> = new Set([
|
||||
'subject',
|
||||
'heading',
|
||||
'loginHintDirectory',
|
||||
'loginHintLocal',
|
||||
]);
|
||||
/** Zeilen der mehrzeiligen Felder. */
|
||||
const ROWS: Partial<Record<Field, number>> = {
|
||||
intro: 6,
|
||||
loginHintDirectory: 3,
|
||||
loginHintLocal: 3,
|
||||
closing: 5,
|
||||
};
|
||||
/** Welche Kontoart die Vorschau zeigt, wenn ein Anmeldehinweis bearbeitet wird. */
|
||||
const HINT_ACCOUNT: Partial<Record<Field, PreviewAccount>> = {
|
||||
loginHintDirectory: 'directory',
|
||||
loginHintLocal: 'local',
|
||||
};
|
||||
|
||||
/** Verzoegerung der Live-Vorschau nach der letzten Eingabe. */
|
||||
const PREVIEW_DELAY_MS = 400;
|
||||
|
||||
/**
|
||||
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
|
||||
*
|
||||
* Sechs Textfelder (Betreff, Ueberschrift, Einleitung, Anmeldehinweis fuer
|
||||
* Verzeichniskonten, Anmeldehinweis fuer lokale Konten, Abschluss), eine
|
||||
* Hinweisbox mit den Platzhaltern (Klick fuegt an der Cursorposition des
|
||||
* zuletzt benutzten Feldes ein), eine Live-Vorschau (von der API gerendert,
|
||||
* also dieselbe Mail wie im Versand) mit Umschalter Verzeichniskonto/lokales
|
||||
* Konto (springt beim Bearbeiten eines Anmeldehinweises auf die passende
|
||||
* Kontoart), "Testmail an mich senden" und "Auf Standard zuruecksetzen".
|
||||
*
|
||||
* Unbekannte Platzhalter benennt das Formular schon beim Tippen; die API
|
||||
* lehnt sie beim Speichern ohnehin mit 400 ab (Meldung wird angezeigt).
|
||||
*/
|
||||
export function WelcomeMailTemplateEditor() {
|
||||
const t = useTranslations('admin.welcomeMail');
|
||||
const locale = useLocale();
|
||||
|
||||
const [state, setState] = useState<WelcomeMailTemplateState | null>(null);
|
||||
const [form, setForm] = useState<WelcomeMailTexts | null>(null);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [feedback, setFeedback] = useState<Feedback>(null);
|
||||
|
||||
const [account, setAccount] = useState<PreviewAccount>('directory');
|
||||
const [preview, setPreview] = useState<WelcomeMailPreview | null>(null);
|
||||
const [previewFailed, setPreviewFailed] = useState(false);
|
||||
|
||||
const fieldRefs = useRef<Partial<Record<Field, HTMLInputElement | HTMLTextAreaElement | null>>>(
|
||||
{},
|
||||
);
|
||||
const lastFocused = useRef<Field>('intro');
|
||||
|
||||
useEffect(() => {
|
||||
fetchWelcomeMailTemplate()
|
||||
.then((loaded) => {
|
||||
setState(loaded);
|
||||
setForm({ ...loaded.texts });
|
||||
})
|
||||
.catch(() => setLoadFailed(true));
|
||||
}, []);
|
||||
|
||||
// ── Pruefung im Formular (dieselben Regeln wie die API) ───────────────
|
||||
const fieldErrors = (field: Field, value: string): string[] => {
|
||||
const errors: string[] = [];
|
||||
if (REQUIRED.has(field) && !value.trim()) {
|
||||
errors.push(t('errors.required'));
|
||||
}
|
||||
if (value.length > WELCOME_MAIL_LIMITS[field]) {
|
||||
errors.push(t('errors.tooLong', { max: WELCOME_MAIL_LIMITS[field] }));
|
||||
}
|
||||
const unknown = findUnknownWelcomeMailPlaceholders(value);
|
||||
if (unknown.length === 1) errors.push(t('errors.unknownPlaceholder', { token: unknown[0] }));
|
||||
if (unknown.length > 1) {
|
||||
errors.push(t('errors.unknownPlaceholders', { tokens: unknown.join(', ') }));
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
const errorsByField = form
|
||||
? (Object.fromEntries(FIELDS.map((f) => [f, fieldErrors(f, form[f])])) as Record<
|
||||
Field,
|
||||
string[]
|
||||
>)
|
||||
: null;
|
||||
const hasErrors = errorsByField ? FIELDS.some((f) => errorsByField[f].length > 0) : false;
|
||||
const dirty = !!form && !!state && FIELDS.some((f) => form[f] !== state.texts[f]);
|
||||
|
||||
// ── Live-Vorschau ─────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!form || hasErrors) return;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
previewWelcomeMail(form, account, controller.signal)
|
||||
.then((result) => {
|
||||
setPreview(result);
|
||||
setPreviewFailed(false);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if ((error as { name?: string })?.name === 'AbortError') return;
|
||||
setPreviewFailed(true);
|
||||
});
|
||||
}, PREVIEW_DELAY_MS);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [form, account, hasErrors]);
|
||||
|
||||
// ── Aktionen ──────────────────────────────────────────────────────────
|
||||
const serverMessage = (error: unknown, fallback: string): string => {
|
||||
if (error instanceof WelcomeMailTemplateRequestError && error.messages.length > 0) {
|
||||
return error.messages.join(' ');
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form || hasErrors) return;
|
||||
setSaving(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const saved = await saveWelcomeMailTemplate(form);
|
||||
setState(saved);
|
||||
setForm({ ...saved.texts });
|
||||
setFeedback({ type: 'success', message: t('saved') });
|
||||
} catch (error) {
|
||||
setFeedback({ type: 'error', message: serverMessage(error, t('errors.save')) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!state) return;
|
||||
setResetting(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const next = state.custom ? await resetWelcomeMailTemplate() : state;
|
||||
setState(next);
|
||||
setForm({ ...next.defaults });
|
||||
setConfirmReset(false);
|
||||
setFeedback({ type: 'success', message: t('resetDone') });
|
||||
} catch (error) {
|
||||
setFeedback({ type: 'error', message: serverMessage(error, t('errors.reset')) });
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!form || hasErrors) return;
|
||||
setTesting(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const result = await sendWelcomeMailTest(form);
|
||||
setFeedback({ type: 'success', message: t('testSent', { email: result.to }) });
|
||||
} catch (error) {
|
||||
setFeedback({ type: 'error', message: serverMessage(error, t('errors.test')) });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Fuegt `{{name}}` an der Cursorposition des zuletzt benutzten Feldes ein. */
|
||||
const insertPlaceholder = (placeholder: WelcomeMailPlaceholder) => {
|
||||
if (!form) return;
|
||||
const field = lastFocused.current;
|
||||
const token = `{{${placeholder}}}`;
|
||||
const el = fieldRefs.current[field];
|
||||
const value = form[field];
|
||||
const start = el?.selectionStart ?? value.length;
|
||||
const end = el?.selectionEnd ?? value.length;
|
||||
setForm({ ...form, [field]: value.slice(0, start) + token + value.slice(end) });
|
||||
const caret = start + token.length;
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(caret, caret);
|
||||
});
|
||||
};
|
||||
|
||||
// ── Darstellung ───────────────────────────────────────────────────────
|
||||
if (loadFailed) {
|
||||
return (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t('errors.load')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!form || !state || !errorsByField) {
|
||||
return <p className="text-sm text-muted-foreground">{t('loading')}</p>;
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded border border-border bg-background px-3 text-sm text-foreground';
|
||||
const statusText = state.custom
|
||||
? state.updatedAt
|
||||
? t(state.updatedBy ? 'statusCustomBy' : 'statusCustom', {
|
||||
date: new Date(state.updatedAt).toLocaleString(locale === 'en' ? 'en-GB' : 'de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}),
|
||||
user: state.updatedBy ?? '',
|
||||
})
|
||||
: t('statusCustom', { date: '' })
|
||||
: t('statusDefault');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="mb-2 text-lg font-semibold text-foreground">{t('title')}</h1>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
|
||||
{/* Feste Bausteine */}
|
||||
<section
|
||||
aria-labelledby="welcome-fixed-title"
|
||||
className="max-w-3xl rounded-lg border border-border bg-muted/40 p-4"
|
||||
>
|
||||
<h2 id="welcome-fixed-title" className="mb-1 text-sm font-semibold text-foreground">
|
||||
{t('fixed.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('fixed.body')}</p>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
{/* Formular */}
|
||||
<form onSubmit={handleSave} className="min-w-0 space-y-4" noValidate>
|
||||
<p
|
||||
className={`text-sm ${state.custom ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
data-testid="welcome-template-status"
|
||||
>
|
||||
{statusText}
|
||||
{dirty && <span className="ml-2 font-medium">· {t('unsaved')}</span>}
|
||||
</p>
|
||||
|
||||
{FIELDS.map((field) => {
|
||||
const id = `welcome-${field}`;
|
||||
const errors = errorsByField[field];
|
||||
const describedBy = [`${id}-help`, errors.length ? `${id}-error` : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const common = {
|
||||
id,
|
||||
value: form[field],
|
||||
'aria-invalid': errors.length > 0 || undefined,
|
||||
'aria-describedby': describedBy,
|
||||
onFocus: () => {
|
||||
lastFocused.current = field;
|
||||
const hintAccount = HINT_ACCOUNT[field];
|
||||
if (hintAccount) setAccount(hintAccount);
|
||||
},
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
|
||||
},
|
||||
};
|
||||
return (
|
||||
<div key={field}>
|
||||
<label htmlFor={id} className="mb-1 block text-sm text-foreground">
|
||||
{t(`fields.${field}`)}
|
||||
</label>
|
||||
{MULTILINE[field] ? (
|
||||
<textarea
|
||||
{...common}
|
||||
ref={(el) => {
|
||||
fieldRefs.current[field] = el;
|
||||
}}
|
||||
rows={ROWS[field] ?? 5}
|
||||
className={`${inputClass} py-2`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
{...common}
|
||||
ref={(el) => {
|
||||
fieldRefs.current[field] = el;
|
||||
}}
|
||||
type="text"
|
||||
className={`${inputClass} h-9`}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-1 flex justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span id={`${id}-help`}>{t(`fields.${field}Help`)}</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{t('counter', { count: form[field].length, max: WELCOME_MAIL_LIMITS[field] })}
|
||||
</span>
|
||||
</div>
|
||||
{errors.length > 0 && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive">
|
||||
{errors.join(' ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<button type="submit" disabled={saving || hasErrors} className="btn btn-primary">
|
||||
{saving ? t('saving') : t('save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={testing || hasErrors}
|
||||
onClick={handleTest}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{testing ? t('testSending') : t('test')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={resetting}
|
||||
onClick={() => setConfirmReset(true)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('reset')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('testHelp')}</p>
|
||||
|
||||
{confirmReset && (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-labelledby="welcome-reset-title"
|
||||
aria-describedby="welcome-reset-body"
|
||||
className="max-w-xl rounded-lg border border-border bg-background p-4"
|
||||
>
|
||||
<h2 id="welcome-reset-title" className="mb-1 text-sm font-semibold text-foreground">
|
||||
{t('resetConfirm.title')}
|
||||
</h2>
|
||||
<p id="welcome-reset-body" className="mb-3 text-sm text-muted-foreground">
|
||||
{t('resetConfirm.body')}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={resetting}
|
||||
onClick={handleReset}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('resetConfirm.confirm')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmReset(false)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('resetConfirm.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback && (
|
||||
<p
|
||||
role={feedback.type === 'error' ? 'alert' : 'status'}
|
||||
className={`text-sm ${feedback.type === 'error' ? 'text-destructive' : ''}`}
|
||||
style={feedback.type === 'success' ? { color: 'oklch(0.40 0.15 148)' } : undefined}
|
||||
>
|
||||
{feedback.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Platzhalter */}
|
||||
<section
|
||||
aria-labelledby="welcome-placeholders-title"
|
||||
className="rounded-lg border border-border p-4"
|
||||
>
|
||||
<h2
|
||||
id="welcome-placeholders-title"
|
||||
className="mb-1 text-sm font-semibold text-foreground"
|
||||
>
|
||||
{t('placeholders.title')}
|
||||
</h2>
|
||||
<p className="mb-3 text-sm text-muted-foreground">{t('placeholders.intro')}</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th scope="col" className="py-2 pr-3 font-medium">
|
||||
{t('placeholders.colPlaceholder')}
|
||||
</th>
|
||||
<th scope="col" className="py-2 pr-3 font-medium">
|
||||
{t('placeholders.colMeaning')}
|
||||
</th>
|
||||
<th scope="col" className="py-2 font-medium">
|
||||
{t('placeholders.colExample')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{WELCOME_MAIL_PLACEHOLDERS.map((name) => (
|
||||
<tr key={name} className="border-b border-border last:border-0 align-top">
|
||||
<td className="py-2 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertPlaceholder(name)}
|
||||
title={t('placeholders.insertHint')}
|
||||
aria-label={t('placeholders.insert', { token: `{{${name}}}` })}
|
||||
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground hover:bg-sidebar-hover"
|
||||
>
|
||||
{`{{${name}}}`}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-foreground">
|
||||
{t(`placeholders.items.${name}.meaning`)}
|
||||
</td>
|
||||
<td className="py-2 text-muted-foreground">
|
||||
{t(`placeholders.items.${name}.example`)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{t('placeholders.rules')}</p>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
{/* Vorschau */}
|
||||
<section aria-labelledby="welcome-preview-title" className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 id="welcome-preview-title" className="text-sm font-semibold text-foreground">
|
||||
{t('preview.title')}
|
||||
</h2>
|
||||
<fieldset className="flex gap-1">
|
||||
<legend className="sr-only">{t('preview.accountLabel')}</legend>
|
||||
{(['directory', 'local'] as const).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
aria-pressed={account === kind}
|
||||
onClick={() => setAccount(kind)}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||
account === kind
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border text-foreground hover:bg-sidebar-hover'
|
||||
}`}
|
||||
>
|
||||
{t(`preview.${kind}`)}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
{t('preview.subject')}{' '}
|
||||
<span className="font-medium text-foreground" data-testid="welcome-preview-subject">
|
||||
{preview?.subject ?? ''}
|
||||
</span>
|
||||
</p>
|
||||
{hasErrors && <p className="mb-2 text-sm text-destructive">{t('preview.blocked')}</p>}
|
||||
{previewFailed && <p className="mb-2 text-sm text-destructive">{t('preview.error')}</p>}
|
||||
<iframe
|
||||
title={t('preview.frameTitle')}
|
||||
sandbox=""
|
||||
srcDoc={preview?.html ?? ''}
|
||||
className="h-[760px] w-full rounded-lg border border-border bg-white"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t('preview.sampleHint')}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
"ldap": "LDAP",
|
||||
"smtp": "SMTP",
|
||||
"groups": "Gruppen"
|
||||
"groups": "Gruppen",
|
||||
"welcomeMail": "Willkommensmail"
|
||||
},
|
||||
"role": {
|
||||
"SUPER_ADMIN": "Super-Admin",
|
||||
@@ -535,6 +536,8 @@
|
||||
"displayName": "Anzeigename",
|
||||
"role": "Rolle",
|
||||
"status": "Status",
|
||||
"lastLogin": "Letzte Anmeldung",
|
||||
"neverLoggedIn": "Noch nie",
|
||||
"actions": "Aktionen",
|
||||
"password": "Passwort",
|
||||
"noUsers": "Keine Benutzer gefunden",
|
||||
@@ -557,6 +560,20 @@
|
||||
"generic": "Die Aktion konnte nicht durchgeführt werden. Bitte erneut versuchen.",
|
||||
"network": "Der Server ist nicht erreichbar. Bitte erneut versuchen.",
|
||||
"loadFailed": "Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"button": "Willkommensmail senden",
|
||||
"resend": "Erneut senden",
|
||||
"sentAt": "Willkommensmail gesendet am {date}",
|
||||
"noEmail": "Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.",
|
||||
"noSmtp": "Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).",
|
||||
"inactive": "Das Konto ist deaktiviert.",
|
||||
"dialogTitle": "Willkommensmail senden",
|
||||
"dialogText": "Tessera schickt eine Willkommensmail mit Adresse, Benutzername und Hinweisen zur ersten Anmeldung an:",
|
||||
"dialogResend": "Eine Willkommensmail wurde bereits am {date} gesendet. Sie wird erneut verschickt.",
|
||||
"send": "Senden",
|
||||
"sending": "Wird gesendet …",
|
||||
"success": "Die Willkommensmail an {email} wurde gesendet."
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
@@ -722,6 +739,106 @@
|
||||
"create": "Gemeinsamen Eintrag anlegen",
|
||||
"noModules": "Noch keine gemeinsamen Einträge",
|
||||
"noModulesBody": "Legen Sie Ihren ersten gemeinsamen Eintrag an, um eine Webseite für alle Benutzer in der Seitenleiste zu zeigen."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"title": "Willkommensmail",
|
||||
"description": "Hier legen Sie fest, mit welchen Worten Tessera neue Benutzer begrüßt. Die Vorlage gilt für Ihren Mandanten und wird bei jeder Willkommensmail aus der Benutzerverwaltung verwendet.",
|
||||
"loading": "Vorlage wird geladen …",
|
||||
"fixed": {
|
||||
"title": "Was Sie anpassen können – und was immer drin bleibt",
|
||||
"body": "Sie bestimmen Betreff, Überschrift, Einleitung, Abschlusstext und den Anmeldehinweis – getrennt für Verzeichniskonten und für lokale Konten. Alles, was für die Anmeldung technisch nötig ist, fügt Tessera immer automatisch ein und lässt sich nicht verändern: der Kopf mit Logo, der Kasten mit Adresse und Benutzername, der Knopf „Passwort festlegen“ samt Link und Gültigkeitshinweis bei lokalen Konten, der Knopf „Zu Tessera“ und die Fußzeile. So kann eine eigene Vorlage die Anmeldung nicht kaputt machen."
|
||||
},
|
||||
"statusDefault": "Zurzeit gelten die Standardtexte.",
|
||||
"statusCustom": "Eigene Vorlage aktiv – zuletzt geändert am {date}.",
|
||||
"statusCustomBy": "Eigene Vorlage aktiv – zuletzt geändert am {date} von {user}.",
|
||||
"unsaved": "Ungespeicherte Änderungen",
|
||||
"fields": {
|
||||
"subject": "Betreff",
|
||||
"subjectHelp": "Eine Zeile, Pflichtfeld.",
|
||||
"heading": "Überschrift",
|
||||
"headingHelp": "Große Zeile oben in der Mail, Pflichtfeld.",
|
||||
"intro": "Einleitungstext",
|
||||
"introHelp": "Steht unter der Überschrift. Eine Leerzeile beginnt einen neuen Absatz.",
|
||||
"loginHintDirectory": "Anmeldehinweis für Verzeichniskonten",
|
||||
"loginHintDirectoryHelp": "Erscheint unter dem Kasten mit den Zugangsdaten, wenn der Benutzer aus dem Verzeichnis (Active Directory/LDAP) kommt und sich mit seinem gewohnten Windows-Passwort anmeldet. Pflichtfeld.",
|
||||
"loginHintLocal": "Anmeldehinweis für lokale Konten",
|
||||
"loginHintLocalHelp": "Erscheint bei lokalen Konten direkt vor dem Knopf „Passwort festlegen“. Knopf und Gültigkeitshinweis darunter fügt Tessera immer selbst ein. Pflichtfeld.",
|
||||
"closing": "Abschlusstext",
|
||||
"closingHelp": "Steht am Ende der Mail, z. B. Tipp und Grußzeile. Darf leer bleiben."
|
||||
},
|
||||
"counter": "{count} / {max}",
|
||||
"save": "Speichern",
|
||||
"saving": "Wird gespeichert …",
|
||||
"saved": "Die Vorlage wurde gespeichert. Ab jetzt verwenden neue Willkommensmails diese Texte.",
|
||||
"test": "Testmail an mich senden",
|
||||
"testSending": "Wird gesendet …",
|
||||
"testSent": "Die Testmail wurde an {email} gesendet.",
|
||||
"testHelp": "Die Testmail geht an Ihre eigene E-Mail-Adresse und zeigt den aktuellen Inhalt des Formulars, auch wenn er noch nicht gespeichert ist. Sie enthält keinen echten Link zum Festlegen eines Passworts.",
|
||||
"reset": "Auf Standard zurücksetzen",
|
||||
"resetDone": "Die Standardtexte sind wiederhergestellt.",
|
||||
"resetConfirm": {
|
||||
"title": "Auf Standard zurücksetzen?",
|
||||
"body": "Ihre eigene Vorlage wird gelöscht und die Standardtexte gelten wieder. Das lässt sich nicht rückgängig machen.",
|
||||
"confirm": "Zurücksetzen",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"errors": {
|
||||
"load": "Die Vorlage konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
|
||||
"save": "Die Vorlage konnte nicht gespeichert werden.",
|
||||
"reset": "Die Vorlage konnte nicht zurückgesetzt werden.",
|
||||
"test": "Die Testmail konnte nicht gesendet werden.",
|
||||
"required": "Bitte füllen Sie dieses Feld aus.",
|
||||
"tooLong": "Höchstens {max} Zeichen.",
|
||||
"unknownPlaceholder": "Unbekannter Platzhalter {token}",
|
||||
"unknownPlaceholders": "Unbekannte Platzhalter {tokens}"
|
||||
},
|
||||
"placeholders": {
|
||||
"title": "Platzhalter",
|
||||
"intro": "Platzhalter setzt Tessera beim Versand durch die Angaben des jeweiligen Benutzers. Sie funktionieren in allen vier Feldern. Ein Klick auf einen Platzhalter fügt ihn an der Cursorposition des zuletzt benutzten Feldes ein.",
|
||||
"colPlaceholder": "Platzhalter",
|
||||
"colMeaning": "Bedeutung",
|
||||
"colExample": "Beispiel",
|
||||
"insert": "{token} einfügen",
|
||||
"insertHint": "Klicken zum Einfügen",
|
||||
"rules": "Andere Platzhalter kennt Tessera nicht – sie werden beim Speichern abgelehnt. Die Texte sind reiner Text: Formatierungen oder HTML werden nicht übernommen, sondern so angezeigt, wie Sie sie eingeben.",
|
||||
"items": {
|
||||
"name": {
|
||||
"meaning": "Anzeigename des Benutzers, sonst sein Benutzername",
|
||||
"example": "Max Mustermann"
|
||||
},
|
||||
"vorname": {
|
||||
"meaning": "Erstes Wort des Anzeigenamens, sonst der Benutzername",
|
||||
"example": "Max"
|
||||
},
|
||||
"benutzername": {
|
||||
"meaning": "Benutzername für die Anmeldung",
|
||||
"example": "max.mustermann"
|
||||
},
|
||||
"email": {
|
||||
"meaning": "E-Mail-Adresse des Benutzers",
|
||||
"example": "max.mustermann@example.com"
|
||||
},
|
||||
"adresse": {
|
||||
"meaning": "Adresse von Tessera",
|
||||
"example": "https://tessera.example.com"
|
||||
},
|
||||
"firma": {
|
||||
"meaning": "Name Ihres Mandanten",
|
||||
"example": "Beispiel GmbH"
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"title": "Vorschau",
|
||||
"accountLabel": "Vorschau für Kontoart",
|
||||
"directory": "Verzeichniskonto",
|
||||
"local": "Lokales Konto",
|
||||
"subject": "Betreff:",
|
||||
"frameTitle": "Vorschau der Willkommensmail",
|
||||
"error": "Die Vorschau konnte nicht erstellt werden.",
|
||||
"blocked": "Die Vorschau wird aktualisiert, sobald alle Felder in Ordnung sind.",
|
||||
"sampleHint": "Die Vorschau verwendet Beispielwerte (Max Mustermann) und den Namen Ihres Mandanten. Der Link „Passwort festlegen“ ist in der Vorschau nur ein Beispiel."
|
||||
}
|
||||
}
|
||||
},
|
||||
"adminModules": {
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"customModules": "Custom modules",
|
||||
"ldap": "LDAP",
|
||||
"smtp": "SMTP",
|
||||
"groups": "Groups"
|
||||
"groups": "Groups",
|
||||
"welcomeMail": "Welcome email"
|
||||
},
|
||||
"role": {
|
||||
"SUPER_ADMIN": "Super Admin",
|
||||
@@ -535,6 +536,8 @@
|
||||
"displayName": "Display Name",
|
||||
"role": "Role",
|
||||
"status": "Status",
|
||||
"lastLogin": "Last sign-in",
|
||||
"neverLoggedIn": "Never",
|
||||
"actions": "Actions",
|
||||
"password": "Password",
|
||||
"noUsers": "No users found",
|
||||
@@ -557,6 +560,20 @@
|
||||
"generic": "The action could not be completed. Please try again.",
|
||||
"network": "The server is not reachable. Please try again.",
|
||||
"loadFailed": "The user list could not be loaded. Please reload the page."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"button": "Send welcome email",
|
||||
"resend": "Send again",
|
||||
"sentAt": "Welcome email sent on {date}",
|
||||
"noEmail": "No email address on file – please add one first.",
|
||||
"noSmtp": "No SMTP server has been set up yet (Administrator → SMTP).",
|
||||
"inactive": "The account is deactivated.",
|
||||
"dialogTitle": "Send welcome email",
|
||||
"dialogText": "Tessera will send a welcome email with the address, username and first sign-in instructions to:",
|
||||
"dialogResend": "A welcome email was already sent on {date}. It will be sent again.",
|
||||
"send": "Send",
|
||||
"sending": "Sending …",
|
||||
"success": "The welcome email to {email} has been sent."
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
@@ -722,6 +739,106 @@
|
||||
"create": "Add shared entry",
|
||||
"noModules": "No shared entries yet",
|
||||
"noModulesBody": "Add your first shared entry to show a website in the sidebar for all users."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"title": "Welcome email",
|
||||
"description": "Choose the words Tessera uses to greet new users. The template applies to your tenant and is used for every welcome email sent from user management.",
|
||||
"loading": "Loading template …",
|
||||
"fixed": {
|
||||
"title": "What you can change – and what always stays",
|
||||
"body": "You decide the subject, heading, introduction, closing text and the sign-in hint – separately for directory accounts and for local accounts. Everything technically needed to sign in is always added automatically and cannot be changed: the header with logo, the box with address and username, the “Set password” button with its link and validity note for local accounts, the “Go to Tessera” button and the footer. This way a custom template cannot break sign-in."
|
||||
},
|
||||
"statusDefault": "The default texts are currently in use.",
|
||||
"statusCustom": "Custom template active – last changed on {date}.",
|
||||
"statusCustomBy": "Custom template active – last changed on {date} by {user}.",
|
||||
"unsaved": "Unsaved changes",
|
||||
"fields": {
|
||||
"subject": "Subject",
|
||||
"subjectHelp": "One line, required.",
|
||||
"heading": "Heading",
|
||||
"headingHelp": "Large line at the top of the email, required.",
|
||||
"intro": "Introduction",
|
||||
"introHelp": "Shown below the heading. An empty line starts a new paragraph.",
|
||||
"loginHintDirectory": "Sign-in hint for directory accounts",
|
||||
"loginHintDirectoryHelp": "Shown below the box with the sign-in details when the user comes from the directory (Active Directory/LDAP) and signs in with their usual Windows password. Required.",
|
||||
"loginHintLocal": "Sign-in hint for local accounts",
|
||||
"loginHintLocalHelp": "Shown for local accounts right before the “Set password” button. Tessera always adds the button and the validity note below it. Required.",
|
||||
"closing": "Closing text",
|
||||
"closingHelp": "Shown at the end of the email, e.g. a tip and a sign-off. May be left empty."
|
||||
},
|
||||
"counter": "{count} / {max}",
|
||||
"save": "Save",
|
||||
"saving": "Saving …",
|
||||
"saved": "The template has been saved. New welcome emails will use these texts from now on.",
|
||||
"test": "Send test email to me",
|
||||
"testSending": "Sending …",
|
||||
"testSent": "The test email was sent to {email}.",
|
||||
"testHelp": "The test email goes to your own email address and shows the current form content, even if it has not been saved yet. It does not contain a real link for setting a password.",
|
||||
"reset": "Reset to default",
|
||||
"resetDone": "The default texts have been restored.",
|
||||
"resetConfirm": {
|
||||
"title": "Reset to default?",
|
||||
"body": "Your custom template will be deleted and the default texts apply again. This cannot be undone.",
|
||||
"confirm": "Reset",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"errors": {
|
||||
"load": "The template could not be loaded. Please reload the page.",
|
||||
"save": "The template could not be saved.",
|
||||
"reset": "The template could not be reset.",
|
||||
"test": "The test email could not be sent.",
|
||||
"required": "Please fill in this field.",
|
||||
"tooLong": "At most {max} characters.",
|
||||
"unknownPlaceholder": "Unknown placeholder {token}",
|
||||
"unknownPlaceholders": "Unknown placeholders {tokens}"
|
||||
},
|
||||
"placeholders": {
|
||||
"title": "Placeholders",
|
||||
"intro": "Tessera replaces placeholders with the details of each user when sending. They work in all four fields. Clicking a placeholder inserts it at the cursor position of the field you used last.",
|
||||
"colPlaceholder": "Placeholder",
|
||||
"colMeaning": "Meaning",
|
||||
"colExample": "Example",
|
||||
"insert": "Insert {token}",
|
||||
"insertHint": "Click to insert",
|
||||
"rules": "Tessera does not know any other placeholders – they are rejected when saving. The texts are plain text: formatting or HTML is not applied but shown exactly as you type it.",
|
||||
"items": {
|
||||
"name": {
|
||||
"meaning": "Display name of the user, otherwise the username",
|
||||
"example": "Max Mustermann"
|
||||
},
|
||||
"vorname": {
|
||||
"meaning": "First word of the display name, otherwise the username",
|
||||
"example": "Max"
|
||||
},
|
||||
"benutzername": {
|
||||
"meaning": "Username for signing in",
|
||||
"example": "max.mustermann"
|
||||
},
|
||||
"email": {
|
||||
"meaning": "Email address of the user",
|
||||
"example": "max.mustermann@example.com"
|
||||
},
|
||||
"adresse": {
|
||||
"meaning": "Address of Tessera",
|
||||
"example": "https://tessera.example.com"
|
||||
},
|
||||
"firma": {
|
||||
"meaning": "Name of your tenant",
|
||||
"example": "Example Ltd"
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview",
|
||||
"accountLabel": "Preview for account type",
|
||||
"directory": "Directory account",
|
||||
"local": "Local account",
|
||||
"subject": "Subject:",
|
||||
"frameTitle": "Welcome email preview",
|
||||
"error": "The preview could not be created.",
|
||||
"blocked": "The preview updates as soon as all fields are valid.",
|
||||
"sampleHint": "The preview uses sample values (Max Mustermann) and the name of your tenant. The “Set password” link is only an example in the preview."
|
||||
}
|
||||
}
|
||||
},
|
||||
"adminModules": {
|
||||
|
||||
@@ -197,4 +197,10 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'Arbeitsspeicher',
|
||||
// quick-260925-bow: „Was ist neu“-Fenster, Gruppe „Verbessert“ — korrektes Deutsch mit „ss“
|
||||
'Verbessert',
|
||||
// Administrator → Willkommensmail (eigene Vorlage) — korrektes Deutsch mit „ss“/„ue“/„ae“
|
||||
'anpassen',
|
||||
'Abschlusstext',
|
||||
'aktuellen',
|
||||
'Passworts',
|
||||
'tessera',
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment node
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { SignJWT } from 'jose';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { middleware } from './middleware';
|
||||
|
||||
@@ -67,7 +68,9 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
|
||||
});
|
||||
|
||||
it('Test 6 (quick-260918-gza): /login mit dv/dc/dos setzt zusaetzlich tessera_desktop_client', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows');
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows',
|
||||
);
|
||||
const res = await middleware(req);
|
||||
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
|
||||
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|windows');
|
||||
@@ -98,15 +101,23 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
|
||||
);
|
||||
expect((await middleware(spaceInOs)).cookies.get('tessera_desktop_client')).toBeUndefined();
|
||||
|
||||
const missingDv = new NextRequest('http://localhost:3000/login?desktop=1&dc=a6d1a64&dos=windows');
|
||||
const missingDv = new NextRequest(
|
||||
'http://localhost:3000/login?desktop=1&dc=a6d1a64&dos=windows',
|
||||
);
|
||||
expect((await middleware(missingDv)).cookies.get('tessera_desktop_client')).toBeUndefined();
|
||||
|
||||
const emptyCommit = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=&dos=linux');
|
||||
expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe('1.2.0||linux');
|
||||
const emptyCommit = new NextRequest(
|
||||
'http://localhost:3000/login?desktop=1&dv=1.2.0&dc=&dos=linux',
|
||||
);
|
||||
expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe(
|
||||
'1.2.0||linux',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 9 (quick-260918-gza, Redirect-Pfad): /dashboard ohne Session setzt beide Cookies auf dem 307', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux');
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux',
|
||||
);
|
||||
const res = await middleware(req);
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get('location')).toContain('/login');
|
||||
@@ -114,3 +125,65 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
|
||||
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|linux');
|
||||
});
|
||||
});
|
||||
|
||||
describe('middleware — /login bei bestehender Anmeldung (quick-260930)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('JWT_SECRET', 'test-secret');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
async function token(claims: Record<string, unknown> = {}) {
|
||||
return new SignJWT({ sub: 'u1', ...claims })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('5m')
|
||||
.sign(new TextEncoder().encode('test-secret'));
|
||||
}
|
||||
|
||||
function loginReq(url: string, session?: string) {
|
||||
return new NextRequest(url, session ? { headers: { cookie: `session=${session}` } } : {});
|
||||
}
|
||||
|
||||
it('angemeldet: /login leitet aufs Dashboard um', async () => {
|
||||
const res = await middleware(loginReq('http://localhost:3000/login', await token()));
|
||||
expect(res.status).toBe(307);
|
||||
expect(new URL(res.headers.get('location') as string).pathname).toBe('/');
|
||||
});
|
||||
|
||||
it('angemeldet mit sicherem next: dorthin', async () => {
|
||||
const res = await middleware(
|
||||
loginReq('http://localhost:3000/login?next=%2Fadmin%2Fusers', await token()),
|
||||
);
|
||||
expect(new URL(res.headers.get('location') as string).pathname).toBe('/admin/users');
|
||||
});
|
||||
|
||||
it('angemeldet mit fremdem oder zirkulaerem next: Dashboard', async () => {
|
||||
for (const next of ['https%3A%2F%2Fboese.example', '%2F%2Fboese.example', '%2Flogin']) {
|
||||
const res = await middleware(
|
||||
loginReq(`http://localhost:3000/login?next=${next}`, await token()),
|
||||
);
|
||||
const loc = new URL(res.headers.get('location') as string);
|
||||
expect(loc.host).toBe('localhost:3000');
|
||||
expect(loc.pathname).toBe('/');
|
||||
}
|
||||
});
|
||||
|
||||
it('ohne oder mit ungueltigem Cookie: Anmeldeseite wie bisher', async () => {
|
||||
const ohne = await middleware(loginReq('http://localhost:3000/login'));
|
||||
expect(ohne.headers.get('x-middleware-next')).toBe('1');
|
||||
const kaputt = await middleware(
|
||||
loginReq('http://localhost:3000/login', 'kein.gueltiges.token'),
|
||||
);
|
||||
expect(kaputt.headers.get('x-middleware-next')).toBe('1');
|
||||
});
|
||||
|
||||
it('Kennwortwechsel ausstehend: keine Umleitung von /login', async () => {
|
||||
const res = await middleware(
|
||||
loginReq('http://localhost:3000/login', await token({ mustChangePassword: true })),
|
||||
);
|
||||
expect(res.headers.get('x-middleware-next')).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { buildNextParam } from '@/lib/safe-next';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { buildNextParam, sanitizeNextPath } from '@/lib/safe-next';
|
||||
|
||||
/**
|
||||
* Next.js middleware for frontend route protection (Pattern 4).
|
||||
@@ -113,6 +113,28 @@ function getSecret() {
|
||||
export async function middleware(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname;
|
||||
|
||||
// Bereits angemeldet und /login aufgerufen (quick-260930, Wunsch des
|
||||
// Nutzers): statt der Anmeldeseite direkt zum Ziel — `next`, sofern ein
|
||||
// sicherer relativer Pfad, sonst das Dashboard. Nur bei gueltiger
|
||||
// Signatur; ist das Konto inzwischen gesperrt, lehnt die API die Sitzung
|
||||
// ab, die Oberflaeche loescht das Cookie serverseitig und schickt zur
|
||||
// Anmeldung zurueck — dann ohne Cookie, also keine Schleife.
|
||||
if (path === '/login' || path.startsWith('/login/')) {
|
||||
const existing = req.cookies.get('session')?.value;
|
||||
if (existing) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(existing, getSecret(), { algorithms: ['HS256'] });
|
||||
if (payload.mustChangePassword !== true) {
|
||||
const next = sanitizeNextPath(req.nextUrl.searchParams.get('next'));
|
||||
const target = next.startsWith('/login') ? '/' : next;
|
||||
return withDesktopCookie(req, NextResponse.redirect(new URL(target, req.nextUrl)));
|
||||
}
|
||||
} catch {
|
||||
// ungueltiges Cookie: Anmeldeseite wie gewohnt zeigen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Allow public routes without authentication
|
||||
if (publicRoutes.some((route) => path.startsWith(route))) {
|
||||
return withDesktopCookie(req, NextResponse.next());
|
||||
@@ -141,10 +163,7 @@ export async function middleware(req: NextRequest) {
|
||||
});
|
||||
|
||||
// D-06: Force password change redirect
|
||||
if (
|
||||
payload.mustChangePassword === true &&
|
||||
!path.startsWith('/change-password')
|
||||
) {
|
||||
if (payload.mustChangePassword === true && !path.startsWith('/change-password')) {
|
||||
return withDesktopCookie(
|
||||
req,
|
||||
NextResponse.redirect(new URL('/change-password', req.nextUrl)),
|
||||
|
||||
@@ -35,7 +35,7 @@ Wichtige Details, die im Code tatsächlich so umgesetzt sind:
|
||||
|
||||
## 2. Benutzerverwaltung
|
||||
|
||||
Der Bereich **Administrator → Benutzer** zeigt eine Tabelle mit Benutzername, E-Mail, Anzeigename, Rolle und Status. Ein ADMIN sieht dabei ausschließlich die Benutzer des eigenen Mandanten, ein SUPER_ADMIN sieht alle.
|
||||
Der Bereich **Administrator → Benutzer** zeigt eine Tabelle mit Benutzername, E-Mail, Anzeigename, Rolle, Status und **Letzte Anmeldung** (Datum und Uhrzeit der letzten Anmeldung, bei neuen Konten „Noch nie“). Ein ADMIN sieht dabei ausschließlich die Benutzer des eigenen Mandanten, ein SUPER_ADMIN sieht alle.
|
||||
|
||||
### Benutzer anlegen
|
||||
|
||||
@@ -53,6 +53,43 @@ Ein Konto, das über die AD-Anbindung importiert oder synchronisiert wurde, hat
|
||||
|
||||
Seit Kurzem ist die E-Mail-Adresse eines Benutzers **optional**: Wenn beim Import eine Adresse bereits einem anderen Konto gehört, wird das Konto trotzdem angelegt bzw. aktualisiert – nur eben ohne diese Adresse. Anmeldung und Zugriff funktionieren für ein solches Konto normal, lediglich Benachrichtigungen per E-Mail (z. B. Passwort-Reset) erreichen es nicht. In der Tabelle wird eine fehlende Adresse als „–“ angezeigt. Bei der manuellen Anlage über das Formular ist eine E-Mail-Adresse weiterhin Pflicht.
|
||||
|
||||
### Willkommensmail
|
||||
|
||||
Bei jedem Benutzer steht in der Tabelle der Button „Willkommensmail senden“ – gedacht vor allem für neue Konten (Spalte „Letzte Anmeldung“: „Noch nie“), er lässt sich aber auch bei Benutzern nutzen, die sich schon angemeldet haben, etwa um die Zugangsdaten erneut zuzuschicken oder die Mail auszuprobieren. Es gelten dieselben Rechte wie beim Bearbeiten: ein ADMIN kann nur Benutzern des eigenen Mandanten und keinem SUPER_ADMIN eine Willkommensmail schicken. Nach einer Rückfrage mit der Empfängeradresse verschickt Tessera eine gestaltete E-Mail mit der Adresse von Tessera, dem Benutzernamen und einem Hinweis zur ersten Anmeldung:
|
||||
|
||||
- **verzeichnisgeführtes Konto (AD/LDAP):** Hinweis, sich mit dem gewohnten Windows-Passwort anzumelden;
|
||||
- **lokales Konto:** ein Button „Passwort festlegen“, über den der Benutzer sein eigenes Passwort setzt. Der Link funktioniert wie „Passwort vergessen?“ – er ist 7 Tage gültig und nur einmal verwendbar; danach kann der Benutzer auf der Anmeldeseite jederzeit einen neuen anfordern.
|
||||
|
||||
Ein Passwort steht nie in der Mail. Bei einem lokalen Konto, das bereits ein Passwort hat, ersetzt „Passwort festlegen“ dieses Passwort erst, wenn der Benutzer den Link tatsächlich benutzt. Nach dem Versand zeigt die Zeile „Willkommensmail gesendet am …“, und der Button heißt „Erneut senden“. Der Button ist ausgegraut, wenn beim Benutzer keine E-Mail-Adresse hinterlegt ist, das Konto deaktiviert ist oder noch kein Mailserver eingerichtet wurde (siehe Kapitel 6, SMTP); der Grund steht im Hinweistext des Buttons. Die Adresse in der Mail stammt aus der Einstellung `APP_URL` des Servers (siehe `docs/anleitung-betrieb.md`); fehlt sie, nimmt Tessera die Adresse, unter der Sie die Benutzerverwaltung gerade geöffnet haben.
|
||||
|
||||
#### Eigene Vorlage für die Willkommensmail
|
||||
|
||||
Unter **Administrator → Willkommensmail** legen Sie fest, mit welchen Worten Tessera neue Benutzer begrüßt. Die Vorlage gilt für Ihren Mandanten; ADMIN und SUPER_ADMIN können sie bearbeiten. Anpassen lassen sich sechs Texte:
|
||||
|
||||
- **Betreff** (Pflicht, höchstens 200 Zeichen),
|
||||
- **Überschrift** (Pflicht, höchstens 200 Zeichen),
|
||||
- **Einleitungstext** (höchstens 4000 Zeichen; eine Leerzeile beginnt einen neuen Absatz),
|
||||
- **Anmeldehinweis für Verzeichniskonten** (Pflicht, höchstens 1000 Zeichen) – erscheint unter dem Kasten mit den Zugangsdaten, wenn der Benutzer aus dem Verzeichnis (AD/LDAP) kommt; Standard: „Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.“,
|
||||
- **Anmeldehinweis für lokale Konten** (Pflicht, höchstens 1000 Zeichen) – erscheint bei lokalen Konten direkt vor dem Button „Passwort festlegen“; Standard: „Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.“,
|
||||
- **Abschlusstext** (höchstens 4000 Zeichen, darf leer bleiben – etwa ein Tipp und die Grußzeile).
|
||||
|
||||
Alles, was für die Anmeldung technisch nötig ist, fügt Tessera immer automatisch ein und lässt sich nicht verändern: der Kopf mit Logo, der Kasten mit Adresse und Benutzername, der Button „Passwort festlegen“ samt persönlichem Link und Gültigkeitshinweis bei lokalen Konten, der Button „Zu Tessera“ und die Fußzeile. Beide Anmeldehinweise dürfen Leerzeilen (neuer Absatz) enthalten. Vorlagen, die vor Einführung der Anmeldehinweise gespeichert wurden, verwenden dafür automatisch die Standardtexte. So kann eine eigene Vorlage die Anmeldung nicht kaputt machen. Die Texte sind reiner Text – Formatierungen oder HTML werden nicht übernommen, sondern so angezeigt, wie sie eingegeben wurden.
|
||||
|
||||
**Platzhalter** funktionieren in allen sechs Feldern und werden beim Versand durch die Angaben des jeweiligen Benutzers ersetzt. Ein Klick auf einen Platzhalter in der Hinweisbox fügt ihn an der Cursorposition des zuletzt benutzten Feldes ein.
|
||||
|
||||
| Platzhalter | Bedeutung | Beispiel |
|
||||
|---|---|---|
|
||||
| `{{name}}` | Anzeigename, sonst Benutzername | Max Mustermann |
|
||||
| `{{vorname}}` | erstes Wort des Anzeigenamens, sonst Benutzername | Max |
|
||||
| `{{benutzername}}` | Benutzername für die Anmeldung | max.mustermann |
|
||||
| `{{email}}` | E-Mail-Adresse des Benutzers | max.mustermann@example.com |
|
||||
| `{{adresse}}` | Adresse von Tessera | https://tessera.example.com |
|
||||
| `{{firma}}` | Name des Mandanten | Beispiel GmbH |
|
||||
|
||||
Andere Platzhalter kennt Tessera nicht: die Seite nennt sie schon beim Tippen („Unbekannter Platzhalter {{xyz}}“), und das Speichern wird abgelehnt.
|
||||
|
||||
Rechts zeigt eine **Vorschau** die Mail genau so, wie sie verschickt wird – mit Beispielwerten und umschaltbar zwischen „Verzeichniskonto“ und „Lokales Konto“; die Vorschau zeigt dabei den jeweiligen Anmeldehinweis und springt beim Bearbeiten eines Anmeldehinweises von selbst auf die passende Kontoart. **Testmail an mich senden** schickt den aktuellen Inhalt des Formulars (auch ungespeichert) an Ihre eigene E-Mail-Adresse, mit Ihrem Konto als Beispiel. Die Testmail enthält nie einen echten Link zum Festlegen eines Passworts: bei einem lokalen Konto führt der Button nur zur Anmeldeseite und ist als Beispiel gekennzeichnet. **Auf Standard zurücksetzen** löscht die eigene Vorlage nach einer Rückfrage; danach gelten wieder die mitgelieferten Standardtexte. Ohne eigene Vorlage verwendet Tessera immer die Standardtexte.
|
||||
|
||||
### Deaktivieren und Löschen
|
||||
|
||||
Die Benutzerliste zeigt einen Status „Aktiv“/„Inaktiv“ an, dieser lässt sich aber **nicht** über einen Schalter im Formular umschalten – im Bearbeiten-Dialog gibt es dafür kein Feld. Ein Konto wird auf zwei Wegen inaktiv:
|
||||
|
||||
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 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