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