feat: reset password

This commit is contained in:
isra el
2024-03-25 14:02:53 +03:00
parent 9d720dba1a
commit 9be88bd2ff
17 changed files with 1596 additions and 33 deletions

View File

@@ -7,4 +7,10 @@ FIREBASE_PRIVATE_KEY_ID=
FIREBASE_PRIVATE_KEY=
FIREBASE_CLIENT_EMAIL=
FIREBASE_CLIENT_ID=
FIREBASE_CLIENT_C509_CERT_URL=
FIREBASE_CLIENT_C509_CERT_URL=
MAIL_HOST=
MAIL_PORT=
MAIL_USER=
MAIL_PASS=
MAIL_FROM=

View File

@@ -1,4 +1,10 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}
"sourceRoot": "src",
"compilerOptions": {
"assets": [
"mail/templates/**/*"
],
"watchAssets": true
}
}

View File

@@ -20,6 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nest-modules/mailer": "^1.3.22",
"@nestjs/common": "^8.4.7",
"@nestjs/core": "^8.4.7",
"@nestjs/jwt": "^8.0.1",
@@ -31,7 +32,9 @@
"bcryptjs": "^2.4.3",
"dotenv": "^16.0.3",
"firebase-admin": "^10.3.0",
"handlebars": "^4.7.8",
"mongoose": "^6.11.1",
"nodemailer": "^6.9.13",
"passport": "^0.5.3",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
@@ -47,6 +50,7 @@
"@types/express": "^4.17.17",
"@types/jest": "27.0.2",
"@types/node": "^16.18.30",
"@types/nodemailer": "^6.4.14",
"@types/passport-jwt": "^3.0.8",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.59.6",

1167
api/pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,12 @@ import {
UseGuards,
} from '@nestjs/common'
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'
import { LoginInputDTO, RegisterInputDTO } from './auth.dto'
import {
LoginInputDTO,
RegisterInputDTO,
RequestResetPasswordInputDTO,
ResetPasswordInputDTO,
} from './auth.dto'
import { AuthGuard } from './guards/auth.guard'
import { AuthService } from './auth.service'
import { CanModifyApiKey } from './guards/can-modify-api-key.guard'
@@ -92,4 +97,18 @@ export class AuthController {
await this.authService.deleteApiKey(params.id)
return { message: 'API Key Deleted' }
}
@ApiOperation({ summary: 'Request Password Reset' })
@HttpCode(HttpStatus.OK)
@Post('/request-password-reset')
async requestPasswordReset(@Body() input: RequestResetPasswordInputDTO) {
return await this.authService.requestResetPassword(input)
}
@ApiOperation({ summary: 'Reset Password' })
@HttpCode(HttpStatus.OK)
@Post('/reset-password')
async resetPassword(@Body() input: ResetPasswordInputDTO) {
return await this.authService.resetPassword(input)
}
}

View File

@@ -21,3 +21,19 @@ export class LoginInputDTO {
@ApiProperty({ type: String, required: true })
password: string
}
export class RequestResetPasswordInputDTO {
@ApiProperty({ type: String, required: true })
email: string
}
export class ResetPasswordInputDTO {
@ApiProperty({ type: String, required: true })
email: string
@ApiProperty({ type: String, required: true })
otp: string
@ApiProperty({ type: String, required: true })
newPassword: string
}

View File

@@ -7,6 +7,11 @@ import { AuthController } from './auth.controller'
import { AuthService } from './auth.service'
import { JwtStrategy } from './jwt.strategy'
import { ApiKey, ApiKeySchema } from './schemas/api-key.schema'
import { MailModule } from 'src/mail/mail.module'
import {
PasswordReset,
PasswordResetSchema,
} from './schemas/password-reset.schema'
@Module({
imports: [
@@ -15,6 +20,10 @@ import { ApiKey, ApiKeySchema } from './schemas/api-key.schema'
name: ApiKey.name,
schema: ApiKeySchema,
},
{
name: PasswordReset.name,
schema: PasswordResetSchema,
},
]),
UsersModule,
PassportModule,
@@ -22,6 +31,7 @@ import { ApiKey, ApiKeySchema } from './schemas/api-key.schema'
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '180d' },
}),
MailModule,
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, MongooseModule],

View File

@@ -8,12 +8,21 @@ import { ApiKey, ApiKeyDocument } from './schemas/api-key.schema'
import { Model } from 'mongoose'
import { User } from '../users/schemas/user.schema'
import axios from 'axios'
import {
PasswordReset,
PasswordResetDocument,
} from './schemas/password-reset.schema'
import { MailService } from 'src/mail/mail.service'
import { RequestResetPasswordInputDTO, ResetPasswordInputDTO } from './auth.dto'
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
@InjectModel(ApiKey.name) private apiKeyModel: Model<ApiKeyDocument>,
@InjectModel(PasswordReset.name)
private passwordResetModel: Model<PasswordResetDocument>,
private readonly mailService: MailService,
) {}
async login(userData: any) {
@@ -88,6 +97,65 @@ export class AuthService {
}
}
async requestResetPassword({ email }: RequestResetPasswordInputDTO) {
const user = await this.usersService.findOne({ email })
if (!user) {
throw new HttpException({ error: 'User not found' }, HttpStatus.NOT_FOUND)
}
const otp = Math.floor(100000 + Math.random() * 900000).toString()
const expiresAt = new Date(Date.now() + 20 * 60 * 1000)
const hashedOtp = await bcrypt.hash(otp, 10)
const passwordReset = new this.passwordResetModel({
user: user._id,
otp: hashedOtp,
expiresAt,
})
passwordReset.save()
await this.mailService.sendEmailFromTemplate({
to: user.email,
subject: 'Password Reset',
template: 'password-reset-request',
context: { name: user.name, otp },
})
return { message: 'Password reset email sent' }
}
async resetPassword({ email, otp, newPassword }: ResetPasswordInputDTO) {
const user = await this.usersService.findOne({ email })
if (!user) {
throw new HttpException({ error: 'User not found' }, HttpStatus.NOT_FOUND)
}
const passwordReset = await this.passwordResetModel.findOne(
{
user: user._id,
expiresAt: { $gt: new Date() },
},
null,
{ sort: { createdAt: -1 } },
)
if (!passwordReset || !(await bcrypt.compare(otp, passwordReset.otp))) {
throw new HttpException({ error: 'Invalid OTP' }, HttpStatus.BAD_REQUEST)
}
const hashedPassword = await bcrypt.hash(newPassword, 10)
user.password = hashedPassword
await user.save()
this.mailService.sendEmailFromTemplate({
to: user.email,
subject: 'Password Reset',
template: 'password-reset-success',
context: { name: user.name },
})
return { message: 'Password reset successfully' }
}
async generateApiKey(currentUser: User) {
const apiKey = uuidv4()
const hashedApiKey = await bcrypt.hash(apiKey, 10)

View File

@@ -0,0 +1,21 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
import { User } from '../../users/schemas/user.schema'
export type PasswordResetDocument = PasswordReset & Document
@Schema({ timestamps: true })
export class PasswordReset {
_id?: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: User.name })
user: User
@Prop({ type: String })
otp: string
@Prop({ type: Date })
expiresAt: Date
}
export const PasswordResetSchema = SchemaFactory.createForClass(PasswordReset)

View File

@@ -0,0 +1,9 @@
export const mailTransportConfig = {
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT ? parseInt(process.env.MAIL_PORT, 10) : 465,
secure: false,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASS,
},
}

View File

@@ -0,0 +1,26 @@
import { HandlebarsAdapter, MailerModule } from '@nest-modules/mailer'
import { Module } from '@nestjs/common'
import { join } from 'path'
import { mailTransportConfig } from './mail.config'
import { MailService } from './mail.service'
@Module({
imports: [
MailerModule.forRoot({
transport: mailTransportConfig,
defaults: {
from: `No Reply ${process.env.MAIL_FROM}`,
},
template: {
dir: join(__dirname, 'templates'),
adapter: new HandlebarsAdapter(),
options: {
strict: true,
},
},
}),
],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}

View File

@@ -0,0 +1,23 @@
import { MailerService } from '@nest-modules/mailer'
import { Injectable } from '@nestjs/common'
@Injectable()
export class MailService {
constructor(private readonly mailerService: MailerService) {}
async sendEmail({ to, subject, html }) {
try {
await this.mailerService.sendMail({ to, subject, html })
} catch (e) {
console.log(e)
}
}
async sendEmailFromTemplate({ to, subject, template, context }) {
try {
await this.mailerService.sendMail({ to, subject, template, context })
} catch (e) {
console.log(e)
}
}
}

View File

@@ -0,0 +1,14 @@
<h1>Hello {{name}},</h1>
<p>
We have received a request to reset your password. If you did not make this
request, please ignore this email. Otherwise, you can reset your password
using the OTP below.
</p>
<hr/>
<strong> {{otp}} </strong>
<hr />
<div>
Thank you,
<br />
<i>TextBee.dev</i>
</div>

View File

@@ -0,0 +1,8 @@
<h1>Hello {{name}}</h1>
<p>
Your password has been successfully reset. You can now login with your new password.
</p>
<div>
Thank you,
<br />
<i>TextBee.dev</i>

View File

@@ -17,7 +17,12 @@ import {
import Link from 'next/link'
import { useState } from 'react'
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons'
import { login, loginWithGoogle, selectAuthLoading, selectAuthUser } from '../store/authSlice'
import {
login,
loginWithGoogle,
selectAuthLoading,
selectAuthUser,
} from '../store/authSlice'
import { useSelector } from 'react-redux'
import { LoginRequestPayload } from '../services/types'
import { GoogleLogin } from '@react-oauth/google'
@@ -99,6 +104,12 @@ export default function LoginPage() {
</InputRightElement>
</InputGroup>
</FormControl>
<Stack pt={2}>
<Text align={'center'}>
Forgot your password?{' '}
<Link href='/reset-password'>Reset Password</Link>
</Text>
</Stack>
<Stack spacing={10} pt={2}>
<Button
loadingText='Submitting'

View File

@@ -0,0 +1,195 @@
import {
Flex,
Box,
FormControl,
FormLabel,
Input,
InputGroup,
InputRightElement,
Stack,
Button,
Heading,
Text,
useColorModeValue,
useToast,
} from '@chakra-ui/react'
import Link from 'next/link'
import { useState } from 'react'
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons'
import { authService } from '../services/authService'
export default function LoginPage() {
const [showPassword, setShowPassword] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(false)
const [otpSent, setOtpSent] = useState<boolean>(false)
const [resetSuccess, setResetSuccess] = useState<boolean>(false)
const [formData, setFormData] = useState({
email: '',
otp: '',
newPassword: '',
})
const toast = useToast()
const handleRequestResetPassword = async (e) => {
setLoading(true)
authService
.requestPasswordReset(formData)
.then((res) => {
setOtpSent(true)
toast({
title: 'OTP sent successfully',
status: 'success',
duration: 5000,
isClosable: true,
})
})
.catch((err) => {
toast({
title: 'Error',
description: err.response.data.message || 'Something went wrong',
status: 'error',
duration: 5000,
isClosable: true,
})
})
.finally(() => {
setLoading(false)
})
}
const handleResetPassword = async (e) => {
setLoading(true)
authService
.resetPassword(formData)
.then((res) => {
toast({
title: 'Password reset successfully',
status: 'success',
})
setResetSuccess(true)
})
.catch((err) => {
toast({
title: 'Error',
description: err.response?.data?.message || 'Something went wrong',
status: 'error',
})
})
.finally(() => {
setLoading(false)
})
}
const onChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
})
}
if (resetSuccess) {
return (
<>
<Flex
minH={'90vh'}
align={'center'}
justify={'center'}
bg={useColorModeValue('gray.50', 'gray.800')}
>
<Stack pt={6}>
<Text align={'center'}>Password reset successfully</Text>
<Link href='/login'>
<Button variant={'ghost'}>Go back to login page</Button>
</Link>
</Stack>
</Flex>
</>
)
}
return (
<Flex
minH={'90vh'}
align={'center'}
justify={'center'}
bg={useColorModeValue('gray.50', 'gray.800')}
>
<Stack spacing={8} mx={'auto'} maxW={'lg'} py={12} px={6}>
<Stack align={'center'}>
<Heading fontSize={'2xl'} textAlign={'center'}>
Reset Password
</Heading>
</Stack>
<Box
rounded={'lg'}
bg={useColorModeValue('white', 'gray.700')}
boxShadow={'lg'}
p={8}
>
<Stack spacing={4}>
<FormControl id='email' isRequired>
<FormLabel>Email address</FormLabel>
<Input type='email' name='email' onChange={onChange} />
</FormControl>
{otpSent && (
<>
<FormControl id='otp' isRequired>
<FormLabel>OTP</FormLabel>
<Input type='number' name='otp' onChange={onChange} />
</FormControl>
<FormControl id='newPassword' isRequired>
<FormLabel>New Password</FormLabel>
<InputGroup>
<Input
type={showPassword ? 'text' : 'password'}
name='newPassword'
onChange={onChange}
/>
<InputRightElement h={'full'}>
<Button
variant={'ghost'}
onClick={() =>
setShowPassword((showPassword) => !showPassword)
}
>
{showPassword ? <ViewIcon /> : <ViewOffIcon />}
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
</>
)}
<Stack spacing={10} pt={2}>
<Button
loadingText='Submitting'
size='lg'
bg={'blue.400'}
color={'white'}
_hover={{
bg: 'blue.500',
}}
onClick={
otpSent ? handleResetPassword : handleRequestResetPassword
}
disabled={loading}
>
{loading ? 'Please Wait...' : 'Continue'}
</Button>
</Stack>
<Stack pt={6}>
<Text align={'center'}>
<Link href='/login'>Go back to login</Link>
</Text>
</Stack>
</Stack>
</Box>
</Stack>
</Flex>
)
}

View File

@@ -29,6 +29,22 @@ class AuthService {
const res = await httpClient.get(`/auth/who-am-i`)
return res.data.data
}
async requestPasswordReset({ email }) {
const res = await httpClient.post(`/auth/request-password-reset`, {
email,
})
return res.data.data
}
async resetPassword({ email, otp, newPassword }) {
const res = await httpClient.post(`/auth/reset-password`, {
email,
otp,
newPassword,
})
return res.data.data
}
}
export const authService = new AuthService()