feat(02-03): password reset flow, force-change interceptor, MailModule

- MailModule with SMTP transport configured from ENV variables
- MailService for password reset and welcome emails (plain text, i18n)
- Password reset flow: request-reset (public), reset-password (token-based)
- Change password for logged-in users with current password verification
- Admin reset password endpoint (ADMIN/SUPER_ADMIN only, D-03)
- ForcePasswordChangeInterceptor blocks all routes except change-password,
  logout, me when mustChangePassword=true (D-06, Pitfall 5)
- Frontend: reset-password request page, token reset page, change-password page
- Forgot password link added to login page
- MailHog service added to docker-compose.dev.yml for dev email testing
- SMTP env vars added to docker-compose.yml (defaults to MailHog)
- Complete DE/EN i18n coverage for reset and change password flows
- SUS packages installed: @nestjs-modules/mailer, nodemailer, ldapts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 13:48:23 +02:00
parent eaaa9adfa5
commit ac617f4fe5
20 changed files with 4216 additions and 12 deletions
+134
View File
@@ -0,0 +1,134 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerService } from '@nestjs-modules/mailer';
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
private readonly appUrl: string;
constructor(
private mailerService: MailerService,
private configService: ConfigService,
) {
this.appUrl = this.configService.get<string>(
'TESSERA_APP_URL',
'http://localhost:3000',
);
}
/**
* Send a password reset email with a time-limited token link.
* T-02-12: The caller always returns 200 regardless of whether this succeeds
* (no email enumeration).
*/
async sendPasswordResetEmail(
email: string,
token: string,
locale: string = 'de',
): Promise<void> {
const resetLink = `${this.appUrl}/reset-password/${token}`;
const isGerman = locale === 'de';
const subject = isGerman
? 'Passwort zuruecksetzen - Tessera'
: 'Reset your password - Tessera';
const text = isGerman
? [
'Hallo,',
'',
'Sie haben eine Passwortzuruecksetzung fuer Ihren Tessera-Account angefordert.',
'',
`Klicken Sie auf den folgenden Link, um Ihr Passwort zurueckzusetzen:`,
resetLink,
'',
'Dieser Link ist 1 Stunde gueltig und kann nur einmal verwendet werden.',
'',
'Falls Sie diese Anfrage nicht gestellt haben, koennen Sie diese E-Mail ignorieren.',
'',
'Mit freundlichen Gruessen,',
'Ihr Tessera-Team',
].join('\n')
: [
'Hello,',
'',
'You have requested a password reset for your Tessera account.',
'',
'Click the following link to reset your password:',
resetLink,
'',
'This link is valid for 1 hour and can only be used once.',
'',
'If you did not request this, you can safely ignore this email.',
'',
'Best regards,',
'The Tessera Team',
].join('\n');
try {
await this.mailerService.sendMail({
to: email,
subject,
text,
});
this.logger.log(`Password reset email sent to ${email}`);
} catch (error) {
// Log but don't throw -- caller returns 200 regardless (T-02-12)
this.logger.error(
`Failed to send password reset email to ${email}`,
error instanceof Error ? error.stack : String(error),
);
}
}
/**
* Send a welcome email to a newly created user (optional).
*/
async sendWelcomeEmail(
email: string,
username: string,
locale: string = 'de',
): Promise<void> {
const isGerman = locale === 'de';
const subject = isGerman
? 'Willkommen bei Tessera'
: 'Welcome to Tessera';
const text = isGerman
? [
`Hallo ${username},`,
'',
'Ihr Tessera-Account wurde erstellt.',
'',
`Sie koennen sich unter ${this.appUrl}/login anmelden.`,
'',
'Mit freundlichen Gruessen,',
'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');
try {
await this.mailerService.sendMail({
to: email,
subject,
text,
});
this.logger.log(`Welcome email sent to ${email}`);
} catch (error) {
this.logger.error(
`Failed to send welcome email to ${email}`,
error instanceof Error ? error.stack : String(error),
);
}
}
}