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