diff --git a/.env.development b/.env.development index fdf469b5..07c382c3 100644 --- a/.env.development +++ b/.env.development @@ -4,4 +4,10 @@ NEXT_PUBLIC_EMAIL_TEMPLATE_ID=template_cz5xqkf NEXT_PUBLIC_EMAIL_PUBLIC_KEY=cmeaOeNpGPYjN8WzV NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=UA-171018032-1 NEXT_PUBLIC_YANDEX_METRIKA_ID=89913543 -NEXT_PUBLIC_ARTICLE_URL=https://raw.githubusercontent.com/TourmalineCore/TourmalineCore.Articles/master/articles/ \ No newline at end of file +NEXT_PUBLIC_ARTICLE_URL=https://raw.githubusercontent.com/TourmalineCore/TourmalineCore.Articles/master/articles/ +NEXT_PUBLIC_TARGET_EMAIL=targetEmail@tourmalinecore.com +MAILRU_EMAIL=test@tourmalinecore.com +MAILRU_PASSWORD=yourpassword +NEXT_PUBLIC_SMARTCAPTCHA_CLIENT_KEY=ysc1_ruZxAsOm5Z3ubTqoerfrehmutrhl0YsP7Tkg3r4mfMPB14652ea0 +SMARTCAPTCHA_SERVER_KEY=ysc2_ruZxAsOm5Z3ubTqoerfrehmutrhl0YsP7Tkg3r4mfMPB14652ea0 + \ No newline at end of file diff --git a/.env.production b/.env.production index fdf469b5..a7d3ec2a 100644 --- a/.env.production +++ b/.env.production @@ -4,4 +4,9 @@ NEXT_PUBLIC_EMAIL_TEMPLATE_ID=template_cz5xqkf NEXT_PUBLIC_EMAIL_PUBLIC_KEY=cmeaOeNpGPYjN8WzV NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=UA-171018032-1 NEXT_PUBLIC_YANDEX_METRIKA_ID=89913543 -NEXT_PUBLIC_ARTICLE_URL=https://raw.githubusercontent.com/TourmalineCore/TourmalineCore.Articles/master/articles/ \ No newline at end of file +NEXT_PUBLIC_ARTICLE_URL=https://raw.githubusercontent.com/TourmalineCore/TourmalineCore.Articles/master/articles/ +NEXT_PUBLIC_TARGET_EMAIL=targetEmail@tourmalinecore.com +MAILRU_EMAIL=test@tourmalinecore.com +MAILRU_PASSWORD=yourpassword +NEXT_PUBLIC_SMARTCAPTCHA_CLIENT_KEY=ysc1_ruZxAsOm5Z3ubTqoerfrehmutrhl0YsP7Tkg3r4mfMPB14652ea0 +SMARTCAPTCHA_SERVER_KEY=ysc2_ruZxAsOm5Z3ubTqoerfrehmutrhl0YsP7Tkg3r4mfMPB14652ea0 \ No newline at end of file diff --git a/architecture decision records/images/captcha-schema.png b/architecture decision records/images/captcha-schema.png new file mode 100644 index 00000000..8a54ac88 Binary files /dev/null and b/architecture decision records/images/captcha-schema.png differ diff --git a/architecture decision records/images/location-of-the-connect-button.png b/architecture decision records/images/location-of-the-connect-button.png new file mode 100644 index 00000000..35b38d9e Binary files /dev/null and b/architecture decision records/images/location-of-the-connect-button.png differ diff --git a/architecture decision records/images/send-email.png b/architecture decision records/images/send-email.png new file mode 100644 index 00000000..6f4f969a Binary files /dev/null and b/architecture decision records/images/send-email.png differ diff --git a/architecture decision records/providing-security-for-the-form.md b/architecture decision records/providing-security-for-the-form.md new file mode 100644 index 00000000..b9385bf6 --- /dev/null +++ b/architecture decision records/providing-security-for-the-form.md @@ -0,0 +1,88 @@ +# Обеспечение безопасной обработки данных в формах при работе с персональными данными в России. + +## Статус +Принято + +## Контекст +Необходимо обеспечить безопасную обработку данных в формах при работе с персональными данными, в соответствии с требованиями законодательства и защитить систему от автоматизированных атак. Форма должна показываться только пользователям из России. + +## Решение +### Обработка персональных данных +1. Необходимо явное соглашение на обработку персональных данных с проставлением галочки напротив текста +"Я даю согласие на обработку своих персональных данных ...". +2. Галочка на согласие не должна проставляться автоматически. +3. Форма не должна отправляться без проставленной галочки на согласие. +4. Присутствует ссылка на документ с политикой конфиденциальности и обработкой персональных данных. + +Плюсы: +- Прозрачность и соответствие юридическим требованиям + +- Четкое доказательство получения согласия + +### Защита от автоматизированных атак (Yandex SmartCaptcha) +Архитектурное решение: + +![alt text](./images/captcha-schema.png) + +1. После успешного заполнения капчи клиент вызывает `/api/validateCaptcha` с параметром `token` который генерируется автоматически после заполнения капчи. +2. Внутри `/api/validateCaptcha` вызывается эндпоинт на стороне Yandex `https://smartcaptcha.yandexcloud.net/validate` с параметрами `token` и `secret` (`secret` можно получить в личном кабинете, его обязательно добавляем в `.env` т.к. это секрет) для валидации отправленного `token`. +3. После валидации вернется ответ, если `token` валидный, то вернется `{ status: 'ok' }`, иначе `{ "status": "failed" }`. + +Особенности работы: +1. Токен одноразовый, поэтому если попробовать отправить несколько форм с одним токеном, мы получим ошибку с сообщением о том, что такой токен уже был использован. + +Yandex SmartCaptcha имеет полноценный гайд по созданию и добавлению капчи на сайта, прочитать можно [тут](https://yandex.cloud/ru/docs/smartcaptcha/quickstart#node_1) + +>В гайде по ссылке есть пример только для ванильного JS, в личном кабинете в сервисе Yandex SmartCaptcha есть пример кода для React. Для этого необходимо нажать на кнопку "Подключить" в верхнем правом углу. + +![alt text](./images/location-of-the-connect-button.png) + +Плюсы: +- Снижение автоматизированных атак ботов + +Минусы: +- Пользователю приходится проделывать больше действий для отправки формы + +## Отправка писем на почту +Используем mail.ru SMTP, более подробно про настройку можно прочитать [тут](https://help.mail.ru/mail/login/mailer/#setup). + +Архитектурное решение: + +![alt text](./images/send-email.png) + +1. После заполнения формы и выполнения капчи клиент вызывает `/api/sendEmail`, который принимает следующие параметры: + +```js + { + to: 'targetEmail', + subject: `yourSubject`, + message: `yourMessage`, + html: 'html', + } +``` + +2. Внутри этого эндпоинта используется библиотека [Nodemailer](https://nodemailer.com/), которая имеет методы для создания `transporter` со следующими параметра: + +```js + { + host: `smtp.mail.ru`, + port: 465, + secure: true, + auth: { + user: yourEmail, + pass: yourServicePassword, + }, + } +``` + +3. Вызывается метод `transporter.sendEmail()`, который отправляет сообщение на указанную почту (параметр `to`). + + +Плюсы: +- Бесплатно +- Прост в настройке + +## Отображение формы только для пользователей из России +Из-за того, что в форме используются российские сервисы для капчи и отправки сообщений, ее можно показывать только пользователям из России. + +Для этого используется стандартный инструмент в JS [Geolocation API](https://developer.mozilla.org/ru/docs/Web/API/Geolocation_API/Using_the_Geolocation_API). С помощью него можно получить координаты пользователя, но для того чтобы получить страну пользователя, эти координаты нужно геокодировать, для этого используется пакет [coordinate_to_country](https://www.npmjs.com/package/coordinate_to_country). \ No newline at end of file diff --git a/common/hooks/index.ts b/common/hooks/index.ts index 72240e45..528de2b1 100644 --- a/common/hooks/index.ts +++ b/common/hooks/index.ts @@ -4,4 +4,5 @@ export * from './useDeviceSize'; export * from './useOnClickOutside'; export * from './usePath'; export * from './useTranslationNamespace'; +export * from './useIsRussianCountry'; export * from './useOnScrollDirections'; diff --git a/common/hooks/useIsRussianCountry.ts b/common/hooks/useIsRussianCountry.ts new file mode 100644 index 00000000..211a9d81 --- /dev/null +++ b/common/hooks/useIsRussianCountry.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; +import { getCountry } from "../utils/getCountry"; + +export function useIsRussianCountry() { + const [country, setCountry] = useState(null); + + useEffect(() => { + const checkCountry = async () => { + setCountry(await getCountry()); + }; + + checkCountry(); + }, []); + + return country === `RUS`; +} diff --git a/common/utils/getCountry.ts b/common/utils/getCountry.ts new file mode 100644 index 00000000..3a7b1e48 --- /dev/null +++ b/common/utils/getCountry.ts @@ -0,0 +1,17 @@ +import lookup from "coordinate_to_country"; + +export async function getCountry(): Promise { + if (navigator.geolocation) { + try { + const position = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject); + }); + + const country = await lookup(position.coords.latitude, position.coords.longitude); + return country[0]; + } catch { + return null; + } + } + return null; +} diff --git a/components/Checkbox/Checkbox.scss b/components/Checkbox/Checkbox.scss new file mode 100644 index 00000000..214b1676 --- /dev/null +++ b/components/Checkbox/Checkbox.scss @@ -0,0 +1,21 @@ +.checkbox { + margin: 0; + border: 2px solid $color-white; + border-radius: 2px; + width: 16px; + height: 16px; + background-color: $color-black; + cursor: pointer; + appearance: none; + + &:checked { + background-image: url("../../public/images/checkbox-mark.svg"); + background-position: center; + background-repeat: no-repeat; + } + + @include desktop-xl { + width: 20px; + height: 20px; + } +} diff --git a/components/Checkbox/Checkbox.tsx b/components/Checkbox/Checkbox.tsx new file mode 100644 index 00000000..686147c4 --- /dev/null +++ b/components/Checkbox/Checkbox.tsx @@ -0,0 +1,23 @@ +import clsx from "clsx"; +import { DetailedHTMLProps, InputHTMLAttributes } from "react"; + +export function CheckBox({ + className, + ...props +}: { + className?: string; +} & DetailedHTMLProps, HTMLInputElement>) { + return ( + { + if (e.key === `Enter`) { + e.preventDefault(); + e.target.checked = !e.target.checked; + } + }} + {...props} + /> + ); +} diff --git a/components/Cta/Cta.tsx b/components/Cta/Cta.tsx index f20b3fc7..d1c719c0 100644 --- a/components/Cta/Cta.tsx +++ b/components/Cta/Cta.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; import { useTranslation } from 'next-i18next'; -import { useBodyScrollHidden, usePath } from '../../common/hooks'; +import { useBodyScrollHidden, useIsRussianCountry, usePath } from '../../common/hooks'; import { TechnologyPageAnchorLink } from '../../common/enums'; import { FormModal } from '../FormModal/FormModal'; +import { PrimaryButton } from '../PrimaryButton/PrimaryButton'; export function Cta() { const { @@ -16,6 +17,8 @@ export function Cta() { useBodyScrollHidden(isOpen); + const isCountryRus = useIsRussianCountry(); + return (

{t(`title`)}

- - {t(`buttonText`)} - - {/* Todo: uncomment after editing the form */} - {/* setIsOpen(true)} - className={`cta__button cta__button--${slicePathname}`} - > - {t(`buttonText`)} - */} + {isCountryRus ? ( + setIsOpen(true)} + className={`cta__button cta__button--${slicePathname}`} + > + {t(`buttonText`)} + + ) : ( + + {t(`buttonText`)} + + )}
diff --git a/components/ExternalLink/ExternalLink.tsx b/components/ExternalLink/ExternalLink.tsx deleted file mode 100644 index 849fc665..00000000 --- a/components/ExternalLink/ExternalLink.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react'; -import clsx from 'clsx'; - -type ExternalLinkProps = DetailedHTMLProps, HTMLAnchorElement>; - -export function ExternalLink({ - children, - className, - ...props -}: ExternalLinkProps) { - return ( - - {children} - - ); -} diff --git a/components/Form/Form.scss b/components/Form/Form.scss index e8bb6e1b..ebaa36b6 100644 --- a/components/Form/Form.scss +++ b/components/Form/Form.scss @@ -11,7 +11,8 @@ } &__footer { - margin-top: 40px; + position: relative; + margin-top: 32px; @include tablet { display: flex; @@ -19,12 +20,42 @@ } @include desktop { - margin-top: 56px; + margin-top: 24px; } } + &__consent { + @include body-s; + + display: flex; + margin-top: 32px; + color: $color-white-alpha-100; + gap: 8px; + + @include desktop { + margin-top: 24px; + } + + @include desktop-xl { + gap: 16px; + } + } + + &__consent-checkbox { + flex-shrink: 0; + margin-top: 2px; + } + + &__consent-link { + position: relative; + margin-left: 4px; + color: inherit; + text-underline-offset: 2px; + } + &__button { margin-bottom: 24px; + cursor: pointer; @include tablet { flex: none; @@ -38,14 +69,10 @@ } } - &__approval { - font-size: 12px; - line-height: 1.3; - color: $color-white-alpha-200; - - @include desktop-xl { - font-size: 14px; - } + &__captcha { + position: absolute; + left: 0; + top: 0; } &--zh { diff --git a/components/Form/Form.tsx b/components/Form/Form.tsx index c7a912f3..6be811db 100644 --- a/components/Form/Form.tsx +++ b/components/Form/Form.tsx @@ -5,18 +5,19 @@ import { FormEvent, useMemo, useRef, + KeyboardEvent, useState, } from 'react'; -import ReCAPTCHA from 'react-google-recaptcha'; -import { ExternalLink } from '../ExternalLink/ExternalLink'; +import { SmartCaptcha } from '@yandex/smart-captcha'; import { Input } from './components/Input/Input'; import { PrimaryButton } from '../PrimaryButton/PrimaryButton'; import { Textarea } from './components/Textarea/Textarea'; import { Spinner } from '../Spinner/Spinner'; -import { DEFAULT_LOCALE } from '../../common/constants'; import { isChineseLanguage } from '../../common/utils'; -import { ReCAPTCHALanguage } from '../../common/enums/captcha'; +import { validateCaptchaToken } from '../../services/smartCaptchaService/validateCaptchaToken'; +import { DEFAULT_LOCALE } from '../../common/constants'; +import { CheckBox } from '../Checkbox/Checkbox'; export function Form({ onSubmit = () => {}, @@ -30,9 +31,12 @@ export function Form({ } = useTranslation(`form`); const router = useRouter(); + const submitButtonRef = useRef(null); + const [isLoading, setIsLoading] = useState(false); - const recaptchaRef = useRef(null); + const [showCaptcha, setShowCaptcha] = useState(false); + const [isCaptchaVerified, setIsCaptchaVerified] = useState(false); const routerLocale = useMemo(() => { if (!router.locale) { @@ -43,110 +47,142 @@ export function Form({ }, [router.locale]); return ( - <> -
- { - if (e.key === `Enter`) { - e.preventDefault(); - } - }} - required - /> - { - if (e.key === `Enter`) { - e.preventDefault(); - } - }} + + + +