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
@@ -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')}