Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "typeorm-ts-node-commonjs",
"typeorm:run-migrations": "npm run typeorm migration:run -- -d ./src/config/typeorm.config.ts",
"typeorm:generate-migration": "npm run typeorm -- migration:generate ./src/migrations/$npm_config_name -d ./src/config/typeorm.config.ts",
"typeorm:create-migration": "npm run typeorm -- migration:create ./src/migrations/$npm_config_name",
"typeorm:revert-migration": "npm run typeorm -- migration:revert -d ./src/config/typeorm.config.ts"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
Expand All @@ -33,6 +38,7 @@
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"cloudinary": "^2.7.0",
"cookie-parser": "^1.4.7",
"nodemailer": "^7.0.6",
"passport": "^0.7.0",
Expand All @@ -51,6 +57,7 @@
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/multer": "^2.0.0",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
Expand Down
32 changes: 32 additions & 0 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { AuthResponse } from './interfaces/authResponse.interface';
import { LoginUserDto } from 'src/users/dto/loginUser.dto';
import { GetCurrentUser } from './decorators/getCurrentUser.decorator';
import { LocalAuthGuard } from './guards/local.guard';
import { VerifyEmailDto } from './dto/verifyEmail.dto';
import { ResendVerifyEmailDto } from './dto/resendVerifyEmail.dto';

@ApiTags('Auth')
@Controller('auth')
Expand Down Expand Up @@ -61,4 +63,34 @@ export class AuthController {
): Promise<AuthResponse> {
return await this.authService.loginUser(user, response);
}

// VERIFY EMAIL
@Public()
@Post('verify-email')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Verify user email with token' })
@ApiOkResponse({ description: 'Email verified successfully.' })
@ApiBadRequestResponse({ description: 'Invalid or expired token.' })
@ApiBody({ type: VerifyEmailDto })
async verifyEmail(
@Body() verifyEmailDto: VerifyEmailDto,
): Promise<{ message: string }> {
return await this.authService.verifyEmail(verifyEmailDto.token);
}

// RESEND VERIFICATION EMAIL
@Public()
@Post('resend-verify-email')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Resend email verification link' })
@ApiOkResponse({ description: 'Verification email sent successfully.' })
@ApiBadRequestResponse({ description: 'User not found or already verified.' })
@ApiBody({ type: ResendVerifyEmailDto })
async resendVerifyEmail(
@Body() resendVerifyEmailDto: ResendVerifyEmailDto,
): Promise<{ message: string }> {
return await this.authService.resendVerificationEmail(
resendVerifyEmailDto.email,
);
}
}
9 changes: 8 additions & 1 deletion backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RefreshToken } from './entities/refreshToken.entity';
import { User } from '../users/entities/user.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './providers/auth.service';
import { UsersModule } from '../users/users.module';
Expand All @@ -16,11 +17,15 @@ import { RefreshTokenRepositoryOperations } from './providers/RefreshTokenCrud.r
import { FindOneRefreshTokenProvider } from './providers/findOneRefreshToken.provider';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { VerifyEmailProvider } from './providers/verifyEmail.provider';
import { ResendVerificationEmailProvider } from './providers/resendVerificationEmail.provider';
import { EmailModule } from '../email/email.module';

@Module({
imports: [
TypeOrmModule.forFeature([RefreshToken]),
TypeOrmModule.forFeature([RefreshToken, User]),
forwardRef(() => UsersModule),
EmailModule,
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
Expand Down Expand Up @@ -48,6 +53,8 @@ import { JwtModule } from '@nestjs/jwt';
RefreshTokensProvider,
RefreshTokenRepositoryOperations,
FindOneRefreshTokenProvider,
VerifyEmailProvider,
ResendVerificationEmailProvider,
],
exports: [
AuthService,
Expand Down
14 changes: 14 additions & 0 deletions backend/src/auth/dto/resendVerifyEmail.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IsEmail, IsNotEmpty, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class ResendVerifyEmailDto {
@ApiProperty({
description: 'User email address',
example: 'john.doe@example.com',
maxLength: 50
})
@IsNotEmpty()
@IsEmail()
@MaxLength(50)
email: string;
}
16 changes: 16 additions & 0 deletions backend/src/auth/dto/verifyEmail.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class VerifyEmailDto {
@ApiProperty({
description: 'Email verification token',
example: 'abc123def456...',
minLength: 10,
maxLength: 255
})
@IsNotEmpty()
@IsString()
@MinLength(10)
@MaxLength(255)
token: string;
}
17 changes: 16 additions & 1 deletion backend/src/auth/providers/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import { User } from '../../users/entities/user.entity';
import { LoginUserProvider } from './loginUser.provider';
import { AuthResponse } from '../interfaces/authResponse.interface';
import { Response } from 'express';
import { VerifyEmailProvider } from './verifyEmail.provider';
import { ResendVerificationEmailProvider } from './resendVerificationEmail.provider';

@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,

private readonly loginUserProvider: LoginUserProvider,
private readonly verifyEmailProvider: VerifyEmailProvider,
private readonly resendVerificationEmailProvider: ResendVerificationEmailProvider,
) {}

// CREATE USER
Expand All @@ -37,4 +40,16 @@ export class AuthService {
): Promise<AuthResponse> {
return await this.loginUserProvider.loginUser(user, response);
}

public async verifyEmail(token: string): Promise<{ message: string }> {
return await this.verifyEmailProvider.verifyEmail(token);
}

public async resendVerificationEmail(
email: string,
): Promise<{ message: string }> {
return await this.resendVerificationEmailProvider.resendVerificationEmail(
email,
);
}
}
87 changes: 87 additions & 0 deletions backend/src/auth/providers/resendVerificationEmail.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// ManageHub/backend/src/auth/providers/resendVerificationEmail.provider.ts
import {
Injectable,
BadRequestException,
NotFoundException,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { EmailService } from '../../email/providers/email.service';
import { ErrorCatch } from '../../utils/error';
import * as crypto from 'crypto';

@Injectable()
export class ResendVerificationEmailProvider {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
private readonly emailService: EmailService,
) {}

public async resendVerificationEmail(
email: string,
): Promise<{ message: string }> {
try {
// Find user by email
const user = await this.usersRepository.findOne({
where: { email },
});

if (!user) {
throw new NotFoundException('User not found');
}

// Check if user is already verified
if (user.isVerified) {
throw new BadRequestException('Email is already verified');
}

// Check rate limiting (5 minutes cooldown)
if (user.lastVerificationEmailSent) {
const timeSinceLastEmail =
new Date().getTime() - user.lastVerificationEmailSent.getTime();
const cooldownPeriod = 5 * 60 * 1000; // 5 minutes in milliseconds

if (timeSinceLastEmail < cooldownPeriod) {
const remainingTime = Math.ceil(
(cooldownPeriod - timeSinceLastEmail) / 1000 / 60,
);
throw new HttpException(
`Please wait ${remainingTime} minute(s) before requesting another verification email`,
HttpStatus.TOO_MANY_REQUESTS,
);
}
}

// Generate new verification token
const verificationToken = crypto.randomBytes(32).toString('hex');
const verificationTokenExpiry = new Date();
verificationTokenExpiry.setHours(verificationTokenExpiry.getHours() + 24); // 24 hours expiry

// Update user with new token and timestamp
await this.usersRepository.update(user.id, {
verificationToken,
verificationTokenExpiry,
lastVerificationEmailSent: new Date(),
});

// Send verification email
const emailSent = await this.emailService.sendVerificationEmail(
user.email,
verificationToken,
`${user.firstname} ${user.lastname}`,
);

if (!emailSent) {
throw new BadRequestException('Failed to send verification email');
}

return { message: 'Verification email sent successfully' };
} catch (error) {
ErrorCatch(error, 'Failed to resend verification email');
}
}
}
43 changes: 43 additions & 0 deletions backend/src/auth/providers/verifyEmail.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { ErrorCatch } from '../../utils/error';

@Injectable()
export class VerifyEmailProvider {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}

public async verifyEmail(token: string): Promise<{ message: string }> {
try {
const user = await this.usersRepository.findOne({
where: { verificationToken: token },
});

if (!user) {
throw new BadRequestException('Invalid verification token');
}

if (user.verificationTokenExpiry && new Date() > user.verificationTokenExpiry) {
throw new BadRequestException('Verification token has expired');
}

if (user.isVerified) {
throw new BadRequestException('Email is already verified');
}

await this.usersRepository.update(user.id, {
isVerified: true,
verificationToken: null,
verificationTokenExpiry: null,
});

return { message: 'Email verified successfully' };
} catch (error) {
ErrorCatch(error, 'Failed to verify email');
}
}
}
25 changes: 25 additions & 0 deletions backend/src/config/typeorm.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { config } from 'dotenv';
import { User } from '../users/entities/user.entity';
import { RefreshToken } from '../auth/entities/refreshToken.entity';

config();

const configService = new ConfigService();

export default new DataSource({
type: 'postgres',
host: configService.get('DATABASE_HOST'),
port: +configService.get('DATABASE_PORT'),
username: configService.get('DATABASE_USERNAME'),
password: configService.get('DATABASE_PASSWORD'),
database: configService.get('DATABASE_NAME'),
ssl:
configService.get('NODE_ENV') === 'production'
? { rejectUnauthorized: false }
: false,
entities: [User, RefreshToken],
migrations: ['src/migrations/*.ts'],
synchronize: false, // Always false for migrations
});
56 changes: 56 additions & 0 deletions backend/src/email/providers/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,60 @@ export class EmailService {
return false;
}
}
async sendVerificationEmail(userEmail: string, verificationToken: string, userName: string): Promise<boolean> {
const template = this.getVerificationEmailTemplate(verificationToken, userName);

return await this.sendEmail({
to: userEmail,
subject: template.subject,
text: template.text,
html: template.html,
});
}

private getVerificationEmailTemplate(verificationToken: string, userName: string): EmailTemplate {
const platformName = 'ManageHub';
const verificationUrl = `${this.configService.get('FRONTEND_URL')}/auth/verify-email?token=${verificationToken}`;

return {
subject: `Verify your email - ${platformName}`,
text: `Dear ${userName},\n\nPlease verify your email address by clicking the following link:\n\n${verificationUrl}\n\nThis link will expire in 24 hours.\n\nIf you didn't create an account with ${platformName}, please ignore this email.\n\nBest regards,\nThe ${platformName} Team`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 10px; text-align: center;">
<h1 style="color: #333; margin-bottom: 20px;">Verify Your Email</h1>
<div style="background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<h2 style="color: #007bff; margin-bottom: 20px;">📧 Email Verification Required</h2>
<p style="font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 20px;">
Dear <strong>${userName}</strong>,
</p>
<p style="font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 20px;">
Thank you for registering with <strong>${platformName}</strong>! To complete your registration, please verify your email address by clicking the button below.
</p>
<div style="margin: 30px 0;">
<a href="${verificationUrl}" style="background-color: #007bff; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; font-weight: bold; display: inline-block;">
Verify Email Address
</a>
</div>
<p style="font-size: 14px; line-height: 1.6; color: #666; margin-bottom: 20px;">
Or copy and paste this link into your browser:<br>
<a href="${verificationUrl}" style="color: #007bff; word-break: break-all;">${verificationUrl}</a>
</p>
<div style="background-color: #fff3cd; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p style="margin: 0; font-size: 14px; color: #856404;">
<strong>⏰ Important:</strong> This verification link will expire in 24 hours.
</p>
</div>
<p style="font-size: 14px; color: #6c757d; margin-top: 30px;">
If you didn't create an account with ${platformName}, please ignore this email.<br><br>
Best regards,<br>
The ${platformName} Team
</p>
</div>
</div>
</div>
`,
};
}
}

Loading