feat(admin): Anmeldehinweise der Willkommensmail in der Vorlage bearbeitbar
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:
@@ -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;
|
||||
@@ -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 <b>Firmenkennwort</b>.</p>',
|
||||
);
|
||||
expect(dir.html).toContain('>Fragen? IT anrufen.</p>');
|
||||
expect(dir.html).not.toContain('Windows-Passwort');
|
||||
expect(dir.html).not.toContain('bitte zuerst ein Passwort setzen');
|
||||
expect(dir.text).toContain(
|
||||
'Anmeldung als max.muster mit <b>Firmenkennwort</b>.\n\nFragen? IT anrufen.',
|
||||
);
|
||||
|
||||
const local = renderWelcomeMail({ ...base, account: localAccount, texts });
|
||||
expect(local.html).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
|
||||
expect(local.html).not.toContain('Firmenkennwort');
|
||||
expect(local.html).toContain('reset-password/abc');
|
||||
expect(local.html).toContain('7 Tage');
|
||||
expect(local.text).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
|
||||
|
||||
const example = renderWelcomeMail({ ...base, account: { kind: 'local-example' }, texts });
|
||||
expect(example.html).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
|
||||
});
|
||||
|
||||
it('leerer oder fehlender Hinweis (aeltere Aufrufer) → Standardtext', () => {
|
||||
const { loginHintDirectory: _d, loginHintLocal: _l, ...old } = DEFAULT_WELCOME_MAIL_TEXTS;
|
||||
const dir = renderWelcomeMail({ ...base, texts: old as any });
|
||||
expect(dir.html).toContain('gewohnten Windows-Passwort');
|
||||
const local = renderWelcomeMail({
|
||||
...base,
|
||||
account: localAccount,
|
||||
texts: { ...DEFAULT_WELCOME_MAIL_TEXTS, loginHintLocal: ' ' },
|
||||
});
|
||||
expect(local.html).toContain('persönliches Passwort fest');
|
||||
});
|
||||
|
||||
it('local-no-link behaelt den festen Hinweis', () => {
|
||||
const mail = renderWelcomeMail({ ...base, account: { kind: 'local-no-link' } });
|
||||
expect(mail.html).toContain('Ihr Startpasswort erhalten Sie von Ihrem Administrator.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kopf (Outlook ohne CID-Bild)', () => {
|
||||
it('Wellenstreifen 600x40 in einer Zelle mit dunkler Kopffarbe, Inhalt beginnt mit 24px Abstand', () => {
|
||||
const mail = renderWelcomeMail({ ...base, headerImageSrc: 'cid:welcome-header@tessera' });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user