mirror of
https://github.com/vernu/textbee.git
synced 2026-02-20 07:34:00 -05:00
feat: reset password
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"assets": [
|
||||
"mail/templates/**/*"
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
@@ -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
1167
api/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
21
api/src/auth/schemas/password-reset.schema.ts
Normal file
21
api/src/auth/schemas/password-reset.schema.ts
Normal 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)
|
||||
9
api/src/mail/mail.config.ts
Normal file
9
api/src/mail/mail.config.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
26
api/src/mail/mail.module.ts
Normal file
26
api/src/mail/mail.module.ts
Normal 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 {}
|
||||
23
api/src/mail/mail.service.ts
Normal file
23
api/src/mail/mail.service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
14
api/src/mail/templates/password-reset-request.hbs
Normal file
14
api/src/mail/templates/password-reset-request.hbs
Normal 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>
|
||||
8
api/src/mail/templates/password-reset-success.hbs
Normal file
8
api/src/mail/templates/password-reset-success.hbs
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
195
web/pages/reset-password.tsx
Normal file
195
web/pages/reset-password.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user