diff --git a/README.md b/README.md index 37f586d0..fd7bfcc4 100644 --- a/README.md +++ b/README.md @@ -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*. diff --git a/backend/package.json b/backend/package.json index 2ae5f407..abefaa98 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/users/templates/recover.pug b/backend/src/users/templates/recover.pug index b50234ac..35333963 100644 --- a/backend/src/users/templates/recover.pug +++ b/backend/src/users/templates/recover.pug @@ -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/bootstrap@5.3.0-alpha1/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/bootstrap@5.3.0-alpha1/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/bootstrap@5.3.0-alpha1/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/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js' integrity='sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN' crossorigin='anonymous') + + diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 598fefe0..579625b7 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -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') diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index e20085a0..0e35fe95 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -59,7 +59,6 @@ export class UsersService { async recover({ email, frontendUrl }: RecoverUserDto): Promise { const recoverHash = await cipher(email); const currentUser = await this.find(email); - if (!currentUser) { return; } @@ -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 }; } diff --git a/frontend/src/AppRoutes.jsx b/frontend/src/AppRoutes.jsx index 20eb4e6c..c26c7d37 100644 --- a/frontend/src/AppRoutes.jsx +++ b/frontend/src/AppRoutes.jsx @@ -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')); @@ -88,8 +89,12 @@ function AppRoutes() { } + path={routes.forgotPassPagePath()} + element={} + /> + } /> null }) { +function ForgotPasswordForm() { const { t } = useTranslation(); const emailRef = useRef(); + const location = window.location.origin; const initialFormState = { state: 'initial', message: '' }; const [formState, setFormState] = useState(initialFormState); @@ -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({ @@ -90,16 +99,16 @@ function RemindPasswordForm({ onSuccess = () => null }) { ); } -export default RemindPasswordForm; +export default ForgotPasswordForm; diff --git a/frontend/src/components/Forms/ResetPasswordForm.jsx b/frontend/src/components/Forms/ResetPasswordForm.jsx new file mode 100644 index 00000000..ff3dd52b --- /dev/null +++ b/frontend/src/components/Forms/ResetPasswordForm.jsx @@ -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 ( + <> + setFormState(initialFormState)} + state={formState.state} + > + {t(formState.message)} + +
+ + + {t('profileSettings.newPassword')} + +
+ + + + {t(formik.errors.password)} + +
+
+ + +
+ + ); +} + +export default ResetPasswordForm; diff --git a/frontend/src/components/Forms/SignInForm.jsx b/frontend/src/components/Forms/SignInForm.jsx index 6491f429..d285a9fd 100644 --- a/frontend/src/components/Forms/SignInForm.jsx +++ b/frontend/src/components/Forms/SignInForm.jsx @@ -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'; @@ -146,12 +147,12 @@ function SignInForm({ onSuccess = () => null }) {
{formState.message === 'errors.signInFailed' ? ( - {t('signIn.remindPass')} - + ) : null}