feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m22s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m10s

Administrator -> Willkommensmail: Betreff, Ueberschrift, Einleitung, Abschluss je
Mandant (Tabelle WelcomeMailTemplate, RLS je Mandant, Migration 20260930150000);
Platzhalter {{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}},
unbekannte -> 400 bzw. Hinweis beim Tippen; Werte escaped, Vorlage reiner Text.
Live-Vorschau per API gerendert, Testmail an die eigene Adresse ohne Token,
Zuruecksetzen auf Standard. Feste Bausteine (Kopf, Zugangsdaten, Anmeldehinweis,
Knoepfe, Fusszeile) bleiben immer drin.
Kopf: Wellenzelle dunkel statt weiss, Streifen 600x40, Inhalt 24 px naeher –
keine weisse Luecke, wenn OWA das CID-Bild nicht zeigt.
Lokal nachgewiesen: Hinweis/Sperre bei {{xyz}}, Speichern, Testmail (Link nur
/login), echte Mail mit eigener Vorlage und 7-Tage-Link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:49:17 +02:00
parent 31d514b7ca
commit e10da76259
27 changed files with 2471 additions and 82 deletions
@@ -0,0 +1,27 @@
'use client';
import { useTranslations } from 'next-intl';
import { WelcomeMailTemplateEditor } from '@/components/admin/welcome-mail-template-editor';
import { useAuthStore } from '@/lib/stores/auth-store';
/**
* Administrator → Willkommensmail: eigene Vorlage je Mandant (Betreff,
* Ueberschrift, Einleitung, Abschluss mit Platzhaltern). ADMIN und
* SUPER_ADMIN; die Rollenpruefung hier ist nur Anzeige — durchgesetzt wird
* sie serverseitig (`@Roles` an `/welcome-mail-template`).
*/
export default function AdminWelcomeMailPage() {
const tCommon = useTranslations('common');
const currentUser = useAuthStore((s) => s.user);
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
if (!hasAccess) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
</div>
);
}
return <WelcomeMailTemplateEditor />;
}
@@ -83,6 +83,18 @@ export function AdminSidebar() {
</svg>
),
},
{
label: t('admin.welcomeMail'),
href: '/admin/welcome-mail',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
<path d="m16 19 2 2 4-4" />
</svg>
),
},
{
label: t('admin.groups'),
href: '/admin/groups',
@@ -0,0 +1,227 @@
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
// Namensraumfaehiger next-intl-Mock gegen die echten deutschen Texte:
// Punktschluessel werden aufgeloest, {param} wird eingesetzt.
vi.mock('next-intl', () => ({
useLocale: () => 'de',
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
let val: unknown = de;
for (const part of `${ns}.${key}`.split('.')) {
val = (val as Record<string, unknown> | undefined)?.[part];
}
if (typeof val !== 'string') return `${ns}.${key}`;
return params
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
: val;
},
}));
const api = vi.hoisted(() => ({
fetchWelcomeMailTemplate: vi.fn(),
saveWelcomeMailTemplate: vi.fn(),
resetWelcomeMailTemplate: vi.fn(),
previewWelcomeMail: vi.fn(),
sendWelcomeMailTest: vi.fn(),
}));
vi.mock('@/lib/welcome-mail-template-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/welcome-mail-template-api')>();
return { ...actual, ...api };
});
import { WelcomeMailTemplateRequestError } from '@/lib/welcome-mail-template-api';
import { WelcomeMailTemplateEditor } from './welcome-mail-template-editor';
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
const custom = {
subject: 'Hallo {{vorname}}',
heading: 'Willkommen, {{name}}!',
intro: 'Eigene Einleitung',
closing: 'Ihr IT-Team',
};
function state(isCustom: boolean) {
return {
custom: isCustom,
texts: isCustom ? { ...custom } : { ...defaults },
defaults: { ...defaults },
updatedAt: isCustom ? '2026-09-30T10:00:00.000Z' : null,
updatedBy: isCustom ? 'ada' : null,
};
}
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
api.fetchWelcomeMailTemplate.mockResolvedValue(state(false));
api.previewWelcomeMail.mockImplementation(async (texts: typeof defaults, account: string) => ({
subject: texts.subject,
html: `<html><body>Vorschau ${account}</body></html>`,
text: '',
}));
api.saveWelcomeMailTemplate.mockImplementation(async (texts: typeof defaults) => ({
...state(true),
texts: { ...texts },
}));
api.resetWelcomeMailTemplate.mockResolvedValue(state(false));
api.sendWelcomeMailTest.mockResolvedValue({ success: true, to: 'ada@example.invalid' });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
async function renderLoaded() {
render(<WelcomeMailTemplateEditor />);
return (await screen.findByLabelText('Betreff')) as HTMLInputElement;
}
describe('WelcomeMailTemplateEditor', () => {
it('laedt die Standardtexte ins Formular und zeigt Hinweis auf die festen Bausteine', async () => {
const subject = await renderLoaded();
expect(subject.value).toBe(defaults.subject);
expect((screen.getByLabelText('Überschrift') as HTMLInputElement).value).toBe(defaults.heading);
expect((screen.getByLabelText('Einleitungstext') as HTMLTextAreaElement).value).toBe(
defaults.intro,
);
expect((screen.getByLabelText('Abschlusstext') as HTMLTextAreaElement).value).toBe(
defaults.closing,
);
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
'Zurzeit gelten die Standardtexte.',
);
expect(
screen.getByText(/So kann eine eigene Vorlage die Anmeldung nicht kaputt machen/),
).toBeTruthy();
});
it('Hinweisbox Platzhalter: Tabelle mit allen sechs Platzhaltern, Bedeutung und Beispiel', async () => {
await renderLoaded();
const box = screen.getByRole('region', { name: 'Platzhalter' });
const rows = within(box).getAllByRole('row');
expect(rows).toHaveLength(7); // Kopf + 6
for (const token of [
'{{name}}',
'{{vorname}}',
'{{benutzername}}',
'{{email}}',
'{{adresse}}',
'{{firma}}',
]) {
expect(within(box).getByRole('button', { name: `${token} einfügen` })).toBeTruthy();
}
expect(within(box).getByText('Name Ihres Mandanten')).toBeTruthy();
expect(within(box).getByText('max.mustermann@example.com')).toBeTruthy();
});
it('Klick auf einen Platzhalter fuegt ihn an der Cursorposition des zuletzt fokussierten Feldes ein', async () => {
const subject = await renderLoaded();
fireEvent.change(subject, { target: { value: 'Hallo bei uns' } });
subject.focus();
subject.setSelectionRange(6, 6);
fireEvent.click(screen.getByRole('button', { name: '{{vorname}} einfügen' }));
expect(subject.value).toBe('Hallo {{vorname}} bei uns');
});
it('unbekannter Platzhalter wird benannt, Speichern und Testmail sind gesperrt', async () => {
const user = userEvent.setup();
await renderLoaded();
const heading = screen.getByLabelText('Überschrift');
await user.clear(heading);
fireEvent.change(heading, { target: { value: 'Hallo {{xyz}}' } });
expect(await screen.findByText('Unbekannter Platzhalter {{xyz}}')).toBeTruthy();
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
true,
);
expect(
(screen.getByRole('button', { name: 'Testmail an mich senden' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it('Speichern schickt die vier 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}}' } });
await user.click(screen.getByRole('button', { name: 'Speichern' }));
expect(api.saveWelcomeMailTemplate).toHaveBeenCalledWith({
...defaults,
subject: 'Neu für {{firma}}',
});
expect((await screen.findByRole('status')).textContent).toContain(
'Die Vorlage wurde gespeichert.',
);
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
'Eigene Vorlage aktiv',
);
expect(screen.getByTestId('welcome-template-status').textContent).toContain('von ada');
});
it('Fehler der API beim Speichern wird angezeigt (400 mit Meldung)', async () => {
const user = userEvent.setup();
api.saveWelcomeMailTemplate.mockRejectedValueOnce(
new WelcomeMailTemplateRequestError(400, ['Betreff: Unbekannter Platzhalter {{abc}}']),
);
await renderLoaded();
await user.click(screen.getByRole('button', { name: 'Speichern' }));
expect((await screen.findByRole('alert')).textContent).toBe(
'Betreff: Unbekannter Platzhalter {{abc}}',
);
});
it('Auf Standard zuruecksetzen: Rueckfrage, DELETE, danach Standardtexte im Formular', async () => {
const user = userEvent.setup();
api.fetchWelcomeMailTemplate.mockResolvedValue(state(true));
const subject = await renderLoaded();
expect(subject.value).toBe(custom.subject);
await user.click(screen.getByRole('button', { name: 'Auf Standard zurücksetzen' }));
const dialog = screen.getByRole('alertdialog');
expect(api.resetWelcomeMailTemplate).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole('button', { name: 'Zurücksetzen' }));
expect(api.resetWelcomeMailTemplate).toHaveBeenCalledTimes(1);
await waitFor(() => expect(subject.value).toBe(defaults.subject));
expect(screen.getByTestId('welcome-template-status').textContent).toContain('Standardtexte');
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('Vorschau: iframe mit dem von der API gerenderten HTML, Umschalter lokales Konto', async () => {
const user = userEvent.setup();
await renderLoaded();
const frame = screen.getByTitle('Vorschau der Willkommensmail') as HTMLIFrameElement;
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau directory'));
expect(frame.getAttribute('sandbox')).toBe('');
await user.click(screen.getByRole('button', { name: 'Lokales Konto' }));
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau local'));
expect(api.previewWelcomeMail).toHaveBeenLastCalledWith(defaults, 'local', expect.anything());
expect(screen.getByTestId('welcome-preview-subject').textContent).toBe(defaults.subject);
});
it('Testmail an mich: sendet den aktuellen (ungespeicherten) Inhalt und nennt die Adresse', async () => {
const user = userEvent.setup();
const subject = await renderLoaded();
fireEvent.change(subject, { target: { value: 'Ungespeichert' } });
await user.click(screen.getByRole('button', { name: 'Testmail an mich senden' }));
expect(api.sendWelcomeMailTest).toHaveBeenCalledWith({ ...defaults, subject: 'Ungespeichert' });
expect(api.saveWelcomeMailTemplate).not.toHaveBeenCalled();
expect((await screen.findByRole('status')).textContent).toBe(
'Die Testmail wurde an ada@example.invalid gesendet.',
);
});
it('Ladefehler wird angezeigt', async () => {
api.fetchWelcomeMailTemplate.mockRejectedValueOnce(new Error('down'));
render(<WelcomeMailTemplateEditor />);
expect((await screen.findByRole('alert')).textContent).toContain('konnte nicht geladen werden');
});
});
@@ -0,0 +1,481 @@
'use client';
import {
findUnknownWelcomeMailPlaceholders,
WELCOME_MAIL_LIMITS,
WELCOME_MAIL_PLACEHOLDERS,
type WelcomeMailPlaceholder,
type WelcomeMailTexts,
} from '@tessera/shared';
import { useLocale, useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import {
fetchWelcomeMailTemplate,
previewWelcomeMail,
resetWelcomeMailTemplate,
saveWelcomeMailTemplate,
sendWelcomeMailTest,
type WelcomeMailPreview,
WelcomeMailTemplateRequestError,
type WelcomeMailTemplateState,
} from '@/lib/welcome-mail-template-api';
type Field = keyof WelcomeMailTexts;
type PreviewAccount = 'directory' | 'local';
type Feedback = { type: 'success' | 'error'; message: string } | null;
const FIELDS: Field[] = ['subject', 'heading', 'intro', 'closing'];
const MULTILINE: Record<Field, boolean> = {
subject: false,
heading: false,
intro: true,
closing: true,
};
/** Verzoegerung der Live-Vorschau nach der letzten Eingabe. */
const PREVIEW_DELAY_MS = 400;
/**
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
*
* Vier Textfelder (Betreff, Ueberschrift, Einleitung, 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".
*
* Unbekannte Platzhalter benennt das Formular schon beim Tippen; die API
* lehnt sie beim Speichern ohnehin mit 400 ab (Meldung wird angezeigt).
*/
export function WelcomeMailTemplateEditor() {
const t = useTranslations('admin.welcomeMail');
const locale = useLocale();
const [state, setState] = useState<WelcomeMailTemplateState | null>(null);
const [form, setForm] = useState<WelcomeMailTexts | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [resetting, setResetting] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [feedback, setFeedback] = useState<Feedback>(null);
const [account, setAccount] = useState<PreviewAccount>('directory');
const [preview, setPreview] = useState<WelcomeMailPreview | null>(null);
const [previewFailed, setPreviewFailed] = useState(false);
const fieldRefs = useRef<Partial<Record<Field, HTMLInputElement | HTMLTextAreaElement | null>>>(
{},
);
const lastFocused = useRef<Field>('intro');
useEffect(() => {
fetchWelcomeMailTemplate()
.then((loaded) => {
setState(loaded);
setForm({ ...loaded.texts });
})
.catch(() => setLoadFailed(true));
}, []);
// ── 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()) {
errors.push(t('errors.required'));
}
if (value.length > WELCOME_MAIL_LIMITS[field]) {
errors.push(t('errors.tooLong', { max: WELCOME_MAIL_LIMITS[field] }));
}
const unknown = findUnknownWelcomeMailPlaceholders(value);
if (unknown.length === 1) errors.push(t('errors.unknownPlaceholder', { token: unknown[0] }));
if (unknown.length > 1) {
errors.push(t('errors.unknownPlaceholders', { tokens: unknown.join(', ') }));
}
return errors;
};
const errorsByField = form
? (Object.fromEntries(FIELDS.map((f) => [f, fieldErrors(f, form[f])])) as Record<
Field,
string[]
>)
: null;
const hasErrors = errorsByField ? FIELDS.some((f) => errorsByField[f].length > 0) : false;
const dirty = !!form && !!state && FIELDS.some((f) => form[f] !== state.texts[f]);
// ── Live-Vorschau ─────────────────────────────────────────────────────
useEffect(() => {
if (!form || hasErrors) return;
const controller = new AbortController();
const timer = setTimeout(() => {
previewWelcomeMail(form, account, controller.signal)
.then((result) => {
setPreview(result);
setPreviewFailed(false);
})
.catch((error: unknown) => {
if ((error as { name?: string })?.name === 'AbortError') return;
setPreviewFailed(true);
});
}, PREVIEW_DELAY_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [form, account, hasErrors]);
// ── Aktionen ──────────────────────────────────────────────────────────
const serverMessage = (error: unknown, fallback: string): string => {
if (error instanceof WelcomeMailTemplateRequestError && error.messages.length > 0) {
return error.messages.join(' ');
}
return fallback;
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!form || hasErrors) return;
setSaving(true);
setFeedback(null);
try {
const saved = await saveWelcomeMailTemplate(form);
setState(saved);
setForm({ ...saved.texts });
setFeedback({ type: 'success', message: t('saved') });
} catch (error) {
setFeedback({ type: 'error', message: serverMessage(error, t('errors.save')) });
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!state) return;
setResetting(true);
setFeedback(null);
try {
const next = state.custom ? await resetWelcomeMailTemplate() : state;
setState(next);
setForm({ ...next.defaults });
setConfirmReset(false);
setFeedback({ type: 'success', message: t('resetDone') });
} catch (error) {
setFeedback({ type: 'error', message: serverMessage(error, t('errors.reset')) });
} finally {
setResetting(false);
}
};
const handleTest = async () => {
if (!form || hasErrors) return;
setTesting(true);
setFeedback(null);
try {
const result = await sendWelcomeMailTest(form);
setFeedback({ type: 'success', message: t('testSent', { email: result.to }) });
} catch (error) {
setFeedback({ type: 'error', message: serverMessage(error, t('errors.test')) });
} finally {
setTesting(false);
}
};
/** Fuegt `{{name}}` an der Cursorposition des zuletzt benutzten Feldes ein. */
const insertPlaceholder = (placeholder: WelcomeMailPlaceholder) => {
if (!form) return;
const field = lastFocused.current;
const token = `{{${placeholder}}}`;
const el = fieldRefs.current[field];
const value = form[field];
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
setForm({ ...form, [field]: value.slice(0, start) + token + value.slice(end) });
const caret = start + token.length;
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(caret, caret);
});
};
// ── Darstellung ───────────────────────────────────────────────────────
if (loadFailed) {
return (
<p role="alert" className="text-sm text-destructive">
{t('errors.load')}
</p>
);
}
if (!form || !state || !errorsByField) {
return <p className="text-sm text-muted-foreground">{t('loading')}</p>;
}
const inputClass =
'w-full rounded border border-border bg-background px-3 text-sm text-foreground';
const statusText = state.custom
? state.updatedAt
? t(state.updatedBy ? 'statusCustomBy' : 'statusCustom', {
date: new Date(state.updatedAt).toLocaleString(locale === 'en' ? 'en-GB' : 'de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
}),
user: state.updatedBy ?? '',
})
: t('statusCustom', { date: '' })
: t('statusDefault');
return (
<div className="space-y-6">
<div>
<h1 className="mb-2 text-lg font-semibold text-foreground">{t('title')}</h1>
<p className="max-w-3xl text-sm text-muted-foreground">{t('description')}</p>
</div>
{/* Feste Bausteine */}
<section
aria-labelledby="welcome-fixed-title"
className="max-w-3xl rounded-lg border border-border bg-muted/40 p-4"
>
<h2 id="welcome-fixed-title" className="mb-1 text-sm font-semibold text-foreground">
{t('fixed.title')}
</h2>
<p className="text-sm text-muted-foreground">{t('fixed.body')}</p>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
{/* Formular */}
<form onSubmit={handleSave} className="min-w-0 space-y-4" noValidate>
<p
className={`text-sm ${state.custom ? 'text-foreground' : 'text-muted-foreground'}`}
data-testid="welcome-template-status"
>
{statusText}
{dirty && <span className="ml-2 font-medium">· {t('unsaved')}</span>}
</p>
{FIELDS.map((field) => {
const id = `welcome-${field}`;
const errors = errorsByField[field];
const describedBy = [`${id}-help`, errors.length ? `${id}-error` : '']
.filter(Boolean)
.join(' ');
const common = {
id,
value: form[field],
'aria-invalid': errors.length > 0 || undefined,
'aria-describedby': describedBy,
onFocus: () => {
lastFocused.current = field;
},
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
},
};
return (
<div key={field}>
<label htmlFor={id} className="mb-1 block text-sm text-foreground">
{t(`fields.${field}`)}
</label>
{MULTILINE[field] ? (
<textarea
{...common}
ref={(el) => {
fieldRefs.current[field] = el;
}}
rows={field === 'intro' ? 6 : 5}
className={`${inputClass} py-2`}
/>
) : (
<input
{...common}
ref={(el) => {
fieldRefs.current[field] = el;
}}
type="text"
className={`${inputClass} h-9`}
/>
)}
<div className="mt-1 flex justify-between gap-3 text-xs text-muted-foreground">
<span id={`${id}-help`}>{t(`fields.${field}Help`)}</span>
<span className="shrink-0 tabular-nums">
{t('counter', { count: form[field].length, max: WELCOME_MAIL_LIMITS[field] })}
</span>
</div>
{errors.length > 0 && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive">
{errors.join(' ')}
</p>
)}
</div>
);
})}
<div className="flex flex-wrap gap-3 pt-2">
<button type="submit" disabled={saving || hasErrors} className="btn btn-primary">
{saving ? t('saving') : t('save')}
</button>
<button
type="button"
disabled={testing || hasErrors}
onClick={handleTest}
className="btn btn-secondary"
>
{testing ? t('testSending') : t('test')}
</button>
<button
type="button"
disabled={resetting}
onClick={() => setConfirmReset(true)}
className="btn btn-secondary"
>
{t('reset')}
</button>
</div>
<p className="text-xs text-muted-foreground">{t('testHelp')}</p>
{confirmReset && (
<div
role="alertdialog"
aria-labelledby="welcome-reset-title"
aria-describedby="welcome-reset-body"
className="max-w-xl rounded-lg border border-border bg-background p-4"
>
<h2 id="welcome-reset-title" className="mb-1 text-sm font-semibold text-foreground">
{t('resetConfirm.title')}
</h2>
<p id="welcome-reset-body" className="mb-3 text-sm text-muted-foreground">
{t('resetConfirm.body')}
</p>
<div className="flex gap-3">
<button
type="button"
disabled={resetting}
onClick={handleReset}
className="btn btn-primary"
>
{t('resetConfirm.confirm')}
</button>
<button
type="button"
onClick={() => setConfirmReset(false)}
className="btn btn-secondary"
>
{t('resetConfirm.cancel')}
</button>
</div>
</div>
)}
{feedback && (
<p
role={feedback.type === 'error' ? 'alert' : 'status'}
className={`text-sm ${feedback.type === 'error' ? 'text-destructive' : ''}`}
style={feedback.type === 'success' ? { color: 'oklch(0.40 0.15 148)' } : undefined}
>
{feedback.message}
</p>
)}
{/* Platzhalter */}
<section
aria-labelledby="welcome-placeholders-title"
className="rounded-lg border border-border p-4"
>
<h2
id="welcome-placeholders-title"
className="mb-1 text-sm font-semibold text-foreground"
>
{t('placeholders.title')}
</h2>
<p className="mb-3 text-sm text-muted-foreground">{t('placeholders.intro')}</p>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th scope="col" className="py-2 pr-3 font-medium">
{t('placeholders.colPlaceholder')}
</th>
<th scope="col" className="py-2 pr-3 font-medium">
{t('placeholders.colMeaning')}
</th>
<th scope="col" className="py-2 font-medium">
{t('placeholders.colExample')}
</th>
</tr>
</thead>
<tbody>
{WELCOME_MAIL_PLACEHOLDERS.map((name) => (
<tr key={name} className="border-b border-border last:border-0 align-top">
<td className="py-2 pr-3">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertPlaceholder(name)}
title={t('placeholders.insertHint')}
aria-label={t('placeholders.insert', { token: `{{${name}}}` })}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground hover:bg-sidebar-hover"
>
{`{{${name}}}`}
</button>
</td>
<td className="py-2 pr-3 text-foreground">
{t(`placeholders.items.${name}.meaning`)}
</td>
<td className="py-2 text-muted-foreground">
{t(`placeholders.items.${name}.example`)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-muted-foreground">{t('placeholders.rules')}</p>
</section>
</form>
{/* Vorschau */}
<section aria-labelledby="welcome-preview-title" className="min-w-0">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h2 id="welcome-preview-title" className="text-sm font-semibold text-foreground">
{t('preview.title')}
</h2>
<fieldset className="flex gap-1">
<legend className="sr-only">{t('preview.accountLabel')}</legend>
{(['directory', 'local'] as const).map((kind) => (
<button
key={kind}
type="button"
aria-pressed={account === kind}
onClick={() => setAccount(kind)}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
account === kind
? 'border-foreground bg-foreground text-background'
: 'border-border text-foreground hover:bg-sidebar-hover'
}`}
>
{t(`preview.${kind}`)}
</button>
))}
</fieldset>
</div>
<p className="mb-2 text-sm text-muted-foreground">
{t('preview.subject')}{' '}
<span className="font-medium text-foreground" data-testid="welcome-preview-subject">
{preview?.subject ?? ''}
</span>
</p>
{hasErrors && <p className="mb-2 text-sm text-destructive">{t('preview.blocked')}</p>}
{previewFailed && <p className="mb-2 text-sm text-destructive">{t('preview.error')}</p>}
<iframe
title={t('preview.frameTitle')}
sandbox=""
srcDoc={preview?.html ?? ''}
className="h-[760px] w-full rounded-lg border border-border bg-white"
/>
<p className="mt-2 text-xs text-muted-foreground">{t('preview.sampleHint')}</p>
</section>
</div>
</div>
);
}
@@ -0,0 +1,99 @@
/**
* Willkommensmail-Vorlage — API-Client (Administrator → Willkommensmail).
* Konsumiert `/welcome-mail-template`. Muster `custom-modules-api.ts`:
* `credentials: 'include'` fuer Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
*/
import type { WelcomeMailTexts } from '@tessera/shared';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/** Antwort von GET/PUT/DELETE /welcome-mail-template. */
export interface WelcomeMailTemplateState {
/** `true` = eigene Vorlage gespeichert, `false` = Standardtexte. */
custom: boolean;
texts: WelcomeMailTexts;
defaults: WelcomeMailTexts;
updatedAt: string | null;
updatedBy: string | null;
}
export interface WelcomeMailPreview {
subject: string;
html: string;
text: string;
}
/** Fehler mit HTTP-Status und den Servermeldungen (400: je Feld eine). */
export class WelcomeMailTemplateRequestError extends Error {
constructor(
readonly status: number,
readonly messages: string[],
) {
super(messages.join(' ') || `HTTP ${status}`);
this.name = 'WelcomeMailTemplateRequestError';
}
}
async function readMessages(res: Response): Promise<string[]> {
try {
const body = (await res.json()) as { message?: unknown };
if (Array.isArray(body.message)) return body.message.map(String);
if (typeof body.message === 'string' && body.message) return [body.message];
} catch {
// kein JSON — Status allein
}
return [];
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_URL}/welcome-mail-template${path}`, {
credentials: 'include',
...init,
headers: init.body ? { 'Content-Type': 'application/json' } : undefined,
});
if (!res.ok) throw new WelcomeMailTemplateRequestError(res.status, await readMessages(res));
return (await res.json()) as T;
}
/** Nur die vier Texte senden — nie weitere Felder aus dem Formularzustand. */
function textsOf(texts: WelcomeMailTexts): WelcomeMailTexts {
return {
subject: texts.subject,
heading: texts.heading,
intro: texts.intro,
closing: texts.closing,
};
}
export function fetchWelcomeMailTemplate(): Promise<WelcomeMailTemplateState> {
return request('');
}
export function saveWelcomeMailTemplate(
texts: WelcomeMailTexts,
): Promise<WelcomeMailTemplateState> {
return request('', { method: 'PUT', body: JSON.stringify(textsOf(texts)) });
}
export function resetWelcomeMailTemplate(): Promise<WelcomeMailTemplateState> {
return request('', { method: 'DELETE' });
}
export function previewWelcomeMail(
texts: WelcomeMailTexts,
account: 'directory' | 'local',
signal?: AbortSignal,
): Promise<WelcomeMailPreview> {
return request('/preview', {
method: 'POST',
body: JSON.stringify({ ...textsOf(texts), account }),
signal,
});
}
export function sendWelcomeMailTest(
texts: WelcomeMailTexts,
): Promise<{ success: true; to: string }> {
return request('/test', { method: 'POST', body: JSON.stringify(textsOf(texts)) });
}
+98 -1
View File
@@ -88,7 +88,8 @@
"customModules": "Eigene Module",
"ldap": "LDAP",
"smtp": "SMTP",
"groups": "Gruppen"
"groups": "Gruppen",
"welcomeMail": "Willkommensmail"
},
"role": {
"SUPER_ADMIN": "Super-Admin",
@@ -738,6 +739,102 @@
"create": "Gemeinsamen Eintrag anlegen",
"noModules": "Noch keine gemeinsamen Einträge",
"noModulesBody": "Legen Sie Ihren ersten gemeinsamen Eintrag an, um eine Webseite für alle Benutzer in der Seitenleiste zu zeigen."
},
"welcomeMail": {
"title": "Willkommensmail",
"description": "Hier legen Sie fest, mit welchen Worten Tessera neue Benutzer begrüßt. Die Vorlage gilt für Ihren Mandanten und wird bei jeder Willkommensmail aus der Benutzerverwaltung verwendet.",
"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."
},
"statusDefault": "Zurzeit gelten die Standardtexte.",
"statusCustom": "Eigene Vorlage aktiv – zuletzt geändert am {date}.",
"statusCustomBy": "Eigene Vorlage aktiv – zuletzt geändert am {date} von {user}.",
"unsaved": "Ungespeicherte Änderungen",
"fields": {
"subject": "Betreff",
"subjectHelp": "Eine Zeile, Pflichtfeld.",
"heading": "Überschrift",
"headingHelp": "Große Zeile oben in der Mail, Pflichtfeld.",
"intro": "Einleitungstext",
"introHelp": "Steht unter der Überschrift. Eine Leerzeile beginnt einen neuen Absatz.",
"closing": "Abschlusstext",
"closingHelp": "Steht am Ende der Mail, z. B. Tipp und Grußzeile. Darf leer bleiben."
},
"counter": "{count} / {max}",
"save": "Speichern",
"saving": "Wird gespeichert …",
"saved": "Die Vorlage wurde gespeichert. Ab jetzt verwenden neue Willkommensmails diese Texte.",
"test": "Testmail an mich senden",
"testSending": "Wird gesendet …",
"testSent": "Die Testmail wurde an {email} gesendet.",
"testHelp": "Die Testmail geht an Ihre eigene E-Mail-Adresse und zeigt den aktuellen Inhalt des Formulars, auch wenn er noch nicht gespeichert ist. Sie enthält keinen echten Link zum Festlegen eines Passworts.",
"reset": "Auf Standard zurücksetzen",
"resetDone": "Die Standardtexte sind wiederhergestellt.",
"resetConfirm": {
"title": "Auf Standard zurücksetzen?",
"body": "Ihre eigene Vorlage wird gelöscht und die Standardtexte gelten wieder. Das lässt sich nicht rückgängig machen.",
"confirm": "Zurücksetzen",
"cancel": "Abbrechen"
},
"errors": {
"load": "Die Vorlage konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
"save": "Die Vorlage konnte nicht gespeichert werden.",
"reset": "Die Vorlage konnte nicht zurückgesetzt werden.",
"test": "Die Testmail konnte nicht gesendet werden.",
"required": "Bitte füllen Sie dieses Feld aus.",
"tooLong": "Höchstens {max} Zeichen.",
"unknownPlaceholder": "Unbekannter Platzhalter {token}",
"unknownPlaceholders": "Unbekannte Platzhalter {tokens}"
},
"placeholders": {
"title": "Platzhalter",
"intro": "Platzhalter setzt Tessera beim Versand durch die Angaben des jeweiligen Benutzers. Sie funktionieren in allen vier Feldern. Ein Klick auf einen Platzhalter fügt ihn an der Cursorposition des zuletzt benutzten Feldes ein.",
"colPlaceholder": "Platzhalter",
"colMeaning": "Bedeutung",
"colExample": "Beispiel",
"insert": "{token} einfügen",
"insertHint": "Klicken zum Einfügen",
"rules": "Andere Platzhalter kennt Tessera nicht – sie werden beim Speichern abgelehnt. Die Texte sind reiner Text: Formatierungen oder HTML werden nicht übernommen, sondern so angezeigt, wie Sie sie eingeben.",
"items": {
"name": {
"meaning": "Anzeigename des Benutzers, sonst sein Benutzername",
"example": "Max Mustermann"
},
"vorname": {
"meaning": "Erstes Wort des Anzeigenamens, sonst der Benutzername",
"example": "Max"
},
"benutzername": {
"meaning": "Benutzername für die Anmeldung",
"example": "max.mustermann"
},
"email": {
"meaning": "E-Mail-Adresse des Benutzers",
"example": "max.mustermann@example.com"
},
"adresse": {
"meaning": "Adresse von Tessera",
"example": "https://tessera.example.com"
},
"firma": {
"meaning": "Name Ihres Mandanten",
"example": "Beispiel GmbH"
}
}
},
"preview": {
"title": "Vorschau",
"accountLabel": "Vorschau für Kontoart",
"directory": "Verzeichniskonto",
"local": "Lokales Konto",
"subject": "Betreff:",
"frameTitle": "Vorschau der Willkommensmail",
"error": "Die Vorschau konnte nicht erstellt werden.",
"blocked": "Die Vorschau wird aktualisiert, sobald alle Felder in Ordnung sind.",
"sampleHint": "Die Vorschau verwendet Beispielwerte (Max Mustermann) und den Namen Ihres Mandanten. Der Link „Passwort festlegen“ ist in der Vorschau nur ein Beispiel."
}
}
},
"adminModules": {
+98 -1
View File
@@ -88,7 +88,8 @@
"customModules": "Custom modules",
"ldap": "LDAP",
"smtp": "SMTP",
"groups": "Groups"
"groups": "Groups",
"welcomeMail": "Welcome email"
},
"role": {
"SUPER_ADMIN": "Super Admin",
@@ -738,6 +739,102 @@
"create": "Add shared entry",
"noModules": "No shared entries yet",
"noModulesBody": "Add your first shared entry to show a website in the sidebar for all users."
},
"welcomeMail": {
"title": "Welcome email",
"description": "Choose the words Tessera uses to greet new users. The template applies to your tenant and is used for every welcome email sent from user management.",
"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."
},
"statusDefault": "The default texts are currently in use.",
"statusCustom": "Custom template active – last changed on {date}.",
"statusCustomBy": "Custom template active – last changed on {date} by {user}.",
"unsaved": "Unsaved changes",
"fields": {
"subject": "Subject",
"subjectHelp": "One line, required.",
"heading": "Heading",
"headingHelp": "Large line at the top of the email, required.",
"intro": "Introduction",
"introHelp": "Shown below the heading. An empty line starts a new paragraph.",
"closing": "Closing text",
"closingHelp": "Shown at the end of the email, e.g. a tip and a sign-off. May be left empty."
},
"counter": "{count} / {max}",
"save": "Save",
"saving": "Saving …",
"saved": "The template has been saved. New welcome emails will use these texts from now on.",
"test": "Send test email to me",
"testSending": "Sending …",
"testSent": "The test email was sent to {email}.",
"testHelp": "The test email goes to your own email address and shows the current form content, even if it has not been saved yet. It does not contain a real link for setting a password.",
"reset": "Reset to default",
"resetDone": "The default texts have been restored.",
"resetConfirm": {
"title": "Reset to default?",
"body": "Your custom template will be deleted and the default texts apply again. This cannot be undone.",
"confirm": "Reset",
"cancel": "Cancel"
},
"errors": {
"load": "The template could not be loaded. Please reload the page.",
"save": "The template could not be saved.",
"reset": "The template could not be reset.",
"test": "The test email could not be sent.",
"required": "Please fill in this field.",
"tooLong": "At most {max} characters.",
"unknownPlaceholder": "Unknown placeholder {token}",
"unknownPlaceholders": "Unknown placeholders {tokens}"
},
"placeholders": {
"title": "Placeholders",
"intro": "Tessera replaces placeholders with the details of each user when sending. They work in all four fields. Clicking a placeholder inserts it at the cursor position of the field you used last.",
"colPlaceholder": "Placeholder",
"colMeaning": "Meaning",
"colExample": "Example",
"insert": "Insert {token}",
"insertHint": "Click to insert",
"rules": "Tessera does not know any other placeholders – they are rejected when saving. The texts are plain text: formatting or HTML is not applied but shown exactly as you type it.",
"items": {
"name": {
"meaning": "Display name of the user, otherwise the username",
"example": "Max Mustermann"
},
"vorname": {
"meaning": "First word of the display name, otherwise the username",
"example": "Max"
},
"benutzername": {
"meaning": "Username for signing in",
"example": "max.mustermann"
},
"email": {
"meaning": "Email address of the user",
"example": "max.mustermann@example.com"
},
"adresse": {
"meaning": "Address of Tessera",
"example": "https://tessera.example.com"
},
"firma": {
"meaning": "Name of your tenant",
"example": "Example Ltd"
}
}
},
"preview": {
"title": "Preview",
"accountLabel": "Preview for account type",
"directory": "Directory account",
"local": "Local account",
"subject": "Subject:",
"frameTitle": "Welcome email preview",
"error": "The preview could not be created.",
"blocked": "The preview updates as soon as all fields are valid.",
"sampleHint": "The preview uses sample values (Max Mustermann) and the name of your tenant. The “Set password” link is only an example in the preview."
}
}
},
"adminModules": {
@@ -197,4 +197,10 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'Arbeitsspeicher',
// quick-260925-bow: „Was ist neu“-Fenster, Gruppe „Verbessert“ — korrektes Deutsch mit „ss“
'Verbessert',
// Administrator → Willkommensmail (eigene Vorlage) — korrektes Deutsch mit „ss“/„ue“/„ae“
'anpassen',
'Abschlusstext',
'aktuellen',
'Passworts',
'tessera',
];