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
@@ -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`}
/>
) : (