feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
Administrator -> Willkommensmail: Betreff, Ueberschrift, Einleitung, Abschluss je
Mandant (Tabelle WelcomeMailTemplate, RLS je Mandant, Migration 20260930150000);
Platzhalter {{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}},
unbekannte -> 400 bzw. Hinweis beim Tippen; Werte escaped, Vorlage reiner Text.
Live-Vorschau per API gerendert, Testmail an die eigene Adresse ohne Token,
Zuruecksetzen auf Standard. Feste Bausteine (Kopf, Zugangsdaten, Anmeldehinweis,
Knoepfe, Fusszeile) bleiben immer drin.
Kopf: Wellenzelle dunkel statt weiss, Streifen 600x40, Inhalt 24 px naeher –
keine weisse Luecke, wenn OWA das CID-Bild nicht zeigt.
Lokal nachgewiesen: Hinweis/Sperre bei {{xyz}}, Speichern, Testmail (Link nur
/login), echte Mail mit eigener Vorlage und 7-Tage-Link.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="112" viewBox="0 0 1200 112">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="80" viewBox="0 0 1200 80">
|
||||
<!--
|
||||
Wellenstreifen unter dem Kopf der Tessera-Systemmails (Willkommensmail).
|
||||
Quelle des PNG daneben (welcome-header.png, 1200x112, angezeigt 600x56);
|
||||
Quelle des PNG daneben (welcome-header.png, 1200x80, angezeigt 600x40);
|
||||
erzeugt mit `node apps/api/scripts/render-mail-header.mjs`.
|
||||
|
||||
Seit quick-260930 (Rueckmeldung des Nutzers: in Outlook "ein riesiger
|
||||
@@ -11,14 +11,17 @@
|
||||
Duenen-Wellen des Dashboard-Hintergrunds (dunkle Fassung,
|
||||
apps/web/src/lib/dashboard-background.ts) mit der feinen gelben Linie,
|
||||
unten laeuft es ins Weiss der Karte aus. Zeigt ein Mailprogramm das Bild
|
||||
nicht, bleibt dort nur weisser Abstand.
|
||||
nicht (Outlook mit gesperrten Bildern), bleibt dort die dunkle Kopffarbe
|
||||
der Zelle stehen — der Kopf wirkt nur etwas hoeher, keine weisse Luecke.
|
||||
Seit quick-260930 (zweite Rueckmeldung) 80 statt 112 px hoch (y-Werte
|
||||
x 80/112), in der Mail 40 statt 56 px.
|
||||
-->
|
||||
<rect width="1200" height="112" fill="#ffffff"/>
|
||||
<rect width="1200" height="80" fill="#ffffff"/>
|
||||
<!-- Flaechen von unten nach oben uebereinander, jede von oben bis zu ihrer
|
||||
Wellenlinie: so teilen sich benachbarte Baender dieselbe Kante, ohne
|
||||
Luecken dazwischen. -->
|
||||
<path d="M0 0 V92 C220 78 420 102 600 96 S1000 76 1200 86 V0 Z" fill="#d9dce0"/>
|
||||
<path d="M0 0 V70 C250 50 420 86 640 76 S1040 50 1200 64 V0 Z" fill="#2c3036"/>
|
||||
<path d="M0 0 V44 C330 18 520 62 700 52 S1060 22 1200 40 V0 Z" fill="#1a1c20"/>
|
||||
<path d="M0 44 C330 18 520 62 700 52 S1060 22 1200 40" fill="none" stroke="#ffed00" stroke-opacity="0.75" stroke-width="3"/>
|
||||
<path d="M0 0 V65.7 C220 55.7 420 72.9 600 68.6 S1000 54.3 1200 61.4 V0 Z" fill="#d9dce0"/>
|
||||
<path d="M0 0 V50 C250 35.7 420 61.4 640 54.3 S1040 35.7 1200 45.7 V0 Z" fill="#2c3036"/>
|
||||
<path d="M0 0 V31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6 V0 Z" fill="#1a1c20"/>
|
||||
<path d="M0 31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6" fill="none" stroke="#ffed00" stroke-opacity="0.75" stroke-width="3"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,52 @@
|
||||
-- Willkommensmail: eigene Vorlage je Mandant (Administrator → Willkommensmail).
|
||||
--
|
||||
-- Zweck: neue Tabelle "WelcomeMailTemplate". Ein Administrator kann Betreff,
|
||||
-- Ueberschrift, Einleitung und Abschlusstext der Willkommensmail anpassen
|
||||
-- (mit Platzhaltern wie {{name}}). Hoechstens EINE Zeile je Mandant
|
||||
-- ("tenantId" eindeutig); "Auf Standard zuruecksetzen" loescht die Zeile, dann
|
||||
-- gelten wieder die Standardtexte aus dem Code. Die festen Bausteine der Mail
|
||||
-- (Kopf, Zugangsdaten, Anmeldehinweis, Knoepfe, Fusszeile) stehen NICHT in
|
||||
-- der Tabelle. "updatedBy" haelt den Benutzernamen des letzten Bearbeiters
|
||||
-- als reinen Anzeigetext (keine Relation).
|
||||
--
|
||||
-- Von Hand geschrieben (Vorbild 20260929120000_custom_module).
|
||||
--
|
||||
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
|
||||
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
|
||||
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
|
||||
-- Vorlage ist Verwaltungsdatum des Mandanten, nicht persoenliches Datum eines
|
||||
-- einzelnen Benutzers.
|
||||
--
|
||||
-- BEWUSST KEINE `system_read_policy`: gelesen wird nur beim Versand einer
|
||||
-- Willkommensmail, und zwar gebunden an den Mandanten des Zielbenutzers
|
||||
-- (`forTenant(prisma, tenantId)`); es gibt keinen Hintergrunddienst, der die
|
||||
-- Vorlagen ueber alle Mandanten liest.
|
||||
--
|
||||
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||
-- tun.
|
||||
--
|
||||
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||
|
||||
CREATE TABLE "WelcomeMailTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"heading" TEXT NOT NULL,
|
||||
"intro" TEXT NOT NULL,
|
||||
"closing" TEXT NOT NULL,
|
||||
"updatedBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WelcomeMailTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "WelcomeMailTemplate_tenantId_key" ON "WelcomeMailTemplate"("tenantId");
|
||||
|
||||
ALTER TABLE "WelcomeMailTemplate" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "WelcomeMailTemplate" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "WelcomeMailTemplate"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
@@ -767,3 +767,20 @@ model Reminder {
|
||||
@@index([tenantId, userId, dueAt])
|
||||
@@index([dueAt])
|
||||
}
|
||||
|
||||
// Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
|
||||
// hoechstens eine je Mandant; fehlt sie, gelten die Standardtexte aus
|
||||
// @tessera/shared (DEFAULT_WELCOME_MAIL_TEXTS). Nur die vier Texte —
|
||||
// Kopf, Zugangsdaten, Anmeldehinweis und Knoepfe bleiben fest im Code.
|
||||
model WelcomeMailTemplate {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique
|
||||
subject String
|
||||
heading String
|
||||
intro 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
|
||||
/**
|
||||
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
||||
* (apps/api/assets/mail/welcome-header.png, 1200x112 fuer hochaufloesende
|
||||
* Bildschirme, in der Mail 600x56 angezeigt) aus der daneben liegenden
|
||||
* (apps/api/assets/mail/welcome-header.png, 1200x80 fuer hochaufloesende
|
||||
* Bildschirme, in der Mail 600x40 angezeigt) aus der daneben liegenden
|
||||
* Quelle welcome-header.svg.
|
||||
*
|
||||
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
||||
@@ -33,7 +33,7 @@ const sharp = createRequire(nextPkg)('sharp');
|
||||
|
||||
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
||||
const png = await sharp(svg, { density: 72 })
|
||||
.resize(1200, 112)
|
||||
.resize(1200, 80)
|
||||
.png({ compressionLevel: 9, palette: false })
|
||||
.toBuffer();
|
||||
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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,
|
||||
};
|
||||
|
||||
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>',
|
||||
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', 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: '', 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('Kopf (Outlook ohne CID-Bild)', () => {
|
||||
it('Wellenstreifen 600x40 in einer Zelle mit dunkler Kopffarbe, Inhalt beginnt mit 24px Abstand', () => {
|
||||
const mail = renderWelcomeMail({ ...base, headerImageSrc: 'cid:welcome-header@tessera' });
|
||||
expect(mail.html).toMatch(
|
||||
/<td bgcolor="#1a1c20" style="background-color:#1a1c20;line-height:0;font-size:0;">\n<img src="cid:welcome-header@tessera" width="600" height="40"/,
|
||||
);
|
||||
expect(mail.html).toContain('padding:24px 40px 12px;');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,14 @@
|
||||
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
|
||||
* Anmeldehinweis" `WelcomeMailService`.
|
||||
*
|
||||
* Eigene Vorlage (Administrator → Willkommensmail): Betreff, Ueberschrift,
|
||||
* Einleitung und Abschluss kommen als `texts` herein (Vorlage des Mandanten
|
||||
* des ZIEL-Benutzers, sonst `DEFAULT_WELCOME_MAIL_TEXTS` aus
|
||||
* `@tessera/shared`). Platzhalter (`{{name}}`, `{{vorname}}`,
|
||||
* `{{benutzername}}`, `{{email}}`, `{{adresse}}`, `{{firma}}`) werden auf dem
|
||||
* REINEN Text ersetzt und erst danach escaped. Leerzeile = neuer Absatz,
|
||||
* einfacher Umbruch = `<br>`. Alles andere bleibt fest.
|
||||
*
|
||||
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
|
||||
* Mail sehr unterschiedlich darstellen:
|
||||
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
|
||||
@@ -25,6 +33,14 @@
|
||||
* Windows-Passwort.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_WELCOME_MAIL_TEXTS,
|
||||
isWelcomeMailPlaceholder,
|
||||
WELCOME_MAIL_PLACEHOLDER_RE,
|
||||
type WelcomeMailPlaceholder,
|
||||
type WelcomeMailTexts,
|
||||
} from '@tessera/shared';
|
||||
|
||||
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
|
||||
const C = {
|
||||
page: '#eceef1',
|
||||
@@ -46,17 +62,31 @@ const FONT =
|
||||
export type WelcomeMailAccount =
|
||||
| { kind: 'directory' }
|
||||
| { kind: 'local'; setPasswordUrl: string; validHours: number }
|
||||
| { kind: 'local-no-link' };
|
||||
| { kind: 'local-no-link' }
|
||||
/**
|
||||
* Nur Testmail aus Administrator → Willkommensmail: derselbe Baustein wie
|
||||
* `local`, aber OHNE Token — der Knopf fuehrt zur Anmeldeseite und ein
|
||||
* Hinweis sagt, was er in der echten Mail tut.
|
||||
*/
|
||||
| { kind: 'local-example' };
|
||||
|
||||
export interface WelcomeMailInput {
|
||||
/** Anzeigename, sonst Benutzername. */
|
||||
name: string;
|
||||
username: string;
|
||||
/** E-Mail-Adresse des Empfaengers (Platzhalter `{{email}}`). */
|
||||
email: string;
|
||||
/** Name des Mandanten (Platzhalter `{{firma}}`). */
|
||||
tenantName: string;
|
||||
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
|
||||
appUrl: string;
|
||||
account: WelcomeMailAccount;
|
||||
/** `cid:...` im Versand, relativer Pfad in der Vorschau, `null` = Textkopf. */
|
||||
/** `cid:...` im Versand, `data:` in der Vorschau, `null` = Textkopf. */
|
||||
headerImageSrc: string | null;
|
||||
/** Eigene Vorlage des Mandanten; fehlt sie, gelten die Standardtexte. */
|
||||
texts?: WelcomeMailTexts | null;
|
||||
/** Hinweisleiste ganz oben (nur Testmail), reiner Text. */
|
||||
notice?: string | null;
|
||||
}
|
||||
|
||||
export interface RenderedWelcomeMail {
|
||||
@@ -65,13 +95,55 @@ export interface RenderedWelcomeMail {
|
||||
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 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.';
|
||||
const DESKTOP_HINT =
|
||||
'Tessera gibt es auch als Desktop-App – den Download finden Sie auf der Anmeldeseite.';
|
||||
const EXAMPLE_LINK_NOTE =
|
||||
'Beispiel-Link: In der echten Willkommensmail führt dieser Knopf zu einem persönlichen Link, mit dem der neue Benutzer sein Passwort festlegt. In dieser Testmail öffnet er nur die Anmeldeseite.';
|
||||
|
||||
/** Werte der Platzhalter, noch NICHT escaped (Ersetzung laeuft auf reinem Text). */
|
||||
export type WelcomeMailPlaceholderValues = Record<WelcomeMailPlaceholder, string>;
|
||||
|
||||
/** Erstes Wort des Anzeigenamens, sonst der Benutzername. */
|
||||
export function firstNameOf(displayName: string | null | undefined, username: string): string {
|
||||
const first = (displayName ?? '').trim().split(/\s+/)[0];
|
||||
return first || username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Platzhalter in einen REINEN Text ein (Gross-/Kleinschreibung
|
||||
* egal). Unbekannte bleiben stehen — gespeicherte Vorlagen enthalten keine,
|
||||
* das prueft die API beim Speichern. Das Ergebnis ist weiter reiner Text und
|
||||
* wird erst beim Einbau ins HTML escaped.
|
||||
*/
|
||||
export function applyWelcomeMailPlaceholders(
|
||||
text: string,
|
||||
values: WelcomeMailPlaceholderValues,
|
||||
): string {
|
||||
return text.replace(WELCOME_MAIL_PLACEHOLDER_RE, (whole, name: string) =>
|
||||
isWelcomeMailPlaceholder(name) ? values[name.toLowerCase() as WelcomeMailPlaceholder] : whole,
|
||||
);
|
||||
}
|
||||
|
||||
/** Zeilenenden vereinheitlichen, Leerraum an den Raendern entfernen. */
|
||||
function normalizeText(value: string): string {
|
||||
return value.replace(/\r\n?/g, '\n').trim();
|
||||
}
|
||||
|
||||
/** Einzeilig (Betreff, Ueberschrift): jeder Umbruch/Leerraum-Lauf wird ein Leerzeichen. */
|
||||
function singleLine(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Absaetze: Leerzeile trennt, einfacher Umbruch bleibt im Absatz. */
|
||||
function paragraphsOf(value: string): string[] {
|
||||
const text = normalizeText(value);
|
||||
if (!text) return [];
|
||||
return text
|
||||
.split(/\n[ \t]*\n+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
@@ -92,6 +164,7 @@ function loginHintText(account: WelcomeMailAccount): string {
|
||||
case 'directory':
|
||||
return 'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.';
|
||||
case 'local':
|
||||
case 'local-example':
|
||||
return 'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.';
|
||||
case 'local-no-link':
|
||||
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
|
||||
@@ -154,8 +227,10 @@ ${row([olive, olive, C.yellow])}${spacer}${row([null, olive, null])}${spacer}${r
|
||||
* - 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:`), der ins Weiss der Karte auslaeuft. Fehlt er, bleibt nur weisser
|
||||
* Abstand, kein dunkler Block.
|
||||
* `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;">
|
||||
@@ -165,30 +240,56 @@ function headerRow(src: string | null): string {
|
||||
</tr></table>
|
||||
</td></tr>`;
|
||||
const wave = src
|
||||
? `<tr><td bgcolor="${C.card}" style="background-color:${C.card};line-height:0;font-size:0;">
|
||||
<img src="${escapeHtml(src)}" width="600" height="56" alt="" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;">
|
||||
? `<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 vier Texte (Betreff, Ueberschrift,
|
||||
* Einleitung, 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, Anmeldehinweis, Knopf "Zu Tessera" und
|
||||
* Fusszeile sind feste Bausteine und nicht Teil der Vorlage.
|
||||
*/
|
||||
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
|
||||
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
|
||||
const loginUrl = `${base}/login`;
|
||||
const 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 closingParagraphs = paragraphsOf(fill(texts.closing));
|
||||
const notice = input.notice ? singleLine(input.notice) : '';
|
||||
|
||||
// ── Text-Alternative ───────────────────────────────────────────────────
|
||||
const textLines = [
|
||||
`Guten Tag ${input.name},`,
|
||||
'',
|
||||
INTRO,
|
||||
'',
|
||||
const textLines: string[] = [];
|
||||
if (notice) textLines.push(`[${notice}]`, '');
|
||||
if (heading) textLines.push(heading, '');
|
||||
for (const para of introParagraphs) textLines.push(para, '');
|
||||
textLines.push(
|
||||
'Ihre Zugangsdaten',
|
||||
`Adresse: ${base}`,
|
||||
`Benutzername: ${input.username}`,
|
||||
'',
|
||||
hint,
|
||||
];
|
||||
);
|
||||
if (input.account.kind === 'local') {
|
||||
textLines.push(
|
||||
'',
|
||||
@@ -196,29 +297,24 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
||||
input.account.setPasswordUrl,
|
||||
validityText(input.account.validHours),
|
||||
);
|
||||
} else if (input.account.kind === 'local-example') {
|
||||
textLines.push('', 'Passwort festlegen:', loginUrl, EXAMPLE_LINK_NOTE);
|
||||
}
|
||||
textLines.push(
|
||||
'',
|
||||
'Zu Tessera:',
|
||||
loginUrl,
|
||||
'',
|
||||
DESKTOP_HINT,
|
||||
'',
|
||||
'Viel Erfolg mit Tessera!',
|
||||
'',
|
||||
'--',
|
||||
FOOTER,
|
||||
);
|
||||
textLines.push('', 'Zu Tessera:', loginUrl);
|
||||
for (const para of closingParagraphs) textLines.push('', para);
|
||||
textLines.push('', '--', FOOTER);
|
||||
const text = textLines.join('\n');
|
||||
|
||||
// ── HTML ───────────────────────────────────────────────────────────────
|
||||
const name = escapeHtml(input.name);
|
||||
const username = escapeHtml(input.username);
|
||||
const baseHtml = escapeHtml(base);
|
||||
|
||||
const p = (content: string, extra = '') =>
|
||||
`<p style="margin:0 0 16px;font-family:${FONT};font-size:16px;line-height:25px;color:${C.body};${extra}">${content}</p>`;
|
||||
|
||||
/** Reiner Text -> escaped, einfache Umbrueche als <br>. */
|
||||
const para = (value: string) => escapeHtml(value).replace(/\n/g, '<br>');
|
||||
|
||||
const label = (content: string) =>
|
||||
`<div style="font-family:${FONT};font-size:12px;line-height:16px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:${C.muted};">${content}</div>`;
|
||||
|
||||
@@ -227,8 +323,35 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
||||
const setUrl = safeUrl(input.account.setPasswordUrl);
|
||||
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
||||
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(validityText(input.account.validHours))}</p>`;
|
||||
} else if (input.account.kind === 'local-example') {
|
||||
accountBlock += `${button(loginUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
||||
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(EXAMPLE_LINK_NOTE)}</p>`;
|
||||
}
|
||||
|
||||
const noticeRow = notice
|
||||
? `<tr><td style="padding:0 0 12px;"><table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td bgcolor="#fff8c2" style="background-color:#fff8c2;border:1px solid #e6d74c;border-radius:8px;padding:10px 16px;font-family:${FONT};font-size:13px;line-height:20px;color:${C.ink};">${escapeHtml(notice)}</td></tr></table></td></tr>
|
||||
`
|
||||
: '';
|
||||
|
||||
const headingHtml = heading
|
||||
? `<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">${escapeHtml(heading)}</h1>
|
||||
`
|
||||
: '';
|
||||
const introHtml = introParagraphs.map((part) => p(para(part))).join('\n');
|
||||
|
||||
const closingHtml = closingParagraphs.length
|
||||
? `<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
|
||||
${closingParagraphs
|
||||
.map((part, i) =>
|
||||
p(
|
||||
para(part),
|
||||
`font-size:14px;line-height:22px;margin:0${i < closingParagraphs.length - 1 ? ' 0 12px' : ''};`,
|
||||
),
|
||||
)
|
||||
.join('\n')}
|
||||
</td></tr></table>`
|
||||
: '';
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
@@ -237,7 +360,7 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="color-scheme" 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]-->
|
||||
<style>
|
||||
a { color: ${C.link}; }
|
||||
@@ -252,10 +375,9 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<!--[if mso]><table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;border-collapse:separate;">
|
||||
${headerRow(input.headerImageSrc)}
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:36px 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>
|
||||
${p(escapeHtml(INTRO.charAt(0).toUpperCase() + INTRO.slice(1)))}
|
||||
${noticeRow}${headerRow(input.headerImageSrc)}
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:24px 40px 12px;">
|
||||
${headingHtml}${introHtml}
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;margin:8px 0 0;">
|
||||
<tr><td bgcolor="${C.well}" style="background-color:${C.well};border:1px solid ${C.line};border-left:4px solid ${C.yellow};border-radius:8px;padding:18px 22px;">
|
||||
${label('Adresse')}
|
||||
@@ -269,10 +391,7 @@ ${accountBlock}
|
||||
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
|
||||
</td></tr>
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:20px 40px 36px;border-radius:0 0 12px 12px;">
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
|
||||
${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>
|
||||
${closingHtml}
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding:20px 24px 0;font-family:${FONT};font-size:12px;line-height:18px;color:${C.muted};">${escapeHtml(FOOTER)}</td></tr>
|
||||
</table>
|
||||
@@ -282,5 +401,5 @@ ${p('Viel Erfolg mit Tessera!', 'font-size:14px;line-height:22px;margin:0;')}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject: WELCOME_MAIL_SUBJECT, text, html };
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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',
|
||||
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 und Ueberschrift 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()
|
||||
@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 { UserService } from './user.service';
|
||||
import { WelcomeMailService } from './welcome-mail.service';
|
||||
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
|
||||
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
|
||||
|
||||
/**
|
||||
* Importiert GroupsModule für UserService.create's Standardgruppen-
|
||||
@@ -15,11 +17,17 @@ import { WelcomeMailService } from './welcome-mail.service';
|
||||
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
|
||||
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
|
||||
* UserModule.
|
||||
*
|
||||
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
|
||||
* `WelcomeMailTemplateService` (Speicherung je Mandant) und
|
||||
* `WelcomeMailTemplateController` (`/welcome-mail-template`) liegen hier,
|
||||
* weil Vorschau und Testmail denselben Weg wie der Versand nutzen
|
||||
* (`WelcomeMailService`: Adresse, Kopfbild, SMTP des Mandanten).
|
||||
*/
|
||||
@Module({
|
||||
imports: [GroupsModule, MailModule],
|
||||
controllers: [UserController],
|
||||
providers: [UserService, AdminSeedService, WelcomeMailService],
|
||||
controllers: [UserController, WelcomeMailTemplateController],
|
||||
providers: [UserService, AdminSeedService, WelcomeMailService, WelcomeMailTemplateService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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, 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);
|
||||
});
|
||||
|
||||
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('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,105 @@
|
||||
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 vier Texte aus dem DTO — nie weitere Felder in die Vorlage. */
|
||||
function textsOf(dto: WelcomeMailTemplateDto) {
|
||||
return { subject: dto.subject, heading: dto.heading, intro: dto.intro, 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,125 @@
|
||||
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;
|
||||
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', 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('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,110 @@
|
||||
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 vier Texte; Kopf,
|
||||
* Zugangsdaten, Anmeldehinweis, Knoepfe und Fusszeile bleiben fest in
|
||||
* `welcome-mail.template.ts`. Fehlt die Vorlage, gelten
|
||||
* `DEFAULT_WELCOME_MAIL_TEXTS` aus `@tessera/shared`.
|
||||
*
|
||||
* 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,
|
||||
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, 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,
|
||||
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 type { WelcomeMailTexts } from '@tessera/shared';
|
||||
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.
|
||||
@@ -34,6 +39,10 @@ function makePrisma() {
|
||||
log.push({ tenantId, model: 'user', method: 'update', args });
|
||||
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
|
||||
}),
|
||||
findFirst: vi.fn(async (args: any) => {
|
||||
log.push({ tenantId, model: 'user', method: 'findFirst', args });
|
||||
return selfRow;
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -73,36 +82,74 @@ const ldapUser: WelcomeMailTarget = {
|
||||
};
|
||||
|
||||
let prisma: ReturnType<typeof makePrisma>;
|
||||
/** Eigenes Konto des Administrators fuer die Testmail (`user.findFirst`). */
|
||||
let selfRow: {
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
ldapDn: string | null;
|
||||
} | null;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = makePrisma();
|
||||
selfRow = {
|
||||
username: 'admin.lokal',
|
||||
displayName: 'Ada Admin',
|
||||
email: 'ada@example.invalid',
|
||||
ldapDn: null,
|
||||
};
|
||||
});
|
||||
|
||||
function make(mail = makeMail(), config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' })) {
|
||||
const service = new WelcomeMailService(prisma as any, mail as any, config as any);
|
||||
/** Vorlagen-Dienst: eigene Vorlage je Mandant (Map), Firmenname je Mandant. */
|
||||
function makeTemplates(custom: Record<string, WelcomeMailTexts> = {}) {
|
||||
return {
|
||||
getCustomTexts: vi.fn(async (tenantId: string) => custom[tenantId] ?? null),
|
||||
getTenantName: vi.fn(async (tenantId: string) => `Firma ${tenantId}`),
|
||||
};
|
||||
}
|
||||
|
||||
function make(
|
||||
mail = makeMail(),
|
||||
config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' }),
|
||||
templates = makeTemplates(),
|
||||
) {
|
||||
const service = new WelcomeMailService(
|
||||
prisma as any,
|
||||
mail as any,
|
||||
config as any,
|
||||
templates as any,
|
||||
);
|
||||
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
|
||||
return { service, mail };
|
||||
return { service, mail, templates };
|
||||
}
|
||||
|
||||
describe('WelcomeMailService.send — Vorbedingungen', () => {
|
||||
it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => {
|
||||
const { service, mail } = make();
|
||||
await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget);
|
||||
await service.send({ ...localUser, lastLoginAt: new Date(), role: 'SUPER_ADMIN' } as WelcomeMailTarget);
|
||||
await service.send({
|
||||
...localUser,
|
||||
lastLoginAt: new Date(),
|
||||
role: 'SUPER_ADMIN',
|
||||
} as WelcomeMailTarget);
|
||||
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('deaktiviertes Konto → ConflictException', async () => {
|
||||
const { service, mail } = make();
|
||||
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(ConflictException);
|
||||
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne E-Mail-Adresse → BadRequestException', async () => {
|
||||
const { service, mail } = make();
|
||||
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -182,18 +229,27 @@ describe('WelcomeMailService.send — Versand', () => {
|
||||
|
||||
describe('WelcomeMailService.resolveAppUrl', () => {
|
||||
it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => {
|
||||
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }));
|
||||
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe('https://tessera.example.invalid');
|
||||
const { service } = make(
|
||||
makeMail(),
|
||||
makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }),
|
||||
);
|
||||
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe(
|
||||
'https://tessera.example.invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('ohne Konfiguration: Origin der Anfrage', () => {
|
||||
const { service } = make(makeMail(), makeConfig({}));
|
||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
|
||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe(
|
||||
'https://alpha.example.invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('Konfiguration zeigt nur auf localhost (Compose-Vorgabe) → Origin gewinnt; ohne Origin bleibt die Konfiguration', () => {
|
||||
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'http://localhost:3000' }));
|
||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
|
||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe(
|
||||
'https://alpha.example.invalid',
|
||||
);
|
||||
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
@@ -203,3 +259,145 @@ describe('WelcomeMailService.resolveAppUrl', () => {
|
||||
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}}',
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailService.preview', () => {
|
||||
const texts: WelcomeMailTexts = {
|
||||
subject: 'Betreff {{name}}',
|
||||
heading: 'Hallo {{vorname}}',
|
||||
intro: 'Bei {{firma}}',
|
||||
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).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', texts, 'directory');
|
||||
expect(rendered.html).toContain('gewohnten Windows-Passwort');
|
||||
expect(rendered.html).not.toContain('Passwort festlegen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailService.sendTest', () => {
|
||||
const texts: WelcomeMailTexts = {
|
||||
subject: 'Test {{name}}',
|
||||
heading: 'Hallo {{vorname}}',
|
||||
intro: 'Text',
|
||||
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');
|
||||
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 {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
@@ -7,12 +8,17 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
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 { loadWelcomeHeaderPng, MailService, WELCOME_HEADER_CID } from '../mail/mail.service';
|
||||
import { renderWelcomeMail, type WelcomeMailAccount } from '../mail/welcome-mail.template';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import {
|
||||
type RenderedWelcomeMail,
|
||||
renderWelcomeMail,
|
||||
type WelcomeMailAccount,
|
||||
} from '../mail/welcome-mail.template';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
|
||||
|
||||
/**
|
||||
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung
|
||||
@@ -42,6 +48,15 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
|
||||
* SMTP-Weg genau dieses Mandanten (`MailService.sendWelcomeMail`).
|
||||
*
|
||||
* Eigene Vorlage (Administrator → Willkommensmail): `send` nimmt die Texte
|
||||
* der Vorlage des Mandanten des ZIELS (`WelcomeMailTemplateService`), sonst
|
||||
* die Standardtexte. `preview` rendert einen (auch ungespeicherten)
|
||||
* Formularinhalt mit Beispielwerten, `sendTest` schickt ihn an die eigene
|
||||
* Adresse des Administrators — OHNE Token: bei einem lokalen eigenen Konto
|
||||
* zeigt die Testmail den Knopf "Passwort festlegen" als Beispiel-Link zur
|
||||
* Anmeldeseite (Kontoart `local-example`), damit nie ein nutzbarer
|
||||
* Passwort-Link an eine Testadresse geht.
|
||||
*
|
||||
* Adresse in der Mail: `TESSERA_APP_URL` (in Compose aus `APP_URL`), sonst
|
||||
* der Origin der Admin-Anfrage. Zeigt die Konfiguration nur auf
|
||||
* `localhost` (Compose-Vorgabe, nie gesetzt), gewinnt ein vorhandener
|
||||
@@ -52,13 +67,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
|
||||
export type WelcomeMailTarget = Pick<
|
||||
User,
|
||||
| 'id'
|
||||
| 'tenantId'
|
||||
| 'username'
|
||||
| 'displayName'
|
||||
| 'email'
|
||||
| 'ldapDn'
|
||||
| 'isActive'
|
||||
'id' | 'tenantId' | 'username' | 'displayName' | 'email' | 'ldapDn' | 'isActive'
|
||||
>;
|
||||
|
||||
export interface WelcomeMailResult {
|
||||
@@ -68,6 +77,13 @@ export interface WelcomeMailResult {
|
||||
|
||||
const FALLBACK_APP_URL = 'http://localhost:3000';
|
||||
|
||||
/** Beispielwerte der Vorschau (Administrator → Willkommensmail). */
|
||||
export const PREVIEW_SAMPLE = {
|
||||
name: 'Max Mustermann',
|
||||
username: 'max.mustermann',
|
||||
email: 'max.mustermann@example.com',
|
||||
} as const;
|
||||
|
||||
/** `http(s)://host[:port]` aus einem Origin-Kopf, sonst `null`. */
|
||||
export function normalizeOrigin(value: string | undefined | null): string | null {
|
||||
if (!value) return null;
|
||||
@@ -97,6 +113,7 @@ export class WelcomeMailService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly templateService: WelcomeMailTemplateService,
|
||||
) {}
|
||||
|
||||
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
|
||||
@@ -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({
|
||||
name: target.displayName?.trim() || target.username,
|
||||
username: target.username,
|
||||
email: to,
|
||||
tenantName,
|
||||
appUrl,
|
||||
account,
|
||||
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
||||
texts,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -188,4 +212,98 @@ export class WelcomeMailService {
|
||||
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
|
||||
return { to, welcomeMailSentAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschau fuer Administrator → Willkommensmail: rendert `texts` (auch
|
||||
* ungespeichert) mit festen Beispielwerten und dem Firmennamen des eigenen
|
||||
* Mandanten. Das Kopfbild steht als `data:`-Adresse im HTML statt als
|
||||
* `cid:`, damit die Vorschau im Rahmen der Seite der echten Mail gleicht.
|
||||
* Der Link "Passwort festlegen" ist ein Beispiel ohne Token.
|
||||
*/
|
||||
async preview(
|
||||
tenantId: string,
|
||||
texts: WelcomeMailTexts,
|
||||
accountKind: 'directory' | 'local',
|
||||
requestOrigin?: string | null,
|
||||
): Promise<RenderedWelcomeMail> {
|
||||
const appUrl = this.resolveAppUrl(requestOrigin);
|
||||
const header = loadWelcomeHeaderPng();
|
||||
const account: WelcomeMailAccount =
|
||||
accountKind === 'directory'
|
||||
? { kind: 'directory' }
|
||||
: {
|
||||
kind: 'local',
|
||||
setPasswordUrl: `${appUrl}/reset-password/beispiel`,
|
||||
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
|
||||
};
|
||||
return renderWelcomeMail({
|
||||
name: PREVIEW_SAMPLE.name,
|
||||
username: PREVIEW_SAMPLE.username,
|
||||
email: PREVIEW_SAMPLE.email,
|
||||
tenantName: await this.templateService.getTenantName(tenantId),
|
||||
appUrl,
|
||||
account,
|
||||
headerImageSrc: header ? `data:image/png;base64,${header.toString('base64')}` : null,
|
||||
texts,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Testmail an den angemeldeten Administrator selbst, mit dem aktuellen
|
||||
* (auch ungespeicherten) Formularinhalt und dem eigenen Konto als
|
||||
* Beispiel. Es entsteht KEIN Token: ein verzeichnisgefuehrtes eigenes
|
||||
* Konto bekommt den Windows-Hinweis, ein lokales den Beispiel-Link zur
|
||||
* Anmeldeseite (`local-example`). `welcomeMailSentAt` bleibt unberuehrt.
|
||||
* Ohne eigene Adresse 400, ohne Versandweg 409, Versandfehler 502.
|
||||
*/
|
||||
async sendTest(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
texts: WelcomeMailTexts,
|
||||
requestOrigin?: string | null,
|
||||
): Promise<{ to: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const self = await tenantPrisma.user.findFirst({
|
||||
where: { id: userId, tenantId },
|
||||
select: { username: true, displayName: true, email: true, ldapDn: true },
|
||||
});
|
||||
const to = self?.email?.trim();
|
||||
if (!self || !to) {
|
||||
throw new BadRequestException(
|
||||
'Für Ihr eigenes Konto ist keine E-Mail-Adresse hinterlegt. Hinterlegen Sie zuerst eine Adresse (Administrator → Benutzer), dann kann die Testmail an Sie gehen.',
|
||||
);
|
||||
}
|
||||
if (!(await this.mailService.hasConfiguredTransport(tenantId))) {
|
||||
throw new ConflictException(
|
||||
'Für den E-Mail-Versand ist noch kein SMTP-Server eingerichtet. Ein Administrator legt ihn unter Administrator → SMTP fest.',
|
||||
);
|
||||
}
|
||||
|
||||
const mail = renderWelcomeMail({
|
||||
name: self.displayName?.trim() || self.username,
|
||||
username: self.username,
|
||||
email: to,
|
||||
tenantName: await this.templateService.getTenantName(tenantId),
|
||||
appUrl: this.resolveAppUrl(requestOrigin),
|
||||
account: self.ldapDn ? { kind: 'directory' } : { kind: 'local-example' },
|
||||
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
||||
texts,
|
||||
notice:
|
||||
'Testmail aus Administrator → Willkommensmail – so sieht die Willkommensmail mit Ihrer Vorlage aus.',
|
||||
});
|
||||
|
||||
try {
|
||||
await this.mailService.sendWelcomeMail(tenantId, to, mail);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Welcome mail test to user ${userId} failed`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
'Die Testmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
|
||||
);
|
||||
}
|
||||
this.logger.log(`Welcome mail test sent to user ${userId} (tenant ${tenantId})`);
|
||||
return { to };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -83,6 +83,18 @@ export function AdminSidebar() {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('admin.welcomeMail'),
|
||||
href: '/admin/welcome-mail',
|
||||
show: true,
|
||||
icon: (
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
<path d="m16 19 2 2 4-4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('admin.groups'),
|
||||
href: '/admin/groups',
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
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',
|
||||
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('Speichern schickt die vier 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,481 @@
|
||||
'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', 'closing'];
|
||||
const MULTILINE: Record<Field, boolean> = {
|
||||
subject: false,
|
||||
heading: false,
|
||||
intro: true,
|
||||
closing: true,
|
||||
};
|
||||
|
||||
/** Verzoegerung der Live-Vorschau nach der letzten Eingabe. */
|
||||
const PREVIEW_DELAY_MS = 400;
|
||||
|
||||
/**
|
||||
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
|
||||
*
|
||||
* Vier Textfelder (Betreff, Ueberschrift, Einleitung, 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, "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 ((field === 'subject' || field === 'heading') && !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;
|
||||
},
|
||||
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={field === 'intro' ? 6 : 5}
|
||||
className={`${inputClass} py-2`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
{...common}
|
||||
ref={(el) => {
|
||||
fieldRefs.current[field] = el;
|
||||
}}
|
||||
type="text"
|
||||
className={`${inputClass} h-9`}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-1 flex justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span id={`${id}-help`}>{t(`fields.${field}Help`)}</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{t('counter', { count: form[field].length, max: WELCOME_MAIL_LIMITS[field] })}
|
||||
</span>
|
||||
</div>
|
||||
{errors.length > 0 && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive">
|
||||
{errors.join(' ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<button type="submit" disabled={saving || hasErrors} className="btn btn-primary">
|
||||
{saving ? t('saving') : t('save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={testing || hasErrors}
|
||||
onClick={handleTest}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{testing ? t('testSending') : t('test')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={resetting}
|
||||
onClick={() => setConfirmReset(true)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('reset')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('testHelp')}</p>
|
||||
|
||||
{confirmReset && (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-labelledby="welcome-reset-title"
|
||||
aria-describedby="welcome-reset-body"
|
||||
className="max-w-xl rounded-lg border border-border bg-background p-4"
|
||||
>
|
||||
<h2 id="welcome-reset-title" className="mb-1 text-sm font-semibold text-foreground">
|
||||
{t('resetConfirm.title')}
|
||||
</h2>
|
||||
<p id="welcome-reset-body" className="mb-3 text-sm text-muted-foreground">
|
||||
{t('resetConfirm.body')}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={resetting}
|
||||
onClick={handleReset}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('resetConfirm.confirm')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmReset(false)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('resetConfirm.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback && (
|
||||
<p
|
||||
role={feedback.type === 'error' ? 'alert' : 'status'}
|
||||
className={`text-sm ${feedback.type === 'error' ? 'text-destructive' : ''}`}
|
||||
style={feedback.type === 'success' ? { color: 'oklch(0.40 0.15 148)' } : undefined}
|
||||
>
|
||||
{feedback.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Platzhalter */}
|
||||
<section
|
||||
aria-labelledby="welcome-placeholders-title"
|
||||
className="rounded-lg border border-border p-4"
|
||||
>
|
||||
<h2
|
||||
id="welcome-placeholders-title"
|
||||
className="mb-1 text-sm font-semibold text-foreground"
|
||||
>
|
||||
{t('placeholders.title')}
|
||||
</h2>
|
||||
<p className="mb-3 text-sm text-muted-foreground">{t('placeholders.intro')}</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th scope="col" className="py-2 pr-3 font-medium">
|
||||
{t('placeholders.colPlaceholder')}
|
||||
</th>
|
||||
<th scope="col" className="py-2 pr-3 font-medium">
|
||||
{t('placeholders.colMeaning')}
|
||||
</th>
|
||||
<th scope="col" className="py-2 font-medium">
|
||||
{t('placeholders.colExample')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{WELCOME_MAIL_PLACEHOLDERS.map((name) => (
|
||||
<tr key={name} className="border-b border-border last:border-0 align-top">
|
||||
<td className="py-2 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertPlaceholder(name)}
|
||||
title={t('placeholders.insertHint')}
|
||||
aria-label={t('placeholders.insert', { token: `{{${name}}}` })}
|
||||
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground hover:bg-sidebar-hover"
|
||||
>
|
||||
{`{{${name}}}`}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-foreground">
|
||||
{t(`placeholders.items.${name}.meaning`)}
|
||||
</td>
|
||||
<td className="py-2 text-muted-foreground">
|
||||
{t(`placeholders.items.${name}.example`)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{t('placeholders.rules')}</p>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
{/* Vorschau */}
|
||||
<section aria-labelledby="welcome-preview-title" className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 id="welcome-preview-title" className="text-sm font-semibold text-foreground">
|
||||
{t('preview.title')}
|
||||
</h2>
|
||||
<fieldset className="flex gap-1">
|
||||
<legend className="sr-only">{t('preview.accountLabel')}</legend>
|
||||
{(['directory', 'local'] as const).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
aria-pressed={account === kind}
|
||||
onClick={() => setAccount(kind)}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||
account === kind
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border text-foreground hover:bg-sidebar-hover'
|
||||
}`}
|
||||
>
|
||||
{t(`preview.${kind}`)}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
{t('preview.subject')}{' '}
|
||||
<span className="font-medium text-foreground" data-testid="welcome-preview-subject">
|
||||
{preview?.subject ?? ''}
|
||||
</span>
|
||||
</p>
|
||||
{hasErrors && <p className="mb-2 text-sm text-destructive">{t('preview.blocked')}</p>}
|
||||
{previewFailed && <p className="mb-2 text-sm text-destructive">{t('preview.error')}</p>}
|
||||
<iframe
|
||||
title={t('preview.frameTitle')}
|
||||
sandbox=""
|
||||
srcDoc={preview?.html ?? ''}
|
||||
className="h-[760px] w-full rounded-lg border border-border bg-white"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t('preview.sampleHint')}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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 vier Texte senden — nie weitere Felder aus dem Formularzustand. */
|
||||
function textsOf(texts: WelcomeMailTexts): WelcomeMailTexts {
|
||||
return {
|
||||
subject: texts.subject,
|
||||
heading: texts.heading,
|
||||
intro: texts.intro,
|
||||
closing: texts.closing,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchWelcomeMailTemplate(): Promise<WelcomeMailTemplateState> {
|
||||
return request('');
|
||||
}
|
||||
|
||||
export function saveWelcomeMailTemplate(
|
||||
texts: WelcomeMailTexts,
|
||||
): Promise<WelcomeMailTemplateState> {
|
||||
return request('', { method: 'PUT', body: JSON.stringify(textsOf(texts)) });
|
||||
}
|
||||
|
||||
export function resetWelcomeMailTemplate(): Promise<WelcomeMailTemplateState> {
|
||||
return request('', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function previewWelcomeMail(
|
||||
texts: WelcomeMailTexts,
|
||||
account: 'directory' | 'local',
|
||||
signal?: AbortSignal,
|
||||
): Promise<WelcomeMailPreview> {
|
||||
return request('/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...textsOf(texts), account }),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendWelcomeMailTest(
|
||||
texts: WelcomeMailTexts,
|
||||
): Promise<{ success: true; to: string }> {
|
||||
return request('/test', { method: 'POST', body: JSON.stringify(textsOf(texts)) });
|
||||
}
|
||||
@@ -88,7 +88,8 @@
|
||||
"customModules": "Eigene Module",
|
||||
"ldap": "LDAP",
|
||||
"smtp": "SMTP",
|
||||
"groups": "Gruppen"
|
||||
"groups": "Gruppen",
|
||||
"welcomeMail": "Willkommensmail"
|
||||
},
|
||||
"role": {
|
||||
"SUPER_ADMIN": "Super-Admin",
|
||||
@@ -738,6 +739,102 @@
|
||||
"create": "Gemeinsamen Eintrag anlegen",
|
||||
"noModules": "Noch keine gemeinsamen Einträge",
|
||||
"noModulesBody": "Legen Sie Ihren ersten gemeinsamen Eintrag an, um eine Webseite für alle Benutzer in der Seitenleiste zu zeigen."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"title": "Willkommensmail",
|
||||
"description": "Hier legen Sie fest, mit welchen Worten Tessera neue Benutzer begrüßt. Die Vorlage gilt für Ihren Mandanten und wird bei jeder Willkommensmail aus der Benutzerverwaltung verwendet.",
|
||||
"loading": "Vorlage wird geladen …",
|
||||
"fixed": {
|
||||
"title": "Was Sie anpassen können – und was immer drin bleibt",
|
||||
"body": "Sie bestimmen Betreff, Überschrift, Einleitung und Abschlusstext. Alles, was für die Anmeldung wichtig 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 Anmeldehinweis samt Knopf „Passwort festlegen“ 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.",
|
||||
"closing": "Abschlusstext",
|
||||
"closingHelp": "Steht am Ende der Mail, z. B. Tipp und Grußzeile. Darf leer bleiben."
|
||||
},
|
||||
"counter": "{count} / {max}",
|
||||
"save": "Speichern",
|
||||
"saving": "Wird gespeichert …",
|
||||
"saved": "Die Vorlage wurde gespeichert. Ab jetzt verwenden neue Willkommensmails diese Texte.",
|
||||
"test": "Testmail an mich senden",
|
||||
"testSending": "Wird gesendet …",
|
||||
"testSent": "Die Testmail wurde an {email} gesendet.",
|
||||
"testHelp": "Die Testmail geht an Ihre eigene E-Mail-Adresse und zeigt den aktuellen Inhalt des Formulars, auch wenn er noch nicht gespeichert ist. Sie enthält keinen echten Link zum Festlegen eines Passworts.",
|
||||
"reset": "Auf Standard zurücksetzen",
|
||||
"resetDone": "Die Standardtexte sind wiederhergestellt.",
|
||||
"resetConfirm": {
|
||||
"title": "Auf Standard zurücksetzen?",
|
||||
"body": "Ihre eigene Vorlage wird gelöscht und die Standardtexte gelten wieder. Das lässt sich nicht rückgängig machen.",
|
||||
"confirm": "Zurücksetzen",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"errors": {
|
||||
"load": "Die Vorlage konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
|
||||
"save": "Die Vorlage konnte nicht gespeichert werden.",
|
||||
"reset": "Die Vorlage konnte nicht zurückgesetzt werden.",
|
||||
"test": "Die Testmail konnte nicht gesendet werden.",
|
||||
"required": "Bitte füllen Sie dieses Feld aus.",
|
||||
"tooLong": "Höchstens {max} Zeichen.",
|
||||
"unknownPlaceholder": "Unbekannter Platzhalter {token}",
|
||||
"unknownPlaceholders": "Unbekannte Platzhalter {tokens}"
|
||||
},
|
||||
"placeholders": {
|
||||
"title": "Platzhalter",
|
||||
"intro": "Platzhalter setzt Tessera beim Versand durch die Angaben des jeweiligen Benutzers. Sie funktionieren in allen vier Feldern. Ein Klick auf einen Platzhalter fügt ihn an der Cursorposition des zuletzt benutzten Feldes ein.",
|
||||
"colPlaceholder": "Platzhalter",
|
||||
"colMeaning": "Bedeutung",
|
||||
"colExample": "Beispiel",
|
||||
"insert": "{token} einfügen",
|
||||
"insertHint": "Klicken zum Einfügen",
|
||||
"rules": "Andere Platzhalter kennt Tessera nicht – sie werden beim Speichern abgelehnt. Die Texte sind reiner Text: Formatierungen oder HTML werden nicht übernommen, sondern so angezeigt, wie Sie sie eingeben.",
|
||||
"items": {
|
||||
"name": {
|
||||
"meaning": "Anzeigename des Benutzers, sonst sein Benutzername",
|
||||
"example": "Max Mustermann"
|
||||
},
|
||||
"vorname": {
|
||||
"meaning": "Erstes Wort des Anzeigenamens, sonst der Benutzername",
|
||||
"example": "Max"
|
||||
},
|
||||
"benutzername": {
|
||||
"meaning": "Benutzername für die Anmeldung",
|
||||
"example": "max.mustermann"
|
||||
},
|
||||
"email": {
|
||||
"meaning": "E-Mail-Adresse des Benutzers",
|
||||
"example": "max.mustermann@example.com"
|
||||
},
|
||||
"adresse": {
|
||||
"meaning": "Adresse von Tessera",
|
||||
"example": "https://tessera.example.com"
|
||||
},
|
||||
"firma": {
|
||||
"meaning": "Name Ihres Mandanten",
|
||||
"example": "Beispiel GmbH"
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"title": "Vorschau",
|
||||
"accountLabel": "Vorschau für Kontoart",
|
||||
"directory": "Verzeichniskonto",
|
||||
"local": "Lokales Konto",
|
||||
"subject": "Betreff:",
|
||||
"frameTitle": "Vorschau der Willkommensmail",
|
||||
"error": "Die Vorschau konnte nicht erstellt werden.",
|
||||
"blocked": "Die Vorschau wird aktualisiert, sobald alle Felder in Ordnung sind.",
|
||||
"sampleHint": "Die Vorschau verwendet Beispielwerte (Max Mustermann) und den Namen Ihres Mandanten. Der Link „Passwort festlegen“ ist in der Vorschau nur ein Beispiel."
|
||||
}
|
||||
}
|
||||
},
|
||||
"adminModules": {
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"customModules": "Custom modules",
|
||||
"ldap": "LDAP",
|
||||
"smtp": "SMTP",
|
||||
"groups": "Groups"
|
||||
"groups": "Groups",
|
||||
"welcomeMail": "Welcome email"
|
||||
},
|
||||
"role": {
|
||||
"SUPER_ADMIN": "Super Admin",
|
||||
@@ -738,6 +739,102 @@
|
||||
"create": "Add shared entry",
|
||||
"noModules": "No shared entries yet",
|
||||
"noModulesBody": "Add your first shared entry to show a website in the sidebar for all users."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"title": "Welcome email",
|
||||
"description": "Choose the words Tessera uses to greet new users. The template applies to your tenant and is used for every welcome email sent from user management.",
|
||||
"loading": "Loading template …",
|
||||
"fixed": {
|
||||
"title": "What you can change – and what always stays",
|
||||
"body": "You decide the subject, heading, introduction and closing text. Everything needed to sign in is always added automatically and cannot be changed: the header with logo, the box with address and username, the sign-in hint including the “Set password” button 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.",
|
||||
"closing": "Closing text",
|
||||
"closingHelp": "Shown at the end of the email, e.g. a tip and a sign-off. May be left empty."
|
||||
},
|
||||
"counter": "{count} / {max}",
|
||||
"save": "Save",
|
||||
"saving": "Saving …",
|
||||
"saved": "The template has been saved. New welcome emails will use these texts from now on.",
|
||||
"test": "Send test email to me",
|
||||
"testSending": "Sending …",
|
||||
"testSent": "The test email was sent to {email}.",
|
||||
"testHelp": "The test email goes to your own email address and shows the current form content, even if it has not been saved yet. It does not contain a real link for setting a password.",
|
||||
"reset": "Reset to default",
|
||||
"resetDone": "The default texts have been restored.",
|
||||
"resetConfirm": {
|
||||
"title": "Reset to default?",
|
||||
"body": "Your custom template will be deleted and the default texts apply again. This cannot be undone.",
|
||||
"confirm": "Reset",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"errors": {
|
||||
"load": "The template could not be loaded. Please reload the page.",
|
||||
"save": "The template could not be saved.",
|
||||
"reset": "The template could not be reset.",
|
||||
"test": "The test email could not be sent.",
|
||||
"required": "Please fill in this field.",
|
||||
"tooLong": "At most {max} characters.",
|
||||
"unknownPlaceholder": "Unknown placeholder {token}",
|
||||
"unknownPlaceholders": "Unknown placeholders {tokens}"
|
||||
},
|
||||
"placeholders": {
|
||||
"title": "Placeholders",
|
||||
"intro": "Tessera replaces placeholders with the details of each user when sending. They work in all four fields. Clicking a placeholder inserts it at the cursor position of the field you used last.",
|
||||
"colPlaceholder": "Placeholder",
|
||||
"colMeaning": "Meaning",
|
||||
"colExample": "Example",
|
||||
"insert": "Insert {token}",
|
||||
"insertHint": "Click to insert",
|
||||
"rules": "Tessera does not know any other placeholders – they are rejected when saving. The texts are plain text: formatting or HTML is not applied but shown exactly as you type it.",
|
||||
"items": {
|
||||
"name": {
|
||||
"meaning": "Display name of the user, otherwise the username",
|
||||
"example": "Max Mustermann"
|
||||
},
|
||||
"vorname": {
|
||||
"meaning": "First word of the display name, otherwise the username",
|
||||
"example": "Max"
|
||||
},
|
||||
"benutzername": {
|
||||
"meaning": "Username for signing in",
|
||||
"example": "max.mustermann"
|
||||
},
|
||||
"email": {
|
||||
"meaning": "Email address of the user",
|
||||
"example": "max.mustermann@example.com"
|
||||
},
|
||||
"adresse": {
|
||||
"meaning": "Address of Tessera",
|
||||
"example": "https://tessera.example.com"
|
||||
},
|
||||
"firma": {
|
||||
"meaning": "Name of your tenant",
|
||||
"example": "Example Ltd"
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview",
|
||||
"accountLabel": "Preview for account type",
|
||||
"directory": "Directory account",
|
||||
"local": "Local account",
|
||||
"subject": "Subject:",
|
||||
"frameTitle": "Welcome email preview",
|
||||
"error": "The preview could not be created.",
|
||||
"blocked": "The preview updates as soon as all fields are valid.",
|
||||
"sampleHint": "The preview uses sample values (Max Mustermann) and the name of your tenant. The “Set password” link is only an example in the preview."
|
||||
}
|
||||
}
|
||||
},
|
||||
"adminModules": {
|
||||
|
||||
@@ -197,4 +197,10 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'Arbeitsspeicher',
|
||||
// quick-260925-bow: „Was ist neu“-Fenster, Gruppe „Verbessert“ — korrektes Deutsch mit „ss“
|
||||
'Verbessert',
|
||||
// Administrator → Willkommensmail (eigene Vorlage) — korrektes Deutsch mit „ss“/„ue“/„ae“
|
||||
'anpassen',
|
||||
'Abschlusstext',
|
||||
'aktuellen',
|
||||
'Passworts',
|
||||
'tessera',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user