feat(admin): Anmeldehinweise der Willkommensmail in der Vorlage bearbeitbar
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m29s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m14s

Neue Felder loginHintDirectory/loginHintLocal (Platzhalter, Pflicht, max. 1000),
Migration 20260930170000 (nullable, leer = Standard aus @tessera/shared).
Knopf Passwort festlegen und Gueltigkeitshinweis bleiben fest; local-no-link
wird nie erzeugt und bleibt fest. Vorschau springt beim Bearbeiten auf die
passende Kontoart. Lokal im Browser nachgewiesen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 17:56:05 +02:00
parent e10da76259
commit 714f731ac9
18 changed files with 411 additions and 53 deletions
@@ -0,0 +1,18 @@
-- Willkommensmail: Anmeldehinweis je Kontoart als Teil der eigenen Vorlage.
--
-- Zweck: zwei neue Spalten in "WelcomeMailTemplate" —
-- "loginHintDirectory" (Hinweis fuer verzeichnisgefuehrte Konten, Standard
-- "Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten
-- Windows-Passwort an.") und "loginHintLocal" (Hinweis vor dem Knopf
-- "Passwort festlegen" fuer lokale Konten).
--
-- Bewusst NULLABLE ohne Default: bestehende Vorlagen behalten NULL, und
-- WelcomeMailTemplateService setzt dafuer den Standardtext aus
-- @tessera/shared ein. So steht der Standardtext nur an EINER Stelle im
-- Code und nicht zusaetzlich in der Datenbank.
--
-- Zeilenschutz: unveraendert (tenant_isolation_policy der Tabelle gilt fuer
-- die neuen Spalten mit).
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintDirectory" TEXT;
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintLocal" TEXT;
+6 -2
View File
@@ -770,14 +770,18 @@ model Reminder {
// 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.
// @tessera/shared (DEFAULT_WELCOME_MAIL_TEXTS). Nur die sechs Texte —
// Kopf, Zugangsdaten und Knoepfe bleiben fest im Code.
model WelcomeMailTemplate {
id String @id @default(uuid())
tenantId String @unique
subject String
heading String
intro String
// Anmeldehinweise je Kontoart (Migration 20260930170000); NULL in einer
// aelteren Vorlage = Standardtext aus @tessera/shared
loginHintDirectory String?
loginHintLocal String?
closing String
// Benutzername des Administrators, der zuletzt gespeichert hat (Anzeige)
updatedBy String?
@@ -22,6 +22,18 @@ const base: WelcomeMailInput = {
headerImageSrc: null,
};
/** Standard-Anmeldehinweise, damit Vorlagen im Test vollstaendig sind. */
const hints = {
loginHintDirectory: DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory,
loginHintLocal: DEFAULT_WELCOME_MAIL_TEXTS.loginHintLocal,
};
const localAccount = {
kind: 'local',
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
validHours: 168,
} as const;
const values = {
name: 'Max Muster',
vorname: 'Max',
@@ -75,6 +87,7 @@ describe('renderWelcomeMail mit Vorlage', () => {
subject: 'Hallo\r\n{{firma}}',
heading: '<h2>{{name}}</h2>',
intro: '<a href="javascript:alert(1)">klick</a>',
...hints,
closing: '',
},
});
@@ -89,7 +102,7 @@ describe('renderWelcomeMail mit Vorlage', () => {
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: ' ' },
texts: { subject: 's', heading: 'h', intro: 'A1\nA2\r\n\r\n\nB', ...hints, closing: ' ' },
});
expect(mail.html).toContain('>A1<br>A2</p>');
expect(mail.html).toContain('>B</p>');
@@ -105,7 +118,7 @@ describe('renderWelcomeMail mit Vorlage', () => {
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
validHours: 168,
},
texts: { subject: 'x', heading: 'y', intro: '', closing: '' },
texts: { subject: 'x', heading: 'y', intro: '', ...hints, closing: '' },
});
expect(mail.html).toContain('https://tessera.example.invalid/reset-password/abc');
expect(mail.html).toContain('Passwort festlegen');
@@ -128,6 +141,67 @@ describe('renderWelcomeMail mit Vorlage', () => {
});
});
describe('Anmeldehinweis je Kontoart', () => {
it('Standard: Verzeichniskonto bekommt den Windows-Hinweis, lokales Konto den Hinweis vor dem Knopf', () => {
const dir = renderWelcomeMail(base);
expect(dir.html).toContain('gewohnten Windows-Passwort');
expect(dir.html).not.toContain('persönliches Passwort fest');
const local = renderWelcomeMail({ ...base, account: localAccount });
expect(local.html).toContain('legen Sie bitte Ihr persönliches Passwort fest');
expect(local.html).not.toContain('Windows-Passwort');
// Hinweis steht vor dem Knopf
expect(local.html.indexOf('persönliches Passwort fest')).toBeLessThan(
local.html.indexOf('Passwort festlegen</a>'),
);
});
it('eigener Text je Kontoart: Platzhalter ersetzt, escaped, Leerzeile = Absatz; Knopf und Gueltigkeit bleiben fest', () => {
const texts = {
...DEFAULT_WELCOME_MAIL_TEXTS,
loginHintDirectory:
'Anmeldung als {{benutzername}} mit <b>Firmenkennwort</b>.\n\nFragen? IT anrufen.',
loginHintLocal: 'Hallo {{vorname}}, bitte zuerst ein Passwort setzen.',
};
const dir = renderWelcomeMail({ ...base, texts });
expect(dir.html).toContain(
'Anmeldung als max.muster mit &lt;b&gt;Firmenkennwort&lt;/b&gt;.</p>',
);
expect(dir.html).toContain('>Fragen? IT anrufen.</p>');
expect(dir.html).not.toContain('Windows-Passwort');
expect(dir.html).not.toContain('bitte zuerst ein Passwort setzen');
expect(dir.text).toContain(
'Anmeldung als max.muster mit <b>Firmenkennwort</b>.\n\nFragen? IT anrufen.',
);
const local = renderWelcomeMail({ ...base, account: localAccount, texts });
expect(local.html).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
expect(local.html).not.toContain('Firmenkennwort');
expect(local.html).toContain('reset-password/abc');
expect(local.html).toContain('7 Tage');
expect(local.text).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
const example = renderWelcomeMail({ ...base, account: { kind: 'local-example' }, texts });
expect(example.html).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
});
it('leerer oder fehlender Hinweis (aeltere Aufrufer) → Standardtext', () => {
const { loginHintDirectory: _d, loginHintLocal: _l, ...old } = DEFAULT_WELCOME_MAIL_TEXTS;
const dir = renderWelcomeMail({ ...base, texts: old as any });
expect(dir.html).toContain('gewohnten Windows-Passwort');
const local = renderWelcomeMail({
...base,
account: localAccount,
texts: { ...DEFAULT_WELCOME_MAIL_TEXTS, loginHintLocal: ' ' },
});
expect(local.html).toContain('persönliches Passwort fest');
});
it('local-no-link behaelt den festen Hinweis', () => {
const mail = renderWelcomeMail({ ...base, account: { kind: 'local-no-link' } });
expect(mail.html).toContain('Ihr Startpasswort erhalten Sie von Ihrem Administrator.');
});
});
describe('Kopf (Outlook ohne CID-Bild)', () => {
it('Wellenstreifen 600x40 in einer Zelle mit dunkler Kopffarbe, Inhalt beginnt mit 24px Abstand', () => {
const mail = renderWelcomeMail({ ...base, headerImageSrc: 'cid:welcome-header@tessera' });
+31 -17
View File
@@ -8,7 +8,8 @@
* Anmeldehinweis" `WelcomeMailService`.
*
* Eigene Vorlage (Administrator → Willkommensmail): Betreff, Ueberschrift,
* Einleitung und Abschluss kommen als `texts` herein (Vorlage des Mandanten
* Einleitung, die zwei Anmeldehinweise (Verzeichniskonto / lokales Konto)
* und Abschluss kommen als `texts` herein (Vorlage des Mandanten
* des ZIEL-Benutzers, sonst `DEFAULT_WELCOME_MAIL_TEXTS` aus
* `@tessera/shared`). Platzhalter (`{{name}}`, `{{vorname}}`,
* `{{benutzername}}`, `{{email}}`, `{{adresse}}`, `{{firma}}`) werden auf dem
@@ -62,6 +63,11 @@ const FONT =
export type WelcomeMailAccount =
| { kind: 'directory' }
| { kind: 'local'; setPasswordUrl: string; validHours: number }
/**
* Lokales Konto ohne Link. Wird heute nirgends erzeugt (`WelcomeMailService`
* legt fuer lokale Konten immer einen Token an); deshalb bleibt ihr Hinweis
* fest und ist nicht Teil der Vorlage.
*/
| { kind: 'local-no-link' }
/**
* Nur Testmail aus Administrator → Willkommensmail: derselbe Baustein wie
@@ -159,13 +165,22 @@ function safeUrl(value: string): string {
return /^https?:\/\//i.test(value) ? value : '';
}
function loginHintText(account: WelcomeMailAccount): string {
/**
* Anmeldehinweis je Kontoart als REINER Text (Platzhalter noch nicht
* ersetzt). Verzeichnis- und lokale Konten nehmen den Text der Vorlage; ist
* er leer oder fehlt er (aeltere Aufrufer), gilt der Standardtext.
*/
function loginHintText(account: WelcomeMailAccount, texts: Partial<WelcomeMailTexts>): string {
const pick = (field: 'loginHintDirectory' | 'loginHintLocal') => {
const value = texts[field];
return typeof value === 'string' && value.trim() ? value : DEFAULT_WELCOME_MAIL_TEXTS[field];
};
switch (account.kind) {
case 'directory':
return 'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.';
return pick('loginHintDirectory');
case 'local':
case 'local-example':
return 'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.';
return pick('loginHintLocal');
case 'local-no-link':
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
}
@@ -248,18 +263,19 @@ function headerRow(src: string | null): string {
}
/**
* Baut die Willkommensmail. Die vier Texte (Betreff, Ueberschrift,
* Einleitung, Abschluss) kommen aus der eigenen Vorlage des Mandanten, sonst
* Baut die Willkommensmail. Die sechs Texte (Betreff, Ueberschrift,
* Einleitung, Anmeldehinweis je Kontoart, Abschluss) kommen aus der eigenen
* Vorlage des Mandanten, sonst
* aus `DEFAULT_WELCOME_MAIL_TEXTS`. Sie sind REINER Text: Platzhalter werden
* auf dem Text ersetzt, erst danach wird alles escaped — HTML aus der
* Vorlage oder aus einem Benutzerwert erscheint als Text, nie als Markup.
* Kopf, Kasten Adresse/Benutzername, Anmeldehinweis, Knopf "Zu Tessera" und
* Fusszeile sind feste Bausteine und nicht Teil der Vorlage.
* Kopf, Kasten Adresse/Benutzername, Knopf "Passwort festlegen" mit
* Gueltigkeitshinweis, Knopf "Zu Tessera" und Fusszeile sind feste Bausteine
* und nicht Teil der Vorlage.
*/
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
const loginUrl = `${base}/login`;
const hint = loginHintText(input.account);
const texts = input.texts ?? DEFAULT_WELCOME_MAIL_TEXTS;
const values: WelcomeMailPlaceholderValues = {
@@ -275,6 +291,7 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
const subject = singleLine(fill(texts.subject)) || WELCOME_MAIL_SUBJECT;
const heading = singleLine(fill(texts.heading));
const introParagraphs = paragraphsOf(fill(texts.intro));
const hintParagraphs = paragraphsOf(fill(loginHintText(input.account, texts)));
const closingParagraphs = paragraphsOf(fill(texts.closing));
const notice = input.notice ? singleLine(input.notice) : '';
@@ -283,13 +300,8 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
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,
);
textLines.push('Ihre Zugangsdaten', `Adresse: ${base}`, `Benutzername: ${input.username}`);
for (const para of hintParagraphs) textLines.push('', para);
if (input.account.kind === 'local') {
textLines.push(
'',
@@ -318,7 +330,9 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
const label = (content: string) =>
`<div style="font-family:${FONT};font-size:12px;line-height:16px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:${C.muted};">${content}</div>`;
let accountBlock = p(escapeHtml(hint), 'margin:24px 0 16px;');
let accountBlock = hintParagraphs
.map((part, i) => p(para(part), i === 0 ? 'margin:24px 0 16px;' : ''))
.join('\n');
if (input.account.kind === 'local') {
const setUrl = safeUrl(input.account.setPasswordUrl);
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
@@ -20,6 +20,8 @@ const FIELD_LABELS: Record<keyof WelcomeMailTexts, string> = {
subject: 'Betreff',
heading: 'Überschrift',
intro: 'Einleitungstext',
loginHintDirectory: 'Anmeldehinweis für Verzeichniskonten',
loginHintLocal: 'Anmeldehinweis für lokale Konten',
closing: 'Abschlusstext',
};
@@ -48,8 +50,8 @@ class NurBekanntePlatzhalterConstraint implements ValidatorConstraintInterface {
* 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).
* Betreff, Ueberschrift und beide Anmeldehinweise sind Pflicht, Einleitung
* und Abschluss duerfen leer sein (der Abschnitt faellt dann weg).
*/
export class WelcomeMailTemplateDto implements WelcomeMailTexts {
@IsString()
@@ -69,6 +71,18 @@ export class WelcomeMailTemplateDto implements WelcomeMailTexts {
@Validate(NurBekanntePlatzhalterConstraint)
intro!: string;
@IsString()
@Matches(/\S/, { message: 'Bitte geben Sie einen Anmeldehinweis für Verzeichniskonten ein.' })
@MaxLength(WELCOME_MAIL_LIMITS.loginHintDirectory)
@Validate(NurBekanntePlatzhalterConstraint)
loginHintDirectory!: string;
@IsString()
@Matches(/\S/, { message: 'Bitte geben Sie einen Anmeldehinweis für lokale Konten ein.' })
@MaxLength(WELCOME_MAIL_LIMITS.loginHintLocal)
@Validate(NurBekanntePlatzhalterConstraint)
loginHintLocal!: string;
@IsString()
@MaxLength(WELCOME_MAIL_LIMITS.closing)
@Validate(NurBekanntePlatzhalterConstraint)
@@ -115,7 +115,7 @@ describe('WelcomeMailTemplateDto (globale ValidationPipe)', () => {
expect(messages).toContain('Betreff: Unbekannter Platzhalter {{xyz}}');
});
it('Laengen: Betreff/Ueberschrift 200, Texte 4000', async () => {
it('Laengen: Betreff/Ueberschrift 200, Anmeldehinweise 1000, Texte 4000', async () => {
await expect(
validateBody({ ...valid, subject: 'a'.repeat(200), intro: 'b'.repeat(4000) }),
).resolves.toBeTruthy();
@@ -123,6 +123,19 @@ describe('WelcomeMailTemplateDto (globale ValidationPipe)', () => {
await expect(messagesOf({ ...valid, heading: 'a'.repeat(201) })).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, intro: 'a'.repeat(4001) })).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, closing: 'a'.repeat(4001) })).resolves.toHaveLength(1);
await expect(
validateBody({
...valid,
loginHintDirectory: 'a'.repeat(1000),
loginHintLocal: 'b'.repeat(1000),
}),
).resolves.toBeTruthy();
await expect(
messagesOf({ ...valid, loginHintDirectory: 'a'.repeat(1001) }),
).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, loginHintLocal: 'a'.repeat(1001) })).resolves.toHaveLength(
1,
);
});
it('leerer Betreff/leere Ueberschrift → 400; leere Einleitung/Abschluss erlaubt', async () => {
@@ -135,6 +148,23 @@ describe('WelcomeMailTemplateDto (globale ValidationPipe)', () => {
await expect(validateBody({ ...valid, intro: '', closing: '' })).resolves.toBeTruthy();
});
it('Anmeldehinweise: Pflicht, Platzhalter geprueft, Leerzeilen erlaubt', async () => {
expect(await messagesOf({ ...valid, loginHintDirectory: ' \n ' })).toContain(
'Bitte geben Sie einen Anmeldehinweis für Verzeichniskonten ein.',
);
expect(await messagesOf({ ...valid, loginHintLocal: '' })).toContain(
'Bitte geben Sie einen Anmeldehinweis für lokale Konten ein.',
);
expect(await messagesOf({ ...valid, loginHintLocal: 'Hallo {{kennwort}}' })).toContain(
'Anmeldehinweis für lokale Konten: Unbekannter Platzhalter {{kennwort}}',
);
await expect(
validateBody({ ...valid, loginHintDirectory: 'Hallo {{ Vorname }}\n\nZweiter Absatz' }),
).resolves.toBeTruthy();
const { loginHintLocal: _l, ...rest } = valid;
await expect(messagesOf(rest)).resolves.not.toHaveLength(0);
});
it('fehlendes Feld → 400', async () => {
const { closing: _closing, ...rest } = valid;
await expect(messagesOf(rest)).resolves.not.toHaveLength(0);
@@ -20,9 +20,16 @@ import {
type WelcomeMailTemplateState,
} from './welcome-mail-template.service';
/** Nur die vier Texte aus dem DTO — nie weitere Felder in die Vorlage. */
/** Nur die sechs Texte aus dem DTO — nie weitere Felder in die Vorlage. */
function textsOf(dto: WelcomeMailTemplateDto) {
return { subject: dto.subject, heading: dto.heading, intro: dto.intro, closing: dto.closing };
return {
subject: dto.subject,
heading: dto.heading,
intro: dto.intro,
loginHintDirectory: dto.loginHintDirectory,
loginHintLocal: dto.loginHintLocal,
closing: dto.closing,
};
}
/**
@@ -18,6 +18,8 @@ type Row = {
subject: string;
heading: string;
intro: string;
loginHintDirectory?: string | null;
loginHintLocal?: string | null;
closing: string;
updatedBy: string | null;
updatedAt: Date;
@@ -66,7 +68,14 @@ function makePrisma(rows: Row[] = []) {
};
}
const texts = { subject: 'S {{name}}', heading: 'H', intro: 'I', closing: 'C' };
const texts = {
subject: 'S {{name}}',
heading: 'H',
intro: 'I',
loginHintDirectory: 'LD {{benutzername}}',
loginHintLocal: 'LL',
closing: 'C',
};
let prisma: ReturnType<typeof makePrisma>;
let service: WelcomeMailTemplateService;
@@ -98,6 +107,25 @@ describe('WelcomeMailTemplateService', () => {
expect(await service.getCustomTexts('t1')).toEqual(texts);
});
it('aeltere Vorlage ohne Anmeldehinweise (NULL) → Standardtexte fuer diese Felder, Rest bleibt eigen', async () => {
prisma.__rows.push({
tenantId: 't1',
subject: 'Alt',
heading: 'H',
intro: 'I',
loginHintDirectory: null,
loginHintLocal: null,
closing: 'C',
updatedBy: null,
updatedAt: new Date(),
});
const state = await service.getState('t1');
expect(state.custom).toBe(true);
expect(state.texts.subject).toBe('Alt');
expect(state.texts.loginHintDirectory).toBe(DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory);
expect(state.texts.loginHintLocal).toBe(DEFAULT_WELCOME_MAIL_TEXTS.loginHintLocal);
});
it('Mandantenbindung: Vorlage von t1 ist fuer t2 nicht sichtbar', async () => {
await service.save('t1', texts, 'ada');
expect(await service.getCustomTexts('t2')).toBeNull();
@@ -8,10 +8,12 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
* (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
* `tenantId` eindeutig). Gespeichert werden nur die sechs Texte; Kopf,
* Zugangsdaten, Knoepfe und Fusszeile bleiben fest in
* `welcome-mail.template.ts`. Fehlt die Vorlage, gelten
* `DEFAULT_WELCOME_MAIL_TEXTS` aus `@tessera/shared`.
* `DEFAULT_WELCOME_MAIL_TEXTS` aus `@tessera/shared`. Die zwei
* Anmeldehinweise kamen spaeter hinzu (Spalten nullable): fehlt ein Wert in
* einer aelteren Vorlage, gilt fuer ihn der Standardtext.
*
* Mandantenbindung: jeder Zugriff laeuft ueber `forTenant(prisma, tenantId)`.
* Beim Versand ist `tenantId` der Mandant des ZIEL-Benutzers
@@ -36,6 +38,8 @@ const TEXT_SELECT = {
subject: true,
heading: true,
intro: true,
loginHintDirectory: true,
loginHintLocal: true,
closing: true,
updatedAt: true,
updatedBy: true,
@@ -57,7 +61,16 @@ export class WelcomeMailTemplateService {
}
return {
custom: true,
texts: { subject: row.subject, heading: row.heading, intro: row.intro, closing: row.closing },
texts: {
subject: row.subject,
heading: row.heading,
intro: row.intro,
loginHintDirectory: row.loginHintDirectory?.trim()
? row.loginHintDirectory
: defaults.loginHintDirectory,
loginHintLocal: row.loginHintLocal?.trim() ? row.loginHintLocal : defaults.loginHintLocal,
closing: row.closing,
},
defaults,
updatedAt: row.updatedAt,
updatedBy: row.updatedBy,
@@ -90,6 +103,8 @@ export class WelcomeMailTemplateService {
subject: texts.subject,
heading: texts.heading,
intro: texts.intro,
loginHintDirectory: texts.loginHintDirectory,
loginHintLocal: texts.loginHintLocal,
closing: texts.closing,
updatedBy,
};
+30 -2
View File
@@ -1,5 +1,5 @@
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
import type { WelcomeMailTexts } from '@tessera/shared';
import { DEFAULT_WELCOME_MAIL_TEXTS, type WelcomeMailTexts } from '@tessera/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
normalizeOrigin,
@@ -265,6 +265,8 @@ describe('WelcomeMailService.send — eigene Vorlage', () => {
subject: 'Hallo {{vorname}} bei {{firma}}',
heading: 'Schön, dass Sie da sind, {{name}}!',
intro: 'Erste Zeile\nzweite Zeile\n\nIhr Konto: {{benutzername}} / {{email}} / {{adresse}}',
loginHintDirectory: 'Bitte mit dem Kennwort von {{firma}} anmelden, {{vorname}}.',
loginHintLocal: 'Zuerst ein eigenes Passwort vergeben, {{vorname}}.',
closing: 'Grüße vom <b>IT-Team</b>',
};
@@ -297,6 +299,19 @@ describe('WelcomeMailService.send — eigene Vorlage', () => {
expect(rendered.html).toContain('Zu Tessera');
expect(rendered.html).toContain('Benutzername');
expect(rendered.text).toContain('Erste Zeile\nzweite Zeile');
// Anmeldehinweis fuer lokale Konten aus der Vorlage
expect(rendered.html).toContain('Zuerst ein eigenes Passwort vergeben, Max.');
expect(rendered.html).not.toContain('persönliches Passwort fest');
});
it('Verzeichniskonto mit eigener Vorlage: eigener Verzeichnis-Hinweis statt Windows-Standard', async () => {
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
await service.send(ldapUser);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.html).toContain('Bitte mit dem Kennwort von Firma t1 anmelden');
expect(rendered.html).not.toContain('gewohnten Windows-Passwort');
expect(rendered.html).not.toContain('Zuerst ein eigenes Passwort vergeben');
expect(rendered.text).toContain('Bitte mit dem Kennwort von Firma t1 anmelden');
});
it('Vorlage eines ANDEREN Mandanten wirkt nicht', async () => {
@@ -315,11 +330,15 @@ describe('WelcomeMailService.send — eigene Vorlage', () => {
});
});
const DEFAULT_TEXTS: WelcomeMailTexts = { ...DEFAULT_WELCOME_MAIL_TEXTS };
describe('WelcomeMailService.preview', () => {
const texts: WelcomeMailTexts = {
subject: 'Betreff {{name}}',
heading: 'Hallo {{vorname}}',
intro: 'Bei {{firma}}',
loginHintDirectory: 'Verzeichnis-Hinweis {{benutzername}}',
loginHintLocal: 'Lokal-Hinweis {{benutzername}}',
closing: '',
};
@@ -331,6 +350,8 @@ describe('WelcomeMailService.preview', () => {
expect(rendered.html).toContain('Hallo Max');
expect(rendered.html).toContain('Bei Firma t1');
expect(rendered.html).toContain('/reset-password/beispiel');
expect(rendered.html).toContain('Lokal-Hinweis max.mustermann');
expect(rendered.html).not.toContain('Verzeichnis-Hinweis');
expect(rendered.html).not.toContain('cid:');
expect(prisma.__log).toHaveLength(0);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
@@ -338,9 +359,12 @@ describe('WelcomeMailService.preview', () => {
it('Verzeichniskonto-Variante: Windows-Hinweis statt Passwort-Knopf', async () => {
const { service } = make();
const rendered = await service.preview('t1', texts, 'directory');
const rendered = await service.preview('t1', DEFAULT_TEXTS, 'directory');
expect(rendered.html).toContain('gewohnten Windows-Passwort');
expect(rendered.html).not.toContain('Passwort festlegen');
const custom = await service.preview('t1', texts, 'directory');
expect(custom.html).toContain('Verzeichnis-Hinweis max.mustermann');
expect(custom.html).not.toContain('Lokal-Hinweis');
});
});
@@ -349,6 +373,8 @@ describe('WelcomeMailService.sendTest', () => {
subject: 'Test {{name}}',
heading: 'Hallo {{vorname}}',
intro: 'Text',
loginHintDirectory: DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory,
loginHintLocal: 'Eigener Lokal-Hinweis für {{vorname}}',
closing: 'Gruß',
};
@@ -371,6 +397,8 @@ describe('WelcomeMailService.sendTest', () => {
expect(rendered.html).toContain('Passwort festlegen');
expect(rendered.html).toContain('Beispiel-Link');
expect(rendered.html).not.toContain('reset-password');
// Anmeldehinweis fuer lokale Konten aus dem Formularinhalt
expect(rendered.html).toContain('Eigener Lokal-Hinweis für Ada');
expect(rendered.text).not.toContain('reset-password');
});
@@ -41,6 +41,8 @@ const custom = {
subject: 'Hallo {{vorname}}',
heading: 'Willkommen, {{name}}!',
intro: 'Eigene Einleitung',
loginHintDirectory: 'Eigener Verzeichnis-Hinweis',
loginHintLocal: 'Eigener Lokal-Hinweis',
closing: 'Ihr IT-Team',
};
@@ -147,7 +149,76 @@ describe('WelcomeMailTemplateEditor', () => {
).toBe(true);
});
it('Speichern schickt die vier Texte und meldet Erfolg; Status zeigt die eigene Vorlage', async () => {
it('Anmeldehinweise: beide Felder nach dem Einleitungstext, mit Standardtext bzw. eigener Vorlage vorbelegt', async () => {
await renderLoaded();
const dir = screen.getByLabelText(
'Anmeldehinweis für Verzeichniskonten',
) as HTMLTextAreaElement;
const local = screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement;
expect(dir.value).toBe(
'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.',
);
expect(local.value).toBe(
'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.',
);
// Reihenfolge: Einleitung → Verzeichnis → lokal → Abschluss
const intro = screen.getByLabelText('Einleitungstext');
const closing = screen.getByLabelText('Abschlusstext');
const follows = (a: Element, b: Element) =>
Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
expect(follows(intro, dir)).toBe(true);
expect(follows(dir, local)).toBe(true);
expect(follows(local, closing)).toBe(true);
expect(screen.getByText(/direkt vor dem Knopf „Passwort festlegen“/)).toBeTruthy();
cleanup();
api.fetchWelcomeMailTemplate.mockResolvedValue(state(true));
await renderLoaded();
expect(
(screen.getByLabelText('Anmeldehinweis für Verzeichniskonten') as HTMLTextAreaElement).value,
).toBe(custom.loginHintDirectory);
expect(
(screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement).value,
).toBe(custom.loginHintLocal);
});
it('Anmeldehinweis: Pflichtfeld, Platzhalter-Klick fuegt ein, Fokus schaltet die Vorschau auf die Kontoart', async () => {
await renderLoaded();
const local = screen.getByLabelText('Anmeldehinweis für lokale Konten') as HTMLTextAreaElement;
fireEvent.change(local, { target: { value: 'Hallo ' } });
local.focus();
local.setSelectionRange(6, 6);
fireEvent.click(screen.getByRole('button', { name: '{{vorname}} einfügen' }));
expect(local.value).toBe('Hallo {{vorname}}');
expect(screen.getByRole('button', { name: 'Lokales Konto' }).getAttribute('aria-pressed')).toBe(
'true',
);
fireEvent.change(local, { target: { value: ' ' } });
expect(await screen.findByText('Bitte füllen Sie dieses Feld aus.')).toBeTruthy();
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
true,
);
});
it('Speichern schickt alle sechs Texte inkl. Anmeldehinweise', async () => {
const user = userEvent.setup();
await renderLoaded();
fireEvent.change(screen.getByLabelText('Anmeldehinweis für Verzeichniskonten'), {
target: { value: 'Mit dem Firmenkennwort anmelden, {{vorname}}.' },
});
fireEvent.change(screen.getByLabelText('Anmeldehinweis für lokale Konten'), {
target: { value: 'Erst Passwort setzen.' },
});
await user.click(screen.getByRole('button', { name: 'Speichern' }));
expect(api.saveWelcomeMailTemplate).toHaveBeenCalledWith({
...defaults,
loginHintDirectory: 'Mit dem Firmenkennwort anmelden, {{vorname}}.',
loginHintLocal: 'Erst Passwort setzen.',
});
});
it('Speichern schickt die Texte und meldet Erfolg; Status zeigt die eigene Vorlage', async () => {
const user = userEvent.setup();
const subject = await renderLoaded();
fireEvent.change(subject, { target: { value: 'Neu für {{firma}}' } });
@@ -24,13 +24,41 @@ type Field = keyof WelcomeMailTexts;
type PreviewAccount = 'directory' | 'local';
type Feedback = { type: 'success' | 'error'; message: string } | null;
const FIELDS: Field[] = ['subject', 'heading', 'intro', 'closing'];
const FIELDS: Field[] = [
'subject',
'heading',
'intro',
'loginHintDirectory',
'loginHintLocal',
'closing',
];
const MULTILINE: Record<Field, boolean> = {
subject: false,
heading: false,
intro: true,
loginHintDirectory: true,
loginHintLocal: true,
closing: true,
};
/** Pflichtfelder (dieselben wie im DTO der API). */
const REQUIRED: ReadonlySet<Field> = new Set([
'subject',
'heading',
'loginHintDirectory',
'loginHintLocal',
]);
/** Zeilen der mehrzeiligen Felder. */
const ROWS: Partial<Record<Field, number>> = {
intro: 6,
loginHintDirectory: 3,
loginHintLocal: 3,
closing: 5,
};
/** Welche Kontoart die Vorschau zeigt, wenn ein Anmeldehinweis bearbeitet wird. */
const HINT_ACCOUNT: Partial<Record<Field, PreviewAccount>> = {
loginHintDirectory: 'directory',
loginHintLocal: 'local',
};
/** Verzoegerung der Live-Vorschau nach der letzten Eingabe. */
const PREVIEW_DELAY_MS = 400;
@@ -38,11 +66,13 @@ const PREVIEW_DELAY_MS = 400;
/**
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
*
* Vier Textfelder (Betreff, Ueberschrift, Einleitung, Abschluss), eine
* Sechs Textfelder (Betreff, Ueberschrift, Einleitung, Anmeldehinweis fuer
* Verzeichniskonten, Anmeldehinweis fuer lokale Konten, Abschluss), eine
* Hinweisbox mit den Platzhaltern (Klick fuegt an der Cursorposition des
* zuletzt benutzten Feldes ein), eine Live-Vorschau (von der API gerendert,
* also dieselbe Mail wie im Versand) mit Umschalter Verzeichniskonto/lokales
* Konto, "Testmail an mich senden" und "Auf Standard zuruecksetzen".
* Konto (springt beim Bearbeiten eines Anmeldehinweises auf die passende
* Kontoart), "Testmail an mich senden" und "Auf Standard zuruecksetzen".
*
* Unbekannte Platzhalter benennt das Formular schon beim Tippen; die API
* lehnt sie beim Speichern ohnehin mit 400 ab (Meldung wird angezeigt).
@@ -81,7 +111,7 @@ export function WelcomeMailTemplateEditor() {
// ── 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()) {
if (REQUIRED.has(field) && !value.trim()) {
errors.push(t('errors.required'));
}
if (value.length > WELCOME_MAIL_LIMITS[field]) {
@@ -266,6 +296,8 @@ export function WelcomeMailTemplateEditor() {
'aria-describedby': describedBy,
onFocus: () => {
lastFocused.current = field;
const hintAccount = HINT_ACCOUNT[field];
if (hintAccount) setAccount(hintAccount);
},
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
@@ -283,7 +315,7 @@ export function WelcomeMailTemplateEditor() {
ref={(el) => {
fieldRefs.current[field] = el;
}}
rows={field === 'intro' ? 6 : 5}
rows={ROWS[field] ?? 5}
className={`${inputClass} py-2`}
/>
) : (
@@ -56,12 +56,14 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
return (await res.json()) as T;
}
/** Nur die vier Texte senden — nie weitere Felder aus dem Formularzustand. */
/** Nur die sechs Texte senden — nie weitere Felder aus dem Formularzustand. */
function textsOf(texts: WelcomeMailTexts): WelcomeMailTexts {
return {
subject: texts.subject,
heading: texts.heading,
intro: texts.intro,
loginHintDirectory: texts.loginHintDirectory,
loginHintLocal: texts.loginHintLocal,
closing: texts.closing,
};
}
+5 -1
View File
@@ -746,7 +746,7 @@
"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."
"body": "Sie bestimmen Betreff, Überschrift, Einleitung, Abschlusstext und den Anmeldehinweis – getrennt für Verzeichniskonten und für lokale Konten. Alles, was für die Anmeldung technisch nötig ist, fügt Tessera immer automatisch ein und lässt sich nicht verändern: der Kopf mit Logo, der Kasten mit Adresse und Benutzername, der Knopf „Passwort festlegen“ samt Link und Gültigkeitshinweis bei lokalen Konten, der Knopf „Zu Tessera“ und die Fußzeile. So kann eine eigene Vorlage die Anmeldung nicht kaputt machen."
},
"statusDefault": "Zurzeit gelten die Standardtexte.",
"statusCustom": "Eigene Vorlage aktiv – zuletzt geändert am {date}.",
@@ -759,6 +759,10 @@
"headingHelp": "Große Zeile oben in der Mail, Pflichtfeld.",
"intro": "Einleitungstext",
"introHelp": "Steht unter der Überschrift. Eine Leerzeile beginnt einen neuen Absatz.",
"loginHintDirectory": "Anmeldehinweis für Verzeichniskonten",
"loginHintDirectoryHelp": "Erscheint unter dem Kasten mit den Zugangsdaten, wenn der Benutzer aus dem Verzeichnis (Active Directory/LDAP) kommt und sich mit seinem gewohnten Windows-Passwort anmeldet. Pflichtfeld.",
"loginHintLocal": "Anmeldehinweis für lokale Konten",
"loginHintLocalHelp": "Erscheint bei lokalen Konten direkt vor dem Knopf „Passwort festlegen“. Knopf und Gültigkeitshinweis darunter fügt Tessera immer selbst ein. Pflichtfeld.",
"closing": "Abschlusstext",
"closingHelp": "Steht am Ende der Mail, z. B. Tipp und Grußzeile. Darf leer bleiben."
},
+5 -1
View File
@@ -746,7 +746,7 @@
"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."
"body": "You decide the subject, heading, introduction, closing text and the sign-in hint – separately for directory accounts and for local accounts. Everything technically needed to sign in is always added automatically and cannot be changed: the header with logo, the box with address and username, the “Set password” button with its link and validity note for local accounts, the “Go to Tessera” button and the footer. This way a custom template cannot break sign-in."
},
"statusDefault": "The default texts are currently in use.",
"statusCustom": "Custom template active – last changed on {date}.",
@@ -759,6 +759,10 @@
"headingHelp": "Large line at the top of the email, required.",
"intro": "Introduction",
"introHelp": "Shown below the heading. An empty line starts a new paragraph.",
"loginHintDirectory": "Sign-in hint for directory accounts",
"loginHintDirectoryHelp": "Shown below the box with the sign-in details when the user comes from the directory (Active Directory/LDAP) and signs in with their usual Windows password. Required.",
"loginHintLocal": "Sign-in hint for local accounts",
"loginHintLocalHelp": "Shown for local accounts right before the “Set password” button. Tessera always adds the button and the validity note below it. Required.",
"closing": "Closing text",
"closingHelp": "Shown at the end of the email, e.g. a tip and a sign-off. May be left empty."
},