Skip to content

Commit

Permalink
Merge pull request #367 from SkyAjax/main
Browse files Browse the repository at this point in the history
[#355] Add reset password feature
  • Loading branch information
dzencot authored Sep 18, 2023
2 parents e17e19d + a8e94b7 commit f396268
Show file tree
Hide file tree
Showing 15 changed files with 279 additions and 66 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ To deploy to [render.com](https://dashboard.render.com/) do the following:
1. Create a Postgres database. After preparing it, copy *Internal Database URL*.
2. Create Web Service, select your fork.
3. Name — it is better to use a prefix with your nickname. For example *fey-runit*.
4. Region — any, you can use *Frankfurt (EU Central)*.
4. Region — any, you can use *Frankfurt (EU Central)* but make sure that Web Service and database are using the same region.
5. Branch — from which the application will be deployed. You can use `main` for starters. In the future, use the branch in which you want to demonstrate the changes.
6. Root Directory — leave blank.
7. Runtime — *Node*.
Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"express-ws": "^5.0.2",
"generate-password": "^1.7.0",
"jest-mock": "^29.4.3",
"nodemailer": "^6.9.1",
"nodemailer": "^6.9.4",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
Expand Down
47 changes: 31 additions & 16 deletions backend/src/users/templates/recover.pug
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
doctype html
head
meta(charset='utf-8')
meta(name='viewport' content='width=device-width, initial-scale=1')
title Recover message
link(href='https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css' rel='stylesheet' integrity='sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD' crossorigin='anonymous')
.container
.d-flex.flex-wrap.align-items-center.justify-content-center.justify-content-lg-start
p
| Вы запросили ссылку для изменения вашего пароля на RunIT. Чтобы изменить пароль -
a(href=url) перейдите по этой ссылке
| или нажмите на кнопку ниже
p
| Если вы не запрашивали изменения пароля, просто проигнорируйте это письмо. Ваш пароль не изменится пока вы не перейдете по ссылке и не введете новый пароль
html
head
meta(charset='utf-8')
meta(name='viewport' content='width=device-width, initial-scale=1')
title Recover message
link(rel="preconnect" href="https://fonts.googleapis.com")
link(rel="preconnect" href="https://fonts.gstatic.com" crossorigin)
link(href='https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css' rel='stylesheet' integrity='sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD' crossorigin='anonymous')
link(href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap" rel="stylesheet")
style.
body {
font-family: 'Roboto', sans-serif !important;
}
body
script(src='https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js' integrity='sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN' crossorigin='anonymous')
.container
.d-flex.flex-wrap.align-items-center.justify-content-center
h1.text-center.font-weight-bold.pb-3
| Изменение пароля
p
| Вы запросили ссылку для изменения вашего пароля на RunIT. Чтобы изменить пароль -
a(href=url) перейдите по этой ссылке
| или нажмите на кнопку ниже.
p
| Если вы не запрашивали изменения пароля, просто проигнорируйте это
| письмо. Ваш пароль не изменится пока вы не перейдете по ссылке и не
| введете новый пароль.
p.text-center
a.btn.btn-primary(type="button" href=url)="Сменить пароль"

a.btn.btn-primary(type="button" href=url)="Сменить пароль"

script(src='https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js' integrity='sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN' crossorigin='anonymous')


13 changes: 8 additions & 5 deletions backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,15 @@ export class UsersController {
return this.usersService.recover(recoverUserDto);
}

@Get('recover/:hash')
@Post('recover/:hash')
@UseFilters(new HttpValidationFilter())
@ApiParam({ name: 'hash', description: 'Hash key for user recovery!' })
@ApiOkResponse({ description: 'Successfully checked recovery hash key' })
async checkHash(@Param('hash') hash: string) {
return this.usersService.checkHash(hash);
@ApiParam({ name: 'hash', description: 'Hash key for user password reset!' })
@ApiOkResponse({ description: 'Successfully updated user password' })
async resetPassword(
@Body() updateUserDto: UpdateUserDto,
@Param('hash') hash: string,
) {
return this.usersService.resetPassword(updateUserDto, hash);
}

@Put(':id')
Expand Down
31 changes: 19 additions & 12 deletions backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ export class UsersService {
async recover({ email, frontendUrl }: RecoverUserDto): Promise<void> {
const recoverHash = await cipher(email);
const currentUser = await this.find(email);

if (!currentUser) {
return;
}
Expand All @@ -75,24 +74,32 @@ export class UsersService {
// FIXME: use env var BASE_URL
const url = `${frontendUrl}/recovery/${recoverHash}`;

this.mailerService.sendMail({
to: email,
// FIXME: use i18n
subject: 'Ссылка для изменения пароля на runit.hexlet.ru',
template: 'recover',
context: {
url,
},
});
try {
this.mailerService.sendMail({
to: email,
// FIXME: use i18n
subject: 'Ссылка для изменения пароля на runit.hexlet.ru',
template: 'recover',
context: {
url,
},
});
} catch (e) {
throw new Error(e);
}
}

async checkHash(hash: string): Promise<{ id: number | null }> {
async resetPassword(
{ password }: UpdateUserDto,
hash,
): Promise<{ id: number | null }> {
const email = await decipher(Buffer.from(hash, 'hex'));
const currentUser = await this.find(email);

if (currentUser && currentUser.recover_hash === hash) {
await this.usersRepository.update(currentUser.id, { recover_hash: null });
return { id: currentUser.id };
await this.update(currentUser.id, { password });
return currentUser;
}
return { id: null };
}
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/AppRoutes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ const SignUpPage = lazy(() => import('./pages/signup'));
const SignInPage = lazy(() => import('./pages/signin'));
const Landing = lazy(() => import('./pages/landing'));
const LicenseAgreement = lazy(() => import('./pages/license-agreement'));
const RemindPasswordPage = lazy(() => import('./pages/remind-password'));
const ForgotPasswordPage = lazy(() => import('./pages/forgot-password'));
const ResetPasswordPage = lazy(() => import('./pages/reset-password'));
const NotFoundPage = lazy(() => import('./pages/404'));
const EmbeddedPage = lazy(() => import('./pages/embed'));

Expand Down Expand Up @@ -88,8 +89,12 @@ function AppRoutes() {
</Route>

<Route
path={routes.remindPassPagePath()}
element={<RemindPasswordPage />}
path={routes.forgotPassPagePath()}
element={<ForgotPasswordPage />}
/>
<Route
path={routes.resetPassPagePath()}
element={<ResetPasswordPage />}
/>
<Route
path={routes.licenseAgreementPath()}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import axios from 'axios';
import { useFormik } from 'formik';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -6,13 +7,15 @@ import { object } from 'yup';
import Button from 'react-bootstrap/Button';
import Form from 'react-bootstrap/Form';

import routes from '../../routes';
import { email } from '../../utils/validationSchemas';

import FormAlert from './FormAlert.jsx';

function RemindPasswordForm({ onSuccess = () => null }) {
function ForgotPasswordForm() {
const { t } = useTranslation();
const emailRef = useRef();
const location = window.location.origin;

const initialFormState = { state: 'initial', message: '' };
const [formState, setFormState] = useState(initialFormState);
Expand All @@ -29,10 +32,16 @@ function RemindPasswordForm({ onSuccess = () => null }) {
validateOnBlur: false,
onSubmit: async (values) => {
setFormState(initialFormState);
const preparedValues = validationSchema.cast(values);
const preparedValues = {
...validationSchema.cast(values),
frontendUrl: location,
};
try {
console.log(preparedValues);
onSuccess();
await axios.post(routes.resetPassPath(), preparedValues);
setFormState({
state: 'success',
message: 'forgotPass.successAlert',
});
} catch (err) {
if (!err.isAxiosError) {
setFormState({
Expand Down Expand Up @@ -90,16 +99,16 @@ function RemindPasswordForm({ onSuccess = () => null }) {
</Form.Group>

<Button
data-disable-with={t('remindPass.resetButton')}
data-disable-with={t('forgotPass.resetButton')}
disabled={formik.isSubmitting}
type="submit"
variant="primary"
>
{t('remindPass.resetButton')}
{t('forgotPass.resetButton')}
</Button>
</Form>
</>
);
}

export default RemindPasswordForm;
export default ForgotPasswordForm;
131 changes: 131 additions & 0 deletions frontend/src/components/Forms/ResetPasswordForm.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import axios from 'axios';

import { useFormik } from 'formik';
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { object } from 'yup';

import Button from 'react-bootstrap/Button';
import Form from 'react-bootstrap/Form';

import { useAuth } from '../../hooks';
import routes from '../../routes';
import { password } from '../../utils/validationSchemas';

import FormAlert from './FormAlert.jsx';
import PasswordVisibilityButton from './PasswordVisibilityButton';

function ResetPasswordForm({ onSuccess = () => null }) {
const { t } = useTranslation();
const { hash } = useParams();
const passwordRef = useRef();
const auth = useAuth();

const initialFormState = { state: 'initial', message: '' };
const [formState, setFormState] = useState(initialFormState);
const [isPasswordVisible, setPasswordVisibility] = useState(false);
const handlePasswordVisibility = () => {
setPasswordVisibility(!isPasswordVisible);
};

const validationSchema = object().shape({
password: password(),
});

const formik = useFormik({
initialValues: {
password: '',
},
validationSchema,
validateOnBlur: false,
onSubmit: async (values) => {
setFormState(initialFormState);
const preparedValues = { ...validationSchema.cast(values), hash };
try {
const { data } = await axios.post(
`${routes.resetPassPath()}/${hash}`,
preparedValues,
);
await axios.post(routes.signInPath(), {
email: data.email,
password: values.password,
});
auth.signIn();
onSuccess();
} catch (err) {
if (!err.isAxiosError) {
setFormState({
state: 'failed',
message: 'errors.unknown',
});
throw err;
} else {
setFormState({
state: 'failed',
message: 'errors.network',
});
throw err;
}
}
},
});

useEffect(() => {
passwordRef.current.focus();
}, []);

return (
<>
<FormAlert
onClose={() => setFormState(initialFormState)}
state={formState.state}
>
{t(formState.message)}
</FormAlert>
<Form
className="d-flex flex-column gap-4"
noValidate
onSubmit={formik.handleSubmit}
>
<Form.Group controlId="password">
<Form.Label className="visually-hidden">
{t('profileSettings.newPassword')}
</Form.Label>
<div className="input-group-inline-button input-group-inline-button">
<Form.Control
ref={passwordRef}
autoComplete="password"
isInvalid={!!formik.touched.password && !!formik.errors.password}
name="password"
onBlur={formik.handleBlur}
onChange={formik.handleChange}
placeholder={t('profileSettings.newPassword')}
required
type={isPasswordVisible ? 'text' : 'password'}
value={formik.values.password}
/>
<PasswordVisibilityButton
enabled={isPasswordVisible}
onClick={handlePasswordVisibility}
/>
<Form.Control.Feedback type="invalid">
{t(formik.errors.password)}
</Form.Control.Feedback>
</div>
</Form.Group>

<Button
data-disable-with={t('profileSettings.changePassword')}
disabled={formik.isSubmitting}
type="submit"
variant="primary"
>
{t('profileSettings.changePassword')}
</Button>
</Form>
</>
);
}

export default ResetPasswordForm;
7 changes: 4 additions & 3 deletions frontend/src/components/Forms/SignInForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { object } from 'yup';

import { Link } from 'react-router-dom';
import Button from 'react-bootstrap/Button';
import Form from 'react-bootstrap/Form';

Expand Down Expand Up @@ -146,12 +147,12 @@ function SignInForm({ onSuccess = () => null }) {
</div>
<div className="d-flex flex-row gap-5">
{formState.message === 'errors.signInFailed' ? (
<a
<Link
to={routes.forgotPassPagePath()}
className="icon-link link-secondary d-block align-self-center"
href={routes.remindPassPagePath()}
>
{t('signIn.remindPass')}
</a>
</Link>
) : null}
<Button
className="flex-fill"
Expand Down
Loading

0 comments on commit f396268

Please sign in to comment.