diff --git a/backend/prisma/migrations/20250226230508_newdbstructure/migration.sql b/backend/prisma/migrations/20250226230508_newdbstructure/migration.sql new file mode 100644 index 0000000..1868485 --- /dev/null +++ b/backend/prisma/migrations/20250226230508_newdbstructure/migration.sql @@ -0,0 +1,27 @@ +/* + Warnings: + + - Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- CreateTable +CREATE TABLE "PasswordReset" ( + "id" SERIAL NOT NULL, + "token" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "used" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PasswordReset_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordReset_token_key" ON "PasswordReset"("token"); + +-- AddForeignKey +ALTER TABLE "PasswordReset" ADD CONSTRAINT "PasswordReset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a161ff0..5786de5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -29,6 +29,9 @@ model User { uploadedDocuments Document[] @relation("UploadedDocuments") Notification Notification[] sharedDocuments Document[] @relation("SharedDocuments") + passwordResets PasswordReset[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Notification { @@ -41,3 +44,13 @@ model Notification { document Document @relation(fields: [documentId], references: [id]) user User @relation(fields: [userId], references: [id]) } + +model PasswordReset { + id Int @id @default(autoincrement()) + token String @unique + userId Int + user User @relation(fields: [userId], references: [id]) + expiresAt DateTime + used Boolean @default(false) + createdAt DateTime @default(now()) +} diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index b0ece71..7edcaea 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -1,4 +1,3 @@ - import { Controller, Get, @@ -103,6 +102,16 @@ async uploadDocument( return this.adminService.updateDocumentStatus(+id, status); } + @Post('users/:id/reset-password') + async resetUserPassword( + @Param('id', ParseIntPipe) id: number, + @Body() { password }: { password: string }, + ) { + if (!password || password.length < 6) { + throw new BadRequestException('Password must be at least 6 characters long'); + } + return this.adminService.resetUserPassword(id, password); + } // @Get('test-s3-connection') // async testS3Connection() { diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 8583751..5368081 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { S3Service } from '../s3/s3.service'; import { UpdateDocumentDto } from '../dto/update-document.dto'; @@ -339,4 +339,65 @@ export class AdminService { throw error; } } + + async resetUserPassword(userId: number, newPassword: string) { + this.logger.log('=== Starting password reset process ==='); + this.logger.debug('Reset request for user:', { userId }); + + try { + // Find the user + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + name: true, + }, + }); + + if (!user) { + this.logger.error('User not found:', { userId }); + throw new NotFoundException('User not found'); + } + + // Hash the new password + const hashedPassword = await bcrypt.hash(newPassword, 10); + + // Update the user's password + await this.prisma.user.update({ + where: { id: userId }, + data: { password: hashedPassword }, + }); + + this.logger.log('Password reset successful for user:', { + userId: user.id, + email: user.email, + }); + + // Send email notification + try { + await this.emailService.sendPasswordResetNotification( + user.email, + user.name + ); + this.logger.debug('Password reset notification sent'); + } catch (emailError) { + this.logger.error('Failed to send password reset notification:', { + error: emailError.message, + code: emailError.code, + stack: emailError.stack, + }); + // Don't throw the error, just log it + } + + return { message: 'Password reset successful' }; + } catch (error) { + this.logger.error('Error in resetUserPassword:', { + error: error.message, + code: error.code, + stack: error.stack, + }); + throw error; + } + } } \ No newline at end of file diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index e4b746a..934c6db 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -7,6 +7,7 @@ import { Get, Request, Logger, + BadRequestException, } from '@nestjs/common'; import { AuthService } from './auth.service'; import { LoginDto } from '../dto/login.dto'; @@ -154,4 +155,25 @@ export class AuthController { async getUserInfo(@Request() req) { return this.authService.getUserInfo(req.user.userId); } + + @Post('forgot-password') + async forgotPassword(@Body() { email }: { email: string }) { + if (!email) { + throw new BadRequestException('Email is required'); + } + return this.authService.sendPasswordResetToken(email); + } + + @Post('reset-password') + async resetPassword( + @Body() { token, newPassword }: { token: string; newPassword: string } + ) { + if (!token || !newPassword) { + throw new BadRequestException('Token and new password are required'); + } + if (newPassword.length < 6) { + throw new BadRequestException('Password must be at least 6 characters long'); + } + return this.authService.resetPasswordWithToken(token, newPassword); + } } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 4988c4f..9cfb419 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,10 +1,11 @@ -import { Injectable, ConflictException, Logger } from '@nestjs/common'; +import { Injectable, ConflictException, Logger, UnauthorizedException, NotFoundException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { PrismaService } from '../prisma/prisma.service'; import * as bcrypt from 'bcrypt'; import { CreateUserDto } from '../dto/create-user.dto'; import { ConfigService } from '@nestjs/config'; import { EmailService } from '../email/email.service'; +import { randomBytes } from 'crypto'; @Injectable() export class AuthService { @@ -226,4 +227,135 @@ export class AuthService { } }); } + + async sendPasswordResetToken(email: string) { + this.logger.log('=== Starting password reset token process ==='); + this.logger.debug('Reset token requested for:', email); + + try { + // Find the user + const user = await this.prisma.user.findUnique({ + where: { email }, + select: { + id: true, + email: true, + name: true, + }, + }); + + if (!user) { + this.logger.warn('User not found for password reset:', email); + // Return success anyway to prevent email enumeration + return { message: 'If an account exists, a password reset link has been sent' }; + } + + // Generate reset token + const token = randomBytes(32).toString('hex'); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 1); // Token expires in 1 hour + + // Save reset token + await this.prisma.passwordReset.create({ + data: { + token, + userId: user.id, + expiresAt, + }, + }); + + // Send email with reset link + try { + await this.emailService.sendPasswordResetEmail( + user.email, + user.name, + token + ); + this.logger.debug('Password reset email sent successfully'); + } catch (emailError) { + this.logger.error('Failed to send password reset email:', { + error: emailError.message, + code: emailError.code, + stack: emailError.stack, + }); + throw emailError; + } + + return { message: 'If an account exists, a password reset link has been sent' }; + } catch (error) { + this.logger.error('Error in sendPasswordResetToken:', { + error: error.message, + code: error.code, + stack: error.stack, + }); + throw error; + } + } + + async resetPasswordWithToken(token: string, newPassword: string) { + this.logger.log('=== Starting password reset with token process ==='); + this.logger.debug('Password reset attempted with token'); + + try { + // Find valid reset token + const resetRecord = await this.prisma.passwordReset.findFirst({ + where: { + token, + expiresAt: { + gt: new Date(), // Token must not be expired + }, + used: false, // Token must not be used + }, + include: { + user: true, + }, + }); + + if (!resetRecord) { + this.logger.warn('Invalid or expired reset token used'); + throw new UnauthorizedException('Invalid or expired reset token'); + } + + // Hash new password + const hashedPassword = await bcrypt.hash(newPassword, 10); + + // Update password and mark token as used + await this.prisma.$transaction([ + this.prisma.user.update({ + where: { id: resetRecord.userId }, + data: { password: hashedPassword }, + }), + this.prisma.passwordReset.update({ + where: { id: resetRecord.id }, + data: { used: true }, + }), + ]); + + this.logger.log('Password reset successful for user:', resetRecord.userId); + + // Send confirmation email + try { + await this.emailService.sendPasswordChangeConfirmation( + resetRecord.user.email, + resetRecord.user.name + ); + this.logger.debug('Password change confirmation email sent'); + } catch (emailError) { + this.logger.error('Failed to send password change confirmation:', { + error: emailError.message, + code: emailError.code, + stack: emailError.stack, + }); + // Don't throw error here as password is already changed + } + + return { message: 'Password reset successful' }; + } catch (error) { + this.logger.error('Error in resetPasswordWithToken:', { + error: error.message, + code: error.code, + stack: error.stack, + }); + throw error; + } + } } diff --git a/backend/src/email/email.service.ts b/backend/src/email/email.service.ts index e3addc5..993c3de 100644 --- a/backend/src/email/email.service.ts +++ b/backend/src/email/email.service.ts @@ -186,4 +186,118 @@ export class EmailService { throw error; } } + + async sendPasswordResetNotification(email: string, name: string) { + this.logger.log('Sending password reset notification email to:', email); + + const mailOptions = { + from: this.from, + to: email, + subject: 'Your Password Has Been Reset', + html: ` +
+

Password Reset Notification

+

Hello ${name},

+

Your password has been reset by an administrator.

+

If you did not expect this change, please contact your administrator immediately.

+

Best regards,
IMK Team

+
+ `, + }; + + try { + this.logger.debug('Attempting to send password reset notification...'); + const info = await this.transporter.sendMail(mailOptions); + this.logger.log('Password reset notification sent successfully:', info.response); + return info; + } catch (error) { + this.logger.error('Failed to send password reset notification:', { + error: error.message, + code: error.code, + command: error.command, + stack: error.stack, + }); + throw error; + } + } + + async sendPasswordResetEmail(email: string, name: string, token: string) { + this.logger.log('Sending password reset email to:', email); + + const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${token}`; + const mailOptions = { + from: this.from, + to: email, + subject: 'Reset Your Password - IMK Platform', + html: ` +
+

Password Reset Request

+

Hello ${name},

+

We received a request to reset your password. Click the link below to set a new password:

+

+ Reset Password +

+

This link will expire in 1 hour.

+

If you didn't request this, please ignore this email or contact support if you have concerns.

+

Best regards,
IMK Team

+
+ `, + }; + + try { + this.logger.debug('Attempting to send password reset email...'); + const info = await this.transporter.sendMail(mailOptions); + this.logger.log('Password reset email sent successfully:', info.response); + return info; + } catch (error) { + this.logger.error('Failed to send password reset email:', { + error: error.message, + code: error.code, + command: error.command, + stack: error.stack, + }); + throw error; + } + } + + async sendPasswordChangeConfirmation(email: string, name: string) { + this.logger.log('Sending password change confirmation to:', email); + + const mailOptions = { + from: this.from, + to: email, + subject: 'Password Changed Successfully - IMK Platform', + html: ` +
+

Password Changed Successfully

+

Hello ${name},

+

Your password has been successfully changed.

+

If you did not make this change, please contact our support team immediately.

+

Best regards,
IMK Team

+
+ `, + }; + + try { + this.logger.debug('Attempting to send password change confirmation...'); + const info = await this.transporter.sendMail(mailOptions); + this.logger.log('Password change confirmation sent successfully:', info.response); + return info; + } catch (error) { + this.logger.error('Failed to send password change confirmation:', { + error: error.message, + code: error.code, + command: error.command, + stack: error.stack, + }); + throw error; + } + } } \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6c667b2..01aa116 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,8 @@ import Login from './components/login/Login'; import Navbar from './components/navbar/Navbar'; import ProtectedRoute from './components/protectedRoute/ProtectedRoute'; import { ThemeProvider } from './theme/ThemeProvider'; +import ForgotPassword from './components/auth/ForgotPassword'; +import ResetPassword from './components/auth/ResetPassword'; // import { Navbar } from './components/navbar/Navbar'; function App() { @@ -52,6 +54,8 @@ function App() { } /> + } /> + } />