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
@@ -0,0 +1,72 @@
import {
CallHandler,
ExecutionContext,
ForbiddenException,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
/**
* Global interceptor: forces users with mustChangePassword=true to change
* their password before accessing any other resource (Pitfall 5 / D-06).
*
* Allowed routes when mustChangePassword=true:
* - POST /auth/change-password (the password change endpoint itself)
* - POST /auth/logout (user should always be able to log out)
* - GET /auth/me (so frontend can detect mustChangePassword flag)
*
* All other routes return 403 with FORCE_PASSWORD_CHANGE message.
* T-02-14: Prevents bypass via direct API access.
*/
@Injectable()
export class ForcePasswordChangeInterceptor implements NestInterceptor {
constructor(private reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// Skip public routes (login, health, reset-password)
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return next.handle();
}
const request = context.switchToHttp().getRequest();
const user = request.user;
// No user on request (shouldn't happen after auth guard, but be defensive)
if (!user) {
return next.handle();
}
// User doesn't need to change password
if (!user.mustChangePassword) {
return next.handle();
}
// Allow specific routes even when password change is required
const path = request.route?.path || request.url;
const method = request.method;
const allowedPaths = [
'/auth/change-password',
'/auth/logout',
'/auth/me',
];
if (allowedPaths.some((allowed) => path.includes(allowed))) {
return next.handle();
}
// Block all other routes
throw new ForbiddenException({
statusCode: 403,
message: 'FORCE_PASSWORD_CHANGE',
error: 'Must change password before continuing',
});
}
}