17 changed files with 1600 additions and 37 deletions
-
8api/.env.example
-
10api/nest-cli.json
-
4api/package.json
-
1175api/pnpm-lock.yaml
-
21api/src/auth/auth.controller.ts
-
16api/src/auth/auth.dto.ts
-
10api/src/auth/auth.module.ts
-
68api/src/auth/auth.service.ts
-
21api/src/auth/schemas/password-reset.schema.ts
-
9api/src/mail/mail.config.ts
-
26api/src/mail/mail.module.ts
-
23api/src/mail/mail.service.ts
-
14api/src/mail/templates/password-reset-request.hbs
-
8api/src/mail/templates/password-reset-success.hbs
-
13web/pages/login.tsx
-
195web/pages/reset-password.tsx
-
16web/services/authService.ts
@ -1,4 +1,10 @@ |
|||||
{ |
{ |
||||
"collection": "@nestjs/schematics", |
"collection": "@nestjs/schematics", |
||||
"sourceRoot": "src" |
|
||||
} |
|
||||
|
"sourceRoot": "src", |
||||
|
"compilerOptions": { |
||||
|
"assets": [ |
||||
|
"mail/templates/**/*" |
||||
|
], |
||||
|
"watchAssets": true |
||||
|
} |
||||
|
} |
||||
1175
api/pnpm-lock.yaml
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
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) |
||||
@ -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, |
||||
|
}, |
||||
|
} |
||||
@ -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 {} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue