verifizierung
This commit is contained in:
@@ -14,3 +14,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
||||
|
||||
# Browser-URL, unter der der Container erreichbar ist.
|
||||
CLIENT_URL=http://localhost:8080
|
||||
|
||||
MAIL_ENABLED=true
|
||||
SMTP_HOST=host.docker.internal
|
||||
SMTP_PORT=1025
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
MAIL_FROM=no-reply@listify.local
|
||||
MAIL_FROM_NAME=Listify
|
||||
|
||||
@@ -11,3 +11,12 @@ JWT_ACCESS_SECRET=change-me-access-secret
|
||||
JWT_REFRESH_SECRET=change-me-refresh-secret
|
||||
|
||||
CLIENT_URL=http://localhost:4200
|
||||
|
||||
MAIL_ENABLED=true
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
MAIL_FROM=no-reply@listify.local
|
||||
MAIL_FROM_NAME=Listify
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"webpack": true,
|
||||
"webpackConfigPath": "webpack.config.js"
|
||||
"webpackConfigPath": "webpack.config.js",
|
||||
"assets": [
|
||||
{
|
||||
"include": "mail/templates/**/*",
|
||||
"outDir": "dist"
|
||||
}
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
|
||||
3692
listify-api/package-lock.json
generated
3692
listify-api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs-modules/mailer": "^2.3.6",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
@@ -31,8 +32,10 @@
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"handlebars": "^4.7.9",
|
||||
"helmet": "^8.2.0",
|
||||
"mysql2": "^3.22.5",
|
||||
"nodemailer": "^8.0.10",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { AuthenticatedRequest } from './auth.types';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { ResendVerificationDto } from './dto/resend-verification.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@@ -31,6 +32,12 @@ export class AuthController {
|
||||
return this.authService.verifyEmail(token);
|
||||
}
|
||||
|
||||
@Post('resend-verification')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
resendVerification(@Body() resendVerificationDto: ResendVerificationDto) {
|
||||
return this.authService.resendVerificationEmail(resendVerificationDto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
login(@Body() loginDto: LoginDto) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Repository } from 'typeorm';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { ResendVerificationDto } from './dto/resend-verification.dto';
|
||||
import {
|
||||
AuthTokenResponse,
|
||||
AuthTokens,
|
||||
@@ -106,6 +107,29 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async resendVerificationEmail(
|
||||
resendVerificationDto: ResendVerificationDto,
|
||||
): Promise<{ message: string }> {
|
||||
const email = this.normalizeEmail(resendVerificationDto.email);
|
||||
const message =
|
||||
'Falls ein unverifiziertes Konto mit dieser E-Mail existiert, wurde eine neue Verifizierungsmail versendet.';
|
||||
const user = await this.usersRepository.findOne({ where: { email } });
|
||||
|
||||
if (!user || user.verified) {
|
||||
return { message };
|
||||
}
|
||||
|
||||
user.verificationToken = this.createToken();
|
||||
const savedUser = await this.usersRepository.save(user);
|
||||
|
||||
this.eventEmitter.emit(AppEvents.UserRegistered, {
|
||||
email: savedUser.email,
|
||||
verificationUrl: this.createVerificationUrl(savedUser.verificationToken!),
|
||||
});
|
||||
|
||||
return { message };
|
||||
}
|
||||
|
||||
async login(loginDto: LoginDto): Promise<AuthTokenResponse> {
|
||||
const email = this.normalizeEmail(loginDto.email);
|
||||
const password = this.requirePassword(loginDto.password);
|
||||
|
||||
3
listify-api/src/auth/dto/resend-verification.dto.ts
Normal file
3
listify-api/src/auth/dto/resend-verification.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class ResendVerificationDto {
|
||||
email?: string;
|
||||
}
|
||||
@@ -258,11 +258,11 @@ export class ListTemplatesService {
|
||||
{
|
||||
name: 'Urlaub',
|
||||
description:
|
||||
'Grundlage fuer Packlisten wie Sommerurlaub oder Staedtetrip.',
|
||||
'Grundlage für Packlisten wie Sommerurlaub oder Städtetrip.',
|
||||
kind: 'packing' as const,
|
||||
items: [
|
||||
{ title: 'Pass oder Ausweis' },
|
||||
{ title: 'Tickets und Buchungsbestaetigungen' },
|
||||
{ title: 'Tickets und Buchungsbestätigungen' },
|
||||
{ title: 'Ladegeraete' },
|
||||
{ title: 'Reiseapotheke' },
|
||||
{ title: 'Sonnencreme', required: false },
|
||||
@@ -270,7 +270,7 @@ export class ListTemplatesService {
|
||||
},
|
||||
{
|
||||
name: 'Wocheneinkauf',
|
||||
description: 'Basis fuer wiederkehrende Einkaufslisten.',
|
||||
description: 'Basis für wiederkehrende Einkaufslisten.',
|
||||
kind: 'shopping' as const,
|
||||
items: [
|
||||
{ title: 'Milch' },
|
||||
@@ -282,7 +282,7 @@ export class ListTemplatesService {
|
||||
},
|
||||
{
|
||||
name: 'Wochenplanung',
|
||||
description: 'Einfache Todo-Vorlage fuer die persoenliche Planung.',
|
||||
description: 'Einfache Todo-Vorlage für die persönliche Planung.',
|
||||
kind: 'todo' as const,
|
||||
items: [
|
||||
{ title: 'Prioritaeten festlegen' },
|
||||
|
||||
@@ -9,7 +9,10 @@ export class MailEventsListener {
|
||||
constructor(private readonly mailService: MailService) {}
|
||||
|
||||
@OnEvent(AppEvents.UserRegistered)
|
||||
handleUserRegistered(event: UserRegisteredEvent): void {
|
||||
this.mailService.sendVerificationEmail(event.email, event.verificationUrl);
|
||||
async handleUserRegistered(event: UserRegisteredEvent): Promise<void> {
|
||||
await this.mailService.sendVerificationEmail(
|
||||
event.email,
|
||||
event.verificationUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { MailerModule } from '@nestjs-modules/mailer';
|
||||
import { MailEventsListener } from './mail-events.listener';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MailerModule.forRootAsync({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
transport: {
|
||||
host: configService.get<string>('SMTP_HOST', 'localhost'),
|
||||
port: Number(configService.get<string>('SMTP_PORT', '1025')),
|
||||
secure: configService.get<string>('SMTP_SECURE', 'false') === 'true',
|
||||
auth: configService.get<string>('SMTP_USER')
|
||||
? {
|
||||
user: configService.get<string>('SMTP_USER'),
|
||||
pass: configService.get<string>('SMTP_PASSWORD', ''),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
defaults: {
|
||||
from: {
|
||||
name: configService.get<string>('MAIL_FROM_NAME', 'Listify'),
|
||||
address: configService.get<string>(
|
||||
'MAIL_FROM',
|
||||
'no-reply@listify.local',
|
||||
),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [MailService, MailEventsListener],
|
||||
exports: [MailService],
|
||||
})
|
||||
|
||||
@@ -1,26 +1,115 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MailerService } from '@nestjs-modules/mailer';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { compile, TemplateDelegate } from 'handlebars';
|
||||
import { join } from 'path';
|
||||
import { SentEmail } from './mail.types';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
private readonly sentEmails: SentEmail[] = [];
|
||||
private verificationTemplate?: TemplateDelegate;
|
||||
|
||||
sendVerificationEmail(to: string, verificationUrl: string): void {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly mailerService: MailerService,
|
||||
) {}
|
||||
|
||||
async sendVerificationEmail(
|
||||
to: string,
|
||||
verificationUrl: string,
|
||||
): Promise<void> {
|
||||
const email: SentEmail = {
|
||||
to,
|
||||
subject: 'Verify your Listify account',
|
||||
text: `Please verify your account by opening this link: ${verificationUrl}`,
|
||||
subject: 'Bestätige deinen Listify Account',
|
||||
text: `Bestätige deinen Listify Account mit diesem Link: ${verificationUrl}`,
|
||||
verificationUrl,
|
||||
};
|
||||
|
||||
this.sentEmails.push(email);
|
||||
this.logger.log(
|
||||
`Verification email sent to ${to}: ${email.verificationUrl}`,
|
||||
);
|
||||
|
||||
if (!this.mailEnabled()) {
|
||||
this.logger.log(`Verification email queued for ${to}. Mail sending is disabled.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.mailerService.sendMail({
|
||||
to,
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
html: this.renderVerificationTemplate({
|
||||
appName: this.configService.get<string>('MAIL_FROM_NAME', 'Listify'),
|
||||
clientUrl: this.configService.get<string>(
|
||||
'CLIENT_URL',
|
||||
'http://localhost:4200',
|
||||
),
|
||||
email: to,
|
||||
verificationUrl,
|
||||
currentYear: new Date().getFullYear(),
|
||||
}),
|
||||
context: {
|
||||
appName: this.configService.get<string>('MAIL_FROM_NAME', 'Listify'),
|
||||
clientUrl: this.configService.get<string>(
|
||||
'CLIENT_URL',
|
||||
'http://localhost:4200',
|
||||
),
|
||||
email: to,
|
||||
verificationUrl,
|
||||
currentYear: new Date().getFullYear(),
|
||||
},
|
||||
});
|
||||
this.logger.log(`Verification email sent to ${to}.`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Verification email could not be sent to ${to}.`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getSentEmails(): SentEmail[] {
|
||||
return [...this.sentEmails];
|
||||
}
|
||||
|
||||
private renderVerificationTemplate(context: {
|
||||
appName: string;
|
||||
clientUrl: string;
|
||||
email: string;
|
||||
verificationUrl: string;
|
||||
currentYear: number;
|
||||
}): string {
|
||||
this.verificationTemplate ??= compile(
|
||||
readFileSync(this.resolveTemplatePath('verification.hbs'), 'utf8'),
|
||||
{ strict: true },
|
||||
);
|
||||
|
||||
return this.verificationTemplate(context);
|
||||
}
|
||||
|
||||
private resolveTemplatePath(fileName: string): string {
|
||||
const candidates = [
|
||||
join(process.cwd(), 'dist', 'mail', 'templates', fileName),
|
||||
join(process.cwd(), 'src', 'mail', 'templates', fileName),
|
||||
join(__dirname, 'templates', fileName),
|
||||
];
|
||||
|
||||
const templatePath = candidates.find((candidate) => existsSync(candidate));
|
||||
|
||||
if (!templatePath) {
|
||||
throw new Error(`Mail template not found: ${fileName}`);
|
||||
}
|
||||
|
||||
return templatePath;
|
||||
}
|
||||
|
||||
private mailEnabled(): boolean {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.configService.get<string>('MAIL_ENABLED', 'true') === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
67
listify-api/src/mail/templates/verification.hbs
Normal file
67
listify-api/src/mail/templates/verification.hbs
Normal file
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Listify Account bestätigen</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#f4f7f5;font-family:Arial,Helvetica,sans-serif;color:#17211b;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7f5;padding:24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border:1px solid #dce5df;border-radius:8px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="padding:28px 28px 18px;background:#0f5f3d;color:#ffffff;">
|
||||
<div style="font-size:14px;letter-spacing:.08em;text-transform:uppercase;opacity:.85;">{{appName}}</div>
|
||||
<h1 style="margin:10px 0 0;font-size:26px;line-height:1.2;font-weight:700;">Bestätige deinen Account</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:28px;">
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.55;">
|
||||
Hallo,
|
||||
</p>
|
||||
<p style="margin:0 0 18px;font-size:16px;line-height:1.55;">
|
||||
du hast dich mit <strong>{{email}}</strong> bei {{appName}} registriert. Bestätige deine E-Mail-Adresse, damit du Templates und Listen sicher nutzen kannst.
|
||||
</p>
|
||||
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin:24px 0;">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{verificationUrl}}" style="display:inline-block;background:#0f5f3d;color:#ffffff;text-decoration:none;border-radius:6px;padding:14px 20px;font-size:16px;font-weight:700;">
|
||||
Account bestätigen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 10px;font-size:14px;line-height:1.5;color:#52645a;">
|
||||
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
|
||||
</p>
|
||||
<p style="margin:0;padding:12px;background:#eef4f0;border:1px solid #dce5df;border-radius:6px;font-size:13px;line-height:1.45;word-break:break-all;">
|
||||
<a href="{{verificationUrl}}" style="color:#0f5f3d;">{{verificationUrl}}</a>
|
||||
</p>
|
||||
|
||||
<p style="margin:22px 0 0;font-size:14px;line-height:1.5;color:#52645a;">
|
||||
Wenn du dich nicht bei {{appName}} registriert hast, kannst du diese E-Mail ignorieren.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:18px 28px;background:#f8faf8;border-top:1px solid #e7eee9;color:#6a7b71;font-size:12px;line-height:1.5;">
|
||||
<div>{{appName}} - Listen, Templates und gemeinsame Planung.</div>
|
||||
<div style="margin-top:4px;">© {{currentYear}} {{appName}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="max-width:560px;margin:14px auto 0;color:#7a8a81;font-size:12px;line-height:1.4;">
|
||||
Diese E-Mail wurde automatisch von {{appName}} versendet.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user