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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user