Skip to content

Commit

Permalink
Implement Password Reset Feature (#100)
Browse files Browse the repository at this point in the history
Co-authored-by: Anjula Shanaka <[email protected]>
  • Loading branch information
mayura-andrew and anjula-sack authored Jun 12, 2024
1 parent 627e646 commit c22e10c
Show file tree
Hide file tree
Showing 10 changed files with 460 additions and 6 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"start": "nodemon --watch 'src/**/*.ts' --exec ts-node-dev --respawn src/server.ts",
"dev": "nodemon --watch 'src/**/*.ts' --exec ts-node-dev --respawn src/server.ts",
"start": "ts-node src/server.ts",
"build": "tsc",
"test": "jest",
"lint": "eslint . --ext .ts",
Expand Down
1 change: 1 addition & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ app.use(
credentials: true
})
)

app.get('/', (req, res) => {
res.send('ScholarX Backend')
})
Expand Down
51 changes: 50 additions & 1 deletion src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { Request, Response, NextFunction } from 'express'
import { registerUser, loginUser } from '../services/auth.service'
import {
registerUser,
loginUser,
resetPassword,
generateResetToken
} from '../services/auth.service'
import passport from 'passport'
import type Profile from '../entities/profile.entity'
import jwt from 'jsonwebtoken'
Expand Down Expand Up @@ -157,3 +162,47 @@ export const requireAuth = (
}
)(req, res, next)
}

export const passwordResetRequest = async (
req: Request,
res: Response
): Promise<ApiResponse<Profile>> => {
const { email } = req.body

if (!email) {
return res.status(400).json({ error: 'Email is a required field' })
}

try {
const { statusCode, message, data: token } = await generateResetToken(email)
return res.status(statusCode).json({ message, token })
} catch (err) {
return res
.status(500)
.json({ error: 'Internal server error', message: (err as Error).message })
}
}

export const passwordReset = async (
req: Request,
res: Response
): Promise<void> => {
try {
const { token, newPassword } = req.body

if (!token || !newPassword) {
res
.status(400)
.json({ error: 'Token and new password are required fields' })
return
}
const { statusCode, message } = await resetPassword(token, newPassword)
res.status(statusCode).json({ message })
} catch (err) {
console.error('Error executing query', err)
res.status(500).json({
error: 'Internal server error',
message: (err as Error).message
})
}
}
6 changes: 5 additions & 1 deletion src/routes/auth/auth.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
register,
login,
logout,
googleRedirect
googleRedirect,
passwordResetRequest,
passwordReset
} from '../../controllers/auth.controller'
import passport from 'passport'

Expand All @@ -21,4 +23,6 @@ authRouter.get(
)

authRouter.get('/google/callback', googleRedirect)
authRouter.post('/password-reset-request', passwordResetRequest)
authRouter.put('/passwordreset', passwordReset)
export default authRouter
34 changes: 34 additions & 0 deletions src/services/admin/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,37 @@ export const sendEmail = async (
throw new Error('Error sending email')
}
}

export const sendResetPasswordEmail = async (
to: string,
subject: string,
message: string
): Promise<{
statusCode: number
message: string
}> => {
const emailRepository = dataSource.getRepository(Email)

try {
const html = await loadTemplate('passwordresetEmailTemplate', {
subject,
message
})

await transporter.sendMail({
from: `"Sustainable Education Foundation" <${SMTP_MAIL}>`,
to,
subject,
html
})

const email = new Email(to, subject, message, EmailStatusTypes.SENT)

await emailRepository.save(email)

return { statusCode: 200, message: 'Email sent and saved successfully' }
} catch (error) {
console.error('Error sending email:', error)
throw new Error('Error sending email')
}
}
32 changes: 31 additions & 1 deletion src/services/auth.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { registerUser, loginUser } from './auth.service'
import {
registerUser,
loginUser,
resetPassword,
generateResetToken
} from './auth.service'
import { dataSource } from '../configs/dbConfig'

jest.mock('bcrypt', () => ({
Expand Down Expand Up @@ -159,3 +164,28 @@ describe('loginUser', () => {
expect(result.user).toBeUndefined()
})
})

describe('Auth Service', () => {
const validEmail = '[email protected]'
const invalidEmail = '[email protected]'
const newPassword = 'newpassword123'

const token = generateResetToken(validEmail)

it('should generate a password reset token', async () => {
expect(token).toBeDefined()
})

it('should not generate a password reset token for invalid email', async () => {
const result = await generateResetToken(invalidEmail)

expect(result.statusCode).toBe(500)
})

it('should return error when parameters are missing', async () => {
const result = await resetPassword('', newPassword)

expect(result.statusCode).toBe(400)
expect(result.message).toBe('Missing parameters')
})
})
94 changes: 94 additions & 0 deletions src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { dataSource } from '../configs/dbConfig'
import bcrypt from 'bcrypt'
import Profile from '../entities/profile.entity'
import type passport from 'passport'
import jwt from 'jsonwebtoken'
import { JWT_SECRET } from '../configs/envConfig'
import {
getPasswordResetEmailContent,
getPasswordChangedEmailContent
} from '../utils'
import { sendResetPasswordEmail } from './admin/email.service'
import { type ApiResponse } from '../types'

export const registerUser = async (
email: string,
Expand Down Expand Up @@ -101,3 +109,89 @@ export const findOrCreateUser = async (

return user
}

export const generateResetToken = async (
email: string
): Promise<ApiResponse<string>> => {
try {
const profileRepository = dataSource.getRepository(Profile)
const profile = await profileRepository.findOne({
where: { primary_email: email },
select: ['password', 'uuid']
})

if (!profile) {
return {
statusCode: 401,
message: 'Invalid email or password'
}
}
const token = jwt.sign({ userId: profile.uuid }, JWT_SECRET, {
expiresIn: '1h'
})
const content = getPasswordResetEmailContent(email, token)
if (content) {
await sendResetPasswordEmail(email, content.subject, content.message)
}
return {
statusCode: 200,
message: 'Password reset link successfully sent to email'
}
} catch (error) {
console.error(
'Error executing Reset Password && Error sending password reset link',
error
)
return { statusCode: 500, message: 'Internal server error' }
}
}

const hashPassword = async (password: string): Promise<string> => {
return await bcrypt.hash(password, 10)
}

const saveProfile = async (
profile: Profile,
hashedPassword: string
): Promise<void> => {
profile.password = hashedPassword
await dataSource.getRepository(Profile).save(profile)
}

export const resetPassword = async (
token: string,
newPassword: string
): Promise<ApiResponse<string>> => {
if (!token || !newPassword) {
return { statusCode: 400, message: 'Missing parameters' }
}

let decoded
try {
decoded = jwt.verify(token, JWT_SECRET) as { userId: string }
} catch (error) {
throw new Error('Invalid token')
}

const profileRepository = dataSource.getRepository(Profile)
const profile = await profileRepository.findOne({
where: { uuid: decoded.userId }
})

if (!profile) {
console.error('Error executing Reset Password: No profile found')
return { statusCode: 409, message: 'No profile found' }
}

const hashedPassword = await hashPassword(newPassword)
await saveProfile(profile, hashedPassword)
const content = getPasswordChangedEmailContent(profile.primary_email)
if (content) {
await sendResetPasswordEmail(
profile.primary_email,
content.subject,
content.message
)
}
return { statusCode: 200, message: 'Password reset successful' }
}
2 changes: 1 addition & 1 deletion src/services/mentor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const createMentor = async (
return {
mentor,
statusCode: 409,
message: 'The mentor application is pending'
message: 'You have already applied'
}
case ApplicationStatus.APPROVED:
return {
Expand Down
Loading

0 comments on commit c22e10c

Please sign in to comment.