Skip to content

Commit

Permalink
Add password reset feature
Browse files Browse the repository at this point in the history
  • Loading branch information
blazer82 committed Jun 21, 2024
1 parent 4b30c1d commit 50348c6
Show file tree
Hide file tree
Showing 15 changed files with 505 additions and 2 deletions.
5 changes: 5 additions & 0 deletions components/LoginPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ const LoginPage: React.FunctionComponent = () => {
<Link variant="body2">{"Don't have an account? Sign up!"}</Link>
</NextLink>
</Grid>
<Grid item>
<NextLink href="/reset-password" passHref legacyBehavior>
<Link variant="body2">{'Forgot your password? Click here!'}</Link>
</NextLink>
</Grid>
</Grid>
</Box>
</Box>
Expand Down
1 change: 1 addition & 0 deletions components/LoginRedirect/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const LoginRedirect: React.FunctionComponent = () => {
!user &&
!router.asPath.startsWith('/login') &&
!router.asPath.startsWith('/register') &&
!router.asPath.startsWith('/reset-password') &&
!router.asPath.startsWith('/unsubscribe') &&
!router.asPath.startsWith('/subscribe')
) {
Expand Down
248 changes: 248 additions & 0 deletions components/ResetPasswordPage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Link from '@mui/material/Link';
import Grid from '@mui/material/Grid';
import Box from '@mui/material/Box';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
import Footer from '@/components/Footer';
import {useAppDispatch, useAppSelector} from '@/redux/hooks';
import {Alert} from '@mui/material';
import Head from 'next/head';
import NextLink from 'next/link';
import {resetPasswordRequest} from '@/redux/auth/action/resetPasswordRequest';
import ResetPasswordRequestFormSchema, {ResetPasswordRequestFormData} from '@/schemas/resetPasswordRequestForm';
import ResetPasswordFormSchema, {ResetPasswordFormData} from '@/schemas/resetPasswordForm';
import {resetPassword} from '@/redux/auth/action/resetPassword';

const RequestLink: React.FunctionComponent = () => {
const dispatch = useAppDispatch();
const {resetPasswordRequestError} = useAppSelector(({auth}) => auth);
const [requested, setRequested] = React.useState(false);

const [values, setValues] = React.useState<ResetPasswordRequestFormData>({
email: '',
});

const [errors, setErrors] = React.useState<ResetPasswordRequestFormData>({
email: '',
});

const hasErrors = React.useMemo(
() => Object.keys(errors).reduce((prev, current) => prev || !!errors[current as keyof ResetPasswordRequestFormData], false),
[errors],
);

const validate = React.useCallback(() => {
const {value, error} = ResetPasswordRequestFormSchema.validate(values, {abortEarly: false, errors: {render: false}});

const formErrors: ResetPasswordRequestFormData = {
email: '',
};

if ((error?.details?.length ?? 0) > 0) {
for (const detail of error?.details ?? []) {
const key = `${detail.path}` as keyof ResetPasswordRequestFormData;
formErrors[key] = detail.message;
}
setErrors(formErrors);
return null;
}

setErrors(formErrors);
return value;
}, [values]);

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const value = validate();
if (value) {
dispatch(resetPasswordRequest(value.email));
}
setRequested(true);
};

React.useEffect(() => {
if (resetPasswordRequestError || hasErrors) {
setRequested(false);
}
}, [resetPasswordRequestError, hasErrors]);

return (
<Container component="main" maxWidth="xs">
<Head>
<title>Reset your Analytodon password</title>
</Head>
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Avatar sx={{m: 1, bgcolor: 'primary.main'}}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Reset your Analytodon password
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1}}>
<TextField
error={!!errors.email}
margin="normal"
required
fullWidth
label="Your Email Address"
name="email"
value={values.email}
onChange={(event) => setValues({...values, email: event.target.value})}
autoComplete="email"
autoFocus
/>
{hasErrors && <Alert severity="error">Please enter valid values into all form fields.</Alert>}
{resetPasswordRequestError && <Alert severity="error">{resetPasswordRequestError}</Alert>}
<Button type="submit" disabled={requested} fullWidth variant="contained" sx={{mt: 3, mb: 2}}>
{requested ? 'Check your emails!' : 'Send reset link'}
</Button>
<Grid container>
<Grid item>
<NextLink href="/login" passHref legacyBehavior>
<Link variant="body2">{"Don't need to reset your password? Log in here!"}</Link>
</NextLink>
</Grid>
</Grid>
</Box>
</Box>
<Footer />
</Container>
);
};

const ResetPassword: React.FunctionComponent<{token: string}> = ({token}) => {
const dispatch = useAppDispatch();
const {resetPasswordError} = useAppSelector(({auth}) => auth);
const [requested, setRequested] = React.useState(false);

const [values, setValues] = React.useState<ResetPasswordFormData>({
token,
password: '',
});

const [errors, setErrors] = React.useState<ResetPasswordFormData>({
token: '',
password: '',
});

const hasErrors = React.useMemo(
() => Object.keys(errors).reduce((prev, current) => prev || !!errors[current as keyof ResetPasswordFormData], false),
[errors],
);

const validate = React.useCallback(() => {
const {value, error} = ResetPasswordFormSchema.validate(values, {abortEarly: false, errors: {render: false}});

const formErrors: ResetPasswordFormData = {
token: '',
password: '',
};

if ((error?.details?.length ?? 0) > 0) {
for (const detail of error?.details ?? []) {
const key = `${detail.path}` as keyof ResetPasswordFormData;
formErrors[key] = detail.message;
}
setErrors(formErrors);
return null;
}

setErrors(formErrors);
return value;
}, [values]);

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const value = validate();
if (value) {
dispatch(resetPassword(value.token, value.password));
}
setRequested(true);
};

React.useEffect(() => {
if (resetPasswordError) {
setRequested(false);
}
}, [resetPasswordError]);

return (
<Container component="main" maxWidth="xs">
<Head>
<title>Reset your Analytodon password</title>
</Head>
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Avatar sx={{m: 1, bgcolor: 'primary.main'}}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Reset your Analytodon password
</Typography>
{!requested && (
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1}}>
<TextField
error={!!errors.password}
margin="normal"
required
fullWidth
name="password"
label="Choose a Password"
type="password"
value={values.password}
onChange={(event) => setValues({...values, password: event.target.value})}
autoComplete="current-password"
/>
{hasErrors && <Alert severity="error">Please enter valid values into all form fields.</Alert>}
{resetPasswordError && <Alert severity="error">{resetPasswordError}</Alert>}
<Button type="submit" disabled={requested} fullWidth variant="contained" sx={{mt: 3, mb: 2}}>
Reset your password
</Button>
<Grid container>
<Grid item>
<NextLink href="/login" passHref legacyBehavior>
<Link variant="body2">{"Don't need to reset your password? Log in here!"}</Link>
</NextLink>
</Grid>
</Grid>
</Box>
)}
{requested && (
<Typography component="h1" variant="h6" mt={10}>
<NextLink href="/login" passHref legacyBehavior>
<Link>{'Your password has been reset - log in here!'}</Link>
</NextLink>
</Typography>
)}
</Box>
<Footer />
</Container>
);
};

const ResetPasswordPage: React.FunctionComponent<{token?: string}> = ({token}) => {
if (!token) {
return <RequestLink />;
}
return <ResetPassword token={token} />;
};

export default ResetPasswordPage;
1 change: 1 addition & 0 deletions components/UserSafeguard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const UserSafeguard: React.FunctionComponent<React.PropsWithChildren> = ({childr
() =>
!router.asPath.startsWith('/login') &&
!router.asPath.startsWith('/register') &&
!router.asPath.startsWith('/reset-password') &&
!router.asPath.startsWith('/unsubscribe') &&
!router.asPath.startsWith('/subscribe'),
[router],
Expand Down
35 changes: 35 additions & 0 deletions helpers/sendPasswordResetMail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import nodemailer from 'nodemailer';
import getConfig from 'next/config';
import {User} from '@/types/User';
import {logger} from './logger';

const {serverRuntimeConfig, publicRuntimeConfig} = getConfig();

const sendPasswordResetMail = async (user: User) => {
logger.info(`Send password reset mail to ${user._id}`);

try {
const transporter = nodemailer.createTransport(serverRuntimeConfig.nodemailerTransport);
await transporter.sendMail({
from: {name: publicRuntimeConfig.emailSenderName, address: publicRuntimeConfig.supportEmail},
to: user.email,
subject: 'Reset your Analytodon password!',
text:
`Hi,\n\n` +
`You requested a link to reset your Analytodon password.\n` +
`If this wan't you then you can just ignore this email.\n\n` +
`Click here to reset your password:\n` +
`${publicRuntimeConfig.appURL}/reset-password?t=${user.resetPasswordToken}\n\n` +
`Best regards,\n` +
`Raphael Stäbler\n` +
`Analytodon\n\n` +
`Email: ${publicRuntimeConfig.supportEmail}\n` +
`Website: ${publicRuntimeConfig.marketingURL}\n` +
`Mastodon: https://undefined.social/@analytodon\n`,
});
} catch (error: any) {
logger.error(`Error while sending password reset mail: ${error?.message}`);
}
};

export default sendPasswordResetMail;
3 changes: 3 additions & 0 deletions models/UserModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const UserSchema = new Schema<UserType>(
emailVerificationCode: {
type: String,
},
resetPasswordToken: {
type: String,
},
role: {
type: String,
enum: [UserRole.Admin, UserRole.AccountOwner],
Expand Down
46 changes: 46 additions & 0 deletions pages/api/user/reset-password-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import {setNoCache} from '@/helpers/setNoCache';
import resetPasswordRequestFormSchema from '@/schemas/resetPasswordRequestForm';
import dbConnect from '@/helpers/dbConnect';
import UserModel from '@/models/UserModel';
import {logger} from '@/helpers/logger';
import {v4 as uuid} from 'uuid';
import sendPasswordResetMail from '@/helpers/sendPasswordResetMail';

type ResetPassword = (req: NextApiRequest, res: NextApiResponse) => Promise<void | NextApiResponse>;
const resetPassword: ResetPassword = async ({body, method}, res) => {
setNoCache(res);
if (method !== 'POST') {
return res.status(405).end();
}

const {value, error} = resetPasswordRequestFormSchema.validate(body, {errors: {render: false}});

if ((error?.details?.length ?? 0) > 0) {
return res.status(400).end();
}

await dbConnect();

const user = await UserModel.findOne({email: value.email, isActive: true, emailVerified: true});
if (!user) {
logger.info(`Reset password: User not found ${value.email}`);
return res.end(); // Do not return 400 to avoid user enumeration
}

try {
const token = uuid();

user.resetPasswordToken = token;
await user.save();

await sendPasswordResetMail(user);
} catch (error: any) {
logger.error(`Reset password request error: ${error?.message}`);
return res.status(500).end();
}

res.end();
};

export default resetPassword;
Loading

0 comments on commit 50348c6

Please sign in to comment.