feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s

POST /users/:id/welcome-mail (gleiche Rechte wie Bearbeiten, jederzeit sendbar),
GET /users/welcome-mail/status; HTML-Mail (Tabellenlayout, Inline-Stile,
Kopfbild als CID-PNG aus assets/mail/welcome-header.svg, erzeugt mit
scripts/render-mail-header.mjs) plus Textfassung. Verzeichniskonten: Hinweis
auf Windows-Passwort; lokale Konten: Link Passwort festlegen (7 Tage, einmalig).
Neue Spalte User.welcomeMailSentAt (Migration 20260930120000). Benutzerliste:
Spalte Letzte Anmeldung, Zeilenaktionen als Symbole. Dockerfile kopiert
apps/api/assets. Lokal per MailHog nachgewiesen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 15:28:52 +02:00
parent 0b34e82b21
commit 32441d77c7
24 changed files with 1540 additions and 115 deletions
@@ -18,6 +18,8 @@ const messages: Record<string, Record<string, unknown>> = {
displayName: 'Anzeigename',
role: 'Rolle',
status: 'Status',
lastLogin: 'Letzte Anmeldung',
neverLoggedIn: 'Noch nie',
actions: 'Aktionen',
password: 'Passwort',
noUsers: 'Keine Benutzer gefunden',
@@ -31,6 +33,20 @@ const messages: Record<string, Record<string, unknown>> = {
loadFailed: 'Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
},
},
'admin.users.welcomeMail': {
button: 'Willkommensmail senden',
resend: 'Erneut senden',
sentAt: 'Willkommensmail gesendet am {date}',
noEmail: 'Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.',
noSmtp: 'Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).',
inactive: 'Das Konto ist deaktiviert.',
dialogTitle: 'Willkommensmail senden',
dialogText: 'Tessera schickt eine Willkommensmail an:',
dialogResend: 'Eine Willkommensmail wurde bereits am {date} gesendet. Sie wird erneut verschickt.',
send: 'Senden',
sending: 'Wird gesendet …',
success: 'Die Willkommensmail an {email} wurde gesendet.',
},
header: {
role: {
SUPER_ADMIN: 'Super-Admin',
@@ -47,6 +63,7 @@ const messages: Record<string, Record<string, unknown>> = {
accessDenied: 'Zugriff verweigert',
active: 'Aktiv',
inactive: 'Inaktiv',
close: 'Schließen',
},
};
@@ -69,6 +86,7 @@ function resolve(ns: string, key: string, params?: Record<string, unknown>): str
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) =>
resolve(ns, key, params),
useLocale: () => 'de',
}));
const mockAuthStore = vi.fn();
@@ -93,6 +111,8 @@ interface User {
isActive: boolean;
tenantId: string;
createdAt: string;
lastLoginAt?: string | null;
welcomeMailSentAt?: string | null;
}
const mockUsers: User[] = [
@@ -524,13 +544,13 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
});
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
expect(within(superAdminRow).getByText('Details')).toBeInTheDocument();
expect(within(superAdminRow).getByRole('button', { name: 'Details' })).toBeInTheDocument();
expect(within(superAdminRow).queryByText('Bearbeiten')).not.toBeInTheDocument();
expect(within(superAdminRow).queryByText('Löschen')).not.toBeInTheDocument();
const normalRow = screen.getByText('user.zwei').closest('tr') as HTMLElement;
expect(within(normalRow).getByText('Bearbeiten')).toBeInTheDocument();
expect(within(normalRow).getByText('Löschen')).toBeInTheDocument();
expect(within(normalRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
expect(within(normalRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
});
it('bietet einem SUPER_ADMIN in der SUPER_ADMIN-Zeile beide Aktionsknoepfe an', async () => {
@@ -544,8 +564,8 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
});
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
expect(within(superAdminRow).getByText('Bearbeiten')).toBeInTheDocument();
expect(within(superAdminRow).getByText('Löschen')).toBeInTheDocument();
expect(within(superAdminRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
expect(within(superAdminRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
});
it('haelt die bestehende Sperre gegen Selbstloeschung unveraendert (ADMIN u1)', async () => {
@@ -559,8 +579,165 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
});
const ownRow = screen.getByText('admin.eins').closest('tr') as HTMLElement;
const ownDeleteButton = within(ownRow).getByText('Löschen');
const ownDeleteButton = within(ownRow).getByRole('button', { name: 'Löschen' });
expect(ownDeleteButton).toBeInTheDocument();
expect(ownDeleteButton).toBeDisabled();
});
});
describe('AdminUsersPage — Willkommensmail', () => {
const base = {
role: 'USER' as const,
isActive: true,
tenantId: 't1',
createdAt: '2026-01-01T00:00:00.000Z',
welcomeMailSentAt: null,
};
const welcomeUsers: User[] = [
{ ...base, id: 'u1', username: 'admin.eins', email: 'admin.eins@ctl.de', displayName: 'Admin Eins', role: 'ADMIN', lastLoginAt: '2026-09-01T08:00:00.000Z' },
{ ...base, id: 'u2', username: 'neu.zwei', email: 'neu.zwei@ctl.de', displayName: 'Neu Zwei', lastLoginAt: null },
{ ...base, id: 'u3', username: 'ohne.adresse', email: null, displayName: null, lastLoginAt: null },
{ ...base, id: 'u4', username: 'schon.da', email: 'schon.da@ctl.de', displayName: null, lastLoginAt: '2026-09-02T08:00:00.000Z' },
{ ...base, id: 'u5', username: 'super.fuenf', email: 'super@ctl.de', displayName: null, role: 'SUPER_ADMIN', lastLoginAt: '2026-09-02T08:00:00.000Z' },
];
function stubFetch(opts: { available?: boolean; post?: () => Promise<unknown> } = {}) {
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
if (url.endsWith('/users/welcome-mail/status')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ available: opts.available ?? true }) });
}
if (url.endsWith('/users') && (!init || init.method === undefined)) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(welcomeUsers) });
}
if (url.endsWith('/welcome-mail') && init?.method === 'POST') {
return (opts.post ?? (() => Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })))();
}
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
it('Knopf bei jedem verwaltbaren Benutzer, auch nach einer Anmeldung; SUPER_ADMIN-Konto fuer einen ADMIN nie; ohne Adresse deaktiviert', async () => {
stubAdmin();
stubFetch();
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
const rowAdmin = screen.getByText('admin.eins').closest('tr') as HTMLElement;
expect(within(rowAdmin).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
const rowDa = screen.getByText('schon.da').closest('tr') as HTMLElement;
expect(within(rowDa).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
// SUPER_ADMIN-Konto: fuer einen ADMIN gesperrt wie beim Bearbeiten
const rowSuper = screen.getByText('super.fuenf').closest('tr') as HTMLElement;
expect(within(rowSuper).queryByText('Willkommensmail senden')).not.toBeInTheDocument();
const rowOhne = screen.getByText('ohne.adresse').closest('tr') as HTMLElement;
const btn = within(rowOhne).getByRole('button', { name: 'Willkommensmail senden' });
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute('title', 'Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.');
});
it('Spalte "Letzte Anmeldung": Datum/Uhrzeit lokal formatiert, sonst "Noch nie"', async () => {
stubAdmin();
stubFetch();
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
expect(screen.getByText('Letzte Anmeldung')).toBeInTheDocument();
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
expect(within(rowNeu).getByText('Noch nie')).toBeInTheDocument();
const expected = new Intl.DateTimeFormat('de', { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date('2026-09-01T08:00:00.000Z'),
);
const rowAdmin = screen.getByText('admin.eins').closest('tr') as HTMLElement;
expect(within(rowAdmin).getByText(expected)).toBeInTheDocument();
});
it('ohne eingerichtetes SMTP ist der Knopf deaktiviert und nennt den Grund', async () => {
stubAdmin();
stubFetch({ available: false });
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
await waitFor(() => expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toBeDisabled());
expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toHaveAttribute(
'title',
'Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).',
);
});
it('Dialog nennt den Empfaenger; Senden ruft POST /users/:id/welcome-mail und zeigt die Erfolgsmeldung', async () => {
stubAdmin();
const fetchMock = stubFetch();
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
await userEvent.click(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText(/neu\.zwei@ctl\.de/)).toBeInTheDocument();
await userEvent.click(within(dialog).getByText('Senden'));
await waitFor(() => {
expect(screen.getByText('Die Willkommensmail an neu.zwei@ctl.de wurde gesendet.')).toBeInTheDocument();
});
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'POST');
expect(post?.[0]).toMatch(/\/users\/u2\/welcome-mail$/);
});
it('bei einer Ablehnung (409) bleibt der Dialog offen und zeigt den Servertext', async () => {
stubAdmin();
stubFetch({
post: () =>
Promise.resolve({
ok: false,
status: 409,
json: () => Promise.resolve({ message: 'Dieser Benutzer hat sich bereits angemeldet.' }),
}),
});
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
await userEvent.click(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' }));
const dialog = await screen.findByRole('dialog');
await userEvent.click(within(dialog).getByText('Senden'));
await waitFor(() => {
expect(
screen.getByText('Der Server hat die Aktion abgelehnt: Dieser Benutzer hat sich bereits angemeldet.'),
).toBeInTheDocument();
});
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('nach einem Versand heisst der Knopf "Erneut senden" und die Zeile nennt das Datum', async () => {
stubAdmin();
welcomeUsers[1] = { ...welcomeUsers[1], welcomeMailSentAt: '2026-09-29T10:15:00.000Z' };
stubFetch();
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
expect(within(rowNeu).getByRole('button', { name: 'Erneut senden' })).toBeInTheDocument();
expect(within(rowNeu).getByText(/Willkommensmail gesendet am/)).toBeInTheDocument();
welcomeUsers[1] = { ...welcomeUsers[1], welcomeMailSentAt: null };
});
it('SUPER_ADMIN sieht den Knopf auch bei einem angemeldeten SUPER_ADMIN-Konto', async () => {
stubAdmin('u9', 'SUPER_ADMIN');
stubFetch();
render(<AdminUsersPage />);
await waitFor(() => expect(screen.getByText('super.fuenf')).toBeInTheDocument());
const rowSuper = screen.getByText('super.fuenf').closest('tr') as HTMLElement;
expect(within(rowSuper).getByRole('button', { name: 'Willkommensmail senden' })).toBeInTheDocument();
});
});