From 3608f913819b273d8a6c8c8728d776d0622d3f44 Mon Sep 17 00:00:00 2001 From: Kakious Date: Sat, 27 Jul 2024 01:19:40 -0400 Subject: [PATCH] feat: email templating --- mail/html/password-changed.hbs | 0 mail/html/verify-email.hbs | 69 ++++++++++++++++++ mail/html/welcome.hbs | 0 mail/text/password-changed.txt | 0 mail/text/verify-email.txt | 12 +++ mail/text/welcome.txt | 0 nest-cli.json | 16 ++-- package.json | 1 + pnpm-lock.yaml | 17 +++++ src/auth/controllers/auth.controller.ts | 6 +- src/auth/decorators/requiresRole.decorator.ts | 0 src/auth/decorators/user.decorator.ts | 7 +- src/auth/guard/auth.guard.ts | 0 src/auth/guard/login.guard.ts | 19 +++++ src/auth/services/auth.service.ts | 2 +- src/mail/mail.service.ts | 73 ++++++++++++++++--- src/main.ts | 9 ++- src/user/user.service.ts | 7 ++ tsconfig.json | 2 +- views/home/privacy-policy.hbs | 0 views/home/terms-and-conditions.hbs | 0 21 files changed, 215 insertions(+), 25 deletions(-) create mode 100644 mail/html/password-changed.hbs create mode 100644 mail/html/verify-email.hbs create mode 100644 mail/html/welcome.hbs create mode 100644 mail/text/password-changed.txt create mode 100644 mail/text/verify-email.txt create mode 100644 mail/text/welcome.txt create mode 100644 src/auth/decorators/requiresRole.decorator.ts create mode 100644 src/auth/guard/auth.guard.ts create mode 100644 src/auth/guard/login.guard.ts create mode 100644 views/home/privacy-policy.hbs create mode 100644 views/home/terms-and-conditions.hbs diff --git a/mail/html/password-changed.hbs b/mail/html/password-changed.hbs new file mode 100644 index 0000000..e69de29 diff --git a/mail/html/verify-email.hbs b/mail/html/verify-email.hbs new file mode 100644 index 0000000..3b9ef41 --- /dev/null +++ b/mail/html/verify-email.hbs @@ -0,0 +1,69 @@ + + + + + + Email Verification + + + + +
+

Verify Your Email Address

+

Hi there,

+

+ Thanks for signing up! Please confirm your email address by clicking the button below. +

+ +

+ If you did not create an account, no further action is required. +

+

+ Regards,
WaterWolf +

+
+
+ + + + + + + + + Powered by Waterwolf +
+
+ + \ No newline at end of file diff --git a/mail/html/welcome.hbs b/mail/html/welcome.hbs new file mode 100644 index 0000000..e69de29 diff --git a/mail/text/password-changed.txt b/mail/text/password-changed.txt new file mode 100644 index 0000000..e69de29 diff --git a/mail/text/verify-email.txt b/mail/text/verify-email.txt new file mode 100644 index 0000000..7d113cd --- /dev/null +++ b/mail/text/verify-email.txt @@ -0,0 +1,12 @@ +Verify Your Email Address + +Hi there, + +Thanks for signing up! Please confirm your email address by clicking the link below. + +Verify Email: {{verificationUrl}} + +If you did not create an account, no further action is required. + +Regards, +WaterWolf \ No newline at end of file diff --git a/mail/text/welcome.txt b/mail/text/welcome.txt new file mode 100644 index 0000000..e69de29 diff --git a/nest-cli.json b/nest-cli.json index f9aa683..a8170d1 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -1,8 +1,8 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/package.json b/package.json index d6c0aab..a6955ee 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "class-validator": "^0.14.1", "cookie-parser": "^1.4.6", "dotenv": "^16.4.5", + "handlebars": "^4.7.8", "hbs": "^4.2.0", "ioredis": "^5.4.1", "keygrip": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25e89bd..abaf822 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.4.5 + handlebars: + specifier: ^4.7.8 + version: 4.7.8 hbs: specifier: ^4.2.0 version: 4.2.0 @@ -2222,6 +2225,11 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -6551,6 +6559,15 @@ snapshots: optionalDependencies: uglify-js: 3.18.0 + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.18.0 + has-flag@3.0.0: {} has-flag@4.0.0: {} diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 813447b..8b6c565 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Render, Res } from '@nestjs/common'; +import { Body, Controller, Get, Post, Render, Res, UseGuards } from '@nestjs/common'; import { ApiExcludeEndpoint } from '@nestjs/swagger'; import { AuthService } from '../services/auth.service'; @@ -7,6 +7,7 @@ import { CreateUserDto } from '../dto/register.dto'; import { LoginUserDto } from '../dto/loginUser.dto'; import { Response } from 'express'; import { User } from '../decorators/user.decorator'; +import { LoginGuard } from '../guard/login.guard'; @Controller('auth') export class AuthController { @@ -42,6 +43,7 @@ export class AuthController { // Render pages @Get('login') + @UseGuards(LoginGuard) @Render('auth/login') @ApiExcludeEndpoint() public async getHello(): Promise { @@ -53,6 +55,7 @@ export class AuthController { } @Get('register') + @UseGuards(LoginGuard) @Render('auth/register') @ApiExcludeEndpoint() public async getRegister(): Promise { @@ -62,6 +65,7 @@ export class AuthController { } @Get('forgot-password') + @UseGuards(LoginGuard) @Render('auth/forgot-password') @ApiExcludeEndpoint() public async getForgotPassword(): Promise { diff --git a/src/auth/decorators/requiresRole.decorator.ts b/src/auth/decorators/requiresRole.decorator.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/auth/decorators/user.decorator.ts b/src/auth/decorators/user.decorator.ts index 40740aa..3452eec 100644 --- a/src/auth/decorators/user.decorator.ts +++ b/src/auth/decorators/user.decorator.ts @@ -10,7 +10,10 @@ export const User = createParamDecorator(() => { } const user = cls.get('user'); - // remove the password from the user object - delete user.password; + + if (!user) { + return null; + } + return user; }); diff --git a/src/auth/guard/auth.guard.ts b/src/auth/guard/auth.guard.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/auth/guard/login.guard.ts b/src/auth/guard/login.guard.ts new file mode 100644 index 0000000..11f2a8e --- /dev/null +++ b/src/auth/guard/login.guard.ts @@ -0,0 +1,19 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import { Observable } from 'rxjs'; +import { Response } from 'express'; + +@Injectable() +export class LoginGuard implements CanActivate { + constructor(private readonly clsService: ClsService) {} + canActivate(context: ExecutionContext): boolean | Promise | Observable { + const authType = this.clsService.get('authType'); + const response = context.switchToHttp().getResponse() as Response; + + if (authType === 'session') { + response.redirect('/auth/auth-test'); + return false; + } + return true; + } +} diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 7786151..f95c6f4 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -75,7 +75,7 @@ export class AuthService { } await this.userService.createUser(username, email, password); - await this.mailService.sendWelcomeEmail(email); + await this.mailService.sendVerificationEmail(email, '11111'); return { error: false, message: 'User registered' }; } diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts index 8499138..39327ba 100644 --- a/src/mail/mail.service.ts +++ b/src/mail/mail.service.ts @@ -1,6 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PostalClientService } from 'nestjs-postal-client'; +import Handlebars from 'handlebars'; +import * as path from 'path'; +import { readFile } from 'fs/promises'; @Injectable() export class MailService { @@ -21,26 +24,74 @@ export class MailService { * Send a welcome email to the user * @param email The email address of the user */ - public async sendWelcomeEmail(email: string): Promise { - this.logger.debug(`Sending welcome email to ${email}`); - - this.postalService.sendMessage({ - to: [email], - subject: 'Welcome to the app!', - from: this.configService.getOrThrow('MAIL_FROM'), - plain_body: 'Welcome to the app!', - }); - } + public async sendWelcomeEmail(email: string): Promise {} /** * Send a verification email to the user * @param email The email address of the user */ - public async sendVerificationEmail(email: string): Promise {} + public async sendVerificationEmail(email: string, code: string): Promise { + this.logger.debug(`Sending welcome email to ${email}`); + + const verificationUrl = `${this.configService.getOrThrow('BASE_URL')}/auth/verify?code=${code}`; + + //handlebar render email template + const { html: templateHtml, text: templateText } = await this.fetchTemplate('verify-email'); + + //replace the placeholders with the actual values + const template = Handlebars.compile(templateHtml); + const html = template({ verificationUrl }); + const text = Handlebars.compile(templateText)({ verificationUrl }); + + this.postalService.sendMessage({ + to: [email], + subject: 'Verify your email address', + from: this.configService.getOrThrow('MAIL_FROM'), + plain_body: text, + html_body: html, + }); + } /** * Send a password changed email to the user * @param email The email address of the user */ public async sendPasswordChangedEmail(email: string): Promise {} + + /** + * Private function. Reads the html email template from the file system and returns the content + * @param templateName The name of the template + * @returns The content of the template + */ + private async readHtmlTemplate(templateName: string): Promise { + return await readFile( + path.join(__dirname, '../..', 'mail/html', `${templateName}.hbs`), + ).toString(); + } + + /** + * Private function. Reads the text email template from the file system and returns the content + * @param templateName The name of the template + * @returns The content of the template + */ + private async readTextTemplate(templateName: string): Promise { + return await readFile( + path.join(__dirname, '../..', 'mail/text', `${templateName}.txt`), + ).toString(); + } + + /** + * Private template fetch function + * @param templateName The name of the template + * @returns The content of the template + */ + private async fetchTemplate(templateName: string): Promise<{ html: string; text: string }> { + // await both promises + const [html, text] = await Promise.all([ + this.readHtmlTemplate(templateName), + this.readTextTemplate(templateName), + ]); + + return { html, text }; + } } diff --git a/src/main.ts b/src/main.ts index ef3d71f..4f07171 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { join } from 'path'; @@ -13,7 +14,13 @@ async function bootstrap() { app.use(cookieParser()); // Doing this to make sure it's always the first middleware. Since it does hold auth data. - app.use(new ClsMiddleware().use); + app.use( + new ClsMiddleware({ + setup: (cls, _context) => { + cls.set('authType', 'none'); + }, + }).use, + ); // Rendering app.useStaticAssets(join(__dirname, '..', 'public')); diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 84c0bfd..a5731a3 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -12,6 +12,7 @@ import { USER_NOT_FOUND_ERROR, userCacheKey, } from './user.constant'; +import { ClsService } from 'nestjs-cls'; @Injectable() export class UserService { @@ -19,6 +20,7 @@ export class UserService { @InjectRepository(User) private readonly userRepository: Repository, private readonly redisService: RedisService, + private readonly clsService: ClsService, ) {} /** @@ -30,6 +32,11 @@ export class UserService { */ @Span() async getUserById(id: string, relations: string[] = []): Promise { + if (this.clsService.get('authType') === 'session') { + if (this.clsService.get('user').id === id) { + return this.clsService.get('user'); + } + } const cacheKey = userCacheKey + id; const cachedUser = await this.redisService.get(cacheKey); diff --git a/tsconfig.json b/tsconfig.json index 20482a3..15b72d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,4 +23,4 @@ } ] } -} \ No newline at end of file +} diff --git a/views/home/privacy-policy.hbs b/views/home/privacy-policy.hbs new file mode 100644 index 0000000..e69de29 diff --git a/views/home/terms-and-conditions.hbs b/views/home/terms-and-conditions.hbs new file mode 100644 index 0000000..e69de29