Compare commits
15 Commits
32441d77c7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c1b26541af | |||
| dfc4e9b781 | |||
| c07b0cfaf0 | |||
| 645c5e5887 | |||
| 2dd11b439d | |||
| 59b8cd43fc | |||
| 5919a55cbf | |||
| 7edaf8c00b | |||
| 61a971ccc0 | |||
| 471cfbf98b | |||
| a257bc3f86 | |||
| 714f731ac9 | |||
| e10da76259 | |||
| 31d514b7ca | |||
| 52f538c432 |
@@ -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)."
|
||||||
|
}
|
||||||
+6
-2
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
|
|||||||
status: verified
|
status: verified
|
||||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||||
last_updated: "2026-09-23T15:30:00.000Z"
|
last_updated: "2026-09-23T15:30:00.000Z"
|
||||||
last_activity: 2026-09-30
|
last_activity: 2026-10-01
|
||||||
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
|
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
|
||||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||||
progress:
|
progress:
|
||||||
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
|||||||
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||||
Plan: 6 of 6
|
Plan: 6 of 6
|
||||||
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
||||||
Last activity: 2026-09-30 - Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. mit 4 Fix-Commits (be1e003, c2e4467, 0710829, 12214a9)
|
Last activity: 2026-09-30 - v1.9.0 freigegeben (Willkommensmail + Vorlage, Rollen je Anfrage aus DB, Dashboard-Skalierung, Keep-Alive eigene Module); pausiert mit HANDOFF
|
||||||
|
|
||||||
Progress: [██████████] 99%
|
Progress: [██████████] 99%
|
||||||
|
|
||||||
@@ -483,6 +483,10 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
|||||||
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
|
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
|
||||||
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
|
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
|
||||||
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
|
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
|
||||||
|
| 261001-cxo | Desktop-Client: Links mit target=_blank (Favoriten) oeffnen jetzt im System-Browser (DesktopExternalLinks -> window.open) | 2026-10-01 | 61a971c | [261001-cxo](./quick/261001-cxo-desktop-client-links-mit-target-blank-oe/) |
|
||||||
|
| 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) |
|
||||||
|
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
|
||||||
|
| 261001-l4q | Zertifikat-Manager: Reiter Übersicht (Paket/ZIP hochladen, Teile erkennen/zuordnen, jedes Teil in jedem Format) + Desktop speichert blob-Downloads selbst | 2026-10-01 | siehe git log | [261001-l4q](./quick/261001-l4q-zertifikatsmodul-paket-hochladen-uebersi/) |
|
||||||
|
|
||||||
## Deferred Items
|
## Deferred Items
|
||||||
|
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-cxo
|
||||||
|
description: "Desktop-Client: Links mit target=_blank oeffnen"
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Desktop-Client: Links mit target=_blank oeffnen
|
||||||
|
|
||||||
|
**Befund (VM 8233, Client 1.9.0 gegen alpha):** Klick auf Favorit (`<a target="_blank">`) tut nichts; Such-Widget (`window.open`) oeffnet Edge ueber `on_new_window` (lib.rs). Der Rust-Weg funktioniert also, nur der Link-Klick erreicht ihn nicht.
|
||||||
|
|
||||||
|
## Task 1 — DesktopExternalLinks
|
||||||
|
- `apps/web/src/components/desktop/desktop-external-links.tsx`: im Desktop-Client (Cookie `tessera_desktop`) Links-/Mittelklick auf `a[href][target=_blank]` mit http/https per `window.open(href,'_blank','noopener,noreferrer')` oeffnen, `preventDefault`. Listener auf `window` (Bubble, nach React) -> von der Seite verhinderte Klicks (Favoriten im Bearbeiten-Modus) bleiben verhindert.
|
||||||
|
- In `apps/web/src/app/layout.tsx` neben `DesktopContextMenuGuard` einhaengen.
|
||||||
|
- Test `desktop-external-links.test.tsx`.
|
||||||
|
- CHANGELOG „Unveröffentlicht → Behoben“.
|
||||||
|
|
||||||
|
**Verify:** vitest gruen, tsc, biome; nach alpha-Pull auf VM 8233: Favorit oeffnet Edge.
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-cxo
|
||||||
|
status: complete
|
||||||
|
date: 2026-10-01
|
||||||
|
commit: 61a971c
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: Desktop-Client – Links mit target=_blank
|
||||||
|
|
||||||
|
- Nachgestellt auf VM 8233 (Client 1.9.0, alpha): Favorit-Klick ohne Wirkung, Such-Widget (`window.open`) oeffnet Edge.
|
||||||
|
- Neu `DesktopExternalLinks` (apps/web/src/components/desktop/desktop-external-links.tsx), in `app/layout.tsx` eingehaengt: im Client Links-/Mittelklick auf `a[target=_blank]` mit http/https -> `window.open(href,'_blank','noopener,noreferrer')`; verhinderte Klicks bleiben verhindert.
|
||||||
|
- 4 Tests (desktop-external-links.test.tsx), tsc + biome sauber. CHANGELOG „Unveröffentlicht → Behoben“.
|
||||||
|
- Reine Web-Aenderung: kein neuer Client noetig, wirkt nach Pull des web-Images.
|
||||||
|
- Offen: Nachweis auf VM nach alpha-Pull (User).
|
||||||
|
|
||||||
|
## Nachtrag (gleicher Tag): erste Fassung wirkte nicht
|
||||||
|
- Nach alpha-Pull weiter ohne Wirkung. Diagnose per temporaerem Klick-Protokoll (lokaler Stack, VM-Client per portproxy auf localhost:3000): `preventDefault` kam aus `<anonymous>:1:442` = Link-Skript von tauri-plugin-opener (init-iife.js, Listener auf `window`): faengt `target=_blank`-Klicks ab und ruft `plugin:opener|open_url` – von der Server-Seite nicht freigegeben, Klick verpufft. Unser Listener auf `window` lief danach und sah den Klick als verhindert.
|
||||||
|
- Fix: Listener auf `document` (Bubble) – nach React (Wurzel document), vor dem Opener-Skript. Auf VM nachgewiesen: Favorit oeffnet Edge, im Bearbeiten-Modus nichts (React-onClick verhindert).
|
||||||
|
- Commit siehe git log; Test „kommt dem Link-Skript des Clients auf window zuvor“.
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-g68
|
||||||
|
description: "Erinnerung: Cursor springt aus Beschreibung in Titel"
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Erinnerung: Cursor springt aus Beschreibung in Titel
|
||||||
|
|
||||||
|
**Befund:** `ReminderFormModal` setzte den Fokus auf den Titel im selben Effekt wie den Escape-Listener, Abhaengigkeit `[onClose]`. `onClose` ist in der Kachel eine Inline-Funktion; die Kachel zeichnet alle 10 s neu (NOW_TICK_MS) und bei jedem Neuladen → Effekt laeuft erneut → Cursor springt in den Titel (User: beim Schreiben, und bei Loeschen-Taste in leerer Beschreibung).
|
||||||
|
|
||||||
|
## Task 1
|
||||||
|
- Fokus-Effekt nur beim Oeffnen (`[]`), Escape-Listener ueber `onCloseRef`.
|
||||||
|
- Test im Widget: Formular oeffnen, Beschreibung fokussieren, 30 s Takt → Fokus bleibt.
|
||||||
|
- CHANGELOG „Unveröffentlicht → Behoben“.
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-g68
|
||||||
|
status: complete
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
- `reminder-form-modal.tsx`: Fokus auf Titel nur einmal beim Oeffnen; Escape ueber Ref statt `[onClose]`-Abhaengigkeit.
|
||||||
|
- Neuer Test in `reminder-widget.test.tsx` – schlaegt ohne Fix fehl, mit Fix gruen; 28/28 Erinnerungs-Tests, tsc, biome sauber.
|
||||||
|
- Andere Dialoge mit `[onClose]`-Fokus (Widget-Katalog, Bilderrahmen-Lightbox) fokussieren nur den Dialog ohne Eingabefelder – nicht betroffen.
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-hbi
|
||||||
|
description: "Favoriten: Logo fuer per JavaScript gesetzte Symbole"
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Favoriten: Logo fuer per JavaScript gesetzte Symbole
|
||||||
|
|
||||||
|
**Befund:** https://www.hosteurope.de/ liefert im HTML nur `<link rel="icon" href="data:;base64,=">`; das echte Symbol (img1.wsimg.com/.../HostEurope.png) setzt erst JavaScript. `/favicon.ico`, `/apple-touch-icon.png`, `/favicon.svg` antworten 200 mit text/html. Die serverseitige Suche faellt auf `/favicon.ico` zurueck, der Abruf scheitert (kein Bild) → Buchstabe.
|
||||||
|
|
||||||
|
## Task 1
|
||||||
|
- `IconDiscoveryService.fetchPublicServiceIconBytes(pageUrl)`: DuckDuckGo-Symboldienst (`icons.duckduckgo.com/ip3/<host>.ico`), NUR wenn die Seite oeffentlich ist (isPublicHttpUrl) — interne Hostnamen verlassen das Haus nicht; 404 fuer Unbekanntes → wirft → Buchstabe bleibt.
|
||||||
|
- `FavoritesService.getIconBytes`: scheitert das gespeicherte Symbol, einmal den Dienst fragen, sonst 502 wie bisher. Repariert auch bestehende Favoriten ohne Neuanlage.
|
||||||
|
- Tests in beiden Specs; CHANGELOG.
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-hbi
|
||||||
|
status: complete
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
- Rueckfall auf den oeffentlichen Symbol-Dienst beim Ausliefern (`getIconBytes`), nur fuer oeffentliche Seiten.
|
||||||
|
- 5 neue Tests (Dienst-URL, interne Seite fragt nicht, 404 wirft, Service nutzt Rueckfall / nicht bei Erfolg); 111/111 Favoriten-Tests, tsc, biome-Stand unveraendert.
|
||||||
|
- Lokal im Browser nachgewiesen: Favorit https://www.hosteurope.de/ zeigt das gruene H-Logo (32x32 ueber /api-proxy/favorites/<id>/icon).
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-l4q
|
||||||
|
description: "Zertifikatsmodul: Paket hochladen, Uebersicht, Download in jedem Format"
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Zertifikatsmodul: Paket hochladen, Uebersicht, Download in jedem Format
|
||||||
|
|
||||||
|
**Auftrag (User):** Testdatei = ZIP vom Aussteller (pem mit Server+Zwischen, key, csr, pfx mit unbekanntem Passwort, .dnstxtrecord). Nach dem Hochladen soll angezeigt werden, welches Zertifikat was ist, darunter jedes Zertifikat in jedem Format herunterladbar.
|
||||||
|
|
||||||
|
**Befund vorher:** Modul nimmt nur EINE Datei; .key/.csr/.zip nicht waehlbar; keine Uebersicht; Labels teils englisch; Texte duzen.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. API `cert-bundle.ts`: `analyzeBundle` (Dateien + ZIP mit Grenzen vor dem Entpacken; PEM/DER/PFX/P7B; Duplikate per SHA-256/Modulus; Schluessel/CSR-Zuordnung; Kette) und `exportBundleItem` (crt, cer, fullchain, p7b, pfx inkl. Schluessel+Kette; key PKCS#8/PKCS#1/DER; csr PEM/DER). Endpunkte POST analyze / export.
|
||||||
|
2. Web: Reiter „Übersicht“ (Standard), Mehrfach-Ablage, Karten je Teil mit Erklaerung, Status, Zuordnung, Download-Knoepfen, PFX-Passwort; geschuetzte PFX entsperren. Texte de/en, Sie-Form.
|
||||||
|
3. Desktop: blob:/data:-Downloads in der App speichern (Downloads-Ordner) + Meldung; vorher landete der Klick als „blob-Link“ im System-Browser (VM gemessen).
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-l4q
|
||||||
|
status: complete
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
- Echte Aussteller-ZIP (nur lokal, nicht im Repo): 4 Teile erkannt (Server, Zwischen, Schluessel→Server, CSR→Server), PFX als geschuetzt gemeldet, .dnstxtrecord als nicht verwendet; alle 15 Exporte mit OpenSSL gueltig (PFX mit 3 Bloecken, Schluessel-Modulus passt).
|
||||||
|
- Tests: API cert-bundle.spec 11 (selbst erzeugte PKI), Web OverviewTab.test 5 + Seitentest angepasst; gesamt Web 1189, API 1686, Rust 65 gruen; tsc/clippy/rustfmt sauber.
|
||||||
|
- Browser lokal: Uebersicht + Downloads (fullchain, pfx, rsa.key, csr.der) geprueft.
|
||||||
|
- Desktop-Client (VM, lokal): Upload/Anzeige ok; Download zeigte „blob-Link“-Fehler → Client-Fix (on_download speichert blob:/data: selbst, Meldung danach). Nachweis auf VM folgt mit neuem Client-Build.
|
||||||
@@ -6,6 +6,26 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|
||||||
|
- Zertifikat-Manager: Neuer Reiter „Übersicht“. Ziehen Sie alle Dateien, die Sie vom Zertifikatsaussteller bekommen haben, auf einmal hinein – gern auch direkt die ZIP-Datei. Tessera zeigt, was jede Datei ist (Serverzertifikat, Zwischenzertifikat, Stammzertifikat, privater Schlüssel, Zertifikatsanfrage), wofür sie gebraucht wird, wie lange sie gültig ist und was zusammengehört. Unter jedem Teil können Sie es in jedem passenden Format herunterladen: Zertifikate als PEM (.crt), DER (.cer), mit Kette, PKCS#7 (.p7b) oder als PFX mit Schlüssel und Kette; den Schlüssel als PEM, RSA-PEM oder DER; die Anfrage als PEM oder DER. Passwortgeschützte PFX-Dateien lassen sich mit dem Passwort entsperren.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Desktop-App: Downloads, die Tessera erst im Fenster erstellt (zum Beispiel im Zertifikat-Manager), werden jetzt im Ordner „Downloads“ gespeichert; eine Meldung nennt den Dateinamen. Bisher öffnete Windows nur den Hinweis „Holen Sie sich eine App, um diesen ‚blob‘-Link zu öffnen“. Dafür ist die neue Version der Desktop-App nötig.
|
||||||
|
- Zertifikat-Manager: Die Texte sprechen Sie jetzt durchgehend mit „Sie“ an.
|
||||||
|
|
||||||
|
## 1.9.1 – 2026-10-01
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Desktop-App: Favoriten und andere Links, die sich in einem neuen Fenster öffnen (etwa „In neuem Tab öffnen“ oder Quellen im Ausschreibungs-Radar), öffnen sich jetzt in Ihrem normalen Browser. Bisher passierte beim Klick in der Desktop-App nichts.
|
||||||
|
- Erinnerungen: Beim Schreiben der Beschreibung springt der Cursor nicht mehr in die Titelzeile zurück. Bisher passierte das alle paar Sekunden, weil sich die Kachel regelmäßig neu aufbaut.
|
||||||
|
- Favoriten: Auch Seiten, die ihr Logo erst beim Laden im Browser setzen (etwa Host Europe), zeigen jetzt ihr Logo statt nur des Anfangsbuchstabens. Findet Tessera auf der Seite selbst kein Logo, fragt es bei öffentlichen Adressen einen Logo-Dienst; interne Adressen werden dabei nie weitergegeben. Das gilt auch für bereits angelegte Favoriten.
|
||||||
|
|
||||||
|
## 1.9.0 – 2026-09-30
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Verwaltung: Eigene Vorlage für die Willkommensmail unter Administrator → Willkommensmail. Betreff, Überschrift, Einleitung, Abschluss und der Anmeldehinweis (getrennt für Verzeichnis- und lokale Konten) lassen sich anpassen, mit Platzhaltern wie {{vorname}}, {{benutzername}}, {{adresse}} oder {{firma}} (eine Tabelle auf der Seite erklärt sie). Die Seite zeigt eine Live-Vorschau der echten Mail und schickt auf Wunsch eine Testmail an Ihre Adresse; „Auf Standard zurücksetzen“ stellt die mitgelieferten Texte wieder her. Logo, Zugangsdaten und Knöpfe fügt Tessera immer selbst ein.
|
||||||
- Benutzerverwaltung: Willkommensmail. Über das Briefsymbol in der Benutzerliste schicken Sie einem Benutzer eine gestaltete Willkommensmail mit Tessera-Logo, Adresse, Benutzername und einem Knopf „Zu Tessera“. Konten aus dem Verzeichnis erhalten den Hinweis auf ihr Windows-Passwort, lokale Konten einen Link „Passwort festlegen“ (7 Tage gültig) – ein Passwort steht nie in der Mail. Die Liste zeigt, wann die Mail zuletzt ging.
|
- Benutzerverwaltung: Willkommensmail. Über das Briefsymbol in der Benutzerliste schicken Sie einem Benutzer eine gestaltete Willkommensmail mit Tessera-Logo, Adresse, Benutzername und einem Knopf „Zu Tessera“. Konten aus dem Verzeichnis erhalten den Hinweis auf ihr Windows-Passwort, lokale Konten einen Link „Passwort festlegen“ (7 Tage gültig) – ein Passwort steht nie in der Mail. Die Liste zeigt, wann die Mail zuletzt ging.
|
||||||
- Benutzerverwaltung: Neue Spalte „Letzte Anmeldung“.
|
- Benutzerverwaltung: Neue Spalte „Letzte Anmeldung“.
|
||||||
|
|
||||||
@@ -15,6 +35,8 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|
||||||
|
- Anmeldung: Wer schon angemeldet ist und die Anmeldeseite aufruft, landet jetzt direkt auf dem Dashboard.
|
||||||
|
- Willkommensmail: Logo und Schriftzug erscheinen jetzt in jedem Mailprogramm. Bisher steckten sie in einem Bild; zeigte Outlook es nicht an, blieb nur ein großer schwarzer Kasten. Die Welle darunter ist nur noch ein schmaler Streifen; zeigt ein Mailprogramm sie nicht an (etwa Outlook im Browser), bleibt keine weiße Lücke mehr.
|
||||||
- Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden.
|
- Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden.
|
||||||
|
|
||||||
## 1.8.0 – 2026-09-30
|
## 1.8.0 – 2026-09-30
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -1,28 +1,27 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="300" viewBox="0 0 1200 300">
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="80" viewBox="0 0 1200 80">
|
||||||
<!--
|
<!--
|
||||||
Kopfbild der Tessera-Systemmails (Willkommensmail). Quelle des PNG
|
Wellenstreifen unter dem Kopf der Tessera-Systemmails (Willkommensmail).
|
||||||
daneben (welcome-header.png, 1200x300, angezeigt 600x150); erzeugt mit
|
Quelle des PNG daneben (welcome-header.png, 1200x80, angezeigt 600x40);
|
||||||
`node apps/api/scripts/render-mail-header.mjs`.
|
erzeugt mit `node apps/api/scripts/render-mail-header.mjs`.
|
||||||
|
|
||||||
Motiv: der Dashboard-Hintergrund "Duenen" in seiner dunklen Fassung
|
Seit quick-260930 (Rueckmeldung des Nutzers: in Outlook "ein riesiger
|
||||||
(apps/web/src/lib/dashboard-background.ts, dunes('#1a1c20', '#23262b',
|
schwarzer Fleck, kein Logo") steckt KEIN Logo und KEIN Text mehr im Bild:
|
||||||
'#2c3036', '#363a41')), auf das flache Mail-Format 4:1 umgelegt, mit
|
Bildmarke und Schriftzug stehen als HTML im Mailkopf und erscheinen immer.
|
||||||
einer feinen gelben Wellenlinie als Akzent; links die Bildmarke
|
Dieses Bild ist nur noch Schmuck: oben die Kopffarbe, darunter die
|
||||||
(apps/web/src/app/icon.svg, Grundplatte mit heller Kontur wie auf der
|
Duenen-Wellen des Dashboard-Hintergrunds (dunkle Fassung,
|
||||||
dunklen App-Leiste) und der Schriftzug.
|
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="300" fill="#1a1c20"/>
|
<rect width="1200" height="80" fill="#ffffff"/>
|
||||||
<path d="M0 176 C230 136 470 222 700 190 S1060 118 1200 160 L1200 300 L0 300Z" fill="#23262b"/>
|
<!-- Flaechen von unten nach oben uebereinander, jede von oben bis zu ihrer
|
||||||
<path d="M0 176 C230 136 470 222 700 190 S1060 118 1200 160" fill="none" stroke="#ffed00" stroke-opacity="0.55" stroke-width="3"/>
|
Wellenlinie: so teilen sich benachbarte Baender dieselbe Kante, ohne
|
||||||
<path d="M0 230 C270 196 500 266 780 232 S1090 190 1200 214 L1200 300 L0 300Z" fill="#2c3036"/>
|
Luecken dazwischen. -->
|
||||||
<path d="M0 272 C300 250 600 296 900 268 S1130 252 1200 260 L1200 300 L0 300Z" fill="#363a41"/>
|
<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"/>
|
||||||
<g transform="translate(60 40) scale(1.5)">
|
<path d="M0 0 V50 C250 35.7 420 61.4 640 54.3 S1040 35.7 1200 45.7 V0 Z" fill="#2c3036"/>
|
||||||
<rect x="4" y="4" width="64" height="64" rx="14" fill="#1a1a1a" stroke="#ffffff" stroke-opacity="0.22" stroke-width="2"/>
|
<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"/>
|
||||||
<rect x="16" y="16" width="12" height="12" rx="2.5" fill="#9c9440"/>
|
<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"/>
|
||||||
<rect x="30" y="16" width="12" height="12" rx="2.5" fill="#9c9440"/>
|
|
||||||
<rect x="45" y="15" width="12" height="12" rx="2.5" transform="rotate(12 51 21)" fill="#ffed00"/>
|
|
||||||
<rect x="30" y="30" width="12" height="12" rx="2.5" fill="#9c9440"/>
|
|
||||||
<rect x="30" y="44" width="12" height="12" rx="2.5" fill="#9c9440"/>
|
|
||||||
</g>
|
|
||||||
<text x="192" y="118" fill="#ffffff" font-family="'Segoe UI', Inter, 'Noto Sans', 'DejaVu Sans', sans-serif" font-size="60" font-weight="700" letter-spacing="-1">Tessera</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
-- Willkommensmail: eigene Vorlage je Mandant (Administrator → Willkommensmail).
|
||||||
|
--
|
||||||
|
-- Zweck: neue Tabelle "WelcomeMailTemplate". Ein Administrator kann Betreff,
|
||||||
|
-- Ueberschrift, Einleitung und Abschlusstext der Willkommensmail anpassen
|
||||||
|
-- (mit Platzhaltern wie {{name}}). Hoechstens EINE Zeile je Mandant
|
||||||
|
-- ("tenantId" eindeutig); "Auf Standard zuruecksetzen" loescht die Zeile, dann
|
||||||
|
-- gelten wieder die Standardtexte aus dem Code. Die festen Bausteine der Mail
|
||||||
|
-- (Kopf, Zugangsdaten, Anmeldehinweis, Knoepfe, Fusszeile) stehen NICHT in
|
||||||
|
-- der Tabelle. "updatedBy" haelt den Benutzernamen des letzten Bearbeiters
|
||||||
|
-- als reinen Anzeigetext (keine Relation).
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben (Vorbild 20260929120000_custom_module).
|
||||||
|
--
|
||||||
|
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
|
||||||
|
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
|
||||||
|
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
|
||||||
|
-- Vorlage ist Verwaltungsdatum des Mandanten, nicht persoenliches Datum eines
|
||||||
|
-- einzelnen Benutzers.
|
||||||
|
--
|
||||||
|
-- BEWUSST KEINE `system_read_policy`: gelesen wird nur beim Versand einer
|
||||||
|
-- Willkommensmail, und zwar gebunden an den Mandanten des Zielbenutzers
|
||||||
|
-- (`forTenant(prisma, tenantId)`); es gibt keinen Hintergrunddienst, der die
|
||||||
|
-- Vorlagen ueber alle Mandanten liest.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||||
|
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||||
|
-- tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||||
|
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||||
|
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||||
|
|
||||||
|
CREATE TABLE "WelcomeMailTemplate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"heading" TEXT NOT NULL,
|
||||||
|
"intro" TEXT NOT NULL,
|
||||||
|
"closing" TEXT NOT NULL,
|
||||||
|
"updatedBy" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WelcomeMailTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "WelcomeMailTemplate_tenantId_key" ON "WelcomeMailTemplate"("tenantId");
|
||||||
|
|
||||||
|
ALTER TABLE "WelcomeMailTemplate" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "WelcomeMailTemplate" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "WelcomeMailTemplate"
|
||||||
|
USING ("tenantId" = current_tenant_id());
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Willkommensmail: Anmeldehinweis je Kontoart als Teil der eigenen Vorlage.
|
||||||
|
--
|
||||||
|
-- Zweck: zwei neue Spalten in "WelcomeMailTemplate" —
|
||||||
|
-- "loginHintDirectory" (Hinweis fuer verzeichnisgefuehrte Konten, Standard
|
||||||
|
-- "Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten
|
||||||
|
-- Windows-Passwort an.") und "loginHintLocal" (Hinweis vor dem Knopf
|
||||||
|
-- "Passwort festlegen" fuer lokale Konten).
|
||||||
|
--
|
||||||
|
-- Bewusst NULLABLE ohne Default: bestehende Vorlagen behalten NULL, und
|
||||||
|
-- WelcomeMailTemplateService setzt dafuer den Standardtext aus
|
||||||
|
-- @tessera/shared ein. So steht der Standardtext nur an EINER Stelle im
|
||||||
|
-- Code und nicht zusaetzlich in der Datenbank.
|
||||||
|
--
|
||||||
|
-- Zeilenschutz: unveraendert (tenant_isolation_policy der Tabelle gilt fuer
|
||||||
|
-- die neuen Spalten mit).
|
||||||
|
|
||||||
|
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintDirectory" TEXT;
|
||||||
|
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintLocal" TEXT;
|
||||||
@@ -767,3 +767,24 @@ model Reminder {
|
|||||||
@@index([tenantId, userId, dueAt])
|
@@index([tenantId, userId, dueAt])
|
||||||
@@index([dueAt])
|
@@index([dueAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
|
||||||
|
// hoechstens eine je Mandant; fehlt sie, gelten die Standardtexte aus
|
||||||
|
// @tessera/shared (DEFAULT_WELCOME_MAIL_TEXTS). Nur die sechs Texte —
|
||||||
|
// Kopf, Zugangsdaten und Knoepfe bleiben fest im Code.
|
||||||
|
model WelcomeMailTemplate {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @unique
|
||||||
|
subject String
|
||||||
|
heading String
|
||||||
|
intro String
|
||||||
|
// Anmeldehinweise je Kontoart (Migration 20260930170000); NULL in einer
|
||||||
|
// aelteren Vorlage = Standardtext aus @tessera/shared
|
||||||
|
loginHintDirectory String?
|
||||||
|
loginHintLocal String?
|
||||||
|
closing String
|
||||||
|
// Benutzername des Administrators, der zuletzt gespeichert hat (Anzeige)
|
||||||
|
updatedBy String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
||||||
* (apps/api/assets/mail/welcome-header.png, 1200x300 fuer hochaufloesende
|
* (apps/api/assets/mail/welcome-header.png, 1200x80 fuer hochaufloesende
|
||||||
* Bildschirme, in der Mail 600x150 angezeigt) aus der daneben liegenden
|
* Bildschirme, in der Mail 600x40 angezeigt) aus der daneben liegenden
|
||||||
* Quelle welcome-header.svg.
|
* Quelle welcome-header.svg.
|
||||||
*
|
*
|
||||||
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
||||||
@@ -33,7 +33,7 @@ const sharp = createRequire(nextPkg)('sharp');
|
|||||||
|
|
||||||
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
||||||
const png = await sharp(svg, { density: 72 })
|
const png = await sharp(svg, { density: 72 })
|
||||||
.resize(1200, 300)
|
.resize(1200, 80)
|
||||||
.png({ compressionLevel: 9, palette: false })
|
.png({ compressionLevel: 9, palette: false })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
import * as forge from 'node-forge';
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { analyzeBundle, exportBundleItem, safeBaseName } from './cert-bundle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cert-bundle.spec (quick-261001-l4q) — Zertifikatspaket wie vom Aussteller:
|
||||||
|
* Stamm -> Zwischen -> Server, dazu Schluessel, CSR und PFX, als ZIP.
|
||||||
|
* Alles hier erzeugt (keine echten Kundendaten im Repo).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Pki {
|
||||||
|
rootPem: string;
|
||||||
|
interPem: string;
|
||||||
|
leafPem: string;
|
||||||
|
keyPem: string;
|
||||||
|
csrPem: string;
|
||||||
|
pfx: Buffer;
|
||||||
|
leafModulus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pki: Pki;
|
||||||
|
|
||||||
|
function makeCert(
|
||||||
|
subjectCn: string,
|
||||||
|
pub: forge.pki.PublicKey,
|
||||||
|
signer: forge.pki.PrivateKey,
|
||||||
|
issuer: forge.pki.CertificateField[] | null,
|
||||||
|
ca: boolean,
|
||||||
|
serial: string,
|
||||||
|
): forge.pki.Certificate {
|
||||||
|
const cert = forge.pki.createCertificate();
|
||||||
|
cert.publicKey = pub;
|
||||||
|
cert.serialNumber = serial;
|
||||||
|
cert.validity.notBefore = new Date(Date.now() - 86_400_000);
|
||||||
|
cert.validity.notAfter = new Date(Date.now() + 90 * 86_400_000);
|
||||||
|
const subject = [{ name: 'commonName', value: subjectCn }];
|
||||||
|
cert.setSubject(subject);
|
||||||
|
cert.setIssuer(issuer ?? subject);
|
||||||
|
const ext: object[] = [{ name: 'basicConstraints', cA: ca }];
|
||||||
|
if (!ca) ext.push({ name: 'subjectAltName', altNames: [{ type: 2, value: subjectCn }] });
|
||||||
|
cert.setExtensions(ext);
|
||||||
|
cert.sign(signer as forge.pki.rsa.PrivateKey, forge.md.sha256.create());
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const rootKeys = forge.pki.rsa.generateKeyPair(1024);
|
||||||
|
const interKeys = forge.pki.rsa.generateKeyPair(1024);
|
||||||
|
const leafKeys = forge.pki.rsa.generateKeyPair(1024);
|
||||||
|
const root = makeCert('Test Root CA', rootKeys.publicKey, rootKeys.privateKey, null, true, '01');
|
||||||
|
const inter = makeCert(
|
||||||
|
'Test Intermediate CA',
|
||||||
|
interKeys.publicKey,
|
||||||
|
rootKeys.privateKey,
|
||||||
|
root.subject.attributes,
|
||||||
|
true,
|
||||||
|
'02',
|
||||||
|
);
|
||||||
|
const leaf = makeCert(
|
||||||
|
'www.example.test',
|
||||||
|
leafKeys.publicKey,
|
||||||
|
interKeys.privateKey,
|
||||||
|
inter.subject.attributes,
|
||||||
|
false,
|
||||||
|
'03',
|
||||||
|
);
|
||||||
|
|
||||||
|
const csr = forge.pki.createCertificationRequest();
|
||||||
|
csr.publicKey = leafKeys.publicKey;
|
||||||
|
csr.setSubject([{ name: 'commonName', value: 'www.example.test' }]);
|
||||||
|
csr.sign(leafKeys.privateKey, forge.md.sha256.create());
|
||||||
|
|
||||||
|
const p12 = forge.pkcs12.toPkcs12Asn1(leafKeys.privateKey, [leaf, inter], 'geheim', {
|
||||||
|
algorithm: '3des',
|
||||||
|
});
|
||||||
|
|
||||||
|
pki = {
|
||||||
|
rootPem: forge.pki.certificateToPem(root),
|
||||||
|
interPem: forge.pki.certificateToPem(inter),
|
||||||
|
leafPem: forge.pki.certificateToPem(leaf),
|
||||||
|
keyPem: forge.pki.privateKeyInfoToPem(
|
||||||
|
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(leafKeys.privateKey)),
|
||||||
|
),
|
||||||
|
csrPem: forge.pki.certificationRequestToPem(csr),
|
||||||
|
pfx: Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary'),
|
||||||
|
leafModulus: leafKeys.publicKey.n.toString(16),
|
||||||
|
};
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
function issuerZip(): Buffer {
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile('www.example.test/www.example.test.pem', Buffer.from(pki.leafPem + pki.interPem));
|
||||||
|
zip.addFile('www.example.test/www.example.test.key', Buffer.from(pki.keyPem));
|
||||||
|
zip.addFile('www.example.test/www.example.test.csr', Buffer.from(pki.csrPem));
|
||||||
|
zip.addFile('www.example.test/www.example.test.pfx', pki.pfx);
|
||||||
|
zip.addFile('www.example.test/.dnstxtrecord', Buffer.from('_dnsauth abc123'));
|
||||||
|
return zip.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('analyzeBundle', () => {
|
||||||
|
it('ZIP vom Aussteller: erkennt jedes Teil, fasst PEM/PFX zusammen, ordnet Schluessel und Kette zu', () => {
|
||||||
|
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
|
||||||
|
|
||||||
|
const kinds = r.items.map((i) => (i.kind === 'certificate' ? i.role : i.kind));
|
||||||
|
expect(kinds).toEqual(['end-entity', 'intermediate', 'privateKey', 'csr']);
|
||||||
|
|
||||||
|
const [leaf, inter, key, csr] = r.items;
|
||||||
|
expect(leaf.cn).toBe('www.example.test');
|
||||||
|
expect(leaf.san).toEqual(['www.example.test']);
|
||||||
|
// in PEM UND PFX enthalten -> ein Eintrag mit beiden Quellen
|
||||||
|
expect(leaf.sources.sort()).toEqual(['www.example.test.pem', 'www.example.test.pfx']);
|
||||||
|
expect(leaf.chainIds).toEqual([inter.id]);
|
||||||
|
expect(leaf.matchId).toBe(key.id);
|
||||||
|
expect(key.matchId).toBe(leaf.id);
|
||||||
|
expect(key.sources.sort()).toEqual(['www.example.test.key', 'www.example.test.pfx']);
|
||||||
|
expect(csr.matchId).toBe(leaf.id);
|
||||||
|
expect(csr.cn).toBe('www.example.test');
|
||||||
|
expect(leaf.baseName).toBe('www.example.test');
|
||||||
|
|
||||||
|
expect(r.locked).toEqual([]);
|
||||||
|
expect(r.ignored).toEqual(['.dnstxtrecord']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PFX ohne passendes Passwort wird als gesperrt gemeldet, der Rest trotzdem erkannt', () => {
|
||||||
|
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'falsch');
|
||||||
|
expect(r.locked).toEqual(['www.example.test.pfx']);
|
||||||
|
expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Stammzertifikat wird als root erkannt und an die Kette gehaengt', () => {
|
||||||
|
const r = analyzeBundle(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
originalname: 'chain.pem',
|
||||||
|
buffer: Buffer.from(pki.leafPem + pki.interPem + pki.rootPem),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
expect(r.items.map((i) => i.role)).toEqual(['end-entity', 'intermediate', 'root']);
|
||||||
|
expect(r.items[0].chainIds).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Dateien -> 400', () => {
|
||||||
|
expect(() => analyzeBundle([], '')).toThrow(/No files/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ZIP mit zu vielen Dateien -> 400', () => {
|
||||||
|
const zip = new AdmZip();
|
||||||
|
for (let i = 0; i < 101; i++) zip.addFile(`f${i}.txt`, Buffer.from('x'));
|
||||||
|
expect(() =>
|
||||||
|
analyzeBundle([{ originalname: 'gross.zip', buffer: zip.toBuffer() }], ''),
|
||||||
|
).toThrow(/too many files/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportBundleItem', () => {
|
||||||
|
function bundle() {
|
||||||
|
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
|
||||||
|
const byId = Object.fromEntries(r.items.map((i) => [i.id, i]));
|
||||||
|
return { items: r.items, byId };
|
||||||
|
}
|
||||||
|
const decode = (b64: string) => Buffer.from(b64, 'base64');
|
||||||
|
|
||||||
|
it('Zertifikat in jedem Format liest sich wieder ein', () => {
|
||||||
|
const { items, byId } = bundle();
|
||||||
|
const leaf = items[0];
|
||||||
|
const chain = leaf.chainIds.map((id) => byId[id].pem);
|
||||||
|
const keyPem = byId[leaf.matchId!].pem;
|
||||||
|
const base = { kind: leaf.kind, pem: leaf.pem, baseName: leaf.baseName, chain, keyPem };
|
||||||
|
|
||||||
|
const crt = exportBundleItem({ ...base, format: 'crt' });
|
||||||
|
expect(crt.filename).toBe('www.example.test.crt');
|
||||||
|
expect(
|
||||||
|
forge.pki.certificateFromPem(decode(crt.content).toString()).subject.getField('CN').value,
|
||||||
|
).toBe('www.example.test');
|
||||||
|
|
||||||
|
const cer = exportBundleItem({ ...base, format: 'cer' });
|
||||||
|
expect(cer.filename).toBe('www.example.test.cer');
|
||||||
|
forge.pki.certificateFromAsn1(forge.asn1.fromDer(decode(cer.content).toString('binary')));
|
||||||
|
|
||||||
|
const full = exportBundleItem({ ...base, format: 'fullchain' });
|
||||||
|
expect(
|
||||||
|
decode(full.content)
|
||||||
|
.toString()
|
||||||
|
.match(/BEGIN CERTIFICATE/g),
|
||||||
|
).toHaveLength(2);
|
||||||
|
|
||||||
|
const p7b = exportBundleItem({ ...base, format: 'p7b' });
|
||||||
|
const p7 = forge.pkcs7.messageFromPem(decode(p7b.content).toString());
|
||||||
|
expect('certificates' in p7 ? p7.certificates : []).toHaveLength(2);
|
||||||
|
|
||||||
|
const pfx = exportBundleItem({ ...base, format: 'pfx', password: 'neu' });
|
||||||
|
expect(pfx.filename).toBe('www.example.test.pfx');
|
||||||
|
const p12 = forge.pkcs12.pkcs12FromAsn1(
|
||||||
|
forge.asn1.fromDer(decode(pfx.content).toString('binary')),
|
||||||
|
'neu',
|
||||||
|
);
|
||||||
|
expect(p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag]).toHaveLength(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const keyBag = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[
|
||||||
|
forge.pki.oids.pkcs8ShroudedKeyBag
|
||||||
|
]![0];
|
||||||
|
expect((keyBag.key as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(pki.leafModulus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PFX ohne Passwort -> 400', () => {
|
||||||
|
const { items } = bundle();
|
||||||
|
expect(() =>
|
||||||
|
exportBundleItem({ kind: 'certificate', pem: items[0].pem, format: 'pfx' }),
|
||||||
|
).toThrow(/password is required/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Schluessel als PKCS#8, PKCS#1 und DER', () => {
|
||||||
|
const { items } = bundle();
|
||||||
|
const key = items.find((i) => i.kind === 'privateKey')!;
|
||||||
|
const base = { kind: key.kind, pem: key.pem, baseName: key.baseName };
|
||||||
|
expect(decode(exportBundleItem({ ...base, format: 'key' }).content).toString()).toContain(
|
||||||
|
'BEGIN PRIVATE KEY',
|
||||||
|
);
|
||||||
|
const rsa = exportBundleItem({ ...base, format: 'key-rsa' });
|
||||||
|
expect(rsa.filename).toBe('www.example.test.rsa.key');
|
||||||
|
expect(decode(rsa.content).toString()).toContain('BEGIN RSA PRIVATE KEY');
|
||||||
|
const der = exportBundleItem({ ...base, format: 'key-der' });
|
||||||
|
const info = forge.asn1.fromDer(decode(der.content).toString('binary'));
|
||||||
|
expect((forge.pki.privateKeyFromAsn1(info) as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(
|
||||||
|
pki.leafModulus,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSR als PEM und DER', () => {
|
||||||
|
const { items } = bundle();
|
||||||
|
const csr = items.find((i) => i.kind === 'csr')!;
|
||||||
|
const der = exportBundleItem({
|
||||||
|
kind: 'csr',
|
||||||
|
pem: csr.pem,
|
||||||
|
baseName: csr.baseName,
|
||||||
|
format: 'csr-der',
|
||||||
|
});
|
||||||
|
expect(der.filename).toBe('www.example.test.csr.der');
|
||||||
|
forge.pki.certificationRequestFromAsn1(
|
||||||
|
forge.asn1.fromDer(decode(der.content).toString('binary')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unpassendes Format -> 400', () => {
|
||||||
|
const { items } = bundle();
|
||||||
|
expect(() =>
|
||||||
|
exportBundleItem({ kind: 'csr', pem: items[3].pem, format: 'pfx', password: 'x' }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
exportBundleItem({ kind: 'privateKey', pem: 'kein pem', format: 'key-rsa' }),
|
||||||
|
).toThrow(/Failed to export/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeBaseName', () => {
|
||||||
|
it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => {
|
||||||
|
expect(safeBaseName('*.example.de', 'x')).toBe('wildcard.example.de');
|
||||||
|
expect(safeBaseName('Encryption Everywhere DV TLS CA - G1', 'x')).toBe(
|
||||||
|
'Encryption_Everywhere_DV_TLS_CA_-_G1',
|
||||||
|
);
|
||||||
|
expect(safeBaseName('../../etc/passwd', 'x')).toBe('etc_passwd');
|
||||||
|
expect(safeBaseName('', 'fallback')).toBe('fallback');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
import * as forge from 'node-forge';
|
||||||
|
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zertifikatspaket (quick-261001-l4q): alles, was ein Aussteller liefert —
|
||||||
|
* Zertifikat mit Kette (.pem/.crt/.cer/.p7b), privater Schluessel (.key),
|
||||||
|
* Zertifikatsanfrage (.csr), PFX/P12, gern als ZIP — auf einmal hochladen,
|
||||||
|
* erkennen, was was ist, und jedes Teil in jedem passenden Format
|
||||||
|
* herunterladen.
|
||||||
|
*
|
||||||
|
* Zustandslos wie der Rest des Moduls: `analyzeBundle` gibt je Teil den
|
||||||
|
* kanonischen PEM-Text zurueck, `exportBundleItem` baut daraus die Datei.
|
||||||
|
* Nichts wird gespeichert, Passwoerter werden nie protokolliert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type BundleFile = Pick<UploadedFileLike, 'buffer' | 'originalname'>;
|
||||||
|
|
||||||
|
export type BundleCertRole = 'end-entity' | 'intermediate' | 'root';
|
||||||
|
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
|
||||||
|
|
||||||
|
export interface BundleItem {
|
||||||
|
id: string;
|
||||||
|
kind: BundleItemKind;
|
||||||
|
/** Nur bei Zertifikaten. */
|
||||||
|
role?: BundleCertRole;
|
||||||
|
/** Dateien, in denen dieses Teil gefunden wurde (Duplikate zusammengefasst). */
|
||||||
|
sources: string[];
|
||||||
|
/** Kanonischer PEM-Text — Grundlage fuer jeden Export. */
|
||||||
|
pem: string;
|
||||||
|
/** Vorschlag fuer den Dateinamen ohne Endung, aus dem CN abgeleitet. */
|
||||||
|
baseName: string;
|
||||||
|
cn: string;
|
||||||
|
organization: string;
|
||||||
|
issuerCn: string;
|
||||||
|
notBefore: string | null;
|
||||||
|
notAfter: string | null;
|
||||||
|
isExpired: boolean | null;
|
||||||
|
daysLeft: number | null;
|
||||||
|
san: string[];
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number;
|
||||||
|
serialNumber: string;
|
||||||
|
sha256: string;
|
||||||
|
/** Zertifikat: id des passenden Schluessels; Schluessel/CSR: id des passenden Zertifikats. */
|
||||||
|
matchId: string | null;
|
||||||
|
/** Zertifikat: ids der Kette darueber (Aussteller, dessen Aussteller ...). */
|
||||||
|
chainIds: string[];
|
||||||
|
/** Formate, die `exportBundleItem` fuer dieses Teil liefern kann. */
|
||||||
|
formats: BundleExportFormat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleAnalysis {
|
||||||
|
items: BundleItem[];
|
||||||
|
/** PFX/P12 oder verschluesselte Schluessel, die ohne (richtiges) Passwort nicht lesbar sind. */
|
||||||
|
locked: string[];
|
||||||
|
/** Dateien ohne erkennbares Zertifikat, Schluessel oder CSR. */
|
||||||
|
ignored: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BundleExportFormat =
|
||||||
|
| 'crt'
|
||||||
|
| 'cer'
|
||||||
|
| 'fullchain'
|
||||||
|
| 'p7b'
|
||||||
|
| 'pfx'
|
||||||
|
| 'key'
|
||||||
|
| 'key-rsa'
|
||||||
|
| 'key-der'
|
||||||
|
| 'csr'
|
||||||
|
| 'csr-der';
|
||||||
|
|
||||||
|
export interface BundleExportInput {
|
||||||
|
kind: BundleItemKind;
|
||||||
|
pem: string;
|
||||||
|
format: BundleExportFormat;
|
||||||
|
baseName?: string;
|
||||||
|
/** Zertifikat: PEMs der Kette darueber (fuer Fullchain/P7B/PFX). */
|
||||||
|
chain?: string[];
|
||||||
|
/** Zertifikat: PEM des passenden privaten Schluessels (fuer PFX). */
|
||||||
|
keyPem?: string;
|
||||||
|
/** PFX: Passwort fuer die neue Datei. */
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleExportFile {
|
||||||
|
filename: string;
|
||||||
|
/** Base64 */
|
||||||
|
content: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ZIP_ENTRIES = 100;
|
||||||
|
const MAX_ENTRY_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----[\s\S]+?-----END \1-----/g;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hilfen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function binary(buffer: Buffer): forge.util.ByteStringBuffer {
|
||||||
|
return forge.util.createBuffer(buffer.toString('binary'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: string): string {
|
||||||
|
return Buffer.from(forge.util.bytesToHex(bytes), 'hex').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToBase64(text: string): string {
|
||||||
|
return Buffer.from(text, 'utf-8').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Of(cert: forge.pki.Certificate): string {
|
||||||
|
const md = forge.md.sha256.create();
|
||||||
|
md.update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes());
|
||||||
|
return (md.digest().toHex().match(/.{2}/g) ?? []).join(':').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(name: forge.pki.Certificate['subject'], short: string): string {
|
||||||
|
return (name.getField(short)?.value as string | undefined) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dateiname ohne Pfad und ohne gefaehrliche Zeichen, z. B. „*.example.de“ -> „wildcard.example.de“. */
|
||||||
|
export function safeBaseName(raw: string, fallback: string): string {
|
||||||
|
const cleaned = raw
|
||||||
|
.replace(/^\*\./, 'wildcard.')
|
||||||
|
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||||
|
.replace(/^[._]+/, '')
|
||||||
|
.slice(0, 80);
|
||||||
|
return cleaned || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function certRole(cert: forge.pki.Certificate): BundleCertRole {
|
||||||
|
const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null;
|
||||||
|
if (!bc?.cA) return 'end-entity';
|
||||||
|
return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate';
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicKeyInfo(key: unknown): { keyType: string; keyBits: number; modulus: string } {
|
||||||
|
// node-forge liefert RSA-Schluessel mit `n`; EC-Schluessel kennt es nur
|
||||||
|
// eingeschraenkt (siehe Kommentar in CertManagerService.parseCert).
|
||||||
|
const k = key as { n?: forge.jsbn.BigInteger };
|
||||||
|
if (k?.n) return { keyType: 'RSA', keyBits: k.n.bitLength(), modulus: k.n.toString(16) };
|
||||||
|
return { keyType: 'EC', keyBits: 0, modulus: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanOf(extensions: unknown[] | undefined): string[] {
|
||||||
|
const ext = (extensions ?? []).find((e) => (e as { name?: string }).name === 'subjectAltName') as
|
||||||
|
| { altNames?: { type: number; value?: string; ip?: string }[] }
|
||||||
|
| undefined;
|
||||||
|
return (ext?.altNames ?? []).map((n) =>
|
||||||
|
n.type === 2 ? (n.value ?? '') : `IP:${n.ip ?? n.value ?? ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Einsammeln: Dateien (inkl. ZIP) -> rohe Teile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RawKey {
|
||||||
|
source: string;
|
||||||
|
pem: string;
|
||||||
|
modulus: string;
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawCsr {
|
||||||
|
source: string;
|
||||||
|
pem: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Collected {
|
||||||
|
certs: { source: string; cert: forge.pki.Certificate }[];
|
||||||
|
keys: RawKey[];
|
||||||
|
csrs: RawCsr[];
|
||||||
|
locked: string[];
|
||||||
|
ignored: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandZips(files: BundleFile[]): { name: string; buffer: Buffer }[] {
|
||||||
|
const out: { name: string; buffer: Buffer }[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.originalname.toLowerCase().endsWith('.zip')) {
|
||||||
|
out.push({ name: file.originalname, buffer: file.buffer });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let zip: AdmZip;
|
||||||
|
try {
|
||||||
|
zip = new AdmZip(file.buffer);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(`"${file.originalname}" is not a readable ZIP archive`);
|
||||||
|
}
|
||||||
|
const entries = zip
|
||||||
|
.getEntries()
|
||||||
|
.filter((e) => !e.isDirectory && !e.entryName.startsWith('__MACOSX/'));
|
||||||
|
if (entries.length > MAX_ZIP_ENTRIES) {
|
||||||
|
throw new BadRequestException(`"${file.originalname}" contains too many files`);
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
// Groesse aus dem Kopf pruefen, BEVOR entpackt wird (Zip-Bombe).
|
||||||
|
if (entry.header.size > MAX_ENTRY_BYTES) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`"${entry.entryName}" in "${file.originalname}" is too large`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const name = entry.entryName.split('/').pop() ?? entry.entryName;
|
||||||
|
out.push({ name, buffer: entry.getData() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKey(c: Collected, source: string, privateKey: forge.pki.rsa.PrivateKey): void {
|
||||||
|
const info = publicKeyInfo(privateKey);
|
||||||
|
const pem = forge.pki.privateKeyInfoToPem(
|
||||||
|
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(privateKey)),
|
||||||
|
);
|
||||||
|
c.keys.push({ source, pem, ...info });
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPemText(c: Collected, source: string, text: string, password: string): boolean {
|
||||||
|
let found = false;
|
||||||
|
for (const match of text.matchAll(PEM_BLOCK)) {
|
||||||
|
const [block, type] = match;
|
||||||
|
try {
|
||||||
|
if (type === 'CERTIFICATE' || type === 'TRUSTED CERTIFICATE') {
|
||||||
|
c.certs.push({ source, cert: forge.pki.certificateFromPem(block) });
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'PRIVATE KEY' || type === 'RSA PRIVATE KEY') {
|
||||||
|
const key = forge.pki.privateKeyFromPem(block) as forge.pki.rsa.PrivateKey;
|
||||||
|
addKey(c, source, key);
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'ENCRYPTED PRIVATE KEY') {
|
||||||
|
const key = password ? forge.pki.decryptRsaPrivateKey(block, password) : null;
|
||||||
|
if (key) addKey(c, source, key as forge.pki.rsa.PrivateKey);
|
||||||
|
else c.locked.push(source);
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'EC PRIVATE KEY') {
|
||||||
|
// node-forge kann EC nicht umrechnen — Teil bleibt im Original erhalten.
|
||||||
|
c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 });
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'CERTIFICATE REQUEST' || type === 'NEW CERTIFICATE REQUEST') {
|
||||||
|
c.csrs.push({
|
||||||
|
source,
|
||||||
|
pem: block.replace(/NEW CERTIFICATE REQUEST/g, 'CERTIFICATE REQUEST'),
|
||||||
|
});
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'PKCS7') {
|
||||||
|
const p7 = forge.pkcs7.messageFromPem(block);
|
||||||
|
for (const cert of 'certificates' in p7 ? p7.certificates : [])
|
||||||
|
c.certs.push({ source, cert });
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// PKCS#8 mit EC-Schluessel o. ae.: node-forge kann ihn nicht lesen —
|
||||||
|
// im Original behalten statt zu verwerfen.
|
||||||
|
if (type === 'PRIVATE KEY') {
|
||||||
|
c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 });
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPfx(c: Collected, source: string, buffer: Buffer, password: string): void {
|
||||||
|
let p12: forge.pkcs12.Pkcs12Pfx | null = null;
|
||||||
|
for (const candidate of password ? [password, ''] : ['']) {
|
||||||
|
try {
|
||||||
|
p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(binary(buffer)), candidate);
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// naechstes Passwort versuchen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!p12) {
|
||||||
|
c.locked.push(source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const bag of p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag] ??
|
||||||
|
[]) {
|
||||||
|
if (bag.cert) c.certs.push({ source, cert: bag.cert });
|
||||||
|
}
|
||||||
|
for (const oid of [forge.pki.oids.pkcs8ShroudedKeyBag, forge.pki.oids.keyBag]) {
|
||||||
|
for (const bag of p12.getBags({ bagType: oid })[oid] ?? []) {
|
||||||
|
if (bag.key) addKey(c, source, bag.key as forge.pki.rsa.PrivateKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDer(c: Collected, source: string, buffer: Buffer): boolean {
|
||||||
|
try {
|
||||||
|
const asn1 = forge.asn1.fromDer(binary(buffer));
|
||||||
|
try {
|
||||||
|
c.certs.push({ source, cert: forge.pki.certificateFromAsn1(asn1) });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
/* kein einzelnes Zertifikat */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const p7 = forge.pkcs7.messageFromAsn1(asn1);
|
||||||
|
const certs = 'certificates' in p7 ? p7.certificates : [];
|
||||||
|
for (const cert of certs) c.certs.push({ source, cert });
|
||||||
|
if (certs.length > 0) return true;
|
||||||
|
} catch {
|
||||||
|
/* kein PKCS#7 */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
forge.pki.certificationRequestFromAsn1(asn1);
|
||||||
|
const body = forge.asn1.toDer(asn1).getBytes();
|
||||||
|
c.csrs.push({ source, pem: forge.pem.encode({ type: 'CERTIFICATE REQUEST', body }) });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
/* keine CSR */
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* kein DER */
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(files: BundleFile[], password: string): Collected {
|
||||||
|
const c: Collected = { certs: [], keys: [], csrs: [], locked: [], ignored: [] };
|
||||||
|
for (const { name, buffer } of expandZips(files)) {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (ext === 'pfx' || ext === 'p12') {
|
||||||
|
collectPfx(c, name, buffer, password);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const head = buffer.subarray(0, 4096).toString('latin1');
|
||||||
|
const found = head.includes('-----BEGIN')
|
||||||
|
? collectPemText(c, name, buffer.toString('utf-8'), password)
|
||||||
|
: collectDer(c, name, buffer);
|
||||||
|
if (!found) c.ignored.push(name);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// analyzeBundle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CERT_FORMATS: BundleExportFormat[] = ['crt', 'cer', 'fullchain', 'p7b', 'pfx'];
|
||||||
|
|
||||||
|
export function analyzeBundle(files: BundleFile[], password = ''): BundleAnalysis {
|
||||||
|
if (files.length === 0) throw new BadRequestException('No files provided');
|
||||||
|
const c = collect(files, password);
|
||||||
|
|
||||||
|
// Zertifikate nach Fingerabdruck zusammenfassen (PEM und PFX enthalten oft dieselben).
|
||||||
|
const certMap = new Map<string, { cert: forge.pki.Certificate; sources: Set<string> }>();
|
||||||
|
for (const { source, cert } of c.certs) {
|
||||||
|
const fp = sha256Of(cert);
|
||||||
|
const entry = certMap.get(fp) ?? { cert, sources: new Set<string>() };
|
||||||
|
entry.sources.add(source);
|
||||||
|
certMap.set(fp, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const certItems: BundleItem[] = [...certMap.entries()].map(([fp, { cert, sources }]) => {
|
||||||
|
const info = publicKeyInfo(cert.publicKey);
|
||||||
|
const cn = field(cert.subject, 'CN');
|
||||||
|
const notAfter = cert.validity.notAfter;
|
||||||
|
const role = certRole(cert);
|
||||||
|
return {
|
||||||
|
id: `cert-${fp.replace(/:/g, '').slice(0, 16).toLowerCase()}`,
|
||||||
|
kind: 'certificate',
|
||||||
|
role,
|
||||||
|
sources: [...sources],
|
||||||
|
pem: forge.pki.certificateToPem(cert),
|
||||||
|
baseName: safeBaseName(cn, role === 'end-entity' ? 'zertifikat' : 'ca'),
|
||||||
|
cn,
|
||||||
|
organization: field(cert.subject, 'O'),
|
||||||
|
issuerCn: field(cert.issuer, 'CN'),
|
||||||
|
notBefore: cert.validity.notBefore.toISOString(),
|
||||||
|
notAfter: notAfter.toISOString(),
|
||||||
|
isExpired: notAfter.getTime() < now,
|
||||||
|
daysLeft: Math.ceil((notAfter.getTime() - now) / 86_400_000),
|
||||||
|
san: sanOf(cert.extensions),
|
||||||
|
keyType: info.keyType,
|
||||||
|
keyBits: info.keyBits,
|
||||||
|
serialNumber: cert.serialNumber,
|
||||||
|
sha256: fp,
|
||||||
|
matchId: null,
|
||||||
|
chainIds: [],
|
||||||
|
formats: CERT_FORMATS,
|
||||||
|
// nur intern fuer Kette/Zuordnung, wird unten entfernt
|
||||||
|
_cert: cert,
|
||||||
|
_modulus: info.modulus,
|
||||||
|
} as BundleItem & { _cert: forge.pki.Certificate; _modulus: string };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kette: zu jedem Zertifikat den Aussteller im Paket suchen.
|
||||||
|
type Internal = BundleItem & { _cert: forge.pki.Certificate; _modulus: string };
|
||||||
|
const internals = certItems as Internal[];
|
||||||
|
for (const item of internals) {
|
||||||
|
let current = item._cert;
|
||||||
|
const seen = new Set<string>([item.id]);
|
||||||
|
for (let depth = 0; depth < 10; depth++) {
|
||||||
|
if (current.subject.hash === current.issuer.hash) break;
|
||||||
|
const issuer = internals.find(
|
||||||
|
(o) => !seen.has(o.id) && o._cert.subject.hash === current.issuer.hash,
|
||||||
|
);
|
||||||
|
if (!issuer) break;
|
||||||
|
item.chainIds.push(issuer.id);
|
||||||
|
seen.add(issuer.id);
|
||||||
|
current = issuer._cert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schluessel: Duplikate zusammenfassen, dem Zertifikat zuordnen.
|
||||||
|
const keyMap = new Map<string, { key: RawKey; sources: Set<string> }>();
|
||||||
|
for (const key of c.keys) {
|
||||||
|
const id = key.modulus || key.pem;
|
||||||
|
const entry = keyMap.get(id) ?? { key, sources: new Set<string>() };
|
||||||
|
entry.sources.add(key.source);
|
||||||
|
keyMap.set(id, entry);
|
||||||
|
}
|
||||||
|
const keyItems: BundleItem[] = [...keyMap.values()].map(({ key, sources }, i) => {
|
||||||
|
const cert = key.modulus ? internals.find((o) => o._modulus === key.modulus) : undefined;
|
||||||
|
const id = `key-${i + 1}`;
|
||||||
|
if (cert) cert.matchId = id;
|
||||||
|
const isRsa = key.keyType === 'RSA';
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: 'privateKey',
|
||||||
|
sources: [...sources],
|
||||||
|
pem: key.pem,
|
||||||
|
baseName:
|
||||||
|
cert?.baseName ??
|
||||||
|
safeBaseName(
|
||||||
|
sources
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.value?.replace(/\.[^.]+$/, '') ?? '',
|
||||||
|
'schluessel',
|
||||||
|
),
|
||||||
|
cn: cert?.cn ?? '',
|
||||||
|
organization: '',
|
||||||
|
issuerCn: '',
|
||||||
|
notBefore: null,
|
||||||
|
notAfter: null,
|
||||||
|
isExpired: null,
|
||||||
|
daysLeft: null,
|
||||||
|
san: [],
|
||||||
|
keyType: key.keyType,
|
||||||
|
keyBits: key.keyBits,
|
||||||
|
serialNumber: '',
|
||||||
|
sha256: '',
|
||||||
|
matchId: cert?.id ?? null,
|
||||||
|
chainIds: [],
|
||||||
|
formats: isRsa ? ['key', 'key-rsa', 'key-der'] : ['key'],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSRs: Duplikate zusammenfassen, Details lesen, dem Zertifikat zuordnen.
|
||||||
|
const csrMap = new Map<string, { pem: string; sources: Set<string> }>();
|
||||||
|
for (const csr of c.csrs) {
|
||||||
|
const norm = csr.pem.replace(/\s+/g, '');
|
||||||
|
const entry = csrMap.get(norm) ?? { pem: csr.pem, sources: new Set<string>() };
|
||||||
|
entry.sources.add(csr.source);
|
||||||
|
csrMap.set(norm, entry);
|
||||||
|
}
|
||||||
|
const csrItems: BundleItem[] = [...csrMap.values()].map(({ pem, sources }, i) => {
|
||||||
|
let cn = '';
|
||||||
|
let organization = '';
|
||||||
|
let info = { keyType: '', keyBits: 0, modulus: '' };
|
||||||
|
let san: string[] = [];
|
||||||
|
try {
|
||||||
|
const csr = forge.pki.certificationRequestFromPem(pem);
|
||||||
|
cn = field(csr.subject as forge.pki.Certificate['subject'], 'CN');
|
||||||
|
organization = field(csr.subject as forge.pki.Certificate['subject'], 'O');
|
||||||
|
info = publicKeyInfo(csr.publicKey);
|
||||||
|
const ext = csr.getAttribute({ name: 'extensionRequest' }) as {
|
||||||
|
extensions?: unknown[];
|
||||||
|
} | null;
|
||||||
|
san = sanOf(ext?.extensions);
|
||||||
|
} catch {
|
||||||
|
// EC-CSR: node-forge liest sie nicht — Teil bleibt trotzdem herunterladbar.
|
||||||
|
}
|
||||||
|
const cert = info.modulus ? internals.find((o) => o._modulus === info.modulus) : undefined;
|
||||||
|
return {
|
||||||
|
id: `csr-${i + 1}`,
|
||||||
|
kind: 'csr',
|
||||||
|
sources: [...sources],
|
||||||
|
pem,
|
||||||
|
baseName: cert?.baseName ?? safeBaseName(cn, 'anfrage'),
|
||||||
|
cn,
|
||||||
|
organization,
|
||||||
|
issuerCn: '',
|
||||||
|
notBefore: null,
|
||||||
|
notAfter: null,
|
||||||
|
isExpired: null,
|
||||||
|
daysLeft: null,
|
||||||
|
san,
|
||||||
|
keyType: info.keyType,
|
||||||
|
keyBits: info.keyBits,
|
||||||
|
serialNumber: '',
|
||||||
|
sha256: '',
|
||||||
|
matchId: cert?.id ?? null,
|
||||||
|
chainIds: [],
|
||||||
|
formats: ['csr', 'csr-der'],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reihenfolge: Serverzertifikat(e), Zwischen-, Stammzertifikate, Schluessel, CSR.
|
||||||
|
const roleOrder: Record<BundleCertRole, number> = { 'end-entity': 0, intermediate: 1, root: 2 };
|
||||||
|
internals.sort(
|
||||||
|
(a, b) =>
|
||||||
|
roleOrder[a.role ?? 'end-entity'] - roleOrder[b.role ?? 'end-entity'] ||
|
||||||
|
b.chainIds.length - a.chainIds.length,
|
||||||
|
);
|
||||||
|
const certsClean: BundleItem[] = internals.map(({ _cert, _modulus, ...rest }) => rest);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: [...certsClean, ...keyItems, ...csrItems],
|
||||||
|
locked: [...new Set(c.locked)],
|
||||||
|
ignored: [...new Set(c.ignored)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// exportBundleItem
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
pem: 'application/x-pem-file',
|
||||||
|
der: 'application/x-x509-ca-cert',
|
||||||
|
p7b: 'application/x-pkcs7-certificates',
|
||||||
|
pfx: 'application/x-pkcs12',
|
||||||
|
key: 'application/x-pem-file',
|
||||||
|
octet: 'application/octet-stream',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function pemBody(pem: string): string {
|
||||||
|
const [msg] = forge.pem.decode(pem);
|
||||||
|
if (!msg) throw new Error('no PEM block');
|
||||||
|
return msg.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportBundleItem(input: BundleExportInput): BundleExportFile {
|
||||||
|
const { kind, pem, format, chain = [], keyPem, password } = input;
|
||||||
|
const base = safeBaseName(
|
||||||
|
input.baseName ?? '',
|
||||||
|
kind === 'csr' ? 'anfrage' : kind === 'privateKey' ? 'schluessel' : 'zertifikat',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pem || typeof pem !== 'string') throw new BadRequestException('No PEM provided');
|
||||||
|
if (format === 'pfx' && (!password || password.trim() === '')) {
|
||||||
|
throw new BadRequestException('A password is required for PFX output');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (kind === 'certificate') {
|
||||||
|
const cert = forge.pki.certificateFromPem(pem);
|
||||||
|
const chainCerts = chain.map((p) => forge.pki.certificateFromPem(p));
|
||||||
|
switch (format) {
|
||||||
|
case 'crt':
|
||||||
|
return {
|
||||||
|
filename: `${base}.crt`,
|
||||||
|
content: textToBase64(forge.pki.certificateToPem(cert)),
|
||||||
|
mimeType: MIME.pem,
|
||||||
|
};
|
||||||
|
case 'cer':
|
||||||
|
return {
|
||||||
|
filename: `${base}.cer`,
|
||||||
|
content: bytesToBase64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()),
|
||||||
|
mimeType: MIME.der,
|
||||||
|
};
|
||||||
|
case 'fullchain': {
|
||||||
|
const text = [cert, ...chainCerts].map((x) => forge.pki.certificateToPem(x)).join('');
|
||||||
|
return {
|
||||||
|
filename: `${base}-fullchain.pem`,
|
||||||
|
content: textToBase64(text),
|
||||||
|
mimeType: MIME.pem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'p7b': {
|
||||||
|
const p7 = forge.pkcs7.createSignedData();
|
||||||
|
for (const x of [cert, ...chainCerts]) p7.addCertificate(x);
|
||||||
|
const text = forge.pem.encode({
|
||||||
|
type: 'PKCS7',
|
||||||
|
body: forge.asn1.toDer(p7.toAsn1()).getBytes(),
|
||||||
|
});
|
||||||
|
return { filename: `${base}.p7b`, content: textToBase64(text), mimeType: MIME.p7b };
|
||||||
|
}
|
||||||
|
case 'pfx': {
|
||||||
|
const key = keyPem
|
||||||
|
? (forge.pki.privateKeyFromPem(keyPem) as forge.pki.rsa.PrivateKey)
|
||||||
|
: null;
|
||||||
|
const p12 = forge.pkcs12.toPkcs12Asn1(
|
||||||
|
// null = reines Zertifikatsbuendel ohne Schluessel (siehe
|
||||||
|
// CertManagerService.mergeCerts).
|
||||||
|
key,
|
||||||
|
[cert, ...chainCerts],
|
||||||
|
password as string, // oben geprueft: PFX verlangt ein Passwort
|
||||||
|
{ algorithm: '3des', friendlyName: input.baseName || undefined },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
filename: `${base}.pfx`,
|
||||||
|
content: bytesToBase64(forge.asn1.toDer(p12).getBytes()),
|
||||||
|
mimeType: MIME.pfx,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (kind === 'privateKey') {
|
||||||
|
switch (format) {
|
||||||
|
case 'key':
|
||||||
|
return {
|
||||||
|
filename: `${base}.key`,
|
||||||
|
content: textToBase64(`${pem.trim()}\n`),
|
||||||
|
mimeType: MIME.key,
|
||||||
|
};
|
||||||
|
case 'key-rsa': {
|
||||||
|
const key = forge.pki.privateKeyFromPem(pem);
|
||||||
|
return {
|
||||||
|
filename: `${base}.rsa.key`,
|
||||||
|
content: textToBase64(forge.pki.privateKeyToPem(key)),
|
||||||
|
mimeType: MIME.key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'key-der': {
|
||||||
|
const key = forge.pki.privateKeyFromPem(pem);
|
||||||
|
const info = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(key));
|
||||||
|
return {
|
||||||
|
filename: `${base}.key.der`,
|
||||||
|
content: bytesToBase64(forge.asn1.toDer(info).getBytes()),
|
||||||
|
mimeType: MIME.octet,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (kind === 'csr') {
|
||||||
|
switch (format) {
|
||||||
|
case 'csr':
|
||||||
|
return {
|
||||||
|
filename: `${base}.csr`,
|
||||||
|
content: textToBase64(`${pem.trim()}\n`),
|
||||||
|
mimeType: MIME.pem,
|
||||||
|
};
|
||||||
|
case 'csr-der':
|
||||||
|
return {
|
||||||
|
filename: `${base}.csr.der`,
|
||||||
|
content: bytesToBase64(pemBody(pem)),
|
||||||
|
mimeType: MIME.octet,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Passwort und Schluessel nie protokollieren oder zurueckgeben.
|
||||||
|
throw new BadRequestException(`Failed to export ${kind} as ${format}`);
|
||||||
|
}
|
||||||
|
throw new BadRequestException(`Unsupported format "${format}" for ${kind}`);
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||||
import { UseModule } from '../module-registry/module.guard';
|
import { UseModule } from '../module-registry/module.guard';
|
||||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||||
|
import {
|
||||||
|
analyzeBundle,
|
||||||
|
type BundleExportFormat,
|
||||||
|
type BundleItemKind,
|
||||||
|
exportBundleItem,
|
||||||
|
} from './cert-bundle';
|
||||||
import { CertManagerService } from './cert-manager.service';
|
import { CertManagerService } from './cert-manager.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,4 +124,50 @@ export class CertManagerController {
|
|||||||
}
|
}
|
||||||
return this.certManagerService.convertCert({ file, pemText, targetFormat, password });
|
return this.certManagerService.convertCert({ file, pemText, targetFormat, password });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /modules/cert-manager/analyze (quick-261001-l4q)
|
||||||
|
* Zertifikatspaket: mehrere Dateien oder ZIP hochladen, jedes Teil erkennen
|
||||||
|
* (Server-/Zwischen-/Stammzertifikat, privater Schluessel, CSR), Duplikate
|
||||||
|
* zusammenfassen, Schluessel und Kette zuordnen.
|
||||||
|
*
|
||||||
|
* T-09-03: 20 Dateien, je 5 MB; ZIP-Inhalt zusaetzlich begrenzt (cert-bundle.ts).
|
||||||
|
* T-09-02: password is never passed to the logger
|
||||||
|
*/
|
||||||
|
@Post('analyze')
|
||||||
|
@UseInterceptors(
|
||||||
|
FilesInterceptor('files', 20, {
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async analyze(
|
||||||
|
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
||||||
|
@Body('password') password?: string,
|
||||||
|
) {
|
||||||
|
return analyzeBundle(files ?? [], password ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /modules/cert-manager/export (quick-261001-l4q)
|
||||||
|
* Ein Teil aus `analyze` (PEM) in das gewuenschte Format bringen.
|
||||||
|
* JSON-Body; PFX verlangt ein Passwort fuer die neue Datei.
|
||||||
|
*/
|
||||||
|
@Post('export')
|
||||||
|
async export(
|
||||||
|
@Body('kind') kind: BundleItemKind,
|
||||||
|
@Body('pem') pem: string,
|
||||||
|
@Body('format') format: BundleExportFormat,
|
||||||
|
@Body('baseName') baseName?: string,
|
||||||
|
@Body('chain') chain?: string[],
|
||||||
|
@Body('keyPem') keyPem?: string,
|
||||||
|
@Body('password') password?: string,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
chain !== undefined &&
|
||||||
|
(!Array.isArray(chain) || chain.some((c) => typeof c !== 'string'))
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('chain must be a list of PEM strings');
|
||||||
|
}
|
||||||
|
return exportBundleItem({ kind, pem, format, baseName, chain, keyPem, password });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,11 @@ function expectBoundCall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeIconDiscovery(
|
function makeIconDiscovery(
|
||||||
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {},
|
overrides: Partial<{
|
||||||
|
discoverFavoriteIconUrl: any;
|
||||||
|
fetchIconBytes: any;
|
||||||
|
fetchPublicServiceIconBytes: any;
|
||||||
|
}> = {},
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
discoverFavoriteIconUrl:
|
discoverFavoriteIconUrl:
|
||||||
@@ -229,6 +233,11 @@ function makeIconDiscovery(
|
|||||||
fetchIconBytes:
|
fetchIconBytes:
|
||||||
overrides.fetchIconBytes ??
|
overrides.fetchIconBytes ??
|
||||||
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
|
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
|
||||||
|
fetchPublicServiceIconBytes:
|
||||||
|
overrides.fetchPublicServiceIconBytes ??
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw new Error('icon service: unknown');
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +520,35 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('quick-261001-hbi: gespeichertes Symbol scheitert -> Symbol-Dienst mit der Seiten-URL', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery({
|
||||||
|
fetchIconBytes: vi.fn(async () => {
|
||||||
|
throw new Error('not an image');
|
||||||
|
}),
|
||||||
|
fetchPublicServiceIconBytes: vi.fn(async () => ({
|
||||||
|
contentType: 'image/png',
|
||||||
|
body: Buffer.from('ddg'),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(iconDiscovery.fetchPublicServiceIconBytes).toHaveBeenCalledWith(baseRow.url);
|
||||||
|
expect(result.body).toEqual(Buffer.from('ddg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quick-261001-hbi: gespeichertes Symbol klappt -> Symbol-Dienst wird nicht gefragt', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(iconDiscovery.fetchPublicServiceIconBytes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
|
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
|
||||||
const prisma = makeFakePrisma([baseRow]);
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
const iconDiscovery = makeIconDiscovery({
|
const iconDiscovery = makeIconDiscovery({
|
||||||
|
|||||||
@@ -497,7 +497,9 @@ export class FavoritesService {
|
|||||||
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
||||||
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
||||||
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
||||||
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
|
* timeout, non-image, or SSRF-blocked) AND the public icon service fallback
|
||||||
|
* (quick-261001-hbi, public pages only) has no icon either -- never returns
|
||||||
|
* a placeholder image.
|
||||||
*/
|
*/
|
||||||
async getIconBytes(
|
async getIconBytes(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -536,7 +538,14 @@ export class FavoritesService {
|
|||||||
try {
|
try {
|
||||||
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
|
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
// quick-261001-hbi: Seite liefert kein abrufbares Symbol (z. B. per
|
||||||
|
// JavaScript gesetzt) -- einmal beim oeffentlichen Symbol-Dienst fragen,
|
||||||
|
// nur fuer oeffentlich erreichbare Seiten.
|
||||||
|
try {
|
||||||
|
return await this.iconDiscovery.fetchPublicServiceIconBytes(link.url);
|
||||||
|
} catch {
|
||||||
|
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,57 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('IconDiscoveryService.fetchPublicServiceIconBytes (quick-261001-hbi)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fragt fuer eine oeffentliche Seite den Symbol-Dienst mit dem Hostnamen', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png' }));
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
const service = new IconDiscoveryService();
|
||||||
|
const result = await service.fetchPublicServiceIconBytes('http://8.8.8.8/start');
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchSpy.mock.calls[0][0]).toBe('https://icons.duckduckgo.com/ip3/8.8.8.8.ico');
|
||||||
|
expect(result.contentType).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fragt fuer eine interne Seite NICHT (Hostname verlaesst das Haus nicht)', async () => {
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
const service = new IconDiscoveryService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.fetchPublicServiceIconBytes('https://docuvita.ctl.local/x'),
|
||||||
|
).rejects.toThrow(/not public/);
|
||||||
|
await expect(service.fetchPublicServiceIconBytes('http://192.168.1.5/')).rejects.toThrow(
|
||||||
|
/not public/,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Dienst kennt kein Symbol (404) -> wirft', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
...mockResponse({ contentType: 'image/png' }),
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new IconDiscoveryService();
|
||||||
|
|
||||||
|
await expect(service.fetchPublicServiceIconBytes('http://8.8.8.8/')).rejects.toThrow(
|
||||||
|
/blocked or failed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', () => {
|
describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ const MAX_REDIRECTS = 2;
|
|||||||
const MAX_HTML_CHARS = 200000;
|
const MAX_HTML_CHARS = 200000;
|
||||||
const MAX_ICON_BYTES = 1_000_000;
|
const MAX_ICON_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-261001-hbi — oeffentlicher Symbol-Dienst als letzter Rueckfall. Manche
|
||||||
|
* Seiten setzen ihr Symbol erst per JavaScript (hosteurope.de: im HTML nur
|
||||||
|
* `<link rel="icon" href="data:;base64,=">`, `/favicon.ico` liefert eine
|
||||||
|
* HTML-Seite) — ohne Browser findet die Suche dort nichts. DuckDuckGo kennt
|
||||||
|
* das gerenderte Symbol und antwortet fuer Unbekanntes mit 404 (dann bleibt
|
||||||
|
* der Buchstabe). Gefragt wird NUR fuer oeffentlich erreichbare Adressen,
|
||||||
|
* damit interne Hostnamen (docuvita.ctl.local, private IPs) das Haus nie
|
||||||
|
* verlassen; der Dienst erfaehrt nur den Hostnamen.
|
||||||
|
*/
|
||||||
|
const PUBLIC_ICON_SERVICE = 'https://icons.duckduckgo.com/ip3/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
|
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
|
||||||
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
|
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
|
||||||
@@ -426,6 +438,22 @@ export class IconDiscoveryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-261001-hbi: Symbol fuer die Seite `pageUrl` beim oeffentlichen
|
||||||
|
* Symbol-Dienst holen (siehe PUBLIC_ICON_SERVICE). Wirft, wenn die Seite
|
||||||
|
* nicht oeffentlich erreichbar ist (dann wird der Dienst NICHT gefragt) oder
|
||||||
|
* der Dienst kein Symbol kennt (404) — wie `fetchIconBytes`.
|
||||||
|
*/
|
||||||
|
async fetchPublicServiceIconBytes(
|
||||||
|
pageUrl: string,
|
||||||
|
): Promise<{ contentType: string; body: Buffer }> {
|
||||||
|
const page = new URL(normalizeUrl(pageUrl));
|
||||||
|
if (!(await isPublicHttpUrl(page))) {
|
||||||
|
throw new Error('Page is not public, icon service not asked');
|
||||||
|
}
|
||||||
|
return this.fetchIconBytes(`${PUBLIC_ICON_SERVICE}${page.hostname}.ico`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming
|
* Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming
|
||||||
* back to the browser from Tessera's own origin (avoids Cross-Origin-
|
* back to the browser from Tessera's own origin (avoids Cross-Origin-
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { DEFAULT_WELCOME_MAIL_TEXTS, findUnknownWelcomeMailPlaceholders } from '@tessera/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
applyWelcomeMailPlaceholders,
|
||||||
|
firstNameOf,
|
||||||
|
renderWelcomeMail,
|
||||||
|
type WelcomeMailInput,
|
||||||
|
} from './welcome-mail.template';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Willkommensmail mit eigener Vorlage: Platzhalter, Escaping, Absaetze,
|
||||||
|
* feste Bausteine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const base: WelcomeMailInput = {
|
||||||
|
name: 'Max Muster',
|
||||||
|
username: 'max.muster',
|
||||||
|
email: 'max@example.invalid',
|
||||||
|
tenantName: 'Muster & Co',
|
||||||
|
appUrl: 'https://tessera.example.invalid/',
|
||||||
|
account: { kind: 'directory' },
|
||||||
|
headerImageSrc: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Standard-Anmeldehinweise, damit Vorlagen im Test vollstaendig sind. */
|
||||||
|
const hints = {
|
||||||
|
loginHintDirectory: DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory,
|
||||||
|
loginHintLocal: DEFAULT_WELCOME_MAIL_TEXTS.loginHintLocal,
|
||||||
|
};
|
||||||
|
|
||||||
|
const localAccount = {
|
||||||
|
kind: 'local',
|
||||||
|
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
|
||||||
|
validHours: 168,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
name: 'Max Muster',
|
||||||
|
vorname: 'Max',
|
||||||
|
benutzername: 'max.muster',
|
||||||
|
email: 'max@example.invalid',
|
||||||
|
adresse: 'https://t.example.invalid',
|
||||||
|
firma: 'Muster & Co',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Platzhalter', () => {
|
||||||
|
it('ersetzt alle sechs, Gross-/Kleinschreibung und Leerraum egal; unbekannte bleiben stehen', () => {
|
||||||
|
expect(
|
||||||
|
applyWelcomeMailPlaceholders(
|
||||||
|
'{{name}}|{{vorname}}|{{benutzername}}|{{email}}|{{adresse}}|{{firma}}|{{ NAME }}|{{xyz}}',
|
||||||
|
values,
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
'Max Muster|Max|max.muster|max@example.invalid|https://t.example.invalid|Muster & Co|Max Muster|{{xyz}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findUnknownWelcomeMailPlaceholders nennt jeden unbekannten einmal', () => {
|
||||||
|
expect(findUnknownWelcomeMailPlaceholders('{{a}} {{name}} {{a}} {{ b }} {{}}')).toEqual([
|
||||||
|
'{{a}}',
|
||||||
|
'{{b}}',
|
||||||
|
'{{}}',
|
||||||
|
]);
|
||||||
|
expect(findUnknownWelcomeMailPlaceholders(DEFAULT_WELCOME_MAIL_TEXTS.heading)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vorname: erstes Wort des Anzeigenamens, sonst Benutzername', () => {
|
||||||
|
expect(firstNameOf(' Erika Mustermann ', 'erika')).toBe('Erika');
|
||||||
|
expect(firstNameOf(null, 'erika')).toBe('erika');
|
||||||
|
expect(firstNameOf(' ', 'erika')).toBe('erika');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderWelcomeMail mit Vorlage', () => {
|
||||||
|
it('ohne Vorlage: Standard-Betreff und -Ueberschrift', () => {
|
||||||
|
const mail = renderWelcomeMail(base);
|
||||||
|
expect(mail.subject).toBe('Willkommen bei Tessera');
|
||||||
|
expect(mail.html).toContain('Willkommen bei Tessera, Max Muster!');
|
||||||
|
expect(mail.html).toContain('Viel Erfolg mit Tessera!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Text ist reiner Text: HTML aus Vorlage und Werten wird escaped; Firma mit & korrekt', () => {
|
||||||
|
const mail = renderWelcomeMail({
|
||||||
|
...base,
|
||||||
|
name: '<script>x</script>',
|
||||||
|
texts: {
|
||||||
|
subject: 'Hallo\r\n{{firma}}',
|
||||||
|
heading: '<h2>{{name}}</h2>',
|
||||||
|
intro: '<a href="javascript:alert(1)">klick</a>',
|
||||||
|
...hints,
|
||||||
|
closing: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mail.subject).toBe('Hallo Muster & Co');
|
||||||
|
expect(mail.html).not.toContain('<script>x</script>');
|
||||||
|
expect(mail.html).not.toContain('<h2>');
|
||||||
|
expect(mail.html).not.toContain('href="javascript:');
|
||||||
|
expect(mail.html).toContain('<h2><script>x</script></h2>');
|
||||||
|
expect(mail.html).toContain('<title>Hallo Muster & Co</title>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Leerzeile = neuer Absatz, einfacher Umbruch = <br>; leerer Abschluss faellt weg', () => {
|
||||||
|
const mail = renderWelcomeMail({
|
||||||
|
...base,
|
||||||
|
texts: { subject: 's', heading: 'h', intro: 'A1\nA2\r\n\r\n\nB', ...hints, closing: ' ' },
|
||||||
|
});
|
||||||
|
expect(mail.html).toContain('>A1<br>A2</p>');
|
||||||
|
expect(mail.html).toContain('>B</p>');
|
||||||
|
expect(mail.html).not.toContain('Viel Erfolg');
|
||||||
|
expect(mail.text).toContain('A1\nA2\n\nB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('feste Bausteine bleiben bei jeder Vorlage: Adresse, Benutzername, Anmeldehinweis, Zu Tessera, Fusszeile', () => {
|
||||||
|
const mail = renderWelcomeMail({
|
||||||
|
...base,
|
||||||
|
account: {
|
||||||
|
kind: 'local',
|
||||||
|
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
|
||||||
|
validHours: 168,
|
||||||
|
},
|
||||||
|
texts: { subject: 'x', heading: 'y', intro: '', ...hints, closing: '' },
|
||||||
|
});
|
||||||
|
expect(mail.html).toContain('https://tessera.example.invalid/reset-password/abc');
|
||||||
|
expect(mail.html).toContain('Passwort festlegen');
|
||||||
|
expect(mail.html).toContain('7 Tage');
|
||||||
|
expect(mail.html).toContain('Zu Tessera');
|
||||||
|
expect(mail.html).toContain('max.muster');
|
||||||
|
expect(mail.html).toContain('im Auftrag Ihres Administrators');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('local-example: Knopf zur Anmeldeseite mit Beispiel-Hinweis, kein reset-password', () => {
|
||||||
|
const mail = renderWelcomeMail({
|
||||||
|
...base,
|
||||||
|
account: { kind: 'local-example' },
|
||||||
|
notice: 'Testmail',
|
||||||
|
});
|
||||||
|
expect(mail.html).toContain('Beispiel-Link');
|
||||||
|
expect(mail.html).not.toContain('reset-password');
|
||||||
|
expect(mail.html).toContain('Testmail');
|
||||||
|
expect(mail.text.startsWith('[Testmail]')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Anmeldehinweis je Kontoart', () => {
|
||||||
|
it('Standard: Verzeichniskonto bekommt den Windows-Hinweis, lokales Konto den Hinweis vor dem Knopf', () => {
|
||||||
|
const dir = renderWelcomeMail(base);
|
||||||
|
expect(dir.html).toContain('gewohnten Windows-Passwort');
|
||||||
|
expect(dir.html).not.toContain('persönliches Passwort fest');
|
||||||
|
const local = renderWelcomeMail({ ...base, account: localAccount });
|
||||||
|
expect(local.html).toContain('legen Sie bitte Ihr persönliches Passwort fest');
|
||||||
|
expect(local.html).not.toContain('Windows-Passwort');
|
||||||
|
// Hinweis steht vor dem Knopf
|
||||||
|
expect(local.html.indexOf('persönliches Passwort fest')).toBeLessThan(
|
||||||
|
local.html.indexOf('Passwort festlegen</a>'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eigener Text je Kontoart: Platzhalter ersetzt, escaped, Leerzeile = Absatz; Knopf und Gueltigkeit bleiben fest', () => {
|
||||||
|
const texts = {
|
||||||
|
...DEFAULT_WELCOME_MAIL_TEXTS,
|
||||||
|
loginHintDirectory:
|
||||||
|
'Anmeldung als {{benutzername}} mit <b>Firmenkennwort</b>.\n\nFragen? IT anrufen.',
|
||||||
|
loginHintLocal: 'Hallo {{vorname}}, bitte zuerst ein Passwort setzen.',
|
||||||
|
};
|
||||||
|
const dir = renderWelcomeMail({ ...base, texts });
|
||||||
|
expect(dir.html).toContain(
|
||||||
|
'Anmeldung als max.muster mit <b>Firmenkennwort</b>.</p>',
|
||||||
|
);
|
||||||
|
expect(dir.html).toContain('>Fragen? IT anrufen.</p>');
|
||||||
|
expect(dir.html).not.toContain('Windows-Passwort');
|
||||||
|
expect(dir.html).not.toContain('bitte zuerst ein Passwort setzen');
|
||||||
|
expect(dir.text).toContain(
|
||||||
|
'Anmeldung als max.muster mit <b>Firmenkennwort</b>.\n\nFragen? IT anrufen.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const local = renderWelcomeMail({ ...base, account: localAccount, texts });
|
||||||
|
expect(local.html).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
|
||||||
|
expect(local.html).not.toContain('Firmenkennwort');
|
||||||
|
expect(local.html).toContain('reset-password/abc');
|
||||||
|
expect(local.html).toContain('7 Tage');
|
||||||
|
expect(local.text).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
|
||||||
|
|
||||||
|
const example = renderWelcomeMail({ ...base, account: { kind: 'local-example' }, texts });
|
||||||
|
expect(example.html).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leerer oder fehlender Hinweis (aeltere Aufrufer) → Standardtext', () => {
|
||||||
|
const { loginHintDirectory: _d, loginHintLocal: _l, ...old } = DEFAULT_WELCOME_MAIL_TEXTS;
|
||||||
|
const dir = renderWelcomeMail({ ...base, texts: old as any });
|
||||||
|
expect(dir.html).toContain('gewohnten Windows-Passwort');
|
||||||
|
const local = renderWelcomeMail({
|
||||||
|
...base,
|
||||||
|
account: localAccount,
|
||||||
|
texts: { ...DEFAULT_WELCOME_MAIL_TEXTS, loginHintLocal: ' ' },
|
||||||
|
});
|
||||||
|
expect(local.html).toContain('persönliches Passwort fest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('local-no-link behaelt den festen Hinweis', () => {
|
||||||
|
const mail = renderWelcomeMail({ ...base, account: { kind: 'local-no-link' } });
|
||||||
|
expect(mail.html).toContain('Ihr Startpasswort erhalten Sie von Ihrem Administrator.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Kopf (Outlook ohne CID-Bild)', () => {
|
||||||
|
it('Wellenstreifen 600x40 in einer Zelle mit dunkler Kopffarbe, Inhalt beginnt mit 24px Abstand', () => {
|
||||||
|
const mail = renderWelcomeMail({ ...base, headerImageSrc: 'cid:welcome-header@tessera' });
|
||||||
|
expect(mail.html).toMatch(
|
||||||
|
/<td bgcolor="#1a1c20" style="background-color:#1a1c20;line-height:0;font-size:0;">\n<img src="cid:welcome-header@tessera" width="600" height="40"/,
|
||||||
|
);
|
||||||
|
expect(mail.html).toContain('padding:24px 40px 12px;');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,14 +7,23 @@
|
|||||||
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
|
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
|
||||||
* Anmeldehinweis" `WelcomeMailService`.
|
* Anmeldehinweis" `WelcomeMailService`.
|
||||||
*
|
*
|
||||||
|
* Eigene Vorlage (Administrator → Willkommensmail): Betreff, Ueberschrift,
|
||||||
|
* Einleitung, die zwei Anmeldehinweise (Verzeichniskonto / lokales Konto)
|
||||||
|
* und Abschluss kommen als `texts` herein (Vorlage des Mandanten
|
||||||
|
* des ZIEL-Benutzers, sonst `DEFAULT_WELCOME_MAIL_TEXTS` aus
|
||||||
|
* `@tessera/shared`). Platzhalter (`{{name}}`, `{{vorname}}`,
|
||||||
|
* `{{benutzername}}`, `{{email}}`, `{{adresse}}`, `{{firma}}`) werden auf dem
|
||||||
|
* REINEN Text ersetzt und erst danach escaped. Leerzeile = neuer Absatz,
|
||||||
|
* einfacher Umbruch = `<br>`. Alles andere bleibt fest.
|
||||||
|
*
|
||||||
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
|
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
|
||||||
* Mail sehr unterschiedlich darstellen:
|
* Mail sehr unterschiedlich darstellen:
|
||||||
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
|
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
|
||||||
* - keine externen Ressourcen, keine Web-Fonts (Systemschriften);
|
* - keine externen Ressourcen, keine Web-Fonts (Systemschriften);
|
||||||
* - der Kopf im Stil des Dashboard-Hintergrunds "Duenen" ist ein PNG
|
* - Kopf: Bildmarke und Schriftzug als HTML (erscheinen immer), darunter
|
||||||
* (`headerImageSrc`, im Versand `cid:`), weil SVG und CSS-Hintergruende in
|
* die Duenen-Welle als schmaler PNG-Streifen (`headerImageSrc`, im
|
||||||
* Outlook nicht erscheinen; fehlt das Bild, steht dort ein dunkler
|
* Versand `cid:`), weil SVG und CSS-Hintergruende in Outlook nicht
|
||||||
* Textkopf;
|
* erscheinen; fehlt das Bild, bleibt nur ein schmaler Abschluss;
|
||||||
* - Knoepfe als Tabelle mit Hintergrundfarbe in der Zelle ("bulletproof"),
|
* - Knoepfe als Tabelle mit Hintergrundfarbe in der Zelle ("bulletproof"),
|
||||||
* Outlook ignoriert Innenabstaende und Rundungen am Link selbst.
|
* Outlook ignoriert Innenabstaende und Rundungen am Link selbst.
|
||||||
*
|
*
|
||||||
@@ -25,6 +34,14 @@
|
|||||||
* Windows-Passwort.
|
* Windows-Passwort.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_WELCOME_MAIL_TEXTS,
|
||||||
|
isWelcomeMailPlaceholder,
|
||||||
|
WELCOME_MAIL_PLACEHOLDER_RE,
|
||||||
|
type WelcomeMailPlaceholder,
|
||||||
|
type WelcomeMailTexts,
|
||||||
|
} from '@tessera/shared';
|
||||||
|
|
||||||
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
|
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
|
||||||
const C = {
|
const C = {
|
||||||
page: '#eceef1',
|
page: '#eceef1',
|
||||||
@@ -46,17 +63,36 @@ const FONT =
|
|||||||
export type WelcomeMailAccount =
|
export type WelcomeMailAccount =
|
||||||
| { kind: 'directory' }
|
| { kind: 'directory' }
|
||||||
| { kind: 'local'; setPasswordUrl: string; validHours: number }
|
| { kind: 'local'; setPasswordUrl: string; validHours: number }
|
||||||
| { kind: 'local-no-link' };
|
/**
|
||||||
|
* Lokales Konto ohne Link. Wird heute nirgends erzeugt (`WelcomeMailService`
|
||||||
|
* legt fuer lokale Konten immer einen Token an); deshalb bleibt ihr Hinweis
|
||||||
|
* fest und ist nicht Teil der Vorlage.
|
||||||
|
*/
|
||||||
|
| { kind: 'local-no-link' }
|
||||||
|
/**
|
||||||
|
* Nur Testmail aus Administrator → Willkommensmail: derselbe Baustein wie
|
||||||
|
* `local`, aber OHNE Token — der Knopf fuehrt zur Anmeldeseite und ein
|
||||||
|
* Hinweis sagt, was er in der echten Mail tut.
|
||||||
|
*/
|
||||||
|
| { kind: 'local-example' };
|
||||||
|
|
||||||
export interface WelcomeMailInput {
|
export interface WelcomeMailInput {
|
||||||
/** Anzeigename, sonst Benutzername. */
|
/** Anzeigename, sonst Benutzername. */
|
||||||
name: string;
|
name: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
/** E-Mail-Adresse des Empfaengers (Platzhalter `{{email}}`). */
|
||||||
|
email: string;
|
||||||
|
/** Name des Mandanten (Platzhalter `{{firma}}`). */
|
||||||
|
tenantName: string;
|
||||||
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
|
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
|
||||||
appUrl: string;
|
appUrl: string;
|
||||||
account: WelcomeMailAccount;
|
account: WelcomeMailAccount;
|
||||||
/** `cid:...` im Versand, relativer Pfad in der Vorschau, `null` = Textkopf. */
|
/** `cid:...` im Versand, `data:` in der Vorschau, `null` = Textkopf. */
|
||||||
headerImageSrc: string | null;
|
headerImageSrc: string | null;
|
||||||
|
/** Eigene Vorlage des Mandanten; fehlt sie, gelten die Standardtexte. */
|
||||||
|
texts?: WelcomeMailTexts | null;
|
||||||
|
/** Hinweisleiste ganz oben (nur Testmail), reiner Text. */
|
||||||
|
notice?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderedWelcomeMail {
|
export interface RenderedWelcomeMail {
|
||||||
@@ -65,13 +101,55 @@ export interface RenderedWelcomeMail {
|
|||||||
html: string;
|
html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WELCOME_MAIL_SUBJECT = 'Willkommen bei Tessera';
|
export const WELCOME_MAIL_SUBJECT = DEFAULT_WELCOME_MAIL_TEXTS.subject;
|
||||||
|
|
||||||
const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.';
|
const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.';
|
||||||
const INTRO =
|
const EXAMPLE_LINK_NOTE =
|
||||||
'für Sie wurde ein Zugang zu Tessera eingerichtet – Ihrer zentralen Plattform für Werkzeuge und Abläufe im Unternehmen. Alles, was Sie für Ihre tägliche Arbeit brauchen, finden Sie dort an einem Ort.';
|
'Beispiel-Link: In der echten Willkommensmail führt dieser Knopf zu einem persönlichen Link, mit dem der neue Benutzer sein Passwort festlegt. In dieser Testmail öffnet er nur die Anmeldeseite.';
|
||||||
const DESKTOP_HINT =
|
|
||||||
'Tessera gibt es auch als Desktop-App – den Download finden Sie auf der Anmeldeseite.';
|
/** Werte der Platzhalter, noch NICHT escaped (Ersetzung laeuft auf reinem Text). */
|
||||||
|
export type WelcomeMailPlaceholderValues = Record<WelcomeMailPlaceholder, string>;
|
||||||
|
|
||||||
|
/** Erstes Wort des Anzeigenamens, sonst der Benutzername. */
|
||||||
|
export function firstNameOf(displayName: string | null | undefined, username: string): string {
|
||||||
|
const first = (displayName ?? '').trim().split(/\s+/)[0];
|
||||||
|
return first || username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Platzhalter in einen REINEN Text ein (Gross-/Kleinschreibung
|
||||||
|
* egal). Unbekannte bleiben stehen — gespeicherte Vorlagen enthalten keine,
|
||||||
|
* das prueft die API beim Speichern. Das Ergebnis ist weiter reiner Text und
|
||||||
|
* wird erst beim Einbau ins HTML escaped.
|
||||||
|
*/
|
||||||
|
export function applyWelcomeMailPlaceholders(
|
||||||
|
text: string,
|
||||||
|
values: WelcomeMailPlaceholderValues,
|
||||||
|
): string {
|
||||||
|
return text.replace(WELCOME_MAIL_PLACEHOLDER_RE, (whole, name: string) =>
|
||||||
|
isWelcomeMailPlaceholder(name) ? values[name.toLowerCase() as WelcomeMailPlaceholder] : whole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeilenenden vereinheitlichen, Leerraum an den Raendern entfernen. */
|
||||||
|
function normalizeText(value: string): string {
|
||||||
|
return value.replace(/\r\n?/g, '\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einzeilig (Betreff, Ueberschrift): jeder Umbruch/Leerraum-Lauf wird ein Leerzeichen. */
|
||||||
|
function singleLine(value: string): string {
|
||||||
|
return value.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absaetze: Leerzeile trennt, einfacher Umbruch bleibt im Absatz. */
|
||||||
|
function paragraphsOf(value: string): string[] {
|
||||||
|
const text = normalizeText(value);
|
||||||
|
if (!text) return [];
|
||||||
|
return text
|
||||||
|
.split(/\n[ \t]*\n+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
export function escapeHtml(value: string): string {
|
export function escapeHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
@@ -87,12 +165,22 @@ function safeUrl(value: string): string {
|
|||||||
return /^https?:\/\//i.test(value) ? value : '';
|
return /^https?:\/\//i.test(value) ? value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function loginHintText(account: WelcomeMailAccount): string {
|
/**
|
||||||
|
* Anmeldehinweis je Kontoart als REINER Text (Platzhalter noch nicht
|
||||||
|
* ersetzt). Verzeichnis- und lokale Konten nehmen den Text der Vorlage; ist
|
||||||
|
* er leer oder fehlt er (aeltere Aufrufer), gilt der Standardtext.
|
||||||
|
*/
|
||||||
|
function loginHintText(account: WelcomeMailAccount, texts: Partial<WelcomeMailTexts>): string {
|
||||||
|
const pick = (field: 'loginHintDirectory' | 'loginHintLocal') => {
|
||||||
|
const value = texts[field];
|
||||||
|
return typeof value === 'string' && value.trim() ? value : DEFAULT_WELCOME_MAIL_TEXTS[field];
|
||||||
|
};
|
||||||
switch (account.kind) {
|
switch (account.kind) {
|
||||||
case 'directory':
|
case 'directory':
|
||||||
return 'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.';
|
return pick('loginHintDirectory');
|
||||||
case 'local':
|
case 'local':
|
||||||
return 'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.';
|
case 'local-example':
|
||||||
|
return pick('loginHintLocal');
|
||||||
case 'local-no-link':
|
case 'local-no-link':
|
||||||
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
|
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
|
||||||
}
|
}
|
||||||
@@ -118,32 +206,102 @@ function button(href: string, label: string, bg: string, fg: string): string {
|
|||||||
</td></tr></table>`;
|
</td></tr></table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function headerRow(src: string | null): string {
|
/** Eine Kachel der Bildmarke: feste Zelle, Hoehe auch in Outlook exakt. */
|
||||||
if (src) {
|
function tile(color: string | null): string {
|
||||||
return `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;line-height:0;font-size:0;">
|
const bg = color ? `bgcolor="${color}" style="background-color:${color};` : 'style="';
|
||||||
<img src="${escapeHtml(src)}" width="600" height="150" alt="Tessera" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;border-radius:12px 12px 0 0;color:#ffffff;font-family:${FONT};font-size:28px;font-weight:700;">
|
return `<td width="9" height="9" ${bg}width:9px;height:9px;font-size:1px;line-height:9px;mso-line-height-rule:exactly;"> </td>`;
|
||||||
</td></tr>`;
|
|
||||||
}
|
|
||||||
return `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:40px 40px 36px;font-family:${FONT};font-size:30px;line-height:36px;font-weight:700;color:#ffffff;border-bottom:3px solid ${C.yellow};">Tessera</td></tr>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
|
||||||
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
|
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
|
||||||
const loginUrl = `${base}/login`;
|
const loginUrl = `${base}/login`;
|
||||||
const hint = loginHintText(input.account);
|
const texts = input.texts ?? DEFAULT_WELCOME_MAIL_TEXTS;
|
||||||
|
|
||||||
|
const values: WelcomeMailPlaceholderValues = {
|
||||||
|
name: input.name,
|
||||||
|
vorname: firstNameOf(input.name, input.username),
|
||||||
|
benutzername: input.username,
|
||||||
|
email: input.email,
|
||||||
|
adresse: base,
|
||||||
|
firma: input.tenantName,
|
||||||
|
};
|
||||||
|
const fill = (value: string) => applyWelcomeMailPlaceholders(value, values);
|
||||||
|
|
||||||
|
const subject = singleLine(fill(texts.subject)) || WELCOME_MAIL_SUBJECT;
|
||||||
|
const heading = singleLine(fill(texts.heading));
|
||||||
|
const introParagraphs = paragraphsOf(fill(texts.intro));
|
||||||
|
const hintParagraphs = paragraphsOf(fill(loginHintText(input.account, texts)));
|
||||||
|
const closingParagraphs = paragraphsOf(fill(texts.closing));
|
||||||
|
const notice = input.notice ? singleLine(input.notice) : '';
|
||||||
|
|
||||||
// ── Text-Alternative ───────────────────────────────────────────────────
|
// ── Text-Alternative ───────────────────────────────────────────────────
|
||||||
const textLines = [
|
const textLines: string[] = [];
|
||||||
`Guten Tag ${input.name},`,
|
if (notice) textLines.push(`[${notice}]`, '');
|
||||||
'',
|
if (heading) textLines.push(heading, '');
|
||||||
INTRO,
|
for (const para of introParagraphs) textLines.push(para, '');
|
||||||
'',
|
textLines.push('Ihre Zugangsdaten', `Adresse: ${base}`, `Benutzername: ${input.username}`);
|
||||||
'Ihre Zugangsdaten',
|
for (const para of hintParagraphs) textLines.push('', para);
|
||||||
`Adresse: ${base}`,
|
|
||||||
`Benutzername: ${input.username}`,
|
|
||||||
'',
|
|
||||||
hint,
|
|
||||||
];
|
|
||||||
if (input.account.kind === 'local') {
|
if (input.account.kind === 'local') {
|
||||||
textLines.push(
|
textLines.push(
|
||||||
'',
|
'',
|
||||||
@@ -151,39 +309,63 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
|||||||
input.account.setPasswordUrl,
|
input.account.setPasswordUrl,
|
||||||
validityText(input.account.validHours),
|
validityText(input.account.validHours),
|
||||||
);
|
);
|
||||||
|
} else if (input.account.kind === 'local-example') {
|
||||||
|
textLines.push('', 'Passwort festlegen:', loginUrl, EXAMPLE_LINK_NOTE);
|
||||||
}
|
}
|
||||||
textLines.push(
|
textLines.push('', 'Zu Tessera:', loginUrl);
|
||||||
'',
|
for (const para of closingParagraphs) textLines.push('', para);
|
||||||
'Zu Tessera:',
|
textLines.push('', '--', FOOTER);
|
||||||
loginUrl,
|
|
||||||
'',
|
|
||||||
DESKTOP_HINT,
|
|
||||||
'',
|
|
||||||
'Viel Erfolg mit Tessera!',
|
|
||||||
'',
|
|
||||||
'--',
|
|
||||||
FOOTER,
|
|
||||||
);
|
|
||||||
const text = textLines.join('\n');
|
const text = textLines.join('\n');
|
||||||
|
|
||||||
// ── HTML ───────────────────────────────────────────────────────────────
|
// ── HTML ───────────────────────────────────────────────────────────────
|
||||||
const name = escapeHtml(input.name);
|
|
||||||
const username = escapeHtml(input.username);
|
const username = escapeHtml(input.username);
|
||||||
const baseHtml = escapeHtml(base);
|
const baseHtml = escapeHtml(base);
|
||||||
|
|
||||||
const p = (content: string, extra = '') =>
|
const p = (content: string, extra = '') =>
|
||||||
`<p style="margin:0 0 16px;font-family:${FONT};font-size:16px;line-height:25px;color:${C.body};${extra}">${content}</p>`;
|
`<p style="margin:0 0 16px;font-family:${FONT};font-size:16px;line-height:25px;color:${C.body};${extra}">${content}</p>`;
|
||||||
|
|
||||||
|
/** Reiner Text -> escaped, einfache Umbrueche als <br>. */
|
||||||
|
const para = (value: string) => escapeHtml(value).replace(/\n/g, '<br>');
|
||||||
|
|
||||||
const label = (content: string) =>
|
const label = (content: string) =>
|
||||||
`<div style="font-family:${FONT};font-size:12px;line-height:16px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:${C.muted};">${content}</div>`;
|
`<div style="font-family:${FONT};font-size:12px;line-height:16px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:${C.muted};">${content}</div>`;
|
||||||
|
|
||||||
let accountBlock = p(escapeHtml(hint), 'margin:24px 0 16px;');
|
let accountBlock = hintParagraphs
|
||||||
|
.map((part, i) => p(para(part), i === 0 ? 'margin:24px 0 16px;' : ''))
|
||||||
|
.join('\n');
|
||||||
if (input.account.kind === 'local') {
|
if (input.account.kind === 'local') {
|
||||||
const setUrl = safeUrl(input.account.setPasswordUrl);
|
const setUrl = safeUrl(input.account.setPasswordUrl);
|
||||||
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
||||||
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(validityText(input.account.validHours))}</p>`;
|
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(validityText(input.account.validHours))}</p>`;
|
||||||
|
} else if (input.account.kind === 'local-example') {
|
||||||
|
accountBlock += `${button(loginUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
||||||
|
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(EXAMPLE_LINK_NOTE)}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noticeRow = notice
|
||||||
|
? `<tr><td style="padding:0 0 12px;"><table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td bgcolor="#fff8c2" style="background-color:#fff8c2;border:1px solid #e6d74c;border-radius:8px;padding:10px 16px;font-family:${FONT};font-size:13px;line-height:20px;color:${C.ink};">${escapeHtml(notice)}</td></tr></table></td></tr>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const headingHtml = heading
|
||||||
|
? `<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">${escapeHtml(heading)}</h1>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
const introHtml = introParagraphs.map((part) => p(para(part))).join('\n');
|
||||||
|
|
||||||
|
const closingHtml = closingParagraphs.length
|
||||||
|
? `<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
|
||||||
|
${closingParagraphs
|
||||||
|
.map((part, i) =>
|
||||||
|
p(
|
||||||
|
para(part),
|
||||||
|
`font-size:14px;line-height:22px;margin:0${i < closingParagraphs.length - 1 ? ' 0 12px' : ''};`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join('\n')}
|
||||||
|
</td></tr></table>`
|
||||||
|
: '';
|
||||||
|
|
||||||
const html = `<!DOCTYPE html>
|
const html = `<!DOCTYPE html>
|
||||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
<head>
|
<head>
|
||||||
@@ -192,7 +374,7 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
|||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<meta name="supported-color-schemes" content="light">
|
<meta name="supported-color-schemes" content="light">
|
||||||
<title>${escapeHtml(WELCOME_MAIL_SUBJECT)}</title>
|
<title>${escapeHtml(subject)}</title>
|
||||||
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
||||||
<style>
|
<style>
|
||||||
a { color: ${C.link}; }
|
a { color: ${C.link}; }
|
||||||
@@ -207,10 +389,9 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
|||||||
<tr><td align="center" style="padding:32px 12px;">
|
<tr><td align="center" style="padding:32px 12px;">
|
||||||
<!--[if mso]><table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
|
<!--[if mso]><table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
|
||||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;border-collapse:separate;">
|
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;border-collapse:separate;">
|
||||||
${headerRow(input.headerImageSrc)}
|
${noticeRow}${headerRow(input.headerImageSrc)}
|
||||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:36px 40px 12px;">
|
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:24px 40px 12px;">
|
||||||
<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">Willkommen bei Tessera, ${name}!</h1>
|
${headingHtml}${introHtml}
|
||||||
${p(escapeHtml(INTRO.charAt(0).toUpperCase() + INTRO.slice(1)))}
|
|
||||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;margin:8px 0 0;">
|
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;margin:8px 0 0;">
|
||||||
<tr><td bgcolor="${C.well}" style="background-color:${C.well};border:1px solid ${C.line};border-left:4px solid ${C.yellow};border-radius:8px;padding:18px 22px;">
|
<tr><td bgcolor="${C.well}" style="background-color:${C.well};border:1px solid ${C.line};border-left:4px solid ${C.yellow};border-radius:8px;padding:18px 22px;">
|
||||||
${label('Adresse')}
|
${label('Adresse')}
|
||||||
@@ -224,10 +405,7 @@ ${accountBlock}
|
|||||||
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
|
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:20px 40px 36px;border-radius:0 0 12px 12px;">
|
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:20px 40px 36px;border-radius:0 0 12px 12px;">
|
||||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
|
${closingHtml}
|
||||||
${p(`<strong style="color:${C.ink};">Tipp:</strong> ${escapeHtml(DESKTOP_HINT)}`, `font-size:14px;line-height:22px;margin:0 0 12px;`)}
|
|
||||||
${p('Viel Erfolg mit Tessera!', 'font-size:14px;line-height:22px;margin:0;')}
|
|
||||||
</td></tr></table>
|
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<tr><td align="center" style="padding:20px 24px 0;font-family:${FONT};font-size:12px;line-height:18px;color:${C.muted};">${escapeHtml(FOOTER)}</td></tr>
|
<tr><td align="center" style="padding:20px 24px 0;font-family:${FONT};font-size:12px;line-height:18px;color:${C.muted};">${escapeHtml(FOOTER)}</td></tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -237,5 +415,5 @@ ${p('Viel Erfolg mit Tessera!', 'font-size:14px;line-height:22px;margin:0;')}
|
|||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
return { subject: WELCOME_MAIL_SUBJECT, text, html };
|
return { subject, text, html };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
findUnknownWelcomeMailPlaceholders,
|
||||||
|
WELCOME_MAIL_LIMITS,
|
||||||
|
type WelcomeMailTexts,
|
||||||
|
} from '@tessera/shared';
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
Validate,
|
||||||
|
type ValidationArguments,
|
||||||
|
ValidatorConstraint,
|
||||||
|
type ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/** Feldnamen fuer die Fehlermeldung (Oberflaeche zeigt sie unveraendert). */
|
||||||
|
const FIELD_LABELS: Record<keyof WelcomeMailTexts, string> = {
|
||||||
|
subject: 'Betreff',
|
||||||
|
heading: 'Überschrift',
|
||||||
|
intro: 'Einleitungstext',
|
||||||
|
loginHintDirectory: 'Anmeldehinweis für Verzeichniskonten',
|
||||||
|
loginHintLocal: 'Anmeldehinweis für lokale Konten',
|
||||||
|
closing: 'Abschlusstext',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nur bekannte Platzhalter (`{{name}}`, `{{vorname}}`, `{{benutzername}}`,
|
||||||
|
* `{{email}}`, `{{adresse}}`, `{{firma}}`). Ein unbekannter wird mit 400
|
||||||
|
* abgelehnt und in der Meldung genannt ("Unbekannter Platzhalter {{xyz}}"),
|
||||||
|
* statt spaeter woertlich in der Mail zu stehen.
|
||||||
|
*/
|
||||||
|
@ValidatorConstraint({ name: 'nurBekanntePlatzhalter', async: false })
|
||||||
|
class NurBekanntePlatzhalterConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
return typeof value !== 'string' || findUnknownWelcomeMailPlaceholders(value).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
const unknown =
|
||||||
|
typeof args.value === 'string' ? findUnknownWelcomeMailPlaceholders(args.value) : [];
|
||||||
|
const label = FIELD_LABELS[args.property as keyof WelcomeMailTexts] ?? args.property;
|
||||||
|
const noun = unknown.length > 1 ? 'Unbekannte Platzhalter' : 'Unbekannter Platzhalter';
|
||||||
|
return `${label}: ${noun} ${unknown.join(', ')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inhalt der eigenen Vorlage (PUT /welcome-mail-template, auch Grundlage fuer
|
||||||
|
* Vorschau und Testmail). Reiner Text: HTML wird beim Rendern escaped, nicht
|
||||||
|
* hier abgewiesen — ein "<" im Text ist erlaubt und erscheint als Zeichen.
|
||||||
|
* Betreff, Ueberschrift und beide Anmeldehinweise sind Pflicht, Einleitung
|
||||||
|
* und Abschluss duerfen leer sein (der Abschnitt faellt dann weg).
|
||||||
|
*/
|
||||||
|
export class WelcomeMailTemplateDto implements WelcomeMailTexts {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/\S/, { message: 'Bitte geben Sie einen Betreff ein.' })
|
||||||
|
@MaxLength(WELCOME_MAIL_LIMITS.subject)
|
||||||
|
@Validate(NurBekanntePlatzhalterConstraint)
|
||||||
|
subject!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/\S/, { message: 'Bitte geben Sie eine Überschrift ein.' })
|
||||||
|
@MaxLength(WELCOME_MAIL_LIMITS.heading)
|
||||||
|
@Validate(NurBekanntePlatzhalterConstraint)
|
||||||
|
heading!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(WELCOME_MAIL_LIMITS.intro)
|
||||||
|
@Validate(NurBekanntePlatzhalterConstraint)
|
||||||
|
intro!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/\S/, { message: 'Bitte geben Sie einen Anmeldehinweis für Verzeichniskonten ein.' })
|
||||||
|
@MaxLength(WELCOME_MAIL_LIMITS.loginHintDirectory)
|
||||||
|
@Validate(NurBekanntePlatzhalterConstraint)
|
||||||
|
loginHintDirectory!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/\S/, { message: 'Bitte geben Sie einen Anmeldehinweis für lokale Konten ein.' })
|
||||||
|
@MaxLength(WELCOME_MAIL_LIMITS.loginHintLocal)
|
||||||
|
@Validate(NurBekanntePlatzhalterConstraint)
|
||||||
|
loginHintLocal!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(WELCOME_MAIL_LIMITS.closing)
|
||||||
|
@Validate(NurBekanntePlatzhalterConstraint)
|
||||||
|
closing!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau: zusaetzlich die Kontoart, fuer die der Anmeldehinweis gezeigt wird. */
|
||||||
|
export class WelcomeMailPreviewDto extends WelcomeMailTemplateDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['directory', 'local'])
|
||||||
|
account?: 'directory' | 'local';
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { AdminSeedService } from './admin-seed.service';
|
|||||||
import { UserController } from './user.controller';
|
import { UserController } from './user.controller';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { WelcomeMailService } from './welcome-mail.service';
|
import { WelcomeMailService } from './welcome-mail.service';
|
||||||
|
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
|
||||||
|
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Importiert GroupsModule für UserService.create's Standardgruppen-
|
* Importiert GroupsModule für UserService.create's Standardgruppen-
|
||||||
@@ -15,11 +17,17 @@ import { WelcomeMailService } from './welcome-mail.service';
|
|||||||
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
|
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
|
||||||
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
|
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
|
||||||
* UserModule.
|
* UserModule.
|
||||||
|
*
|
||||||
|
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
|
||||||
|
* `WelcomeMailTemplateService` (Speicherung je Mandant) und
|
||||||
|
* `WelcomeMailTemplateController` (`/welcome-mail-template`) liegen hier,
|
||||||
|
* weil Vorschau und Testmail denselben Weg wie der Versand nutzen
|
||||||
|
* (`WelcomeMailService`: Adresse, Kopfbild, SMTP des Mandanten).
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [GroupsModule, MailModule],
|
imports: [GroupsModule, MailModule],
|
||||||
controllers: [UserController],
|
controllers: [UserController, WelcomeMailTemplateController],
|
||||||
providers: [UserService, AdminSeedService, WelcomeMailService],
|
providers: [UserService, AdminSeedService, WelcomeMailService, WelcomeMailTemplateService],
|
||||||
exports: [UserService],
|
exports: [UserService],
|
||||||
})
|
})
|
||||||
export class UserModule {}
|
export class UserModule {}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
|
import { WelcomeMailPreviewDto, WelcomeMailTemplateDto } from './dto/welcome-mail-template.dto';
|
||||||
|
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrator → Willkommensmail (eigene Vorlage).
|
||||||
|
*
|
||||||
|
* Festgenagelt: alle Routen nur ADMIN/SUPER_ADMIN; Mandant IMMER aus dem
|
||||||
|
* Token (nie aus dem Body); unbekannte Platzhalter und zu lange Texte → 400
|
||||||
|
* mit Nennung des Platzhalters; die Testmail geht an den Aufrufer selbst.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeTemplates() {
|
||||||
|
return {
|
||||||
|
getState: vi.fn(async (..._args: unknown[]) => ({ custom: false })),
|
||||||
|
save: vi.fn(async (..._args: unknown[]) => ({ custom: true })),
|
||||||
|
reset: vi.fn(async (..._args: unknown[]) => ({ custom: false })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWelcome() {
|
||||||
|
return {
|
||||||
|
preview: vi.fn(async (..._args: unknown[]) => ({ subject: 's', html: '<html>', text: 't' })),
|
||||||
|
sendTest: vi.fn(async (..._args: unknown[]) => ({ to: 'ada@example.invalid' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = { id: 'u-admin', username: 'ada', role: 'ADMIN', tenantId: 't1' } as any;
|
||||||
|
const proto = WelcomeMailTemplateController.prototype as any;
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
|
||||||
|
const valid = { ...DEFAULT_WELCOME_MAIL_TEXTS };
|
||||||
|
|
||||||
|
async function validateBody(body: unknown, metatype: any = WelcomeMailTemplateDto) {
|
||||||
|
return pipe.transform(body, { type: 'body', metatype });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function messagesOf(body: unknown): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
await validateBody(body);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(BadRequestException);
|
||||||
|
return ((error as BadRequestException).getResponse() as { message: string[] }).message;
|
||||||
|
}
|
||||||
|
throw new Error('erwartete 400');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WelcomeMailTemplateController — Rechte', () => {
|
||||||
|
it.each([
|
||||||
|
'get',
|
||||||
|
'save',
|
||||||
|
'reset',
|
||||||
|
'preview',
|
||||||
|
'sendTest',
|
||||||
|
])('%s nur fuer ADMIN und SUPER_ADMIN', (name) => {
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual(['ADMIN', 'SUPER_ADMIN']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haengt an Pfad welcome-mail-template', () => {
|
||||||
|
expect(Reflect.getMetadata('path', WelcomeMailTemplateController)).toBe(
|
||||||
|
'welcome-mail-template',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WelcomeMailTemplateController — Mandant aus dem Token', () => {
|
||||||
|
it('reicht currentUser.tenantId (und Benutzername/-kennung) weiter, nie Werte aus dem Body', async () => {
|
||||||
|
const templates = makeTemplates();
|
||||||
|
const welcome = makeWelcome();
|
||||||
|
const controller = new WelcomeMailTemplateController(templates as any, welcome as any);
|
||||||
|
|
||||||
|
await controller.get(admin);
|
||||||
|
await controller.save(admin, { ...valid, tenantId: 'evil' } as any);
|
||||||
|
await controller.reset(admin);
|
||||||
|
await controller.preview(admin, { ...valid, account: 'local' }, 'https://o.example.invalid');
|
||||||
|
const result = await controller.sendTest(admin, valid as any, undefined);
|
||||||
|
|
||||||
|
expect(templates.getState).toHaveBeenCalledWith('t1');
|
||||||
|
expect(templates.save).toHaveBeenCalledWith('t1', valid, 'ada');
|
||||||
|
expect(templates.reset).toHaveBeenCalledWith('t1');
|
||||||
|
expect(welcome.preview).toHaveBeenCalledWith('t1', valid, 'local', 'https://o.example.invalid');
|
||||||
|
expect(welcome.sendTest).toHaveBeenCalledWith('t1', 'u-admin', valid, undefined);
|
||||||
|
expect(result).toEqual({ success: true, to: 'ada@example.invalid' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Vorschau ohne Kontoart zeigt die Verzeichniskonto-Variante', async () => {
|
||||||
|
const welcome = makeWelcome();
|
||||||
|
const controller = new WelcomeMailTemplateController(makeTemplates() as any, welcome as any);
|
||||||
|
await controller.preview(admin, { ...valid });
|
||||||
|
expect(welcome.preview.mock.calls[0][2]).toBe('directory');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WelcomeMailTemplateDto (globale ValidationPipe)', () => {
|
||||||
|
it('Standardtexte und alle bekannten Platzhalter (auch mit Leerraum/Grossschreibung) sind gueltig', async () => {
|
||||||
|
const out: any = await validateBody({
|
||||||
|
...valid,
|
||||||
|
intro: '{{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}} {{ Name }}',
|
||||||
|
tenantId: 'evil',
|
||||||
|
});
|
||||||
|
expect(out).not.toHaveProperty('tenantId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unbekannter Platzhalter → 400 mit Feld und Platzhalter', async () => {
|
||||||
|
const messages = await messagesOf({ ...valid, heading: 'Hallo {{xyz}} und {{abc}}' });
|
||||||
|
expect(messages).toContain('Überschrift: Unbekannte Platzhalter {{xyz}}, {{abc}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein unbekannter Platzhalter im Betreff → "Unbekannter Platzhalter {{xyz}}"', async () => {
|
||||||
|
const messages = await messagesOf({ ...valid, subject: 'Hi {{xyz}}' });
|
||||||
|
expect(messages).toContain('Betreff: Unbekannter Platzhalter {{xyz}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Laengen: Betreff/Ueberschrift 200, Anmeldehinweise 1000, Texte 4000', async () => {
|
||||||
|
await expect(
|
||||||
|
validateBody({ ...valid, subject: 'a'.repeat(200), intro: 'b'.repeat(4000) }),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
await expect(messagesOf({ ...valid, subject: 'a'.repeat(201) })).resolves.toHaveLength(1);
|
||||||
|
await expect(messagesOf({ ...valid, heading: 'a'.repeat(201) })).resolves.toHaveLength(1);
|
||||||
|
await expect(messagesOf({ ...valid, intro: 'a'.repeat(4001) })).resolves.toHaveLength(1);
|
||||||
|
await expect(messagesOf({ ...valid, closing: 'a'.repeat(4001) })).resolves.toHaveLength(1);
|
||||||
|
await expect(
|
||||||
|
validateBody({
|
||||||
|
...valid,
|
||||||
|
loginHintDirectory: 'a'.repeat(1000),
|
||||||
|
loginHintLocal: 'b'.repeat(1000),
|
||||||
|
}),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
await expect(
|
||||||
|
messagesOf({ ...valid, loginHintDirectory: 'a'.repeat(1001) }),
|
||||||
|
).resolves.toHaveLength(1);
|
||||||
|
await expect(messagesOf({ ...valid, loginHintLocal: 'a'.repeat(1001) })).resolves.toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leerer Betreff/leere Ueberschrift → 400; leere Einleitung/Abschluss erlaubt', async () => {
|
||||||
|
expect(await messagesOf({ ...valid, subject: ' ' })).toContain(
|
||||||
|
'Bitte geben Sie einen Betreff ein.',
|
||||||
|
);
|
||||||
|
expect(await messagesOf({ ...valid, heading: '' })).toContain(
|
||||||
|
'Bitte geben Sie eine Überschrift ein.',
|
||||||
|
);
|
||||||
|
await expect(validateBody({ ...valid, intro: '', closing: '' })).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anmeldehinweise: Pflicht, Platzhalter geprueft, Leerzeilen erlaubt', async () => {
|
||||||
|
expect(await messagesOf({ ...valid, loginHintDirectory: ' \n ' })).toContain(
|
||||||
|
'Bitte geben Sie einen Anmeldehinweis für Verzeichniskonten ein.',
|
||||||
|
);
|
||||||
|
expect(await messagesOf({ ...valid, loginHintLocal: '' })).toContain(
|
||||||
|
'Bitte geben Sie einen Anmeldehinweis für lokale Konten ein.',
|
||||||
|
);
|
||||||
|
expect(await messagesOf({ ...valid, loginHintLocal: 'Hallo {{kennwort}}' })).toContain(
|
||||||
|
'Anmeldehinweis für lokale Konten: Unbekannter Platzhalter {{kennwort}}',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
validateBody({ ...valid, loginHintDirectory: 'Hallo {{ Vorname }}\n\nZweiter Absatz' }),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
const { loginHintLocal: _l, ...rest } = valid;
|
||||||
|
await expect(messagesOf(rest)).resolves.not.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fehlendes Feld → 400', async () => {
|
||||||
|
const { closing: _closing, ...rest } = valid;
|
||||||
|
await expect(messagesOf(rest)).resolves.not.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Vorschau: nur directory/local als Kontoart', async () => {
|
||||||
|
await expect(
|
||||||
|
validateBody({ ...valid, account: 'local' }, WelcomeMailPreviewDto),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
await expect(
|
||||||
|
validateBody({ ...valid, account: 'root' }, WelcomeMailPreviewDto),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import type { AuthUser } from '../auth/types/auth-user';
|
||||||
|
import { WelcomeMailPreviewDto, WelcomeMailTemplateDto } from './dto/welcome-mail-template.dto';
|
||||||
|
import { WelcomeMailService } from './welcome-mail.service';
|
||||||
|
import {
|
||||||
|
WelcomeMailTemplateService,
|
||||||
|
type WelcomeMailTemplateState,
|
||||||
|
} from './welcome-mail-template.service';
|
||||||
|
|
||||||
|
/** Nur die sechs Texte aus dem DTO — nie weitere Felder in die Vorlage. */
|
||||||
|
function textsOf(dto: WelcomeMailTemplateDto) {
|
||||||
|
return {
|
||||||
|
subject: dto.subject,
|
||||||
|
heading: dto.heading,
|
||||||
|
intro: dto.intro,
|
||||||
|
loginHintDirectory: dto.loginHintDirectory,
|
||||||
|
loginHintLocal: dto.loginHintLocal,
|
||||||
|
closing: dto.closing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
|
||||||
|
*
|
||||||
|
* Alle Routen nur fuer ADMIN und SUPER_ADMIN (`@Roles`), immer fuer den
|
||||||
|
* Mandanten des angemeldeten Administrators (`currentUser.tenantId`, aus dem
|
||||||
|
* Token) — ein Mandant aus der Anfrage wird nie uebernommen. Unbekannte
|
||||||
|
* Platzhalter und zu lange Texte weist das DTO mit 400 ab.
|
||||||
|
*
|
||||||
|
* - GET /welcome-mail-template wirksame Texte + Standardtexte
|
||||||
|
* - PUT /welcome-mail-template eigene Vorlage speichern
|
||||||
|
* - DELETE /welcome-mail-template auf Standard zuruecksetzen
|
||||||
|
* - POST /welcome-mail-template/preview gerendertes HTML (Beispielwerte)
|
||||||
|
* - POST /welcome-mail-template/test Testmail an die eigene Adresse
|
||||||
|
*
|
||||||
|
* Keine Kennungs-Route (`:id`) — eine Vorlage je Mandant —, deshalb keine
|
||||||
|
* Reihenfolge-Falle.
|
||||||
|
*/
|
||||||
|
@Controller('welcome-mail-template')
|
||||||
|
export class WelcomeMailTemplateController {
|
||||||
|
constructor(
|
||||||
|
private readonly templateService: WelcomeMailTemplateService,
|
||||||
|
private readonly welcomeMailService: WelcomeMailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async get(@CurrentUser() currentUser: AuthUser): Promise<WelcomeMailTemplateState> {
|
||||||
|
return this.templateService.getState(currentUser.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async save(
|
||||||
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Body() dto: WelcomeMailTemplateDto,
|
||||||
|
): Promise<WelcomeMailTemplateState> {
|
||||||
|
return this.templateService.save(currentUser.tenantId, textsOf(dto), currentUser.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete()
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async reset(@CurrentUser() currentUser: AuthUser): Promise<WelcomeMailTemplateState> {
|
||||||
|
return this.templateService.reset(currentUser.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('preview')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async preview(
|
||||||
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Body() dto: WelcomeMailPreviewDto,
|
||||||
|
@Headers('origin') origin?: string,
|
||||||
|
): Promise<{ subject: string; html: string; text: string }> {
|
||||||
|
return this.welcomeMailService.preview(
|
||||||
|
currentUser.tenantId,
|
||||||
|
textsOf(dto),
|
||||||
|
dto.account ?? 'directory',
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('test')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async sendTest(
|
||||||
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Body() dto: WelcomeMailTemplateDto,
|
||||||
|
@Headers('origin') origin?: string,
|
||||||
|
): Promise<{ success: true; to: string }> {
|
||||||
|
const result = await this.welcomeMailService.sendTest(
|
||||||
|
currentUser.tenantId,
|
||||||
|
currentUser.id,
|
||||||
|
textsOf(dto),
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
return { success: true, ...result };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WelcomeMailTemplateService — Speicherung der eigenen Vorlage je Mandant.
|
||||||
|
*
|
||||||
|
* Festgenagelt: jeder Zugriff gebunden an den uebergebenen Mandanten
|
||||||
|
* (`forTenant`), Standardtexte ohne Vorlage, Speichern als upsert auf
|
||||||
|
* `tenantId`, Zuruecksetzen loescht die Vorlage.
|
||||||
|
*/
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
tenantId: string;
|
||||||
|
subject: string;
|
||||||
|
heading: string;
|
||||||
|
intro: string;
|
||||||
|
loginHintDirectory?: string | null;
|
||||||
|
loginHintLocal?: string | null;
|
||||||
|
closing: string;
|
||||||
|
updatedBy: string | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makePrisma(rows: Row[] = []) {
|
||||||
|
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
|
||||||
|
return {
|
||||||
|
__log: log,
|
||||||
|
__rows: rows,
|
||||||
|
__bound(tenantId: string) {
|
||||||
|
return {
|
||||||
|
welcomeMailTemplate: {
|
||||||
|
findUnique: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'findUnique', args });
|
||||||
|
// RLS-Nachbildung: nur Zeilen des gebundenen Mandanten sichtbar
|
||||||
|
return (
|
||||||
|
rows.find((r) => r.tenantId === args.where.tenantId && r.tenantId === tenantId) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
upsert: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'upsert', args });
|
||||||
|
const existing = rows.find((r) => r.tenantId === args.where.tenantId);
|
||||||
|
if (existing) Object.assign(existing, args.update, { updatedAt: new Date() });
|
||||||
|
else rows.push({ ...args.create, updatedAt: new Date() });
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
deleteMany: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'deleteMany', args });
|
||||||
|
const before = rows.length;
|
||||||
|
for (let i = rows.length - 1; i >= 0; i--) {
|
||||||
|
if (rows[i].tenantId === args.where.tenantId) rows.splice(i, 1);
|
||||||
|
}
|
||||||
|
return { count: before - rows.length };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tenant: {
|
||||||
|
findUnique: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'tenant', method: 'findUnique', args });
|
||||||
|
return args.where.id === 't1' ? { name: 'Beispiel GmbH' } : null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const texts = {
|
||||||
|
subject: 'S {{name}}',
|
||||||
|
heading: 'H',
|
||||||
|
intro: 'I',
|
||||||
|
loginHintDirectory: 'LD {{benutzername}}',
|
||||||
|
loginHintLocal: 'LL',
|
||||||
|
closing: 'C',
|
||||||
|
};
|
||||||
|
|
||||||
|
let prisma: ReturnType<typeof makePrisma>;
|
||||||
|
let service: WelcomeMailTemplateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = makePrisma();
|
||||||
|
service = new WelcomeMailTemplateService(prisma as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WelcomeMailTemplateService', () => {
|
||||||
|
it('ohne Vorlage: Standardtexte, custom=false, getCustomTexts=null', async () => {
|
||||||
|
const state = await service.getState('t1');
|
||||||
|
expect(state.custom).toBe(false);
|
||||||
|
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
|
||||||
|
expect(state.defaults).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
|
||||||
|
expect(await service.getCustomTexts('t1')).toBeNull();
|
||||||
|
expect(prisma.__log.every((c) => c.tenantId === 't1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speichern: upsert auf tenantId, gebunden, mit Bearbeiter; danach custom=true', async () => {
|
||||||
|
const state = await service.save('t1', texts, 'ada');
|
||||||
|
const upsert = prisma.__log.find((c) => c.method === 'upsert');
|
||||||
|
expect(upsert?.tenantId).toBe('t1');
|
||||||
|
expect(upsert?.args.where).toEqual({ tenantId: 't1' });
|
||||||
|
expect(upsert?.args.create).toEqual({ tenantId: 't1', ...texts, updatedBy: 'ada' });
|
||||||
|
expect(state.custom).toBe(true);
|
||||||
|
expect(state.texts).toEqual(texts);
|
||||||
|
expect(state.updatedBy).toBe('ada');
|
||||||
|
expect(await service.getCustomTexts('t1')).toEqual(texts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aeltere Vorlage ohne Anmeldehinweise (NULL) → Standardtexte fuer diese Felder, Rest bleibt eigen', async () => {
|
||||||
|
prisma.__rows.push({
|
||||||
|
tenantId: 't1',
|
||||||
|
subject: 'Alt',
|
||||||
|
heading: 'H',
|
||||||
|
intro: 'I',
|
||||||
|
loginHintDirectory: null,
|
||||||
|
loginHintLocal: null,
|
||||||
|
closing: 'C',
|
||||||
|
updatedBy: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
const state = await service.getState('t1');
|
||||||
|
expect(state.custom).toBe(true);
|
||||||
|
expect(state.texts.subject).toBe('Alt');
|
||||||
|
expect(state.texts.loginHintDirectory).toBe(DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory);
|
||||||
|
expect(state.texts.loginHintLocal).toBe(DEFAULT_WELCOME_MAIL_TEXTS.loginHintLocal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Mandantenbindung: Vorlage von t1 ist fuer t2 nicht sichtbar', async () => {
|
||||||
|
await service.save('t1', texts, 'ada');
|
||||||
|
expect(await service.getCustomTexts('t2')).toBeNull();
|
||||||
|
expect(prisma.__log.filter((c) => c.method === 'findUnique').at(-1)?.tenantId).toBe('t2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zuruecksetzen: loescht gebunden, danach wieder Standard', async () => {
|
||||||
|
await service.save('t1', texts, 'ada');
|
||||||
|
const state = await service.reset('t1');
|
||||||
|
const del = prisma.__log.find((c) => c.method === 'deleteMany');
|
||||||
|
expect(del?.tenantId).toBe('t1');
|
||||||
|
expect(del?.args.where).toEqual({ tenantId: 't1' });
|
||||||
|
expect(state.custom).toBe(false);
|
||||||
|
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Firmenname gebunden gelesen; unbekannter Mandant → leer', async () => {
|
||||||
|
expect(await service.getTenantName('t1')).toBe('Beispiel GmbH');
|
||||||
|
expect(await service.getTenantName('tx')).toBe('');
|
||||||
|
expect(prisma.__log.filter((c) => c.model === 'tenant').map((c) => c.tenantId)).toEqual([
|
||||||
|
't1',
|
||||||
|
'tx',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DEFAULT_WELCOME_MAIL_TEXTS, type WelcomeMailTexts } from '@tessera/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WelcomeMailTemplateService — eigene Vorlage der Willkommensmail
|
||||||
|
* (Administrator → Willkommensmail).
|
||||||
|
*
|
||||||
|
* Hoechstens eine Vorlage je Mandant (Tabelle "WelcomeMailTemplate",
|
||||||
|
* `tenantId` eindeutig). Gespeichert werden nur die sechs Texte; Kopf,
|
||||||
|
* Zugangsdaten, Knoepfe und Fusszeile bleiben fest in
|
||||||
|
* `welcome-mail.template.ts`. Fehlt die Vorlage, gelten
|
||||||
|
* `DEFAULT_WELCOME_MAIL_TEXTS` aus `@tessera/shared`. Die zwei
|
||||||
|
* Anmeldehinweise kamen spaeter hinzu (Spalten nullable): fehlt ein Wert in
|
||||||
|
* einer aelteren Vorlage, gilt fuer ihn der Standardtext.
|
||||||
|
*
|
||||||
|
* Mandantenbindung: jeder Zugriff laeuft ueber `forTenant(prisma, tenantId)`.
|
||||||
|
* Beim Versand ist `tenantId` der Mandant des ZIEL-Benutzers
|
||||||
|
* (`WelcomeMailService.send`), in der Verwaltung der Mandant des
|
||||||
|
* angemeldeten Administrators (`WelcomeMailTemplateController`). Die
|
||||||
|
* Platzhalter-Pruefung liegt im DTO (400 bei unbekanntem Platzhalter).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Antwort von GET /welcome-mail-template. */
|
||||||
|
export interface WelcomeMailTemplateState {
|
||||||
|
/** `true` = eigene Vorlage gespeichert, `false` = Standardtexte. */
|
||||||
|
custom: boolean;
|
||||||
|
/** Wirksame Texte (eigene Vorlage oder Standard) — Vorbelegung im Formular. */
|
||||||
|
texts: WelcomeMailTexts;
|
||||||
|
/** Standardtexte fuer "Auf Standard zuruecksetzen". */
|
||||||
|
defaults: WelcomeMailTexts;
|
||||||
|
updatedAt: Date | null;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_SELECT = {
|
||||||
|
subject: true,
|
||||||
|
heading: true,
|
||||||
|
intro: true,
|
||||||
|
loginHintDirectory: true,
|
||||||
|
loginHintLocal: true,
|
||||||
|
closing: true,
|
||||||
|
updatedAt: true,
|
||||||
|
updatedBy: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WelcomeMailTemplateService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getState(tenantId: string): Promise<WelcomeMailTemplateState> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const row = await tenantPrisma.welcomeMailTemplate.findUnique({
|
||||||
|
where: { tenantId },
|
||||||
|
select: TEXT_SELECT,
|
||||||
|
});
|
||||||
|
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
|
||||||
|
if (!row) {
|
||||||
|
return { custom: false, texts: defaults, defaults, updatedAt: null, updatedBy: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
custom: true,
|
||||||
|
texts: {
|
||||||
|
subject: row.subject,
|
||||||
|
heading: row.heading,
|
||||||
|
intro: row.intro,
|
||||||
|
loginHintDirectory: row.loginHintDirectory?.trim()
|
||||||
|
? row.loginHintDirectory
|
||||||
|
: defaults.loginHintDirectory,
|
||||||
|
loginHintLocal: row.loginHintLocal?.trim() ? row.loginHintLocal : defaults.loginHintLocal,
|
||||||
|
closing: row.closing,
|
||||||
|
},
|
||||||
|
defaults,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Texte fuer den Versand: eigene Vorlage des Mandanten, sonst `null` (= Standard). */
|
||||||
|
async getCustomTexts(tenantId: string): Promise<WelcomeMailTexts | null> {
|
||||||
|
const state = await this.getState(tenantId);
|
||||||
|
return state.custom ? state.texts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name des Mandanten fuer `{{firma}}`; leer, wenn der Mandant fehlt. */
|
||||||
|
async getTenantName(tenantId: string): Promise<string> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const tenant = await tenantPrisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
return tenant?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(
|
||||||
|
tenantId: string,
|
||||||
|
texts: WelcomeMailTexts,
|
||||||
|
updatedBy: string,
|
||||||
|
): Promise<WelcomeMailTemplateState> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const data = {
|
||||||
|
subject: texts.subject,
|
||||||
|
heading: texts.heading,
|
||||||
|
intro: texts.intro,
|
||||||
|
loginHintDirectory: texts.loginHintDirectory,
|
||||||
|
loginHintLocal: texts.loginHintLocal,
|
||||||
|
closing: texts.closing,
|
||||||
|
updatedBy,
|
||||||
|
};
|
||||||
|
await tenantPrisma.welcomeMailTemplate.upsert({
|
||||||
|
where: { tenantId },
|
||||||
|
create: { tenantId, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return this.getState(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Auf Standard zuruecksetzen": loescht die eigene Vorlage (idempotent). */
|
||||||
|
async reset(tenantId: string): Promise<WelcomeMailTemplateState> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
await tenantPrisma.welcomeMailTemplate.deleteMany({ where: { tenantId } });
|
||||||
|
return this.getState(tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
|
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
|
import { DEFAULT_WELCOME_MAIL_TEXTS, type WelcomeMailTexts } from '@tessera/shared';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { normalizeOrigin, type WelcomeMailTarget, WelcomeMailService } from './welcome-mail.service';
|
import {
|
||||||
|
normalizeOrigin,
|
||||||
|
WelcomeMailService,
|
||||||
|
type WelcomeMailTarget,
|
||||||
|
} from './welcome-mail.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
|
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
|
||||||
@@ -34,6 +39,10 @@ function makePrisma() {
|
|||||||
log.push({ tenantId, model: 'user', method: 'update', args });
|
log.push({ tenantId, model: 'user', method: 'update', args });
|
||||||
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
|
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
|
||||||
}),
|
}),
|
||||||
|
findFirst: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'user', method: 'findFirst', args });
|
||||||
|
return selfRow;
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -73,36 +82,74 @@ const ldapUser: WelcomeMailTarget = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let prisma: ReturnType<typeof makePrisma>;
|
let prisma: ReturnType<typeof makePrisma>;
|
||||||
|
/** Eigenes Konto des Administrators fuer die Testmail (`user.findFirst`). */
|
||||||
|
let selfRow: {
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
email: string | null;
|
||||||
|
ldapDn: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma = makePrisma();
|
prisma = makePrisma();
|
||||||
|
selfRow = {
|
||||||
|
username: 'admin.lokal',
|
||||||
|
displayName: 'Ada Admin',
|
||||||
|
email: 'ada@example.invalid',
|
||||||
|
ldapDn: null,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function make(mail = makeMail(), config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' })) {
|
/** Vorlagen-Dienst: eigene Vorlage je Mandant (Map), Firmenname je Mandant. */
|
||||||
const service = new WelcomeMailService(prisma as any, mail as any, config as any);
|
function makeTemplates(custom: Record<string, WelcomeMailTexts> = {}) {
|
||||||
|
return {
|
||||||
|
getCustomTexts: vi.fn(async (tenantId: string) => custom[tenantId] ?? null),
|
||||||
|
getTenantName: vi.fn(async (tenantId: string) => `Firma ${tenantId}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function make(
|
||||||
|
mail = makeMail(),
|
||||||
|
config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' }),
|
||||||
|
templates = makeTemplates(),
|
||||||
|
) {
|
||||||
|
const service = new WelcomeMailService(
|
||||||
|
prisma as any,
|
||||||
|
mail as any,
|
||||||
|
config as any,
|
||||||
|
templates as any,
|
||||||
|
);
|
||||||
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
|
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
|
||||||
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
|
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
|
||||||
return { service, mail };
|
return { service, mail, templates };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('WelcomeMailService.send — Vorbedingungen', () => {
|
describe('WelcomeMailService.send — Vorbedingungen', () => {
|
||||||
it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => {
|
it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => {
|
||||||
const { service, mail } = make();
|
const { service, mail } = make();
|
||||||
await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget);
|
await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget);
|
||||||
await service.send({ ...localUser, lastLoginAt: new Date(), role: 'SUPER_ADMIN' } as WelcomeMailTarget);
|
await service.send({
|
||||||
|
...localUser,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
} as WelcomeMailTarget);
|
||||||
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2);
|
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2);
|
||||||
expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2);
|
expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deaktiviertes Konto → ConflictException', async () => {
|
it('deaktiviertes Konto → ConflictException', async () => {
|
||||||
const { service, mail } = make();
|
const { service, mail } = make();
|
||||||
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(ConflictException);
|
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ohne E-Mail-Adresse → BadRequestException', async () => {
|
it('ohne E-Mail-Adresse → BadRequestException', async () => {
|
||||||
const { service, mail } = make();
|
const { service, mail } = make();
|
||||||
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(BadRequestException);
|
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,18 +229,27 @@ describe('WelcomeMailService.send — Versand', () => {
|
|||||||
|
|
||||||
describe('WelcomeMailService.resolveAppUrl', () => {
|
describe('WelcomeMailService.resolveAppUrl', () => {
|
||||||
it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => {
|
it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => {
|
||||||
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }));
|
const { service } = make(
|
||||||
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe('https://tessera.example.invalid');
|
makeMail(),
|
||||||
|
makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }),
|
||||||
|
);
|
||||||
|
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe(
|
||||||
|
'https://tessera.example.invalid',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ohne Konfiguration: Origin der Anfrage', () => {
|
it('ohne Konfiguration: Origin der Anfrage', () => {
|
||||||
const { service } = make(makeMail(), makeConfig({}));
|
const { service } = make(makeMail(), makeConfig({}));
|
||||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
|
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe(
|
||||||
|
'https://alpha.example.invalid',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Konfiguration zeigt nur auf localhost (Compose-Vorgabe) → Origin gewinnt; ohne Origin bleibt die Konfiguration', () => {
|
it('Konfiguration zeigt nur auf localhost (Compose-Vorgabe) → Origin gewinnt; ohne Origin bleibt die Konfiguration', () => {
|
||||||
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'http://localhost:3000' }));
|
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'http://localhost:3000' }));
|
||||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
|
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe(
|
||||||
|
'https://alpha.example.invalid',
|
||||||
|
);
|
||||||
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
|
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,3 +259,173 @@ describe('WelcomeMailService.resolveAppUrl', () => {
|
|||||||
expect(normalizeOrigin('https://a.example.invalid/pfad')).toBe('https://a.example.invalid');
|
expect(normalizeOrigin('https://a.example.invalid/pfad')).toBe('https://a.example.invalid');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('WelcomeMailService.send — eigene Vorlage', () => {
|
||||||
|
const custom: WelcomeMailTexts = {
|
||||||
|
subject: 'Hallo {{vorname}} bei {{firma}}',
|
||||||
|
heading: 'Schön, dass Sie da sind, {{name}}!',
|
||||||
|
intro: 'Erste Zeile\nzweite Zeile\n\nIhr Konto: {{benutzername}} / {{email}} / {{adresse}}',
|
||||||
|
loginHintDirectory: 'Bitte mit dem Kennwort von {{firma}} anmelden, {{vorname}}.',
|
||||||
|
loginHintLocal: 'Zuerst ein eigenes Passwort vergeben, {{vorname}}.',
|
||||||
|
closing: 'Grüße vom <b>IT-Team</b>',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('ohne eigene Vorlage: Standardtexte; Vorlage und Firmenname werden fuer den Mandanten des ZIELS gelesen', async () => {
|
||||||
|
const { service, mail, templates } = make();
|
||||||
|
await service.send({ ...ldapUser, tenantId: 't9' });
|
||||||
|
expect(templates.getCustomTexts).toHaveBeenCalledWith('t9');
|
||||||
|
expect(templates.getTenantName).toHaveBeenCalledWith('t9');
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.subject).toBe('Willkommen bei Tessera');
|
||||||
|
expect(rendered.html).toContain('Tessera gibt es auch als Desktop-App');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit eigener Vorlage des Ziel-Mandanten: Platzhalter ersetzt, Absaetze/Umbrueche, Markup aus der Vorlage escaped, feste Bausteine bleiben', async () => {
|
||||||
|
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
|
||||||
|
await service.send(localUser);
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.subject).toBe('Hallo Max bei Firma t1');
|
||||||
|
expect(rendered.html).toContain('Schön, dass Sie da sind, Max Muster!');
|
||||||
|
expect(rendered.html).toContain('Erste Zeile<br>zweite Zeile');
|
||||||
|
expect(rendered.html).toContain(
|
||||||
|
'Ihr Konto: max.muster / max@example.invalid / https://tessera.example.invalid',
|
||||||
|
);
|
||||||
|
expect(rendered.html).toContain('Grüße vom <b>IT-Team</b>');
|
||||||
|
expect(rendered.html).not.toContain('<b>IT-Team</b>');
|
||||||
|
expect(rendered.html).not.toContain('Desktop-App');
|
||||||
|
// feste Bausteine
|
||||||
|
expect(rendered.html).toContain('Passwort festlegen');
|
||||||
|
expect(rendered.html).toContain('/reset-password/');
|
||||||
|
expect(rendered.html).toContain('Zu Tessera');
|
||||||
|
expect(rendered.html).toContain('Benutzername');
|
||||||
|
expect(rendered.text).toContain('Erste Zeile\nzweite Zeile');
|
||||||
|
// Anmeldehinweis fuer lokale Konten aus der Vorlage
|
||||||
|
expect(rendered.html).toContain('Zuerst ein eigenes Passwort vergeben, Max.');
|
||||||
|
expect(rendered.html).not.toContain('persönliches Passwort fest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Verzeichniskonto mit eigener Vorlage: eigener Verzeichnis-Hinweis statt Windows-Standard', async () => {
|
||||||
|
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
|
||||||
|
await service.send(ldapUser);
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.html).toContain('Bitte mit dem Kennwort von Firma t1 anmelden');
|
||||||
|
expect(rendered.html).not.toContain('gewohnten Windows-Passwort');
|
||||||
|
expect(rendered.html).not.toContain('Zuerst ein eigenes Passwort vergeben');
|
||||||
|
expect(rendered.text).toContain('Bitte mit dem Kennwort von Firma t1 anmelden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Vorlage eines ANDEREN Mandanten wirkt nicht', async () => {
|
||||||
|
const { service, mail } = make(undefined, undefined, makeTemplates({ t2: custom }));
|
||||||
|
await service.send(localUser);
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.subject).toBe('Willkommen bei Tessera');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Platzhalterwerte mit Markup werden escaped (Anzeigename in {{name}}/{{vorname}})', async () => {
|
||||||
|
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
|
||||||
|
await service.send({ ...ldapUser, displayName: '<img src=x onerror=alert(1)> Böse' });
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.html).not.toContain('<img src=x');
|
||||||
|
expect(rendered.html).toContain('<img src=x onerror=alert(1)> Böse');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_TEXTS: WelcomeMailTexts = { ...DEFAULT_WELCOME_MAIL_TEXTS };
|
||||||
|
|
||||||
|
describe('WelcomeMailService.preview', () => {
|
||||||
|
const texts: WelcomeMailTexts = {
|
||||||
|
subject: 'Betreff {{name}}',
|
||||||
|
heading: 'Hallo {{vorname}}',
|
||||||
|
intro: 'Bei {{firma}}',
|
||||||
|
loginHintDirectory: 'Verzeichnis-Hinweis {{benutzername}}',
|
||||||
|
loginHintLocal: 'Lokal-Hinweis {{benutzername}}',
|
||||||
|
closing: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('Beispielwerte, Firmenname des eigenen Mandanten, kein Token, kein Versand, kein cid:', async () => {
|
||||||
|
const { service, mail, templates } = make();
|
||||||
|
const rendered = await service.preview('t1', texts, 'local');
|
||||||
|
expect(templates.getTenantName).toHaveBeenCalledWith('t1');
|
||||||
|
expect(rendered.subject).toBe('Betreff Max Mustermann');
|
||||||
|
expect(rendered.html).toContain('Hallo Max');
|
||||||
|
expect(rendered.html).toContain('Bei Firma t1');
|
||||||
|
expect(rendered.html).toContain('/reset-password/beispiel');
|
||||||
|
expect(rendered.html).toContain('Lokal-Hinweis max.mustermann');
|
||||||
|
expect(rendered.html).not.toContain('Verzeichnis-Hinweis');
|
||||||
|
expect(rendered.html).not.toContain('cid:');
|
||||||
|
expect(prisma.__log).toHaveLength(0);
|
||||||
|
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Verzeichniskonto-Variante: Windows-Hinweis statt Passwort-Knopf', async () => {
|
||||||
|
const { service } = make();
|
||||||
|
const rendered = await service.preview('t1', DEFAULT_TEXTS, 'directory');
|
||||||
|
expect(rendered.html).toContain('gewohnten Windows-Passwort');
|
||||||
|
expect(rendered.html).not.toContain('Passwort festlegen');
|
||||||
|
const custom = await service.preview('t1', texts, 'directory');
|
||||||
|
expect(custom.html).toContain('Verzeichnis-Hinweis max.mustermann');
|
||||||
|
expect(custom.html).not.toContain('Lokal-Hinweis');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WelcomeMailService.sendTest', () => {
|
||||||
|
const texts: WelcomeMailTexts = {
|
||||||
|
subject: 'Test {{name}}',
|
||||||
|
heading: 'Hallo {{vorname}}',
|
||||||
|
intro: 'Text',
|
||||||
|
loginHintDirectory: DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory,
|
||||||
|
loginHintLocal: 'Eigener Lokal-Hinweis für {{vorname}}',
|
||||||
|
closing: 'Gruß',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('geht an die eigene Adresse, liest das eigene Konto gebunden, erzeugt KEIN Token und setzt KEIN welcomeMailSentAt', async () => {
|
||||||
|
const { service, mail } = make();
|
||||||
|
const result = await service.sendTest('t1', 'u-self', texts);
|
||||||
|
expect(result.to).toBe('ada@example.invalid');
|
||||||
|
const find = prisma.__log.find((c) => c.method === 'findFirst');
|
||||||
|
expect(find?.tenantId).toBe('t1');
|
||||||
|
expect(find?.args.where).toEqual({ id: 'u-self', tenantId: 't1' });
|
||||||
|
expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false);
|
||||||
|
expect(prisma.__log.some((c) => c.method === 'update')).toBe(false);
|
||||||
|
|
||||||
|
const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[];
|
||||||
|
expect(tenantId).toBe('t1');
|
||||||
|
expect(to).toBe('ada@example.invalid');
|
||||||
|
expect(rendered.subject).toBe('Test Ada Admin');
|
||||||
|
expect(rendered.html).toContain('Testmail');
|
||||||
|
// lokales Konto: Beispiel-Link zur Anmeldeseite, kein reset-password
|
||||||
|
expect(rendered.html).toContain('Passwort festlegen');
|
||||||
|
expect(rendered.html).toContain('Beispiel-Link');
|
||||||
|
expect(rendered.html).not.toContain('reset-password');
|
||||||
|
// Anmeldehinweis fuer lokale Konten aus dem Formularinhalt
|
||||||
|
expect(rendered.html).toContain('Eigener Lokal-Hinweis für Ada');
|
||||||
|
expect(rendered.text).not.toContain('reset-password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verzeichnisgefuehrtes eigenes Konto: Windows-Hinweis', async () => {
|
||||||
|
selfRow = { ...(selfRow as NonNullable<typeof selfRow>), ldapDn: 'CN=Ada' };
|
||||||
|
const { service, mail } = make();
|
||||||
|
await service.sendTest('t1', 'u-self', texts);
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.html).toContain('gewohnten Windows-Passwort');
|
||||||
|
expect(rendered.html).not.toContain('Passwort festlegen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne eigene Adresse → BadRequestException, kein Versand', async () => {
|
||||||
|
selfRow = { ...(selfRow as NonNullable<typeof selfRow>), email: null };
|
||||||
|
const { service, mail } = make();
|
||||||
|
await expect(service.sendTest('t1', 'u-self', texts)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Versandweg → ConflictException; Versandfehler → BadGatewayException', async () => {
|
||||||
|
await expect(
|
||||||
|
make(makeMail({ available: false })).service.sendTest('t1', 'u-self', texts),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
await expect(
|
||||||
|
make(makeMail({ fail: true })).service.sendTest('t1', 'u-self', texts),
|
||||||
|
).rejects.toBeInstanceOf(BadGatewayException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
BadGatewayException,
|
BadGatewayException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -7,12 +8,17 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
import { randomUUID } from 'node:crypto';
|
import type { WelcomeMailTexts } from '@tessera/shared';
|
||||||
import { WELCOME_TOKEN_TTL_MS } from '../auth/password-reset-token';
|
import { WELCOME_TOKEN_TTL_MS } from '../auth/password-reset-token';
|
||||||
import { loadWelcomeHeaderPng, MailService, WELCOME_HEADER_CID } from '../mail/mail.service';
|
import { loadWelcomeHeaderPng, MailService, WELCOME_HEADER_CID } from '../mail/mail.service';
|
||||||
import { renderWelcomeMail, type WelcomeMailAccount } from '../mail/welcome-mail.template';
|
import {
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
type RenderedWelcomeMail,
|
||||||
|
renderWelcomeMail,
|
||||||
|
type WelcomeMailAccount,
|
||||||
|
} from '../mail/welcome-mail.template';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung
|
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung
|
||||||
@@ -42,6 +48,15 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
|
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
|
||||||
* SMTP-Weg genau dieses Mandanten (`MailService.sendWelcomeMail`).
|
* SMTP-Weg genau dieses Mandanten (`MailService.sendWelcomeMail`).
|
||||||
*
|
*
|
||||||
|
* Eigene Vorlage (Administrator → Willkommensmail): `send` nimmt die Texte
|
||||||
|
* der Vorlage des Mandanten des ZIELS (`WelcomeMailTemplateService`), sonst
|
||||||
|
* die Standardtexte. `preview` rendert einen (auch ungespeicherten)
|
||||||
|
* Formularinhalt mit Beispielwerten, `sendTest` schickt ihn an die eigene
|
||||||
|
* Adresse des Administrators — OHNE Token: bei einem lokalen eigenen Konto
|
||||||
|
* zeigt die Testmail den Knopf "Passwort festlegen" als Beispiel-Link zur
|
||||||
|
* Anmeldeseite (Kontoart `local-example`), damit nie ein nutzbarer
|
||||||
|
* Passwort-Link an eine Testadresse geht.
|
||||||
|
*
|
||||||
* Adresse in der Mail: `TESSERA_APP_URL` (in Compose aus `APP_URL`), sonst
|
* Adresse in der Mail: `TESSERA_APP_URL` (in Compose aus `APP_URL`), sonst
|
||||||
* der Origin der Admin-Anfrage. Zeigt die Konfiguration nur auf
|
* der Origin der Admin-Anfrage. Zeigt die Konfiguration nur auf
|
||||||
* `localhost` (Compose-Vorgabe, nie gesetzt), gewinnt ein vorhandener
|
* `localhost` (Compose-Vorgabe, nie gesetzt), gewinnt ein vorhandener
|
||||||
@@ -52,13 +67,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
|
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
|
||||||
export type WelcomeMailTarget = Pick<
|
export type WelcomeMailTarget = Pick<
|
||||||
User,
|
User,
|
||||||
| 'id'
|
'id' | 'tenantId' | 'username' | 'displayName' | 'email' | 'ldapDn' | 'isActive'
|
||||||
| 'tenantId'
|
|
||||||
| 'username'
|
|
||||||
| 'displayName'
|
|
||||||
| 'email'
|
|
||||||
| 'ldapDn'
|
|
||||||
| 'isActive'
|
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface WelcomeMailResult {
|
export interface WelcomeMailResult {
|
||||||
@@ -68,6 +77,13 @@ export interface WelcomeMailResult {
|
|||||||
|
|
||||||
const FALLBACK_APP_URL = 'http://localhost:3000';
|
const FALLBACK_APP_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
/** Beispielwerte der Vorschau (Administrator → Willkommensmail). */
|
||||||
|
export const PREVIEW_SAMPLE = {
|
||||||
|
name: 'Max Mustermann',
|
||||||
|
username: 'max.mustermann',
|
||||||
|
email: 'max.mustermann@example.com',
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** `http(s)://host[:port]` aus einem Origin-Kopf, sonst `null`. */
|
/** `http(s)://host[:port]` aus einem Origin-Kopf, sonst `null`. */
|
||||||
export function normalizeOrigin(value: string | undefined | null): string | null {
|
export function normalizeOrigin(value: string | undefined | null): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
@@ -97,6 +113,7 @@ export class WelcomeMailService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly mailService: MailService,
|
private readonly mailService: MailService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
|
private readonly templateService: WelcomeMailTemplateService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
|
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
|
||||||
@@ -158,12 +175,19 @@ export class WelcomeMailService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [texts, tenantName] = await Promise.all([
|
||||||
|
this.templateService.getCustomTexts(target.tenantId),
|
||||||
|
this.templateService.getTenantName(target.tenantId),
|
||||||
|
]);
|
||||||
const mail = renderWelcomeMail({
|
const mail = renderWelcomeMail({
|
||||||
name: target.displayName?.trim() || target.username,
|
name: target.displayName?.trim() || target.username,
|
||||||
username: target.username,
|
username: target.username,
|
||||||
|
email: to,
|
||||||
|
tenantName,
|
||||||
appUrl,
|
appUrl,
|
||||||
account,
|
account,
|
||||||
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
||||||
|
texts,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -188,4 +212,98 @@ export class WelcomeMailService {
|
|||||||
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
|
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
|
||||||
return { to, welcomeMailSentAt };
|
return { to, welcomeMailSentAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorschau fuer Administrator → Willkommensmail: rendert `texts` (auch
|
||||||
|
* ungespeichert) mit festen Beispielwerten und dem Firmennamen des eigenen
|
||||||
|
* Mandanten. Das Kopfbild steht als `data:`-Adresse im HTML statt als
|
||||||
|
* `cid:`, damit die Vorschau im Rahmen der Seite der echten Mail gleicht.
|
||||||
|
* Der Link "Passwort festlegen" ist ein Beispiel ohne Token.
|
||||||
|
*/
|
||||||
|
async preview(
|
||||||
|
tenantId: string,
|
||||||
|
texts: WelcomeMailTexts,
|
||||||
|
accountKind: 'directory' | 'local',
|
||||||
|
requestOrigin?: string | null,
|
||||||
|
): Promise<RenderedWelcomeMail> {
|
||||||
|
const appUrl = this.resolveAppUrl(requestOrigin);
|
||||||
|
const header = loadWelcomeHeaderPng();
|
||||||
|
const account: WelcomeMailAccount =
|
||||||
|
accountKind === 'directory'
|
||||||
|
? { kind: 'directory' }
|
||||||
|
: {
|
||||||
|
kind: 'local',
|
||||||
|
setPasswordUrl: `${appUrl}/reset-password/beispiel`,
|
||||||
|
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
|
||||||
|
};
|
||||||
|
return renderWelcomeMail({
|
||||||
|
name: PREVIEW_SAMPLE.name,
|
||||||
|
username: PREVIEW_SAMPLE.username,
|
||||||
|
email: PREVIEW_SAMPLE.email,
|
||||||
|
tenantName: await this.templateService.getTenantName(tenantId),
|
||||||
|
appUrl,
|
||||||
|
account,
|
||||||
|
headerImageSrc: header ? `data:image/png;base64,${header.toString('base64')}` : null,
|
||||||
|
texts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testmail an den angemeldeten Administrator selbst, mit dem aktuellen
|
||||||
|
* (auch ungespeicherten) Formularinhalt und dem eigenen Konto als
|
||||||
|
* Beispiel. Es entsteht KEIN Token: ein verzeichnisgefuehrtes eigenes
|
||||||
|
* Konto bekommt den Windows-Hinweis, ein lokales den Beispiel-Link zur
|
||||||
|
* Anmeldeseite (`local-example`). `welcomeMailSentAt` bleibt unberuehrt.
|
||||||
|
* Ohne eigene Adresse 400, ohne Versandweg 409, Versandfehler 502.
|
||||||
|
*/
|
||||||
|
async sendTest(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
texts: WelcomeMailTexts,
|
||||||
|
requestOrigin?: string | null,
|
||||||
|
): Promise<{ to: string }> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const self = await tenantPrisma.user.findFirst({
|
||||||
|
where: { id: userId, tenantId },
|
||||||
|
select: { username: true, displayName: true, email: true, ldapDn: true },
|
||||||
|
});
|
||||||
|
const to = self?.email?.trim();
|
||||||
|
if (!self || !to) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Für Ihr eigenes Konto ist keine E-Mail-Adresse hinterlegt. Hinterlegen Sie zuerst eine Adresse (Administrator → Benutzer), dann kann die Testmail an Sie gehen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(await this.mailService.hasConfiguredTransport(tenantId))) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Für den E-Mail-Versand ist noch kein SMTP-Server eingerichtet. Ein Administrator legt ihn unter Administrator → SMTP fest.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mail = renderWelcomeMail({
|
||||||
|
name: self.displayName?.trim() || self.username,
|
||||||
|
username: self.username,
|
||||||
|
email: to,
|
||||||
|
tenantName: await this.templateService.getTenantName(tenantId),
|
||||||
|
appUrl: this.resolveAppUrl(requestOrigin),
|
||||||
|
account: self.ldapDn ? { kind: 'directory' } : { kind: 'local-example' },
|
||||||
|
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
||||||
|
texts,
|
||||||
|
notice:
|
||||||
|
'Testmail aus Administrator → Willkommensmail – so sieht die Willkommensmail mit Ihrer Vorlage aus.',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.mailService.sendWelcomeMail(tenantId, to, mail);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Welcome mail test to user ${userId} failed`,
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
);
|
||||||
|
throw new BadGatewayException(
|
||||||
|
'Die Testmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.log(`Welcome mail test sent to user ${userId} (tenant ${tenantId})`);
|
||||||
|
return { to };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -939,6 +939,41 @@ fn external_target(url: &tauri::Url) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Downloads, die die App selbst speichert statt sie an den System-Browser
|
||||||
|
/// zu geben: Inhalte, die die Seite im Speicher erzeugt hat (`blob:`,
|
||||||
|
/// `data:`) -- der Browser kann sie nicht abrufen.
|
||||||
|
fn saves_in_app(url: &tauri::Url) -> bool {
|
||||||
|
matches!(url.scheme(), "blob" | "data")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Titel und Text der Meldung nach einem in der App gespeicherten Download.
|
||||||
|
fn download_notice(path: Option<&std::path::Path>, success: bool) -> (String, String) {
|
||||||
|
let name = path
|
||||||
|
.and_then(|p| p.file_name())
|
||||||
|
.map(|n| n.to_string_lossy().into_owned());
|
||||||
|
let folder = path
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.and_then(|p| p.file_name())
|
||||||
|
.map(|n| n.to_string_lossy().into_owned());
|
||||||
|
match (success, name) {
|
||||||
|
(true, Some(name)) => (
|
||||||
|
"Download gespeichert".to_string(),
|
||||||
|
match folder {
|
||||||
|
Some(folder) => format!("„{name}“ liegt im Ordner {folder}."),
|
||||||
|
None => format!("„{name}“ wurde gespeichert."),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(true, None) => (
|
||||||
|
"Download gespeichert".to_string(),
|
||||||
|
"Die Datei wurde gespeichert.".to_string(),
|
||||||
|
),
|
||||||
|
(false, _) => (
|
||||||
|
"Download fehlgeschlagen".to_string(),
|
||||||
|
"Die Datei konnte nicht gespeichert werden.".to_string(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Hauptfenster nach vorne holen. Ein minimiertes Fenster (z. B. per Win+D)
|
/// Hauptfenster nach vorne holen. Ein minimiertes Fenster (z. B. per Win+D)
|
||||||
/// wird zuerst wiederhergestellt.
|
/// wird zuerst wiederhergestellt.
|
||||||
fn show_main_window(app: &AppHandle) {
|
fn show_main_window(app: &AppHandle) {
|
||||||
@@ -1009,15 +1044,36 @@ pub fn run() {
|
|||||||
tauri::webview::NewWindowResponse::Deny
|
tauri::webview::NewWindowResponse::Deny
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on_download(|webview, event| {
|
.on_download(|webview, event| match event {
|
||||||
if let tauri::webview::DownloadEvent::Requested { url, .. } = event {
|
// Dateien, die die Seite selbst erzeugt (blob:/data:, z. B.
|
||||||
|
// Zertifikats-Downloads), kennt der System-Browser nicht --
|
||||||
|
// Windows meldete „Holen Sie sich eine App, um diesen
|
||||||
|
// ‚blob‘-Link zu öffnen“ (VM 8233, 01.10.2026). Die speichert
|
||||||
|
// die App selbst im Ordner Downloads und meldet es danach.
|
||||||
|
tauri::webview::DownloadEvent::Requested { url, .. } => {
|
||||||
|
if saves_in_app(&url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
let _ = webview
|
let _ = webview
|
||||||
.app_handle()
|
.app_handle()
|
||||||
.opener()
|
.opener()
|
||||||
.open_url(url.to_string(), None::<&str>);
|
.open_url(url.to_string(), None::<&str>);
|
||||||
return false;
|
false
|
||||||
}
|
}
|
||||||
true
|
tauri::webview::DownloadEvent::Finished { url, path, success } => {
|
||||||
|
if saves_in_app(&url) {
|
||||||
|
let (title, body) = download_notice(path.as_deref(), success);
|
||||||
|
let _ = webview
|
||||||
|
.app_handle()
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title(title)
|
||||||
|
.body(body)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => true,
|
||||||
})
|
})
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
@@ -1500,6 +1556,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saves_in_app_nur_blob_und_data() {
|
||||||
|
for (url, expected) in [
|
||||||
|
("blob:http://localhost:3000/0c1d-11", true),
|
||||||
|
("data:application/x-pem-file;base64,QUJD", true),
|
||||||
|
("https://alpha.tessera.ctl.de/desktop/download/x.exe", false),
|
||||||
|
("http://intranet.local/datei.pdf", false),
|
||||||
|
] {
|
||||||
|
let parsed = tauri::Url::parse(url).unwrap();
|
||||||
|
assert_eq!(saves_in_app(&parsed), expected, "{url}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_notice_nennt_datei_und_ordner() {
|
||||||
|
let path = std::path::Path::new("/home/tessera/Downloads/www.example.de.crt");
|
||||||
|
let (title, body) = download_notice(Some(path), true);
|
||||||
|
assert_eq!(title, "Download gespeichert");
|
||||||
|
assert_eq!(body, "„www.example.de.crt“ liegt im Ordner Downloads.");
|
||||||
|
|
||||||
|
let (title, _) = download_notice(None, false);
|
||||||
|
assert_eq!(title, "Download fehlgeschlagen");
|
||||||
|
|
||||||
|
let (_, body) = download_notice(None, true);
|
||||||
|
assert_eq!(body, "Die Datei wurde gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn external_target_nur_http_und_https() {
|
fn external_target_nur_http_und_https() {
|
||||||
let https = tauri::Url::parse("https://www.google.com/search?q=tessera").unwrap();
|
let https = tauri::Url::parse("https://www.google.com/search?q=tessera").unwrap();
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -208,3 +208,81 @@ export async function postForm(
|
|||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zertifikatspaket (quick-261001-l4q) — spiegelt apps/api/src/cert-manager/cert-bundle.ts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
|
||||||
|
export type BundleExportFormat =
|
||||||
|
| 'crt'
|
||||||
|
| 'cer'
|
||||||
|
| 'fullchain'
|
||||||
|
| 'p7b'
|
||||||
|
| 'pfx'
|
||||||
|
| 'key'
|
||||||
|
| 'key-rsa'
|
||||||
|
| 'key-der'
|
||||||
|
| 'csr'
|
||||||
|
| 'csr-der';
|
||||||
|
|
||||||
|
export interface BundleItem {
|
||||||
|
id: string;
|
||||||
|
kind: BundleItemKind;
|
||||||
|
role?: CertRole;
|
||||||
|
sources: string[];
|
||||||
|
pem: string;
|
||||||
|
baseName: string;
|
||||||
|
cn: string;
|
||||||
|
organization: string;
|
||||||
|
issuerCn: string;
|
||||||
|
notBefore: string | null;
|
||||||
|
notAfter: string | null;
|
||||||
|
isExpired: boolean | null;
|
||||||
|
daysLeft: number | null;
|
||||||
|
san: string[];
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number;
|
||||||
|
serialNumber: string;
|
||||||
|
sha256: string;
|
||||||
|
matchId: string | null;
|
||||||
|
chainIds: string[];
|
||||||
|
formats: BundleExportFormat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleAnalysis {
|
||||||
|
items: BundleItem[];
|
||||||
|
locked: string[];
|
||||||
|
ignored: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mehrere Dateien (auch ZIP) analysieren — POST /modules/cert-manager/analyze. */
|
||||||
|
export async function analyzeBundleAction(files: File[], password?: string): Promise<BundleAnalysis> {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const file of files) form.append('files', file);
|
||||||
|
if (password) form.append('password', password);
|
||||||
|
return postForm('analyze', form) as Promise<BundleAnalysis>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Teil des Pakets in ein Format bringen — POST /modules/cert-manager/export (JSON). */
|
||||||
|
export async function exportBundleItemAction(input: {
|
||||||
|
kind: BundleItemKind;
|
||||||
|
pem: string;
|
||||||
|
format: BundleExportFormat;
|
||||||
|
baseName: string;
|
||||||
|
chain?: string[];
|
||||||
|
keyPem?: string;
|
||||||
|
password?: string;
|
||||||
|
}): Promise<FileResponse> {
|
||||||
|
const response = await fetch(`${API_URL}/modules/cert-manager/export`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => '');
|
||||||
|
throw new Error(`${response.status} ${body}`.trim());
|
||||||
|
}
|
||||||
|
return response.json() as Promise<FileResponse>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ vi.mock('next-intl', () => ({
|
|||||||
const certManager: Record<string, string> = {
|
const certManager: Record<string, string> = {
|
||||||
'title': 'Zertifikat-Manager',
|
'title': 'Zertifikat-Manager',
|
||||||
'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.',
|
'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.',
|
||||||
|
'tabs.overview': 'Uebersicht',
|
||||||
'tabs.inspect': 'Analysieren',
|
'tabs.inspect': 'Analysieren',
|
||||||
'tabs.split': 'Aufteilen',
|
'tabs.split': 'Aufteilen',
|
||||||
'tabs.merge': 'Zusammenfuehren',
|
'tabs.merge': 'Zusammenfuehren',
|
||||||
@@ -76,8 +77,15 @@ describe('CertManagerPage shell', () => {
|
|||||||
expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument();
|
expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Inspect tab empty state on initial render', () => {
|
it('startet mit der Uebersicht ohne Einzeldatei-Eingabe (quick-261001-l4q)', () => {
|
||||||
render(<CertManagerPage />);
|
render(<CertManagerPage />);
|
||||||
|
expect(screen.getByText('dropTitle')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Datei hierher ziehen oder klicken')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Inspect tab empty state when Inspect tab is active', () => {
|
||||||
|
render(<CertManagerPage />);
|
||||||
|
fireEvent.click(screen.getAllByText('Analysieren')[0]);
|
||||||
expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument();
|
expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument();
|
expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
||||||
|
values ? `${key} ${JSON.stringify(values)}` : key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../actions', () => ({
|
||||||
|
analyzeBundleAction: vi.fn(),
|
||||||
|
exportBundleItemAction: vi.fn(),
|
||||||
|
downloadBase64: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
analyzeBundleAction,
|
||||||
|
type BundleItem,
|
||||||
|
downloadBase64,
|
||||||
|
exportBundleItemAction,
|
||||||
|
} from '../actions';
|
||||||
|
import { OverviewTab } from './OverviewTab';
|
||||||
|
|
||||||
|
const mockAnalyze = analyzeBundleAction as ReturnType<typeof vi.fn>;
|
||||||
|
const mockExport = exportBundleItemAction as ReturnType<typeof vi.fn>;
|
||||||
|
const mockDownload = downloadBase64 as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function item(over: Partial<BundleItem>): BundleItem {
|
||||||
|
return {
|
||||||
|
id: 'x',
|
||||||
|
kind: 'certificate',
|
||||||
|
sources: ['a.pem'],
|
||||||
|
pem: 'PEM',
|
||||||
|
baseName: 'www.example.test',
|
||||||
|
cn: 'www.example.test',
|
||||||
|
organization: '',
|
||||||
|
issuerCn: '',
|
||||||
|
notBefore: null,
|
||||||
|
notAfter: null,
|
||||||
|
isExpired: null,
|
||||||
|
daysLeft: null,
|
||||||
|
san: [],
|
||||||
|
keyType: 'RSA',
|
||||||
|
keyBits: 2048,
|
||||||
|
serialNumber: '',
|
||||||
|
sha256: '',
|
||||||
|
matchId: null,
|
||||||
|
chainIds: [],
|
||||||
|
formats: [],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEAF = item({
|
||||||
|
id: 'leaf',
|
||||||
|
role: 'end-entity',
|
||||||
|
pem: 'LEAF-PEM',
|
||||||
|
issuerCn: 'Test Intermediate CA',
|
||||||
|
notBefore: '2026-01-01T00:00:00.000Z',
|
||||||
|
notAfter: '2027-01-01T00:00:00.000Z',
|
||||||
|
isExpired: false,
|
||||||
|
daysLeft: 200,
|
||||||
|
san: ['www.example.test', 'example.test'],
|
||||||
|
matchId: 'key',
|
||||||
|
chainIds: ['inter'],
|
||||||
|
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
|
||||||
|
});
|
||||||
|
const INTER = item({
|
||||||
|
id: 'inter',
|
||||||
|
role: 'intermediate',
|
||||||
|
pem: 'INTER-PEM',
|
||||||
|
cn: 'Test Intermediate CA',
|
||||||
|
isExpired: false,
|
||||||
|
daysLeft: 900,
|
||||||
|
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
|
||||||
|
});
|
||||||
|
const KEY = item({
|
||||||
|
id: 'key',
|
||||||
|
kind: 'privateKey',
|
||||||
|
pem: 'KEY-PEM',
|
||||||
|
matchId: 'leaf',
|
||||||
|
formats: ['key', 'key-rsa', 'key-der'],
|
||||||
|
});
|
||||||
|
const CSR = item({
|
||||||
|
id: 'csr',
|
||||||
|
kind: 'csr',
|
||||||
|
pem: 'CSR-PEM',
|
||||||
|
matchId: 'leaf',
|
||||||
|
formats: ['csr', 'csr-der'],
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function upload(files: File[]) {
|
||||||
|
render(<OverviewTab />);
|
||||||
|
fireEvent.change(screen.getByTestId('overview-file-input'), { target: { files } });
|
||||||
|
await waitFor(() => expect(mockAnalyze).toHaveBeenCalled());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OverviewTab', () => {
|
||||||
|
it('analysiert die abgelegten Dateien und zeigt je Teil Typ und Download-Knoepfe', async () => {
|
||||||
|
mockAnalyze.mockResolvedValue({
|
||||||
|
items: [LEAF, INTER, KEY, CSR],
|
||||||
|
locked: [],
|
||||||
|
ignored: ['.dnstxtrecord'],
|
||||||
|
});
|
||||||
|
const zip = new File(['zip'], 'paket.zip');
|
||||||
|
await upload([zip]);
|
||||||
|
|
||||||
|
expect(mockAnalyze).toHaveBeenCalledWith([zip], undefined);
|
||||||
|
const cards = await screen.findAllByTestId('bundle-item');
|
||||||
|
expect(cards).toHaveLength(4);
|
||||||
|
expect(within(cards[0]).getByText('type.end-entity')).toBeInTheDocument();
|
||||||
|
expect(within(cards[1]).getByText('type.intermediate')).toBeInTheDocument();
|
||||||
|
expect(within(cards[2]).getByText('type.privateKey')).toBeInTheDocument();
|
||||||
|
expect(within(cards[3]).getByText('type.csr')).toBeInTheDocument();
|
||||||
|
// Zuordnung und Gueltigkeit
|
||||||
|
expect(within(cards[0]).getByText('www.example.test, example.test')).toBeInTheDocument();
|
||||||
|
expect(within(cards[0]).getByText('valid')).toBeInTheDocument();
|
||||||
|
expect(within(cards[2]).getByText(/type\.end-entity/)).toBeInTheDocument();
|
||||||
|
// alle Formate als Knoepfe
|
||||||
|
expect(
|
||||||
|
within(cards[0])
|
||||||
|
.getAllByRole('button')
|
||||||
|
.map((b) => b.textContent),
|
||||||
|
).toEqual(['format.crt', 'format.cer', 'format.fullchain', 'format.p7b', 'format.pfx']);
|
||||||
|
expect(within(cards[2]).getAllByRole('button')).toHaveLength(3);
|
||||||
|
expect(screen.getByText(/ignored/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Download schickt Kette und Schluessel mit und loest die Datei aus', async () => {
|
||||||
|
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
|
||||||
|
mockExport.mockResolvedValue({
|
||||||
|
filename: 'www.example.test-fullchain.pem',
|
||||||
|
content: 'Zm9v',
|
||||||
|
mimeType: 'x',
|
||||||
|
});
|
||||||
|
await upload([new File(['x'], 'a.pem')]);
|
||||||
|
const [leafCard] = await screen.findAllByTestId('bundle-item');
|
||||||
|
|
||||||
|
fireEvent.click(within(leafCard).getByText('format.fullchain'));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockDownload).toHaveBeenCalledWith('www.example.test-fullchain.pem', 'Zm9v', 'x'),
|
||||||
|
);
|
||||||
|
expect(mockExport).toHaveBeenCalledWith({
|
||||||
|
kind: 'certificate',
|
||||||
|
pem: 'LEAF-PEM',
|
||||||
|
format: 'fullchain',
|
||||||
|
baseName: 'www.example.test',
|
||||||
|
chain: ['INTER-PEM'],
|
||||||
|
keyPem: 'KEY-PEM',
|
||||||
|
password: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PFX fragt erst ein Passwort ab', async () => {
|
||||||
|
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
|
||||||
|
mockExport.mockResolvedValue({
|
||||||
|
filename: 'www.example.test.pfx',
|
||||||
|
content: 'Zm9v',
|
||||||
|
mimeType: 'x',
|
||||||
|
});
|
||||||
|
await upload([new File(['x'], 'a.pem')]);
|
||||||
|
const [leafCard] = await screen.findAllByTestId('bundle-item');
|
||||||
|
|
||||||
|
fireEvent.click(within(leafCard).getByText('format.pfx'));
|
||||||
|
expect(mockExport).not.toHaveBeenCalled();
|
||||||
|
expect(within(leafCard).getByText('pfxWithKey')).toBeInTheDocument();
|
||||||
|
const download = within(leafCard).getByText('pfxDownload');
|
||||||
|
expect(download).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.change(within(leafCard).getByLabelText('pfxPassword'), {
|
||||||
|
target: { value: 'geheim' },
|
||||||
|
});
|
||||||
|
fireEvent.click(download);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockExport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ format: 'pfx', password: 'geheim' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('geschuetzte PFX: Passwort eingeben und erneut pruefen', async () => {
|
||||||
|
mockAnalyze.mockResolvedValueOnce({ items: [LEAF], locked: ['a.pfx'], ignored: [] });
|
||||||
|
const pfx = new File(['x'], 'a.pfx');
|
||||||
|
await upload([pfx]);
|
||||||
|
|
||||||
|
fireEvent.change(await screen.findByLabelText('lockedPassword'), { target: { value: 'pw' } });
|
||||||
|
mockAnalyze.mockResolvedValueOnce({ items: [LEAF, KEY], locked: [], ignored: [] });
|
||||||
|
fireEvent.click(screen.getByText('unlock'));
|
||||||
|
await waitFor(() => expect(mockAnalyze).toHaveBeenLastCalledWith([pfx], 'pw'));
|
||||||
|
expect(await screen.findAllByTestId('bundle-item')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler beim Pruefen wird angezeigt', async () => {
|
||||||
|
mockAnalyze.mockRejectedValue(new Error('400'));
|
||||||
|
await upload([new File(['x'], 'a.txt')]);
|
||||||
|
expect(await screen.findByText('error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
analyzeBundleAction,
|
||||||
|
type BundleAnalysis,
|
||||||
|
type BundleExportFormat,
|
||||||
|
type BundleItem,
|
||||||
|
downloadBase64,
|
||||||
|
exportBundleItemAction,
|
||||||
|
} from '../actions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OverviewTab (quick-261001-l4q) — Zertifikatspaket vom Aussteller auf einmal
|
||||||
|
* hochladen (mehrere Dateien oder ZIP), sehen, welche Datei was ist, und jedes
|
||||||
|
* Teil in jedem passenden Format herunterladen. Die Erkennung macht der Server
|
||||||
|
* (POST analyze), der Export ebenfalls (POST export) — beides zustandslos.
|
||||||
|
*
|
||||||
|
* T-09-02: Passwoerter leben nur im lokalen Zustand, nie in URL oder Log.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ACCEPT = '.zip,.pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c,.key,.csr';
|
||||||
|
|
||||||
|
const ROLE_STYLES: Record<string, string> = {
|
||||||
|
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||||
|
root: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
|
privateKey: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
|
||||||
|
csr: 'bg-muted text-muted-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
function typeKey(item: BundleItem): string {
|
||||||
|
return item.kind === 'certificate' ? (item.role ?? 'end-entity') : item.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In UTC wie im Zertifikat: „bis 23:59:59 UTC“ ist bei uns schon der Folgetag — der Aussteller nennt aber dieses Datum. */
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewTab() {
|
||||||
|
const t = useTranslations('certManager.overview');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<BundleAnalysis | null>(null);
|
||||||
|
|
||||||
|
async function analyze(next: File[], pw: string) {
|
||||||
|
if (next.length === 0) {
|
||||||
|
setResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setResult(await analyzeBundleAction(next, pw || undefined));
|
||||||
|
} catch {
|
||||||
|
setResult(null);
|
||||||
|
setError(t('error'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(list: FileList | null) {
|
||||||
|
const added = Array.from(list ?? []);
|
||||||
|
if (added.length === 0) return;
|
||||||
|
const next = [...files, ...added];
|
||||||
|
setFiles(next);
|
||||||
|
void analyze(next, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(index: number) {
|
||||||
|
const next = files.filter((_, i) => i !== index);
|
||||||
|
setFiles(next);
|
||||||
|
void analyze(next, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setFiles([]);
|
||||||
|
setPassword('');
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = Object.fromEntries((result?.items ?? []).map((i) => [i.id, i]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={ACCEPT}
|
||||||
|
className="hidden"
|
||||||
|
data-testid="overview-file-input"
|
||||||
|
onChange={(e) => {
|
||||||
|
addFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setIsDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
addFiles(e.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
|
||||||
|
isDragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-foreground">{t('dropTitle')}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t('dropHint')}</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{files.map((file, i) => (
|
||||||
|
<span
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: dieselbe Datei darf zweimal in der Liste stehen; die Liste aendert sich nur durch Anhaengen/Entfernen
|
||||||
|
key={`${file.name}-${i}`}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('removeFile', { name: file.name })}
|
||||||
|
onClick={() => removeFile(i)}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={reset} className="text-xs text-muted-foreground underline">
|
||||||
|
{t('reset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <p className="text-sm text-muted-foreground">{t('analyzing')}</p>}
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{result && result.locked.length > 0 && (
|
||||||
|
<div className="space-y-2 rounded-lg border border-status-warn/50 bg-status-warn/10 p-4">
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
{t('locked', { files: result.locked.join(', ') })}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t('lockedPassword')}
|
||||||
|
aria-label={t('lockedPassword')}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={!password || loading}
|
||||||
|
onClick={() => void analyze(files, password)}
|
||||||
|
>
|
||||||
|
{t('unlock')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && result.items.length === 0 && !loading && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('nothingFound')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && result.items.length > 0 && (
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{result.items.map((item) => (
|
||||||
|
<BundleItemCard key={item.id} item={item} byId={byId} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && result.ignored.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('ignored', { files: result.ignored.join(', ') })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BundleItemCard({ item, byId }: { item: BundleItem; byId: Record<string, BundleItem> }) {
|
||||||
|
const t = useTranslations('certManager.overview');
|
||||||
|
const [pfxOpen, setPfxOpen] = useState(false);
|
||||||
|
const [pfxPassword, setPfxPassword] = useState('');
|
||||||
|
const [busy, setBusy] = useState<BundleExportFormat | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const match = item.matchId ? byId[item.matchId] : undefined;
|
||||||
|
const chain = item.chainIds.map((id) => byId[id]).filter(Boolean);
|
||||||
|
|
||||||
|
async function download(format: BundleExportFormat, password?: string) {
|
||||||
|
setBusy(format);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const file = await exportBundleItemAction({
|
||||||
|
kind: item.kind,
|
||||||
|
pem: item.pem,
|
||||||
|
format,
|
||||||
|
baseName: item.baseName,
|
||||||
|
chain: item.kind === 'certificate' ? chain.map((c) => c.pem) : undefined,
|
||||||
|
keyPem: item.kind === 'certificate' && match ? match.pem : undefined,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
downloadBase64(file.filename, file.content, file.mimeType);
|
||||||
|
if (format === 'pfx') {
|
||||||
|
setPfxOpen(false);
|
||||||
|
setPfxPassword('');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(t('exportError'));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status =
|
||||||
|
item.isExpired === null
|
||||||
|
? null
|
||||||
|
: item.isExpired
|
||||||
|
? { cls: 'bg-status-down/15 text-status-down-fg', text: t('expired') }
|
||||||
|
: (item.daysLeft ?? 0) <= 30
|
||||||
|
? {
|
||||||
|
cls: 'bg-status-warn/15 text-status-warn-fg',
|
||||||
|
text: t('expiresSoon', { days: item.daysLeft ?? 0 }),
|
||||||
|
}
|
||||||
|
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('valid') };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded-lg border border-border p-4" data-testid="bundle-item">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[typeKey(item)]}`}>
|
||||||
|
{t(`type.${typeKey(item)}`)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium break-all">{item.cn || item.baseName}</span>
|
||||||
|
{status && (
|
||||||
|
<span className={`rounded px-2 py-0.5 text-xs font-medium ${status.cls}`}>
|
||||||
|
{status.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t(`explain.${typeKey(item)}`)}</p>
|
||||||
|
|
||||||
|
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||||
|
{item.kind === 'certificate' && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t('issuer')}</dt>
|
||||||
|
<dd className="break-all">{item.issuerCn}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t('validity')}</dt>
|
||||||
|
<dd>
|
||||||
|
{formatDate(item.notBefore)} – {formatDate(item.notAfter)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.san.length > 0 && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t('names')}</dt>
|
||||||
|
<dd className="break-all">{item.san.join(', ')}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.keyType && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t('key')}</dt>
|
||||||
|
<dd>{item.keyBits > 0 ? `${item.keyType} ${item.keyBits} Bit` : item.keyType}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{match && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t('belongsTo')}</dt>
|
||||||
|
<dd className="break-all">
|
||||||
|
{t(`type.${typeKey(match)}`)} {match.cn}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt className="text-muted-foreground">{t('source')}</dt>
|
||||||
|
<dd className="break-all">{item.sources.join(', ')}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{item.kind === 'privateKey' && (
|
||||||
|
<p className="mt-2 text-xs text-status-warn-fg">{t('keySecret')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{item.formats.map((format) => (
|
||||||
|
<button
|
||||||
|
key={format}
|
||||||
|
type="button"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => (format === 'pfx' ? setPfxOpen((o) => !o) : void download(format))}
|
||||||
|
className="rounded border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-secondary disabled:opacity-50"
|
||||||
|
title={t(`formatHint.${format}`)}
|
||||||
|
>
|
||||||
|
{busy === format ? t('downloading') : t(`format.${format}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pfxOpen && (
|
||||||
|
<div className="mt-3 space-y-2 rounded border border-border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{match ? t('pfxWithKey') : t('pfxWithoutKey')}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pfxPassword}
|
||||||
|
onChange={(e) => setPfxPassword(e.target.value)}
|
||||||
|
placeholder={t('pfxPassword')}
|
||||||
|
aria-label={t('pfxPassword')}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!pfxPassword || busy !== null}
|
||||||
|
onClick={() => void download('pfx', pfxPassword)}
|
||||||
|
>
|
||||||
|
{busy === 'pfx' ? t('downloading') : t('pfxDownload')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,10 +9,11 @@ import { InspectTab } from './components/InspectTab';
|
|||||||
import { SplitTab } from './components/SplitTab';
|
import { SplitTab } from './components/SplitTab';
|
||||||
import { MergeTab } from './components/MergeTab';
|
import { MergeTab } from './components/MergeTab';
|
||||||
import { ConvertTab } from './components/ConvertTab';
|
import { ConvertTab } from './components/ConvertTab';
|
||||||
|
import { OverviewTab } from './components/OverviewTab';
|
||||||
|
|
||||||
type TabId = 'inspect' | 'split' | 'merge' | 'convert';
|
type TabId = 'overview' | 'inspect' | 'split' | 'merge' | 'convert';
|
||||||
|
|
||||||
const TABS: TabId[] = ['inspect', 'split', 'merge', 'convert'];
|
const TABS: TabId[] = ['overview', 'inspect', 'split', 'merge', 'convert'];
|
||||||
|
|
||||||
/** Returns true if the selected file is a PFX/P12 (requires decryption password) */
|
/** Returns true if the selected file is a PFX/P12 (requires decryption password) */
|
||||||
function isPfxFile(file: File | null): boolean {
|
function isPfxFile(file: File | null): boolean {
|
||||||
@@ -23,6 +24,7 @@ function isPfxFile(file: File | null): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* CertManagerPage — tab-based shell for certificate operations.
|
* CertManagerPage — tab-based shell for certificate operations.
|
||||||
|
* quick-261001-l4q: erster Reiter „Übersicht“ fuer ganze Zertifikatspakete.
|
||||||
* Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card.
|
* Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card.
|
||||||
* T-09-02: password lives in local React state only; never logged or placed in URLs.
|
* T-09-02: password lives in local React state only; never logged or placed in URLs.
|
||||||
* T-09-04: all API calls via postForm() which sends credentials:'include'.
|
* T-09-04: all API calls via postForm() which sends credentials:'include'.
|
||||||
@@ -34,7 +36,7 @@ function isPfxFile(file: File | null): boolean {
|
|||||||
export default function CertManagerPage() {
|
export default function CertManagerPage() {
|
||||||
const t = useTranslations('certManager');
|
const t = useTranslations('certManager');
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('inspect');
|
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [pemText, setPemText] = useState('');
|
const [pemText, setPemText] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -75,17 +77,14 @@ export default function CertManagerPage() {
|
|||||||
|
|
||||||
const renderActiveTab = () => {
|
const renderActiveTab = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
|
case 'overview':
|
||||||
|
return <OverviewTab />;
|
||||||
case 'inspect':
|
case 'inspect':
|
||||||
return <InspectTab file={file} pemText={pemText} password={password} />;
|
return <InspectTab file={file} pemText={pemText} password={password} />;
|
||||||
case 'split':
|
case 'split':
|
||||||
return <SplitTab file={file} pemText={pemText} password={password} />;
|
return <SplitTab file={file} pemText={pemText} password={password} />;
|
||||||
case 'merge':
|
case 'merge':
|
||||||
return (
|
return <MergeTab password={password} onOutputFormatChange={setMergeOutputFormat} />;
|
||||||
<MergeTab
|
|
||||||
password={password}
|
|
||||||
onOutputFormatChange={setMergeOutputFormat}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'convert':
|
case 'convert':
|
||||||
return (
|
return (
|
||||||
<ConvertTab
|
<ConvertTab
|
||||||
@@ -102,42 +101,8 @@ export default function CertManagerPage() {
|
|||||||
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
|
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
|
||||||
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
|
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
|
||||||
|
|
||||||
{/* Shared input card */}
|
|
||||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
|
|
||||||
{/* DropZone */}
|
|
||||||
<DropZone
|
|
||||||
onFile={handleFile}
|
|
||||||
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
|
|
||||||
currentFile={file}
|
|
||||||
onClear={handleClearFile}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ODER divider */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<hr className="flex-1 border-border" />
|
|
||||||
<span className="text-xs text-muted-foreground">{t('or')}</span>
|
|
||||||
<hr className="flex-1 border-border" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PEM textarea */}
|
|
||||||
<textarea
|
|
||||||
value={pemText}
|
|
||||||
onChange={(e) => handlePemChange(e.target.value)}
|
|
||||||
placeholder={t('paste.placeholder')}
|
|
||||||
rows={4}
|
|
||||||
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Conditional password field (T-09-02) */}
|
|
||||||
<PasswordField
|
|
||||||
value={password}
|
|
||||||
onChange={setPassword}
|
|
||||||
show={showPassword}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab navigation */}
|
{/* Tab navigation */}
|
||||||
<nav className="border-b border-border flex gap-6">
|
<nav className="flex gap-6 overflow-x-auto border-b border-border">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -154,6 +119,39 @@ export default function CertManagerPage() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Shared input card — nur fuer die Einzeldatei-Werkzeuge; die Uebersicht
|
||||||
|
(quick-261001-l4q) hat ihre eigene Mehrfach-Ablage. */}
|
||||||
|
{activeTab !== 'overview' && (
|
||||||
|
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
|
||||||
|
{/* DropZone */}
|
||||||
|
<DropZone
|
||||||
|
onFile={handleFile}
|
||||||
|
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
|
||||||
|
currentFile={file}
|
||||||
|
onClear={handleClearFile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ODER divider */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<hr className="flex-1 border-border" />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('or')}</span>
|
||||||
|
<hr className="flex-1 border-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PEM textarea */}
|
||||||
|
<textarea
|
||||||
|
value={pemText}
|
||||||
|
onChange={(e) => handlePemChange(e.target.value)}
|
||||||
|
placeholder={t('paste.placeholder')}
|
||||||
|
rows={4}
|
||||||
|
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Conditional password field (T-09-02) */}
|
||||||
|
<PasswordField value={password} onChange={setPassword} show={showPassword} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tab content card */}
|
{/* Tab content card */}
|
||||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
|
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
|
||||||
{renderActiveTab()}
|
{renderActiveTab()}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
|||||||
import { getLocale, getMessages } from 'next-intl/server';
|
import { getLocale, getMessages } from 'next-intl/server';
|
||||||
import { ThemeProvider } from 'next-themes';
|
import { ThemeProvider } from 'next-themes';
|
||||||
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
|
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
|
||||||
|
import { DesktopExternalLinks } from '@/components/desktop/desktop-external-links';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -10,11 +11,7 @@ export const metadata: Metadata = {
|
|||||||
description: 'Modulare Workflow-Plattform',
|
description: 'Modulare Workflow-Plattform',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
@@ -29,6 +26,7 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<DesktopContextMenuGuard />
|
<DesktopContextMenuGuard />
|
||||||
|
<DesktopExternalLinks />
|
||||||
{children}
|
{children}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ export function AdminSidebar() {
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t('admin.welcomeMail'),
|
||||||
|
href: '/admin/welcome-mail',
|
||||||
|
show: true,
|
||||||
|
icon: (
|
||||||
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8" />
|
||||||
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||||
|
<path d="m16 19 2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t('admin.groups'),
|
label: t('admin.groups'),
|
||||||
href: '/admin/groups',
|
href: '/admin/groups',
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import de from '@/messages/de.json';
|
||||||
|
|
||||||
|
// Namensraumfaehiger next-intl-Mock gegen die echten deutschen Texte:
|
||||||
|
// Punktschluessel werden aufgeloest, {param} wird eingesetzt.
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useLocale: () => 'de',
|
||||||
|
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
|
||||||
|
let val: unknown = de;
|
||||||
|
for (const part of `${ns}.${key}`.split('.')) {
|
||||||
|
val = (val as Record<string, unknown> | undefined)?.[part];
|
||||||
|
}
|
||||||
|
if (typeof val !== 'string') return `${ns}.${key}`;
|
||||||
|
return params
|
||||||
|
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
|
||||||
|
: val;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
fetchWelcomeMailTemplate: vi.fn(),
|
||||||
|
saveWelcomeMailTemplate: vi.fn(),
|
||||||
|
resetWelcomeMailTemplate: vi.fn(),
|
||||||
|
previewWelcomeMail: vi.fn(),
|
||||||
|
sendWelcomeMailTest: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/welcome-mail-template-api', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/lib/welcome-mail-template-api')>();
|
||||||
|
return { ...actual, ...api };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { WelcomeMailTemplateRequestError } from '@/lib/welcome-mail-template-api';
|
||||||
|
import { WelcomeMailTemplateEditor } from './welcome-mail-template-editor';
|
||||||
|
|
||||||
|
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
|
||||||
|
const custom = {
|
||||||
|
subject: 'Hallo {{vorname}}',
|
||||||
|
heading: 'Willkommen, {{name}}!',
|
||||||
|
intro: 'Eigene Einleitung',
|
||||||
|
loginHintDirectory: 'Eigener Verzeichnis-Hinweis',
|
||||||
|
loginHintLocal: 'Eigener Lokal-Hinweis',
|
||||||
|
closing: 'Ihr IT-Team',
|
||||||
|
};
|
||||||
|
|
||||||
|
function state(isCustom: boolean) {
|
||||||
|
return {
|
||||||
|
custom: isCustom,
|
||||||
|
texts: isCustom ? { ...custom } : { ...defaults },
|
||||||
|
defaults: { ...defaults },
|
||||||
|
updatedAt: isCustom ? '2026-09-30T10:00:00.000Z' : null,
|
||||||
|
updatedBy: isCustom ? 'ada' : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||||
|
cb(0);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
api.fetchWelcomeMailTemplate.mockResolvedValue(state(false));
|
||||||
|
api.previewWelcomeMail.mockImplementation(async (texts: typeof defaults, account: string) => ({
|
||||||
|
subject: texts.subject,
|
||||||
|
html: `<html><body>Vorschau ${account}</body></html>`,
|
||||||
|
text: '',
|
||||||
|
}));
|
||||||
|
api.saveWelcomeMailTemplate.mockImplementation(async (texts: typeof defaults) => ({
|
||||||
|
...state(true),
|
||||||
|
texts: { ...texts },
|
||||||
|
}));
|
||||||
|
api.resetWelcomeMailTemplate.mockResolvedValue(state(false));
|
||||||
|
api.sendWelcomeMailTest.mockResolvedValue({ success: true, to: 'ada@example.invalid' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderLoaded() {
|
||||||
|
render(<WelcomeMailTemplateEditor />);
|
||||||
|
return (await screen.findByLabelText('Betreff')) as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WelcomeMailTemplateEditor', () => {
|
||||||
|
it('laedt die Standardtexte ins Formular und zeigt Hinweis auf die festen Bausteine', async () => {
|
||||||
|
const subject = await renderLoaded();
|
||||||
|
expect(subject.value).toBe(defaults.subject);
|
||||||
|
expect((screen.getByLabelText('Überschrift') as HTMLInputElement).value).toBe(defaults.heading);
|
||||||
|
expect((screen.getByLabelText('Einleitungstext') as HTMLTextAreaElement).value).toBe(
|
||||||
|
defaults.intro,
|
||||||
|
);
|
||||||
|
expect((screen.getByLabelText('Abschlusstext') as HTMLTextAreaElement).value).toBe(
|
||||||
|
defaults.closing,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
|
||||||
|
'Zurzeit gelten die Standardtexte.',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/So kann eine eigene Vorlage die Anmeldung nicht kaputt machen/),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Hinweisbox Platzhalter: Tabelle mit allen sechs Platzhaltern, Bedeutung und Beispiel', async () => {
|
||||||
|
await renderLoaded();
|
||||||
|
const box = screen.getByRole('region', { name: 'Platzhalter' });
|
||||||
|
const rows = within(box).getAllByRole('row');
|
||||||
|
expect(rows).toHaveLength(7); // Kopf + 6
|
||||||
|
for (const token of [
|
||||||
|
'{{name}}',
|
||||||
|
'{{vorname}}',
|
||||||
|
'{{benutzername}}',
|
||||||
|
'{{email}}',
|
||||||
|
'{{adresse}}',
|
||||||
|
'{{firma}}',
|
||||||
|
]) {
|
||||||
|
expect(within(box).getByRole('button', { name: `${token} einfügen` })).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(within(box).getByText('Name Ihres Mandanten')).toBeTruthy();
|
||||||
|
expect(within(box).getByText('max.mustermann@example.com')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Klick auf einen Platzhalter fuegt ihn an der Cursorposition des zuletzt fokussierten Feldes ein', async () => {
|
||||||
|
const subject = await renderLoaded();
|
||||||
|
fireEvent.change(subject, { target: { value: 'Hallo bei uns' } });
|
||||||
|
subject.focus();
|
||||||
|
subject.setSelectionRange(6, 6);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '{{vorname}} einfügen' }));
|
||||||
|
expect(subject.value).toBe('Hallo {{vorname}} bei uns');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unbekannter Platzhalter wird benannt, Speichern und Testmail sind gesperrt', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderLoaded();
|
||||||
|
const heading = screen.getByLabelText('Überschrift');
|
||||||
|
await user.clear(heading);
|
||||||
|
fireEvent.change(heading, { target: { value: 'Hallo {{xyz}}' } });
|
||||||
|
expect(await screen.findByText('Unbekannter Platzhalter {{xyz}}')).toBeTruthy();
|
||||||
|
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole('button', { name: 'Testmail an mich senden' }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anmeldehinweise: beide Felder nach dem Einleitungstext, mit Standardtext bzw. eigener Vorlage vorbelegt', async () => {
|
||||||
|
await renderLoaded();
|
||||||
|
const dir = screen.getByLabelText(
|
||||||
|
'Anmeldehinweis für Verzeichniskonten',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
const local = screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement;
|
||||||
|
expect(dir.value).toBe(
|
||||||
|
'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.',
|
||||||
|
);
|
||||||
|
expect(local.value).toBe(
|
||||||
|
'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.',
|
||||||
|
);
|
||||||
|
// Reihenfolge: Einleitung → Verzeichnis → lokal → Abschluss
|
||||||
|
const intro = screen.getByLabelText('Einleitungstext');
|
||||||
|
const closing = screen.getByLabelText('Abschlusstext');
|
||||||
|
const follows = (a: Element, b: Element) =>
|
||||||
|
Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||||
|
expect(follows(intro, dir)).toBe(true);
|
||||||
|
expect(follows(dir, local)).toBe(true);
|
||||||
|
expect(follows(local, closing)).toBe(true);
|
||||||
|
expect(screen.getByText(/direkt vor dem Knopf „Passwort festlegen“/)).toBeTruthy();
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
api.fetchWelcomeMailTemplate.mockResolvedValue(state(true));
|
||||||
|
await renderLoaded();
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText('Anmeldehinweis für Verzeichniskonten') as HTMLTextAreaElement).value,
|
||||||
|
).toBe(custom.loginHintDirectory);
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement).value,
|
||||||
|
).toBe(custom.loginHintLocal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anmeldehinweis: Pflichtfeld, Platzhalter-Klick fuegt ein, Fokus schaltet die Vorschau auf die Kontoart', async () => {
|
||||||
|
await renderLoaded();
|
||||||
|
const local = screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(local, { target: { value: 'Hallo ' } });
|
||||||
|
local.focus();
|
||||||
|
local.setSelectionRange(6, 6);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '{{vorname}} einfügen' }));
|
||||||
|
expect(local.value).toBe('Hallo {{vorname}}');
|
||||||
|
expect(screen.getByRole('button', { name: 'Lokales Konto' }).getAttribute('aria-pressed')).toBe(
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(local, { target: { value: ' ' } });
|
||||||
|
expect(await screen.findByText('Bitte füllen Sie dieses Feld aus.')).toBeTruthy();
|
||||||
|
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Speichern schickt alle sechs Texte inkl. Anmeldehinweise', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderLoaded();
|
||||||
|
fireEvent.change(screen.getByLabelText('Anmeldehinweis für Verzeichniskonten'), {
|
||||||
|
target: { value: 'Mit dem Firmenkennwort anmelden, {{vorname}}.' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('Anmeldehinweis für lokale Konten'), {
|
||||||
|
target: { value: 'Erst Passwort setzen.' },
|
||||||
|
});
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
expect(api.saveWelcomeMailTemplate).toHaveBeenCalledWith({
|
||||||
|
...defaults,
|
||||||
|
loginHintDirectory: 'Mit dem Firmenkennwort anmelden, {{vorname}}.',
|
||||||
|
loginHintLocal: 'Erst Passwort setzen.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Speichern schickt die Texte und meldet Erfolg; Status zeigt die eigene Vorlage', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const subject = await renderLoaded();
|
||||||
|
fireEvent.change(subject, { target: { value: 'Neu für {{firma}}' } });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
expect(api.saveWelcomeMailTemplate).toHaveBeenCalledWith({
|
||||||
|
...defaults,
|
||||||
|
subject: 'Neu für {{firma}}',
|
||||||
|
});
|
||||||
|
expect((await screen.findByRole('status')).textContent).toContain(
|
||||||
|
'Die Vorlage wurde gespeichert.',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
|
||||||
|
'Eigene Vorlage aktiv',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('welcome-template-status').textContent).toContain('von ada');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler der API beim Speichern wird angezeigt (400 mit Meldung)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
api.saveWelcomeMailTemplate.mockRejectedValueOnce(
|
||||||
|
new WelcomeMailTemplateRequestError(400, ['Betreff: Unbekannter Platzhalter {{abc}}']),
|
||||||
|
);
|
||||||
|
await renderLoaded();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||||
|
'Betreff: Unbekannter Platzhalter {{abc}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Auf Standard zuruecksetzen: Rueckfrage, DELETE, danach Standardtexte im Formular', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
api.fetchWelcomeMailTemplate.mockResolvedValue(state(true));
|
||||||
|
const subject = await renderLoaded();
|
||||||
|
expect(subject.value).toBe(custom.subject);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Auf Standard zurücksetzen' }));
|
||||||
|
const dialog = screen.getByRole('alertdialog');
|
||||||
|
expect(api.resetWelcomeMailTemplate).not.toHaveBeenCalled();
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Zurücksetzen' }));
|
||||||
|
|
||||||
|
expect(api.resetWelcomeMailTemplate).toHaveBeenCalledTimes(1);
|
||||||
|
await waitFor(() => expect(subject.value).toBe(defaults.subject));
|
||||||
|
expect(screen.getByTestId('welcome-template-status').textContent).toContain('Standardtexte');
|
||||||
|
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Vorschau: iframe mit dem von der API gerenderten HTML, Umschalter lokales Konto', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderLoaded();
|
||||||
|
const frame = screen.getByTitle('Vorschau der Willkommensmail') as HTMLIFrameElement;
|
||||||
|
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau directory'));
|
||||||
|
expect(frame.getAttribute('sandbox')).toBe('');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Lokales Konto' }));
|
||||||
|
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau local'));
|
||||||
|
expect(api.previewWelcomeMail).toHaveBeenLastCalledWith(defaults, 'local', expect.anything());
|
||||||
|
expect(screen.getByTestId('welcome-preview-subject').textContent).toBe(defaults.subject);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Testmail an mich: sendet den aktuellen (ungespeicherten) Inhalt und nennt die Adresse', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const subject = await renderLoaded();
|
||||||
|
fireEvent.change(subject, { target: { value: 'Ungespeichert' } });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Testmail an mich senden' }));
|
||||||
|
expect(api.sendWelcomeMailTest).toHaveBeenCalledWith({ ...defaults, subject: 'Ungespeichert' });
|
||||||
|
expect(api.saveWelcomeMailTemplate).not.toHaveBeenCalled();
|
||||||
|
expect((await screen.findByRole('status')).textContent).toBe(
|
||||||
|
'Die Testmail wurde an ada@example.invalid gesendet.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ladefehler wird angezeigt', async () => {
|
||||||
|
api.fetchWelcomeMailTemplate.mockRejectedValueOnce(new Error('down'));
|
||||||
|
render(<WelcomeMailTemplateEditor />);
|
||||||
|
expect((await screen.findByRole('alert')).textContent).toContain('konnte nicht geladen werden');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
findUnknownWelcomeMailPlaceholders,
|
||||||
|
WELCOME_MAIL_LIMITS,
|
||||||
|
WELCOME_MAIL_PLACEHOLDERS,
|
||||||
|
type WelcomeMailPlaceholder,
|
||||||
|
type WelcomeMailTexts,
|
||||||
|
} from '@tessera/shared';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
fetchWelcomeMailTemplate,
|
||||||
|
previewWelcomeMail,
|
||||||
|
resetWelcomeMailTemplate,
|
||||||
|
saveWelcomeMailTemplate,
|
||||||
|
sendWelcomeMailTest,
|
||||||
|
type WelcomeMailPreview,
|
||||||
|
WelcomeMailTemplateRequestError,
|
||||||
|
type WelcomeMailTemplateState,
|
||||||
|
} from '@/lib/welcome-mail-template-api';
|
||||||
|
|
||||||
|
type Field = keyof WelcomeMailTexts;
|
||||||
|
type PreviewAccount = 'directory' | 'local';
|
||||||
|
type Feedback = { type: 'success' | 'error'; message: string } | null;
|
||||||
|
|
||||||
|
const FIELDS: Field[] = [
|
||||||
|
'subject',
|
||||||
|
'heading',
|
||||||
|
'intro',
|
||||||
|
'loginHintDirectory',
|
||||||
|
'loginHintLocal',
|
||||||
|
'closing',
|
||||||
|
];
|
||||||
|
const MULTILINE: Record<Field, boolean> = {
|
||||||
|
subject: false,
|
||||||
|
heading: false,
|
||||||
|
intro: true,
|
||||||
|
loginHintDirectory: true,
|
||||||
|
loginHintLocal: true,
|
||||||
|
closing: true,
|
||||||
|
};
|
||||||
|
/** Pflichtfelder (dieselben wie im DTO der API). */
|
||||||
|
const REQUIRED: ReadonlySet<Field> = new Set([
|
||||||
|
'subject',
|
||||||
|
'heading',
|
||||||
|
'loginHintDirectory',
|
||||||
|
'loginHintLocal',
|
||||||
|
]);
|
||||||
|
/** Zeilen der mehrzeiligen Felder. */
|
||||||
|
const ROWS: Partial<Record<Field, number>> = {
|
||||||
|
intro: 6,
|
||||||
|
loginHintDirectory: 3,
|
||||||
|
loginHintLocal: 3,
|
||||||
|
closing: 5,
|
||||||
|
};
|
||||||
|
/** Welche Kontoart die Vorschau zeigt, wenn ein Anmeldehinweis bearbeitet wird. */
|
||||||
|
const HINT_ACCOUNT: Partial<Record<Field, PreviewAccount>> = {
|
||||||
|
loginHintDirectory: 'directory',
|
||||||
|
loginHintLocal: 'local',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Verzoegerung der Live-Vorschau nach der letzten Eingabe. */
|
||||||
|
const PREVIEW_DELAY_MS = 400;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
|
||||||
|
*
|
||||||
|
* Sechs Textfelder (Betreff, Ueberschrift, Einleitung, Anmeldehinweis fuer
|
||||||
|
* Verzeichniskonten, Anmeldehinweis fuer lokale Konten, Abschluss), eine
|
||||||
|
* Hinweisbox mit den Platzhaltern (Klick fuegt an der Cursorposition des
|
||||||
|
* zuletzt benutzten Feldes ein), eine Live-Vorschau (von der API gerendert,
|
||||||
|
* also dieselbe Mail wie im Versand) mit Umschalter Verzeichniskonto/lokales
|
||||||
|
* Konto (springt beim Bearbeiten eines Anmeldehinweises auf die passende
|
||||||
|
* Kontoart), "Testmail an mich senden" und "Auf Standard zuruecksetzen".
|
||||||
|
*
|
||||||
|
* Unbekannte Platzhalter benennt das Formular schon beim Tippen; die API
|
||||||
|
* lehnt sie beim Speichern ohnehin mit 400 ab (Meldung wird angezeigt).
|
||||||
|
*/
|
||||||
|
export function WelcomeMailTemplateEditor() {
|
||||||
|
const t = useTranslations('admin.welcomeMail');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const [state, setState] = useState<WelcomeMailTemplateState | null>(null);
|
||||||
|
const [form, setForm] = useState<WelcomeMailTexts | null>(null);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
|
const [feedback, setFeedback] = useState<Feedback>(null);
|
||||||
|
|
||||||
|
const [account, setAccount] = useState<PreviewAccount>('directory');
|
||||||
|
const [preview, setPreview] = useState<WelcomeMailPreview | null>(null);
|
||||||
|
const [previewFailed, setPreviewFailed] = useState(false);
|
||||||
|
|
||||||
|
const fieldRefs = useRef<Partial<Record<Field, HTMLInputElement | HTMLTextAreaElement | null>>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const lastFocused = useRef<Field>('intro');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchWelcomeMailTemplate()
|
||||||
|
.then((loaded) => {
|
||||||
|
setState(loaded);
|
||||||
|
setForm({ ...loaded.texts });
|
||||||
|
})
|
||||||
|
.catch(() => setLoadFailed(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Pruefung im Formular (dieselben Regeln wie die API) ───────────────
|
||||||
|
const fieldErrors = (field: Field, value: string): string[] => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (REQUIRED.has(field) && !value.trim()) {
|
||||||
|
errors.push(t('errors.required'));
|
||||||
|
}
|
||||||
|
if (value.length > WELCOME_MAIL_LIMITS[field]) {
|
||||||
|
errors.push(t('errors.tooLong', { max: WELCOME_MAIL_LIMITS[field] }));
|
||||||
|
}
|
||||||
|
const unknown = findUnknownWelcomeMailPlaceholders(value);
|
||||||
|
if (unknown.length === 1) errors.push(t('errors.unknownPlaceholder', { token: unknown[0] }));
|
||||||
|
if (unknown.length > 1) {
|
||||||
|
errors.push(t('errors.unknownPlaceholders', { tokens: unknown.join(', ') }));
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorsByField = form
|
||||||
|
? (Object.fromEntries(FIELDS.map((f) => [f, fieldErrors(f, form[f])])) as Record<
|
||||||
|
Field,
|
||||||
|
string[]
|
||||||
|
>)
|
||||||
|
: null;
|
||||||
|
const hasErrors = errorsByField ? FIELDS.some((f) => errorsByField[f].length > 0) : false;
|
||||||
|
const dirty = !!form && !!state && FIELDS.some((f) => form[f] !== state.texts[f]);
|
||||||
|
|
||||||
|
// ── Live-Vorschau ─────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!form || hasErrors) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
previewWelcomeMail(form, account, controller.signal)
|
||||||
|
.then((result) => {
|
||||||
|
setPreview(result);
|
||||||
|
setPreviewFailed(false);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if ((error as { name?: string })?.name === 'AbortError') return;
|
||||||
|
setPreviewFailed(true);
|
||||||
|
});
|
||||||
|
}, PREVIEW_DELAY_MS);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [form, account, hasErrors]);
|
||||||
|
|
||||||
|
// ── Aktionen ──────────────────────────────────────────────────────────
|
||||||
|
const serverMessage = (error: unknown, fallback: string): string => {
|
||||||
|
if (error instanceof WelcomeMailTemplateRequestError && error.messages.length > 0) {
|
||||||
|
return error.messages.join(' ');
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form || hasErrors) return;
|
||||||
|
setSaving(true);
|
||||||
|
setFeedback(null);
|
||||||
|
try {
|
||||||
|
const saved = await saveWelcomeMailTemplate(form);
|
||||||
|
setState(saved);
|
||||||
|
setForm({ ...saved.texts });
|
||||||
|
setFeedback({ type: 'success', message: t('saved') });
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({ type: 'error', message: serverMessage(error, t('errors.save')) });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
if (!state) return;
|
||||||
|
setResetting(true);
|
||||||
|
setFeedback(null);
|
||||||
|
try {
|
||||||
|
const next = state.custom ? await resetWelcomeMailTemplate() : state;
|
||||||
|
setState(next);
|
||||||
|
setForm({ ...next.defaults });
|
||||||
|
setConfirmReset(false);
|
||||||
|
setFeedback({ type: 'success', message: t('resetDone') });
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({ type: 'error', message: serverMessage(error, t('errors.reset')) });
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
if (!form || hasErrors) return;
|
||||||
|
setTesting(true);
|
||||||
|
setFeedback(null);
|
||||||
|
try {
|
||||||
|
const result = await sendWelcomeMailTest(form);
|
||||||
|
setFeedback({ type: 'success', message: t('testSent', { email: result.to }) });
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({ type: 'error', message: serverMessage(error, t('errors.test')) });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fuegt `{{name}}` an der Cursorposition des zuletzt benutzten Feldes ein. */
|
||||||
|
const insertPlaceholder = (placeholder: WelcomeMailPlaceholder) => {
|
||||||
|
if (!form) return;
|
||||||
|
const field = lastFocused.current;
|
||||||
|
const token = `{{${placeholder}}}`;
|
||||||
|
const el = fieldRefs.current[field];
|
||||||
|
const value = form[field];
|
||||||
|
const start = el?.selectionStart ?? value.length;
|
||||||
|
const end = el?.selectionEnd ?? value.length;
|
||||||
|
setForm({ ...form, [field]: value.slice(0, start) + token + value.slice(end) });
|
||||||
|
const caret = start + token.length;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el?.focus();
|
||||||
|
el?.setSelectionRange(caret, caret);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Darstellung ───────────────────────────────────────────────────────
|
||||||
|
if (loadFailed) {
|
||||||
|
return (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{t('errors.load')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!form || !state || !errorsByField) {
|
||||||
|
return <p className="text-sm text-muted-foreground">{t('loading')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full rounded border border-border bg-background px-3 text-sm text-foreground';
|
||||||
|
const statusText = state.custom
|
||||||
|
? state.updatedAt
|
||||||
|
? t(state.updatedBy ? 'statusCustomBy' : 'statusCustom', {
|
||||||
|
date: new Date(state.updatedAt).toLocaleString(locale === 'en' ? 'en-GB' : 'de-DE', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}),
|
||||||
|
user: state.updatedBy ?? '',
|
||||||
|
})
|
||||||
|
: t('statusCustom', { date: '' })
|
||||||
|
: t('statusDefault');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-2 text-lg font-semibold text-foreground">{t('title')}</h1>
|
||||||
|
<p className="max-w-3xl text-sm text-muted-foreground">{t('description')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feste Bausteine */}
|
||||||
|
<section
|
||||||
|
aria-labelledby="welcome-fixed-title"
|
||||||
|
className="max-w-3xl rounded-lg border border-border bg-muted/40 p-4"
|
||||||
|
>
|
||||||
|
<h2 id="welcome-fixed-title" className="mb-1 text-sm font-semibold text-foreground">
|
||||||
|
{t('fixed.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('fixed.body')}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
|
{/* Formular */}
|
||||||
|
<form onSubmit={handleSave} className="min-w-0 space-y-4" noValidate>
|
||||||
|
<p
|
||||||
|
className={`text-sm ${state.custom ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||||
|
data-testid="welcome-template-status"
|
||||||
|
>
|
||||||
|
{statusText}
|
||||||
|
{dirty && <span className="ml-2 font-medium">· {t('unsaved')}</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{FIELDS.map((field) => {
|
||||||
|
const id = `welcome-${field}`;
|
||||||
|
const errors = errorsByField[field];
|
||||||
|
const describedBy = [`${id}-help`, errors.length ? `${id}-error` : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
const common = {
|
||||||
|
id,
|
||||||
|
value: form[field],
|
||||||
|
'aria-invalid': errors.length > 0 || undefined,
|
||||||
|
'aria-describedby': describedBy,
|
||||||
|
onFocus: () => {
|
||||||
|
lastFocused.current = field;
|
||||||
|
const hintAccount = HINT_ACCOUNT[field];
|
||||||
|
if (hintAccount) setAccount(hintAccount);
|
||||||
|
},
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div key={field}>
|
||||||
|
<label htmlFor={id} className="mb-1 block text-sm text-foreground">
|
||||||
|
{t(`fields.${field}`)}
|
||||||
|
</label>
|
||||||
|
{MULTILINE[field] ? (
|
||||||
|
<textarea
|
||||||
|
{...common}
|
||||||
|
ref={(el) => {
|
||||||
|
fieldRefs.current[field] = el;
|
||||||
|
}}
|
||||||
|
rows={ROWS[field] ?? 5}
|
||||||
|
className={`${inputClass} py-2`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
{...common}
|
||||||
|
ref={(el) => {
|
||||||
|
fieldRefs.current[field] = el;
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
className={`${inputClass} h-9`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<span id={`${id}-help`}>{t(`fields.${field}Help`)}</span>
|
||||||
|
<span className="shrink-0 tabular-nums">
|
||||||
|
{t('counter', { count: form[field].length, max: WELCOME_MAIL_LIMITS[field] })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<p id={`${id}-error`} className="mt-1 text-sm text-destructive">
|
||||||
|
{errors.join(' ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
|
<button type="submit" disabled={saving || hasErrors} className="btn btn-primary">
|
||||||
|
{saving ? t('saving') : t('save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={testing || hasErrors}
|
||||||
|
onClick={handleTest}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{testing ? t('testSending') : t('test')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={resetting}
|
||||||
|
onClick={() => setConfirmReset(true)}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{t('reset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('testHelp')}</p>
|
||||||
|
|
||||||
|
{confirmReset && (
|
||||||
|
<div
|
||||||
|
role="alertdialog"
|
||||||
|
aria-labelledby="welcome-reset-title"
|
||||||
|
aria-describedby="welcome-reset-body"
|
||||||
|
className="max-w-xl rounded-lg border border-border bg-background p-4"
|
||||||
|
>
|
||||||
|
<h2 id="welcome-reset-title" className="mb-1 text-sm font-semibold text-foreground">
|
||||||
|
{t('resetConfirm.title')}
|
||||||
|
</h2>
|
||||||
|
<p id="welcome-reset-body" className="mb-3 text-sm text-muted-foreground">
|
||||||
|
{t('resetConfirm.body')}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={resetting}
|
||||||
|
onClick={handleReset}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{t('resetConfirm.confirm')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmReset(false)}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{t('resetConfirm.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feedback && (
|
||||||
|
<p
|
||||||
|
role={feedback.type === 'error' ? 'alert' : 'status'}
|
||||||
|
className={`text-sm ${feedback.type === 'error' ? 'text-destructive' : ''}`}
|
||||||
|
style={feedback.type === 'success' ? { color: 'oklch(0.40 0.15 148)' } : undefined}
|
||||||
|
>
|
||||||
|
{feedback.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Platzhalter */}
|
||||||
|
<section
|
||||||
|
aria-labelledby="welcome-placeholders-title"
|
||||||
|
className="rounded-lg border border-border p-4"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="welcome-placeholders-title"
|
||||||
|
className="mb-1 text-sm font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
{t('placeholders.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-3 text-sm text-muted-foreground">{t('placeholders.intro')}</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||||
|
<th scope="col" className="py-2 pr-3 font-medium">
|
||||||
|
{t('placeholders.colPlaceholder')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="py-2 pr-3 font-medium">
|
||||||
|
{t('placeholders.colMeaning')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="py-2 font-medium">
|
||||||
|
{t('placeholders.colExample')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{WELCOME_MAIL_PLACEHOLDERS.map((name) => (
|
||||||
|
<tr key={name} className="border-b border-border last:border-0 align-top">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => insertPlaceholder(name)}
|
||||||
|
title={t('placeholders.insertHint')}
|
||||||
|
aria-label={t('placeholders.insert', { token: `{{${name}}}` })}
|
||||||
|
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground hover:bg-sidebar-hover"
|
||||||
|
>
|
||||||
|
{`{{${name}}}`}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-foreground">
|
||||||
|
{t(`placeholders.items.${name}.meaning`)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-muted-foreground">
|
||||||
|
{t(`placeholders.items.${name}.example`)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">{t('placeholders.rules')}</p>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Vorschau */}
|
||||||
|
<section aria-labelledby="welcome-preview-title" className="min-w-0">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 id="welcome-preview-title" className="text-sm font-semibold text-foreground">
|
||||||
|
{t('preview.title')}
|
||||||
|
</h2>
|
||||||
|
<fieldset className="flex gap-1">
|
||||||
|
<legend className="sr-only">{t('preview.accountLabel')}</legend>
|
||||||
|
{(['directory', 'local'] as const).map((kind) => (
|
||||||
|
<button
|
||||||
|
key={kind}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={account === kind}
|
||||||
|
onClick={() => setAccount(kind)}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
account === kind
|
||||||
|
? 'border-foreground bg-foreground text-background'
|
||||||
|
: 'border-border text-foreground hover:bg-sidebar-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`preview.${kind}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-muted-foreground">
|
||||||
|
{t('preview.subject')}{' '}
|
||||||
|
<span className="font-medium text-foreground" data-testid="welcome-preview-subject">
|
||||||
|
{preview?.subject ?? ''}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{hasErrors && <p className="mb-2 text-sm text-destructive">{t('preview.blocked')}</p>}
|
||||||
|
{previewFailed && <p className="mb-2 text-sm text-destructive">{t('preview.error')}</p>}
|
||||||
|
<iframe
|
||||||
|
title={t('preview.frameTitle')}
|
||||||
|
sandbox=""
|
||||||
|
srcDoc={preview?.html ?? ''}
|
||||||
|
className="h-[760px] w-full rounded-lg border border-border bg-white"
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">{t('preview.sampleHint')}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { CalendarEvent } from '@/lib/calendar-api';
|
import type { CalendarEvent } from '@/lib/calendar-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,6 +65,16 @@ beforeEach(() => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Das erste `await import('./calendar-widget')` uebersetzt die Komponente
|
||||||
|
// samt Abhaengigkeiten. Auf dem ausgelasteten CI-Runner dauerte das ueber
|
||||||
|
// 5 s: Test 1 lief in die Zeitgrenze, renderte nach dem Aufraeumen weiter,
|
||||||
|
// und Test 2 fand 84 statt 42 Tageszellen (CI-Lauf 478, 01.10.2026).
|
||||||
|
// Einmal vorab laden, mit grosszuegiger Grenze; die Tests treffen danach
|
||||||
|
// den Modul-Zwischenspeicher.
|
||||||
|
beforeAll(async () => {
|
||||||
|
await import('./calendar-widget');
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|||||||
@@ -65,14 +65,23 @@ export function ReminderFormModal({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const titleRef = useRef<HTMLInputElement>(null);
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Fokus nur EINMAL beim Oeffnen (quick-261001-g68): `onClose` kommt aus der
|
||||||
|
// Kachel als neue Funktion bei jedem Neuzeichnen — die Kachel zeichnet alle
|
||||||
|
// 10 s neu (NOW_TICK_MS). Hing der Fokus mit am `onClose`-Effekt, sprang der
|
||||||
|
// Cursor beim Schreiben der Beschreibung immer wieder in den Titel.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
titleRef.current?.focus();
|
titleRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') onCloseRef.current();
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
return () => document.removeEventListener('keydown', onKey);
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
}, [onClose]);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -349,6 +349,32 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ReminderWidget — Formular behaelt den Fokus (quick-261001-g68)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der 10-s-Takt der Kachel holt den Cursor nicht aus der Beschreibung zurueck in den Titel', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-09-29T12:00:00.000Z'));
|
||||||
|
mockList.mockResolvedValue([]);
|
||||||
|
render(<ReminderWidget {...props} />);
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('reminder.add'));
|
||||||
|
expect(document.activeElement).toBe(screen.getByLabelText('reminder.titleLabel'));
|
||||||
|
|
||||||
|
const description = screen.getByLabelText('reminder.descriptionLabel');
|
||||||
|
description.focus();
|
||||||
|
fireEvent.change(description, { target: { value: 'Unterlagen mitnehmen' } });
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
});
|
||||||
|
expect(document.activeElement).toBe(description);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
|
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { act, cleanup, render } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { DESKTOP_COOKIE_NAME } from '@/lib/desktop-client';
|
||||||
|
import { DesktopExternalLinks } from './desktop-external-links';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* desktop-external-links.test (quick-261001-cxo) — im Desktop-Client gehen
|
||||||
|
* Klicks auf Links mit `target="_blank"` ueber `window.open`; ohne Cookie,
|
||||||
|
* bei verhinderten Klicks, ohne `_blank` und bei Nicht-http(s) bleibt alles
|
||||||
|
* unberuehrt. `fire` gibt den `dispatchEvent`-Rueckgabewert zurueck:
|
||||||
|
* `false` bedeutet, `preventDefault()` wurde aufgerufen.
|
||||||
|
*/
|
||||||
|
function setCookie() {
|
||||||
|
document.cookie = `${DESKTOP_COOKIE_NAME}=1; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCookie() {
|
||||||
|
document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function link(href: string, target?: string) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = href;
|
||||||
|
if (target) a.target = target;
|
||||||
|
const span = document.createElement('span');
|
||||||
|
a.appendChild(span);
|
||||||
|
document.body.appendChild(a);
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fire(el: Element, type: 'click' | 'auxclick' = 'click', button = 0) {
|
||||||
|
return el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, button }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let openSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
clearCookie();
|
||||||
|
openSpy.mockRestore();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DesktopExternalLinks', () => {
|
||||||
|
it('mit Cookie: Klick auf _blank-Link oeffnet per window.open', async () => {
|
||||||
|
setCookie();
|
||||||
|
render(<DesktopExternalLinks />);
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
const inner = link('https://chatgpt.com/', '_blank');
|
||||||
|
expect(fire(inner)).toBe(false);
|
||||||
|
expect(openSpy).toHaveBeenCalledWith('https://chatgpt.com/', '_blank', 'noopener,noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit Cookie: kommt dem Link-Skript des Clients auf window zuvor', async () => {
|
||||||
|
setCookie();
|
||||||
|
render(<DesktopExternalLinks />);
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
// Nachbau von tauri-plugin-opener (init-iife.js): bricht ab, wenn der
|
||||||
|
// Klick schon verhindert ist, sonst preventDefault + IPC-Aufruf.
|
||||||
|
const openerSaw: boolean[] = [];
|
||||||
|
const opener = (e: MouseEvent) => openerSaw.push(e.defaultPrevented);
|
||||||
|
window.addEventListener('click', opener);
|
||||||
|
try {
|
||||||
|
fire(link('https://chatgpt.com/', '_blank'));
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener('click', opener);
|
||||||
|
}
|
||||||
|
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(openerSaw).toEqual([true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit Cookie: Mittelklick auf _blank-Link oeffnet ebenfalls', async () => {
|
||||||
|
setCookie();
|
||||||
|
render(<DesktopExternalLinks />);
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
const inner = link('http://intranet.local/', '_blank');
|
||||||
|
expect(fire(inner, 'auxclick', 1)).toBe(false);
|
||||||
|
expect(openSpy).toHaveBeenCalledWith('http://intranet.local/', '_blank', 'noopener,noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit Cookie: verhinderter Klick, Link ohne _blank und mailto bleiben unberuehrt', async () => {
|
||||||
|
setCookie();
|
||||||
|
render(<DesktopExternalLinks />);
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
const prevented = link('https://chatgpt.com/', '_blank');
|
||||||
|
prevented.addEventListener('click', (e) => e.preventDefault());
|
||||||
|
fire(prevented);
|
||||||
|
|
||||||
|
expect(fire(link('https://example.com/'))).toBe(true);
|
||||||
|
expect(fire(link('mailto:a@example.com', '_blank'))).toBe(true);
|
||||||
|
expect(fire(link('https://example.com/', '_blank'), 'click', 2)).toBe(true);
|
||||||
|
expect(openSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Cookie: keine Umleitung', async () => {
|
||||||
|
render(<DesktopExternalLinks />);
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(fire(link('https://chatgpt.com/', '_blank'))).toBe(true);
|
||||||
|
expect(openSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useIsDesktopClient } from '@/lib/desktop-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DesktopExternalLinks (quick-261001-cxo) — Links mit `target="_blank"`
|
||||||
|
* (Favoriten, „In neuem Tab öffnen“ bei XFrame/eigenen Modulen, Quellen im
|
||||||
|
* Ausschreibungs-Radar) taten im Desktop-Client beim Klick nichts, waehrend
|
||||||
|
* `window.open` (Such-Widget) ueber `on_new_window` im System-Browser landet.
|
||||||
|
*
|
||||||
|
* Ursache (VM 8233, 01.10.2026, gemessen): tauri-plugin-opener schleust ein
|
||||||
|
* Skript ein, das auf `window` solche Klicks abfaengt, `preventDefault()`
|
||||||
|
* ruft und `plugin:opener|open_url` aufruft -- von der Server-Seite aus ist
|
||||||
|
* dieser Aufruf nicht freigegeben, der Klick verpufft. Dieser Helfer kommt
|
||||||
|
* ihm zuvor und oeffnet per `window.open`; das Opener-Skript sieht den Klick
|
||||||
|
* dann als verhindert und tut nichts.
|
||||||
|
*
|
||||||
|
* Lauscht deshalb auf `document` in der Bubble-Phase: NACH Reacts Handlern
|
||||||
|
* (Wurzel `document`, frueher registriert) -- ein Klick, den die Seite selbst
|
||||||
|
* verhindert (Favoriten im Bearbeiten-Modus), bleibt verhindert --, aber VOR
|
||||||
|
* dem Opener-Skript auf `window`. Nur http/https.
|
||||||
|
*/
|
||||||
|
function externalTarget(event: MouseEvent): HTMLAnchorElement | null {
|
||||||
|
if (event.defaultPrevented) return null;
|
||||||
|
// Linksklick und Mittelklick oeffnen beide ein neues Fenster.
|
||||||
|
if (event.button !== 0 && event.button !== 1) return null;
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Element)) return null;
|
||||||
|
const anchor = target.closest('a[href]');
|
||||||
|
if (!(anchor instanceof HTMLAnchorElement)) return null;
|
||||||
|
if (anchor.target !== '_blank') return null;
|
||||||
|
if (anchor.protocol !== 'http:' && anchor.protocol !== 'https:') return null;
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopExternalLinks() {
|
||||||
|
const isDesktop = useIsDesktopClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesktop) return;
|
||||||
|
|
||||||
|
const handler = (event: MouseEvent) => {
|
||||||
|
if (event.type === 'auxclick' && event.button !== 1) return;
|
||||||
|
if (event.type === 'click' && event.button !== 0) return;
|
||||||
|
const anchor = externalTarget(event);
|
||||||
|
if (!anchor) return;
|
||||||
|
event.preventDefault();
|
||||||
|
window.open(anchor.href, '_blank', 'noopener,noreferrer');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handler);
|
||||||
|
document.addEventListener('auxclick', handler);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handler);
|
||||||
|
document.removeEventListener('auxclick', handler);
|
||||||
|
};
|
||||||
|
}, [isDesktop]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Willkommensmail-Vorlage — API-Client (Administrator → Willkommensmail).
|
||||||
|
* Konsumiert `/welcome-mail-template`. Muster `custom-modules-api.ts`:
|
||||||
|
* `credentials: 'include'` fuer Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WelcomeMailTexts } from '@tessera/shared';
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
/** Antwort von GET/PUT/DELETE /welcome-mail-template. */
|
||||||
|
export interface WelcomeMailTemplateState {
|
||||||
|
/** `true` = eigene Vorlage gespeichert, `false` = Standardtexte. */
|
||||||
|
custom: boolean;
|
||||||
|
texts: WelcomeMailTexts;
|
||||||
|
defaults: WelcomeMailTexts;
|
||||||
|
updatedAt: string | null;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WelcomeMailPreview {
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fehler mit HTTP-Status und den Servermeldungen (400: je Feld eine). */
|
||||||
|
export class WelcomeMailTemplateRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly messages: string[],
|
||||||
|
) {
|
||||||
|
super(messages.join(' ') || `HTTP ${status}`);
|
||||||
|
this.name = 'WelcomeMailTemplateRequestError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMessages(res: Response): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { message?: unknown };
|
||||||
|
if (Array.isArray(body.message)) return body.message.map(String);
|
||||||
|
if (typeof body.message === 'string' && body.message) return [body.message];
|
||||||
|
} catch {
|
||||||
|
// kein JSON — Status allein
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(`${API_URL}/welcome-mail-template${path}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
...init,
|
||||||
|
headers: init.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new WelcomeMailTemplateRequestError(res.status, await readMessages(res));
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur die sechs Texte senden — nie weitere Felder aus dem Formularzustand. */
|
||||||
|
function textsOf(texts: WelcomeMailTexts): WelcomeMailTexts {
|
||||||
|
return {
|
||||||
|
subject: texts.subject,
|
||||||
|
heading: texts.heading,
|
||||||
|
intro: texts.intro,
|
||||||
|
loginHintDirectory: texts.loginHintDirectory,
|
||||||
|
loginHintLocal: texts.loginHintLocal,
|
||||||
|
closing: texts.closing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchWelcomeMailTemplate(): Promise<WelcomeMailTemplateState> {
|
||||||
|
return request('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWelcomeMailTemplate(
|
||||||
|
texts: WelcomeMailTexts,
|
||||||
|
): Promise<WelcomeMailTemplateState> {
|
||||||
|
return request('', { method: 'PUT', body: JSON.stringify(textsOf(texts)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetWelcomeMailTemplate(): Promise<WelcomeMailTemplateState> {
|
||||||
|
return request('', { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewWelcomeMail(
|
||||||
|
texts: WelcomeMailTexts,
|
||||||
|
account: 'directory' | 'local',
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<WelcomeMailPreview> {
|
||||||
|
return request('/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ...textsOf(texts), account }),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendWelcomeMailTest(
|
||||||
|
texts: WelcomeMailTexts,
|
||||||
|
): Promise<{ success: true; to: string }> {
|
||||||
|
return request('/test', { method: 'POST', body: JSON.stringify(textsOf(texts)) });
|
||||||
|
}
|
||||||
@@ -88,7 +88,8 @@
|
|||||||
"customModules": "Eigene Module",
|
"customModules": "Eigene Module",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"smtp": "SMTP",
|
"smtp": "SMTP",
|
||||||
"groups": "Gruppen"
|
"groups": "Gruppen",
|
||||||
|
"welcomeMail": "Willkommensmail"
|
||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"SUPER_ADMIN": "Super-Admin",
|
"SUPER_ADMIN": "Super-Admin",
|
||||||
@@ -738,6 +739,106 @@
|
|||||||
"create": "Gemeinsamen Eintrag anlegen",
|
"create": "Gemeinsamen Eintrag anlegen",
|
||||||
"noModules": "Noch keine gemeinsamen Einträge",
|
"noModules": "Noch keine gemeinsamen Einträge",
|
||||||
"noModulesBody": "Legen Sie Ihren ersten gemeinsamen Eintrag an, um eine Webseite für alle Benutzer in der Seitenleiste zu zeigen."
|
"noModulesBody": "Legen Sie Ihren ersten gemeinsamen Eintrag an, um eine Webseite für alle Benutzer in der Seitenleiste zu zeigen."
|
||||||
|
},
|
||||||
|
"welcomeMail": {
|
||||||
|
"title": "Willkommensmail",
|
||||||
|
"description": "Hier legen Sie fest, mit welchen Worten Tessera neue Benutzer begrüßt. Die Vorlage gilt für Ihren Mandanten und wird bei jeder Willkommensmail aus der Benutzerverwaltung verwendet.",
|
||||||
|
"loading": "Vorlage wird geladen …",
|
||||||
|
"fixed": {
|
||||||
|
"title": "Was Sie anpassen können – und was immer drin bleibt",
|
||||||
|
"body": "Sie bestimmen Betreff, Überschrift, Einleitung, Abschlusstext und den Anmeldehinweis – getrennt für Verzeichniskonten und für lokale Konten. Alles, was für die Anmeldung technisch nötig ist, fügt Tessera immer automatisch ein und lässt sich nicht verändern: der Kopf mit Logo, der Kasten mit Adresse und Benutzername, der Knopf „Passwort festlegen“ samt Link und Gültigkeitshinweis bei lokalen Konten, der Knopf „Zu Tessera“ und die Fußzeile. So kann eine eigene Vorlage die Anmeldung nicht kaputt machen."
|
||||||
|
},
|
||||||
|
"statusDefault": "Zurzeit gelten die Standardtexte.",
|
||||||
|
"statusCustom": "Eigene Vorlage aktiv – zuletzt geändert am {date}.",
|
||||||
|
"statusCustomBy": "Eigene Vorlage aktiv – zuletzt geändert am {date} von {user}.",
|
||||||
|
"unsaved": "Ungespeicherte Änderungen",
|
||||||
|
"fields": {
|
||||||
|
"subject": "Betreff",
|
||||||
|
"subjectHelp": "Eine Zeile, Pflichtfeld.",
|
||||||
|
"heading": "Überschrift",
|
||||||
|
"headingHelp": "Große Zeile oben in der Mail, Pflichtfeld.",
|
||||||
|
"intro": "Einleitungstext",
|
||||||
|
"introHelp": "Steht unter der Überschrift. Eine Leerzeile beginnt einen neuen Absatz.",
|
||||||
|
"loginHintDirectory": "Anmeldehinweis für Verzeichniskonten",
|
||||||
|
"loginHintDirectoryHelp": "Erscheint unter dem Kasten mit den Zugangsdaten, wenn der Benutzer aus dem Verzeichnis (Active Directory/LDAP) kommt und sich mit seinem gewohnten Windows-Passwort anmeldet. Pflichtfeld.",
|
||||||
|
"loginHintLocal": "Anmeldehinweis für lokale Konten",
|
||||||
|
"loginHintLocalHelp": "Erscheint bei lokalen Konten direkt vor dem Knopf „Passwort festlegen“. Knopf und Gültigkeitshinweis darunter fügt Tessera immer selbst ein. Pflichtfeld.",
|
||||||
|
"closing": "Abschlusstext",
|
||||||
|
"closingHelp": "Steht am Ende der Mail, z. B. Tipp und Grußzeile. Darf leer bleiben."
|
||||||
|
},
|
||||||
|
"counter": "{count} / {max}",
|
||||||
|
"save": "Speichern",
|
||||||
|
"saving": "Wird gespeichert …",
|
||||||
|
"saved": "Die Vorlage wurde gespeichert. Ab jetzt verwenden neue Willkommensmails diese Texte.",
|
||||||
|
"test": "Testmail an mich senden",
|
||||||
|
"testSending": "Wird gesendet …",
|
||||||
|
"testSent": "Die Testmail wurde an {email} gesendet.",
|
||||||
|
"testHelp": "Die Testmail geht an Ihre eigene E-Mail-Adresse und zeigt den aktuellen Inhalt des Formulars, auch wenn er noch nicht gespeichert ist. Sie enthält keinen echten Link zum Festlegen eines Passworts.",
|
||||||
|
"reset": "Auf Standard zurücksetzen",
|
||||||
|
"resetDone": "Die Standardtexte sind wiederhergestellt.",
|
||||||
|
"resetConfirm": {
|
||||||
|
"title": "Auf Standard zurücksetzen?",
|
||||||
|
"body": "Ihre eigene Vorlage wird gelöscht und die Standardtexte gelten wieder. Das lässt sich nicht rückgängig machen.",
|
||||||
|
"confirm": "Zurücksetzen",
|
||||||
|
"cancel": "Abbrechen"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load": "Die Vorlage konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
|
||||||
|
"save": "Die Vorlage konnte nicht gespeichert werden.",
|
||||||
|
"reset": "Die Vorlage konnte nicht zurückgesetzt werden.",
|
||||||
|
"test": "Die Testmail konnte nicht gesendet werden.",
|
||||||
|
"required": "Bitte füllen Sie dieses Feld aus.",
|
||||||
|
"tooLong": "Höchstens {max} Zeichen.",
|
||||||
|
"unknownPlaceholder": "Unbekannter Platzhalter {token}",
|
||||||
|
"unknownPlaceholders": "Unbekannte Platzhalter {tokens}"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"title": "Platzhalter",
|
||||||
|
"intro": "Platzhalter setzt Tessera beim Versand durch die Angaben des jeweiligen Benutzers. Sie funktionieren in allen vier Feldern. Ein Klick auf einen Platzhalter fügt ihn an der Cursorposition des zuletzt benutzten Feldes ein.",
|
||||||
|
"colPlaceholder": "Platzhalter",
|
||||||
|
"colMeaning": "Bedeutung",
|
||||||
|
"colExample": "Beispiel",
|
||||||
|
"insert": "{token} einfügen",
|
||||||
|
"insertHint": "Klicken zum Einfügen",
|
||||||
|
"rules": "Andere Platzhalter kennt Tessera nicht – sie werden beim Speichern abgelehnt. Die Texte sind reiner Text: Formatierungen oder HTML werden nicht übernommen, sondern so angezeigt, wie Sie sie eingeben.",
|
||||||
|
"items": {
|
||||||
|
"name": {
|
||||||
|
"meaning": "Anzeigename des Benutzers, sonst sein Benutzername",
|
||||||
|
"example": "Max Mustermann"
|
||||||
|
},
|
||||||
|
"vorname": {
|
||||||
|
"meaning": "Erstes Wort des Anzeigenamens, sonst der Benutzername",
|
||||||
|
"example": "Max"
|
||||||
|
},
|
||||||
|
"benutzername": {
|
||||||
|
"meaning": "Benutzername für die Anmeldung",
|
||||||
|
"example": "max.mustermann"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"meaning": "E-Mail-Adresse des Benutzers",
|
||||||
|
"example": "max.mustermann@example.com"
|
||||||
|
},
|
||||||
|
"adresse": {
|
||||||
|
"meaning": "Adresse von Tessera",
|
||||||
|
"example": "https://tessera.example.com"
|
||||||
|
},
|
||||||
|
"firma": {
|
||||||
|
"meaning": "Name Ihres Mandanten",
|
||||||
|
"example": "Beispiel GmbH"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Vorschau",
|
||||||
|
"accountLabel": "Vorschau für Kontoart",
|
||||||
|
"directory": "Verzeichniskonto",
|
||||||
|
"local": "Lokales Konto",
|
||||||
|
"subject": "Betreff:",
|
||||||
|
"frameTitle": "Vorschau der Willkommensmail",
|
||||||
|
"error": "Die Vorschau konnte nicht erstellt werden.",
|
||||||
|
"blocked": "Die Vorschau wird aktualisiert, sobald alle Felder in Ordnung sind.",
|
||||||
|
"sampleHint": "Die Vorschau verwendet Beispielwerte (Max Mustermann) und den Namen Ihres Mandanten. Der Link „Passwort festlegen“ ist in der Vorschau nur ein Beispiel."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adminModules": {
|
"adminModules": {
|
||||||
@@ -1081,8 +1182,9 @@
|
|||||||
},
|
},
|
||||||
"certManager": {
|
"certManager": {
|
||||||
"title": "Zertifikat-Manager",
|
"title": "Zertifikat-Manager",
|
||||||
"description": "Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
|
"description": "Zertifikatspakete prüfen und in jedes Format bringen; einzelne Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
"overview": "Übersicht",
|
||||||
"inspect": "Analysieren",
|
"inspect": "Analysieren",
|
||||||
"split": "Aufteilen",
|
"split": "Aufteilen",
|
||||||
"merge": "Zusammenführen",
|
"merge": "Zusammenführen",
|
||||||
@@ -1116,18 +1218,85 @@
|
|||||||
},
|
},
|
||||||
"emptyState": {
|
"emptyState": {
|
||||||
"inspect": "Kein Zertifikat geladen.",
|
"inspect": "Kein Zertifikat geladen.",
|
||||||
"inspectBody": "Lade eine Datei hoch oder füge PEM-Text ein.",
|
"inspectBody": "Laden Sie eine Datei hoch oder fügen Sie PEM-Text ein.",
|
||||||
"split": "Keine Datei geladen.",
|
"split": "Keine Datei geladen.",
|
||||||
"splitBody": "Lade eine Fullchain- oder P7B-Datei hoch.",
|
"splitBody": "Laden Sie eine Fullchain- oder P7B-Datei hoch.",
|
||||||
"merge": "Keine Zertifikate ausgewählt.",
|
"merge": "Keine Zertifikate ausgewählt.",
|
||||||
"mergeBody": "Lade mindestens zwei Dateien hoch.",
|
"mergeBody": "Laden Sie mindestens zwei Dateien hoch.",
|
||||||
"convert": "Keine Datei geladen.",
|
"convert": "Keine Datei geladen.",
|
||||||
"convertBody": "Lade eine Datei hoch und wähle ein Ausgabeformat."
|
"convertBody": "Laden Sie eine Datei hoch und wählen Sie ein Ausgabeformat."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"generic": "Verarbeitung fehlgeschlagen. Prüfe das Dateiformat oder das Passwort.",
|
"generic": "Verarbeitung fehlgeschlagen. Prüfen Sie das Dateiformat oder das Passwort.",
|
||||||
"wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschlüsselt werden.",
|
"wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschlüsselt werden.",
|
||||||
"unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden."
|
"unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden."
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"dropTitle": "Zertifikatsdateien oder ZIP hierher ziehen oder klicken",
|
||||||
|
"dropHint": "Alles auf einmal, so wie es vom Aussteller kommt: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
|
||||||
|
"removeFile": "{name} entfernen",
|
||||||
|
"reset": "Alle entfernen",
|
||||||
|
"analyzing": "Dateien werden geprüft …",
|
||||||
|
"error": "Die Dateien konnten nicht geprüft werden. Bitte prüfen Sie, ob es Zertifikatsdateien sind.",
|
||||||
|
"locked": "Geschützt: {files}. Geben Sie das Passwort ein, um auch diesen Inhalt zu lesen.",
|
||||||
|
"lockedPassword": "Passwort der geschützten Datei",
|
||||||
|
"unlock": "Entsperren",
|
||||||
|
"nothingFound": "In den Dateien wurde kein Zertifikat, Schlüssel und keine Zertifikatsanfrage gefunden.",
|
||||||
|
"ignored": "Nicht verwendet (kein Zertifikat erkannt): {files}",
|
||||||
|
"type": {
|
||||||
|
"end-entity": "Serverzertifikat",
|
||||||
|
"intermediate": "Zwischenzertifikat",
|
||||||
|
"root": "Stammzertifikat",
|
||||||
|
"privateKey": "Privater Schlüssel",
|
||||||
|
"csr": "Zertifikatsanfrage (CSR)"
|
||||||
|
},
|
||||||
|
"explain": {
|
||||||
|
"end-entity": "Das eigentliche Zertifikat für Ihre Domain – das gehört auf den Webserver.",
|
||||||
|
"intermediate": "Bestätigt Ihr Serverzertifikat gegenüber dem Browser. Wird zusammen mit dem Serverzertifikat eingespielt (Kette).",
|
||||||
|
"root": "Oberste Zertifizierungsstelle. Ist in Browsern und Betriebssystemen meist schon vorhanden.",
|
||||||
|
"privateKey": "Der geheime Schlüssel zum Serverzertifikat. Wird auf dem Server gebraucht, darf aber nie weitergegeben werden.",
|
||||||
|
"csr": "Die Anfrage, mit der das Zertifikat beim Aussteller bestellt wurde. Wird nur für eine Neuausstellung gebraucht."
|
||||||
|
},
|
||||||
|
"issuer": "Ausgestellt von",
|
||||||
|
"validity": "Gültig",
|
||||||
|
"names": "Gilt für",
|
||||||
|
"key": "Schlüssel",
|
||||||
|
"belongsTo": "Gehört zu",
|
||||||
|
"source": "Gefunden in",
|
||||||
|
"valid": "Gültig",
|
||||||
|
"expired": "Abgelaufen",
|
||||||
|
"expiresSoon": "Läuft in {days} Tagen ab",
|
||||||
|
"keySecret": "Geheim halten: Wer diesen Schlüssel hat, kann sich als Ihre Website ausgeben.",
|
||||||
|
"downloading": "Wird erstellt …",
|
||||||
|
"exportError": "Diese Datei konnte nicht erstellt werden.",
|
||||||
|
"format": {
|
||||||
|
"crt": "PEM (.crt)",
|
||||||
|
"cer": "DER (.cer)",
|
||||||
|
"fullchain": "Mit Kette (.pem)",
|
||||||
|
"p7b": "PKCS#7 (.p7b)",
|
||||||
|
"pfx": "PFX (.pfx)",
|
||||||
|
"key": "PEM (.key)",
|
||||||
|
"key-rsa": "RSA-PEM (.rsa.key)",
|
||||||
|
"key-der": "DER (.key.der)",
|
||||||
|
"csr": "PEM (.csr)",
|
||||||
|
"csr-der": "DER (.csr.der)"
|
||||||
|
},
|
||||||
|
"formatHint": {
|
||||||
|
"crt": "Textformat, z. B. für Apache, Nginx und die meisten Geräte",
|
||||||
|
"cer": "Binärformat, z. B. für Windows und Java",
|
||||||
|
"fullchain": "Zertifikat und Kette in einer Datei, z. B. für Nginx",
|
||||||
|
"p7b": "Zertifikat und Kette ohne Schlüssel, z. B. für Windows/IIS",
|
||||||
|
"pfx": "Zertifikat, Kette und Schlüssel in einer passwortgeschützten Datei, z. B. für Windows/IIS und Exchange",
|
||||||
|
"key": "Schlüssel im Standardformat (PKCS#8)",
|
||||||
|
"key-rsa": "Schlüssel im älteren RSA-Format (PKCS#1), für ältere Software",
|
||||||
|
"key-der": "Schlüssel als Binärdatei",
|
||||||
|
"csr": "Zertifikatsanfrage als Text",
|
||||||
|
"csr-der": "Zertifikatsanfrage als Binärdatei"
|
||||||
|
},
|
||||||
|
"pfxWithKey": "Die PFX-Datei enthält Zertifikat, Kette und privaten Schlüssel. Legen Sie ein Passwort fest – es wird beim Einspielen abgefragt.",
|
||||||
|
"pfxWithoutKey": "Zu diesem Zertifikat liegt kein Schlüssel vor – die PFX-Datei enthält nur Zertifikat und Kette. Legen Sie ein Passwort fest.",
|
||||||
|
"pfxPassword": "Passwort für die PFX-Datei",
|
||||||
|
"pfxDownload": "PFX herunterladen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tenderRadar": {
|
"tenderRadar": {
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"customModules": "Custom modules",
|
"customModules": "Custom modules",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"smtp": "SMTP",
|
"smtp": "SMTP",
|
||||||
"groups": "Groups"
|
"groups": "Groups",
|
||||||
|
"welcomeMail": "Welcome email"
|
||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"SUPER_ADMIN": "Super Admin",
|
"SUPER_ADMIN": "Super Admin",
|
||||||
@@ -738,6 +739,106 @@
|
|||||||
"create": "Add shared entry",
|
"create": "Add shared entry",
|
||||||
"noModules": "No shared entries yet",
|
"noModules": "No shared entries yet",
|
||||||
"noModulesBody": "Add your first shared entry to show a website in the sidebar for all users."
|
"noModulesBody": "Add your first shared entry to show a website in the sidebar for all users."
|
||||||
|
},
|
||||||
|
"welcomeMail": {
|
||||||
|
"title": "Welcome email",
|
||||||
|
"description": "Choose the words Tessera uses to greet new users. The template applies to your tenant and is used for every welcome email sent from user management.",
|
||||||
|
"loading": "Loading template …",
|
||||||
|
"fixed": {
|
||||||
|
"title": "What you can change – and what always stays",
|
||||||
|
"body": "You decide the subject, heading, introduction, closing text and the sign-in hint – separately for directory accounts and for local accounts. Everything technically needed to sign in is always added automatically and cannot be changed: the header with logo, the box with address and username, the “Set password” button with its link and validity note for local accounts, the “Go to Tessera” button and the footer. This way a custom template cannot break sign-in."
|
||||||
|
},
|
||||||
|
"statusDefault": "The default texts are currently in use.",
|
||||||
|
"statusCustom": "Custom template active – last changed on {date}.",
|
||||||
|
"statusCustomBy": "Custom template active – last changed on {date} by {user}.",
|
||||||
|
"unsaved": "Unsaved changes",
|
||||||
|
"fields": {
|
||||||
|
"subject": "Subject",
|
||||||
|
"subjectHelp": "One line, required.",
|
||||||
|
"heading": "Heading",
|
||||||
|
"headingHelp": "Large line at the top of the email, required.",
|
||||||
|
"intro": "Introduction",
|
||||||
|
"introHelp": "Shown below the heading. An empty line starts a new paragraph.",
|
||||||
|
"loginHintDirectory": "Sign-in hint for directory accounts",
|
||||||
|
"loginHintDirectoryHelp": "Shown below the box with the sign-in details when the user comes from the directory (Active Directory/LDAP) and signs in with their usual Windows password. Required.",
|
||||||
|
"loginHintLocal": "Sign-in hint for local accounts",
|
||||||
|
"loginHintLocalHelp": "Shown for local accounts right before the “Set password” button. Tessera always adds the button and the validity note below it. Required.",
|
||||||
|
"closing": "Closing text",
|
||||||
|
"closingHelp": "Shown at the end of the email, e.g. a tip and a sign-off. May be left empty."
|
||||||
|
},
|
||||||
|
"counter": "{count} / {max}",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving …",
|
||||||
|
"saved": "The template has been saved. New welcome emails will use these texts from now on.",
|
||||||
|
"test": "Send test email to me",
|
||||||
|
"testSending": "Sending …",
|
||||||
|
"testSent": "The test email was sent to {email}.",
|
||||||
|
"testHelp": "The test email goes to your own email address and shows the current form content, even if it has not been saved yet. It does not contain a real link for setting a password.",
|
||||||
|
"reset": "Reset to default",
|
||||||
|
"resetDone": "The default texts have been restored.",
|
||||||
|
"resetConfirm": {
|
||||||
|
"title": "Reset to default?",
|
||||||
|
"body": "Your custom template will be deleted and the default texts apply again. This cannot be undone.",
|
||||||
|
"confirm": "Reset",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load": "The template could not be loaded. Please reload the page.",
|
||||||
|
"save": "The template could not be saved.",
|
||||||
|
"reset": "The template could not be reset.",
|
||||||
|
"test": "The test email could not be sent.",
|
||||||
|
"required": "Please fill in this field.",
|
||||||
|
"tooLong": "At most {max} characters.",
|
||||||
|
"unknownPlaceholder": "Unknown placeholder {token}",
|
||||||
|
"unknownPlaceholders": "Unknown placeholders {tokens}"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"title": "Placeholders",
|
||||||
|
"intro": "Tessera replaces placeholders with the details of each user when sending. They work in all four fields. Clicking a placeholder inserts it at the cursor position of the field you used last.",
|
||||||
|
"colPlaceholder": "Placeholder",
|
||||||
|
"colMeaning": "Meaning",
|
||||||
|
"colExample": "Example",
|
||||||
|
"insert": "Insert {token}",
|
||||||
|
"insertHint": "Click to insert",
|
||||||
|
"rules": "Tessera does not know any other placeholders – they are rejected when saving. The texts are plain text: formatting or HTML is not applied but shown exactly as you type it.",
|
||||||
|
"items": {
|
||||||
|
"name": {
|
||||||
|
"meaning": "Display name of the user, otherwise the username",
|
||||||
|
"example": "Max Mustermann"
|
||||||
|
},
|
||||||
|
"vorname": {
|
||||||
|
"meaning": "First word of the display name, otherwise the username",
|
||||||
|
"example": "Max"
|
||||||
|
},
|
||||||
|
"benutzername": {
|
||||||
|
"meaning": "Username for signing in",
|
||||||
|
"example": "max.mustermann"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"meaning": "Email address of the user",
|
||||||
|
"example": "max.mustermann@example.com"
|
||||||
|
},
|
||||||
|
"adresse": {
|
||||||
|
"meaning": "Address of Tessera",
|
||||||
|
"example": "https://tessera.example.com"
|
||||||
|
},
|
||||||
|
"firma": {
|
||||||
|
"meaning": "Name of your tenant",
|
||||||
|
"example": "Example Ltd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Preview",
|
||||||
|
"accountLabel": "Preview for account type",
|
||||||
|
"directory": "Directory account",
|
||||||
|
"local": "Local account",
|
||||||
|
"subject": "Subject:",
|
||||||
|
"frameTitle": "Welcome email preview",
|
||||||
|
"error": "The preview could not be created.",
|
||||||
|
"blocked": "The preview updates as soon as all fields are valid.",
|
||||||
|
"sampleHint": "The preview uses sample values (Max Mustermann) and the name of your tenant. The “Set password” link is only an example in the preview."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adminModules": {
|
"adminModules": {
|
||||||
@@ -1081,8 +1182,9 @@
|
|||||||
},
|
},
|
||||||
"certManager": {
|
"certManager": {
|
||||||
"title": "Certificate Manager",
|
"title": "Certificate Manager",
|
||||||
"description": "Inspect, split, merge and convert certificates.",
|
"description": "Check certificate bundles and download them in any format; inspect, split, merge and convert single certificates.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
"overview": "Overview",
|
||||||
"inspect": "Inspect",
|
"inspect": "Inspect",
|
||||||
"split": "Split",
|
"split": "Split",
|
||||||
"merge": "Merge",
|
"merge": "Merge",
|
||||||
@@ -1128,6 +1230,73 @@
|
|||||||
"generic": "Processing failed. Check the file format or password.",
|
"generic": "Processing failed. Check the file format or password.",
|
||||||
"wrongPassword": "Wrong password. Could not decrypt the PFX/P12 file.",
|
"wrongPassword": "Wrong password. Could not decrypt the PFX/P12 file.",
|
||||||
"unknownFormat": "Unknown format. The file could not be recognized as a certificate."
|
"unknownFormat": "Unknown format. The file could not be recognized as a certificate."
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"dropTitle": "Drop certificate files or a ZIP here, or click",
|
||||||
|
"dropHint": "Everything at once, as delivered by the issuer: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
|
||||||
|
"removeFile": "Remove {name}",
|
||||||
|
"reset": "Remove all",
|
||||||
|
"analyzing": "Checking files …",
|
||||||
|
"error": "The files could not be checked. Please make sure they are certificate files.",
|
||||||
|
"locked": "Protected: {files}. Enter the password to read this content as well.",
|
||||||
|
"lockedPassword": "Password of the protected file",
|
||||||
|
"unlock": "Unlock",
|
||||||
|
"nothingFound": "No certificate, key or certificate request was found in the files.",
|
||||||
|
"ignored": "Not used (no certificate detected): {files}",
|
||||||
|
"type": {
|
||||||
|
"end-entity": "Server certificate",
|
||||||
|
"intermediate": "Intermediate certificate",
|
||||||
|
"root": "Root certificate",
|
||||||
|
"privateKey": "Private key",
|
||||||
|
"csr": "Certificate request (CSR)"
|
||||||
|
},
|
||||||
|
"explain": {
|
||||||
|
"end-entity": "The actual certificate for your domain – it goes on the web server.",
|
||||||
|
"intermediate": "Vouches for your server certificate towards the browser. Install it together with the server certificate (chain).",
|
||||||
|
"root": "Top-level certificate authority. Usually already present in browsers and operating systems.",
|
||||||
|
"privateKey": "The secret key for the server certificate. Needed on the server, but must never be shared.",
|
||||||
|
"csr": "The request used to order the certificate from the issuer. Only needed for a reissue."
|
||||||
|
},
|
||||||
|
"issuer": "Issued by",
|
||||||
|
"validity": "Valid",
|
||||||
|
"names": "Valid for",
|
||||||
|
"key": "Key",
|
||||||
|
"belongsTo": "Belongs to",
|
||||||
|
"source": "Found in",
|
||||||
|
"valid": "Valid",
|
||||||
|
"expired": "Expired",
|
||||||
|
"expiresSoon": "Expires in {days} days",
|
||||||
|
"keySecret": "Keep secret: whoever has this key can impersonate your website.",
|
||||||
|
"downloading": "Creating …",
|
||||||
|
"exportError": "This file could not be created.",
|
||||||
|
"format": {
|
||||||
|
"crt": "PEM (.crt)",
|
||||||
|
"cer": "DER (.cer)",
|
||||||
|
"fullchain": "With chain (.pem)",
|
||||||
|
"p7b": "PKCS#7 (.p7b)",
|
||||||
|
"pfx": "PFX (.pfx)",
|
||||||
|
"key": "PEM (.key)",
|
||||||
|
"key-rsa": "RSA PEM (.rsa.key)",
|
||||||
|
"key-der": "DER (.key.der)",
|
||||||
|
"csr": "PEM (.csr)",
|
||||||
|
"csr-der": "DER (.csr.der)"
|
||||||
|
},
|
||||||
|
"formatHint": {
|
||||||
|
"crt": "Text format, e.g. for Apache, Nginx and most devices",
|
||||||
|
"cer": "Binary format, e.g. for Windows and Java",
|
||||||
|
"fullchain": "Certificate and chain in one file, e.g. for Nginx",
|
||||||
|
"p7b": "Certificate and chain without key, e.g. for Windows/IIS",
|
||||||
|
"pfx": "Certificate, chain and key in one password-protected file, e.g. for Windows/IIS and Exchange",
|
||||||
|
"key": "Key in standard format (PKCS#8)",
|
||||||
|
"key-rsa": "Key in older RSA format (PKCS#1), for older software",
|
||||||
|
"key-der": "Key as binary file",
|
||||||
|
"csr": "Certificate request as text",
|
||||||
|
"csr-der": "Certificate request as binary file"
|
||||||
|
},
|
||||||
|
"pfxWithKey": "The PFX file contains certificate, chain and private key. Set a password – it is asked for when importing.",
|
||||||
|
"pfxWithoutKey": "No key is available for this certificate – the PFX file contains only certificate and chain. Set a password.",
|
||||||
|
"pfxPassword": "Password for the PFX file",
|
||||||
|
"pfxDownload": "Download PFX"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tenderRadar": {
|
"tenderRadar": {
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ export const UMLAUT_REPLACEMENTS: Record<string, string> = {
|
|||||||
* and must never be touched by the replacement or flagged by the guard.
|
* and must never be touched by the replacement or flagged by the guard.
|
||||||
*/
|
*/
|
||||||
export const UMLAUT_ALLOWLIST: readonly string[] = [
|
export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||||
|
// quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch)
|
||||||
|
'Aussteller',
|
||||||
|
'Betriebssystemen',
|
||||||
|
'Neuausstellung',
|
||||||
|
'passwortgeschützten',
|
||||||
|
'Schlüssel',
|
||||||
|
'Zertifizierungsstelle',
|
||||||
// quick-260929-if2: „lässt“ (Erinnerung „lässt sich nicht mehr bearbeiten“)
|
// quick-260929-if2: „lässt“ (Erinnerung „lässt sich nicht mehr bearbeiten“)
|
||||||
'lässt',
|
'lässt',
|
||||||
'manuell',
|
'manuell',
|
||||||
@@ -197,4 +204,10 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
|||||||
'Arbeitsspeicher',
|
'Arbeitsspeicher',
|
||||||
// quick-260925-bow: „Was ist neu“-Fenster, Gruppe „Verbessert“ — korrektes Deutsch mit „ss“
|
// quick-260925-bow: „Was ist neu“-Fenster, Gruppe „Verbessert“ — korrektes Deutsch mit „ss“
|
||||||
'Verbessert',
|
'Verbessert',
|
||||||
|
// Administrator → Willkommensmail (eigene Vorlage) — korrektes Deutsch mit „ss“/„ue“/„ae“
|
||||||
|
'anpassen',
|
||||||
|
'Abschlusstext',
|
||||||
|
'aktuellen',
|
||||||
|
'Passworts',
|
||||||
|
'tessera',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { NextRequest } from 'next/server';
|
|
||||||
import { SignJWT } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { middleware } from './middleware';
|
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 () => {
|
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);
|
const res = await middleware(req);
|
||||||
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
|
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
|
||||||
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|windows');
|
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();
|
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();
|
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');
|
const emptyCommit = new NextRequest(
|
||||||
expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe('1.2.0||linux');
|
'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 () => {
|
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);
|
const res = await middleware(req);
|
||||||
expect(res.status).toBe(307);
|
expect(res.status).toBe(307);
|
||||||
expect(res.headers.get('location')).toContain('/login');
|
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');
|
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 { 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).
|
* Next.js middleware for frontend route protection (Pattern 4).
|
||||||
@@ -113,6 +113,28 @@ function getSecret() {
|
|||||||
export async function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const path = req.nextUrl.pathname;
|
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
|
// Allow public routes without authentication
|
||||||
if (publicRoutes.some((route) => path.startsWith(route))) {
|
if (publicRoutes.some((route) => path.startsWith(route))) {
|
||||||
return withDesktopCookie(req, NextResponse.next());
|
return withDesktopCookie(req, NextResponse.next());
|
||||||
@@ -141,10 +163,7 @@ export async function middleware(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// D-06: Force password change redirect
|
// D-06: Force password change redirect
|
||||||
if (
|
if (payload.mustChangePassword === true && !path.startsWith('/change-password')) {
|
||||||
payload.mustChangePassword === true &&
|
|
||||||
!path.startsWith('/change-password')
|
|
||||||
) {
|
|
||||||
return withDesktopCookie(
|
return withDesktopCookie(
|
||||||
req,
|
req,
|
||||||
NextResponse.redirect(new URL('/change-password', req.nextUrl)),
|
NextResponse.redirect(new URL('/change-password', req.nextUrl)),
|
||||||
|
|||||||
@@ -58,10 +58,38 @@ Seit Kurzem ist die E-Mail-Adresse eines Benutzers **optional**: Wenn beim Impor
|
|||||||
Bei jedem Benutzer steht in der Tabelle der Button „Willkommensmail senden“ – gedacht vor allem für neue Konten (Spalte „Letzte Anmeldung“: „Noch nie“), er lässt sich aber auch bei Benutzern nutzen, die sich schon angemeldet haben, etwa um die Zugangsdaten erneut zuzuschicken oder die Mail auszuprobieren. Es gelten dieselben Rechte wie beim Bearbeiten: ein ADMIN kann nur Benutzern des eigenen Mandanten und keinem SUPER_ADMIN eine Willkommensmail schicken. Nach einer Rückfrage mit der Empfängeradresse verschickt Tessera eine gestaltete E-Mail mit der Adresse von Tessera, dem Benutzernamen und einem Hinweis zur ersten Anmeldung:
|
Bei jedem Benutzer steht in der Tabelle der Button „Willkommensmail senden“ – gedacht vor allem für neue Konten (Spalte „Letzte Anmeldung“: „Noch nie“), er lässt sich aber auch bei Benutzern nutzen, die sich schon angemeldet haben, etwa um die Zugangsdaten erneut zuzuschicken oder die Mail auszuprobieren. Es gelten dieselben Rechte wie beim Bearbeiten: ein ADMIN kann nur Benutzern des eigenen Mandanten und keinem SUPER_ADMIN eine Willkommensmail schicken. Nach einer Rückfrage mit der Empfängeradresse verschickt Tessera eine gestaltete E-Mail mit der Adresse von Tessera, dem Benutzernamen und einem Hinweis zur ersten Anmeldung:
|
||||||
|
|
||||||
- **verzeichnisgeführtes Konto (AD/LDAP):** Hinweis, sich mit dem gewohnten Windows-Passwort anzumelden;
|
- **verzeichnisgeführtes Konto (AD/LDAP):** Hinweis, sich mit dem gewohnten Windows-Passwort anzumelden;
|
||||||
- **lokales Konto:** ein Button „Passwort festlegen“, über den der Benutzer sein eigenes Passwort setzt. Der Link funktioniert wie „Passwort vergessen?“ – er ist 1 Stunde gültig und nur einmal verwendbar; danach kann der Benutzer auf der Anmeldeseite jederzeit einen neuen anfordern.
|
- **lokales Konto:** ein Button „Passwort festlegen“, über den der Benutzer sein eigenes Passwort setzt. Der Link funktioniert wie „Passwort vergessen?“ – er ist 7 Tage gültig und nur einmal verwendbar; danach kann der Benutzer auf der Anmeldeseite jederzeit einen neuen anfordern.
|
||||||
|
|
||||||
Ein Passwort steht nie in der Mail. Bei einem lokalen Konto, das bereits ein Passwort hat, ersetzt „Passwort festlegen“ dieses Passwort erst, wenn der Benutzer den Link tatsächlich benutzt. Nach dem Versand zeigt die Zeile „Willkommensmail gesendet am …“, und der Button heißt „Erneut senden“. Der Button ist ausgegraut, wenn beim Benutzer keine E-Mail-Adresse hinterlegt ist, das Konto deaktiviert ist oder noch kein Mailserver eingerichtet wurde (siehe Kapitel 6, SMTP); der Grund steht im Hinweistext des Buttons. Die Adresse in der Mail stammt aus der Einstellung `APP_URL` des Servers (siehe `docs/anleitung-betrieb.md`); fehlt sie, nimmt Tessera die Adresse, unter der Sie die Benutzerverwaltung gerade geöffnet haben.
|
Ein Passwort steht nie in der Mail. Bei einem lokalen Konto, das bereits ein Passwort hat, ersetzt „Passwort festlegen“ dieses Passwort erst, wenn der Benutzer den Link tatsächlich benutzt. Nach dem Versand zeigt die Zeile „Willkommensmail gesendet am …“, und der Button heißt „Erneut senden“. Der Button ist ausgegraut, wenn beim Benutzer keine E-Mail-Adresse hinterlegt ist, das Konto deaktiviert ist oder noch kein Mailserver eingerichtet wurde (siehe Kapitel 6, SMTP); der Grund steht im Hinweistext des Buttons. Die Adresse in der Mail stammt aus der Einstellung `APP_URL` des Servers (siehe `docs/anleitung-betrieb.md`); fehlt sie, nimmt Tessera die Adresse, unter der Sie die Benutzerverwaltung gerade geöffnet haben.
|
||||||
|
|
||||||
|
#### Eigene Vorlage für die Willkommensmail
|
||||||
|
|
||||||
|
Unter **Administrator → Willkommensmail** legen Sie fest, mit welchen Worten Tessera neue Benutzer begrüßt. Die Vorlage gilt für Ihren Mandanten; ADMIN und SUPER_ADMIN können sie bearbeiten. Anpassen lassen sich sechs Texte:
|
||||||
|
|
||||||
|
- **Betreff** (Pflicht, höchstens 200 Zeichen),
|
||||||
|
- **Überschrift** (Pflicht, höchstens 200 Zeichen),
|
||||||
|
- **Einleitungstext** (höchstens 4000 Zeichen; eine Leerzeile beginnt einen neuen Absatz),
|
||||||
|
- **Anmeldehinweis für Verzeichniskonten** (Pflicht, höchstens 1000 Zeichen) – erscheint unter dem Kasten mit den Zugangsdaten, wenn der Benutzer aus dem Verzeichnis (AD/LDAP) kommt; Standard: „Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.“,
|
||||||
|
- **Anmeldehinweis für lokale Konten** (Pflicht, höchstens 1000 Zeichen) – erscheint bei lokalen Konten direkt vor dem Button „Passwort festlegen“; Standard: „Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.“,
|
||||||
|
- **Abschlusstext** (höchstens 4000 Zeichen, darf leer bleiben – etwa ein Tipp und die Grußzeile).
|
||||||
|
|
||||||
|
Alles, was für die Anmeldung technisch nötig ist, fügt Tessera immer automatisch ein und lässt sich nicht verändern: der Kopf mit Logo, der Kasten mit Adresse und Benutzername, der Button „Passwort festlegen“ samt persönlichem Link und Gültigkeitshinweis bei lokalen Konten, der Button „Zu Tessera“ und die Fußzeile. Beide Anmeldehinweise dürfen Leerzeilen (neuer Absatz) enthalten. Vorlagen, die vor Einführung der Anmeldehinweise gespeichert wurden, verwenden dafür automatisch die Standardtexte. So kann eine eigene Vorlage die Anmeldung nicht kaputt machen. Die Texte sind reiner Text – Formatierungen oder HTML werden nicht übernommen, sondern so angezeigt, wie sie eingegeben wurden.
|
||||||
|
|
||||||
|
**Platzhalter** funktionieren in allen sechs Feldern und werden beim Versand durch die Angaben des jeweiligen Benutzers ersetzt. Ein Klick auf einen Platzhalter in der Hinweisbox fügt ihn an der Cursorposition des zuletzt benutzten Feldes ein.
|
||||||
|
|
||||||
|
| Platzhalter | Bedeutung | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| `{{name}}` | Anzeigename, sonst Benutzername | Max Mustermann |
|
||||||
|
| `{{vorname}}` | erstes Wort des Anzeigenamens, sonst Benutzername | Max |
|
||||||
|
| `{{benutzername}}` | Benutzername für die Anmeldung | max.mustermann |
|
||||||
|
| `{{email}}` | E-Mail-Adresse des Benutzers | max.mustermann@example.com |
|
||||||
|
| `{{adresse}}` | Adresse von Tessera | https://tessera.example.com |
|
||||||
|
| `{{firma}}` | Name des Mandanten | Beispiel GmbH |
|
||||||
|
|
||||||
|
Andere Platzhalter kennt Tessera nicht: die Seite nennt sie schon beim Tippen („Unbekannter Platzhalter {{xyz}}“), und das Speichern wird abgelehnt.
|
||||||
|
|
||||||
|
Rechts zeigt eine **Vorschau** die Mail genau so, wie sie verschickt wird – mit Beispielwerten und umschaltbar zwischen „Verzeichniskonto“ und „Lokales Konto“; die Vorschau zeigt dabei den jeweiligen Anmeldehinweis und springt beim Bearbeiten eines Anmeldehinweises von selbst auf die passende Kontoart. **Testmail an mich senden** schickt den aktuellen Inhalt des Formulars (auch ungespeichert) an Ihre eigene E-Mail-Adresse, mit Ihrem Konto als Beispiel. Die Testmail enthält nie einen echten Link zum Festlegen eines Passworts: bei einem lokalen Konto führt der Button nur zur Anmeldeseite und ist als Beispiel gekennzeichnet. **Auf Standard zurücksetzen** löscht die eigene Vorlage nach einer Rückfrage; danach gelten wieder die mitgelieferten Standardtexte. Ohne eigene Vorlage verwendet Tessera immer die Standardtexte.
|
||||||
|
|
||||||
### Deaktivieren und Löschen
|
### Deaktivieren und Löschen
|
||||||
|
|
||||||
Die Benutzerliste zeigt einen Status „Aktiv“/„Inaktiv“ an, dieser lässt sich aber **nicht** über einen Schalter im Formular umschalten – im Bearbeiten-Dialog gibt es dafür kein Feld. Ein Konto wird auf zwei Wegen inaktiv:
|
Die Benutzerliste zeigt einen Status „Aktiv“/„Inaktiv“ an, dieser lässt sich aber **nicht** über einen Schalter im Formular umschalten – im Bearbeiten-Dialog gibt es dafür kein Feld. Ein Konto wird auf zwei Wegen inaktiv:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -288,3 +288,91 @@ export const CUSTOM_MODULE_CATEGORY = "custom-modules" as const;
|
|||||||
export const CUSTOM_MODULE_CATEGORIES = [...MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY] as const;
|
export const CUSTOM_MODULE_CATEGORIES = [...MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY] as const;
|
||||||
|
|
||||||
export type CustomModuleCategory = (typeof CUSTOM_MODULE_CATEGORIES)[number];
|
export type CustomModuleCategory = (typeof CUSTOM_MODULE_CATEGORIES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail).
|
||||||
|
*
|
||||||
|
* Eine Stelle fuer API und Oberflaeche: welche Platzhalter es gibt, wie lang
|
||||||
|
* die sechs Textfelder sein duerfen, wie die Standardtexte lauten (Vorbelegung
|
||||||
|
* im Formular, "Auf Standard zuruecksetzen", Versand ohne eigene Vorlage) und
|
||||||
|
* wie unbekannte Platzhalter erkannt werden. Die API lehnt eine Vorlage mit
|
||||||
|
* unbekanntem Platzhalter mit 400 ab; das Formular benennt ihn schon vorher.
|
||||||
|
*
|
||||||
|
* Die festen Bausteine der Mail (Kopf, Kasten Adresse/Benutzername, Knopf
|
||||||
|
* "Passwort festlegen" samt Gueltigkeitshinweis, Knopf "Zu Tessera",
|
||||||
|
* Fusszeile) sind KEIN Teil der Vorlage — so kann eine eigene Vorlage die
|
||||||
|
* Anmeldung nicht kaputt machen. Anpassbar ist nur der Anmeldehinweis-TEXT
|
||||||
|
* davor, getrennt fuer Verzeichniskonten und lokale Konten.
|
||||||
|
*/
|
||||||
|
export const WELCOME_MAIL_PLACEHOLDERS = [
|
||||||
|
'name',
|
||||||
|
'vorname',
|
||||||
|
'benutzername',
|
||||||
|
'email',
|
||||||
|
'adresse',
|
||||||
|
'firma',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type WelcomeMailPlaceholder = (typeof WELCOME_MAIL_PLACEHOLDERS)[number];
|
||||||
|
|
||||||
|
/** Die sechs bearbeitbaren Texte der Willkommensmail. */
|
||||||
|
export interface WelcomeMailTexts {
|
||||||
|
subject: string;
|
||||||
|
heading: string;
|
||||||
|
intro: string;
|
||||||
|
/** Anmeldehinweis fuer verzeichnisgefuehrte Konten (Windows-Passwort). */
|
||||||
|
loginHintDirectory: string;
|
||||||
|
/** Anmeldehinweis fuer lokale Konten, steht vor dem Knopf "Passwort festlegen". */
|
||||||
|
loginHintLocal: string;
|
||||||
|
closing: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hoechstlaengen je Feld (Zeichen). */
|
||||||
|
export const WELCOME_MAIL_LIMITS: Readonly<Record<keyof WelcomeMailTexts, number>> = {
|
||||||
|
subject: 200,
|
||||||
|
heading: 200,
|
||||||
|
intro: 4000,
|
||||||
|
loginHintDirectory: 1000,
|
||||||
|
loginHintLocal: 1000,
|
||||||
|
closing: 4000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardtexte — entsprechen der Willkommensmail vor der eigenen Vorlage.
|
||||||
|
* Leerzeile = neuer Absatz, einfacher Zeilenumbruch = Umbruch im Absatz.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_WELCOME_MAIL_TEXTS: Readonly<WelcomeMailTexts> = {
|
||||||
|
subject: 'Willkommen bei Tessera',
|
||||||
|
heading: 'Willkommen bei Tessera, {{name}}!',
|
||||||
|
intro:
|
||||||
|
'Für Sie wurde ein Zugang zu Tessera eingerichtet – Ihrer zentralen Plattform für Werkzeuge und Abläufe im Unternehmen. Alles, was Sie für Ihre tägliche Arbeit brauchen, finden Sie dort an einem Ort.',
|
||||||
|
loginHintDirectory:
|
||||||
|
'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.',
|
||||||
|
loginHintLocal:
|
||||||
|
'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.',
|
||||||
|
closing:
|
||||||
|
'Tipp: Tessera gibt es auch als Desktop-App – den Download finden Sie auf der Anmeldeseite.\n\nViel Erfolg mit Tessera!',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Findet `{{ … }}`; der Name ohne Leerraum steht in Gruppe 1. */
|
||||||
|
export const WELCOME_MAIL_PLACEHOLDER_RE = /\{\{\s*([^{}]*?)\s*\}\}/g;
|
||||||
|
|
||||||
|
/** Ob `name` (Gross-/Kleinschreibung egal) ein bekannter Platzhalter ist. */
|
||||||
|
export function isWelcomeMailPlaceholder(name: string): name is WelcomeMailPlaceholder {
|
||||||
|
return (WELCOME_MAIL_PLACEHOLDERS as readonly string[]).includes(name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle unbekannten Platzhalter eines Textes in der Form `{{xyz}}`, ohne
|
||||||
|
* Doppelte, in Reihenfolge des ersten Auftretens.
|
||||||
|
*/
|
||||||
|
export function findUnknownWelcomeMailPlaceholders(text: string): string[] {
|
||||||
|
const unknown: string[] = [];
|
||||||
|
for (const match of text.matchAll(WELCOME_MAIL_PLACEHOLDER_RE)) {
|
||||||
|
const token = `{{${match[1]}}}`;
|
||||||
|
if (!isWelcomeMailPlaceholder(match[1]) && !unknown.includes(token)) {
|
||||||
|
unknown.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unknown;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user