feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
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:
@@ -47,6 +47,9 @@ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_
|
||||
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
|
||||
COPY --from=builder /app/packages/shared/src ./packages/shared/src
|
||||
COPY apps/api/scripts ./apps/api/scripts
|
||||
# Kopfbild der Willkommensmail (MailService.loadWelcomeHeaderPng liest
|
||||
# apps/api/assets/mail/welcome-header.png relativ zu dist/mail/).
|
||||
COPY apps/api/assets ./apps/api/assets
|
||||
# Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete +
|
||||
# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur
|
||||
# lesend zur Laufzeit -- kein chown noetig.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="300" viewBox="0 0 1200 300">
|
||||
<!--
|
||||
Kopfbild der Tessera-Systemmails (Willkommensmail). Quelle des PNG
|
||||
daneben (welcome-header.png, 1200x300, angezeigt 600x150); erzeugt mit
|
||||
`node apps/api/scripts/render-mail-header.mjs`.
|
||||
|
||||
Motiv: der Dashboard-Hintergrund "Duenen" in seiner dunklen Fassung
|
||||
(apps/web/src/lib/dashboard-background.ts, dunes('#1a1c20', '#23262b',
|
||||
'#2c3036', '#363a41')), auf das flache Mail-Format 4:1 umgelegt, mit
|
||||
einer feinen gelben Wellenlinie als Akzent; links die Bildmarke
|
||||
(apps/web/src/app/icon.svg, Grundplatte mit heller Kontur wie auf der
|
||||
dunklen App-Leiste) und der Schriftzug.
|
||||
-->
|
||||
<rect width="1200" height="300" fill="#1a1c20"/>
|
||||
<path d="M0 176 C230 136 470 222 700 190 S1060 118 1200 160 L1200 300 L0 300Z" fill="#23262b"/>
|
||||
<path d="M0 176 C230 136 470 222 700 190 S1060 118 1200 160" fill="none" stroke="#ffed00" stroke-opacity="0.55" stroke-width="3"/>
|
||||
<path d="M0 230 C270 196 500 266 780 232 S1090 190 1200 214 L1200 300 L0 300Z" fill="#2c3036"/>
|
||||
<path d="M0 272 C300 250 600 296 900 268 S1130 252 1200 260 L1200 300 L0 300Z" fill="#363a41"/>
|
||||
<g transform="translate(60 40) scale(1.5)">
|
||||
<rect x="4" y="4" width="64" height="64" rx="14" fill="#1a1a1a" stroke="#ffffff" stroke-opacity="0.22" stroke-width="2"/>
|
||||
<rect x="16" y="16" width="12" height="12" rx="2.5" fill="#9c9440"/>
|
||||
<rect x="30" y="16" width="12" height="12" rx="2.5" fill="#9c9440"/>
|
||||
<rect x="45" y="15" width="12" height="12" rx="2.5" transform="rotate(12 51 21)" fill="#ffed00"/>
|
||||
<rect x="30" y="30" width="12" height="12" rx="2.5" fill="#9c9440"/>
|
||||
<rect x="30" y="44" width="12" height="12" rx="2.5" fill="#9c9440"/>
|
||||
</g>
|
||||
<text x="192" y="118" fill="#ffffff" font-family="'Segoe UI', Inter, 'Noto Sans', 'DejaVu Sans', sans-serif" font-size="60" font-weight="700" letter-spacing="-1">Tessera</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,15 @@
|
||||
-- Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer).
|
||||
--
|
||||
-- Merkt pro Benutzer, wann zuletzt eine Willkommensmail verschickt wurde,
|
||||
-- damit die Liste "Willkommensmail gesendet am …" zeigen kann. Gesetzt nur
|
||||
-- ueber POST /users/:id/welcome-mail nach erfolgreichem Versand; erneutes
|
||||
-- Senden ueberschreibt den Wert. NULL = nie gesendet. Kein Standardwert,
|
||||
-- kein Backfill.
|
||||
--
|
||||
-- Keine neue Regel noetig: die Spalte liegt in "User", dessen
|
||||
-- tenant_isolation_policy die ganze Zeile schuetzt. Die Anmelde-Funktionen
|
||||
-- auth_lookup_* liefern eine feste Spaltenliste (RETURNS TABLE) und bleiben
|
||||
-- unberuehrt.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "welcomeMailSentAt" TIMESTAMP(3);
|
||||
@@ -50,6 +50,9 @@ model User {
|
||||
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
|
||||
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
|
||||
dashboardBackground Json?
|
||||
// Willkommensmail aus der Benutzerverwaltung: Zeitpunkt des letzten
|
||||
// Versands; null = nie gesendet
|
||||
welcomeMailSentAt DateTime?
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
groupMemberships GroupMembership[]
|
||||
moduleGrants ModuleGrant[]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
||||
* (apps/api/assets/mail/welcome-header.png, 1200x300 fuer hochaufloesende
|
||||
* Bildschirme, in der Mail 600x150 angezeigt) aus der daneben liegenden
|
||||
* Quelle welcome-header.svg.
|
||||
*
|
||||
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
||||
* Darstellung) und viele Webmailer zeigen weder SVG noch Hintergrundbilder
|
||||
* zuverlaessig an. Das PNG wird als CID-Anhang eingebettet (MailService),
|
||||
* die Mail laedt also nichts von aussen nach.
|
||||
*
|
||||
* Das PNG liegt fertig im Repo; dieses Skript ist nur noetig, wenn die SVG
|
||||
* geaendert wird. Kein neues Paket: `sharp` ist ueber Next.js (apps/web)
|
||||
* bereits installiert und wird von dort aufgeloest. Der Schriftzug wird
|
||||
* mit den Systemschriften des erzeugenden Rechners gesetzt (fontconfig:
|
||||
* Segoe UI, Inter, Noto Sans, DejaVu Sans — die erste vorhandene gewinnt).
|
||||
*
|
||||
* Aufruf (Repo-Wurzel): node apps/api/scripts/render-mail-header.mjs
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const assetDir = join(here, '..', 'assets', 'mail');
|
||||
const webDir = join(here, '..', '..', 'web');
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const nextPkg = require.resolve('next/package.json', { paths: [webDir] });
|
||||
const sharp = createRequire(nextPkg)('sharp');
|
||||
|
||||
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
||||
const png = await sharp(svg, { density: 72 })
|
||||
.resize(1200, 300)
|
||||
.png({ compressionLevel: 9, palette: false })
|
||||
.toBuffer();
|
||||
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
||||
console.log(`welcome-header.png geschrieben (${png.length} Bytes)`);
|
||||
@@ -17,6 +17,7 @@ import { LdapService } from '../ldap/ldap.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PASSWORD_RESET_TOKEN_TTL_MS } from './password-reset-token';
|
||||
import type { JwtPayload, LoginUser } from './types/auth-user';
|
||||
|
||||
/**
|
||||
@@ -239,7 +240,7 @@ export class AuthService {
|
||||
|
||||
// Generate a unique reset token
|
||||
const token = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_TTL_MS); // 1 hour
|
||||
|
||||
// Create the reset token record — mandantengebunden, sobald der
|
||||
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Gueltigkeit eines Kennwort-Tokens (`PasswordResetToken`, T-02-13): eine
|
||||
* Stunde, einmal verwendbar. Gemeinsam genutzt vom Weg "Passwort
|
||||
* vergessen" (`AuthService.requestPasswordReset`) und vom Link "Passwort
|
||||
* festlegen" der Willkommensmail (`WelcomeMailService`) — beide legen
|
||||
* dieselbe Art Token an und fuehren auf dieselbe Seite
|
||||
* `/reset-password/<token>`, deshalb gilt dieselbe Frist.
|
||||
*/
|
||||
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Gueltigkeit des Links "Passwort festlegen" in der Willkommensmail: 7 Tage.
|
||||
* Neue Mitarbeiter lesen die Mail oft erst Tage spaeter; eine Stunde wie bei
|
||||
* "Passwort vergessen" (dort fordert der Benutzer den Link selbst an und
|
||||
* nutzt ihn sofort) waere hier fast immer abgelaufen. Einmal verwendbar
|
||||
* bleibt der Link trotzdem, und jede neue Willkommensmail legt einen neuen an.
|
||||
*/
|
||||
export const WELCOME_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { MailService } from './mail.service';
|
||||
import { MailService, WELCOME_HEADER_CID } from './mail.service';
|
||||
|
||||
/**
|
||||
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
|
||||
@@ -166,7 +166,11 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
|
||||
const service = new MailService(settings as any, makeFakeConfig({}) as any);
|
||||
|
||||
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
|
||||
await service.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2');
|
||||
await service.sendWelcomeMail('t2', 'bob@b.example.invalid', {
|
||||
subject: 'Willkommen bei Tessera',
|
||||
text: 'Guten Tag bob,',
|
||||
html: '<p>bob</p>',
|
||||
});
|
||||
|
||||
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
|
||||
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
|
||||
@@ -314,3 +318,47 @@ describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)'
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MailService.sendWelcomeMail / hasConfiguredTransport (Willkommensmail)', () => {
|
||||
it('haengt das Kopfbild als CID-Anhang an, reicht HTML und Text durch und wirft Transportfehler nach aussen', async () => {
|
||||
const service = new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
|
||||
|
||||
await service.sendWelcomeMail('t1', 'neu@a.example.invalid', {
|
||||
subject: 'Willkommen bei Tessera',
|
||||
text: 'Text',
|
||||
html: `<img src="cid:${WELCOME_HEADER_CID}">`,
|
||||
});
|
||||
|
||||
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||
expect(sent.from).toBe('noreply@a.example.invalid');
|
||||
expect(sent.html).toContain(`cid:${WELCOME_HEADER_CID}`);
|
||||
expect(sent.text).toBe('Text');
|
||||
expect(sent.attachments).toHaveLength(1);
|
||||
expect(sent.attachments[0].cid).toBe(WELCOME_HEADER_CID);
|
||||
expect(sent.attachments[0].contentType).toBe('image/png');
|
||||
// PNG-Signatur: das Bild aus apps/api/assets/mail ist wirklich geladen.
|
||||
expect((sent.attachments[0].content as Buffer).subarray(1, 4).toString()).toBe('PNG');
|
||||
|
||||
mockSendMail = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
await expect(
|
||||
service.sendWelcomeMail('t1', 'neu@a.example.invalid', { subject: 'S', text: 'T', html: 'H' }),
|
||||
).rejects.toThrow('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('hasConfiguredTransport: SmtpConfig des Mandanten oder MAIL_HOST/TESSERA_SMTP_HOST — der Rueckfall localhost:1025 zaehlt nicht', async () => {
|
||||
expect(
|
||||
await new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ MAIL_HOST: 'smtp.example.invalid' }) as any).hasConfiguredTransport('t1'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ TESSERA_SMTP_HOST: 'legacy.example.invalid' }) as any).hasConfiguredTransport('t1'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
@@ -45,15 +47,43 @@ import { SettingsService } from '../settings/settings.service';
|
||||
* Seit quick-260914-m97 (Fehler-melden-Knopf) ist der Versandkern
|
||||
* `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt
|
||||
* Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer
|
||||
* Kennwort-Reset und Willkommensmail (T-02-12 unveraendert); `sendBugReport`
|
||||
* ruft den Kern direkt, damit der Anwender erfaehrt, ob sein Bericht ankam.
|
||||
* den Kennwort-Reset (T-02-12 unveraendert); `sendBugReport` und
|
||||
* `sendWelcomeMail` rufen den Kern direkt, damit der Ausloesende erfaehrt,
|
||||
* ob die Mail ging.
|
||||
*/
|
||||
|
||||
/** Inhaltskennung des Kopfbilds der Willkommensmail (`<img src="cid:...">`). */
|
||||
export const WELCOME_HEADER_CID = 'welcome-header@tessera';
|
||||
|
||||
let welcomeHeaderCache: Buffer | null | undefined;
|
||||
|
||||
/**
|
||||
* Laedt das Kopfbild der Willkommensmail einmal je Prozess
|
||||
* (apps/api/assets/mail/welcome-header.png, erzeugt von
|
||||
* scripts/render-mail-header.mjs). Zur Laufzeit liegt diese Datei unter
|
||||
* dist/mail/, im Test unter src/mail/ — beide Male zwei Ebenen unter
|
||||
* apps/api. Fehlt das Bild, liefert die Funktion `null`; die Mail zeigt
|
||||
* dann einen dunklen Textkopf statt abzubrechen.
|
||||
*/
|
||||
export function loadWelcomeHeaderPng(): Buffer | null {
|
||||
if (welcomeHeaderCache !== undefined) return welcomeHeaderCache;
|
||||
const file = path.resolve(__dirname, '..', '..', 'assets', 'mail', 'welcome-header.png');
|
||||
try {
|
||||
welcomeHeaderCache = fs.readFileSync(file);
|
||||
} catch {
|
||||
new Logger('MailService').warn(`Welcome header image missing: ${file}`);
|
||||
welcomeHeaderCache = null;
|
||||
}
|
||||
return welcomeHeaderCache;
|
||||
}
|
||||
|
||||
/** Anhang in der nodemailer-Form (`attachments` von `sendMail`). */
|
||||
export interface OutgoingAttachment {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
/** Inhaltskennung fuer eingebettete Bilder (`<img src="cid:...">`). */
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
/** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */
|
||||
@@ -186,10 +216,10 @@ export class MailService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschluckender Mantel um `deliver` fuer Kennwort-Reset und
|
||||
* Willkommensmail: Fehler werden protokolliert, nie geworfen — der
|
||||
* Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration (T-02-12
|
||||
* bleibt fuer genau diese beiden Wege bestehen).
|
||||
* Verschluckender Mantel um `deliver` fuer den Kennwort-Reset: Fehler
|
||||
* werden protokolliert, nie geworfen — der Anmeldeweg antwortet weiter
|
||||
* 200, keine E-Mail-Enumeration (T-02-12). Die Willkommensmail benutzt
|
||||
* ihn seit dem Versand aus der Benutzerverwaltung nicht mehr.
|
||||
*/
|
||||
private async sendViaTenantTransport(
|
||||
tenantId: string,
|
||||
@@ -275,46 +305,59 @@ export class MailService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a welcome email to a newly created user (optional — derzeit ohne
|
||||
* Aufrufer, gemessen 260914-eym; bleibt als Pfad ueber denselben
|
||||
* Transport je Versand erhalten).
|
||||
* Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer,
|
||||
* "Willkommensmail senden"). Inhalt und HTML baut
|
||||
* `renderWelcomeMail` (welcome-mail.template.ts), die Entscheidung ueber
|
||||
* den Anmeldehinweis trifft `WelcomeMailService`. Diese Methode haengt
|
||||
* nur das Kopfbild als CID-Anhang an (`WELCOME_HEADER_CID`, kein
|
||||
* Nachladen von aussen) und versendet ueber den Transport des Mandanten
|
||||
* des EMPFAENGERS.
|
||||
*
|
||||
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
|
||||
* Fehler gehen BEWUSST nach aussen (wie `sendBugReport`): ein
|
||||
* Administrator loest den Versand gezielt aus und muss erfahren, ob die
|
||||
* Mail ging — es gibt hier keinen Anmeldeweg, den eine Fehlermeldung
|
||||
* verraten koennte (T-02-12 betrifft nur Kennwort-Reset).
|
||||
*/
|
||||
async sendWelcomeEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
async sendWelcomeMail(
|
||||
tenantId: string,
|
||||
locale: string = 'de',
|
||||
to: string,
|
||||
mail: { subject: string; text: string; html: string },
|
||||
): Promise<void> {
|
||||
const isGerman = locale === 'de';
|
||||
const subject = isGerman
|
||||
? 'Willkommen bei Tessera'
|
||||
: 'Welcome to Tessera';
|
||||
const header = loadWelcomeHeaderPng();
|
||||
await this.deliver(
|
||||
tenantId,
|
||||
{
|
||||
to,
|
||||
...mail,
|
||||
attachments: header
|
||||
? [
|
||||
{
|
||||
filename: 'tessera.png',
|
||||
content: header,
|
||||
contentType: 'image/png',
|
||||
cid: WELCOME_HEADER_CID,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
'Welcome',
|
||||
);
|
||||
}
|
||||
|
||||
const text = isGerman
|
||||
? [
|
||||
`Hallo ${username},`,
|
||||
'',
|
||||
'Ihr Tessera-Account wurde erstellt.',
|
||||
'',
|
||||
`Sie können sich unter ${this.appUrl}/login anmelden.`,
|
||||
'',
|
||||
'Mit freundlichen Grüßen,',
|
||||
'Ihr Tessera-Team',
|
||||
].join('\n')
|
||||
: [
|
||||
`Hello ${username},`,
|
||||
'',
|
||||
'Your Tessera account has been created.',
|
||||
'',
|
||||
`You can sign in at ${this.appUrl}/login.`,
|
||||
'',
|
||||
'Best regards,',
|
||||
'The Tessera Team',
|
||||
].join('\n');
|
||||
|
||||
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome');
|
||||
/**
|
||||
* Gibt an, ob fuer den Mandanten ein Versandweg eingerichtet ist: eine
|
||||
* eigene SmtpConfig ODER ein per Umgebung gesetzter Server (MAIL_HOST /
|
||||
* TESSERA_SMTP_HOST). Der letzte Rueckfall `localhost:1025` (Mailhog in
|
||||
* der Entwicklung) zaehlt NICHT — sonst saehe die Oberflaeche einen
|
||||
* Versandweg, der im Betrieb ins Leere geht.
|
||||
*/
|
||||
async hasConfiguredTransport(tenantId: string): Promise<boolean> {
|
||||
const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId);
|
||||
if (smtpConfig) return true;
|
||||
const envHost =
|
||||
this.configService.get<string>('MAIL_HOST') ??
|
||||
this.configService.get<string>('TESSERA_SMTP_HOST');
|
||||
return typeof envHost === 'string' && envHost.trim() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* welcome-mail.template.ts — Inhalt und Gestaltung der Willkommensmail.
|
||||
*
|
||||
* Reine Funktion ohne Abhaengigkeiten: bekommt die fertigen Werte
|
||||
* (Anzeigename, Benutzername, Adresse, Anmeldeweg) und liefert Betreff,
|
||||
* Text-Alternative und HTML. Versand und Kopfbild-Anhang erledigt
|
||||
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
|
||||
* Anmeldehinweis" `WelcomeMailService`.
|
||||
*
|
||||
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
|
||||
* Mail sehr unterschiedlich darstellen:
|
||||
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
|
||||
* - keine externen Ressourcen, keine Web-Fonts (Systemschriften);
|
||||
* - der Kopf im Stil des Dashboard-Hintergrunds "Duenen" ist ein PNG
|
||||
* (`headerImageSrc`, im Versand `cid:`), weil SVG und CSS-Hintergruende in
|
||||
* Outlook nicht erscheinen; fehlt das Bild, steht dort ein dunkler
|
||||
* Textkopf;
|
||||
* - Knoepfe als Tabelle mit Hintergrundfarbe in der Zelle ("bulletproof"),
|
||||
* Outlook ignoriert Innenabstaende und Rundungen am Link selbst.
|
||||
*
|
||||
* Sicherheit: jeder eingesetzte Wert laeuft durch `escapeHtml`; Links werden
|
||||
* nur als http(s) uebernommen. Ein Kennwort steht NIE in der Mail — lokale
|
||||
* Konten bekommen einen Link zum Festlegen (Token wie beim
|
||||
* "Passwort vergessen"-Weg), verzeichnisgefuehrte den Hinweis auf das
|
||||
* Windows-Passwort.
|
||||
*/
|
||||
|
||||
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
|
||||
const C = {
|
||||
page: '#eceef1',
|
||||
card: '#ffffff',
|
||||
ink: '#1a1d21',
|
||||
body: '#3d4450',
|
||||
muted: '#6b7280',
|
||||
line: '#e3e5e9',
|
||||
well: '#f7f7f5',
|
||||
yellow: '#ffed00',
|
||||
link: '#1d5fc2',
|
||||
header: '#1a1c20',
|
||||
} as const;
|
||||
|
||||
const FONT =
|
||||
"'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', Arial, sans-serif";
|
||||
|
||||
/** Anmeldeweg des Empfaengers — entscheidet den Hinweis in der Mail. */
|
||||
export type WelcomeMailAccount =
|
||||
| { kind: 'directory' }
|
||||
| { kind: 'local'; setPasswordUrl: string; validHours: number }
|
||||
| { kind: 'local-no-link' };
|
||||
|
||||
export interface WelcomeMailInput {
|
||||
/** Anzeigename, sonst Benutzername. */
|
||||
name: string;
|
||||
username: string;
|
||||
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
|
||||
appUrl: string;
|
||||
account: WelcomeMailAccount;
|
||||
/** `cid:...` im Versand, relativer Pfad in der Vorschau, `null` = Textkopf. */
|
||||
headerImageSrc: string | null;
|
||||
}
|
||||
|
||||
export interface RenderedWelcomeMail {
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export const WELCOME_MAIL_SUBJECT = 'Willkommen bei Tessera';
|
||||
|
||||
const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.';
|
||||
const INTRO =
|
||||
'für Sie wurde ein Zugang zu Tessera eingerichtet – Ihrer zentralen Plattform für Werkzeuge und Abläufe im Unternehmen. Alles, was Sie für Ihre tägliche Arbeit brauchen, finden Sie dort an einem Ort.';
|
||||
const DESKTOP_HINT =
|
||||
'Tessera gibt es auch als Desktop-App – den Download finden Sie auf der Anmeldeseite.';
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Nur http(s)-Adressen gelangen in ein href; alles andere wird leer. */
|
||||
function safeUrl(value: string): string {
|
||||
return /^https?:\/\//i.test(value) ? value : '';
|
||||
}
|
||||
|
||||
function loginHintText(account: WelcomeMailAccount): string {
|
||||
switch (account.kind) {
|
||||
case 'directory':
|
||||
return 'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.';
|
||||
case 'local':
|
||||
return 'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.';
|
||||
case 'local-no-link':
|
||||
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
|
||||
}
|
||||
}
|
||||
|
||||
function validityText(hours: number): string {
|
||||
const span =
|
||||
hours % 24 === 0 && hours >= 24
|
||||
? hours === 24
|
||||
? '1 Tag'
|
||||
: `${hours / 24} Tage`
|
||||
: hours === 1
|
||||
? '1 Stunde'
|
||||
: `${hours} Stunden`;
|
||||
return `Der Link ist ${span} gültig und nur einmal verwendbar. Ist er abgelaufen, fordern Sie auf der Anmeldeseite über „Passwort vergessen?“ einfach einen neuen an.`;
|
||||
}
|
||||
|
||||
/** Knopf als Tabelle: Farbe an der Zelle, damit Outlook ihn als Flaeche zeigt. */
|
||||
function button(href: string, label: string, bg: string, fg: string): string {
|
||||
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
|
||||
<tr><td align="center" bgcolor="${bg}" style="background-color:${bg};border-radius:6px;mso-padding-alt:14px 30px;">
|
||||
<a href="${escapeHtml(href)}" target="_blank" style="display:inline-block;padding:14px 30px;font-family:${FONT};font-size:16px;line-height:20px;font-weight:600;color:${fg};text-decoration:none;border-radius:6px;">${escapeHtml(label)}</a>
|
||||
</td></tr></table>`;
|
||||
}
|
||||
|
||||
function headerRow(src: string | null): string {
|
||||
if (src) {
|
||||
return `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;line-height:0;font-size:0;">
|
||||
<img src="${escapeHtml(src)}" width="600" height="150" alt="Tessera" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;border-radius:12px 12px 0 0;color:#ffffff;font-family:${FONT};font-size:28px;font-weight:700;">
|
||||
</td></tr>`;
|
||||
}
|
||||
return `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:40px 40px 36px;font-family:${FONT};font-size:30px;line-height:36px;font-weight:700;color:#ffffff;border-bottom:3px solid ${C.yellow};">Tessera</td></tr>`;
|
||||
}
|
||||
|
||||
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
|
||||
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
|
||||
const loginUrl = `${base}/login`;
|
||||
const hint = loginHintText(input.account);
|
||||
|
||||
// ── Text-Alternative ───────────────────────────────────────────────────
|
||||
const textLines = [
|
||||
`Guten Tag ${input.name},`,
|
||||
'',
|
||||
INTRO,
|
||||
'',
|
||||
'Ihre Zugangsdaten',
|
||||
`Adresse: ${base}`,
|
||||
`Benutzername: ${input.username}`,
|
||||
'',
|
||||
hint,
|
||||
];
|
||||
if (input.account.kind === 'local') {
|
||||
textLines.push(
|
||||
'',
|
||||
'Passwort festlegen:',
|
||||
input.account.setPasswordUrl,
|
||||
validityText(input.account.validHours),
|
||||
);
|
||||
}
|
||||
textLines.push(
|
||||
'',
|
||||
'Zu Tessera:',
|
||||
loginUrl,
|
||||
'',
|
||||
DESKTOP_HINT,
|
||||
'',
|
||||
'Viel Erfolg mit Tessera!',
|
||||
'',
|
||||
'--',
|
||||
FOOTER,
|
||||
);
|
||||
const text = textLines.join('\n');
|
||||
|
||||
// ── HTML ───────────────────────────────────────────────────────────────
|
||||
const name = escapeHtml(input.name);
|
||||
const username = escapeHtml(input.username);
|
||||
const baseHtml = escapeHtml(base);
|
||||
|
||||
const p = (content: string, extra = '') =>
|
||||
`<p style="margin:0 0 16px;font-family:${FONT};font-size:16px;line-height:25px;color:${C.body};${extra}">${content}</p>`;
|
||||
|
||||
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;');
|
||||
if (input.account.kind === 'local') {
|
||||
const setUrl = safeUrl(input.account.setPasswordUrl);
|
||||
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
||||
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(validityText(input.account.validHours))}</p>`;
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>${escapeHtml(WELCOME_MAIL_SUBJECT)}</title>
|
||||
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
||||
<style>
|
||||
a { color: ${C.link}; }
|
||||
@media only screen and (max-width: 620px) {
|
||||
.tsr-pad { padding-left: 24px !important; padding-right: 24px !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:${C.page};-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${C.page};">Ihr Zugang zu Tessera ist eingerichtet – hier finden Sie Adresse und Benutzername.</div>
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="${C.page}" style="background-color:${C.page};">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<!--[if mso]><table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;border-collapse:separate;">
|
||||
${headerRow(input.headerImageSrc)}
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:36px 40px 12px;">
|
||||
<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">Willkommen bei Tessera, ${name}!</h1>
|
||||
${p(escapeHtml(INTRO.charAt(0).toUpperCase() + INTRO.slice(1)))}
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;margin:8px 0 0;">
|
||||
<tr><td bgcolor="${C.well}" style="background-color:${C.well};border:1px solid ${C.line};border-left:4px solid ${C.yellow};border-radius:8px;padding:18px 22px;">
|
||||
${label('Adresse')}
|
||||
<div style="margin:4px 0 14px;font-family:${FONT};font-size:16px;line-height:24px;font-weight:600;"><a href="${escapeHtml(loginUrl)}" target="_blank" style="color:${C.link};text-decoration:none;">${baseHtml}</a></div>
|
||||
${label('Benutzername')}
|
||||
<div style="margin:4px 0 0;font-family:Consolas,'SF Mono',Menlo,'Courier New',monospace;font-size:16px;line-height:24px;font-weight:600;color:${C.ink};">${username}</div>
|
||||
</td></tr></table>
|
||||
${accountBlock}
|
||||
</td></tr>
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" align="left" style="background-color:${C.card};padding:20px 40px 8px;">
|
||||
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
|
||||
</td></tr>
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:20px 40px 36px;border-radius:0 0 12px 12px;">
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
|
||||
${p(`<strong style="color:${C.ink};">Tipp:</strong> ${escapeHtml(DESKTOP_HINT)}`, `font-size:14px;line-height:22px;margin:0 0 12px;`)}
|
||||
${p('Viel Erfolg mit Tessera!', 'font-size:14px;line-height:22px;margin:0;')}
|
||||
</td></tr></table>
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding:20px 24px 0;font-family:${FONT};font-size:12px;line-height:18px;color:${C.muted};">${escapeHtml(FOOTER)}</td></tr>
|
||||
</table>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject: WELCOME_MAIL_SUBJECT, text, html };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
@@ -124,11 +124,13 @@ describe('UserController', () => {
|
||||
let prisma: any;
|
||||
let userService: any;
|
||||
let controller: UserController;
|
||||
let welcomeMail: { isAvailable: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = makeFakePrisma();
|
||||
userService = makeUserServiceMock();
|
||||
controller = new UserController(userService as any, prisma);
|
||||
welcomeMail = { isAvailable: vi.fn(), send: vi.fn() };
|
||||
controller = new UserController(userService as any, prisma, welcomeMail as any);
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
@@ -663,4 +665,80 @@ describe('UserController', () => {
|
||||
expect(await storedFor('t2', 'u-other')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Willkommensmail (POST :id/welcome-mail, GET welcome-mail/status)', () => {
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't9', id: 'super1', username: 'super1', mustChangePassword: false };
|
||||
const sentAt = new Date('2026-09-30T10:00:00.000Z');
|
||||
|
||||
it('ADMIN: Ziel gebunden an den eigenen Mandanten aufgeloest, Dienst mit Ziel und Origin gerufen, Antwort traegt Empfaenger und Zeitpunkt', async () => {
|
||||
const target = { id: 'u-x', username: 'x', tenantId: 't1', role: 'USER', lastLoginAt: null };
|
||||
userService.findById.mockResolvedValue(target);
|
||||
welcomeMail.send.mockResolvedValue({ to: 'x@x.invalid', welcomeMailSentAt: sentAt });
|
||||
|
||||
const result = await controller.sendWelcomeMail('u-x', admin, 'https://tessera.example.invalid');
|
||||
|
||||
expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x');
|
||||
expect(welcomeMail.send).toHaveBeenCalledWith(target, 'https://tessera.example.invalid');
|
||||
expect(result).toEqual({ success: true, to: 'x@x.invalid', welcomeMailSentAt: sentAt });
|
||||
});
|
||||
|
||||
it('ADMIN: unbekannter oder fremdmandantiger Benutzer (gebundene Aufloesung null) → NotFoundException, kein Versand', async () => {
|
||||
userService.findById.mockResolvedValue(null);
|
||||
await expect(controller.sendWelcomeMail('u-fremd', admin)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(welcomeMail.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ADMIN: Ziel eines fremden Mandanten (zweite Schicht) → ForbiddenException mit Mandanten-Meldung, kein Versand', async () => {
|
||||
userService.findById.mockResolvedValue({ id: 'u-y', tenantId: 't2', role: 'SUPER_ADMIN' });
|
||||
await expect(controller.sendWelcomeMail('u-y', admin)).rejects.toThrow('Cannot modify users from other tenants');
|
||||
expect(welcomeMail.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ADMIN: Ziel ist SUPER_ADMIN des eigenen Mandanten → ForbiddenException (Zielrollen-Riegel), kein Versand', async () => {
|
||||
userService.findById.mockResolvedValue({ id: 'u-s', tenantId: 't1', role: 'SUPER_ADMIN' });
|
||||
await expect(controller.sendWelcomeMail('u-s', admin)).rejects.toThrow('Cannot modify a SUPER_ADMIN user');
|
||||
expect(welcomeMail.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN: Ziel ueber die uebergreifende Aufloesung, auch in fremdem Mandanten und mit oberster Rolle', async () => {
|
||||
const target = { id: 'u-z', tenantId: 't2', role: 'SUPER_ADMIN', lastLoginAt: null };
|
||||
userService.findByIdForPlatformAdmin.mockResolvedValue(target);
|
||||
welcomeMail.send.mockResolvedValue({ to: 'z@x.invalid', welcomeMailSentAt: sentAt });
|
||||
|
||||
await controller.sendWelcomeMail('u-z', superAdmin);
|
||||
|
||||
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-z');
|
||||
expect(welcomeMail.send).toHaveBeenCalledWith(target, undefined);
|
||||
});
|
||||
|
||||
it('Fehler des Dienstes (z. B. 409 Konto deaktiviert) gehen unveraendert nach aussen', async () => {
|
||||
userService.findById.mockResolvedValue({ id: 'u-x', tenantId: 't1', role: 'USER' });
|
||||
welcomeMail.send.mockRejectedValue(new ConflictException('deaktiviert'));
|
||||
await expect(controller.sendWelcomeMail('u-x', admin)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('GET welcome-mail/status fragt den Versandweg fuer den Mandanten des Aufrufers ab', async () => {
|
||||
welcomeMail.isAvailable.mockResolvedValue(true);
|
||||
await expect(controller.getWelcomeMailStatus(admin)).resolves.toEqual({ available: true });
|
||||
expect(welcomeMail.isAvailable).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
|
||||
it('findAll (ADMIN) liefert welcomeMailSentAt mit', async () => {
|
||||
prisma.__seedUser({
|
||||
id: 'u-a',
|
||||
username: 'alice',
|
||||
tenantId: 't1',
|
||||
email: 'alice@x.invalid',
|
||||
displayName: null,
|
||||
role: 'USER',
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
lastLoginAt: null,
|
||||
welcomeMailSentAt: sentAt,
|
||||
});
|
||||
const result = await controller.findAll(admin);
|
||||
expect((result as any[])[0].welcomeMailSentAt).toBe(sentAt);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Headers,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
@@ -39,6 +40,7 @@ import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { ReleaseSeenDto } from './dto/release-seen.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UserService } from './user.service';
|
||||
import { WelcomeMailService } from './welcome-mail.service';
|
||||
|
||||
/** Map accepted MIME types to file extensions (T-gbh-01). */
|
||||
const AVATAR_MIME_TO_EXT: Record<string, string> = {
|
||||
@@ -68,6 +70,10 @@ function resolveAvatarsDir(): string {
|
||||
* quick-260928-ujj: dazu kommt ein gebundener Zugriff des
|
||||
* Selbstbedienungswegs `PATCH me/dashboard-background`, ebenfalls
|
||||
* `forTenant()` mit `where: { id: currentUser.id }`.
|
||||
*
|
||||
* Willkommensmail: `POST :id/welcome-mail` loest den Zielbenutzer ueber
|
||||
* `resolveTargetUser` auf; die beiden Schreibzugriffe (Token, Versandzeit)
|
||||
* liegen gebunden in `WelcomeMailService`, nicht in diesem Controller.
|
||||
*/
|
||||
@Controller('users')
|
||||
@UseGuards(RolesGuard)
|
||||
@@ -75,6 +81,7 @@ export class UserController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly welcomeMailService: WelcomeMailService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -126,6 +133,7 @@ export class UserController {
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
welcomeMailSentAt: true,
|
||||
},
|
||||
orderBy: { username: 'asc' },
|
||||
});
|
||||
@@ -218,6 +226,23 @@ export class UserController {
|
||||
return { success: true, lastSeenReleaseVersion: version };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/welcome-mail/status (Willkommensmail)
|
||||
*
|
||||
* Ob fuer den Mandanten des Aufrufers ein Versandweg eingerichtet ist —
|
||||
* die Oberflaeche deaktiviert damit den Knopf "Willkommensmail senden"
|
||||
* und nennt den Grund. Die wirksame Pruefung bleibt beim Versand selbst
|
||||
* (`POST :id/welcome-mail`, dort je Mandant des ZIELS). Statische Route
|
||||
* steht VOR der Kennungs-Route (GET :id).
|
||||
*/
|
||||
@Get('welcome-mail/status')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async getWelcomeMailStatus(
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
): Promise<{ available: boolean }> {
|
||||
return { available: await this.welcomeMailService.isAvailable(currentUser.tenantId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/:id
|
||||
*/
|
||||
@@ -330,6 +355,47 @@ export class UserController {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /users/:id/welcome-mail (Willkommensmail)
|
||||
*
|
||||
* Schickt einem Benutzer (egal ob schon angemeldet) eine
|
||||
* Willkommensmail (Adresse, Benutzername, Anmeldehinweis je Kontoart) und
|
||||
* merkt den Zeitpunkt in `welcomeMailSentAt`. Dieselben Regeln wie beim
|
||||
* Bearbeiten: ADMIN nur im eigenen Mandanten, SUPER_ADMIN uebergreifend;
|
||||
* ein Nicht-SUPER_ADMIN darf einem SUPER_ADMIN nichts schicken
|
||||
* (Zielrollen-Riegel wie in update()). Deaktiviert -> 409, ohne
|
||||
* Adresse -> 400, ohne Versandweg -> 409, Versandfehler -> 502 (alles in
|
||||
* `WelcomeMailService`). Der Origin-Kopf dient nur als Rueckfall fuer die
|
||||
* Adresse in der Mail, wenn `TESSERA_APP_URL` fehlt.
|
||||
*/
|
||||
@Post(':id/welcome-mail')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async sendWelcomeMail(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
@Headers('origin') origin?: string,
|
||||
): Promise<{ success: true; to: string; welcomeMailSentAt: Date }> {
|
||||
const user = await this.resolveTargetUser(currentUser, id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (
|
||||
currentUser.role !== Role.SUPER_ADMIN &&
|
||||
user.tenantId !== currentUser.tenantId
|
||||
) {
|
||||
throw new ForbiddenException('Cannot modify users from other tenants');
|
||||
}
|
||||
|
||||
if (user.role === Role.SUPER_ADMIN && currentUser.role !== Role.SUPER_ADMIN) {
|
||||
throw new ForbiddenException('Cannot modify a SUPER_ADMIN user');
|
||||
}
|
||||
|
||||
const result = await this.welcomeMailService.send(user, origin);
|
||||
return { success: true, ...result };
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /users/:id
|
||||
* ADMIN cannot delete self or users from other tenants.
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GroupsModule } from '../groups/groups.module';
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { AdminSeedService } from './admin-seed.service';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
import { WelcomeMailService } from './welcome-mail.service';
|
||||
|
||||
/**
|
||||
* Importiert GroupsModule für UserService.create's Standardgruppen-
|
||||
* Mitgliedschaft (D-11/D-12). GroupsModule importiert seinerseits
|
||||
* UserModule nicht — GroupsService greift für Benutzerprüfungen direkt
|
||||
* auf PrismaService zu, keine Zirkularität.
|
||||
*
|
||||
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
|
||||
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
|
||||
* UserModule.
|
||||
*/
|
||||
@Module({
|
||||
imports: [GroupsModule],
|
||||
imports: [GroupsModule, MailModule],
|
||||
controllers: [UserController],
|
||||
providers: [UserService, AdminSeedService],
|
||||
providers: [UserService, AdminSeedService, WelcomeMailService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@@ -37,6 +37,7 @@ const PLATFORM_USER_SELECT = {
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
welcomeMailSentAt: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { normalizeOrigin, type WelcomeMailTarget, WelcomeMailService } from './welcome-mail.service';
|
||||
|
||||
/**
|
||||
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
|
||||
*
|
||||
* Festgenagelt: Versand auch an bereits angemeldete Benutzer; 409 fuer
|
||||
* deaktivierte Benutzer,
|
||||
* 400 ohne Adresse, 409 ohne Versandweg, 502 bei Versandfehler (und dann
|
||||
* KEIN `welcomeMailSentAt`); beide Schreibzugriffe gebunden an den
|
||||
* Mandanten des ZIELS; Inhalt je Kontoart (verzeichnisgefuehrt: Windows-
|
||||
* Passwort, kein Token; lokal: Token-Link mit der Frist des
|
||||
* "Passwort vergessen"-Wegs); Adresse aus Konfiguration bzw. Origin.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
|
||||
}));
|
||||
|
||||
function makePrisma() {
|
||||
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
|
||||
return {
|
||||
__log: log,
|
||||
__bound(tenantId: string) {
|
||||
return {
|
||||
passwordResetToken: {
|
||||
create: vi.fn(async (args: any) => {
|
||||
log.push({ tenantId, model: 'passwordResetToken', method: 'create', args });
|
||||
return { id: 'tok-row', ...args.data };
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
update: vi.fn(async (args: any) => {
|
||||
log.push({ tenantId, model: 'user', method: 'update', args });
|
||||
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMail(opts: { available?: boolean; fail?: boolean } = {}) {
|
||||
return {
|
||||
hasConfiguredTransport: vi.fn(async () => opts.available ?? true),
|
||||
sendWelcomeMail: vi.fn(async () => {
|
||||
if (opts.fail) throw new Error('ECONNREFUSED');
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeConfig(values: Record<string, string | undefined>) {
|
||||
return { get: vi.fn((key: string) => values[key]) };
|
||||
}
|
||||
|
||||
const localUser: WelcomeMailTarget = {
|
||||
id: 'u-local',
|
||||
tenantId: 't1',
|
||||
username: 'max.muster',
|
||||
displayName: 'Max Muster',
|
||||
email: 'max@example.invalid',
|
||||
ldapDn: null,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const ldapUser: WelcomeMailTarget = {
|
||||
...localUser,
|
||||
id: 'u-ldap',
|
||||
username: 'erika',
|
||||
displayName: null,
|
||||
email: 'erika@example.invalid',
|
||||
ldapDn: 'CN=Erika,OU=Users,DC=example,DC=invalid',
|
||||
};
|
||||
|
||||
let prisma: ReturnType<typeof makePrisma>;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = makePrisma();
|
||||
});
|
||||
|
||||
function make(mail = makeMail(), config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' })) {
|
||||
const service = new WelcomeMailService(prisma as any, mail as any, config as any);
|
||||
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
|
||||
return { service, mail };
|
||||
}
|
||||
|
||||
describe('WelcomeMailService.send — Vorbedingungen', () => {
|
||||
it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => {
|
||||
const { service, mail } = make();
|
||||
await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget);
|
||||
await service.send({ ...localUser, lastLoginAt: new Date(), role: 'SUPER_ADMIN' } as WelcomeMailTarget);
|
||||
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('deaktiviertes Konto → ConflictException', async () => {
|
||||
const { service, mail } = make();
|
||||
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne E-Mail-Adresse → BadRequestException', async () => {
|
||||
const { service, mail } = make();
|
||||
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne Versandweg → ConflictException mit Hinweis auf Administrator → SMTP; Pruefung im Mandanten des Ziels', async () => {
|
||||
const mail = makeMail({ available: false });
|
||||
const { service } = make(mail);
|
||||
await expect(service.send(localUser)).rejects.toThrow('Administrator → SMTP');
|
||||
expect(mail.hasConfiguredTransport).toHaveBeenCalledWith('t1');
|
||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||
expect(prisma.__log).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailService.send — Versand', () => {
|
||||
it('lokales Konto: Token gebunden an den Mandanten des Ziels, Frist 7 Tage, Link in Text und HTML, kein Kennwort; welcomeMailSentAt gebunden gesetzt', async () => {
|
||||
const { service, mail } = make();
|
||||
const before = Date.now();
|
||||
|
||||
const result = await service.send(localUser);
|
||||
|
||||
const tokenCall = prisma.__log.find((c) => c.model === 'passwordResetToken');
|
||||
expect(tokenCall?.tenantId).toBe('t1');
|
||||
expect(tokenCall?.args.data.userId).toBe('u-local');
|
||||
const ttl = (tokenCall as { args: any }).args.data.expiresAt.getTime() - before;
|
||||
expect(ttl).toBeGreaterThan(7 * 24 * 60 * 60 * 1000 - 60 * 1000);
|
||||
expect(ttl).toBeLessThanOrEqual(7 * 24 * 60 * 60 * 1000 + 1000);
|
||||
|
||||
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(1);
|
||||
const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[];
|
||||
expect(tenantId).toBe('t1');
|
||||
expect(to).toBe('max@example.invalid');
|
||||
const token = (tokenCall as { args: any }).args.data.token;
|
||||
expect(rendered.text).toContain(`https://tessera.example.invalid/reset-password/${token}`);
|
||||
expect(rendered.html).toContain(`https://tessera.example.invalid/reset-password/${token}`);
|
||||
expect(rendered.html).toContain('Passwort festlegen');
|
||||
expect(rendered.html).toContain('Willkommen bei Tessera, Max Muster!');
|
||||
expect(rendered.text).toContain('Benutzername: max.muster');
|
||||
expect(rendered.html).toContain('https://tessera.example.invalid/login');
|
||||
expect(rendered.text).not.toContain('Windows-Passwort');
|
||||
|
||||
const updateCall = prisma.__log.find((c) => c.model === 'user' && c.method === 'update');
|
||||
expect(updateCall?.tenantId).toBe('t1');
|
||||
expect(updateCall?.args.where).toEqual({ id: 'u-local' });
|
||||
expect(updateCall?.args.data.welcomeMailSentAt).toBeInstanceOf(Date);
|
||||
expect(result.to).toBe('max@example.invalid');
|
||||
expect(result.welcomeMailSentAt).toBe(updateCall?.args.data.welcomeMailSentAt);
|
||||
});
|
||||
|
||||
it('verzeichnisgefuehrtes Konto: kein Token, Hinweis auf das Windows-Passwort, Anrede mit Benutzername ohne Anzeigenamen', async () => {
|
||||
const { service, mail } = make();
|
||||
|
||||
await service.send(ldapUser);
|
||||
|
||||
expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false);
|
||||
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||
expect(rendered.text).toContain('gewohnten Windows-Passwort');
|
||||
expect(rendered.html).toContain('gewohnten Windows-Passwort');
|
||||
expect(rendered.html).not.toContain('reset-password');
|
||||
expect(rendered.html).not.toContain('Passwort festlegen');
|
||||
expect(rendered.html).toContain('Willkommen bei Tessera, erika!');
|
||||
});
|
||||
|
||||
it('Versandfehler → BadGatewayException, welcomeMailSentAt wird NICHT gesetzt', async () => {
|
||||
const { service } = make(makeMail({ fail: true }));
|
||||
await expect(service.send(ldapUser)).rejects.toBeInstanceOf(BadGatewayException);
|
||||
expect(prisma.__log.some((c) => c.model === 'user')).toBe(false);
|
||||
});
|
||||
|
||||
it('Anzeigename mit Markup wird im HTML maskiert', async () => {
|
||||
const { service, mail } = make();
|
||||
await service.send({ ...ldapUser, displayName: '<b>Böse</b>' });
|
||||
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||
expect(rendered.html).not.toContain('<b>Böse</b>');
|
||||
expect(rendered.html).toContain('<b>Böse</b>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailService.resolveAppUrl', () => {
|
||||
it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => {
|
||||
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }));
|
||||
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe('https://tessera.example.invalid');
|
||||
});
|
||||
|
||||
it('ohne Konfiguration: Origin der Anfrage', () => {
|
||||
const { service } = make(makeMail(), makeConfig({}));
|
||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
|
||||
});
|
||||
|
||||
it('Konfiguration zeigt nur auf localhost (Compose-Vorgabe) → Origin gewinnt; ohne Origin bleibt die Konfiguration', () => {
|
||||
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'http://localhost:3000' }));
|
||||
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
|
||||
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('ungueltiger Origin wird ignoriert', () => {
|
||||
expect(normalizeOrigin('javascript:alert(1)')).toBeNull();
|
||||
expect(normalizeOrigin('kein origin')).toBeNull();
|
||||
expect(normalizeOrigin('https://a.example.invalid/pfad')).toBe('https://a.example.invalid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { User } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { WELCOME_TOKEN_TTL_MS } from '../auth/password-reset-token';
|
||||
import { loadWelcomeHeaderPng, MailService, WELCOME_HEADER_CID } from '../mail/mail.service';
|
||||
import { renderWelcomeMail, type WelcomeMailAccount } from '../mail/welcome-mail.template';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung
|
||||
* (Administrator → Benutzer, "Willkommensmail senden").
|
||||
*
|
||||
* Kann an JEDEN Benutzer gehen, unabhaengig davon, ob und wann er sich
|
||||
* schon angemeldet hat (Wunsch des Betreibers: auch zum Erneuern des
|
||||
* Zugangs und zum Ausprobieren). Wer wem senden darf (nur eigener
|
||||
* Mandant, ADMIN nie an SUPER_ADMIN), prueft der Controller wie beim
|
||||
* Bearbeiten. Erneutes Senden ist erlaubt und ueberschreibt
|
||||
* `welcomeMailSentAt`. Abgelehnt wird nur ein deaktiviertes Konto (409),
|
||||
* ein Konto ohne Adresse (400) und ein Mandant ohne Versandweg (409).
|
||||
*
|
||||
* Anmeldehinweis je Kontoart:
|
||||
* - verzeichnisgefuehrt (`ldapDn` gesetzt): Hinweis auf das gewohnte
|
||||
* Windows-Passwort — Tessera kennt und setzt dieses Kennwort nicht (das
|
||||
* Verzeichnis bleibt nur lesend angebunden);
|
||||
* - lokal: Link "Passwort festlegen" mit einem frischen `PasswordResetToken`
|
||||
* — derselbe Token, dieselbe Seite `/reset-password/<token>` und dieselbe
|
||||
* Frist `WELCOME_TOKEN_TTL_MS` (7 Tage statt 1 Stunde wie beim Weg "Passwort
|
||||
* vergessen"). Ein Kennwort steht NIE in der Mail.
|
||||
*
|
||||
* Mandantenbindung: der Aufrufer (`UserController`) hat den Zielbenutzer
|
||||
* bereits rollenabhaengig aufgeloest und die Mandanten-/Rollenregeln
|
||||
* geprueft; beide Zugriffe hier (`passwordResetToken.create`,
|
||||
* `user.update`) binden ueber `forTenant()` an den Mandanten des ZIELS —
|
||||
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
|
||||
* SMTP-Weg genau dieses Mandanten (`MailService.sendWelcomeMail`).
|
||||
*
|
||||
* Adresse in der Mail: `TESSERA_APP_URL` (in Compose aus `APP_URL`), sonst
|
||||
* der Origin der Admin-Anfrage. Zeigt die Konfiguration nur auf
|
||||
* `localhost` (Compose-Vorgabe, nie gesetzt), gewinnt ein vorhandener
|
||||
* Origin — sonst bekaeme der Empfaenger einen Link, der auf seinem
|
||||
* Rechner ins Leere fuehrt.
|
||||
*/
|
||||
|
||||
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
|
||||
export type WelcomeMailTarget = Pick<
|
||||
User,
|
||||
| 'id'
|
||||
| 'tenantId'
|
||||
| 'username'
|
||||
| 'displayName'
|
||||
| 'email'
|
||||
| 'ldapDn'
|
||||
| 'isActive'
|
||||
>;
|
||||
|
||||
export interface WelcomeMailResult {
|
||||
to: string;
|
||||
welcomeMailSentAt: Date;
|
||||
}
|
||||
|
||||
const FALLBACK_APP_URL = 'http://localhost:3000';
|
||||
|
||||
/** `http(s)://host[:port]` aus einem Origin-Kopf, sonst `null`. */
|
||||
export function normalizeOrigin(value: string | undefined | null): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalhostUrl(value: string): boolean {
|
||||
try {
|
||||
const host = new URL(value).hostname;
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WelcomeMailService {
|
||||
private readonly logger = new Logger(WelcomeMailService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
|
||||
async isAvailable(tenantId: string): Promise<boolean> {
|
||||
return this.mailService.hasConfiguredTransport(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffentliche Adresse fuer Links in der Mail: Konfiguration, sonst Origin
|
||||
* der Anfrage, sonst die Entwicklungsvorgabe (siehe Kopfkommentar).
|
||||
*/
|
||||
resolveAppUrl(requestOrigin?: string | null): string {
|
||||
const raw = (this.configService.get<string>('TESSERA_APP_URL') ?? '').trim();
|
||||
const configured = /^https?:\/\//i.test(raw) ? raw.replace(/\/+$/, '') : '';
|
||||
const origin = normalizeOrigin(requestOrigin);
|
||||
if (configured && !(isLocalhostUrl(configured) && origin)) {
|
||||
return configured;
|
||||
}
|
||||
return origin ?? (configured || FALLBACK_APP_URL);
|
||||
}
|
||||
|
||||
async send(target: WelcomeMailTarget, requestOrigin?: string | null): Promise<WelcomeMailResult> {
|
||||
if (!target.isActive) {
|
||||
throw new ConflictException(
|
||||
'Dieses Konto ist deaktiviert. Aktivieren Sie es zuerst, bevor Sie eine Willkommensmail senden.',
|
||||
);
|
||||
}
|
||||
const to = target.email?.trim();
|
||||
if (!to) {
|
||||
throw new BadRequestException('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.');
|
||||
}
|
||||
if (!(await this.mailService.hasConfiguredTransport(target.tenantId))) {
|
||||
throw new ConflictException(
|
||||
'Für den E-Mail-Versand ist noch kein SMTP-Server eingerichtet. Ein Administrator legt ihn unter Administrator → SMTP fest.',
|
||||
);
|
||||
}
|
||||
|
||||
const appUrl = this.resolveAppUrl(requestOrigin);
|
||||
const tenantPrisma = forTenant(this.prisma, target.tenantId);
|
||||
|
||||
let account: WelcomeMailAccount;
|
||||
if (target.ldapDn) {
|
||||
account = { kind: 'directory' };
|
||||
} else {
|
||||
// Derselbe Token wie beim Weg "Passwort vergessen" (T-02-13): zufaellig,
|
||||
// einmal verwendbar, gleiche Frist.
|
||||
const token = randomUUID();
|
||||
await tenantPrisma.passwordResetToken.create({
|
||||
data: {
|
||||
token,
|
||||
userId: target.id,
|
||||
expiresAt: new Date(Date.now() + WELCOME_TOKEN_TTL_MS),
|
||||
},
|
||||
});
|
||||
account = {
|
||||
kind: 'local',
|
||||
setPasswordUrl: `${appUrl}/reset-password/${token}`,
|
||||
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
const mail = renderWelcomeMail({
|
||||
name: target.displayName?.trim() || target.username,
|
||||
username: target.username,
|
||||
appUrl,
|
||||
account,
|
||||
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.mailService.sendWelcomeMail(target.tenantId, to, mail);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Welcome mail to user ${target.id} failed`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
'Die Willkommensmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await tenantPrisma.user.update({
|
||||
where: { id: target.id },
|
||||
data: { welcomeMailSentAt: new Date() },
|
||||
select: { welcomeMailSentAt: true },
|
||||
});
|
||||
const welcomeMailSentAt = updated.welcomeMailSentAt ?? new Date();
|
||||
|
||||
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
|
||||
return { to, welcomeMailSentAt };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { UserAccessModal } from './components/UserAccessModal';
|
||||
|
||||
@@ -19,6 +19,10 @@ interface User {
|
||||
isActive: boolean;
|
||||
tenantId: string;
|
||||
createdAt: string;
|
||||
/** `null` = hat sich noch nie angemeldet (Spalte "Letzte Anmeldung": "Noch nie"). */
|
||||
lastLoginAt?: string | null;
|
||||
/** Zeitpunkt der letzten Willkommensmail, `null` = nie gesendet. */
|
||||
welcomeMailSentAt?: string | null;
|
||||
}
|
||||
|
||||
interface UserFormData {
|
||||
@@ -57,6 +61,8 @@ export default function AdminUsersPage() {
|
||||
const t = useTranslations('admin.users');
|
||||
const tCommon = useTranslations('common');
|
||||
const tHeader = useTranslations('header');
|
||||
const tWelcome = useTranslations('admin.users.welcomeMail');
|
||||
const locale = useLocale();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
@@ -68,6 +74,13 @@ export default function AdminUsersPage() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [detailsUser, setDetailsUser] = useState<User | null>(null);
|
||||
// Willkommensmail: Versandweg vorhanden? `null` = noch unbekannt (Knopf
|
||||
// bleibt bedienbar, die API meldet einen fehlenden Versandweg selbst).
|
||||
const [welcomeAvailable, setWelcomeAvailable] = useState<boolean | null>(null);
|
||||
const [welcomeUser, setWelcomeUser] = useState<User | null>(null);
|
||||
const [welcomeSending, setWelcomeSending] = useState(false);
|
||||
const [welcomeError, setWelcomeError] = useState<string | null>(null);
|
||||
const [welcomeSuccess, setWelcomeSuccess] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<UserFormData>({
|
||||
username: '',
|
||||
email: '',
|
||||
@@ -77,8 +90,7 @@ export default function AdminUsersPage() {
|
||||
});
|
||||
|
||||
// Access check: only ADMIN and SUPER_ADMIN
|
||||
const hasAccess =
|
||||
currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoadError(null);
|
||||
@@ -106,6 +118,73 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
}, [hasAccess, fetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) return;
|
||||
let cancelled = false;
|
||||
fetch(`${API_URL}/users/welcome-mail/status`, { credentials: 'include' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { available?: unknown };
|
||||
if (!cancelled && typeof body?.available === 'boolean') {
|
||||
setWelcomeAvailable(body.available);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* unbekannt lassen — die API prueft beim Versand selbst */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasAccess]);
|
||||
|
||||
const formatDateTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d);
|
||||
};
|
||||
|
||||
/**
|
||||
* Grund, warum der Knopf "Willkommensmail senden" nicht bedienbar ist,
|
||||
* oder `null`. Rein ergonomisch — die API prueft dasselbe (400/409).
|
||||
*/
|
||||
const welcomeBlockedReason = (user: User): string | null => {
|
||||
if (!user.email) return tWelcome('noEmail');
|
||||
if (!user.isActive) return tWelcome('inactive');
|
||||
if (welcomeAvailable === false) return tWelcome('noSmtp');
|
||||
return null;
|
||||
};
|
||||
|
||||
const openWelcome = (user: User) => {
|
||||
setWelcomeUser(user);
|
||||
setWelcomeError(null);
|
||||
setWelcomeSuccess(null);
|
||||
};
|
||||
|
||||
const handleSendWelcome = async () => {
|
||||
if (!welcomeUser) return;
|
||||
setWelcomeSending(true);
|
||||
setWelcomeError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/users/${welcomeUser.id}/welcome-mail`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
const email = welcomeUser.email ?? '';
|
||||
setWelcomeUser(null);
|
||||
setWelcomeSuccess(tWelcome('success', { email }));
|
||||
fetchUsers();
|
||||
} else {
|
||||
const detail = await readApiMessage(res);
|
||||
setWelcomeError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
|
||||
}
|
||||
} catch {
|
||||
setWelcomeError(t('errors.network'));
|
||||
} finally {
|
||||
setWelcomeSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingUser(null);
|
||||
setFormData({
|
||||
@@ -136,9 +215,7 @@ export default function AdminUsersPage() {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
const url = editingUser
|
||||
? `${API_URL}/users/${editingUser.id}`
|
||||
: `${API_URL}/users`;
|
||||
const url = editingUser ? `${API_URL}/users/${editingUser.id}` : `${API_URL}/users`;
|
||||
const method = editingUser ? 'PATCH' : 'POST';
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
@@ -166,9 +243,7 @@ export default function AdminUsersPage() {
|
||||
fetchUsers();
|
||||
} else {
|
||||
const detail = await readApiMessage(res);
|
||||
setFormError(
|
||||
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
|
||||
);
|
||||
setFormError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
|
||||
}
|
||||
} catch {
|
||||
setFormError(t('errors.network'));
|
||||
@@ -187,9 +262,7 @@ export default function AdminUsersPage() {
|
||||
fetchUsers();
|
||||
} else {
|
||||
const detail = await readApiMessage(res);
|
||||
setDeleteError(
|
||||
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
|
||||
);
|
||||
setDeleteError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
|
||||
}
|
||||
} catch {
|
||||
setDeleteError(t('errors.network'));
|
||||
@@ -212,6 +285,13 @@ export default function AdminUsersPage() {
|
||||
const canManageRow = (user: User) =>
|
||||
!(user.role === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN');
|
||||
|
||||
/**
|
||||
* Willkommensmail: an jeden Benutzer, dessen Zeile die angemeldete Person
|
||||
* verwalten darf (Zielrollen-Riegel wie beim Bearbeiten). Spiegelt die
|
||||
* API, die allein entscheidet.
|
||||
*/
|
||||
const canSendWelcome = (user: User) => canManageRow(user);
|
||||
|
||||
const roleBadgeClass = (role: string) => {
|
||||
switch (role) {
|
||||
case 'SUPER_ADMIN':
|
||||
@@ -228,11 +308,7 @@ export default function AdminUsersPage() {
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||
{t('create')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -246,6 +322,23 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{welcomeSuccess && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start justify-between gap-3 rounded-md border border-status-ok/40 bg-status-ok/12 p-3 text-sm text-status-ok-fg"
|
||||
>
|
||||
<span>{welcomeSuccess}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWelcomeSuccess(null)}
|
||||
aria-label={tCommon('close')}
|
||||
className="rounded px-1 text-xs hover:bg-muted"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users table */}
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||
@@ -271,6 +364,9 @@ export default function AdminUsersPage() {
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('status')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('lastLogin')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||
{t('actions')}
|
||||
</th>
|
||||
@@ -279,15 +375,9 @@ export default function AdminUsersPage() {
|
||||
<tbody className="divide-y divide-border">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{user.username}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{user.email ?? '–'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{user.displayName ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-foreground">{user.username}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{user.email ?? '–'}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{user.displayName ?? '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`}
|
||||
@@ -305,23 +395,52 @@ export default function AdminUsersPage() {
|
||||
>
|
||||
{user.isActive ? tCommon('active') : tCommon('inactive')}
|
||||
</span>
|
||||
{user.welcomeMailSentAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{tWelcome('sentAt', { date: formatDateTime(user.welcomeMailSentAt) })}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
|
||||
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t('neverLoggedIn')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{canSendWelcome(user) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openWelcome(user)}
|
||||
disabled={welcomeBlockedReason(user) !== null}
|
||||
title={
|
||||
welcomeBlockedReason(user) ??
|
||||
(user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button'))
|
||||
}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ActionIcon kind="mail" />
|
||||
<span className="sr-only">
|
||||
{user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsUser(user)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||
title={t('grants.detailsButton')}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('grants.detailsButton')}
|
||||
<ActionIcon kind="details" />
|
||||
<span className="sr-only">{t('grants.detailsButton')}</span>
|
||||
</button>
|
||||
{canManageRow(user) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(user)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||
title={tCommon('edit')}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{tCommon('edit')}
|
||||
<ActionIcon kind="edit" />
|
||||
<span className="sr-only">{tCommon('edit')}</span>
|
||||
</button>
|
||||
)}
|
||||
{canManageRow(user) && (
|
||||
@@ -332,9 +451,11 @@ export default function AdminUsersPage() {
|
||||
setDeleteError(null);
|
||||
}}
|
||||
disabled={user.id === currentUser?.id}
|
||||
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
|
||||
title={tCommon('delete')}
|
||||
className="rounded p-1.5 text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
|
||||
>
|
||||
{tCommon('delete')}
|
||||
<ActionIcon kind="delete" />
|
||||
<span className="sr-only">{tCommon('delete')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -363,9 +484,7 @@ export default function AdminUsersPage() {
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -378,9 +497,7 @@ export default function AdminUsersPage() {
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -388,9 +505,7 @@ export default function AdminUsersPage() {
|
||||
<label htmlFor="user-password" className="text-sm font-medium text-foreground">
|
||||
{t('password')}
|
||||
{editingUser && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
(optional)
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">(optional)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
@@ -399,9 +514,7 @@ export default function AdminUsersPage() {
|
||||
required={!editingUser}
|
||||
minLength={8}
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -413,9 +526,7 @@ export default function AdminUsersPage() {
|
||||
id="user-display-name"
|
||||
type="text"
|
||||
value={formData.displayName}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, displayName: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -437,9 +548,7 @@ export default function AdminUsersPage() {
|
||||
<option value="USER">{tHeader('role.USER')}</option>
|
||||
<option value="ADMIN">{tHeader('role.ADMIN')}</option>
|
||||
{currentUser?.role === 'SUPER_ADMIN' && (
|
||||
<option value="SUPER_ADMIN">
|
||||
{tHeader('role.SUPER_ADMIN')}
|
||||
</option>
|
||||
<option value="SUPER_ADMIN">{tHeader('role.SUPER_ADMIN')}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -459,10 +568,7 @@ export default function AdminUsersPage() {
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{tCommon('save')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -475,9 +581,7 @@ export default function AdminUsersPage() {
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<p className="text-sm text-foreground mb-4">
|
||||
{t('deleteConfirm')}
|
||||
</p>
|
||||
<p className="text-sm text-foreground mb-4">{t('deleteConfirm')}</p>
|
||||
{deleteError && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -506,6 +610,57 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Willkommensmail: Bestaetigung mit Empfaengeradresse */}
|
||||
{welcomeUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="welcome-mail-title"
|
||||
className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg"
|
||||
>
|
||||
<h2 id="welcome-mail-title" className="text-lg font-bold text-foreground mb-3">
|
||||
{tWelcome('dialogTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-foreground">{tWelcome('dialogText')}</p>
|
||||
<p className="mt-2 mb-4 rounded-md bg-muted px-3 py-2 text-sm font-medium text-foreground break-all">
|
||||
{welcomeUser.displayName ?? welcomeUser.username} <{welcomeUser.email}>
|
||||
</p>
|
||||
{welcomeUser.welcomeMailSentAt && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{tWelcome('dialogResend', { date: formatDateTime(welcomeUser.welcomeMailSentAt) })}
|
||||
</p>
|
||||
)}
|
||||
{welcomeError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive mb-4"
|
||||
>
|
||||
{welcomeError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWelcomeUser(null)}
|
||||
disabled={welcomeSending}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendWelcome}
|
||||
disabled={welcomeSending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{welcomeSending ? tWelcome('sending') : tWelcome('send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User-Detail-Zugriff (D-16): geerbte + direkte Modulfreigaben */}
|
||||
{detailsUser && (
|
||||
<UserAccessModal
|
||||
@@ -517,3 +672,52 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbole der Zeilenaktionen (quick-260930): die Tabelle wurde mit der
|
||||
* Spalte "Letzte Anmeldung" und der Willkommensmail zu breit fuer Text-
|
||||
* Knoepfe. Beschriftung steht an jedem Knopf als unsichtbarer Text (sr-only)
|
||||
* und als title (Tooltip).
|
||||
*/
|
||||
function ActionIcon({ kind }: { kind: 'mail' | 'details' | 'edit' | 'delete' }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{kind === 'mail' && (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</>
|
||||
)}
|
||||
{kind === 'details' && (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5" />
|
||||
<path d="M12 8h.01" />
|
||||
</>
|
||||
)}
|
||||
{kind === 'edit' && (
|
||||
<>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</>
|
||||
)}
|
||||
{kind === 'delete' && (
|
||||
<>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -535,6 +535,8 @@
|
||||
"displayName": "Anzeigename",
|
||||
"role": "Rolle",
|
||||
"status": "Status",
|
||||
"lastLogin": "Letzte Anmeldung",
|
||||
"neverLoggedIn": "Noch nie",
|
||||
"actions": "Aktionen",
|
||||
"password": "Passwort",
|
||||
"noUsers": "Keine Benutzer gefunden",
|
||||
@@ -557,6 +559,20 @@
|
||||
"generic": "Die Aktion konnte nicht durchgeführt werden. Bitte erneut versuchen.",
|
||||
"network": "Der Server ist nicht erreichbar. Bitte erneut versuchen.",
|
||||
"loadFailed": "Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu."
|
||||
},
|
||||
"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 mit Adresse, Benutzername und Hinweisen zur ersten Anmeldung 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."
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
|
||||
@@ -535,6 +535,8 @@
|
||||
"displayName": "Display Name",
|
||||
"role": "Role",
|
||||
"status": "Status",
|
||||
"lastLogin": "Last sign-in",
|
||||
"neverLoggedIn": "Never",
|
||||
"actions": "Actions",
|
||||
"password": "Password",
|
||||
"noUsers": "No users found",
|
||||
@@ -557,6 +559,20 @@
|
||||
"generic": "The action could not be completed. Please try again.",
|
||||
"network": "The server is not reachable. Please try again.",
|
||||
"loadFailed": "The user list could not be loaded. Please reload the page."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"button": "Send welcome email",
|
||||
"resend": "Send again",
|
||||
"sentAt": "Welcome email sent on {date}",
|
||||
"noEmail": "No email address on file – please add one first.",
|
||||
"noSmtp": "No SMTP server has been set up yet (Administrator → SMTP).",
|
||||
"inactive": "The account is deactivated.",
|
||||
"dialogTitle": "Send welcome email",
|
||||
"dialogText": "Tessera will send a welcome email with the address, username and first sign-in instructions to:",
|
||||
"dialogResend": "A welcome email was already sent on {date}. It will be sent again.",
|
||||
"send": "Send",
|
||||
"sending": "Sending …",
|
||||
"success": "The welcome email to {email} has been sent."
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
|
||||
Reference in New Issue
Block a user